brookmd 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.js CHANGED
@@ -15,6 +15,7 @@ import { CodeBlock } from "./renderers/CodeBlock.js";
15
15
  import { MathBlock } from "./renderers/Math.js";
16
16
  import { Mermaid } from "./renderers/Mermaid.js";
17
17
  import { htmlToReact } from "./html-to-react.js";
18
+ import { useHtmlSplice } from "./react-splice.js";
18
19
  import { warnOnce } from "./warn.js";
19
20
  const NO_DEFER_BLOCKS = [];
20
21
  const EMPTY_KEYS = [];
@@ -53,6 +54,7 @@ function BrookMarkdownFromClient({
53
54
  sanitize,
54
55
  childMemo,
55
56
  streamingHighlight,
57
+ __fullRebuild,
56
58
  className,
57
59
  id,
58
60
  role,
@@ -106,6 +108,7 @@ function BrookMarkdownFromClient({
106
108
  sanitize,
107
109
  childMemo,
108
110
  streamingHighlight,
111
+ __fullRebuild,
109
112
  onRenderMetrics: onMetrics,
110
113
  decorators,
111
114
  urlTransform,
@@ -314,6 +317,18 @@ function SafeHtml({
314
317
  return htmlToReact(html, components, map, opts);
315
318
  }, [html, components, childMemo, decorators, urlTransform]);
316
319
  }
320
+ function SplicedBlock({ className, block }) {
321
+ const host = useRef(null);
322
+ const seedHtml = useHtmlSplice(host, block, true);
323
+ return /* @__PURE__ */ jsx(
324
+ "div",
325
+ {
326
+ className,
327
+ ref: host,
328
+ dangerouslySetInnerHTML: { __html: seedHtml ?? block.html }
329
+ }
330
+ );
331
+ }
317
332
  function KeyedListItemImpl({
318
333
  html,
319
334
  components,
@@ -489,6 +504,7 @@ function renderBlockContent({
489
504
  sanitize,
490
505
  childMemo,
491
506
  streamingHighlight,
507
+ __fullRebuild,
492
508
  decorators,
493
509
  urlTransform
494
510
  }) {
@@ -519,7 +535,9 @@ function renderBlockContent({
519
535
  html: block.html,
520
536
  open: block.open,
521
537
  code: typeof source === "string" ? source : void 0,
522
- streamingHighlight
538
+ streamingHighlight,
539
+ block,
540
+ __fullRebuild
523
541
  }
524
542
  );
525
543
  }
@@ -555,13 +573,13 @@ function renderBlockContent({
555
573
  );
556
574
  }
557
575
  }
558
- if (components || hasInlineTransforms) {
559
- if (components && !hasInlineTransforms && block.open && !sanitize && (kind === "Blockquote" || kind === "Alert")) {
560
- const nested = block.kind.data?.nested;
561
- if (Array.isArray(nested)) {
562
- return /* @__PURE__ */ jsx("div", { className, children: /* @__PURE__ */ jsx(KeyedContainer, { block, nested, components }) });
563
- }
576
+ if (block.open && !sanitize && !hasInlineTransforms && (kind === "Blockquote" || kind === "Alert")) {
577
+ const nested = block.kind.data?.nested;
578
+ if (Array.isArray(nested)) {
579
+ return /* @__PURE__ */ jsx("div", { className, children: /* @__PURE__ */ jsx(KeyedContainer, { block, nested, components: components ?? NO_COMPONENTS }) });
564
580
  }
581
+ }
582
+ if (components || hasInlineTransforms) {
565
583
  const safe = sanitize ? sanitize(block.html) : block.html;
566
584
  return /* @__PURE__ */ jsx("div", { className, children: /* @__PURE__ */ jsx(
567
585
  SafeHtml,
@@ -574,6 +592,9 @@ function renderBlockContent({
574
592
  }
575
593
  ) });
576
594
  }
595
+ if (block.open && !sanitize && !__fullRebuild) {
596
+ return /* @__PURE__ */ jsx(SplicedBlock, { className, block });
597
+ }
577
598
  return /* @__PURE__ */ jsx(
578
599
  "div",
579
600
  {
@@ -584,7 +605,7 @@ function renderBlockContent({
584
605
  }
585
606
  function blocksEqual(prev, next) {
586
607
  if (prev.block == null || next.block == null) return prev.block === next.block;
587
- return prev.block.id === next.block.id && prev.block.html === next.block.html && prev.block.open === next.block.open && prev.block.speculative === next.block.speculative && prev.components === next.components && prev.virtualize === next.virtualize && prev.sanitize === next.sanitize && prev.childMemo === next.childMemo && prev.streamingHighlight === next.streamingHighlight && prev.onRenderMetrics === next.onRenderMetrics && // Identity compare: an unstable decorators/urlTransform (fresh each render)
608
+ return prev.block.id === next.block.id && prev.block.html === next.block.html && prev.block.open === next.block.open && prev.block.speculative === next.block.speculative && prev.components === next.components && prev.virtualize === next.virtualize && prev.sanitize === next.sanitize && prev.childMemo === next.childMemo && prev.streamingHighlight === next.streamingHighlight && prev.__fullRebuild === next.__fullRebuild && prev.onRenderMetrics === next.onRenderMetrics && // Identity compare: an unstable decorators/urlTransform (fresh each render)
588
609
  // busts the memo so every committed block re-decorates — the O(n²) footgun
589
610
  // the dev warning calls out. A hoisted/memoized value keeps the memo holding.
590
611
  prev.decorators === next.decorators && prev.urlTransform === next.urlTransform && // Same identity rule as onRenderMetrics: an inline `onBlockError={() => …}`
@@ -1,3 +1,4 @@
1
+ import type { Block } from "../types-core.js";
1
2
  interface Props {
2
3
  html: string;
3
4
  open: boolean;
@@ -10,7 +11,18 @@ interface Props {
10
11
  code?: string;
11
12
  /** Highlight the block while it is still open. Default true. */
12
13
  streamingHighlight?: boolean;
14
+ /**
15
+ * The block this markup came from, when the renderer is driven by the stream.
16
+ * Only used to apply the wire's `html_delta` to the PLAIN escaped body of an
17
+ * open fence (the `streamingHighlight: false` / no-language path) instead of
18
+ * re-setting its whole innerHTML each patch. Absent → that body rebuilds, as
19
+ * it always did.
20
+ */
21
+ block?: Block;
22
+ /** @internal TEST-ONLY: force the pre-mirror path (a full `innerHTML` set of
23
+ * the whole markup on every patch) so the parity fuzz has a reference. */
24
+ __fullRebuild?: boolean;
13
25
  }
14
- declare function CodeBlockImpl({ html, open, code, streamingHighlight }: Props): import("react/jsx-runtime").JSX.Element;
26
+ declare function CodeBlockImpl({ html, open, code, streamingHighlight, block, __fullRebuild }: Props): import("react/jsx-runtime").JSX.Element;
15
27
  export declare const CodeBlock: import("react").MemoExoticComponent<typeof CodeBlockImpl>;
16
28
  export {};
@@ -1,15 +1,18 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
- import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
3
  import { highlight } from "../hi.js";
4
4
  import { highlightDeferred, highlightWithin } from "../hi-defer.js";
5
5
  import { createInc, incHighlight, incSeed } from "../hi-inc.js";
6
+ import { newIncCode, paintIncCode } from "../splice.js";
7
+ import { useHtmlSplice } from "../react-splice.js";
6
8
  import { extractLang } from "../block-props.js";
7
9
  function decodeText(html) {
8
10
  const m = html.match(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
9
11
  if (!m) return "";
10
12
  return m[1].replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
11
13
  }
12
- function CodeBlockImpl({ html, open, code, streamingHighlight }) {
14
+ const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
15
+ function CodeBlockImpl({ html, open, code, streamingHighlight, block, __fullRebuild }) {
13
16
  const lang = extractLang(html) || "text";
14
17
  const text = useMemo(() => open ? "" : code ?? decodeText(html), [html, open, code]);
15
18
  const streaming = open && streamingHighlight !== false;
@@ -19,6 +22,9 @@ function CodeBlockImpl({ html, open, code, streamingHighlight }) {
19
22
  );
20
23
  const incRef = useRef(null);
21
24
  const [inc, setInc] = useState(null);
25
+ const codeRef = useRef(null);
26
+ const mirrorRef = useRef(null);
27
+ const plainRef = useRef(null);
22
28
  const sync = useMemo(() => {
23
29
  if (!text) return null;
24
30
  if (typeof window === "undefined") return highlight(text, lang);
@@ -62,10 +68,30 @@ function CodeBlockImpl({ html, open, code, streamingHighlight }) {
62
68
  run.cancel();
63
69
  };
64
70
  }, [text, lang, sync]);
65
- const highlighted = sync ?? (slow !== null && slow.text === text && slow.lang === lang ? slow.html : null) ?? // The streaming tail. Not gated on `openText` identity: the markup lags the
66
- // props by one commit, and showing last patch's spans beats flashing the
67
- // whole block back to plain every tick. A language change does invalidate it.
68
- (streaming && inc !== null && inc.lang === lang ? inc.html : null);
71
+ const settled = sync ?? (slow !== null && slow.text === text && slow.lang === lang ? slow.html : null);
72
+ const streamed = streaming && inc !== null && inc.lang === lang ? inc.html : null;
73
+ const highlighted = settled ?? streamed;
74
+ const mirrored = settled === null && streamed !== null && !__fullRebuild;
75
+ useIsoLayoutEffect(() => {
76
+ if (!mirrored) {
77
+ mirrorRef.current = null;
78
+ return;
79
+ }
80
+ const node = codeRef.current;
81
+ const st = incRef.current;
82
+ if (node === null || st === null || streamed === null) return;
83
+ let m = mirrorRef.current;
84
+ if (m === null || m.code !== node || m.lang !== lang) {
85
+ node.innerHTML = "";
86
+ m = newIncCode(node, lang, st);
87
+ mirrorRef.current = m;
88
+ }
89
+ if (!paintIncCode(m, st, streamed)) {
90
+ node.innerHTML = streamed;
91
+ mirrorRef.current = null;
92
+ }
93
+ });
94
+ const plainSeed = useHtmlSplice(plainRef, block, open && highlighted === null && !__fullRebuild);
69
95
  const [copied, setCopied] = useState(false);
70
96
  const timerRef = useRef(null);
71
97
  useEffect(() => {
@@ -117,8 +143,25 @@ function CodeBlockImpl({ html, open, code, streamingHighlight }) {
117
143
  /* @__PURE__ */ jsx("div", { className: "brook-code-body", children: highlighted ? (
118
144
  // tabIndex=0 + role/label so keyboard users can scroll long code and
119
145
  // screen readers announce the region with its language.
120
- /* @__PURE__ */ jsx("pre", { tabIndex: 0, role: "region", "aria-label": `${lang} code`, children: /* @__PURE__ */ jsx("code", { dangerouslySetInnerHTML: { __html: highlighted } }) })
121
- ) : /* @__PURE__ */ jsx("div", { tabIndex: 0, role: "region", "aria-label": `${lang} code`, dangerouslySetInnerHTML: { __html: html } }) })
146
+ /* @__PURE__ */ jsx("pre", { tabIndex: 0, role: "region", "aria-label": `${lang} code`, children: mirrored ? (
147
+ // Rendered with NO children and NO dangerouslySetInnerHTML, so
148
+ // React never writes into it; the layout effect above owns it.
149
+ // Same element type and position as the settled form below, so
150
+ // the close-time swap updates this node in place rather than
151
+ // remounting it — and React's own innerHTML write at that point
152
+ // is what discards the mirror's nodes.
153
+ /* @__PURE__ */ jsx("code", { ref: codeRef })
154
+ ) : /* @__PURE__ */ jsx("code", { dangerouslySetInnerHTML: { __html: highlighted } }) })
155
+ ) : /* @__PURE__ */ jsx(
156
+ "div",
157
+ {
158
+ tabIndex: 0,
159
+ role: "region",
160
+ "aria-label": `${lang} code`,
161
+ ref: plainRef,
162
+ dangerouslySetInnerHTML: { __html: plainSeed ?? html }
163
+ }
164
+ ) })
122
165
  ] });
123
166
  }
124
167
  const CodeBlock = memo(CodeBlockImpl);
package/dist/server.js CHANGED
@@ -54,6 +54,7 @@ function makeParser(config) {
54
54
  p.setBlockHtml(config?.blockHtml ?? false);
55
55
  p.setAllowSchemes(config?.allowSchemes ?? []);
56
56
  p.setBlockData(config?.blockData ?? false);
57
+ p.setRetainCommittedHtml(true);
57
58
  return p;
58
59
  }
59
60
  function requireReady() {
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Incremental DOM application for a streaming block — the two shapes of
3
+ * "apply this patch without rewriting everything before it".
4
+ *
5
+ * 1. {@link paintIncCode} mirrors hi-inc's frozen-prefix / speculative-tail split
6
+ * into an open code fence's live `<code>`.
7
+ * 2. {@link spliceHtml} applies the wire's `html_delta` to a generic block's
8
+ * subtree, guided by {@link spliceKeep}.
9
+ *
10
+ * Both are shared by the DOM and React renderers so the invariants below have
11
+ * exactly one implementation.
12
+ *
13
+ * ## The generic splice
14
+ *
15
+ * A streaming block's html grows at its END: the core appends bytes and then
16
+ * SPECULATIVELY CLOSES whatever is open, so patch N's html is patch N+1's html
17
+ * with a different run of closing tags stitched on. The wire already computes
18
+ * and verifies that boundary (`html_delta.keep_units`, WIRE.md §11), and
19
+ * `applyPatch` publishes it to renderers as {@link spliceKeep}. What is left is
20
+ * to apply it to the DOM without re-parsing everything before it.
21
+ *
22
+ * ## Why "top-level children" is not enough
23
+ *
24
+ * A block's html is usually ONE top-level element — `<p>…</p>`, `<ul>…</ul>`,
25
+ * `<blockquote>…</blockquote>` — so splicing at that level degenerates to a full
26
+ * rebuild. The growth point is at the bottom of the chain of elements still OPEN
27
+ * at the splice offset, and that is where this splices: it walks down that chain
28
+ * in the live DOM, appends the new markup in the right context, and never
29
+ * touches a node before the boundary. Everything earlier — including a user's
30
+ * text selection and a `<pre>`'s scroll offset — survives untouched.
31
+ *
32
+ * ## The precondition, and why it is the honest one
33
+ *
34
+ * The old html's discarded suffix (`prevHtml.slice(keep)`) must be **pure
35
+ * structure**: closing tags and inter-tag whitespace, nothing that contributed
36
+ * real content. That is exactly the speculative-closure shape, and it is what
37
+ * makes "the DOM built from `prevHtml[0, keep)`" recoverable from the live tree
38
+ * by removing a bounded amount of trailing whitespace. Anything else — a link
39
+ * losing its `data-brook-pending` attribute, a literal `**b` becoming
40
+ * `<strong>b</strong>` — rewrites bytes the old DOM already committed to, and
41
+ * this bails so the caller rebuilds. Correctness never depends on the fast path
42
+ * firing; every check below returns `false` rather than guessing.
43
+ *
44
+ * The result is byte-identical to `host.innerHTML = nextHtml` when serialized.
45
+ * The node COUNT can differ (a splice may leave two adjacent text nodes where a
46
+ * one-shot parse makes one), which is what any streaming DOM append does and
47
+ * what `innerHTML` parity is checked against.
48
+ */
49
+ import type { IncState } from "./hi-inc.js";
50
+ import type { Block } from "./types-core.js";
51
+ /** @internal Called by `applyPatch` for every delta-reconstructed active block. */
52
+ export declare function noteSplice(next: Block, prev: Block, keep: number): void;
53
+ /**
54
+ * The longest common prefix, in UTF-16 units, that `from.html` and `to.html`
55
+ * provably share — or `undefined` when the wire did not establish one (delta
56
+ * mode off, a full re-emit, or `from` is further back than {@link SPLICE_DEPTH}).
57
+ *
58
+ * The value is the MINIMUM `keep_units` across the patches between them: each
59
+ * one guarantees its own prefix, so their minimum is a prefix of all of them.
60
+ * That is conservative — it can be shorter than the true common prefix — and
61
+ * never wrong, which is the right side to err on when a caller splices at it.
62
+ *
63
+ * @internal Renderer-only; not part of the public API.
64
+ */
65
+ export declare function spliceKeep(from: Block, to: Block): number | undefined;
66
+ /**
67
+ * The live `<code>` of an OPEN code block, split the way hi-inc splits its
68
+ * markup: a **frozen** run of children (proven immutable — appended once and
69
+ * never touched again) followed by a **speculative tail** (rewritten per patch,
70
+ * bounded by hi-inc's CAP).
71
+ *
72
+ * The two regions are NOT wrapped in elements — `frozenEnd` is simply the last
73
+ * child that belongs to the frozen run — so the resulting `innerHTML` is
74
+ * byte-identical to the `code.innerHTML = markup` this replaces. Only the node
75
+ * *count* differs (a splice can leave two adjacent text nodes where a one-shot
76
+ * parse would have made one), which serializes the same and is exactly what a
77
+ * browser does for any streamed append.
78
+ */
79
+ export interface IncCode {
80
+ code: Element;
81
+ /** The language the mirror was built for; a change invalidates it. */
82
+ lang: string;
83
+ /** Last child of the frozen run — everything after it is the tail. */
84
+ frozenEnd: ChildNode | null;
85
+ /** Chars of `IncState.frozenHtml` already mirrored into the DOM. */
86
+ frozenLen: number;
87
+ /** The `IncState.frozenRev` that `frozenLen` belongs to. */
88
+ frozenRev: number;
89
+ /** The boundary BEFORE `frozenEnd`, and the length that went with it — one
90
+ * step of history mirroring hi-inc's own `c0`/`frozenLen0`, which is exactly
91
+ * how far hi-inc's `adopt` can rewind. Without it a rewind would have to re-seed
92
+ * the whole run, and 18 of those over a 20 KB fence cost more than everything
93
+ * else on the streaming path combined. */
94
+ frozenEnd0: ChildNode | null;
95
+ frozenLen0: number;
96
+ /** The tail markup currently in the DOM, so an unchanged tail is not rewritten. */
97
+ tail: string;
98
+ }
99
+ /** A fresh, empty mirror for a `<code>` that has nothing painted into it yet. */
100
+ export declare function newIncCode(code: Element, lang: string, st: IncState): IncCode;
101
+ /**
102
+ * Mirror hi-inc's frozen/tail split into a live `<code>`: append whatever the
103
+ * frozen prefix settled since the last patch, then replace the speculative
104
+ * tail. Returns false when the mirror cannot be trusted (see the length
105
+ * invariant below) so the caller falls back to a full node rebuild.
106
+ *
107
+ * Cost per patch is |newly frozen| + |tail|. The frozen term sums, across the
108
+ * whole stream, to one pass over the final markup; the tail is bounded by
109
+ * hi-inc's CAP. That is what makes an open fence linear at the DOM, not just
110
+ * at the tokenizer.
111
+ */
112
+ export declare function paintIncCode(ic: IncCode, st: IncState, markup: string): boolean;
113
+ /**
114
+ * Apply `prevHtml → nextHtml` to `host`, whose `innerHTML` is exactly
115
+ * `prevHtml`, given the wire-verified common-prefix length `keep`. Returns
116
+ * `false` (having changed NOTHING) when the shape is not one it can prove; the
117
+ * caller then rebuilds as it always did.
118
+ */
119
+ export declare function spliceHtml(host: Element, prevHtml: string, nextHtml: string, keep: number): boolean;
120
+ /** @internal Test-only. */
121
+ export declare function __spliceStats(): {
122
+ attempts: number;
123
+ hits: number;
124
+ };
125
+ /** @internal Test-only. */
126
+ export declare function __resetSpliceStats(): void;
package/dist/splice.js ADDED
@@ -0,0 +1,212 @@
1
+ const SPLICE = /* @__PURE__ */ new WeakMap();
2
+ const SPLICE_DEPTH = 8;
3
+ function noteSplice(next, prev, keep) {
4
+ let old = prev;
5
+ for (let d = 1; d < SPLICE_DEPTH && old !== void 0; d++) old = SPLICE.get(old)?.prev;
6
+ if (old !== void 0) SPLICE.delete(old);
7
+ SPLICE.set(next, { prev, keep });
8
+ }
9
+ function spliceKeep(from, to) {
10
+ let keep = Infinity;
11
+ let cur = to;
12
+ for (let i = 0; i < SPLICE_DEPTH; i++) {
13
+ const link = SPLICE.get(cur);
14
+ if (link === void 0) return void 0;
15
+ if (link.keep < keep) keep = link.keep;
16
+ if (link.prev === from) return keep;
17
+ cur = link.prev;
18
+ }
19
+ return void 0;
20
+ }
21
+ function newIncCode(code, lang, st) {
22
+ return {
23
+ code,
24
+ lang,
25
+ frozenEnd: null,
26
+ frozenLen: 0,
27
+ frozenRev: st.frozenRev,
28
+ frozenEnd0: null,
29
+ frozenLen0: 0,
30
+ tail: ""
31
+ };
32
+ }
33
+ function paintIncCode(ic, st, markup) {
34
+ const frozen = st.frozenHtml;
35
+ if (markup.length < frozen.length) return false;
36
+ let rewound = ic.frozenRev !== st.frozenRev || frozen.length < ic.frozenLen;
37
+ const tail = markup.slice(frozen.length);
38
+ if (!rewound && frozen.length === ic.frozenLen && tail === ic.tail) return true;
39
+ if (rewound && st.frozenRev === ic.frozenRev + 1 && st.frozenCut === ic.frozenLen0) {
40
+ ic.frozenEnd = ic.frozenEnd0;
41
+ ic.frozenLen = ic.frozenLen0;
42
+ ic.frozenRev = st.frozenRev;
43
+ ic.frozenEnd0 = null;
44
+ ic.frozenLen0 = 0;
45
+ rewound = false;
46
+ }
47
+ const keep = rewound ? null : ic.frozenEnd;
48
+ while (ic.code.lastChild !== keep) ic.code.removeChild(ic.code.lastChild);
49
+ if (rewound) {
50
+ ic.frozenEnd = null;
51
+ ic.frozenLen = 0;
52
+ ic.frozenEnd0 = null;
53
+ ic.frozenLen0 = 0;
54
+ ic.frozenRev = st.frozenRev;
55
+ }
56
+ if (frozen.length > ic.frozenLen) {
57
+ ic.frozenEnd0 = ic.frozenEnd;
58
+ ic.frozenLen0 = ic.frozenLen;
59
+ ic.code.insertAdjacentHTML("beforeend", frozen.slice(ic.frozenLen));
60
+ ic.frozenEnd = ic.code.lastChild;
61
+ ic.frozenLen = frozen.length;
62
+ }
63
+ if (tail) ic.code.insertAdjacentHTML("beforeend", tail);
64
+ ic.tail = tail;
65
+ return true;
66
+ }
67
+ const UNSAFE_CHAIN = /* @__PURE__ */ new Set([
68
+ // Content models the fragment parser treats specially (raw text, escapable
69
+ // raw text, foreign content, or a separate document fragment).
70
+ "template",
71
+ "svg",
72
+ "math",
73
+ "script",
74
+ "style",
75
+ "textarea",
76
+ "title",
77
+ "noscript",
78
+ "noframes",
79
+ "iframe",
80
+ "xmp",
81
+ "plaintext",
82
+ "listing",
83
+ // Foster parenting relocates non-table content out of these, so a scaffold
84
+ // parse would not place the appended nodes where a whole parse does.
85
+ "table",
86
+ "thead",
87
+ "tbody",
88
+ "tfoot",
89
+ "tr",
90
+ "select",
91
+ "optgroup"
92
+ ]);
93
+ const UNSAFE_TIP = /* @__PURE__ */ new Set(["pre", "listing", "textarea"]);
94
+ const CLOSE_TAG_NAME = /^[a-zA-Z][a-zA-Z0-9-]*$/;
95
+ function scanTail(t) {
96
+ const ops = [];
97
+ let i = 0;
98
+ let closes = 0;
99
+ while (i < t.length) {
100
+ if (t.charCodeAt(i) === 60) {
101
+ if (t.charCodeAt(i + 1) !== 47) return null;
102
+ const gt = t.indexOf(">", i + 2);
103
+ if (gt === -1) return null;
104
+ const name = t.slice(i + 2, gt);
105
+ if (!CLOSE_TAG_NAME.test(name)) return null;
106
+ ops.push({ close: name.toLowerCase() });
107
+ closes++;
108
+ i = gt + 1;
109
+ continue;
110
+ }
111
+ let j = i;
112
+ while (j < t.length && t.charCodeAt(j) !== 60) j++;
113
+ const run = t.slice(i, j);
114
+ if (/\S/.test(run)) return null;
115
+ ops.push({ ws: run });
116
+ i = j;
117
+ }
118
+ return closes > 0 ? ops : null;
119
+ }
120
+ function spliceHtml(host, prevHtml, nextHtml, keep) {
121
+ attempts++;
122
+ if (keep <= 0 || keep >= prevHtml.length || keep > nextHtml.length) return false;
123
+ const ops = scanTail(prevHtml.slice(keep));
124
+ if (ops === null) return false;
125
+ const closes = [];
126
+ for (const op of ops) if ("close" in op) closes.push(op.close);
127
+ const n = closes.length;
128
+ const chain = new Array(n + 1);
129
+ chain[0] = host;
130
+ for (let d2 = 1; d2 <= n; d2++) {
131
+ const want = closes[n - d2];
132
+ if (UNSAFE_CHAIN.has(want)) return false;
133
+ if (d2 === n && UNSAFE_TIP.has(want)) return false;
134
+ const el = chain[d2 - 1].lastElementChild;
135
+ if (el === null || el.tagName.toLowerCase() !== want) return false;
136
+ chain[d2] = el;
137
+ }
138
+ const strips = [];
139
+ const ws = new Array(n + 1);
140
+ let d = n;
141
+ for (const op of ops) {
142
+ if ("close" in op) {
143
+ d--;
144
+ continue;
145
+ }
146
+ if (ws[d] !== void 0) return false;
147
+ ws[d] = op.ws;
148
+ }
149
+ if (d !== 0) return false;
150
+ for (let i = 0; i <= n; i++) {
151
+ const w = ws[i];
152
+ const last = chain[i].lastChild;
153
+ if (w === void 0 || w === "") {
154
+ if (i < n && last !== chain[i + 1]) return false;
155
+ continue;
156
+ }
157
+ if (last === null || last.nodeType !== 3) return false;
158
+ const data = last.nodeValue ?? "";
159
+ if (i < n) {
160
+ if (data !== w || last.previousSibling !== chain[i + 1]) return false;
161
+ } else if (!data.endsWith(w)) {
162
+ return false;
163
+ }
164
+ strips.push({ text: last, ws: w });
165
+ }
166
+ let scaffold = "";
167
+ for (let i = 1; i <= n; i++) scaffold += `<${chain[i].tagName.toLowerCase()}>`;
168
+ const tmp = host.ownerDocument.createElement("div");
169
+ tmp.innerHTML = scaffold + nextHtml.slice(keep);
170
+ const sc = new Array(n + 1);
171
+ sc[0] = tmp;
172
+ for (let i = 1; i <= n; i++) {
173
+ const el = sc[i - 1].firstChild;
174
+ if (el === null || el.nodeType !== 1) return false;
175
+ const e = el;
176
+ if (e.tagName !== chain[i].tagName) return false;
177
+ sc[i] = e;
178
+ }
179
+ for (const strip of strips) {
180
+ const data = strip.text.nodeValue ?? "";
181
+ if (data.length === strip.ws.length) strip.text.parentNode?.removeChild(strip.text);
182
+ else strip.text.nodeValue = data.slice(0, data.length - strip.ws.length);
183
+ }
184
+ for (let i = n; i >= 0; i--) {
185
+ let node = i === n ? sc[i].firstChild : sc[i + 1].nextSibling;
186
+ while (node !== null) {
187
+ const next = node.nextSibling;
188
+ chain[i].appendChild(node);
189
+ node = next;
190
+ }
191
+ }
192
+ hits++;
193
+ return true;
194
+ }
195
+ let attempts = 0;
196
+ let hits = 0;
197
+ function __spliceStats() {
198
+ return { attempts, hits };
199
+ }
200
+ function __resetSpliceStats() {
201
+ attempts = 0;
202
+ hits = 0;
203
+ }
204
+ export {
205
+ __resetSpliceStats,
206
+ __spliceStats,
207
+ newIncCode,
208
+ noteSplice,
209
+ paintIncCode,
210
+ spliceHtml,
211
+ spliceKeep
212
+ };
@@ -513,6 +513,22 @@ export interface ParserConfig {
513
513
  * serde bytes; output and the `kind` serde shape stay byte-identical when off).
514
514
  */
515
515
  blockData?: boolean;
516
+ /**
517
+ * Keep every committed block's rendered HTML retained **inside the parser**.
518
+ *
519
+ * Defaults to **false on the streaming path** (the worker), which is what you
520
+ * want: the client receives each committed block exactly once, in the patch
521
+ * that commits it, and stores it itself — so a second copy sitting in WASM for
522
+ * the life of the stream serves nobody. Dropping it roughly halves a long
523
+ * stream's retained bytes (`onPatch`'s `retainedBytes`); the wire is
524
+ * byte-identical either way.
525
+ *
526
+ * Set `true` only if you need the parser itself to still hold the whole
527
+ * rendered document. The server one-shot renderers (`renderToString`,
528
+ * `parseToBlocks`) read the document back out of the parser and therefore pin
529
+ * this on regardless of what you pass.
530
+ */
531
+ retainCommittedHtml?: boolean;
516
532
  }
517
533
  export type ToWorker = {
518
534
  type: "append";
@@ -139,6 +139,18 @@ export class BrookParser {
139
139
  * markers all stay strictly conformant.
140
140
  */
141
141
  setLenientLists(on: boolean): void;
142
+ /**
143
+ * Keep every committed block's rendered HTML retained inside the parser for
144
+ * `allBlocks()`. ON by default. Turn it OFF in a pure STREAMING consumer —
145
+ * one that reads each committed block exactly once out of its patch and
146
+ * never calls `allBlocks()` (the npm worker): the HTML is then released the
147
+ * moment the block is emitted, so a long stream retains the source buffer
148
+ * plus the open tail instead of the whole rendered document (`retainedBytes`
149
+ * reflects it). Patch bytes are identical either way; the only cost is that
150
+ * `allBlocks()` then reports committed blocks with an EMPTY `html` (id,
151
+ * kind, start, end, open, speculative all stay exact).
152
+ */
153
+ setRetainCommittedHtml(on: boolean): void;
142
154
  /**
143
155
  * Render a CommonMark SOFT line break (a bare `\n` in inline content) as a
144
156
  * `<br>` — the `remark-breaks` convention, where one Enter is one visual
@@ -188,6 +200,7 @@ export interface InitOutput {
188
200
  readonly brookparser_setHtmlSanitize: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
189
201
  readonly brookparser_setInlineComponentTags: (a: number, b: number, c: number) => void;
190
202
  readonly brookparser_setLenientLists: (a: number, b: number) => void;
203
+ readonly brookparser_setRetainCommittedHtml: (a: number, b: number) => void;
191
204
  readonly brookparser_setSoftBreaks: (a: number, b: number) => void;
192
205
  readonly brookparser_setUnsafeHtml: (a: number, b: number) => void;
193
206
  readonly brookparser_setWireDelta: (a: number, b: number) => void;
@@ -290,6 +290,21 @@ export class BrookParser {
290
290
  setLenientLists(on) {
291
291
  wasm.brookparser_setLenientLists(this.__wbg_ptr, on);
292
292
  }
293
+ /**
294
+ * Keep every committed block's rendered HTML retained inside the parser for
295
+ * `allBlocks()`. ON by default. Turn it OFF in a pure STREAMING consumer —
296
+ * one that reads each committed block exactly once out of its patch and
297
+ * never calls `allBlocks()` (the npm worker): the HTML is then released the
298
+ * moment the block is emitted, so a long stream retains the source buffer
299
+ * plus the open tail instead of the whole rendered document (`retainedBytes`
300
+ * reflects it). Patch bytes are identical either way; the only cost is that
301
+ * `allBlocks()` then reports committed blocks with an EMPTY `html` (id,
302
+ * kind, start, end, open, speculative all stay exact).
303
+ * @param {boolean} on
304
+ */
305
+ setRetainCommittedHtml(on) {
306
+ wasm.brookparser_setRetainCommittedHtml(this.__wbg_ptr, on);
307
+ }
293
308
  /**
294
309
  * Render a CommonMark SOFT line break (a bare `\n` in inline content) as a
295
310
  * `<br>` — the `remark-breaks` convention, where one Enter is one visual
Binary file
@@ -22,6 +22,7 @@ export const brookparser_setGfmTagfilter: (a: number, b: number) => void;
22
22
  export const brookparser_setHtmlSanitize: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
23
23
  export const brookparser_setInlineComponentTags: (a: number, b: number, c: number) => void;
24
24
  export const brookparser_setLenientLists: (a: number, b: number) => void;
25
+ export const brookparser_setRetainCommittedHtml: (a: number, b: number) => void;
25
26
  export const brookparser_setSoftBreaks: (a: number, b: number) => void;
26
27
  export const brookparser_setUnsafeHtml: (a: number, b: number) => void;
27
28
  export const brookparser_setWireDelta: (a: number, b: number) => void;
package/dist/worker.js CHANGED
@@ -29,6 +29,7 @@ const core = new WorkerCore({
29
29
  p.setBlockHtml(c?.blockHtml ?? false);
30
30
  p.setAllowSchemes(c?.allowSchemes ?? []);
31
31
  p.setBlockData(c?.blockData ?? false);
32
+ p.setRetainCommittedHtml(c?.retainCommittedHtml ?? false);
32
33
  p.setWireDelta(true);
33
34
  return p;
34
35
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brookmd",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "Zero-dep streaming markdown for the browser. Rust→WASM core, Web Worker per stream, incremental parse with speculative closure.",
5
5
  "type": "module",
6
6
  "sideEffects": ["./dist/worker.js", "./dist/styles.css"],