From 719f0038dbb09f0fe9b073c3dc8e4f403815223d Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sun, 6 Sep 2026 18:17:38 +0900 Subject: feat(markdown): checkpoint streaming render — settled-block cache, highlight on close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/markdown/logic/markdown.ts | 90 +++- .../markdown/logic/streaming-markdown.test.ts | 323 ++++++++++++++ src/features/markdown/logic/streaming-markdown.ts | 480 +++++++++++++++++++++ src/features/markdown/ui/Markdown.svelte | 38 +- src/features/markdown/ui/markdown.test.ts | 93 ++++ 5 files changed, 1012 insertions(+), 12 deletions(-) create mode 100644 src/features/markdown/logic/streaming-markdown.test.ts create mode 100644 src/features/markdown/logic/streaming-markdown.ts diff --git a/src/features/markdown/logic/markdown.ts b/src/features/markdown/logic/markdown.ts index ad8a8bd..626b4da 100644 --- a/src/features/markdown/logic/markdown.ts +++ b/src/features/markdown/logic/markdown.ts @@ -125,19 +125,50 @@ const md = new Marked( /** * While a message is still streaming, balance dangling fences / emphasis so the - * partial text renders cleanly instead of flashing raw markers. + * partial text renders cleanly instead of flashing raw markers. `seed` carries + * the delimiter counts of the ALREADY-SETTLED prefix (see + * `logic/streaming-markdown`): the full-text streaming render appends markers + * at the very END of the whole source, so a tail rendered alone must count the + * prefix's parity to append in exactly the same place (or not) — otherwise a + * settled paragraph with a lone `**` would transiently render differently. */ -function closeOpenDelimiters(src: string): string { +function closeOpenDelimiters( + src: string, + seed: DelimiterCounts = { fences: 0, bold: 0, code: 0 }, +): string { let out = src; - const fenceCount = (out.match(/^```/gm) ?? []).length; + const fenceCount = (out.match(/^```/gm) ?? []).length + seed.fences; if (fenceCount % 2 !== 0) out += "\n```"; - const boldCount = (out.match(/\*\*/g) ?? []).length; + const boldCount = (out.match(/\*\*/g) ?? []).length + seed.bold; if (boldCount % 2 !== 0) out += "**"; - const inlineCode = (out.match(/(?…`) in a positioned container with a // copy button. marked emits exactly one `
`/`
` pair per block and // escapes `<`/`>` inside code, so these literal tags only ever delimit blocks. @@ -163,3 +194,52 @@ export function renderMarkdown(text: string, opts?: { streaming?: boolean }): st return ""; } } + +/** + * The streaming TAIL pipeline — today's exact pipeline for the UNSETTLED tail + * of a streaming message (`closeOpenDelimiters` → marked → copy buttons → + * DOMPurify), with the delimiter parity SEEDED from the settled prefix (see + * {@link countDelimiters}). With a zero seed this is byte-identical to + * `renderMarkdown(text, { streaming: true })`, so the checkpoint module's + * degenerate case (nothing settled yet) is exactly today's behavior. + * Returns `""` if parsing ever throws. + */ +export function renderMarkdownTail(text: string, seed: DelimiterCounts = ZERO_COUNTS): string { + const src = closeOpenDelimiters(text, seed); + try { + const raw = md.parse(src) as string; + return DOMPurify.sanitize(addCopyButtons(raw)); + } catch { + return ""; + } +} + +/** + * Escapes code content the way marked does inside `` (only `&`, `<`, + * `>` — quotes stay literal) so the plain open-fence render below is + * character-identical to what the closed-fence pipeline would emit. + */ +function escapeCode(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +/** + * Plain (UN-highlighted) rendering of an OPEN fenced code block — the Level-2 + * streaming behavior: while a fence is still open its content is final-format + * escaped `
` with NO hljs (hljs state spans lines — an unterminated
+ * string/block comment would recolor earlier lines, so line-incremental
+ * highlighting is unsound). The block is highlighted exactly ONCE, when it
+ * closes and settles into the checkpoint cache. Emits the same copy-button
+ * wrapper + `hljs language-X` classes as the closed pipeline (modulo color
+ * spans), so the pop-in on close is "just colour".
+ */
+export function renderOpenFence(info: string, code: string): string {
+  const lang = info.trim().split(/\s+/)[0] ?? "";
+  const cls = lang === "" ? "hljs" : `hljs language-${escapeHtml(lang)}`;
+  const html = `
${escapeCode(code)}
`; + try { + return DOMPurify.sanitize(addCopyButtons(html)); + } catch { + return ""; + } +} diff --git a/src/features/markdown/logic/streaming-markdown.test.ts b/src/features/markdown/logic/streaming-markdown.test.ts new file mode 100644 index 0000000..f810579 --- /dev/null +++ b/src/features/markdown/logic/streaming-markdown.test.ts @@ -0,0 +1,323 @@ +import hljs from "highlight.js/lib/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { countDelimiters, renderMarkdown, renderMarkdownTail, renderOpenFence } from "./markdown"; +import { advance, createStreamingMarkdown } from "./streaming-markdown"; + +/** + * Test-side normalizer for the equivalence contract. The checkpoint render may + * legitimately differ from today's whole-text streaming render in exactly two + * transient ways while streaming: + * 1. Level 2: an OPEN fence renders plain-escaped (no hljs spans) — unwrapping + * hljs spans on both sides makes them comparable ("just colour" is the + * accepted trade; the closed render is byte-identical). + * 2. Newlines BETWEEN top-level blocks (each rendered unit emits its own + * trailing newline) and marked's trailing newline inside `` before an + * appended closing fence — inter-tag whitespace collapse handles both. + * Text-node content inside `
` is untouched (`>…<` never matches
+ * escaped code content), so code bytes are still compared exactly.
+ */
+function normalizeHtml(html: string): string {
+  const host = document.createElement("div");
+  host.innerHTML = html;
+  for (const span of [...host.querySelectorAll("span")]) {
+    if ([...span.classList].some((c) => c.startsWith("hljs"))) {
+      span.replaceWith(...span.childNodes);
+    }
+  }
+  return host.innerHTML
+    .replace(/>\s+<")
+    .replace(/\n+(?=<\/code>)/g, "")
+    .trim();
+}
+
+describe("streaming-markdown", () => {
+  beforeEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it("degenerate case (nothing settled) is exactly today's streaming render", () => {
+    const state = createStreamingMarkdown();
+    const text = "Some **bold** and `code` text";
+    const { prefixHtml, tailHtml } = advance(state, text);
+    expect(prefixHtml).toBe("");
+    expect(tailHtml).toBe(renderMarkdown(text, { streaming: true }));
+  });
+
+  it("settles a paragraph at a blank-line boundary and matches the full render", () => {
+    const state = createStreamingMarkdown();
+    // The settle commits only once the boundary's lookahead line completes.
+    advance(state, "first para\n\nnext");
+    expect(state.prefixHtml).toBe("");
+    advance(state, "first para\n\nnext\n");
+    // The settle point is the START of the blank run (the separator blanks
+    // lead the tail, where marked ignores them — the rendered HTML is the
+    // same either way).
+    expect(state.prefixHtml).toBe(renderMarkdown("first para\n"));
+    expect(state.settledOffset).toBe("first para\n".length);
+    // Equivalence against the whole-text render.
+    const { prefixHtml, tailHtml } = advance(state, "first para\n\nnext\n");
+    expect(normalizeHtml(prefixHtml + tailHtml)).toBe(
+      normalizeHtml(renderMarkdown("first para\n\nnext\n", { streaming: true })),
+    );
+  });
+
+  it("matches the full render while inside an open list (no checkpoints — degraded caching, never wrong)", () => {
+    const state = createStreamingMarkdown();
+    const text = "- item a\n\n- item b\n\n- item c";
+    let shown = "";
+    for (let i = 1; i <= text.length; i++) {
+      const prefix = text.slice(0, i);
+      const r = advance(state, prefix);
+      shown = r.prefixHtml + r.tailHtml;
+      expect(normalizeHtml(shown)).toBe(normalizeHtml(renderMarkdown(prefix, { streaming: true })));
+    }
+  });
+
+  it("highlight-exactly-once: zero hljs while a fence is open, one call when it closes", () => {
+    const spy = vi.spyOn(hljs, "highlight");
+    const state = createStreamingMarkdown();
+
+    advance(state, "intro\n\n```js\nc");
+    expect(spy).not.toHaveBeenCalled(); // open fence → plain escaped, no hljs
+
+    advance(state, "intro\n\n```js\nconst x = 1;\n");
+    expect(spy).not.toHaveBeenCalled(); // still open
+
+    const closed = advance(state, "intro\n\n```js\nconst x = 1;\n```\n");
+    expect(spy).toHaveBeenCalledTimes(1); // highlighted EXACTLY once, on close
+    expect(closed.prefixHtml).toContain("hljs-keyword"); // the settled fence is colored
+    // The open-fence tail is gone (nothing but prose after the fence).
+    expect(closed.tailHtml).toBe(renderMarkdownTail("", state.seed));
+
+    // Subsequent deltas do NOT re-highlight the settled block.
+    advance(state, "intro\n\n```js\nconst x = 1;\n```\nafter\n");
+    advance(state, "intro\n\n```js\nconst x = 1;\n```\nafter\nmore\n");
+    expect(spy).toHaveBeenCalledTimes(1);
+  });
+
+  it("open fence renders plain-escaped 
 with the same classes/wrapper as the closed pipeline", () => {
+    const state = createStreamingMarkdown();
+    const { prefixHtml, tailHtml } = advance(state, "intro\n\n```js\nconst x = 1;");
+    expect(prefixHtml).toContain("intro");
+    expect(tailHtml).toContain('
');
+    expect(tailHtml).toContain("data-copy"); // same copy-button wrapper
+    expect(tailHtml).not.toContain("hljs-keyword"); // uncolored while open
+    expect(tailHtml).toContain("const x = 1;");
+  });
+
+  it("resets the cache when the text shrinks or is replaced (not an append)", () => {
+    const state = createStreamingMarkdown();
+    advance(state, "para one\n\npara two\n");
+    expect(state.prefixHtml).not.toBe("");
+    const shrunk = advance(state, "para one\n\npara");
+    expect(state.settledOffset).toBe(0);
+    expect(state.prefixHtml).toBe("");
+    expect(shrunk.tailHtml).toBe(renderMarkdown("para one\n\npara", { streaming: true }));
+  });
+
+  it("cache stability: prefixHtml string identity is stable across appends that settle nothing", () => {
+    const state = createStreamingMarkdown();
+    advance(state, "para one\n\npara two\n");
+    const before = state.prefixHtml;
+    advance(state, "para one\n\npara two\ntail");
+    advance(state, "para one\n\npara two\ntail more");
+    expect(state.prefixHtml).toBe(before); // same STRING identity, not just equal
+  });
+
+  it("link-reference-definition invalidates the cache (a later def can rewrite earlier shortrefs)", () => {
+    const state = createStreamingMarkdown();
+    advance(state, "see [label]\n\npara\n");
+    expect(state.prefixHtml).not.toBe("");
+    advance(state, "see [label]\n\npara\n\n[label]: https://example.com\n");
+    // The definition line was scanned: the cache dropped, everything re-renders
+    // through the tail (whole-text parse resolves the shortref).
+    expect(state.settledOffset).toBe(0);
+    expect(state.prefixHtml).toBe("");
+    const r = advance(state, "see [label]\n\npara\n\n[label]: https://example.com\n\nafter\n");
+    expect(normalizeHtml(r.prefixHtml + r.tailHtml)).toBe(
+      normalizeHtml(
+        renderMarkdown("see [label]\n\npara\n\n[label]: https://example.com\n\nafter\n", {
+          streaming: true,
+        }),
+      ),
+    );
+  });
+
+  it("does not checkpoint inside an unclosed HTML comment or 
 block", () => {
+    const state = createStreamingMarkdown();
+    const text = "`, `
 
 
- - {@html html} + {#if streaming} + +
{@html rendered.prefixHtml}
+
{@html rendered.tailHtml}
+ {:else} + + {@html rendered.tailHtml} + {/if}
diff --git a/src/features/markdown/ui/markdown.test.ts b/src/features/markdown/ui/markdown.test.ts index d65b3d1..721dddb 100644 --- a/src/features/markdown/ui/markdown.test.ts +++ b/src/features/markdown/ui/markdown.test.ts @@ -1,4 +1,5 @@ import { fireEvent, render, screen } from "@testing-library/svelte"; +import hljs from "highlight.js/lib/core"; import { describe, expect, it, vi } from "vitest"; import Markdown from "./Markdown.svelte"; @@ -37,4 +38,96 @@ describe("Markdown", () => { expect(writeText).toHaveBeenCalledTimes(1); expect(writeText.mock.calls[0]?.[0]).toContain("const x = 1;"); }); + + it("non-streaming renders a single container (today's full-render path)", () => { + const { container } = render(Markdown, { props: { text: "# Hello" } }); + expect(container.querySelector(".markdown-stream-prefix")).toBeNull(); + expect(container.querySelector(".markdown-stream-tail")).toBeNull(); + // The rendered blocks are DIRECT children of .markdown-body (no wrappers). + const body = container.querySelector(".markdown-body"); + expect(body?.querySelector("h1")).not.toBeNull(); + expect(body?.children[0]?.tagName).toBe("H1"); + }); + + it("streaming renders the prefix/tail split with content matching the full render", () => { + const { container } = render(Markdown, { + props: { text: "# Hello\n\nsettled paragraph\n\ngrowing tail", streaming: true }, + }); + + const prefix = container.querySelector(".markdown-stream-prefix"); + const tail = container.querySelector(".markdown-stream-tail"); + expect(prefix).not.toBeNull(); + expect(tail).not.toBeNull(); + // Same visible content as today's render, split across the two containers. + expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument(); + expect(container.textContent).toContain("growing tail"); + expect(prefix?.querySelector("h1")).not.toBeNull(); + }); + + it("streaming: the settled prefix's html string identity is stable across text appends (no DOM churn)", async () => { + const spy = vi.spyOn(hljs, "highlight"); + const { container, rerender } = render(Markdown, { + props: { text: "intro\n\n```js\nconst x = 1;\n```\n\nafter", streaming: true }, + }); + + const prefixBefore = container.querySelector(".markdown-stream-prefix")?.innerHTML; + expect(prefixBefore).toContain("hljs-keyword"); // the closed fence settled, highlighted once + expect(spy).toHaveBeenCalledTimes(1); + + // Appends that settle nothing leave the prefix string (and its DOM) alone. + await rerender({ text: "intro\n\n```js\nconst x = 1;\n```\n\nafter and", streaming: true }); + await rerender({ + text: "intro\n\n```js\nconst x = 1;\n```\n\nafter and more", + streaming: true, + }); + expect(container.querySelector(".markdown-stream-prefix")?.innerHTML).toBe(prefixBefore); + expect(spy).toHaveBeenCalledTimes(1); // still exactly one highlight + }); + + it("streaming: an open fence renders uncolored, then highlights exactly once when it closes", async () => { + const spy = vi.spyOn(hljs, "highlight"); + const { container, rerender } = render(Markdown, { + props: { text: "intro\n\n```js\nconst x", streaming: true }, + }); + + const tail = container.querySelector(".markdown-stream-tail"); + expect(tail?.innerHTML).toContain("
 {
+    const { container, rerender } = render(Markdown, {
+      props: { text: "# Hello\n\nbody", streaming: true },
+    });
+    expect(container.querySelector(".markdown-stream-prefix")).not.toBeNull();
+
+    await rerender({ text: "# Hello\n\nbody", streaming: false });
+    expect(container.querySelector(".markdown-stream-prefix")).toBeNull();
+    expect(container.querySelector(".markdown-stream-tail")).toBeNull();
+    expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument();
+  });
+
+  it("streaming: the delegated copy-button listener still works for tail code blocks", async () => {
+    const writeText = vi.fn().mockResolvedValue(undefined);
+    Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
+
+    const { container } = render(Markdown, {
+      props: { text: "para\n\n```js\nconst y = 2;", streaming: true },
+    });
+
+    const button = container.querySelector("[data-copy]");
+    expect(button).not.toBeNull();
+    if (button === null) throw new Error("expected a copy button");
+    await fireEvent.click(button);
+
+    expect(writeText).toHaveBeenCalledTimes(1);
+    expect(writeText.mock.calls[0]?.[0]).toContain("const y = 2;");
+  });
 });
-- 
cgit v1.2.3