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/CHANGELOG.md +96 -0
- package/README.md +100 -0
- package/dist/client.d.ts +139 -0
- package/dist/client.js +251 -2
- package/dist/dom.d.ts +17 -0
- package/dist/dom.js +196 -18
- package/dist/element.js +3 -1
- package/dist/hi-inc.d.ts +25 -0
- package/dist/hi-inc.js +6 -0
- package/dist/html-to-react.d.ts +2 -0
- package/dist/html-to-react.js +7 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/react-splice.d.ts +32 -0
- package/dist/react-splice.js +33 -0
- package/dist/react.d.ts +11 -0
- package/dist/react.js +29 -8
- package/dist/renderers/CodeBlock.d.ts +13 -1
- package/dist/renderers/CodeBlock.js +51 -8
- package/dist/server.js +1 -0
- package/dist/splice.d.ts +126 -0
- package/dist/splice.js +212 -0
- package/dist/types-core.d.ts +16 -0
- package/dist/wasm/brook_md_core.d.ts +13 -0
- package/dist/wasm/brook_md_core.js +15 -0
- package/dist/wasm/brook_md_core_bg.wasm +0 -0
- package/dist/wasm/brook_md_core_bg.wasm.d.ts +1 -0
- package/dist/worker.js +1 -0
- package/package.json +1 -1
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 (
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
11
13
|
}
|
|
12
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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:
|
|
121
|
-
|
|
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
package/dist/splice.d.ts
ADDED
|
@@ -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/dist/types-core.d.ts
CHANGED
|
@@ -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.
|
|
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"],
|