/** * Tiny hand-rolled syntax highlighter for the artifact source view. * * Zero dependencies by design: the IIFE bundle is built with `--splitting * false`, so any highlight.js / prism / shiki style library would be inlined * and inflate the critical bundle. This module is a small regex tokenizer * (html / css / js|ts / json) plus a DOM builder that emits one * `span.persona-code-line` per source line for the CSS-counter gutter. * * Safety by construction: the DOM is built ONLY with `createElement` + * `textContent` + `createTextNode` — never `innerHTML` — so the output is safe * regardless of the widget's `sanitize` config, even when the source itself is * hostile markup (e.g. ``). * * Verbatim-reconstruction invariant: the built fragment's `textContent` equals * the original `source` exactly. Every source newline becomes a literal `"\n"` * text node placed *between* line spans; token spans never contain a newline * (multi-line tokens such as block comments and template literals are split at * newline boundaries into per-line spans of the same class). A trailing source * newline is preserved as a `"\n"` text node without a phantom numbered empty * final line. */ import { createElement, createFragment } from "./dom"; /** Token classification. `plain`/`punctuation` render as bare text (no span). */ export type CodeTokenType = | "keyword" | "string" | "comment" | "number" | "tag" | "attr" | "property" | "punctuation" | "plain"; export interface CodeToken { type: CodeTokenType; value: string; } /** Canonical tokenizer keys. `ts`/`jsx`/`tsx` share the `js` tokenizer. */ export type HighlightLanguage = "html" | "css" | "js" | "json"; /** Token type → CSS class. Types absent here render as a bare text node. */ const TOKEN_CLASS: Partial> = { keyword: "persona-code-token-keyword", string: "persona-code-token-string", comment: "persona-code-token-comment", number: "persona-code-token-number", tag: "persona-code-token-tag", attr: "persona-code-token-attr", property: "persona-code-token-property", }; /** * Streaming perf guard: skip tokenization above this many characters and fall * back to plain line-numbered text. `renderPre` runs on every streaming delta, * so keep the tokenizer off huge sources. */ export const MAX_HIGHLIGHT_LENGTH = 150_000; /** Extension / short-name → canonical tokenizer key. */ const LANGUAGE_ALIASES: Record = { html: "html", htm: "html", xhtml: "html", xml: "html", svg: "html", css: "css", js: "js", jsx: "js", mjs: "js", cjs: "js", ts: "js", tsx: "js", mts: "js", cts: "js", javascript: "js", typescript: "js", ecmascript: "js", json: "json", jsonc: "json", json5: "json", }; /** * Resolve a canonical tokenizer from an explicit language or a file path * extension. Returns `null` for anything unmapped (plain-text fallback). */ export function resolveHighlightLanguage( language?: string, path?: string ): HighlightLanguage | null { const lang = (language || "").trim().toLowerCase(); if (lang && LANGUAGE_ALIASES[lang]) return LANGUAGE_ALIASES[lang]; if (path) { const dot = path.lastIndexOf("."); if (dot >= 0) { const ext = path.slice(dot + 1).toLowerCase(); if (LANGUAGE_ALIASES[ext]) return LANGUAGE_ALIASES[ext]; } } return null; } interface Rule { type: CodeTokenType; /** Sticky (`y`) regex so a match only counts when it starts at `lastIndex`. */ re: RegExp; /** Optional reclassifier (e.g. identifier → keyword when in a keyword set). */ map?: (text: string) => CodeTokenType; } /** * Left-to-right rule scanner. At each index the first rule that matches at that * exact position wins; unmatched characters accrete into a single `plain` run. */ function runRules(source: string, rules: Rule[]): CodeToken[] { const tokens: CodeToken[] = []; let i = 0; let plainStart = 0; const flushPlain = (end: number) => { if (end > plainStart) { tokens.push({ type: "plain", value: source.slice(plainStart, end) }); } }; while (i < source.length) { let matched = false; for (const rule of rules) { rule.re.lastIndex = i; const m = rule.re.exec(source); if (m && m.index === i && m[0].length > 0) { flushPlain(i); const type = rule.map ? rule.map(m[0]) : rule.type; tokens.push({ type, value: m[0] }); i += m[0].length; plainStart = i; matched = true; break; } } if (!matched) i += 1; } flushPlain(source.length); return tokens; } const JS_KEYWORDS = new Set([ "abstract", "any", "as", "async", "await", "boolean", "break", "case", "catch", "class", "const", "continue", "debugger", "declare", "default", "delete", "do", "else", "enum", "export", "extends", "false", "finally", "for", "from", "function", "get", "if", "implements", "import", "in", "instanceof", "interface", "keyof", "let", "namespace", "never", "new", "null", "number", "object", "of", "private", "protected", "public", "readonly", "return", "satisfies", "set", "static", "string", "super", "switch", "symbol", "this", "throw", "true", "try", "type", "typeof", "undefined", "unknown", "var", "void", "while", "yield", ]); const JS_RULES: Rule[] = [ { type: "comment", re: /\/\/[^\n]*/y }, { type: "comment", re: /\/\*[\s\S]*?\*\//y }, { type: "string", re: /`(?:\\[\s\S]|[^\\`])*`/y }, { type: "string", re: /"(?:\\.|[^"\\\n])*"/y }, { type: "string", re: /'(?:\\.|[^'\\\n])*'/y }, { type: "number", re: /0[xX][0-9a-fA-F]+|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/y, }, { type: "plain", re: /[A-Za-z_$][\w$]*/y, map: (t) => (JS_KEYWORDS.has(t) ? "keyword" : "plain"), }, ]; function tokenizeJs(source: string): CodeToken[] { return runRules(source, JS_RULES); } const JSON_RULES: Rule[] = [ { type: "string", re: /"(?:\\.|[^"\\])*"/y }, { type: "number", re: /-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/y }, { type: "plain", re: /[A-Za-z_]\w*/y, map: (t) => t === "true" || t === "false" || t === "null" ? "keyword" : "plain", }, ]; function tokenizeJson(source: string): CodeToken[] { const tokens = runRules(source, JSON_RULES); // Reclassify object keys: a string whose next significant token opens with // `:` is a property, not a value string. for (let idx = 0; idx < tokens.length; idx += 1) { if (tokens[idx].type !== "string") continue; let j = idx + 1; while (j < tokens.length && tokens[j].value.trim() === "") j += 1; const next = tokens[j]; if (next && next.value.replace(/^\s*/, "").startsWith(":")) { tokens[idx].type = "property"; } } return tokens; } const CSS_RULES: Rule[] = [ { type: "comment", re: /\/\*[\s\S]*?\*\//y }, { type: "string", re: /"(?:\\.|[^"\\\n])*"/y }, { type: "string", re: /'(?:\\.|[^'\\\n])*'/y }, { type: "keyword", re: /@[A-Za-z-]+/y }, { type: "number", re: /#[0-9a-fA-F]{3,8}\b/y }, { type: "number", re: /-?(?:\d+\.?\d*|\.\d+)(?:[a-z%]+)?/y }, { type: "plain", re: /[A-Za-z_-][\w-]*/y }, ]; function tokenizeCss(source: string): CodeToken[] { const tokens = runRules(source, CSS_RULES); // Property detection: an identifier immediately followed by `:` counts as a // declaration property only when the previous significant character opens a // declaration (`{`, `}`, `;`, `,`, or start of input). This keeps selectors // and pseudo-classes (`a:hover`) plain in the common cases. let lastSignificant = ""; for (let idx = 0; idx < tokens.length; idx += 1) { const tok = tokens[idx]; if (tok.type === "plain" && /^[A-Za-z_-][\w-]*$/.test(tok.value)) { let j = idx + 1; while (j < tokens.length && tokens[j].value.trim() === "") j += 1; const next = tokens[j]; if ( next && next.value.replace(/^\s*/, "").startsWith(":") && (lastSignificant === "" || lastSignificant === "{" || lastSignificant === "}" || lastSignificant === ";" || lastSignificant === ",") ) { tok.type = "property"; } } const trimmed = tok.value.replace(/\s+$/, ""); if (trimmed) lastSignificant = trimmed[trimmed.length - 1]; } return tokens; } const HTML_TAG_RULES: Rule[] = [ { type: "tag", re: /<\/?\s*[A-Za-z][\w:-]*/y }, { type: "string", re: /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/y }, { type: "attr", re: /[A-Za-z_:@][\w:.-]*(?=\s*=)/y }, { type: "attr", re: /[A-Za-z_:@][\w:.-]*/y }, { type: "tag", re: /\/?>/y }, ]; function tokenizeHtml(source: string): CodeToken[] { const tokens: CodeToken[] = []; const push = (type: CodeTokenType, value: string) => { if (value) tokens.push({ type, value }); }; const n = source.length; let i = 0; while (i < n) { // Comments. if (source.startsWith("", i + 4); const stop = end === -1 ? n : end + 3; push("comment", source.slice(i, stop)); i = stop; continue; } // Doctype (and other `` declarations). if (source[i] === "<" && source[i + 1] === "!") { const end = source.indexOf(">", i); const stop = end === -1 ? n : end + 1; push("tag", source.slice(i, stop)); i = stop; continue; } // Element tag. if (source[i] === "<" && /[A-Za-z/]/.test(source[i + 1] || "")) { const tagEnd = source.indexOf(">", i); const stop = tagEnd === -1 ? n : tagEnd + 1; const tagText = source.slice(i, stop); for (const t of runRules(tagText, HTML_TAG_RULES)) tokens.push(t); i = stop; // Embedded