Files
turnstone/turnstone/shared_static/renderer.js
T
Patrick Buckley 729a02a833 feat(ui): copy-to-clipboard for messages and rendered blocks
Three idle-only affordances on every chat surface: a persistent copy
button in each assistant bubble's actions bar, a pointer-only floating
button over the hovered markdown block (fence, mermaid diagram, table),
and Enter on a focused block for keyboard users, with the outcome
flashed on the block itself.

Copy resolves to SOURCE, not rendered text.  The renderer stashes each
table's raw markdown in data-md-source at render time — span sentinels
restored in reverse mask order, footnote-definition bodies restored to
raw before their recursive render — and whole-message copy reads the
streaming pipeline's per-frame stash.  The clipboard transport falls
back to the legacy execCommand path for plain-HTTP LAN nodes, cloning
and restoring the user's selection and focus.

Outcomes surface button-local only: flash + title + one live-region
announcement through the shared makeAnnouncer factory (also adopted by
the interactive voice/tool announcers, whose lazily created regions
swallowed their first announcement).  Busy refusals answer with their
own message.  Coordinator retry and admin token-copy keep
zero-module-dependency degrade paths.
2026-08-02 06:15:40 -07:00

1583 lines
63 KiB
JavaScript

// renderer.js — Markdown + LaTeX rendering (no external deps except KaTeX)
//
// ES module (imports utils; vendor hljs/katex/mermaid stay lazy typeof-guarded
// globals). Window bridge at the bottom for the still-classic consumers.
import { escapeHtml } from "./utils.js";
// Side-effect import: the copy-to-clipboard affordances (floating block
// button + delegated listeners) ride the renderer so they land on every
// surface that renders markdown — no per-page wiring to drift.
import "./copy_actions.js";
// ---------------------------------------------------------------------------
// Inline formatting
// ---------------------------------------------------------------------------
// Safe inline HTML tags allowed through escapeHtml (no attributes — XSS safe)
// Block-level tags (details, summary, hr) handled by their own protection passes
var _SAFE_TAGS =
/<(\/?(?:br|kbd|mark|sub|sup|ins|wbr|abbr|small|u|s))(?:\s*\/?)>/gi;
export function inlineMarkdown(text) {
// Escape HTML first so only tags we generate are real
text = escapeHtml(text);
// Restore safe HTML tags (attribute-free only — already escaped so no XSS)
text = text.replace(_SAFE_TAGS, function (m, tag) {
return "<" + tag + ">";
});
// Bold (asterisks only — underscores cause false positives on snake_case)
text = text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
// Italic (asterisks only)
text = text.replace(
/(?<!\*)\*([^\s*](?:.*?[^\s*])?)\*(?!\*)/g,
"<em>$1</em>",
);
// Strikethrough
text = text.replace(/~~(.+?)~~/g, "<del>$1</del>");
// Images (must come before links — render as click-to-load placeholder).
// Captured groups inherit inlineMarkdown's leading escapeHtml(text) —
// they are already entity-encoded by the time this regex runs. Don't
// re-escape them: double-encoding turns `&` into `&amp;amp;`, which
// breaks query-string URLs after browser parse + getAttribute. The
// `safe*` rename signals the pre-escape invariant; the attribute-
// context lint in tests/test_renderer_js.py enforces that future
// attribute-context concat sites maintain the convention or call
// escapeHtml explicitly.
text = text.replace(
/!\[([^\]]*)\]\(([^)]+)\)/g,
function (m, safeAlt, safeUrl) {
if (!/^\s*(https?:\/\/|data:image\/)/i.test(safeUrl)) return m;
if (!safeAlt) safeAlt = "Image";
var safeDomain;
try {
// hostname is freshly extracted, not from the regex capture,
// so it does NOT carry the upstream escape — escape locally.
safeDomain = escapeHtml(new URL(safeUrl).hostname);
} catch (e) {
safeDomain = safeUrl.length > 40 ? safeUrl.slice(0, 40) + "…" : safeUrl;
}
return (
'<span class="img-placeholder" tabindex="0" role="button" ' +
'aria-label="Load image: ' +
safeAlt +
'" ' +
'data-src="' +
safeUrl +
'" data-alt="' +
safeAlt +
'">' +
'<span class="img-placeholder-icon">&#x1F5BC;</span> ' +
'<span class="img-placeholder-label">' +
safeAlt +
"</span>" +
'<span class="img-placeholder-domain">' +
safeDomain +
"</span>" +
"</span>"
);
},
);
// Links — same upstream-escape invariant as images. `safeLabel` and
// `safeUrl` are entity-encoded via inlineMarkdown's leading pass.
text = text.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
function (m, safeLabel, safeUrl) {
if (!/^\s*(https?:\/\/|\/(?!\/))/i.test(safeUrl)) return m;
return (
'<a href="' +
safeUrl +
'" target="_blank" rel="noopener noreferrer">' +
safeLabel +
"</a>"
);
},
);
// Footnote references [^id] — after links (link regex requires (url), so no conflict)
text = text.replace(/\[\^([^\]]+)\]/g, function (m, fnId) {
var safeFnId = escapeHtml(fnId);
return (
'<sup class="fn-ref" id="fn-' +
_fnScopeId +
"-ref-" +
safeFnId +
'">' +
'<a href="#fn-' +
_fnScopeId +
"-def-" +
safeFnId +
'" role="doc-noteref">[' +
safeFnId +
"]</a></sup>"
);
});
return text;
}
// Attach click-to-load listener for image placeholders (delegated)
document.addEventListener("click", function (e) {
var ph = e.target.closest(".img-placeholder");
if (!ph) return;
var raw = ph.getAttribute("data-src") || "";
if (!/^(https?:\/\/|data:image\/)/i.test(raw)) return;
var src;
try {
src = new URL(raw).href;
} catch (_e) {
return;
}
var img = document.createElement("img");
img.src = src;
img.alt = ph.getAttribute("data-alt");
img.loading = "lazy";
ph.replaceWith(img);
});
document.addEventListener("keydown", function (e) {
if (e.key !== "Enter") return;
var ph = e.target.closest(".img-placeholder");
if (!ph) return;
ph.click();
});
// Smooth-scroll footnote navigation (native fragment jumps don't work in scrollable containers)
document.addEventListener("click", function (e) {
var a = e.target.closest(".fn-ref a, .fn-backref");
if (!a) return;
var href = a.getAttribute("href");
if (!href || href[0] !== "#") return;
var target = document.getElementById(href.slice(1));
if (!target) return;
e.preventDefault();
target.scrollIntoView({ behavior: "smooth", block: "center" });
});
// ---------------------------------------------------------------------------
// List rendering (nested + task lists)
// ---------------------------------------------------------------------------
function renderListBlock(items) {
if (items.length === 0) return "";
var minIndent = items[0].indent;
for (var i = 1; i < items.length; i++) {
if (items[i].indent < minIndent) minIndent = items[i].indent;
}
// Split into separate lists when marker type changes at top indent level
var segments = [];
var cur = [items[0]];
for (var i = 1; i < items.length; i++) {
if (items[i].indent <= minIndent && items[i].ordered !== cur[0].ordered) {
segments.push(cur);
cur = [items[i]];
} else {
cur.push(items[i]);
}
}
segments.push(cur);
if (segments.length > 1) {
return segments.map(renderListBlock).join("\n");
}
var type = items[0].ordered ? "ol" : "ul";
var html = "<" + type + ">";
var i = 0;
while (i < items.length) {
var item = items[i];
if (item.indent <= minIndent) {
var content = item.content;
// Task list checkboxes
var taskMatch = content.match(/^\[([ xX])\]\s*(.*)/);
if (taskMatch) {
var checked = taskMatch[1] !== " ";
content =
'<input type="checkbox" disabled' +
(checked ? " checked" : "") +
' aria-label="' +
escapeHtml(taskMatch[2]) +
'"> ' +
inlineMarkdown(taskMatch[2]);
} else {
content = inlineMarkdown(content);
}
// Collect children (deeper indent items following this one)
var children = [];
var j = i + 1;
while (j < items.length && items[j].indent > minIndent) {
children.push(items[j]);
j++;
}
if (children.length > 0) {
html += "<li>" + content + renderListBlock(children) + "</li>";
} else {
html += "<li>" + content + "</li>";
}
i = j;
} else {
i++;
}
}
html += "</" + type + ">";
return html;
}
// ---------------------------------------------------------------------------
// LaTeX rendering via KaTeX
// ---------------------------------------------------------------------------
function renderLatex(tex, displayMode) {
if (typeof katex === "undefined") return escapeHtml(tex);
try {
return katex.renderToString(tex, {
displayMode: displayMode,
throwOnError: false,
errorColor: "#f87171",
output: "html",
});
} catch (e) {
return '<code class="katex-error">' + escapeHtml(tex) + "</code>";
}
}
// ---------------------------------------------------------------------------
// Footnote scope — prevents ID collisions across multiple renderMarkdown calls
// ---------------------------------------------------------------------------
var _fnScopeId = 0;
var _fnDepth = 0;
// ---------------------------------------------------------------------------
// GFM callout types (alerts)
// ---------------------------------------------------------------------------
var CALLOUT_TYPES = {
NOTE: { icon: "\u2139", label: "Note" },
TIP: { icon: "\u{1F4A1}", label: "Tip" },
IMPORTANT: { icon: "\u2757", label: "Important" },
WARNING: { icon: "\u26A0", label: "Warning" },
CAUTION: { icon: "\u{1F6D1}", label: "Caution" },
};
// ---------------------------------------------------------------------------
// Code fence language → CSS class normalization
// ---------------------------------------------------------------------------
var _LANG_ALIASES = { "c++": "cpp", "c#": "csharp", "f#": "fsharp" };
function _langToCssClass(lang) {
if (!lang) return "";
var lower = lang.toLowerCase();
if (_LANG_ALIASES[lower]) return _LANG_ALIASES[lower];
// Strip chars invalid in CSS class names (keep alphanumeric + hyphen)
return lower.replace(/[^a-z0-9-]/g, "");
}
// ---------------------------------------------------------------------------
// Main markdown renderer
// ---------------------------------------------------------------------------
// Hard cap on renderMarkdown re-entrancy. Blockquote/callout/list bodies
// recurse through renderMarkdown; a pathological input (a few KB of nested
// "> " prefixes) would otherwise overflow the call stack mid-render — an
// exception the streaming callers can only partially recover from. Beyond
// the cap the nested body renders as escaped plain text: degraded, visible.
var _MD_MAX_DEPTH = 100;
// U+0000 (NUL) only. Structural sentinels are NUL-framed (chr(0)+tag+idx+
// chr(0)) and renderer.js is the sole NUL producer, so stripping NUL at the
// top-level entry closes every forgery path — while leaving every OTHER
// control byte intact, because a code fence must show pasted source verbatim
// (terminal output legitimately carries ESC/FF/VT/DEL, none of which can forge
// a sentinel). Authored as the literal \x00 escape (only \uXXXX decodes to a
// raw byte in this toolchain), matching the file's \x00 sentinel convention.
var _NUL_STRIP_RE = /\x00/g;
export function renderMarkdown(text) {
if (_fnDepth >= _MD_MAX_DEPTH) {
return "<p>" + escapeHtml(String(text == null ? "" : text)) + "</p>";
}
// Scope footnote IDs per top-level render call (prevents collisions across
// messages). Depth accounting rides a try/finally: a throw anywhere in the
// body used to strand _fnDepth elevated, freezing _fnScopeId so footnote
// anchor ids collided across every later message.
//
// Strip caller-supplied NUL, but at the TOP-LEVEL call ONLY. The renderer
// frames structural blocks with in-band NUL sentinels (NUL+tag+index+NUL)
// and escapeHtml preserves U+0000, so model/tool text carrying that shape
// could forge a sentinel and duplicate/relocate a block or print "undefined"
// (B1/B2/B3). renderer.js is the sole NUL producer and every restore regex
// is NUL-framed, so removing NUL here erases every forgery path — and ONLY
// NUL, so a fenced code block still shows pasted control bytes (ESC/FF/VT/
// DEL) verbatim. Depth 0 only: recursive frames (blockquote/details/
// footnote bodies) legitimately carry generated sentinels and an
// unconditional strip would shred them. NUL is never valid content.
if (_fnDepth === 0) {
text = String(text == null ? "" : text).replace(_NUL_STRIP_RE, "");
_fnScopeId++;
}
_fnDepth++;
try {
return _renderMarkdownBody(text);
} finally {
_fnDepth--;
}
}
// Restore-pass callback factory: one guarded closure per protected-block
// array. Returns the matched sentinel `m` when the index is out of range (an
// inert placeholder the tokenizer strips the NUL from) instead of the array's
// `undefined`. Factored so the ~12 restore passes share one implementation
// and can't drift (e.g. a callback reading the wrong array after a copy-paste).
function _restorer(arr) {
return function (m, idx) {
var v = arr[parseInt(idx)];
return v === undefined ? m : v;
};
}
function _renderMarkdownBody(text) {
// Protect code blocks before the line-based passes (blockquote,
// table) that would otherwise scoop a `> ` / `|` line out of a fenced body
// and render it as real markdown nested in <pre><code> (B4: shell
// transcripts, quoted email, markdown-about-markdown). The open matches at
// line start after optional indent (group 1) AND an optional list marker
// (group 2: `- `, `1. `), so a fence opening on a list-item's marker line
// (`- ```py`) still tokenises; both the indent and the marker are re-emitted
// before the sentinel so the fence keeps its document position (see the
// callback). A blockquoted fence (`> ```) is NOT matched — `>` is not indent
// or a list marker — so the blockquote pass below extracts that `> ` run and
// its recursive render handles it.
//
// The opening-run length is captured and required on the close via
// backreference so a 4-backtick outer fence wrapping a 3-backtick inner
// (common when embedding markdown-about-markdown or lang-tagged snippets
// inside another code block) is tokenised as one outer block with the inner
// triple-backticks preserved verbatim — the prior `` ```...``` `` regex
// treated the outer-open and inner-open as a single fence pair, stranding
// the rest of the content with visible \x00CB{n}\x00 sentinels.
//
// Two constraints below close the gap that mid-stream buffers expose:
//
// 1. Content can't contain its own close pattern — `(?!\3)` (group 3 is
// the backtick run; groups 1-2 are the indent and optional list marker)
// inside the content quantifier blocks the lazy matcher from extending
// across another N-backtick run. Without this, a buffer like
// ```mermaid\n<partial>\n```python\n<partial>\n``` would extend
// mermaid's content all the way to the FINAL ```, swallowing python
// and handing mermaid a wrong (and incomplete-looking) source. With
// the lookahead, content stops at the first matching run and the open
// simply doesn't match anything until a true close arrives. Inner
// backticks of a SMALLER count (e.g. 3-backtick inner inside a
// 4-backtick outer) still pass since `\3` is the OPEN count.
//
// 2. The close must live at a line boundary — `[ \t]*(?=\n|$)` after
// `\3` forbids the close from being immediately followed by a language
// tag, so ```python opening another fence can't masquerade as the
// previous fence's close.
//
// Together these mean an unclosed fence stays as plain markdown until its
// true close arrives — no intermediate parse errors flash through mermaid /
// hljs while a stream is in flight.
// codeBlockRaw keeps each fence's RAW source next to its rendered HTML, so
// the <details> pre-pass below can restore a fenced body to raw markdown for
// its recursive render (NEW-1) rather than an unresolvable outer sentinel.
// Populated only when the text contains a <details> tag — its sole reader —
// so the common no-<details> render skips the per-fence slice. (When there
// is no <details>, the details .replace matches nothing and its CB->raw
// restore never runs, so the empty array is never read.)
var codeBlocks = [];
var codeBlockRaw = [];
var needFenceRaw = /<details>/i.test(text);
text = text.replace(
/^([ \t]*)((?:[-*+]|\d+[.)])[ \t]+)?(```+)([^\s`]*)\n((?:(?!\3)[\s\S])*?)\3[ \t]*(?=\n|$)/gm,
function (m, indent, marker, _open, lang, code) {
var cssLang = _langToCssClass(lang);
// tabindex=0: a fence is a horizontal scroll region (keyboard users
// must be able to reach and scroll it), and a focused block is what
// the keyboard copy path acts on (copy_actions.js, Enter).
codeBlocks.push(
'<pre tabindex="0"><code' +
(cssLang ? ' class="language-' + escapeHtml(cssLang) + '"' : "") +
">" +
// Strip the trailing newline plus any whitespace an indented close
// dragged into the content (a ` ```` close would otherwise leave a
// whitespace-only last line in the code block).
escapeHtml(code.replace(/\n[ \t]*$/, "")) +
"</code></pre>",
);
// Store the fence source WITHOUT the re-emitted indent/marker prefix so
// the <details> CB->raw restore substitutes the fence alone (no double);
// only when a <details> is present to read it (kept index-aligned with
// codeBlocks because every fence pushes to both in that case).
if (needFenceRaw) {
codeBlockRaw.push(
m.slice(indent.length + (marker ? marker.length : 0)),
);
}
// Re-emit the leading indent AND any list marker so the fence keeps its
// place in the document: a nested list item (` - ```py`) stays nested,
// `- ```py` stays a list item, and a fence continuing a footnote def
// keeps the 2-space indent the continuation scan needs. The CB <p>-unwrap
// (restore pass) tolerates that preserved indent, so an own-line indented
// fence still leaves no stray <p> (NEW-3). A blockquoted fence (`> ```)
// stays excluded: `>` is neither indent nor a list marker.
return (
indent + (marker || "") + "\x00CB" + (codeBlocks.length - 1) + "\x00"
);
},
);
// Extract <details> blocks and recursively render — AFTER fence protection,
// BEFORE the blockquote/inline/math passes. Running after fence means a
// fenced code block inside <details> is already a \x00CB\x00 sentinel; it is
// restored to its RAW source (codeBlockRaw) before the recursive
// renderMarkdown, so the code renders in-frame instead of restoring to
// `undefined` / an inert sentinel against the recursion's empty arrays
// (NEW-1, silent content loss). Fence-first also masks a `</details>` shown
// as example code (so it can't close the block early) and a whole <details>
// shown inside a fence (so it stays literal) — no separate fence-awareness
// needed. The open is anchored to line start (`^[ \t]*`: allows indentation,
// e.g. under a list, but not a mid-line `<details>` inside inline code — B5);
// the close stays unanchored so the one-line
// <details><summary>x</summary>y</details> form still matches.
var detailsBlocks = [];
text = text.replace(
/^[ \t]*<details>\s*\n?([\s\S]*?)<\/details>/gim,
function (m, inner) {
// Restore any fenced bodies to raw markdown so the recursion re-renders
// them in its own frame (the outer codeBlocks entry is then unused).
inner = inner.replace(/\x00CB(\d+)\x00/g, _restorer(codeBlockRaw));
var sumMatch = inner.match(
/^\s*<summary>([\s\S]*?)<\/summary>\s*\n?([\s\S]*)/i,
);
var html;
if (sumMatch) {
html =
"<details><summary>" +
inlineMarkdown(sumMatch[1].trim()) +
"</summary>" +
renderMarkdown(sumMatch[2]) +
"</details>";
} else {
html = "<details>" + renderMarkdown(inner) + "</details>";
}
detailsBlocks.push(html);
return "\x00DT" + (detailsBlocks.length - 1) + "\x00";
},
);
// Pre-pass: extract blockquote blocks and recursively render. Runs after
// fence protection (a fenced `> ` line is already masked as a \x00CB\x00
// sentinel, so it is not scooped here — B4) but before the remaining
// code/math protection, so the recursive call still processes raw markdown,
// not text with outer-scope placeholders.
var bqBlocks = [];
(function () {
var blines = text.split("\n");
var result = [];
var i = 0;
while (i < blines.length) {
if (blines[i].startsWith("> ") || blines[i] === ">") {
var inner = [];
while (
i < blines.length &&
(blines[i].startsWith("> ") || blines[i] === ">")
) {
inner.push(blines[i] === ">" ? "" : blines[i].slice(2));
i++;
}
// Check for GFM alert/callout syntax: > [!NOTE], > [!TIP], etc.
var alertMatch =
inner.length > 0 &&
inner[0].match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$/i);
if (alertMatch) {
var alertType = alertMatch[1].toUpperCase();
var info = CALLOUT_TYPES[alertType];
var bodyLines = inner.slice(1);
if (bodyLines.length > 0 && bodyLines[0].trim() === "") {
bodyLines = bodyLines.slice(1);
}
var bodyHtml =
bodyLines.length > 0 ? renderMarkdown(bodyLines.join("\n")) : "";
bqBlocks.push(
'<div class="callout callout-' +
alertType.toLowerCase() +
'" role="note" aria-label="' +
info.label +
'">' +
'<div class="callout-title">' +
'<span class="callout-icon" aria-hidden="true">' +
info.icon +
"</span> " +
'<span class="callout-label">' +
info.label +
"</span>" +
"</div>" +
(bodyHtml
? '<div class="callout-body">' + bodyHtml + "</div>"
: "") +
"</div>",
);
} else {
bqBlocks.push(
"<blockquote>" + renderMarkdown(inner.join("\n")) + "</blockquote>",
);
}
result.push("\x00BQ" + (bqBlocks.length - 1) + "\x00");
} else {
result.push(blines[i]);
i++;
}
}
text = result.join("\n");
})();
// Protect inline code FIRST so backtick spans containing math
// delimiters (e.g. `` `$$x$$` `` or `` `\[x\]` ``) stay literal.
// Display math used to run first, but that lets the math regex
// consume delimiters inside backticks and replace them with
// \x00MB…\x00 sentinels — sentinels then captured by the inline
// code grab end up restored INSIDE the <code>, leaking the
// null-byte placeholder into rendered output. Code first means
// backticks seal their content before any math regex sees it.
// The reverse edge case (math containing backticks, e.g.
// ``$$ \verb|`x`| $$``) is much rarer and KaTeX would reject
// the verbatim syntax anyway.
// Raw-source twins for the inline-code / math sentinels, mirroring
// Raw twins: the table pass slices its data-md-source region from
// POST-masking text, so a cell's sentinels must be restored to the raw
// source the user wrote before the slice lands in an attribute — the
// rendered substitutes carry quotes/markup and are restored AFTER the
// table splice, i.e. inside the attribute value (the B-class breakout).
// Every mask pushes to its raw twin unconditionally, so the twins are
// index-aligned with the rendered arrays by construction — the cost is
// one string reference per span.
var inlineCodesRaw = [];
var mathBlocksRaw = [];
var inlineMathsRaw = [];
var inlineCodes = [];
text = text.replace(/`([^`\n]+)`/g, function (m, code) {
inlineCodes.push("<code>" + escapeHtml(code) + "</code>");
inlineCodesRaw.push(m);
return "\x00IC" + (inlineCodes.length - 1) + "\x00";
});
// Protect display math — both TeX ($$...$$) and LaTeX (\[...\])
// delimiter styles. Most models emit one or the other depending
// on system-prompt style; GPT-5 / o-series and Claude with
// reasoning effort tend to emit the LaTeX form. Without both,
// math nested in a markdown paragraph silently passes through
// as raw \[...\] text.
var mathBlocks = [];
text = text.replace(/\$\$([\s\S]+?)\$\$/g, function (m, tex) {
mathBlocks.push(renderLatex(tex.trim(), true));
mathBlocksRaw.push(m);
return "\x00MB" + (mathBlocks.length - 1) + "\x00";
});
text = text.replace(/\\\[([\s\S]+?)\\\]/g, function (m, tex) {
mathBlocks.push(renderLatex(tex.trim(), true));
mathBlocksRaw.push(m);
return "\x00MB" + (mathBlocks.length - 1) + "\x00";
});
// Protect inline math — LaTeX-style \(...\) only.
//
// Single-$ delimiters were dropped because $ is overloaded in
// prose: currency ("$5 and $10"), env vars ("$HOME and $PATH"),
// and shell prompts all produced false-positive math spans.
// \(...\) is unambiguous and is what GPT-5 / o-series / Claude
// with reasoning effort emit by default anyway. Display math
// ($$...$$ and \[...\]) is unchanged; it has enough delimiter
// mass that ambiguity is not a practical problem.
//
// Newlines inside the captured group are forbidden: an
// unterminated \(...\) on one line would otherwise eat the next
// paragraph until it found a closing \), which is jarring on
// streaming markdown where the closer hasn't arrived yet.
var inlineMaths = [];
text = text.replace(/\\\(([^\n]+?)\\\)/g, function (m, tex) {
inlineMaths.push(renderLatex(tex.trim(), false));
inlineMathsRaw.push(m);
return "\x00IM" + (inlineMaths.length - 1) + "\x00";
});
// One ordered spec drives every raw-span restore: REVERSE mask order
// (IM, MB, then IC), because a later mask's span can swallow an
// earlier mask's sentinel into its raw twin (math wrapping inline
// code) — each pass re-exposes the sentinels the passes after it
// resolve. A new span mask gets a row here alongside its raw twin.
// Two restore sites: a table's data-md-source stash, and
// footnote-definition bodies before their recursive render.
var rawSpanPasses = [
[/\x00IM(\d+)\x00/g, inlineMathsRaw],
[/\x00MB(\d+)\x00/g, mathBlocksRaw],
[/\x00IC(\d+)\x00/g, inlineCodesRaw],
];
function restoreRawSpans(slice) {
if (slice.indexOf("\x00") === -1) return slice;
for (var rs = 0; rs < rawSpanPasses.length; rs++) {
slice = slice.replace(
rawSpanPasses[rs][0],
_restorer(rawSpanPasses[rs][1]),
);
}
return slice;
}
// Protect markdown tables (extract before line-by-line processing)
var tableBlocks = [];
(function () {
var tlines = text.split("\n");
var sepRe = /^\|?(\s*:?-{1,}:?\s*\|)+\s*:?-{1,}:?\s*\|?\s*$/;
var result = [];
var i = 0;
while (i < tlines.length) {
if (
i + 1 < tlines.length &&
tlines[i].includes("|") &&
sepRe.test(tlines[i + 1])
) {
var headerLine = tlines[i];
var sepLine = tlines[i + 1];
var sepCells = sepLine
.replace(/^\|/, "")
.replace(/\|?\s*$/, "")
.split("|");
var aligns = sepCells.map(function (c) {
c = c.trim();
if (c.startsWith(":") && c.endsWith(":")) return "center";
if (c.endsWith(":")) return "right";
return "left";
});
var hdrCells = headerLine
.replace(/^\|/, "")
.replace(/\|?\s*$/, "")
.split("|")
.map(function (c) {
return c.trim();
});
var dataRows = [];
var j = i + 2;
while (
j < tlines.length &&
tlines[j].includes("|") &&
tlines[j].trim() !== ""
) {
var row = tlines[j]
.replace(/^\|/, "")
.replace(/\|?\s*$/, "")
.split("|")
.map(function (c) {
return c.trim();
});
dataRows.push(row);
j++;
}
// The original pipe/alignment lines are unrecoverable from the
// rendered cells (trimmed, inline-rendered), so the copy affordance
// (copy_actions.js) needs them stashed here — the block's WHOLE
// raw source, by the same contract as message copy: the clipboard
// carries what was written, including source the render does not
// display (a row wider than the header renders truncated but
// copies whole). What is SHOWN is the render's decision; what is
// COPIED is the source.
//
// * The slice comes from POST-masking text: inline-code/math in
// cells are NUL sentinels whose global restores run AFTER the
// table restore — i.e. INSIDE this attribute — so they are
// resolved to their RAW sources first via restoreRawSpans
// (never into tlines: the rendered cells above keep theirs
// for the global passes). Every frame resolves its OWN span
// sentinels — footnote bodies are restored to raw before
// their recursive render — so the trailing strip is a pure
// backstop for a future recursion site that forgets that
// restore: an attribute must never carry a NUL sentinel,
// because the outer restores would splice rendered HTML into
// it after the fact.
//
// * The attribute escape is escapeHtml — the pipeline's one
// escaping helper — bound to safeMdSource for the
// double-quoted attribute value below.
var rawMdSource = restoreRawSpans(
tlines.slice(i, j).join("\n"),
).replace(/\x00[A-Z]{2}\d+\x00/g, "");
var safeMdSource = escapeHtml(rawMdSource);
var html =
'<div class="table-wrap" tabindex="0" role="region" aria-label="Data table" data-md-source="' +
safeMdSource +
'"><table>';
html += "<thead><tr>";
for (var k = 0; k < hdrCells.length; k++) {
var align = aligns[k] || "left";
html +=
'<th scope="col" class="align-' +
align +
'">' +
inlineMarkdown(hdrCells[k]) +
"</th>";
}
html += "</tr></thead><tbody>";
for (var r = 0; r < dataRows.length; r++) {
html += "<tr>";
for (var k = 0; k < hdrCells.length; k++) {
var align = aligns[k] || "left";
var cell = dataRows[r][k] || "";
html +=
'<td class="align-' +
align +
'">' +
inlineMarkdown(cell) +
"</td>";
}
html += "</tr>";
}
html += "</tbody></table></div>";
tableBlocks.push(html);
result.push("\x00TB" + (tableBlocks.length - 1) + "\x00");
i = j;
} else {
result.push(tlines[i]);
i++;
}
}
text = result.join("\n");
})();
// Collect footnote definitions ([^id]: content)
var footnoteDefs = {};
(function () {
var flines = text.split("\n");
var result = [];
var i = 0;
while (i < flines.length) {
var fnm = flines[i].match(/^\[\^([^\]]+)\]:\s*(.*)/);
if (fnm) {
var fnId = fnm[1];
var fnContent = fnm[2];
// Collect continuation lines (indented by 2+ spaces)
var j = i + 1;
while (j < flines.length && /^ {2}/.test(flines[j])) {
fnContent += "\n" + flines[j].slice(2);
j++;
}
footnoteDefs[fnId] = fnContent;
i = j;
} else {
result.push(flines[i]);
i++;
}
}
text = result.join("\n");
})();
// Process block-level elements per line
var lines = text.split("\n");
var out = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
// Horizontal rule
if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) {
out.push("<hr>");
continue;
}
// Headers
var hm = line.match(/^(#{1,6})\s+(.+)/);
if (hm) {
var level = hm[1].length;
out.push(
"<h" + level + ">" + inlineMarkdown(hm[2]) + "</h" + level + ">",
);
continue;
}
// Lists — collect consecutive list lines, then render with nesting
var ulm = line.match(/^(\s*)[-*+]\s+(.*)/);
var olm = !ulm ? line.match(/^(\s*)\d+[.)]\s+(.*)/) : null;
if (ulm || olm) {
var listItems = [];
while (i < lines.length) {
var um = lines[i].match(/^(\s*)[-*+]\s+(.*)/);
var om = !um ? lines[i].match(/^(\s*)\d+[.)]\s+(.*)/) : null;
if (um || om) {
var lm = um || om;
listItems.push({
indent: lm[1].length,
ordered: !!om,
content: lm[2],
});
i++;
} else {
break;
}
}
i--; // for-loop will increment
out.push(renderListBlock(listItems));
continue;
}
// Definition list — term followed by `: definition` lines
if (
line.trim() !== "" &&
i + 1 < lines.length &&
/^:\s+/.test(lines[i + 1])
) {
var dlItems = [];
while (i < lines.length) {
if (lines[i].trim() === "" || /^:\s+/.test(lines[i])) break;
var dlTerm = lines[i];
var dlDefs = [];
i++;
while (i < lines.length && /^:\s+/.test(lines[i])) {
dlDefs.push(lines[i].match(/^:\s+(.*)/)[1]);
i++;
}
if (dlDefs.length > 0) {
dlItems.push({ term: dlTerm, defs: dlDefs });
} else {
break;
}
// Skip blank lines between entries
while (i < lines.length && lines[i].trim() === "") i++;
// Check if another term+definition follows
if (
i < lines.length &&
lines[i].trim() !== "" &&
!/^:\s+/.test(lines[i]) &&
i + 1 < lines.length &&
/^:\s+/.test(lines[i + 1])
) {
continue;
}
break;
}
if (dlItems.length > 0) {
var dlHtml = "<dl>";
for (var di = 0; di < dlItems.length; di++) {
dlHtml += "<dt>" + inlineMarkdown(dlItems[di].term) + "</dt>";
for (var dd = 0; dd < dlItems[di].defs.length; dd++) {
dlHtml += "<dd>" + inlineMarkdown(dlItems[di].defs[dd]) + "</dd>";
}
}
dlHtml += "</dl>";
out.push(dlHtml);
i--; // for-loop will increment
continue;
}
}
// Paragraph / plain text
if (line.trim() === "") {
out.push("");
} else {
out.push("<p>" + inlineMarkdown(line) + "</p>");
}
}
var result = out.join("\n");
// Append footnote section if any definitions were collected.
//
// Each definition body is rendered by a recursive renderMarkdown call. The
// body was collected AFTER the inline-span masks, so it carries outer-scope
// span sentinels — restored to RAW source first (restoreRawSpans, the same
// recipe the <details> pass uses for fenced bodies) so the recursion
// re-masks the spans itself and its own table stashes resolve against its
// own raw twins (an outer sentinel reaching a recursive stash would be
// stripped as residue: silent loss in the copied source). BLOCK sentinels
// keep the original path: the recursion leaves a fence's \x00CB\x00 inert
// (the restore guard emits the matched sentinel, never "undefined"), and
// because this section is appended to `result` BEFORE the restore passes
// below — whose codeBlocks array is still populated — the OUTER restore
// resolves it, so a FENCED block continuing a footnote definition renders
// correctly (the fence pass re-emits its 2-space indent before the
// sentinel, so the continuation scan still collects it).
var fnKeys = Object.keys(footnoteDefs);
if (fnKeys.length > 0) {
var fnHtml =
'<section class="footnotes" role="doc-endnotes">' +
'<hr class="footnotes-sep"><ol class="footnotes-list">';
for (var fi = 0; fi < fnKeys.length; fi++) {
var fid = fnKeys[fi];
var safeFid = escapeHtml(fid);
fnHtml +=
'<li class="footnote-item" id="fn-' +
_fnScopeId +
"-def-" +
safeFid +
'">' +
renderMarkdown(restoreRawSpans(footnoteDefs[fid])) +
' <a href="#fn-' +
_fnScopeId +
"-ref-" +
safeFid +
'" class="fn-backref" ' +
'role="doc-backlink" aria-label="Back to reference">\u21A9</a></li>';
}
fnHtml += "</ol></section>";
result += fnHtml;
}
// Restore protected blocks through the _restorer factory (out-of-range
// indices — reachable only via a recursive frame whose fresh array can't
// resolve an outer-scope sentinel, the NEW-1 residual — leave the inert
// sentinel rather than the literal "undefined"). Each block type unwraps a
// `<p>SENTINEL</p>` paragraph first (the line pass wraps a lone sentinel in
// <p>) so the browser doesn't split a stray empty <p> off the block; the
// bare form follows. The CB unwrap also tolerates surrounding whitespace
// (`<p> \x00CB0\x00</p>`) because the fence pass re-emits an own-line
// fence's leading indent before the sentinel — the other block types emit
// their sentinel at column 0, so they don't need it.
result = result.replace(
/<p>[ \t]*\x00CB(\d+)\x00[ \t]*<\/p>/g,
_restorer(codeBlocks),
);
result = result.replace(/\x00CB(\d+)\x00/g, _restorer(codeBlocks));
result = result.replace(/<p>\x00DT(\d+)\x00<\/p>/g, _restorer(detailsBlocks));
result = result.replace(/\x00DT(\d+)\x00/g, _restorer(detailsBlocks));
result = result.replace(/<p>\x00BQ(\d+)\x00<\/p>/g, _restorer(bqBlocks));
result = result.replace(/\x00BQ(\d+)\x00/g, _restorer(bqBlocks));
// Tables restore BEFORE the inline-span passes (MB/IC/IM): a table
// CELL's rendered content still holds its inline sentinels, which only
// resolve if the table's HTML is already spliced into `result` when
// those passes run. (MB used to run first, so display math in a cell
// rendered as literal sentinel garbage.) The data-md-source attribute
// is immune to this ordering either way — it is built sentinel-free
// from the raw twins at emission time.
result = result.replace(/<p>\x00TB(\d+)\x00<\/p>/g, _restorer(tableBlocks));
result = result.replace(/\x00TB(\d+)\x00/g, _restorer(tableBlocks));
result = result.replace(/<p>\x00MB(\d+)\x00<\/p>/g, _restorer(mathBlocks));
result = result.replace(/\x00MB(\d+)\x00/g, _restorer(mathBlocks));
result = result.replace(/\x00IC(\d+)\x00/g, _restorer(inlineCodes));
result = result.replace(/\x00IM(\d+)\x00/g, _restorer(inlineMaths));
return result;
}
// ---------------------------------------------------------------------------
// Post-render hook — syntax highlighting via highlight.js
// ---------------------------------------------------------------------------
var _NO_HIGHLIGHT_LANGS = {
ascii: true,
text: true,
plaintext: true,
plain: true,
nohighlight: true,
mermaid: true,
plantuml: true,
};
var _TERMINAL_LANGS = {
bash: true,
shell: true,
sh: true,
zsh: true,
console: true,
terminal: true,
};
var _hljsConfigured = false;
// Source-keyed highlight cache. Mirrors _mermaidSvgCache: streamingRender
// replaces innerHTML wholesale on every rAF tick, so the <code> elements
// inside come up FRESH each tick — they don't carry the hljs class, and
// nothing on them carries forward. Without a cache, running hljs per
// tick would re-tokenize every code block every paint cycle on long
// streamed responses with many fences. With the cache, identical
// (language, source) pairs reuse the highlighted innerHTML synchronously.
//
// Cache miss runs hljs.highlightElement(el) (which mutates the element
// in place: replaces its innerHTML with highlighted span markup and
// adds the hljs class) and stores the resulting markup. Cache hit
// assigns that stored markup to el.innerHTML and re-adds the hljs
// class manually — semantically equivalent to a fresh highlightElement
// call without paying for re-tokenization.
//
// The cached value is the structured span markup that hljs itself
// produced from already-escaped text content, so re-assigning it to
// innerHTML doesn't widen the XSS surface beyond what hljs.highlight
// Element already does.
//
// FIFO-bounded so a long session with many distinct code blocks can't
// grow unbounded.
var _hljsCache = new Map();
var _HLJS_CACHE_MAX = 64;
// Shared FIFO eviction helper for the source-keyed caches in this
// file (_hljsCache, _mermaidSvgCache, _mermaidErrorCache, plus the
// raw→normalized mermaid memo). Only evicts the oldest when inserting
// a NEW key — overwriting an existing key is an in-place update and
// must not pay the eviction cost (which would drop an unrelated
// cached entry). The `cache_overwrite_does_not_evict` tests pin this
// invariant per cache.
function _cacheFifoEntry(cache, key, value, max) {
if (!cache.has(key) && cache.size >= max) {
var firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
cache.set(key, value);
}
function _applyCachedHljs(el, cachedHtml) {
el.innerHTML = cachedHtml;
el.classList.add("hljs");
}
export function postRenderHljs(containerEl) {
if (typeof hljs === "undefined") return;
if (!_hljsConfigured) {
hljs.configure({ ignoreUnescapedHTML: true });
_hljsConfigured = true;
}
var codeEls = containerEl.querySelectorAll("pre code[class*='language-']");
for (var i = 0; i < codeEls.length; i++) {
var el = codeEls[i];
// Already-highlighted element (e.g. postRenderMarkdown called twice
// on the same DOM with no intervening innerHTML replace). The
// streaming path replaces innerHTML wholesale per tick, so this
// guard primarily protects the non-streaming render path.
if (el.classList.contains("hljs")) continue;
// Extract language name from class
var langClass = "";
for (var j = 0; j < el.classList.length; j++) {
if (el.classList[j].startsWith("language-")) {
langClass = el.classList[j].substring(9);
break;
}
}
// Skip plaintext variants
if (_NO_HIGHLIGHT_LANGS[langClass]) {
el.classList.add("nohighlight");
continue;
}
// Cache key: language + separator + source. ":" isn't part of a
// language identifier so the prefix is unambiguous across keys.
var source = el.textContent;
var cacheKey = langClass + ":" + source;
if (_hljsCache.has(cacheKey)) {
_applyCachedHljs(el, _hljsCache.get(cacheKey));
} else {
hljs.highlightElement(el);
_cacheFifoEntry(_hljsCache, cacheKey, el.innerHTML, _HLJS_CACHE_MAX);
}
// Add terminal styling class for shell languages
if (_TERMINAL_LANGS[langClass]) {
var pre = el.closest("pre");
if (pre) pre.classList.add("code-terminal");
}
}
}
export function postRenderMarkdown(containerEl) {
postRenderHljs(containerEl);
// Render mermaid diagrams (lazy-loads mermaid.js on first use)
postRenderMermaid(containerEl);
}
// ---------------------------------------------------------------------------
// Mermaid diagram rendering (lazy-loaded)
// ---------------------------------------------------------------------------
var _mermaidState = "idle"; // idle | loading | ready
var _mermaidQueue = []; // callbacks queued while loading
var _mermaidIdCounter = 0;
function _initMermaid() {
// Clear caches on (re-)init so a theme change via reRenderAllMermaid
// doesn't serve stale SVG keyed by source-only — the rendered output
// depends on themeVariables which we just changed.
if (typeof _mermaidSvgCache !== "undefined") _mermaidSvgCache.clear();
if (typeof _mermaidErrorCache !== "undefined") _mermaidErrorCache.clear();
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
theme: "base",
themeVariables: _getMermaidTheme(),
});
}
function _loadMermaid(callback) {
if (_mermaidState === "ready") {
callback();
return;
}
_mermaidQueue.push(callback);
if (_mermaidState === "loading") return;
_mermaidState = "loading";
var script = document.createElement("script");
script.src = "/shared/mermaid-11.16.0/mermaid.min.js";
script.onload = function () {
_initMermaid();
_mermaidState = "ready";
var q = _mermaidQueue;
_mermaidQueue = [];
for (var i = 0; i < q.length; i++) q[i]();
};
script.onerror = function () {
_mermaidState = "idle";
_mermaidQueue = [];
var els = document.querySelectorAll(".mermaid-loading");
for (var i = 0; i < els.length; i++) {
els[i].classList.remove("mermaid-loading");
els[i].classList.add("mermaid-error");
els[i].textContent = "Failed to load diagram renderer";
}
};
document.head.appendChild(script);
}
function _getMermaidTheme() {
var s = getComputedStyle(document.documentElement);
return {
primaryColor: s.getPropertyValue("--bg-surface").trim(),
primaryTextColor: s.getPropertyValue("--fg").trim(),
primaryBorderColor:
s.getPropertyValue("--border-strong").trim() || "rgba(255,255,255,0.1)",
lineColor: s.getPropertyValue("--fg-dim").trim(),
secondaryColor: s.getPropertyValue("--bg-highlight").trim(),
tertiaryColor: s.getPropertyValue("--bg").trim(),
noteBkgColor: s.getPropertyValue("--bg-surface").trim(),
noteTextColor: s.getPropertyValue("--fg").trim(),
noteBorderColor: s.getPropertyValue("--accent").trim(),
actorTextColor: s.getPropertyValue("--fg-bright").trim(),
actorBkg: s.getPropertyValue("--bg-surface").trim(),
actorBorder: s.getPropertyValue("--accent").trim(),
signalColor: s.getPropertyValue("--fg").trim(),
signalTextColor: s.getPropertyValue("--fg").trim(),
};
}
// Mermaid label autoquoter.
//
// Mermaid's flowchart parser treats ( ) [ ] { } as shape delimiters
// EVERYWHERE — including inside other labels — unless the label is
// wrapped in "...". LLM-emitted diagrams routinely produce things
// like A["x"] -->|note (with parens)| B or D[label (foo, bar)]
// and Mermaid then rejects them with "Parse error, got PS" (paren-
// start in shape context — the parser entered a nested shape parse
// at the bare `(` and ran out of expected closing tokens).
//
// We can't fix every malformed diagram, but the two patterns above
// are easy to spot syntactically and quote:
//
// 1. Edge labels: |content| → |"content"|
// 2. Plain rectangle node labels: ID[content] → ID["content"]
//
// Shapes whose syntax already nests delimiters — cylinders [(...)],
// subroutines [[...]], trapezoids [/.../] [\...\], circles ((...)),
// double circles (((...))), hexagons {{...}}, diamonds {...} — are
// intentionally left alone. The inner delimiters are part of the
// shape, and our regex would corrupt valid syntax. Authors using
// those shapes must quote the label manually.
function _normalizeMermaidSource(source) {
if (!source) return source;
// Fast path: no shape delimiters anywhere → nothing to quote.
if (
source.indexOf("(") === -1 &&
source.indexOf("[") === -1 &&
source.indexOf("{") === -1
) {
return source;
}
var lines = source.split("\n");
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
// %% directives and comments — never rewrite. The %%{init:...}%%
// form contains braces that would otherwise look like a label.
if (/^\s*%%/.test(line)) continue;
line = _quoteMermaidNodeLabels(line);
line = _quoteMermaidEdgeLabels(line);
lines[i] = line;
}
return lines.join("\n");
}
function _quoteMermaidNodeLabels(line) {
// ID[content] → ID["content"] when content needs quoting.
//
// The first character of content is restricted to NOT be [ ( / \
// so we skip [[subroutine]], [(cylinder)], [/trap/], [\trap\].
// The rest of content is restricted to NOT contain [ ] so the
// regex can't run away past a legitimate ].
return line.replace(
/([A-Za-z_][\w-]*)\[([^[(/\\\n][^[\]\n]*?)\]/g,
function (m, id, content) {
if (_mermaidLabelNeedsQuoting(content)) {
return id + '["' + content + '"]';
}
return m;
},
);
}
function _quoteMermaidEdgeLabels(line) {
// |content| → |"content"| when content needs quoting.
// Edge labels can't contain a literal | (it's the delimiter), so
// [^|\n] is exhaustive.
return line.replace(/\|([^|\n]+)\|/g, function (m, content) {
if (_mermaidLabelNeedsQuoting(content)) {
return '|"' + content + '"|';
}
return m;
});
}
function _mermaidLabelNeedsQuoting(content) {
// Any literal " in content would produce nested unescaped quotes
// when we wrap. Punt to manual fix. This also short-circuits the
// already-correctly-quoted "..." case (which has " at the bounds).
if (content.indexOf('"') !== -1) return false;
// <br/> and <br> are part of Mermaid's allowed HTML in labels and
// don't on their own require quoting.
var stripped = content.replace(/<br\s*\/?>/gi, "");
return /[()[\]{}]/.test(stripped);
}
// Source-keyed SVG cache. Identical mermaid source produces identical
// SVG, so we can swap in cached output synchronously without re-running
// mermaid.render. Crucial for streaming markdown: streamingRender does
// `el.innerHTML = html` wholesale on every rAF tick, which destroys
// rendered SVG nodes — without the cache, a closed mermaid block would
// re-trigger an async render on every subsequent token. With the cache,
// each unique source pays mermaid.render exactly once per session.
//
// Errored sources are cached too (as the message string) so a
// syntactically-broken diagram doesn't re-thrash mermaid on every
// render. The user can fix the diagram and the new source string
// misses the cache, triggering a fresh render.
//
// FIFO bounded so a long session that emits many distinct diagrams
// can't grow unbounded.
var _mermaidSvgCache = new Map();
var _mermaidErrorCache = new Map();
var _MERMAID_CACHE_MAX = 64;
// Raw-textContent → normalized memo. _normalizeMermaidSource splits +
// regex-replaces line by line; on a 50-line flowchart that's ~57 µs.
// The SVG cache short-circuits mermaid.render once we have the
// normalized key, but the *normalize step itself* runs on every rAF
// tick (postRenderMermaid always calls it before the SVG-cache
// lookup, since the normalized output IS the lookup key). Memoizing
// raw → normalized avoids repeating the split + regex for diagrams
// whose source hasn't changed between ticks.
//
// Bounded by _MERMAID_CACHE_MAX so its memory footprint stays in the
// same order of magnitude as the SVG cache it feeds, but the two
// queues evict INDEPENDENTLY: this memo keys on raw textContent while
// the SVG cache keys on normalized source, so a single diagram can
// occupy one slot in each with no positional coupling. The memo also
// deliberately survives `_initMermaid` (which clears the SVG / error
// caches on theme change) — normalization output is purely a function
// of input text, independent of mermaid theme / config.
var _mermaidNormalizeCache = new Map();
function _applyMermaidSvg(container, svg, bindFunctions) {
container.innerHTML = svg;
container.classList.remove("mermaid-loading", "mermaid-error");
container.classList.add("mermaid-rendered");
if (bindFunctions) bindFunctions(container);
}
function _applyMermaidError(container, source, message) {
container.classList.remove("mermaid-loading");
container.classList.add("mermaid-error");
container.innerHTML =
'<div class="mermaid-error-msg">' +
escapeHtml(message || "Diagram error") +
"</div>" +
"<pre><code>" +
escapeHtml(source) +
"</code></pre>";
}
// Global render queue + per-source pending-container map.
//
// mermaid.render() uses module-level state internally — concurrent
// calls clobber that state. With postRenderMermaid now firing on
// every streaming rAF tick (not just on stream_end), two ticks
// could each find a fresh container (the prior tick's container
// is detached after innerHTML replace) for the SAME unfinished
// source, or for DIFFERENT sources, and both would queue
// mermaid.render() concurrently. Two layers of serialization fix
// this:
//
// 1. _mermaidPending: per-source. While a render is in flight
// for source X, additional containers asking for source X
// are queued; the single render result fans out to all
// pending containers when it lands.
// 2. _mermaidRenderChain: across-source. Promises chain so
// mermaid.render() runs at most one at a time globally.
//
// Detached containers (no longer in the DOM by the time the
// render completes) are skipped — innerHTML replace during
// streaming detaches them and a later rAF tick's render is
// already taking care of the live container.
var _mermaidPending = new Map();
var _mermaidRenderChain = Promise.resolve();
function _renderMermaidBlock(container, callback) {
var source = container.getAttribute("data-mermaid-source");
if (!source) {
if (callback) callback();
return;
}
// Same source already in flight — append to pending list.
// Caller's callback fires as if the render started; the actual
// SVG application happens when the in-flight render lands.
if (_mermaidPending.has(source)) {
_mermaidPending.get(source).push(container);
if (callback) callback();
return;
}
_mermaidPending.set(source, [container]);
// ``linkPending`` is hoisted to the link's closure so the rejection-proof
// .catch below can paint the error on the containers THIS link captured —
// by the time it runs, the link already removed them from _mermaidPending,
// so without the hoist they'd sit at "Loading diagram…" forever.
var linkPending = null;
_mermaidRenderChain = _mermaidRenderChain
.then(function () {
var pending = _mermaidPending.get(source) || [];
linkPending = pending;
_mermaidPending.delete(source);
var id = "mermaid-" + ++_mermaidIdCounter;
return mermaid.render(id, source).then(
function (result) {
_cacheFifoEntry(
_mermaidSvgCache,
source,
{ svg: result.svg, bindFunctions: result.bindFunctions },
_MERMAID_CACHE_MAX,
);
for (var i = 0; i < pending.length; i++) {
var c = pending[i];
if (c.isConnected) {
// Per-container guard: one bad apply (a bindFunctions throw)
// must not skip the remaining containers for this source.
try {
_applyMermaidSvg(c, result.svg, result.bindFunctions);
} catch (e) {
_applyMermaidError(c, source, "diagram apply failed");
}
}
}
},
function (err) {
var orphan = document.getElementById(id);
if (orphan) orphan.remove();
var msg = err && err.message ? err.message : "Diagram error";
_cacheFifoEntry(_mermaidErrorCache, source, msg, _MERMAID_CACHE_MAX);
for (var i = 0; i < pending.length; i++) {
var c = pending[i];
if (c.isConnected) _applyMermaidError(c, source, msg);
}
},
);
})
.catch(function (e) {
// Rejection-proof every link: a sync throw escaping the link body
// (e.g. mermaid.render throwing on malformed input before returning a
// promise) would otherwise reject the shared chain, and every later
// diagram would silently sit at "Loading diagram…" forever. Settle
// back to fulfilled and paint the error on the containers this link
// had already claimed. Deliberately NO _mermaidPending.delete(source)
// here: the link deleted its own entry up top, and any entry present
// NOW belongs to a newer re-entry for the same source — deleting it
// would orphan THAT link's containers.
var msg = (e && e.message) || "Diagram error";
_cacheFifoEntry(_mermaidErrorCache, source, msg, _MERMAID_CACHE_MAX);
(linkPending || []).forEach(function (c) {
if (!c.isConnected) return;
try {
_applyMermaidError(c, source, msg);
} catch (_) {
/* container-level failure — nothing left to degrade to */
}
});
console.warn("renderer: mermaid render chain error", e);
});
if (callback) callback();
}
// Render mermaid blocks via the global chain. Calls return
// immediately; serialization happens inside _renderMermaidBlock.
function _renderMermaidSequence(containers, idx) {
for (var i = 0; i < containers.length; i++) {
_renderMermaidBlock(containers[i]);
}
}
function postRenderMermaid(containerEl) {
var codeEls = containerEl.querySelectorAll("pre code.language-mermaid");
if (codeEls.length === 0) return;
var pendingContainers = [];
for (var i = 0; i < codeEls.length; i++) {
var pre = codeEls[i].closest("pre");
if (!pre) continue;
// Autoquote labels with bare shape-delimiter chars before
// caching / rendering. Identical malformed input maps to identical
// normalized output, so the SVG cache still hits on repeated
// streams of the same diagram. _mermaidNormalizeCache skips the
// split + per-line regex when the raw textContent hasn't changed
// between ticks — only on a fresh source does normalization run.
var raw = codeEls[i].textContent;
var source;
if (_mermaidNormalizeCache.has(raw)) {
source = _mermaidNormalizeCache.get(raw);
} else {
source = _normalizeMermaidSource(raw);
_cacheFifoEntry(_mermaidNormalizeCache, raw, source, _MERMAID_CACHE_MAX);
}
var div = document.createElement("div");
div.setAttribute("data-mermaid-source", source);
// Focusable like the other copyable blocks (pre / .table-wrap carry
// tabindex=0): a focused block is what the keyboard copy path acts on
// (copy_actions.js, Enter), and the container is a scrollable region
// (overflow-x) that keyboard users must be able to reach. Set at
// creation so the loading and error states are reachable too — the
// rendered-SVG apply replaces innerHTML, which leaves host attributes
// intact. role=region (not img: the error state carries readable
// message + source that AT must not flatten away).
div.setAttribute("tabindex", "0");
div.setAttribute("role", "region");
div.setAttribute("aria-label", "Diagram");
// Use cache.has (not truthiness) so a future cached value of
// empty string / falsy SVG doesn't masquerade as a miss.
if (_mermaidSvgCache.has(source)) {
// Cache hit — sync swap, no loading flash, no async work.
// Identical source produces identical SVG (mermaid is
// deterministic for a given init), so reusing the rendered
// result is safe across streamingRender's wholesale
// innerHTML replaces. Re-apply via the same helper used
// by fresh renders so mermaid's bindFunctions (link/click
// bindings) attach to each new container instance.
var cached = _mermaidSvgCache.get(source);
div.className = "mermaid-container mermaid-rendered";
_applyMermaidSvg(div, cached.svg, cached.bindFunctions);
pre.replaceWith(div);
} else if (_mermaidErrorCache.has(source)) {
// Errored source — keep showing the error without thrashing
// mermaid.render on every streaming tick.
div.className = "mermaid-container mermaid-error";
_applyMermaidError(div, source, _mermaidErrorCache.get(source));
pre.replaceWith(div);
} else {
// Cache miss — show loading state, queue async render.
div.className = "mermaid-container mermaid-loading";
div.textContent = "Loading diagram\u2026";
pre.replaceWith(div);
pendingContainers.push(div);
}
}
if (pendingContainers.length === 0) return;
_loadMermaid(function () {
_renderMermaidSequence(pendingContainers, 0);
});
}
export function reRenderAllMermaid() {
if (_mermaidState !== "ready") return;
_initMermaid();
var els = document.querySelectorAll(
".mermaid-container[data-mermaid-source]",
);
var arr = [];
for (var i = 0; i < els.length; i++) {
els[i].classList.add("mermaid-loading");
els[i].classList.remove("mermaid-rendered", "mermaid-error");
arr.push(els[i]);
}
_renderMermaidSequence(arr, 0);
}
// ---------------------------------------------------------------------------
// Streaming helpers — shared between the server-node UI and coordinator.
// Re-render the full buffer on each streamed token, but coalesce through
// requestAnimationFrame so fast producers (10-100 tokens/sec) don't
// trigger renderMarkdown + DOM replacement more than once per paint
// cycle. renderMarkdown tolerates mid-stream partial fences / lists
// (they render as literal text and resolve once the closing tokens
// arrive), and the per-element buffer cache skips identical redundant
// renders (SSE retries / resumes). Both hljs syntax highlighting
// and mermaid run inline on every render so closed code / diagram
// fences appear progressively as they complete; their source-keyed
// caches (_hljsCache, _mermaidSvgCache) make subsequent rAF ticks
// that re-extract the same closed fence hit synchronously without
// re-invoking hljs.highlightElement / mermaid.render.
// renderMarkdown escapes HTML internally (see escapeHtml in
// utils.js); it is the trust boundary for the markup written to el
// below.
// ---------------------------------------------------------------------------
function _streamingRenderApply(el, buffer) {
if (el._lastRenderedBuffer === buffer) return;
// Unconditional copy-source stash (read by copy_actions.msgCopySource):
// BOTH exits below display content for this buffer — the catch paints it
// as plain text — so the stash must not ride _lastRenderedBuffer, whose
// stays-unset-on-throw semantics exist to make the next frame re-attempt
// the render (a copy reading it would get the last SUCCESSFUL frame's
// prefix while the bubble displays the full reply).
el._copySource = buffer;
try {
el.innerHTML = renderMarkdown(buffer);
} catch (e) {
// A render failure must not wedge the stream: show THIS frame as plain
// text and leave the buffer UN-marked, so the next delta / the finalize
// pass re-attempts a full render (partial-input throws heal themselves
// once the closing tokens arrive). Marking before the render used to
// make an errored frame look done — the finalize short-circuit then
// pinned the stale DOM forever.
console.warn("renderer: streaming render failed; plain-text frame", e);
el.textContent = buffer;
return;
}
el._lastRenderedBuffer = buffer;
// Progressive hljs + mermaid render — see comment above. Both are
// no-ops when the element has no matching code blocks, and their
// source-keyed caches avoid re-tokenizing / re-rendering for
// sources we've already processed. Subsequent rAF ticks that
// re-extract the same closed fence hit the cache synchronously.
// Guarded: decoration failures degrade to undecorated markup, never to
// a broken segment state upstream.
try {
postRenderHljs(el);
postRenderMermaid(el);
} catch (e) {
console.warn("renderer: post-render decoration failed", e);
}
}
export function streamingRender(el, buffer) {
if (!el) return;
// Short-circuit identical-buffer calls (e.g. SSE retry / resume).
// V8's string === length-compares internally so the explicit check is
// redundant.
if (el._lastRenderedBuffer === buffer) return;
el._pendingBuffer = buffer;
if (el._rafHandle) return; // one render per animation frame
el._rafHandle = requestAnimationFrame(function () {
el._rafHandle = 0;
// If the element has been removed from the tree between schedule
// and flush (pane cleared, message deleted, view swapped) skip the
// render so we don't mutate a detached node + keep its buffer
// strings alive until GC.
if (!el.isConnected) return;
_streamingRenderApply(el, el._pendingBuffer);
});
}
export function streamingRenderFinalize(el, buffer) {
if (!el) return;
// Flush any pending rAF-scheduled render so the finalize sees the
// final buffer exactly once, then run the expensive post-render
// (hljs / mermaid / KaTeX) on the settled DOM.
if (el._rafHandle) {
cancelAnimationFrame(el._rafHandle);
el._rafHandle = 0;
}
_streamingRenderApply(el, buffer);
if (typeof postRenderMarkdown === "function") {
postRenderMarkdown(el);
}
}
// --- Legacy window bridge ---------------------------------------------------
// Still-classic consumers reach these as globals at event/boot time (after
// this deferred module evaluated). New module code imports instead.
Object.assign(window, {
renderMarkdown,
inlineMarkdown,
postRenderHljs,
postRenderMarkdown,
reRenderAllMermaid,
streamingRender,
streamingRenderFinalize,
});