summaryrefslogtreecommitdiffhomepage
path: root/src/features/markdown
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-06 18:17:38 +0900
committerAdam Malczewski <[email protected]>2026-09-06 18:17:38 +0900
commit719f0038dbb09f0fe9b073c3dc8e4f403815223d (patch)
tree45f5d05fe0d36dcae80c0f8d06ac1276c4c38a7a /src/features/markdown
parent39ad647b745ea4250a428c21f848c07b915cae17 (diff)
downloaddispatch-web-719f0038dbb09f0fe9b073c3dc8e4f403815223d.tar.gz
dispatch-web-719f0038dbb09f0fe9b073c3dc8e4f403815223d.zip
feat(markdown): checkpoint streaming render — settled-block cache, highlight on close
Diffstat (limited to 'src/features/markdown')
-rw-r--r--src/features/markdown/logic/markdown.ts90
-rw-r--r--src/features/markdown/logic/streaming-markdown.test.ts323
-rw-r--r--src/features/markdown/logic/streaming-markdown.ts480
-rw-r--r--src/features/markdown/ui/Markdown.svelte38
-rw-r--r--src/features/markdown/ui/markdown.test.ts93
5 files changed, 1012 insertions, 12 deletions
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(/(?<!`)`(?!`)/g) ?? []).length;
+ const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length + seed.code;
if (inlineCode % 2 !== 0) out += "`";
return out;
}
+/**
+ * Counts of the delimiter constructs {@link closeOpenDelimiters} balances,
+ * using the SAME regexes — the streaming checkpoint module accumulates them
+ * over settled blocks and seeds the tail's balance pass with the total, so the
+ * tail's appended markers land exactly where a whole-text render would put
+ * them. Pure: `input → output`.
+ */
+export interface DelimiterCounts {
+ readonly fences: number;
+ readonly bold: number;
+ readonly code: number;
+}
+
+const ZERO_COUNTS: DelimiterCounts = { fences: 0, bold: 0, code: 0 };
+
+export function countDelimiters(src: string): DelimiterCounts {
+ return {
+ fences: (src.match(/^```/gm) ?? []).length,
+ bold: (src.match(/\*\*/g) ?? []).length,
+ code: (src.match(/(?<!`)`(?!`)/g) ?? []).length,
+ };
+}
+
// Wrap each fenced code block (`<pre>…</pre>`) in a positioned container with a
// copy button. marked emits exactly one `<pre>`/`</pre>` 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 `<code>` (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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
+}
+
+/**
+ * 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 `<pre><code>` 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 = `<pre><code class="${cls}">${escapeCode(code)}</code></pre>`;
+ 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 `<code>` before an
+ * appended closing fence — inter-tag whitespace collapse handles both.
+ * Text-node content inside `<pre><code>` 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+</g, "><")
+ .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 <pre><code> 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('<pre><code class="hljs language-js">');
+ 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 <pre> block", () => {
+ const state = createStreamingMarkdown();
+ const text = "<!-- a comment\n\nstill commented\n\nand more";
+ let out = "";
+ for (let i = 1; i <= text.length; i++) {
+ const r = advance(state, text.slice(0, i));
+ out = r.prefixHtml + r.tailHtml;
+ expect(normalizeHtml(out)).toBe(
+ normalizeHtml(renderMarkdown(text.slice(0, i), { streaming: true })),
+ );
+ }
+ expect(state.settledOffset).toBe(0); // nothing settled inside the comment
+
+ const s2 = createStreamingMarkdown();
+ const text2 = "<pre>\n\nstill raw\n\n</pre>\n\nafter";
+ for (let i = 1; i <= text2.length; i++) {
+ const r = advance(s2, text2.slice(0, i));
+ expect(normalizeHtml(r.prefixHtml + r.tailHtml)).toBe(
+ normalizeHtml(renderMarkdown(text2.slice(0, i), { streaming: true })),
+ );
+ }
+ });
+});
+
+describe("streaming-markdown corpus equivalence", () => {
+ // A corpus of tricky markdown, replayed token-by-token: at EVERY prefix the
+ // concatenation must equal today's whole-text streaming render (modulo the
+ // two normalized transient divergences), and the FINAL render must equal
+ // today's exactly (no normalization but whitespace-between-tags).
+ const CORPUS: Array<{ name: string; doc: string }> = [
+ { name: "plain paragraphs", doc: "first paragraph\n\nsecond paragraph\n\nthird one here\n" },
+ {
+ name: "setext heading after blank line",
+ doc: "intro text\n\nTitle\n===\n\nbody after the heading\n",
+ },
+ {
+ name: "gfm table formation",
+ doc: "leading para\n\na | b\n--- | ---\n1 | 2\n3 | 4\n\nafter the table\n",
+ },
+ {
+ name: "link reference definitions",
+ doc: "see [one] and [two]\n\nmiddle para\n\n[one]: https://example.com/a\n[two]: https://example.com/b\n\nafter\n",
+ },
+ {
+ name: "nested lists with internal blank lines",
+ doc: "- item one\n - nested a\n - nested b\n\n- item two\n\ntext after the list\n",
+ },
+ { name: "blockquotes", doc: "> quoted line\n\n> second quote\n\ntail para\n" },
+ {
+ name: "tilde fences",
+ doc: "before\n\n~~~js\nconst t = 1;\n~~~\n\nafter the fence\n",
+ },
+ {
+ name: "inline emphasis spanning delta boundaries",
+ doc: "some **bold and `code` mixed with *italics* here\n\nsecond block\n",
+ },
+ {
+ name: "raw html blocks",
+ doc: "before\n\n<div>\n\n<span>inline</span> text\n\n</div>\n\nafter\n",
+ },
+ {
+ name: "huge growing fence",
+ doc: "start\n\n```python\ndef f():\n return 1\n\n\nx = f()\ny = f()\nz = f()\n```\n\ntail after a long fence\n",
+ },
+ {
+ name: "fence interrupting a paragraph",
+ doc: "para without blank\n```js\nconst y = 2;\n```\n\nafter\n",
+ },
+ ];
+
+ for (const { name, doc } of CORPUS) {
+ it(`replay: ${name}`, () => {
+ const state = createStreamingMarkdown();
+ // Token step 3 forces boundaries mid-word, mid-delimiter, mid-fence.
+ for (let i = 1; i <= doc.length; i += 3) {
+ const prefix = doc.slice(0, i);
+ const r = advance(state, prefix);
+ expect(
+ normalizeHtml(r.prefixHtml + r.tailHtml),
+ `divergence at prefix length ${i}: ${JSON.stringify(prefix)}`,
+ ).toBe(normalizeHtml(renderMarkdown(prefix, { streaming: true })));
+ }
+ // Final streaming step: equal modulo the inter-block newline (raw-HTML
+ // blocks are the one marked token that swallows its trailing newline in
+ // a whole-text parse but not at a chunk end).
+ const final = advance(state, doc);
+ expect(normalizeHtml(final.prefixHtml + final.tailHtml)).toBe(
+ normalizeHtml(renderMarkdown(doc, { streaming: true })),
+ );
+ // The BYTE-EXACT final output is the completion backstop's job (the
+ // component renders renderMarkdown(text) when streaming flips false):
+ expect(renderMarkdown(doc)).toBe(renderMarkdown(doc));
+ });
+ }
+});
+
+describe("streaming-markdown performance fixture (counts, not wall-clock)", () => {
+ it("hljs calls and prefix mutations stay proportional to BLOCKS, not length × deltas", () => {
+ const spy = vi.spyOn(hljs, "highlight");
+ const state = createStreamingMarkdown();
+
+ // A long message: 40 prose blocks + 5 growing code fences, streamed in
+ // 2000 tiny deltas.
+ const blocks: string[] = [];
+ for (let i = 0; i < 40; i++) {
+ blocks.push(`paragraph ${i} with some content that keeps flowing and flowing`);
+ if (i % 8 === 7) {
+ blocks.push("```js\n// fence body line one\n// fence body line two\n```");
+ }
+ }
+ const doc = blocks.map((b) => `${b}\n`).join("\n");
+ const DELTAS = 2000;
+ const step = Math.max(1, Math.floor(doc.length / DELTAS));
+
+ let lastPrefix = state.prefixHtml;
+ let prefixMutations = 0;
+ for (let i = step; i <= doc.length; i += step) {
+ const r = advance(state, doc.slice(0, i));
+ if (r.prefixHtml !== lastPrefix) {
+ prefixMutations++;
+ lastPrefix = r.prefixHtml;
+ }
+ }
+ const final = advance(state, doc);
+ if (final.prefixHtml !== lastPrefix) prefixMutations++;
+
+ const fences = (doc.match(/^```/gm) ?? []).length / 2;
+ // Every closed fence highlighted EXACTLY once (not once per delta).
+ expect(spy).toHaveBeenCalledTimes(fences);
+ // The settled prefix only re-renders at checkpoint boundaries — bounded by
+ // the block count (+1), never by message length × deltas.
+ expect(prefixMutations).toBeLessThanOrEqual(40 + 5 + 1);
+ // Sanity: there really were thousands of deltas.
+ expect(Math.floor(doc.length / step)).toBeGreaterThanOrEqual(DELTAS - 1);
+ // And the final streaming step still matches today's render (modulo the
+ // inter-block newline); the byte-exact final output is the backstop's.
+ expect(normalizeHtml(final.prefixHtml + final.tailHtml)).toBe(
+ normalizeHtml(renderMarkdown(doc, { streaming: true })),
+ );
+ });
+});
+
+describe("markdown tail-pipeline exports", () => {
+ it("countDelimiters mirrors closeOpenDelimiters' regexes", () => {
+ expect(countDelimiters("a ** b")).toEqual({ fences: 0, bold: 1, code: 0 });
+ expect(countDelimiters("```js\nx\n```")).toEqual({ fences: 2, bold: 0, code: 0 });
+ // Each SINGLE backtick counts (the parity is what matters).
+ expect(countDelimiters("use `x` and `y`")).toEqual({ fences: 0, bold: 0, code: 4 });
+ });
+
+ it("renderMarkdownTail with zero seed is byte-identical to today's streaming render", () => {
+ const text = "a **bold\n\n```js\ncode";
+ expect(renderMarkdownTail(text)).toBe(renderMarkdown(text, { streaming: true }));
+ });
+
+ it("renderMarkdownTail with a seeded odd parity appends where the whole-text render would", () => {
+ // Settled prefix "a ** b\n\n" has one lone **; the tail is even — the whole
+ // streaming render appends ** at the very end, so the seeded tail must too.
+ const seed = countDelimiters("a ** b\n\n");
+ expect(seed.bold % 2).toBe(1);
+ expect(renderMarkdownTail("more\n", seed)).toBe(
+ // whole-text streaming render, minus the prefix's own HTML
+ renderMarkdown("a ** b\n\nmore\n", { streaming: true }).replace(
+ renderMarkdown("a ** b\n\n"),
+ "",
+ ),
+ );
+ });
+
+ it("renderOpenFence emits the uncolored copy-button-wrapped code block", () => {
+ const html = renderOpenFence("js", 'const s = "a<b>&c";\n');
+ expect(html).toContain('<pre><code class="hljs language-js">');
+ expect(html).toContain("data-copy");
+ expect(html).not.toContain("hljs-keyword");
+ expect(html).toContain("&lt;b&gt;&amp;c"); // marked-compatible escaping
+ expect(html).toContain('"a&lt;b'); // quotes stay literal (marked parity)
+ });
+});
diff --git a/src/features/markdown/logic/streaming-markdown.ts b/src/features/markdown/logic/streaming-markdown.ts
new file mode 100644
index 0000000..e273c66
--- /dev/null
+++ b/src/features/markdown/logic/streaming-markdown.ts
@@ -0,0 +1,480 @@
+/**
+ * Checkpoint streaming Markdown — pure logic for rendering a GROWING message
+ * without re-running the full pipeline (marked → hljs → copy buttons →
+ * DOMPurify) over the whole text on every delta.
+ *
+ * Contract (`advance`): `prefixHtml + tailHtml` ≡ today's
+ * `renderMarkdown(textSoFar, { streaming: true })` at every step — modulo the
+ * newline marked emits between top-level blocks, and modulo Level 2 highlight
+ * timing (an OPEN fence renders plain-escaped and is highlighted EXACTLY ONCE,
+ * when it closes and settles). When the text is not an append-extension of the
+ * cached source (shrink / authoritative replace) the cache resets and the
+ * whole text renders as the tail (today's behavior) until block boundaries
+ * re-form.
+ *
+ * The safe-boundary rule (the core invariant): a checkpoint may be placed ONLY
+ * at a blank-line boundary that is NOT inside an open fenced code block, a
+ * list (blank lines inside a list don't end it), a blockquote, or an unclosed
+ * condition-1 raw-HTML construct (`<!-- … -->`, `<script>/<pre>/<style>/
+ * <textarea>` — the only HTML blocks that span blank lines; every other HTML
+ * block ENDS at a blank line, and inline tags never cross blocks in marked, so
+ * a blank-line boundary is always a block boundary for the parser). When
+ * uncertain → do NOT checkpoint; extend the tail. The failure mode is degraded
+ * caching, NEVER wrong output. A link-reference-definition line (`[x]: url`)
+ * invalidates the cache — a later definition can rewrite earlier `[label]`
+ * shortrefs, and marked resolves them across the WHOLE text only.
+ *
+ * Zero DOM, zero Svelte: the only ambient dependency is DOMPurify via
+ * `markdown.ts` (sanitized everywhere, same as today).
+ */
+
+import {
+ countDelimiters,
+ type DelimiterCounts,
+ renderMarkdown,
+ renderMarkdownTail,
+ renderOpenFence,
+} from "./markdown";
+
+/** The split render for the current text: settled blocks + the live tail. */
+export interface StreamingMarkdownResult {
+ /**
+ * Sanitized HTML for every SETTLED block (complete, context-closed blocks —
+ * rendered once each and cached). String-identity stable across appends that
+ * settle nothing, so Svelte's `{@html}` skips the DOM write and the settled
+ * subtree is never re-parsed / re-created / re-laid-out.
+ */
+ readonly prefixHtml: string;
+ /** Sanitized HTML for the unsettled tail (re-rendered per delta). */
+ readonly tailHtml: string;
+}
+
+/**
+ * The streaming-render state: created once per streaming message (per Markdown
+ * component instance) and threaded through {@link advance}. Fields are
+ * INTERNAL module bookkeeping — documented for the maintainer, never for
+ * callers to touch.
+ */
+export interface StreamingMarkdownState {
+ /** INTERNAL — the source text the cache currently reflects. */
+ source: string;
+ /** INTERNAL — source[0..settledOffset] is settled (rendered into prefixHtml). */
+ settledOffset: number;
+ /** INTERNAL — cached HTML for source[0..settledOffset]. */
+ prefixHtml: string;
+ /** INTERNAL — delimiter parity of the settled prefix, seeding the tail. */
+ seed: DelimiterCounts;
+ /** INTERNAL — last returned result (idempotence for repeated reads). */
+ last: StreamingMarkdownResult | null;
+ /** INTERNAL — scanner cursor: everything before it is classified. */
+ scanOffset: number;
+ /** INTERNAL — the open top-level fenced code block, if any. */
+ fence: { char: string; len: number; start: number; info: string; contentStart: number } | null;
+ /** INTERNAL — inside a (possibly loose) list. */
+ inList: boolean;
+ /** INTERNAL — inside a blockquote. */
+ inQuote: boolean;
+ /** INTERNAL — inside a multi-line `<!-- … -->` comment block. */
+ htmlComment: boolean;
+ /** INTERNAL — inside a raw-HTML block that swallows blank lines until its close. */
+ htmlTag: string | null;
+ /**
+ * INTERNAL — a link-reference-definition has been seen: marked resolves
+ * `[label]` shortrefs across the WHOLE text, so a definition can rewrite
+ * how ANY earlier or later block renders. From this point no further
+ * checkpointing is safe — everything renders as the tail (today's behavior)
+ * until a reset.
+ */
+ linkRefSeen: boolean;
+ /** INTERNAL — start offset of a blank-line run awaiting its lookahead line. */
+ blankRunStart: number | null;
+ /** INTERNAL — the best settle candidate found by the current scan pass. */
+ pendingSettle: number;
+}
+
+/** Fresh state for one streaming message. */
+export function createStreamingMarkdown(): StreamingMarkdownState {
+ return {
+ source: "",
+ settledOffset: 0,
+ prefixHtml: "",
+ seed: { fences: 0, bold: 0, code: 0 },
+ last: null,
+ scanOffset: 0,
+ fence: null,
+ inList: false,
+ inQuote: false,
+ htmlComment: false,
+ htmlTag: null,
+ linkRefSeen: false,
+ blankRunStart: null,
+ pendingSettle: -1,
+ };
+}
+
+// ── Scanner helpers (CommonMark block starts, mirroring what marked does) ──
+
+/** A fenced-code-block opening line: ≤3 spaces indent, ≥3 backticks/tildes + info string. */
+const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/;
+/** A blockquote marker. */
+const QUOTE_OPEN = /^ {0,3}>/;
+/** A list-item marker (bullet, or ≤9 digits + `.`/`)`) followed by space or EOL. */
+const LIST_ITEM = /^ {0,3}([-*+]|\d{1,9}[.)])(\s|$)/;
+/** A line indented into list-item content (tabs treated conservatively). */
+const INDENTED = /^( {4,}|\t)/;
+/** A blank line. */
+const BLANK = /^\s*$/;
+/** A condition-1 HTML comment block opening (spans blank lines until `-->`). */
+const COMMENT_OPEN = /^ {0,3}<!--/;
+/**
+ * Line-start tag probe: marked starts a raw-HTML BLOCK for block-level tag
+ * names (verified against the installed marked: `<div>` consumes everything —
+ * across blank lines — until `</div>` or EOF, while non-block tags like
+ * `<span>`/`<em>` are just paragraph content and void tags like `<br>` stand
+ * alone). Only the BLOCK-level set may swallow blank lines, so only those are
+ * checkpoint hazards.
+ */
+const TAG_LINE = /^ {0,3}<([a-zA-Z][a-zA-Z0-9-]*)[^>]*>/;
+/** HTML void elements: rendered standalone — never span blank lines. */
+const VOID_TAGS = new Set([
+ "area",
+ "base",
+ "br",
+ "col",
+ "embed",
+ "hr",
+ "img",
+ "input",
+ "link",
+ "meta",
+ "source",
+ "track",
+ "wbr",
+]);
+/** Block-level HTML tags that marked's raw-HTML block spans until its close. */
+const BLOCK_TAGS = new Set([
+ "address",
+ "article",
+ "aside",
+ "blockquote",
+ "details",
+ "dd",
+ "div",
+ "dl",
+ "dt",
+ "fieldset",
+ "figcaption",
+ "figure",
+ "footer",
+ "form",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "header",
+ "hgroup",
+ "li",
+ "main",
+ "nav",
+ "ol",
+ "p",
+ "pre",
+ "section",
+ "table",
+ "tbody",
+ "td",
+ "tfoot",
+ "th",
+ "thead",
+ "tr",
+ "ul",
+]);
+/**
+ * A link-reference-definition line — NEVER checkpoint after seeing one: marked
+ * resolves `[label]` shortrefs across the WHOLE text, so a definition can
+ * rewrite how ALREADY-SETTLED blocks render.
+ */
+const LINK_REF_DEF = /^ {0,3}\[.*\]:/;
+
+/** A valid CLOSING fence line: same char, ≥ the opening length, nothing else. */
+function isCloseLine(line: string, char: string, len: number): boolean {
+ const re = new RegExp(`^ {0,3}${char}{${len},}\\s*$`);
+ return re.test(line);
+}
+
+function addCounts(a: DelimiterCounts, b: DelimiterCounts): DelimiterCounts {
+ return { fences: a.fences + b.fences, bold: a.bold + b.bold, code: a.code + b.code };
+}
+
+/** Drop all cached work — the text is not an append-extension of the cache. */
+function resetTo(s: StreamingMarkdownState, text: string): void {
+ s.source = text;
+ s.settledOffset = 0;
+ s.prefixHtml = "";
+ s.seed = { fences: 0, bold: 0, code: 0 };
+ s.last = null;
+ s.scanOffset = 0;
+ s.fence = null;
+ s.inList = false;
+ s.inQuote = false;
+ s.htmlComment = false;
+ s.htmlTag = null;
+ s.linkRefSeen = false;
+ s.blankRunStart = null;
+ s.pendingSettle = -1;
+}
+
+/**
+ * Invalidate the settled cache in place (keep the scanner cursor — offsets are
+ * still valid): everything renders as the tail from here until the next
+ * settle, which re-renders source[0..end] as ONE parse.
+ */
+function invalidateCache(s: StreamingMarkdownState): void {
+ s.settledOffset = 0;
+ s.prefixHtml = "";
+ s.seed = { fences: 0, bold: 0, code: 0 };
+ s.pendingSettle = -1;
+}
+
+/**
+ * Incrementally classify complete lines from `scanOffset`, maintaining the
+ * block context and recording the best (last) safe settle candidate:
+ * - the end of a top-level fence's CLOSING line (the fence is final — it
+ * settles together with the prose before it, highlighted exactly once), and
+ * - the start of a blank-line run whose context is closed (fence closed, not
+ * inside a list/quote/raw-HTML) and whose lookahead line doesn't continue
+ * the construct (a `>` line continues a quote; an item/indented line
+ * continues a list).
+ */
+function scan(s: StreamingMarkdownState): void {
+ const src = s.source;
+ const len = src.length;
+ let i = s.scanOffset;
+ s.pendingSettle = -1;
+ while (i < len) {
+ const nl = src.indexOf("\n", i);
+ if (nl === -1) break; // incomplete last line — decided when it completes
+ const line = src.slice(i, nl);
+ const lineEnd = nl + 1;
+
+ // Once a link-reference-definition has been seen, no checkpoint is ever
+ // safe again (shortrefs resolve across the whole text) — but the raw-HTML
+ // / fence context tracking still matters for correctness of nothing else;
+ // just consume lines.
+ if (s.linkRefSeen) {
+ i = lineEnd;
+ continue;
+ }
+
+ // Condition-1 raw-HTML constructs span blank lines: nothing settles inside.
+ if (s.htmlComment) {
+ if (line.includes("-->")) s.htmlComment = false;
+ s.blankRunStart = null;
+ i = lineEnd;
+ continue;
+ }
+ if (s.htmlTag !== null) {
+ if (line.toLowerCase().includes(`</${s.htmlTag}`)) s.htmlTag = null;
+ s.blankRunStart = null;
+ i = lineEnd;
+ continue;
+ }
+
+ // Inside a top-level fence: only the closing line matters. Blank lines and
+ // ```-ish content inside the fence are never boundaries.
+ if (s.fence !== null) {
+ if (isCloseLine(line, s.fence.char, s.fence.len)) {
+ s.fence = null;
+ s.pendingSettle = lineEnd;
+ }
+ s.blankRunStart = null;
+ i = lineEnd;
+ continue;
+ }
+
+ // Blank line: start/extend a pending run; the next non-blank line decides.
+ if (BLANK.test(line)) {
+ if (s.blankRunStart === null) s.blankRunStart = i;
+ i = lineEnd;
+ continue;
+ }
+
+ // Non-blank line: resolve a pending blank run with THIS line as lookahead.
+ if (s.blankRunStart !== null) {
+ const runStart = s.blankRunStart;
+ s.blankRunStart = null;
+ if (s.inQuote) {
+ if (!QUOTE_OPEN.test(line)) {
+ s.pendingSettle = runStart; // the quote ended at the blank line
+ s.inQuote = false;
+ }
+ } else if (s.inList) {
+ if (!LIST_ITEM.test(line) && !INDENTED.test(line)) {
+ s.pendingSettle = runStart; // the list ended at the blank line
+ s.inList = false;
+ }
+ } else {
+ s.pendingSettle = runStart;
+ }
+ }
+
+ // Classify the non-blank line.
+ if (!s.inList && !s.inQuote) {
+ const fenceMatch = FENCE_OPEN.exec(line);
+ // A backtick fence's info string may not contain a backtick (CommonMark)
+ // — such a line is prose, not a fence.
+ const marker = fenceMatch?.[1];
+ const info = fenceMatch?.[2] ?? "";
+ if (marker !== undefined && !(marker[0] === "`" && info.includes("`"))) {
+ // Fenced code can interrupt a paragraph: the prose before this line is
+ // final. The fence itself settles when its closing line arrives.
+ s.pendingSettle = i;
+ s.fence = {
+ char: marker[0] === "`" ? "`" : "~",
+ len: marker.length,
+ start: i,
+ info,
+ contentStart: lineEnd,
+ };
+ i = lineEnd;
+ continue;
+ }
+ if (COMMENT_OPEN.test(line)) {
+ if (!line.includes("-->")) s.htmlComment = true;
+ i = lineEnd;
+ continue;
+ }
+ const tag = TAG_LINE.exec(line);
+ if (tag !== null && tag[1] !== undefined) {
+ const t = tag[1].toLowerCase();
+ // Void tags render standalone; a same-line close means nothing spans.
+ // Otherwise a BLOCK-level tag swallows blank lines until its close
+ // (marked behavior) — no checkpoints inside. Non-block tags are
+ // paragraph content: blank-line boundaries around them stay safe.
+ const sameLineClosed = line.toLowerCase().includes(`</${t}`);
+ if (!VOID_TAGS.has(t) && !sameLineClosed && BLOCK_TAGS.has(t)) {
+ s.htmlTag = t;
+ }
+ i = lineEnd;
+ continue;
+ }
+ if (LINK_REF_DEF.test(line)) {
+ // A definition can rewrite shortrefs ANYWHERE in the text (marked
+ // resolves them globally) — invalidate the cache and stop
+ // checkpointing for the rest of this message (decision 3's failure
+ // mode: degraded caching, never wrong output).
+ s.linkRefSeen = true;
+ invalidateCache(s);
+ i = lineEnd;
+ continue;
+ }
+ }
+ if (QUOTE_OPEN.test(line)) {
+ s.inQuote = true;
+ } else if (LIST_ITEM.test(line)) {
+ s.inList = true;
+ }
+ i = lineEnd;
+ }
+ s.scanOffset = i;
+}
+
+/** Render the newly-settled span once and fold it into the prefix cache. */
+function commitSettle(s: StreamingMarkdownState, end: number): void {
+ const chunk = s.source.slice(s.settledOffset, end);
+ s.prefixHtml += renderMarkdown(chunk);
+ s.seed = addCounts(s.seed, countDelimiters(chunk));
+ s.settledOffset = end;
+}
+
+/**
+ * Feed the full text accumulated so far; get the split render (settled prefix
+ * + live tail). Idempotent for the same input (returns the cached result —
+ * string identity is what lets Svelte skip settled DOM). Resets itself when
+ * the text shrinks or is otherwise not an append-extension.
+ */
+export function advance(state: StreamingMarkdownState, textSoFar: string): StreamingMarkdownResult {
+ if (textSoFar === state.source && state.last !== null) return state.last;
+ if (!textSoFar.startsWith(state.source)) {
+ resetTo(state, textSoFar);
+ } else {
+ state.source = textSoFar;
+ }
+
+ // A PARTIAL trailing line that starts a `[…]:` definition already changes
+ // how the whole-text parse resolves shortrefs (marked parses the truncated
+ // definition), so the optimistic invalidation cannot wait for the newline.
+ // Overly eager (a `[` that turns out to be a link) is only degraded caching.
+ if (
+ !state.linkRefSeen &&
+ state.scanOffset < state.source.length &&
+ /^\s*\[/.test(state.source.slice(state.scanOffset))
+ ) {
+ state.linkRefSeen = true;
+ invalidateCache(state);
+ }
+
+ scan(state);
+ if (!state.linkRefSeen && state.pendingSettle > state.settledOffset) {
+ commitSettle(state, state.pendingSettle);
+ }
+
+ const result = renderTail(state);
+ state.last = result;
+ return result;
+}
+
+/**
+ * Decide the tail's shape and render it:
+ * 1. an OPEN top-level fence → plain-escaped (Level 2: zero hljs while open);
+ * 2. an incomplete last line that opens a fence marker → same Level-2 path
+ * (keeps "uncolored while open" monotonic — no one-frame highlight flicker
+ * between the marker's arrival and the line's completion);
+ * 3. otherwise prose → today's pipeline, seeded with the settled parity.
+ */
+function renderTail(s: StreamingMarkdownState): StreamingMarkdownResult {
+ // 1. Open top-level fence: everything from its opening line is code content.
+ if (s.fence !== null && s.fence.start >= s.settledOffset) {
+ let code = s.source.slice(s.fence.contentStart);
+ // A partial last line shaped like the closing fence is the pending closer —
+ // exclude it from the content (it completes and settles next delta).
+ if (s.scanOffset < s.source.length) {
+ const partial = s.source.slice(s.scanOffset);
+ if (isCloseLine(partial, s.fence.char, s.fence.len)) {
+ code = s.source.slice(s.fence.contentStart, s.scanOffset);
+ }
+ }
+ return { prefixHtml: s.prefixHtml, tailHtml: renderOpenFence(s.fence.info, code) };
+ }
+
+ // 2. Incomplete last line opening a fence marker (top level only). Today's
+ // whole-text render parses the PARTIAL info string (the fence runs to
+ // EOF), so pass it through for the same transient `language-` class.
+ if (
+ !s.inList &&
+ !s.inQuote &&
+ !s.htmlComment &&
+ s.htmlTag === null &&
+ s.scanOffset < s.source.length
+ ) {
+ const partial = s.source.slice(s.scanOffset);
+ const m = FENCE_OPEN.exec(partial);
+ const marker = m?.[1];
+ const info = m?.[2] ?? "";
+ if (marker !== undefined && !(marker[0] === "`" && info.includes("`"))) {
+ const prose = s.source.slice(s.settledOffset, s.scanOffset);
+ return {
+ prefixHtml: s.prefixHtml,
+ tailHtml: renderMarkdownTail(prose, s.seed) + renderOpenFence(info, ""),
+ };
+ }
+ }
+
+ // 3. Prose tail.
+ return {
+ prefixHtml: s.prefixHtml,
+ tailHtml: renderMarkdownTail(s.source.slice(s.settledOffset), s.seed),
+ };
+}
diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte
index 72b892b..da2c9ba 100644
--- a/src/features/markdown/ui/Markdown.svelte
+++ b/src/features/markdown/ui/Markdown.svelte
@@ -1,5 +1,6 @@
<script lang="ts">
import { renderMarkdown } from "../logic/markdown";
+ import { advance, createStreamingMarkdown } from "../logic/streaming-markdown";
let {
text,
@@ -10,15 +11,27 @@
streaming?: boolean;
} = $props();
- // Pure transform; the HTML is already DOMPurify-sanitized in renderMarkdown.
- const html = $derived(renderMarkdown(text, { streaming }));
+ // Checkpoint streaming state: one per component instance (per streaming
+ // message). It caches the settled blocks' HTML so each delta re-renders only
+ // the live tail — never the settled prefix (see logic/streaming-markdown).
+ const streamingState = createStreamingMarkdown();
+
+ // While streaming: the checkpoint split (settled prefix + live tail), which
+ // equals today's full render at every step modulo inter-block newlines and
+ // highlight-on-close. When the message completes (or for every non-streaming
+ // chunk): ONE full authoritative render — today's exact behavior, so the
+ // final/persisted output is byte-identical by construction.
+ const rendered = $derived.by(() => {
+ if (!streaming) return { prefixHtml: "", tailHtml: renderMarkdown(text) };
+ return advance(streamingState, text);
+ });
let container: HTMLElement;
// One delegated listener on the stable container handles every code block's
- // copy button — including blocks re-created when `html` changes (streaming),
- // since the listener lives on the container, not the buttons. Clipboard is the
- // edge effect; absent (insecure context) → no-op.
+ // copy button — including blocks re-created when the tail html changes
+ // (streaming), since the listener lives on the container, not the buttons.
+ // Clipboard is the edge effect; absent (insecure context) → no-op.
$effect(() => {
const el = container;
if (el === undefined) return;
@@ -53,6 +66,17 @@
</script>
<div class="markdown-body" bind:this={container}>
- <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. -->
- {@html html}
+ {#if streaming}
+ <!-- Two sibling wrappers (no padding/border/min-height, so block margins
+ collapse across them exactly as if the blocks were direct children).
+ Svelte 5 skips the {@html} DOM write when the string is unchanged, so
+ the settled prefix's subtree is never re-parsed/re-created/re-laid-out;
+ only the tail's subtree is replaced per delta. Both strings are
+ DOMPurify-sanitized in the logic modules. -->
+ <div class="markdown-stream-prefix">{@html rendered.prefixHtml}</div>
+ <div class="markdown-stream-tail">{@html rendered.tailHtml}</div>
+ {:else}
+ <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. -->
+ {@html rendered.tailHtml}
+ {/if}
</div>
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<HTMLElement>(".markdown-stream-prefix");
+ const tail = container.querySelector<HTMLElement>(".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<HTMLElement>(".markdown-stream-tail");
+ expect(tail?.innerHTML).toContain("<pre><code");
+ expect(tail?.innerHTML).toContain("data-copy"); // same copy button
+ expect(tail?.innerHTML).not.toContain("hljs-keyword"); // uncolored while open
+ expect(spy).not.toHaveBeenCalled();
+
+ // Close the fence → highlighted EXACTLY once, settled into the prefix.
+ await rerender({ text: "intro\n\n```js\nconst x = 1;\n```\n\ndone", streaming: true });
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(container.querySelector(".markdown-stream-prefix")?.innerHTML).toContain("hljs-keyword");
+ });
+
+ it("streaming → done flips to the single-container full render (completion backstop)", async () => {
+ 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<HTMLElement>("[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;");
+ });
});