brookmd 0.26.1 → 0.28.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 = [];
@@ -52,6 +53,8 @@ function BrookMarkdownFromClient({
52
53
  stickToBottom,
53
54
  sanitize,
54
55
  childMemo,
56
+ streamingHighlight,
57
+ __fullRebuild,
55
58
  className,
56
59
  id,
57
60
  role,
@@ -104,6 +107,8 @@ function BrookMarkdownFromClient({
104
107
  virtualize,
105
108
  sanitize,
106
109
  childMemo,
110
+ streamingHighlight,
111
+ __fullRebuild,
107
112
  onRenderMetrics: onMetrics,
108
113
  decorators,
109
114
  urlTransform,
@@ -312,6 +317,18 @@ function SafeHtml({
312
317
  return htmlToReact(html, components, map, opts);
313
318
  }, [html, components, childMemo, decorators, urlTransform]);
314
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
+ }
315
332
  function KeyedListItemImpl({
316
333
  html,
317
334
  components,
@@ -486,6 +503,8 @@ function renderBlockContent({
486
503
  components,
487
504
  sanitize,
488
505
  childMemo,
506
+ streamingHighlight,
507
+ __fullRebuild,
489
508
  decorators,
490
509
  urlTransform
491
510
  }) {
@@ -515,7 +534,10 @@ function renderBlockContent({
515
534
  {
516
535
  html: block.html,
517
536
  open: block.open,
518
- code: typeof source === "string" ? source : void 0
537
+ code: typeof source === "string" ? source : void 0,
538
+ streamingHighlight,
539
+ block,
540
+ __fullRebuild
519
541
  }
520
542
  );
521
543
  }
@@ -551,13 +573,13 @@ function renderBlockContent({
551
573
  );
552
574
  }
553
575
  }
554
- if (components || hasInlineTransforms) {
555
- if (components && !hasInlineTransforms && block.open && !sanitize && (kind === "Blockquote" || kind === "Alert")) {
556
- const nested = block.kind.data?.nested;
557
- if (Array.isArray(nested)) {
558
- return /* @__PURE__ */ jsx("div", { className, children: /* @__PURE__ */ jsx(KeyedContainer, { block, nested, components }) });
559
- }
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 }) });
560
580
  }
581
+ }
582
+ if (components || hasInlineTransforms) {
561
583
  const safe = sanitize ? sanitize(block.html) : block.html;
562
584
  return /* @__PURE__ */ jsx("div", { className, children: /* @__PURE__ */ jsx(
563
585
  SafeHtml,
@@ -570,6 +592,9 @@ function renderBlockContent({
570
592
  }
571
593
  ) });
572
594
  }
595
+ if (block.open && !sanitize && !__fullRebuild) {
596
+ return /* @__PURE__ */ jsx(SplicedBlock, { className, block });
597
+ }
573
598
  return /* @__PURE__ */ jsx(
574
599
  "div",
575
600
  {
@@ -580,7 +605,7 @@ function renderBlockContent({
580
605
  }
581
606
  function blocksEqual(prev, next) {
582
607
  if (prev.block == null || next.block == null) return prev.block === next.block;
583
- 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.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)
584
609
  // busts the memo so every committed block re-decorates — the O(n²) footgun
585
610
  // the dev warning calls out. A hoisted/memoized value keeps the memo holding.
586
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;
@@ -8,7 +9,20 @@ interface Props {
8
9
  * as the highlight itself. Absent (blockData off) the HTML is decoded here.
9
10
  */
10
11
  code?: string;
12
+ /** Highlight the block while it is still open. Default true. */
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;
11
25
  }
12
- declare function CodeBlockImpl({ html, open, code }: Props): import("react/jsx-runtime").JSX.Element;
26
+ declare function CodeBlockImpl({ html, open, code, streamingHighlight, block, __fullRebuild }: Props): import("react/jsx-runtime").JSX.Element;
13
27
  export declare const CodeBlock: import("react").MemoExoticComponent<typeof CodeBlockImpl>;
14
28
  export {};
@@ -1,20 +1,50 @@
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
+ import { createInc, incHighlight, incSeed } from "../hi-inc.js";
6
+ import { newIncCode, paintIncCode } from "../splice.js";
7
+ import { useHtmlSplice } from "../react-splice.js";
5
8
  import { extractLang } from "../block-props.js";
6
9
  function decodeText(html) {
7
10
  const m = html.match(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
8
11
  if (!m) return "";
9
12
  return m[1].replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
10
13
  }
11
- function CodeBlockImpl({ html, open, code }) {
14
+ const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
15
+ function CodeBlockImpl({ html, open, code, streamingHighlight, block, __fullRebuild }) {
12
16
  const lang = extractLang(html) || "text";
13
17
  const text = useMemo(() => open ? "" : code ?? decodeText(html), [html, open, code]);
18
+ const streaming = open && streamingHighlight !== false;
19
+ const openText = useMemo(
20
+ () => streaming ? code ?? decodeText(html) : "",
21
+ [streaming, code, html]
22
+ );
23
+ const incRef = useRef(null);
24
+ const [inc, setInc] = useState(null);
25
+ const codeRef = useRef(null);
26
+ const mirrorRef = useRef(null);
27
+ const plainRef = useRef(null);
14
28
  const sync = useMemo(() => {
15
29
  if (!text) return null;
16
- return typeof window === "undefined" ? highlight(text, lang) : highlightWithin(text, lang);
30
+ if (typeof window === "undefined") return highlight(text, lang);
31
+ const st = incRef.current;
32
+ return highlightWithin(text, lang, st ? incSeed(st, text, lang) : void 0);
17
33
  }, [text, lang]);
34
+ useEffect(() => {
35
+ if (!streaming || typeof window === "undefined") {
36
+ incRef.current = null;
37
+ setInc((prev) => prev === null ? prev : null);
38
+ return;
39
+ }
40
+ let st = incRef.current;
41
+ if (st === null || st.lang !== lang.toLowerCase()) {
42
+ st = createInc(lang);
43
+ incRef.current = st;
44
+ }
45
+ const markup = st === null ? null : incHighlight(st, openText);
46
+ setInc(markup === null ? null : { lang, html: markup });
47
+ }, [streaming, openText, lang]);
18
48
  const [slow, setSlow] = useState(null);
19
49
  useEffect(() => {
20
50
  if (!text || sync !== null) {
@@ -38,7 +68,30 @@ function CodeBlockImpl({ html, open, code }) {
38
68
  run.cancel();
39
69
  };
40
70
  }, [text, lang, sync]);
41
- const highlighted = sync ?? (slow !== null && slow.text === text && slow.lang === lang ? slow.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);
42
95
  const [copied, setCopied] = useState(false);
43
96
  const timerRef = useRef(null);
44
97
  useEffect(() => {
@@ -90,8 +143,25 @@ function CodeBlockImpl({ html, open, code }) {
90
143
  /* @__PURE__ */ jsx("div", { className: "brook-code-body", children: highlighted ? (
91
144
  // tabIndex=0 + role/label so keyboard users can scroll long code and
92
145
  // screen readers announce the region with its language.
93
- /* @__PURE__ */ jsx("pre", { tabIndex: 0, role: "region", "aria-label": `${lang} code`, children: /* @__PURE__ */ jsx("code", { dangerouslySetInnerHTML: { __html: highlighted } }) })
94
- ) : /* @__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
+ ) })
95
165
  ] });
96
166
  }
97
167
  const CodeBlock = memo(CodeBlockImpl);
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brookmd",
3
- "version": "0.26.1",
3
+ "version": "0.28.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"],