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/CHANGELOG.md +100 -0
- package/README.md +32 -4
- package/dist/client.js +4 -1
- package/dist/dom.d.ts +28 -0
- package/dist/dom.js +214 -20
- package/dist/hi-defer.d.ts +3 -2
- package/dist/hi-defer.js +7 -4
- package/dist/hi-inc.d.ts +127 -0
- package/dist/hi-inc.js +323 -0
- package/dist/hi.d.ts +17 -1
- package/dist/hi.js +5 -1
- package/dist/html-to-react.d.ts +2 -0
- package/dist/html-to-react.js +7 -0
- package/dist/react-splice.d.ts +32 -0
- package/dist/react-splice.js +33 -0
- package/dist/react.d.ts +28 -0
- package/dist/react.js +33 -8
- package/dist/renderers/CodeBlock.d.ts +15 -1
- package/dist/renderers/CodeBlock.js +76 -6
- package/dist/splice.d.ts +126 -0
- package/dist/splice.js +212 -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 = [];
|
|
@@ -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 (
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
10
13
|
}
|
|
11
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
94
|
-
|
|
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);
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brookmd",
|
|
3
|
-
"version": "0.
|
|
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"],
|