Files
Exile-UI/src/lib/markup.ts

130 lines
4.2 KiB
TypeScript

import type { Area } from "./api";
// Named colors used by the guide markup; anything else that is 3/6 hex chars
// is treated as a hex color.
const COLOR_NAMES: Record<string, string> = {
red: "#e05555",
lime: "#7CFC00",
aqua: "#55e0e0",
yellow: "#e0c84a",
green: "#5fbf5f",
white: "#ffffff",
orange: "#e08a3c",
};
function resolveColor(name: string): string {
if (COLOR_NAMES[name]) return COLOR_NAMES[name];
if (/^[0-9a-fA-F]{6}$/.test(name) || /^[0-9a-fA-F]{3}$/.test(name)) return "#" + name;
return "inherit";
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// Icon names available under static/icons/leveling/ (from the original AHK
// "leveling tracker" assets). Only these render as real <img>; anything else
// falls back to a text chip so unknown markup never shows a broken image.
const ICONS = new Set([
"0", "1", "2", "3", "4", "5", "6", "7",
"arena", "artificer", "b-rune", "checkpoint", "craft", "exa", "flasks",
"gcp", "hideout", "in-out", "in-out2", "jeweller", "lab", "portal",
"quest", "quest_2", "regal", "ring", "rune", "skill", "skill2", "skip",
"spirit", "spirit2", "support", "support2", "town", "waypoint",
]);
// Underscores stand in for spaces in plain guide text.
function plain(text: string, color: string): string {
const t = escapeHtml(text.replace(/_/g, " "));
if (!t) return "";
if (color === "inherit") return t;
return `<span style="color:${color}">${t}</span>`;
}
export interface RenderedLine {
html: string;
kind: "normal" | "hint" | "optional" | "info";
indent: number;
}
const TOKEN_RE = /\(([a-z]+)(?::([^)]*))?\)|areaid([a-z0-9_]+)/gi;
/** Render a single guide line (with embedded markup) into HTML + metadata. */
export function renderLine(raw: string, areas: Record<string, Area>): RenderedLine {
let line = raw;
// Classify the line for styling.
let kind: RenderedLine["kind"] = "normal";
let indent = 0;
const lower = line.toLowerCase();
if (lower.startsWith("(hint)")) {
kind = "hint";
line = line.slice("(hint)".length);
// leading underscores after (hint) indicate indentation
const m = line.match(/^_+/);
if (m) {
indent = 1;
line = line.slice(m[0].length);
}
} else if (lower.startsWith("optional:")) {
kind = "optional";
} else if (lower.includes("info:")) {
kind = "info";
}
// Pull out the trailing " ;; area name" annotation.
let areaName: string | null = null;
const sc = line.indexOf(";;");
if (sc >= 0) {
areaName = line.slice(sc + 2).trim();
line = line.slice(0, sc).trim();
}
let out = "";
let color = "inherit";
let last = 0;
let m: RegExpExecArray | null;
TOKEN_RE.lastIndex = 0;
while ((m = TOKEN_RE.exec(line)) !== null) {
out += plain(line.slice(last, m.index), color);
last = m.index + m[0].length;
const kindTok = m[1]?.toLowerCase();
const val = m[2] ?? "";
const areaId = m[3];
if (areaId !== undefined) {
const name = areaName || areas[areaId]?.name || areaId.replace(/_/g, " ");
out += `<span class="area">${escapeHtml(name)}</span>`;
areaName = null; // consume once
} else if (kindTok === "color") {
color = resolveColor(val.trim());
} else if (kindTok === "img") {
const name = val.trim();
const label = name.replace(/_/g, " ");
if (ICONS.has(name.toLowerCase())) {
out += `<img class="icon" src="/icons/leveling/${encodeURIComponent(
name.toLowerCase()
)}.png" alt="${escapeHtml(label)}" title="${escapeHtml(label)}" />`;
} else {
out += `<span class="chip img" data-img="${escapeHtml(name)}">${escapeHtml(
label
)}</span>`;
}
} else if (kindTok === "quest") {
out += `<span class="chip quest">${escapeHtml(val.replace(/_/g, " "))}</span>`;
} else if (kindTok === "emph") {
// ignored: treated as a plain emphasis marker
}
// (hint) handled at line level above
}
out += plain(line.slice(last), color);
// Any leftover area name annotation with no areaid token: append it.
if (areaName) {
out += ` <span class="area">${escapeHtml(areaName)}</span>`;
}
return { html: out, kind, indent };
}