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/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,106 @@ Notable changes to brookmd (formerly `flux-md`). Format based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/); this project aims to follow
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## 0.28.0 — 2026-07-31
|
|
8
|
+
|
|
9
|
+
**Browser-side linearity.** The parser and wire have been O(new bytes) per
|
|
10
|
+
append for a while; this release makes the *DOM application* match. Before, an
|
|
11
|
+
open (streaming) block was fully rebuilt on every animation frame — a 20 KB
|
|
12
|
+
highlighted code block wrote ~44 million characters of `innerHTML` over its
|
|
13
|
+
lifetime (≈2,150× the wire bytes), and a per-frame `replaceWith` destroyed any
|
|
14
|
+
text selection or `<pre>` scroll position inside the block. Every open-block
|
|
15
|
+
path now applies patches incrementally, measured at **2–3.4× the block's final
|
|
16
|
+
markup, flat across sizes** (1× is the write-it-once floor), and enforced by a
|
|
17
|
+
chars-written regression gate so browser-side linearity is CI-pinned like the
|
|
18
|
+
parser's scaling shapes.
|
|
19
|
+
|
|
20
|
+
### Performance
|
|
21
|
+
|
|
22
|
+
- **Open blocks apply the wire's splice instead of rebuilding.** The delta
|
|
23
|
+
signal (`keep_units`) the wire already computed was being discarded at the
|
|
24
|
+
client; it now reaches both renderers, which rebuild only the element the
|
|
25
|
+
splice lands in (fast path fires ~94% of syncs; any ambiguity falls back to
|
|
26
|
+
a full rebuild, so correctness never depends on the fast path).
|
|
27
|
+
- **Open code blocks reuse the streaming highlighter's frozen prefix**: the
|
|
28
|
+
frozen markup is appended once and never rewritten; only the bounded
|
|
29
|
+
speculative tail repaints. 20 KB highlighted stream: ~44 MB written → ~370 KB
|
|
30
|
+
(**~120× less**).
|
|
31
|
+
- **Keyed list/container sync in the DOM renderer** (`blockData` on): settled
|
|
32
|
+
`<li>`s / nested container children are never re-rendered — new items append,
|
|
33
|
+
only the open last item repaints; a tight→loose flip resyncs once, keyed by
|
|
34
|
+
`(index, html)` so it cannot be silently missed. Streamed 20 KB list: 284× →
|
|
35
|
+
**2.9×**; blockquote: 318× → **3.4×**.
|
|
36
|
+
- **React's keyed container path now engages by default.** It was accidentally
|
|
37
|
+
gated behind a `components` map (git history shows the gate was incidental);
|
|
38
|
+
a default-config streaming blockquote/alert now renders keyed — 22.6× and
|
|
39
|
+
growing → **3.4× flat**. React and DOM implementations were cross-checked to
|
|
40
|
+
byte-identical work counts.
|
|
41
|
+
- Known documented fallback: a streamed table with `blockData: false` still
|
|
42
|
+
takes the full-rebuild path (the splice refuses table tag chains — foster
|
|
43
|
+
parenting). `blockData: true` tables were already incremental (1.7×). A
|
|
44
|
+
table-scoped splice was measured (15% improvement) and rejected as not worth
|
|
45
|
+
the parser-divergence risk.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- **Text selection and `<pre>` scroll survive streaming.** Selecting text in
|
|
50
|
+
the already-settled part of a streaming block no longer collapses on the
|
|
51
|
+
next token; a code block's horizontal scroll position is preserved. Both are
|
|
52
|
+
pinned by tests with negative controls.
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- The DOM inside an open, streaming `<code>` element is now two spans (frozen +
|
|
57
|
+
speculative tail) while streaming; it settles to the same single-markup form
|
|
58
|
+
as before when the block closes. CSS that targets `code > *` structurally
|
|
59
|
+
may observe the difference mid-stream only. Settled output is unchanged.
|
|
60
|
+
- React's default open blockquote/alert omits inter-block whitespace text
|
|
61
|
+
nodes mid-stream (matching the DOM renderer's long-standing keyed behavior);
|
|
62
|
+
layout is unaffected and settle output is byte-identical.
|
|
63
|
+
|
|
64
|
+
## 0.27.0 — 2026-07-31
|
|
65
|
+
|
|
66
|
+
### Added
|
|
67
|
+
|
|
68
|
+
- **Streaming syntax highlighting — open code blocks now highlight live, on by
|
|
69
|
+
default.** Since 0.19 the built-in highlighter deliberately waited for a
|
|
70
|
+
fence to close; a streaming block showed plain text. Open blocks now render
|
|
71
|
+
highlighted as they grow, and the mechanism preserves every guarantee the
|
|
72
|
+
deferred design existed to protect:
|
|
73
|
+
|
|
74
|
+
- **Byte-identical at settle.** All committed markup comes from seeded runs
|
|
75
|
+
of the same resumable tokenizer that `highlight()` uses — the frozen
|
|
76
|
+
prefix is only ever extended at *checkpoints* (positions provably outside
|
|
77
|
+
any future token: after a newline in whitespace, ≥ 3 bytes behind the
|
|
78
|
+
stream head, with no unterminated string/comment/template live; HTML
|
|
79
|
+
checkpoints after `>` instead). When the block closes, one seeded run
|
|
80
|
+
finishes the tail, so the final bytes equal a one-shot `highlight()` —
|
|
81
|
+
pinned by a ~44,000-case fuzz (all languages, chunk sizes down to 1 byte,
|
|
82
|
+
plus speculative-revision streams) asserting settle-identity and that
|
|
83
|
+
every intermediate frozen prefix is a byte-prefix of the final output.
|
|
84
|
+
- **Bounded work, independent of block size.** Between checkpoints, an
|
|
85
|
+
unterminated string/comment advances via an O(1)-state scanner over only
|
|
86
|
+
the new bytes; the tail re-scan is capped at 8 KB (past the cap the tail
|
|
87
|
+
renders plain until the next checkpoint — correctness unaffected). Total
|
|
88
|
+
tokenization work measures ≈ 5.7× the streamed bytes end-to-end against
|
|
89
|
+
the real parser, flat from 288 B to 23 KB blocks (naive re-highlighting
|
|
90
|
+
measures 812× more at 23 KB); a work-bound test enforces < 6×. The
|
|
91
|
+
50 000-char plain-escape guard still applies and discards streaming state.
|
|
92
|
+
- **The tail is speculative.** The few tokens nearest the stream head may
|
|
93
|
+
change color as bytes arrive (an unterminated `"` reads as plain until its
|
|
94
|
+
closer lands) — the same speculative-tail behavior brookmd's links and
|
|
95
|
+
emphasis already have. Frozen output never changes.
|
|
96
|
+
|
|
97
|
+
Opt out with `streamingHighlight={false}` on `BrookMarkdown` (React) or
|
|
98
|
+
`streamingHighlight: false` in `mountBrookMarkdown` options (DOM, and via it
|
|
99
|
+
the Web Component and Vue/Svelte/Solid adapters) — that restores 0.26.1's
|
|
100
|
+
plain-until-close exactly. Not a `ParserConfig` field: highlighting is a
|
|
101
|
+
renderer concern; the parser, wire, and WASM are untouched. SSR is
|
|
102
|
+
unchanged (closed blocks, synchronous), and `components.CodeBlock` /
|
|
103
|
+
`pre` / `code` overrides still bypass the built-in highlighter entirely.
|
|
104
|
+
Closing a block that streamed in now also highlights near-instantly: the
|
|
105
|
+
close-time pass reuses the accumulated prefix instead of starting over.
|
|
106
|
+
|
|
7
107
|
## 0.26.1 — 2026-07-30
|
|
8
108
|
|
|
9
109
|
Two fixes found by benchmarking 0.26.0 against real chat traffic. Requires
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Zero-dep streaming markdown for the browser. Rust→WASM core, one Web Worker pe
|
|
|
4
4
|
|
|
5
5
|
Drop in a streaming-aware renderer — **React, Vue, Svelte, Solid, a framework-agnostic `<brook-markdown>` Web Component, or the vanilla DOM mount** — wire each LLM stream to a `BrookClient`, and the markdown renders incrementally off the main thread, block by block, with stable identities so unchanged blocks never re-reconcile.
|
|
6
6
|
|
|
7
|
-
Parsing runs entirely **off the main thread** — each stream gets its own pooled Web Worker, so many concurrent LLM responses render without contending for the UI thread. On each token the parser re-parses only the **active tail**, not the whole document; patches cross the worker boundary as **verified splices** (not full re-sends, so emitted bytes stay O(n) even for one giant growing block); and heavy renderers (
|
|
7
|
+
Parsing runs entirely **off the main thread** — each stream gets its own pooled Web Worker, so many concurrent LLM responses render without contending for the UI thread. On each token the parser re-parses only the **active tail**, not the whole document; patches cross the worker boundary as **verified splices** (not full re-sends, so emitted bytes stay O(n) even for one giant growing block); and heavy renderers (math, mermaid) are **deferred until a block closes** — code fences highlight as they stream, re-tokenizing only the last line rather than the whole block per chunk. The result is low retained memory and a main thread that stays responsive while streaming. See [the live demo](https://md.hsingh.app/).
|
|
8
8
|
|
|
9
9
|
> **Beyond the browser:** the same Rust core also powers experimental React
|
|
10
10
|
> Native, Swift (iOS/macOS), Kotlin/Android, Flutter, and C-ABI bindings —
|
|
@@ -248,7 +248,9 @@ controlled-string helpers wrap; in vanilla you call it directly.
|
|
|
248
248
|
|
|
249
249
|
`mountBrookMarkdown(client, container, options?)` returns `{ destroy(), refresh() }`.
|
|
250
250
|
Options: `components`, `sanitize`, `virtualize`, `stickToBottom`, `highlightCode`
|
|
251
|
-
(default true), `
|
|
251
|
+
(default true), `streamingHighlight` (default true — highlight a code fence while
|
|
252
|
+
it is still streaming; see [Streaming syntax highlighting](#streaming-syntax-highlighting)),
|
|
253
|
+
`batch` (default true — one DOM write per `requestAnimationFrame`),
|
|
252
254
|
`morphOpenBlocks` (default false — morph a growing generic open block's subtree in
|
|
253
255
|
place instead of rebuilding it via `innerHTML`, so only the changed parts repaint
|
|
254
256
|
and focus/selection in the streaming tail survive; the rendered result is
|
|
@@ -1167,6 +1169,34 @@ import { highlight } from "brookmd/highlight";
|
|
|
1167
1169
|
const html = highlight("const x = 1;", "ts");
|
|
1168
1170
|
```
|
|
1169
1171
|
|
|
1172
|
+
### Streaming syntax highlighting
|
|
1173
|
+
|
|
1174
|
+
A code fence is highlighted **while it streams**, not only once it closes. On by
|
|
1175
|
+
default; turn it off with `streamingHighlight={false}` (React) or
|
|
1176
|
+
`{ streamingHighlight: false }` (the DOM mount options / Vue / Svelte / Solid),
|
|
1177
|
+
which restores the plain-until-close behaviour.
|
|
1178
|
+
|
|
1179
|
+
It stays O(n) over the whole block rather than re-highlighting the fence on every
|
|
1180
|
+
chunk. An open block keeps a **frozen prefix** — markup for everything behind a
|
|
1181
|
+
checkpoint, which later bytes provably cannot rewrite — and re-tokenizes only the
|
|
1182
|
+
**tail** after it, about one source line's worth per patch. When the fence closes,
|
|
1183
|
+
the tokenizer resumes from that checkpoint instead of starting over, so a block
|
|
1184
|
+
that streamed in highlights near-instantly.
|
|
1185
|
+
|
|
1186
|
+
Two things worth knowing:
|
|
1187
|
+
|
|
1188
|
+
- **The tail is speculative.** A prefix of source does not tokenize like the same
|
|
1189
|
+
prefix of a longer source: `const s = "hello` is a stray quote plus an
|
|
1190
|
+
identifier until its closing quote lands, and `123.456e` is `123` plus loose
|
|
1191
|
+
fragments until a digit arrives. So the last line's colours can shift as bytes
|
|
1192
|
+
come in. Nothing behind the checkpoint ever changes.
|
|
1193
|
+
- **The settled markup is byte-identical** to `highlight(text, lang)` either way.
|
|
1194
|
+
Turning this on or off changes when colour appears, never what it is.
|
|
1195
|
+
|
|
1196
|
+
Blocks past the highlighter's 50 000-character guard, unknown languages, and any
|
|
1197
|
+
fence taken over by a `components.CodeBlock` / `pre` / `code` override are
|
|
1198
|
+
unaffected — they behave exactly as before.
|
|
1199
|
+
|
|
1170
1200
|
## Coverage
|
|
1171
1201
|
|
|
1172
1202
|
**CommonMark 0.31: 100% (652/652 spec examples), byte-exact** — every section,
|
|
@@ -1218,8 +1248,6 @@ By design, not yet, or only partially:
|
|
|
1218
1248
|
(`<span>`/`<div class="math …">` with `gfmMath` on) and a `Mermaid` slot, but
|
|
1219
1249
|
stays zero-dep: bring your own KaTeX / mermaid pass (or a `components.MathBlock`
|
|
1220
1250
|
/ `components.Mermaid` override) for the actual SVG/MathML output.
|
|
1221
|
-
- **Syntax highlighting on open code blocks** — deferred until close. This is a
|
|
1222
|
-
deliberate perf choice.
|
|
1223
1251
|
|
|
1224
1252
|
## Performance
|
|
1225
1253
|
|
package/dist/client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { warnOnce } from "./warn.js";
|
|
2
2
|
import { createWorker } from "./asset-urls.js";
|
|
3
|
+
import { noteSplice } from "./splice.js";
|
|
3
4
|
function emptyBlockStore() {
|
|
4
5
|
return { committed: /* @__PURE__ */ new Map(), committedOrder: [], active: [], snapshot: [] };
|
|
5
6
|
}
|
|
@@ -18,7 +19,9 @@ function applyPatch(store, patch) {
|
|
|
18
19
|
const { html_delta, ...rest } = entry;
|
|
19
20
|
const prev = store.active.find((b) => b.id === entry.id);
|
|
20
21
|
if (!prev) throw new Error(`brookmd: html_delta for block ${entry.id} without a base`);
|
|
21
|
-
|
|
22
|
+
const next2 = { ...rest, html: prev.html.slice(0, html_delta.keep_units) + html_delta.append };
|
|
23
|
+
active[i] = next2;
|
|
24
|
+
noteSplice(next2, prev, html_delta.keep_units);
|
|
22
25
|
} else {
|
|
23
26
|
active[i] = entry;
|
|
24
27
|
}
|
package/dist/dom.d.ts
CHANGED
|
@@ -64,6 +64,17 @@ export interface MountOptions {
|
|
|
64
64
|
/** Use the built-in code highlighter. Default true; suppressed when a
|
|
65
65
|
* `components.CodeBlock` override is supplied. */
|
|
66
66
|
highlightCode?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Highlight a code fence **while it is still streaming**, instead of showing
|
|
69
|
+
* plain escaped text until it closes. On by default (parity with the React
|
|
70
|
+
* renderer's `streamingHighlight` prop).
|
|
71
|
+
*
|
|
72
|
+
* An open block keeps a frozen prefix and re-tokenizes only its tail on each
|
|
73
|
+
* patch, so this stays linear in the block's size. The settled markup is
|
|
74
|
+
* byte-identical either way — only the tail's colours are provisional, and
|
|
75
|
+
* they may shift as bytes arrive. Set `false` for the pre-0.27 behaviour.
|
|
76
|
+
*/
|
|
77
|
+
streamingHighlight?: boolean;
|
|
67
78
|
/** Coalesce patches into one DOM write per animation frame. Default true. */
|
|
68
79
|
batch?: boolean;
|
|
69
80
|
/**
|
|
@@ -118,7 +129,24 @@ export interface MountOptions {
|
|
|
118
129
|
* `client.getMetrics().rebuildCount`.
|
|
119
130
|
*/
|
|
120
131
|
onRenderMetrics?: RenderMetricsHook;
|
|
132
|
+
/**
|
|
133
|
+
* @internal TEST-ONLY. Turn off the incremental apply fast paths — the open
|
|
134
|
+
* code block's frozen/tail mirror and the delta-driven child splice — so every
|
|
135
|
+
* changed open block rebuilds its whole node, exactly as it did before those
|
|
136
|
+
* existed. The DOM-parity fuzz mounts one renderer with this on and one with it
|
|
137
|
+
* off over the same patch stream and asserts their `innerHTML` matches after
|
|
138
|
+
* every sync: correctness must never depend on a fast path firing. Not part of
|
|
139
|
+
* the supported API and never useful in an app.
|
|
140
|
+
*/
|
|
141
|
+
__fullRebuild?: boolean;
|
|
121
142
|
}
|
|
143
|
+
/** @internal Test-only. */
|
|
144
|
+
export declare function __keyedStats(): {
|
|
145
|
+
attempts: number;
|
|
146
|
+
hits: number;
|
|
147
|
+
};
|
|
148
|
+
/** @internal Test-only. */
|
|
149
|
+
export declare function __resetKeyedStats(): void;
|
|
122
150
|
export declare function mountBrookMarkdown(client: BrookClient, container: HTMLElement, options?: MountOptions): MountHandle;
|
|
123
151
|
/**
|
|
124
152
|
* Derive the streaming tail's block id from an ordered snapshot: the id of the
|
package/dist/dom.js
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import { highlightDeferred } from "./hi-defer.js";
|
|
2
|
+
import { createInc, incHighlight, incSeed } from "./hi-inc.js";
|
|
2
3
|
import { morph } from "./morph.js";
|
|
4
|
+
import { newIncCode, paintIncCode, spliceHtml, spliceKeep } from "./splice.js";
|
|
3
5
|
import { blockProps, extractLang } from "./block-props.js";
|
|
4
6
|
import { decorateSegments } from "./decorate.js";
|
|
5
7
|
import { safeUrl } from "./url-safety.js";
|
|
8
|
+
let keyedAttempts = 0;
|
|
9
|
+
let keyedHits = 0;
|
|
10
|
+
function __keyedStats() {
|
|
11
|
+
return { attempts: keyedAttempts, hits: keyedHits };
|
|
12
|
+
}
|
|
13
|
+
function __resetKeyedStats() {
|
|
14
|
+
keyedAttempts = 0;
|
|
15
|
+
keyedHits = 0;
|
|
16
|
+
}
|
|
6
17
|
const INTRINSIC_PX = {
|
|
7
18
|
Paragraph: 80,
|
|
8
19
|
Heading: 44,
|
|
@@ -28,8 +39,10 @@ function mountBrookMarkdown(client, container, options = {}) {
|
|
|
28
39
|
const hasInlineTransforms = !!decorators || !!urlTransform;
|
|
29
40
|
const hasPerf = typeof performance !== "undefined";
|
|
30
41
|
const highlightCode = options.highlightCode !== false && !components?.CodeBlock;
|
|
42
|
+
const streamingHighlight = options.streamingHighlight !== false;
|
|
31
43
|
const batch = options.batch !== false && typeof requestAnimationFrame === "function";
|
|
32
44
|
const morphOpenBlocks = options.morphOpenBlocks === true;
|
|
45
|
+
const fullRebuild = options.__fullRebuild === true;
|
|
33
46
|
const root = document.createElement("div");
|
|
34
47
|
root.className = options.className ? `brook-md ${options.className}` : "brook-md";
|
|
35
48
|
if (options.id) root.id = options.id;
|
|
@@ -68,6 +81,7 @@ function mountBrookMarkdown(client, container, options = {}) {
|
|
|
68
81
|
id: b.id,
|
|
69
82
|
node: void 0,
|
|
70
83
|
html: b.html,
|
|
84
|
+
block: b,
|
|
71
85
|
open: b.open,
|
|
72
86
|
speculative: b.speculative,
|
|
73
87
|
kind: b.kind.type,
|
|
@@ -85,6 +99,13 @@ function mountBrookMarkdown(client, container, options = {}) {
|
|
|
85
99
|
continue;
|
|
86
100
|
}
|
|
87
101
|
const t0 = onRenderMetrics && hasPerf ? performance.now() : 0;
|
|
102
|
+
if (existing.codeInc && existing.inc && b.open && existing.open && b.kind.type === "CodeBlock" && existing.kind === "CodeBlock" && syncIncCode(existing, b)) {
|
|
103
|
+
if (onRenderMetrics) noteRender(existing, b, t0);
|
|
104
|
+
existing.html = b.html;
|
|
105
|
+
existing.block = b;
|
|
106
|
+
existing.speculative = b.speculative;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
88
109
|
if (existing.table && b.open && b.kind.type === "Table") {
|
|
89
110
|
const data = tableData(b);
|
|
90
111
|
if (data) {
|
|
@@ -94,15 +115,38 @@ function mountBrookMarkdown(client, container, options = {}) {
|
|
|
94
115
|
existing.node.classList.toggle("brook-speculative", b.speculative);
|
|
95
116
|
}
|
|
96
117
|
existing.html = b.html;
|
|
118
|
+
existing.block = b;
|
|
119
|
+
existing.open = b.open;
|
|
120
|
+
existing.speculative = b.speculative;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!fullRebuild && existing.list && b.open && b.kind.type === "List" && existing.kind === "List") {
|
|
125
|
+
const ld = b.kind.data;
|
|
126
|
+
if (ld && syncKeyedList(existing.list, ld)) {
|
|
127
|
+
if (onRenderMetrics) noteRender(existing, b, t0);
|
|
128
|
+
existing.node.className = genericClassName(b);
|
|
129
|
+
existing.html = b.html;
|
|
130
|
+
existing.block = b;
|
|
97
131
|
existing.open = b.open;
|
|
98
132
|
existing.speculative = b.speculative;
|
|
99
133
|
continue;
|
|
100
134
|
}
|
|
101
135
|
}
|
|
136
|
+
if (!fullRebuild && existing.container && b.open && existing.kind === b.kind.type && (b.kind.type === "Blockquote" || b.kind.type === "Alert") && syncKeyedContainer(existing.container, b)) {
|
|
137
|
+
if (onRenderMetrics) noteRender(existing, b, t0);
|
|
138
|
+
existing.node.className = genericClassName(b);
|
|
139
|
+
existing.html = b.html;
|
|
140
|
+
existing.block = b;
|
|
141
|
+
existing.open = b.open;
|
|
142
|
+
existing.speculative = b.speculative;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
102
145
|
if (morphOpenBlocks && b.open && existing.open && existing.generic && existing.kind === b.kind.type && usesGenericPath(b)) {
|
|
103
146
|
morph(existing.node, sanitize ? sanitize(b.html) : b.html);
|
|
104
147
|
if (onRenderMetrics) noteRender(existing, b, t0);
|
|
105
148
|
existing.html = b.html;
|
|
149
|
+
existing.block = b;
|
|
106
150
|
existing.speculative = b.speculative;
|
|
107
151
|
existing.node.className = genericClassName(b);
|
|
108
152
|
existing.generic = !sanitize;
|
|
@@ -112,9 +156,36 @@ function mountBrookMarkdown(client, container, options = {}) {
|
|
|
112
156
|
existing.node.insertAdjacentHTML("beforeend", b.html.slice(existing.html.length));
|
|
113
157
|
if (onRenderMetrics) noteRender(existing, b, t0);
|
|
114
158
|
existing.html = b.html;
|
|
159
|
+
existing.block = b;
|
|
115
160
|
continue;
|
|
116
161
|
}
|
|
162
|
+
if (!fullRebuild && !sanitize && !hasInlineTransforms && existing.generic && b.open && existing.open && existing.kind === b.kind.type && usesGenericPath(b)) {
|
|
163
|
+
const keep = spliceKeep(existing.block, b);
|
|
164
|
+
if (keep !== void 0 && spliceHtml(existing.node, existing.html, b.html, keep)) {
|
|
165
|
+
if (onRenderMetrics) noteRender(existing, b, t0);
|
|
166
|
+
existing.html = b.html;
|
|
167
|
+
existing.block = b;
|
|
168
|
+
existing.speculative = b.speculative;
|
|
169
|
+
existing.node.className = genericClassName(b);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!fullRebuild && existing.plainCode && b.open && existing.open && b.kind.type === "CodeBlock" && existing.kind === "CodeBlock") {
|
|
174
|
+
const keep = spliceKeep(existing.block, b);
|
|
175
|
+
if (keep !== void 0 && spliceHtml(existing.plainCode, existing.html, b.html, keep)) {
|
|
176
|
+
if (onRenderMetrics) noteRender(existing, b, t0);
|
|
177
|
+
existing.html = b.html;
|
|
178
|
+
existing.block = b;
|
|
179
|
+
existing.speculative = b.speculative;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
117
183
|
existing.table = void 0;
|
|
184
|
+
existing.list = void 0;
|
|
185
|
+
existing.container = void 0;
|
|
186
|
+
existing.codeInc = void 0;
|
|
187
|
+
existing.plainCode = void 0;
|
|
188
|
+
if (existing.inc && b.kind.type !== "CodeBlock") existing.inc = void 0;
|
|
118
189
|
if (existing.highlight) {
|
|
119
190
|
existing.highlight.cancel();
|
|
120
191
|
existing.highlight = void 0;
|
|
@@ -124,6 +195,7 @@ function mountBrookMarkdown(client, container, options = {}) {
|
|
|
124
195
|
existing.node = node;
|
|
125
196
|
if (onRenderMetrics) noteRender(existing, b, t0);
|
|
126
197
|
existing.html = b.html;
|
|
198
|
+
existing.block = b;
|
|
127
199
|
existing.open = b.open;
|
|
128
200
|
existing.speculative = b.speculative;
|
|
129
201
|
existing.kind = b.kind.type;
|
|
@@ -133,6 +205,9 @@ function mountBrookMarkdown(client, container, options = {}) {
|
|
|
133
205
|
for (const [id, mb] of mounted) {
|
|
134
206
|
if (!seen.has(id)) {
|
|
135
207
|
if (mb.highlight) mb.highlight.cancel();
|
|
208
|
+
mb.inc = void 0;
|
|
209
|
+
mb.codeInc = void 0;
|
|
210
|
+
mb.plainCode = void 0;
|
|
136
211
|
mb.node.remove();
|
|
137
212
|
mounted.delete(id);
|
|
138
213
|
}
|
|
@@ -202,13 +277,13 @@ function mountBrookMarkdown(client, container, options = {}) {
|
|
|
202
277
|
if (data) return buildKeyedTable(b, data, mb);
|
|
203
278
|
}
|
|
204
279
|
if (b.open && kind === "List" && !hasInlineTransforms) {
|
|
205
|
-
const keyed = renderKeyedList(b);
|
|
280
|
+
const keyed = renderKeyedList(b, mb);
|
|
206
281
|
if (keyed) return keyed;
|
|
207
282
|
}
|
|
208
283
|
const node = document.createElement("div");
|
|
209
284
|
node.className = genericClassName(b);
|
|
210
285
|
if (b.open && !sanitize && !hasInlineTransforms && (kind === "Blockquote" || kind === "Alert")) {
|
|
211
|
-
const wrapper = renderKeyedContainer(b);
|
|
286
|
+
const wrapper = renderKeyedContainer(b, mb);
|
|
212
287
|
if (wrapper) {
|
|
213
288
|
node.appendChild(wrapper);
|
|
214
289
|
return node;
|
|
@@ -220,47 +295,122 @@ function mountBrookMarkdown(client, container, options = {}) {
|
|
|
220
295
|
lastRenderGeneric = !sanitize && !hasInlineTransforms;
|
|
221
296
|
return node;
|
|
222
297
|
}
|
|
223
|
-
function renderKeyedList(b) {
|
|
298
|
+
function renderKeyedList(b, mb) {
|
|
224
299
|
const ld = b.kind.data;
|
|
225
300
|
const items = ld?.items;
|
|
226
301
|
if (!Array.isArray(items) || items.length === 0) return null;
|
|
227
302
|
const node = document.createElement("div");
|
|
228
|
-
node.className =
|
|
229
|
-
const
|
|
230
|
-
|
|
303
|
+
node.className = genericClassName(b);
|
|
304
|
+
const ordered = !!ld?.ordered;
|
|
305
|
+
const list = document.createElement(ordered ? "ol" : "ul");
|
|
306
|
+
if (ordered && ld.start !== void 0 && ld.start !== 1) {
|
|
231
307
|
list.setAttribute("start", String(ld.start));
|
|
232
308
|
}
|
|
233
|
-
|
|
309
|
+
const rendered = new Array(items.length);
|
|
310
|
+
for (let i = 0; i < items.length; i++) {
|
|
234
311
|
const li = document.createElement("li");
|
|
235
|
-
li.innerHTML = sanitize ? sanitize(
|
|
312
|
+
li.innerHTML = sanitize ? sanitize(items[i].html) : items[i].html;
|
|
236
313
|
list.appendChild(li);
|
|
314
|
+
rendered[i] = items[i].html;
|
|
237
315
|
}
|
|
238
316
|
node.appendChild(list);
|
|
317
|
+
mb.list = { list, ordered, start: ld?.start, items: rendered };
|
|
239
318
|
return node;
|
|
240
319
|
}
|
|
241
|
-
function
|
|
320
|
+
function syncKeyedList(km, ld) {
|
|
321
|
+
keyedAttempts++;
|
|
322
|
+
const items = ld.items;
|
|
323
|
+
if (!Array.isArray(items) || items.length === 0) return false;
|
|
324
|
+
if (!!ld.ordered !== km.ordered) return false;
|
|
325
|
+
const cur = km.items;
|
|
326
|
+
const kids = km.list.children;
|
|
327
|
+
if (kids.length !== cur.length) return false;
|
|
328
|
+
if (ld.start !== km.start) {
|
|
329
|
+
if (km.ordered && ld.start !== void 0 && ld.start !== 1) {
|
|
330
|
+
km.list.setAttribute("start", String(ld.start));
|
|
331
|
+
} else {
|
|
332
|
+
km.list.removeAttribute("start");
|
|
333
|
+
}
|
|
334
|
+
km.start = ld.start;
|
|
335
|
+
}
|
|
336
|
+
const n = items.length;
|
|
337
|
+
while (cur.length > n) {
|
|
338
|
+
km.list.removeChild(km.list.lastElementChild);
|
|
339
|
+
cur.pop();
|
|
340
|
+
}
|
|
341
|
+
for (let i = 0; i < cur.length; i++) {
|
|
342
|
+
if (cur[i] === items[i].html) continue;
|
|
343
|
+
kids[i].innerHTML = sanitize ? sanitize(items[i].html) : items[i].html;
|
|
344
|
+
cur[i] = items[i].html;
|
|
345
|
+
}
|
|
346
|
+
for (let i = cur.length; i < n; i++) {
|
|
347
|
+
const li = document.createElement("li");
|
|
348
|
+
li.innerHTML = sanitize ? sanitize(items[i].html) : items[i].html;
|
|
349
|
+
km.list.appendChild(li);
|
|
350
|
+
cur.push(items[i].html);
|
|
351
|
+
}
|
|
352
|
+
keyedHits++;
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
function renderKeyedContainer(b, mb) {
|
|
242
356
|
const nested = b.kind.data?.nested;
|
|
243
357
|
if (!Array.isArray(nested)) return null;
|
|
244
358
|
const tagName = b.kind.type === "Alert" ? "div" : "blockquote";
|
|
245
359
|
const wrapper = document.createElement(tagName);
|
|
246
360
|
applyOpenTagAttrs(wrapper, b.html);
|
|
361
|
+
let offset = 0;
|
|
362
|
+
let title = "";
|
|
247
363
|
if (b.kind.type === "Alert") {
|
|
248
|
-
|
|
364
|
+
title = alertTitleHtml(b.html);
|
|
249
365
|
if (title) {
|
|
250
366
|
const t = document.createElement("div");
|
|
251
367
|
t.innerHTML = title;
|
|
252
368
|
const titleNode = t.firstElementChild;
|
|
253
|
-
if (titleNode)
|
|
369
|
+
if (titleNode) {
|
|
370
|
+
wrapper.appendChild(titleNode);
|
|
371
|
+
offset = 1;
|
|
372
|
+
}
|
|
254
373
|
}
|
|
255
374
|
}
|
|
375
|
+
const rendered = new Array(nested.length);
|
|
256
376
|
for (let i = 0; i < nested.length; i++) {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const inner = child.firstElementChild;
|
|
260
|
-
wrapper.appendChild(inner ?? child);
|
|
377
|
+
wrapper.appendChild(nestedChild(nested[i].html));
|
|
378
|
+
rendered[i] = nested[i].html;
|
|
261
379
|
}
|
|
380
|
+
mb.container = { wrapper, offset, openTag: openTagOf(b.html), title, nested: rendered };
|
|
262
381
|
return wrapper;
|
|
263
382
|
}
|
|
383
|
+
function nestedChild(html) {
|
|
384
|
+
const child = document.createElement("div");
|
|
385
|
+
child.innerHTML = html;
|
|
386
|
+
return child.firstElementChild ?? child;
|
|
387
|
+
}
|
|
388
|
+
function syncKeyedContainer(kc, b) {
|
|
389
|
+
keyedAttempts++;
|
|
390
|
+
const nested = b.kind.data?.nested;
|
|
391
|
+
if (!Array.isArray(nested)) return false;
|
|
392
|
+
if (openTagOf(b.html) !== kc.openTag) return false;
|
|
393
|
+
if (b.kind.type === "Alert" && alertTitleHtml(b.html) !== kc.title) return false;
|
|
394
|
+
const cur = kc.nested;
|
|
395
|
+
const kids = kc.wrapper.children;
|
|
396
|
+
if (kids.length !== kc.offset + cur.length) return false;
|
|
397
|
+
const n = nested.length;
|
|
398
|
+
while (cur.length > n) {
|
|
399
|
+
kc.wrapper.removeChild(kc.wrapper.lastElementChild);
|
|
400
|
+
cur.pop();
|
|
401
|
+
}
|
|
402
|
+
for (let i = 0; i < cur.length; i++) {
|
|
403
|
+
if (cur[i] === nested[i].html) continue;
|
|
404
|
+
kc.wrapper.replaceChild(nestedChild(nested[i].html), kids[kc.offset + i]);
|
|
405
|
+
cur[i] = nested[i].html;
|
|
406
|
+
}
|
|
407
|
+
for (let i = cur.length; i < n; i++) {
|
|
408
|
+
kc.wrapper.appendChild(nestedChild(nested[i].html));
|
|
409
|
+
cur.push(nested[i].html);
|
|
410
|
+
}
|
|
411
|
+
keyedHits++;
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
264
414
|
function buildKeyedTable(b, data, mb) {
|
|
265
415
|
const node = document.createElement("div");
|
|
266
416
|
node.className = "brook-block brook-block-table brook-open" + (b.speculative ? " brook-speculative" : "");
|
|
@@ -348,9 +498,22 @@ function mountBrookMarkdown(client, container, options = {}) {
|
|
|
348
498
|
}
|
|
349
499
|
function renderCodeBlock(b, mb) {
|
|
350
500
|
const lang = extractLang(b.html) || "text";
|
|
501
|
+
mb.codeInc = void 0;
|
|
502
|
+
mb.plainCode = void 0;
|
|
351
503
|
const text = b.open ? "" : codeText(b);
|
|
352
|
-
|
|
353
|
-
|
|
504
|
+
let openMarkup = null;
|
|
505
|
+
if (b.open && streamingHighlight) {
|
|
506
|
+
let inc = mb.inc;
|
|
507
|
+
if (inc === void 0 || inc.lang !== lang.toLowerCase()) {
|
|
508
|
+
inc = createInc(lang) ?? void 0;
|
|
509
|
+
mb.inc = inc;
|
|
510
|
+
}
|
|
511
|
+
if (inc !== void 0) openMarkup = incHighlight(inc, codeText(b));
|
|
512
|
+
}
|
|
513
|
+
const seed = !b.open && mb.inc !== void 0 ? incSeed(mb.inc, text, lang) : void 0;
|
|
514
|
+
const run = text ? highlightDeferred(text, lang, seed) : null;
|
|
515
|
+
if (!b.open) mb.inc = void 0;
|
|
516
|
+
const highlighted = openMarkup ?? (run ? run.html : null);
|
|
354
517
|
const block = document.createElement("div");
|
|
355
518
|
block.className = "brook-code-block" + (b.open ? " brook-streaming" : "");
|
|
356
519
|
const header = document.createElement("div");
|
|
@@ -371,7 +534,9 @@ function mountBrookMarkdown(client, container, options = {}) {
|
|
|
371
534
|
const body = document.createElement("div");
|
|
372
535
|
body.className = "brook-code-body";
|
|
373
536
|
if (highlighted !== null) {
|
|
374
|
-
body.appendChild(
|
|
537
|
+
body.appendChild(
|
|
538
|
+
openMarkup !== null && mb.inc !== void 0 && !fullRebuild ? incPre(lang, mb, mb.inc, openMarkup) : highlightedPre(lang, highlighted)
|
|
539
|
+
);
|
|
375
540
|
} else {
|
|
376
541
|
const div = document.createElement("div");
|
|
377
542
|
div.tabIndex = 0;
|
|
@@ -379,12 +544,14 @@ function mountBrookMarkdown(client, container, options = {}) {
|
|
|
379
544
|
div.setAttribute("aria-label", `${lang} code`);
|
|
380
545
|
div.innerHTML = b.html;
|
|
381
546
|
body.appendChild(div);
|
|
547
|
+
if (b.open) mb.plainCode = div;
|
|
382
548
|
if (run !== null && run.rest !== null) {
|
|
383
549
|
mb.highlight = run;
|
|
384
550
|
run.rest.then((markup) => {
|
|
385
551
|
if (markup === null || dead) return;
|
|
386
552
|
if (mb.highlight !== run || mb.node !== block) return;
|
|
387
553
|
mb.highlight = void 0;
|
|
554
|
+
mb.plainCode = void 0;
|
|
388
555
|
body.replaceChild(highlightedPre(lang, markup), div);
|
|
389
556
|
});
|
|
390
557
|
}
|
|
@@ -392,6 +559,25 @@ function mountBrookMarkdown(client, container, options = {}) {
|
|
|
392
559
|
block.appendChild(body);
|
|
393
560
|
return block;
|
|
394
561
|
}
|
|
562
|
+
function syncIncCode(mb, b) {
|
|
563
|
+
const ic = mb.codeInc;
|
|
564
|
+
if ((extractLang(b.html) || "text") !== ic.lang) return false;
|
|
565
|
+
const markup = incHighlight(mb.inc, codeText(b));
|
|
566
|
+
if (markup === null) return false;
|
|
567
|
+
return paintIncCode(ic, mb.inc, markup);
|
|
568
|
+
}
|
|
569
|
+
function incPre(lang, mb, st, markup) {
|
|
570
|
+
const pre = document.createElement("pre");
|
|
571
|
+
pre.tabIndex = 0;
|
|
572
|
+
pre.setAttribute("role", "region");
|
|
573
|
+
pre.setAttribute("aria-label", `${lang} code`);
|
|
574
|
+
const code = document.createElement("code");
|
|
575
|
+
const ic = newIncCode(code, lang, st);
|
|
576
|
+
if (paintIncCode(ic, st, markup)) mb.codeInc = ic;
|
|
577
|
+
else code.innerHTML = markup;
|
|
578
|
+
pre.appendChild(code);
|
|
579
|
+
return pre;
|
|
580
|
+
}
|
|
395
581
|
function highlightedPre(lang, markup) {
|
|
396
582
|
const pre = document.createElement("pre");
|
|
397
583
|
pre.tabIndex = 0;
|
|
@@ -499,6 +685,9 @@ function mountBrookMarkdown(client, container, options = {}) {
|
|
|
499
685
|
mb.highlight.cancel();
|
|
500
686
|
mb.highlight = void 0;
|
|
501
687
|
}
|
|
688
|
+
mb.inc = void 0;
|
|
689
|
+
mb.codeInc = void 0;
|
|
690
|
+
mb.plainCode = void 0;
|
|
502
691
|
}
|
|
503
692
|
root.remove();
|
|
504
693
|
},
|
|
@@ -592,9 +781,12 @@ function decodeCodeText(html) {
|
|
|
592
781
|
return m[1].replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
593
782
|
}
|
|
594
783
|
const CONTAINER_ATTR_RE = /([a-zA-Z][a-zA-Z0-9-]*)="([^"]*)"/g;
|
|
595
|
-
function
|
|
784
|
+
function openTagOf(html) {
|
|
596
785
|
const gt = html.indexOf(">");
|
|
597
|
-
|
|
786
|
+
return gt < 0 ? html : html.slice(0, gt);
|
|
787
|
+
}
|
|
788
|
+
function applyOpenTagAttrs(el, html) {
|
|
789
|
+
const open = openTagOf(html);
|
|
598
790
|
let m;
|
|
599
791
|
CONTAINER_ATTR_RE.lastIndex = 0;
|
|
600
792
|
while (m = CONTAINER_ATTR_RE.exec(open)) {
|
|
@@ -653,6 +845,8 @@ function applyUrlTransformDom(root, urlTransform) {
|
|
|
653
845
|
});
|
|
654
846
|
}
|
|
655
847
|
export {
|
|
848
|
+
__keyedStats,
|
|
849
|
+
__resetKeyedStats,
|
|
656
850
|
mountBrookMarkdown,
|
|
657
851
|
tailOpenBlockId
|
|
658
852
|
};
|
package/dist/hi-defer.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type HighlightState } from "./hi.js";
|
|
1
2
|
/**
|
|
2
3
|
* Test-only: shrink the per-slice budget so a suite can force the deferred path
|
|
3
4
|
* deterministically instead of betting on how fast the machine is. Call with no
|
|
@@ -27,9 +28,9 @@ export interface DeferredHighlight {
|
|
|
27
28
|
* renderer can call it from a render pass and only reach for
|
|
28
29
|
* {@link highlightDeferred} when this comes back empty.
|
|
29
30
|
*/
|
|
30
|
-
export declare function highlightWithin(code: string, lang: string): string | null;
|
|
31
|
+
export declare function highlightWithin(code: string, lang: string, seed?: HighlightState): string | null;
|
|
31
32
|
/**
|
|
32
33
|
* Highlight `code` without blocking: the first slice runs here, synchronously,
|
|
33
34
|
* and the rest (if any) continues on later tasks. See {@link DeferredHighlight}.
|
|
34
35
|
*/
|
|
35
|
-
export declare function highlightDeferred(code: string, lang: string): DeferredHighlight;
|
|
36
|
+
export declare function highlightDeferred(code: string, lang: string, seed?: HighlightState): DeferredHighlight;
|