diff --git a/tests/test_renderer_js.py b/tests/test_renderer_js.py index 5e97d971..05630ecc 100644 --- a/tests/test_renderer_js.py +++ b/tests/test_renderer_js.py @@ -255,12 +255,17 @@ function makeEl(tag) { setAttribute(k, v) { this._attrs[k] = v; }, getAttribute(k) { return this._attrs[k] !== undefined ? this._attrs[k] : null; }, get classList() { + // Real DOMTokenList is array-like (length + indexed access) AND + // exposes add/remove/contains. The hljs language-extraction + // loop reads .length + [j], so we return a fresh Array snapshot + // each get + bolt the mutator methods on. add/remove operate on + // the live _classes set so subsequent reads see updates. const self = this; - return { - add(...c) { c.forEach(x => self._classes.add(x)); }, - remove(...c) { c.forEach(x => self._classes.delete(x)); }, - contains(c) { return self._classes.has(c); }, - }; + const arr = Array.from(self._classes); + arr.add = (...c) => c.forEach((x) => self._classes.add(x)); + arr.remove = (...c) => c.forEach((x) => self._classes.delete(x)); + arr.contains = (c) => self._classes.has(c); + return arr; }, get className() { return Array.from(this._classes).join(' '); }, set className(v) { @@ -269,9 +274,33 @@ function makeEl(tag) { get textContent() { return this._textContent || this.children.map(c => c.textContent || '').join(''); }, - set textContent(v) { this._textContent = v; this.children = []; }, + set textContent(v) { + // Real DOM: assigning textContent ALSO replaces innerHTML with + // an entity-escaped representation of the same text. escapeHtml + // (utils.js) round-trips via this side effect — without it, + // every escapeHtml() call returns '' and renderMarkdown emits + // empty
tags.
+ this._textContent = v;
+ this.children = [];
+ this._innerHTML = String(v)
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+ },
get innerHTML() { return this._innerHTML; },
- set innerHTML(v) { this._innerHTML = v; this.children = []; },
+ set innerHTML(v) {
+ // Real DOM invalidates the previous textContent when innerHTML
+ // is replaced — leaving _textContent intact would return stale
+ // data from subsequent textContent reads and mask bugs that
+ // depend on innerHTML/textContent consistency. We don't HTML-
+ // parse here, so the cheap correct behavior is to clear
+ // _textContent and let the children-derived fallback in the
+ // textContent getter (which is empty after this children = [])
+ // take over.
+ this._innerHTML = v;
+ this.children = [];
+ this._textContent = '';
+ },
get isConnected() {
// In real DOM this checks attachment to the document; for the
// test harness we approximate via the parent chain. After
@@ -304,17 +333,28 @@ function makeEl(tag) {
this.parent = null;
},
querySelectorAll(selector) {
- // Only supports the literal "pre code.language-mermaid"
- // selector that postRenderMermaid uses.
+ // Supports the two selectors the post-render passes use:
+ // "pre code.language-mermaid" (postRenderMermaid)
+ // "pre code[class*='language-']" (postRenderHljs)
const out = [];
+ const wantsMermaid = selector === "pre code.language-mermaid";
+ function matchesLangAttr(el) {
+ for (const cls of el._classes) {
+ if (cls.startsWith('language-')) return true;
+ }
+ return false;
+ }
function walk(node) {
for (const c of (node.children || [])) {
- if (
+ const isCodeInPre =
c.tagName === 'CODE' &&
- c.parent && c.parent.tagName === 'PRE' &&
- c._classes.has('language-mermaid')
- ) {
- out.push(c);
+ c.parent && c.parent.tagName === 'PRE';
+ if (isCodeInPre) {
+ if (wantsMermaid) {
+ if (c._classes.has('language-mermaid')) out.push(c);
+ } else if (matchesLangAttr(c)) {
+ out.push(c);
+ }
}
walk(c);
}
@@ -352,6 +392,20 @@ global.mermaid = {
},
};
+// hljs stub. highlightElement mutates the element in place: replaces
+// innerHTML with a deterministic synthetic span keyed by the source,
+// and adds the hljs class — same surface postRenderHljs depends on.
+// hljsHighlightCallCount lets tests assert "ran N times" semantics.
+let hljsHighlightCallCount = 0;
+global.hljs = {
+ configure: () => {},
+ highlightElement: (el) => {
+ hljsHighlightCallCount++;
+ el._classes.add('hljs');
+ el._innerHTML = '' + el._textContent + '';
+ },
+};
+
vm.runInThisContext(fs.readFileSync(%(utils)s, 'utf8'));
vm.runInThisContext(fs.readFileSync(%(renderer)s, 'utf8'));
@@ -514,7 +568,7 @@ def test_mermaid_cache_evicts_oldest_at_cap() -> None:
scenario = """
const cap = _MERMAID_CACHE_MAX;
for (let i = 0; i < cap + 5; i++) {
- _cacheMermaidEntry(_mermaidSvgCache, 'src-' + i, {svg: 'svg-' + i, bindFunctions: null});
+ _cacheFifoEntry(_mermaidSvgCache, 'src-' + i, {svg: 'svg-' + i, bindFunctions: null}, cap);
}
process.stdout.write(JSON.stringify({
size: _mermaidSvgCache.size,
@@ -536,10 +590,10 @@ def test_mermaid_overwrite_does_not_evict() -> None:
const cap = _MERMAID_CACHE_MAX;
// Fill exactly to cap.
for (let i = 0; i < cap; i++) {
- _cacheMermaidEntry(_mermaidSvgCache, 'src-' + i, {svg: 'svg-' + i, bindFunctions: null});
+ _cacheFifoEntry(_mermaidSvgCache, 'src-' + i, {svg: 'svg-' + i, bindFunctions: null}, cap);
}
// Overwrite an existing entry — must not evict src-0.
-_cacheMermaidEntry(_mermaidSvgCache, 'src-5', {svg: 'svg-updated', bindFunctions: null});
+_cacheFifoEntry(_mermaidSvgCache, 'src-5', {svg: 'svg-updated', bindFunctions: null}, cap);
process.stdout.write(JSON.stringify({
size: _mermaidSvgCache.size,
hasOldest: _mermaidSvgCache.has('src-0'),
@@ -558,8 +612,8 @@ def test_mermaid_cache_cleared_on_init() -> None:
— the rendered output depends on themeVariables which change
on init."""
scenario = """
-_cacheMermaidEntry(_mermaidSvgCache, 'src-1', {svg: 'old', bindFunctions: null});
-_cacheMermaidEntry(_mermaidErrorCache, 'src-bad', 'old error');
+_cacheFifoEntry(_mermaidSvgCache, 'src-1', {svg: 'old', bindFunctions: null}, _MERMAID_CACHE_MAX);
+_cacheFifoEntry(_mermaidErrorCache, 'src-bad', 'old error', _MERMAID_CACHE_MAX);
_initMermaid();
process.stdout.write(JSON.stringify({
svgSize: _mermaidSvgCache.size,
@@ -624,3 +678,598 @@ def test_streaming_render_invokes_mermaid_post_render() -> None:
"_streamingRenderApply must call postRenderMermaid for "
"progressive diagram rendering during streaming"
)
+
+
+# ---------------------------------------------------------------------------
+# _normalizeMermaidSource — autoquote labels with bare shape-delimiter
+# chars. Mermaid rejects unquoted ( ) [ ] { } inside other labels with
+# a "got 'PS'" parse error (paren-start in shape context). The two
+# diagrams in the screenshot regression case are encoded here verbatim.
+# ---------------------------------------------------------------------------
+
+
+def _run_normalize(source: str) -> str:
+ """Drive _normalizeMermaidSource against the JS harness and return
+ its output. The function is pure, so no container / mermaid stub
+ setup is required."""
+ scenario = f"""
+const input = {json.dumps(source)};
+const output = _normalizeMermaidSource(input);
+process.stdout.write(JSON.stringify({{ output: output }}));
+"""
+ out = _run_mermaid_scenario(scenario)
+ return str(out["output"])
+
+
+# Diagram 1 from the screenshot regression — unquoted edge labels with
+# parens and
markers. Mermaid rejects both edge labels with
+# "got 'PS'"; quoting them resolves it.
+_SCREENSHOT_DIAGRAM_1_IN = (
+ "flowchart LR\n"
+ ' A["vllm-openai:nightly
commit 5536fc0c0
2026-05-11 11:59"]'
+ " -->|22 upstream
main commits
(10 csrc, but
no new bindings)|"
+ ' B["fork merge_base
7863fff6e5
2026-05-12 00:27"]\n'
+ " B -->|13 jasl patches
(Python only:
tunings, kernels,"
+ "
warmup, etc.)|"
+ ' C["ds4-sm120-preview-dev
acc3455b1e"]'
+)
+_SCREENSHOT_DIAGRAM_1_OUT = (
+ "flowchart LR\n"
+ ' A["vllm-openai:nightly
commit 5536fc0c0
2026-05-11 11:59"]'
+ ' -->|"22 upstream
main commits
(10 csrc, but
no new bindings)"|'
+ ' B["fork merge_base
7863fff6e5
2026-05-12 00:27"]\n'
+ ' B -->|"13 jasl patches
(Python only:
tunings, kernels,'
+ '
warmup, etc.)"|'
+ ' C["ds4-sm120-preview-dev
acc3455b1e"]'
+)
+
+# Diagram 2 from the screenshot regression — unquoted RECTANGLE node
+# label `D[untouched
(.so, _version.py,
install-vendored)]`.
+# Same parser failure mode; quoting the bracket label fixes it.
+_SCREENSHOT_DIAGRAM_2_IN = (
+ "flowchart LR\n"
+ " A[nightly's vllm/
installed package] --> B{tar -xf
fork-vllm.tar}\n"
+ " B -->|in archive| C[overwritten with
fork's version]\n"
+ " B -->|not in archive| D[untouched
(.so, _version.py,"
+ "
install-vendored)]\n"
+ " E[explicit rm of 1 file
deleted upstream] --> B"
+)
+_SCREENSHOT_DIAGRAM_2_OUT = (
+ "flowchart LR\n"
+ " A[nightly's vllm/
installed package] --> B{tar -xf
fork-vllm.tar}\n"
+ " B -->|in archive| C[overwritten with
fork's version]\n"
+ ' B -->|not in archive| D["untouched
(.so, _version.py,'
+ '
install-vendored)"]\n'
+ " E[explicit rm of 1 file
deleted upstream] --> B"
+)
+
+
+@pytest.mark.parametrize(
+ ("source", "expected"),
+ [
+ (_SCREENSHOT_DIAGRAM_1_IN, _SCREENSHOT_DIAGRAM_1_OUT),
+ (_SCREENSHOT_DIAGRAM_2_IN, _SCREENSHOT_DIAGRAM_2_OUT),
+ ],
+)
+def test_mermaid_autoquote_fixes_screenshot_diagrams(source: str, expected: str) -> None:
+ """The two exact diagrams from the screenshot regression. If
+ these stop being rewritten with quoted labels, mermaid will
+ again reject them with `Expecting ... got 'PS'` during live
+ streaming."""
+ assert _run_normalize(source) == expected
+
+
+@pytest.mark.parametrize(
+ "source",
+ [
+ # Clean diagram — no shape delimiters in any label.
+ "graph TD\n A[foo] --> B[bar]",
+ # Edge label with no special chars.
+ "A --> B\nA -->|plain text| B",
+ # Already-correctly-quoted node label.
+ 'A["already (quoted)"] --> B',
+ # Already-correctly-quoted edge label.
+ 'A -->|"already (quoted)"| B',
+ # Cylinder shape — inner () is part of the shape syntax.
+ "A[(database)] --> B",
+ # Subroutine shape — inner [] is part of the shape syntax.
+ "A[[subroutine]] --> B",
+ # Trapezoid shape — inner / is part of the shape syntax.
+ "A[/trapezoid/] --> B",
+ # Reverse trapezoid.
+ "A[\\trap\\] --> B",
+ # Mermaid directive — braces here are config, not a label.
+ '%%{init: {"theme": "dark"}}%%\ngraph TD\n A --> B',
+ #
tags on their own don't trip quoting.
+ "A[line1
line2] --> B",
+ # Sequence diagram — different grammar; we only target labels
+ # in shape/edge syntax that match the regex anchors.
+ "sequenceDiagram\n A->>B: hello",
+ ],
+)
+def test_mermaid_autoquote_leaves_valid_source_alone(source: str) -> None:
+ """The autoquoter must not rewrite syntactically valid Mermaid —
+ a false positive here would break a working diagram. Each case
+ covers a syntax form whose delimiters are intentional and must
+ not be wrapped."""
+ assert _run_normalize(source) == source
+
+
+def test_mermaid_autoquote_edge_label_with_parens() -> None:
+ """Bare-parens edge label gets wrapped. The bare `(` would
+ otherwise re-enter Mermaid's shape parser."""
+ src = "A -->|note (with parens)| B"
+ assert _run_normalize(src) == 'A -->|"note (with parens)"| B'
+
+
+def test_mermaid_autoquote_node_label_with_parens() -> None:
+ """Bare-parens node label gets wrapped."""
+ src = "D[label (foo, bar)]"
+ assert _run_normalize(src) == 'D["label (foo, bar)"]'
+
+
+def test_mermaid_autoquote_node_label_with_braces() -> None:
+ """Bare-braces in a rectangle label get wrapped. (Diamond {}
+ shapes are left alone — only single-bracket [] labels are
+ rewritten.)"""
+ src = "A[config {key: value}]"
+ assert _run_normalize(src) == 'A["config {key: value}"]'
+
+
+def test_mermaid_autoquote_preserves_br_tag_with_parens() -> None:
+ """`
` inside a label that also has parens stays — only the
+ quoting needs to be added around the whole label."""
+ src = "A[line1
(line2)] --> B"
+ assert _run_normalize(src) == 'A["line1
(line2)"] --> B'
+
+
+def test_mermaid_autoquote_skips_label_with_internal_quote() -> None:
+ """If a label contains a literal `"`, wrapping would produce
+ nested unescaped quotes. The autoquoter must punt — leaving the
+ parse error to surface, rather than silently producing a worse
+ one."""
+ src = 'A[he said "hi" (lol)]'
+ assert _run_normalize(src) == src
+
+
+def test_mermaid_autoquote_multiple_edges_on_one_line() -> None:
+ """Both edge labels on a single line get rewritten independently."""
+ src = "A -->|first (paren)| B -->|second (paren)| C"
+ expected = 'A -->|"first (paren)"| B -->|"second (paren)"| C'
+ assert _run_normalize(src) == expected
+
+
+def test_mermaid_autoquote_normalized_source_hits_cache() -> None:
+ """The SVG cache keys on the normalized source — same malformed
+ input that the LLM streamed earlier still hits the cache on
+ re-render rather than re-invoking mermaid.render every tick."""
+ bad = "A[label (with parens)] --> B"
+ scenario = (
+ _build_mermaid_container_js([bad])
+ + _MERMAID_DRAIN_JS
+ + """
+postRenderMermaid(container);
+setTimeout(() => setTimeout(() => {
+ const container2 = buildContainer(sources);
+ postRenderMermaid(container2);
+ setTimeout(() => {
+ process.stdout.write(JSON.stringify({
+ renderCalls: renderCallCount,
+ normalized: container.children[0]._attrs['data-mermaid-source'],
+ }));
+ }, 0);
+}, 0), 0);
+"""
+ )
+ out = _run_mermaid_scenario(scenario)
+ assert out["renderCalls"] == 1, "second render bypassed the cache"
+ assert out["normalized"] == 'A["label (with parens)"] --> B'
+
+
+def test_mermaid_normalize_memo_populates_on_first_call() -> None:
+ """First postRenderMermaid call populates _mermaidNormalizeCache
+ with a raw→normalized entry. A second call on identical raw
+ textContent then hits the memo (size stays at 1, no second
+ normalize call), which is the perf-1 fix — avoids re-running
+ split + per-line regex per rAF tick when the diagram hasn't
+ changed."""
+ bad = "A[label (with parens)] --> B"
+ scenario = (
+ _build_mermaid_container_js([bad])
+ + _MERMAID_DRAIN_JS
+ + """
+postRenderMermaid(container);
+const sizeAfterFirst = _mermaidNormalizeCache.size;
+const cachedNorm = _mermaidNormalizeCache.get(sources[0]);
+// Re-render on a fresh container with the same source.
+const container2 = buildContainer(sources);
+postRenderMermaid(container2);
+setTimeout(() => setTimeout(() => {
+ process.stdout.write(JSON.stringify({
+ sizeAfterFirst: sizeAfterFirst,
+ cachedNorm: cachedNorm,
+ sizeAfterSecond: _mermaidNormalizeCache.size,
+ }));
+}, 0), 0);
+"""
+ )
+ out = _run_mermaid_scenario(scenario)
+ assert out["sizeAfterFirst"] == 1, "first call didn't populate normalize memo"
+ assert out["cachedNorm"] == 'A["label (with parens)"] --> B'
+ assert out["sizeAfterSecond"] == 1, (
+ "second call added a new entry — memo missed on identical source"
+ )
+
+
+def test_mermaid_normalize_memo_is_consulted_before_normalize() -> None:
+ """Pre-seed _mermaidNormalizeCache with a sentinel value for a
+ raw source. postRenderMermaid must use the sentinel rather than
+ re-running _normalizeMermaidSource. Catches a regression where
+ the memo gets populated but the lookup path is skipped."""
+ bad = "A[label (with parens)] --> B"
+ sentinel = "SENTINEL_FROM_MEMO --> X"
+ raw_js = json.dumps(bad)
+ sentinel_js = json.dumps(sentinel)
+ scenario = (
+ _build_mermaid_container_js([bad])
+ + _MERMAID_DRAIN_JS
+ + f"""
+_mermaidNormalizeCache.set({raw_js}, {sentinel_js});
+postRenderMermaid(container);
+setTimeout(() => setTimeout(() => {{
+ process.stdout.write(JSON.stringify({{
+ sourceAttr: container.children[0]._attrs['data-mermaid-source'],
+ }}));
+}}, 0), 0);
+"""
+ )
+ out = _run_mermaid_scenario(scenario)
+ assert out["sourceAttr"] == sentinel, (
+ "postRenderMermaid bypassed the normalize memo and re-ran normalize"
+ )
+
+
+def test_mermaid_normalize_memo_distinct_sources_cache_separately() -> None:
+ """Two distinct raw sources produce two memo entries. Confirms
+ the memo keys on raw textContent, not on something coarser like
+ container identity."""
+ bad1 = "A[label (with parens)] --> B"
+ bad2 = "C[other (label)] --> D"
+ scenario = (
+ _build_mermaid_container_js([bad1, bad2])
+ + _MERMAID_DRAIN_JS
+ + """
+postRenderMermaid(container);
+setTimeout(() => setTimeout(() => {
+ process.stdout.write(JSON.stringify({
+ size: _mermaidNormalizeCache.size,
+ hasBad1: _mermaidNormalizeCache.has(sources[0]),
+ hasBad2: _mermaidNormalizeCache.has(sources[1]),
+ }));
+}, 0), 0);
+"""
+ )
+ out = _run_mermaid_scenario(scenario)
+ assert out["size"] == 2
+ assert out["hasBad1"] is True
+ assert out["hasBad2"] is True
+
+
+# ---------------------------------------------------------------------------
+# Code-fence pairing — close requires \n / EOS, content can't cross
+# another close-pattern. Repros the streaming bug where ```mermaid +
+# later ```python were paired by the regex, handing mermaid a
+# truncated source.
+# ---------------------------------------------------------------------------
+
+
+def _render_md(source: str) -> str:
+ """Drive renderMarkdown against the JS harness and return the
+ rendered HTML. The function is a pure string transform; no DOM
+ container scaffolding is required."""
+ scenario = f"""
+const input = {json.dumps(source)};
+const output = renderMarkdown(input);
+process.stdout.write(JSON.stringify({{ output: output }}));
+"""
+ out = _run_mermaid_scenario(scenario)
+ return str(out["output"])
+
+
+_FENCE = "```"
+
+
+def test_fence_partial_open_emits_no_code_block() -> None:
+ """While a fence is still open and there's no other ``` later in
+ the buffer, no block is emitted — the open fence stays as
+ plain markdown text until the real close arrives."""
+ src = "Intro\n" + _FENCE + 'mermaid\nA["x"] -->|note (with parens)| B["y"]\nstill streaming'
+ html = _render_md(src)
+ assert " mid-stream: {html!r}"
+
+
+def test_fence_partial_with_later_open_does_not_pair_wrongly() -> None:
+ """Before the fence-pair fix: an unclosed ```mermaid followed by
+ a ```python (also unclosed) would have paired up as
+ ...python..., handing mermaid a
+ truncated source. With the new regex, neither fence emits a
+ block until its OWN closing line arrives."""
+ src = "Intro\n" + _FENCE + "mermaid\nA --> B\n" + _FENCE + 'python\nprint("hi")'
+ html = _render_md(src)
+ assert 'class="language-mermaid"' not in html, (
+ f"mermaid fence should not emit while open: {html!r}"
+ )
+ assert 'class="language-python"' not in html, (
+ f"python fence should not emit while open: {html!r}"
+ )
+
+
+def test_fence_close_paired_with_next_open_is_rejected() -> None:
+ """Repro of the live-streaming failure: mermaid fence open, then
+ ```python opens and ``` closes the python block. Without the
+ fix, the regex paired mermaid's open with python's *open* (or
+ backtracked all the way to python's close), producing
+ truncated. With the fix mermaid stays
+ open (content can't cross another \\1 run; close must be at line
+ boundary) and only python's pair matches."""
+ src = (
+ "Intro\n"
+ + _FENCE
+ + 'mermaid\nA["x"] -->|note (with parens)| B["y"]\n'
+ + _FENCE
+ + 'python\nprint("hi")\n'
+ + _FENCE
+ )
+ html = _render_md(src)
+ assert 'class="language-mermaid"' not in html, f"mermaid fence misparing reintroduced: {html!r}"
+ assert 'class="language-python"' in html, f"python fence on its own should match: {html!r}"
+
+
+def test_fence_closed_emits_code_block() -> None:
+ """Baseline: a properly closed fence with its close on its own
+ line emits the block as expected — the anchor doesn't
+ break the normal case."""
+ src = "Intro\n" + _FENCE + "python\nimport os\n" + _FENCE + "\nAfter"
+ html = _render_md(src)
+ assert 'class="language-python"' in html
+ assert "import os" in html
+
+
+def test_fence_close_at_end_of_buffer_emits() -> None:
+ """A fence that closes at the very end of the buffer (no trailing
+ newline) still emits — the anchor accepts end-of-string as a
+ valid line boundary, so the rehydration / static-render path
+ where the buffer ends cleanly at ``` still works."""
+ src = "Intro\n" + _FENCE + "python\nimport os\n" + _FENCE
+ html = _render_md(src)
+ assert 'class="language-python"' in html
+ assert "import os" in html
+
+
+def test_fence_close_with_trailing_whitespace_emits() -> None:
+ """A close followed only by spaces / tabs before \\n still counts
+ — CommonMark allows trailing whitespace on the close line."""
+ src = "Intro\n" + _FENCE + "python\nimport os\n" + _FENCE + " \nAfter"
+ html = _render_md(src)
+ assert 'class="language-python"' in html
+
+
+# ---------------------------------------------------------------------------
+# postRenderHljs — progressive syntax highlighting + source-keyed cache
+# ---------------------------------------------------------------------------
+
+
+def _build_hljs_container_js(blocks: list[tuple[str, str]]) -> str:
+ """Build a container with blocks.
+
+ ``blocks`` is a list of ``(language, source)`` tuples — the language
+ becomes the ``language-X`` class, the source becomes textContent."""
+ arr = "[" + ", ".join(f"[{json.dumps(lang)}, {json.dumps(src)}]" for lang, src in blocks) + "]"
+ return f"""
+function buildHljsContainer(blocks) {{
+ const container = document.createElement('div');
+ for (const [lang, src] of blocks) {{
+ const pre = document.createElement('pre');
+ const code = document.createElement('code');
+ code.classList.add('language-' + lang);
+ code.textContent = src;
+ pre.appendChild(code);
+ container.appendChild(pre);
+ }}
+ return container;
+}}
+const blocks = {arr};
+const container = buildHljsContainer(blocks);
+"""
+
+
+def test_hljs_cache_hit_skips_highlight_call() -> None:
+ """Two postRenderHljs calls on identical source must invoke
+ hljs.highlightElement exactly once — the second call hits the
+ cache and applies the stored markup synchronously. Mirrors the
+ mermaid SVG-cache invariant that lets streamingRender fire on
+ every rAF tick without re-tokenizing every code block."""
+ scenario = (
+ _build_hljs_container_js([("python", "import os")])
+ + """
+postRenderHljs(container);
+const container2 = buildHljsContainer(blocks);
+postRenderHljs(container2);
+process.stdout.write(JSON.stringify({
+ highlightCalls: hljsHighlightCallCount,
+ cacheSize: _hljsCache.size,
+ firstHtml: container.children[0].children[0]._innerHTML,
+ secondHtml: container2.children[0].children[0]._innerHTML,
+ secondHasHljsClass: container2.children[0].children[0]._classes.has('hljs'),
+}));
+"""
+ )
+ out = _run_mermaid_scenario(scenario)
+ assert out["highlightCalls"] == 1, (
+ "second postRenderHljs call invoked highlightElement — cache miss"
+ )
+ assert out["cacheSize"] == 1
+ assert out["firstHtml"] == out["secondHtml"]
+ assert out["secondHasHljsClass"] is True
+
+
+def test_hljs_distinct_sources_highlight_independently() -> None:
+ """Distinct sources each trigger one highlight and cache one entry.
+ Cache key includes the source string, not e.g. just the language."""
+ scenario = (
+ _build_hljs_container_js([("python", "import os"), ("python", "print('hi')")])
+ + """
+postRenderHljs(container);
+process.stdout.write(JSON.stringify({
+ highlightCalls: hljsHighlightCallCount,
+ cacheSize: _hljsCache.size,
+}));
+"""
+ )
+ out = _run_mermaid_scenario(scenario)
+ assert out["highlightCalls"] == 2
+ assert out["cacheSize"] == 2
+
+
+def test_hljs_cache_separates_by_language() -> None:
+ """Same source text under different language fences must NOT
+ collide in the cache — language is part of the key. Otherwise a
+ `python` block of `foo` and a `ruby` block of `foo` would share
+ a single (wrongly-highlighted) cache entry."""
+ scenario = (
+ _build_hljs_container_js([("python", "foo"), ("ruby", "foo")])
+ + """
+postRenderHljs(container);
+process.stdout.write(JSON.stringify({
+ highlightCalls: hljsHighlightCallCount,
+ cacheSize: _hljsCache.size,
+}));
+"""
+ )
+ out = _run_mermaid_scenario(scenario)
+ assert out["highlightCalls"] == 2
+ assert out["cacheSize"] == 2
+
+
+def test_hljs_skips_no_highlight_langs() -> None:
+ """language-mermaid / language-text / language-plaintext etc. must
+ get the `nohighlight` class without invoking hljs.highlightElement.
+ Highlighting plaintext or mermaid source would be both wasteful
+ and ugly."""
+ scenario = (
+ _build_hljs_container_js(
+ [("mermaid", "graph TD\\nA-->B"), ("text", "plain"), ("plaintext", "p")]
+ )
+ + """
+postRenderHljs(container);
+process.stdout.write(JSON.stringify({
+ highlightCalls: hljsHighlightCallCount,
+ cacheSize: _hljsCache.size,
+ mermaidNoHighlight: container.children[0].children[0]._classes.has('nohighlight'),
+ textNoHighlight: container.children[1].children[0]._classes.has('nohighlight'),
+ plaintextNoHighlight: container.children[2].children[0]._classes.has('nohighlight'),
+}));
+"""
+ )
+ out = _run_mermaid_scenario(scenario)
+ assert out["highlightCalls"] == 0
+ assert out["cacheSize"] == 0
+ assert out["mermaidNoHighlight"] is True
+ assert out["textNoHighlight"] is True
+ assert out["plaintextNoHighlight"] is True
+
+
+def test_hljs_terminal_lang_marks_pre_for_terminal_styling() -> None:
+ """Shell-family languages (bash / sh / zsh / console / terminal)
+ must add the `code-terminal` class to the parent , so the
+ stylesheet can give them the terminal look-and-feel."""
+ scenario = (
+ _build_hljs_container_js([("bash", "echo hi")])
+ + """
+postRenderHljs(container);
+process.stdout.write(JSON.stringify({
+ highlightCalls: hljsHighlightCallCount,
+ preHasTerminalClass: container.children[0]._classes.has('code-terminal'),
+}));
+"""
+ )
+ out = _run_mermaid_scenario(scenario)
+ assert out["highlightCalls"] == 1
+ assert out["preHasTerminalClass"] is True
+
+
+def test_hljs_cache_evicts_oldest_at_cap() -> None:
+ """FIFO eviction at _HLJS_CACHE_MAX. Mirrors the mermaid cache —
+ prevents unbounded growth on long sessions with many distinct
+ code blocks."""
+ scenario = """
+const cap = _HLJS_CACHE_MAX;
+for (let i = 0; i < cap + 5; i++) {
+ _cacheFifoEntry(_hljsCache, 'key-' + i, 'val-' + i, cap);
+}
+process.stdout.write(JSON.stringify({
+ size: _hljsCache.size,
+ hasOldest: _hljsCache.has('key-0'),
+ hasNewest: _hljsCache.has('key-' + (cap + 4)),
+}));
+"""
+ out = _run_mermaid_scenario(scenario)
+ assert out["size"] == 64
+ assert out["hasOldest"] is False
+ assert out["hasNewest"] is True
+
+
+def test_hljs_overwrite_does_not_evict() -> None:
+ """Overwriting an existing key is an in-place update, not a new
+ insertion — must not evict the oldest unrelated entry. Same
+ invariant as the mermaid cache."""
+ scenario = """
+const cap = _HLJS_CACHE_MAX;
+for (let i = 0; i < cap; i++) {
+ _cacheFifoEntry(_hljsCache, 'key-' + i, 'val-' + i, cap);
+}
+_cacheFifoEntry(_hljsCache, 'key-5', 'val-updated', cap);
+process.stdout.write(JSON.stringify({
+ size: _hljsCache.size,
+ hasOldest: _hljsCache.has('key-0'),
+ updated: _hljsCache.get('key-5'),
+}));
+"""
+ out = _run_mermaid_scenario(scenario)
+ assert out["size"] == 64
+ assert out["hasOldest"] is True, "overwrite evicted oldest unnecessarily"
+ assert out["updated"] == "val-updated"
+
+
+def test_post_render_markdown_invokes_hljs() -> None:
+ """postRenderMarkdown is the public end-of-stream entry point and
+ must still run syntax highlighting after the postRenderHljs
+ refactor — regression guard for the public API surface that
+ app.js / coordinator code already call."""
+ scenario = (
+ _build_hljs_container_js([("python", "import os")])
+ + """
+postRenderMarkdown(container);
+process.stdout.write(JSON.stringify({
+ highlightCalls: hljsHighlightCallCount,
+ hasHljsClass: container.children[0].children[0]._classes.has('hljs'),
+}));
+"""
+ )
+ out = _run_mermaid_scenario(scenario)
+ assert out["highlightCalls"] == 1
+ assert out["hasHljsClass"] is True
+
+
+def test_streaming_render_invokes_hljs() -> None:
+ """_streamingRenderApply must call postRenderHljs so closed code
+ fences appear progressively (syntax-highlighted) during streaming,
+ not only at stream_end via streamingRenderFinalize. The cache
+ keeps the per-tick cost down to a synchronous lookup."""
+ body = _RENDERER_JS.read_text(encoding="utf-8")
+ start = body.index("function _streamingRenderApply")
+ hljs_call = body.find("postRenderHljs(el)", start, start + 4000)
+ assert hljs_call != -1, (
+ "_streamingRenderApply must call postRenderHljs for progressive "
+ "syntax highlighting during streaming"
+ )
diff --git a/turnstone/shared_static/renderer.js b/turnstone/shared_static/renderer.js
index 299faaea..2a354f93 100644
--- a/turnstone/shared_static/renderer.js
+++ b/turnstone/shared_static/renderer.js
@@ -317,9 +317,34 @@ function renderMarkdown(text) {
// 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 — `(?!\1)`
+ // inside the content quantifier blocks the lazy matcher
+ // from extending across another N-backtick run. Without
+ // this, a buffer like ```mermaid\n 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");
+}
+
+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;
+ }
}
- var codeEls = containerEl.querySelectorAll("pre code[class*='language-']");
- for (var i = 0; i < codeEls.length; i++) {
- var el = codeEls[i];
- 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;
- }
- // Apply highlighting
+ // 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);
- // Add terminal styling class for shell languages
- if (_TERMINAL_LANGS[langClass]) {
- el.closest("pre").classList.add("code-terminal");
- }
+ _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");
}
}
+}
+
+function postRenderMarkdown(containerEl) {
+ postRenderHljs(containerEl);
// Render mermaid diagrams (lazy-loads mermaid.js on first use)
postRenderMermaid(containerEl);
}
@@ -826,6 +911,92 @@ function _getMermaidTheme() {
};
}
+// 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;
+ //
and
are part of Mermaid's allowed HTML in labels and
+ // don't on their own require quoting.
+ var stripped = content.replace(/
/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
@@ -845,16 +1016,24 @@ var _mermaidSvgCache = new Map();
var _mermaidErrorCache = new Map();
var _MERMAID_CACHE_MAX = 64;
-function _cacheMermaidEntry(cache, source, value) {
- // Only evict the oldest when inserting a new key — overwriting an
- // existing source is an in-place update and should not pay the
- // eviction cost (which would drop an unrelated cached entry).
- if (!cache.has(source) && cache.size >= _MERMAID_CACHE_MAX) {
- var firstKey = cache.keys().next().value;
- cache.delete(firstKey);
- }
- cache.set(source, value);
-}
+// 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;
@@ -921,10 +1100,12 @@ function _renderMermaidBlock(container, callback) {
var id = "mermaid-" + ++_mermaidIdCounter;
return mermaid.render(id, source).then(
function (result) {
- _cacheMermaidEntry(_mermaidSvgCache, source, {
- svg: result.svg,
- bindFunctions: result.bindFunctions,
- });
+ _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) {
@@ -936,7 +1117,7 @@ function _renderMermaidBlock(container, callback) {
var orphan = document.getElementById(id);
if (orphan) orphan.remove();
var msg = err && err.message ? err.message : "Diagram error";
- _cacheMermaidEntry(_mermaidErrorCache, source, msg);
+ _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);
@@ -962,7 +1143,20 @@ function postRenderMermaid(containerEl) {
for (var i = 0; i < codeEls.length; i++) {
var pre = codeEls[i].closest("pre");
if (!pre) continue;
- var source = codeEls[i].textContent;
+ // 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);
// Use cache.has (not truthiness) so a future cached value of
@@ -1022,11 +1216,12 @@ function reRenderAllMermaid() {
// 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). hljs syntax highlighting stays
-// deferred to streamingRenderFinalize, but mermaid runs inline on
-// every render so closed diagram fences appear progressively as they
-// complete (the source-keyed SVG cache makes re-renders cheap; only
-// the first encounter with a given source pays mermaid.render).
+// 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.
@@ -1036,11 +1231,14 @@ function _streamingRenderApply(el, buffer) {
el._lastRenderedBuffer = buffer;
var html = renderMarkdown(buffer);
el.innerHTML = html;
- // Progressive mermaid render — see comment above. postRenderMermaid
- // is no-op when the element has no language-mermaid code blocks,
- // and the source-keyed cache avoids re-invoking mermaid.render
- // for blocks we've already rendered. Subsequent rAF ticks that
+ // 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.
+ if (typeof postRenderHljs === "function") {
+ postRenderHljs(el);
+ }
if (typeof postRenderMermaid === "function") {
postRenderMermaid(el);
}