brookmd 0.26.1 → 0.27.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 CHANGED
@@ -4,6 +4,49 @@ 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.27.0 — 2026-07-31
8
+
9
+ ### Added
10
+
11
+ - **Streaming syntax highlighting — open code blocks now highlight live, on by
12
+ default.** Since 0.19 the built-in highlighter deliberately waited for a
13
+ fence to close; a streaming block showed plain text. Open blocks now render
14
+ highlighted as they grow, and the mechanism preserves every guarantee the
15
+ deferred design existed to protect:
16
+
17
+ - **Byte-identical at settle.** All committed markup comes from seeded runs
18
+ of the same resumable tokenizer that `highlight()` uses — the frozen
19
+ prefix is only ever extended at *checkpoints* (positions provably outside
20
+ any future token: after a newline in whitespace, ≥ 3 bytes behind the
21
+ stream head, with no unterminated string/comment/template live; HTML
22
+ checkpoints after `>` instead). When the block closes, one seeded run
23
+ finishes the tail, so the final bytes equal a one-shot `highlight()` —
24
+ pinned by a ~44,000-case fuzz (all languages, chunk sizes down to 1 byte,
25
+ plus speculative-revision streams) asserting settle-identity and that
26
+ every intermediate frozen prefix is a byte-prefix of the final output.
27
+ - **Bounded work, independent of block size.** Between checkpoints, an
28
+ unterminated string/comment advances via an O(1)-state scanner over only
29
+ the new bytes; the tail re-scan is capped at 8 KB (past the cap the tail
30
+ renders plain until the next checkpoint — correctness unaffected). Total
31
+ tokenization work measures ≈ 5.7× the streamed bytes end-to-end against
32
+ the real parser, flat from 288 B to 23 KB blocks (naive re-highlighting
33
+ measures 812× more at 23 KB); a work-bound test enforces < 6×. The
34
+ 50 000-char plain-escape guard still applies and discards streaming state.
35
+ - **The tail is speculative.** The few tokens nearest the stream head may
36
+ change color as bytes arrive (an unterminated `"` reads as plain until its
37
+ closer lands) — the same speculative-tail behavior brookmd's links and
38
+ emphasis already have. Frozen output never changes.
39
+
40
+ Opt out with `streamingHighlight={false}` on `BrookMarkdown` (React) or
41
+ `streamingHighlight: false` in `mountBrookMarkdown` options (DOM, and via it
42
+ the Web Component and Vue/Svelte/Solid adapters) — that restores 0.26.1's
43
+ plain-until-close exactly. Not a `ParserConfig` field: highlighting is a
44
+ renderer concern; the parser, wire, and WASM are untouched. SSR is
45
+ unchanged (closed blocks, synchronous), and `components.CodeBlock` /
46
+ `pre` / `code` overrides still bypass the built-in highlighter entirely.
47
+ Closing a block that streamed in now also highlights near-instantly: the
48
+ close-time pass reuses the accumulated prefix instead of starting over.
49
+
7
50
  ## 0.26.1 — 2026-07-30
8
51
 
9
52
  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 (syntax highlighting, math, mermaid) are **deferred until a block closes**. The result is low retained memory and a main thread that stays responsive while streaming. See [the live demo](https://md.hsingh.app/).
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), `batch` (default true — one DOM write per `requestAnimationFrame`),
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/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
  /**
package/dist/dom.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { highlightDeferred } from "./hi-defer.js";
2
+ import { createInc, incHighlight, incSeed } from "./hi-inc.js";
2
3
  import { morph } from "./morph.js";
3
4
  import { blockProps, extractLang } from "./block-props.js";
4
5
  import { decorateSegments } from "./decorate.js";
@@ -28,6 +29,7 @@ function mountBrookMarkdown(client, container, options = {}) {
28
29
  const hasInlineTransforms = !!decorators || !!urlTransform;
29
30
  const hasPerf = typeof performance !== "undefined";
30
31
  const highlightCode = options.highlightCode !== false && !components?.CodeBlock;
32
+ const streamingHighlight = options.streamingHighlight !== false;
31
33
  const batch = options.batch !== false && typeof requestAnimationFrame === "function";
32
34
  const morphOpenBlocks = options.morphOpenBlocks === true;
33
35
  const root = document.createElement("div");
@@ -115,6 +117,7 @@ function mountBrookMarkdown(client, container, options = {}) {
115
117
  continue;
116
118
  }
117
119
  existing.table = void 0;
120
+ if (existing.inc && b.kind.type !== "CodeBlock") existing.inc = void 0;
118
121
  if (existing.highlight) {
119
122
  existing.highlight.cancel();
120
123
  existing.highlight = void 0;
@@ -133,6 +136,7 @@ function mountBrookMarkdown(client, container, options = {}) {
133
136
  for (const [id, mb] of mounted) {
134
137
  if (!seen.has(id)) {
135
138
  if (mb.highlight) mb.highlight.cancel();
139
+ mb.inc = void 0;
136
140
  mb.node.remove();
137
141
  mounted.delete(id);
138
142
  }
@@ -349,8 +353,19 @@ function mountBrookMarkdown(client, container, options = {}) {
349
353
  function renderCodeBlock(b, mb) {
350
354
  const lang = extractLang(b.html) || "text";
351
355
  const text = b.open ? "" : codeText(b);
352
- const run = text ? highlightDeferred(text, lang) : null;
353
- const highlighted = run ? run.html : null;
356
+ let openMarkup = null;
357
+ if (b.open && streamingHighlight) {
358
+ let inc = mb.inc;
359
+ if (inc === void 0 || inc.lang !== lang.toLowerCase()) {
360
+ inc = createInc(lang) ?? void 0;
361
+ mb.inc = inc;
362
+ }
363
+ if (inc !== void 0) openMarkup = incHighlight(inc, codeText(b));
364
+ }
365
+ const seed = !b.open && mb.inc !== void 0 ? incSeed(mb.inc, text, lang) : void 0;
366
+ const run = text ? highlightDeferred(text, lang, seed) : null;
367
+ if (!b.open) mb.inc = void 0;
368
+ const highlighted = openMarkup ?? (run ? run.html : null);
354
369
  const block = document.createElement("div");
355
370
  block.className = "brook-code-block" + (b.open ? " brook-streaming" : "");
356
371
  const header = document.createElement("div");
@@ -499,6 +514,7 @@ function mountBrookMarkdown(client, container, options = {}) {
499
514
  mb.highlight.cancel();
500
515
  mb.highlight = void 0;
501
516
  }
517
+ mb.inc = void 0;
502
518
  }
503
519
  root.remove();
504
520
  },
@@ -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;
package/dist/hi-defer.js CHANGED
@@ -8,12 +8,15 @@ function __setSliceMs(ms) {
8
8
  }
9
9
  const noop = () => {
10
10
  };
11
- function highlightWithin(code, lang) {
12
- const state = { pos: 0, out: "" };
11
+ function seeded(seed) {
12
+ return seed === void 0 ? { pos: 0, out: "" } : { pos: seed.pos, out: seed.out };
13
+ }
14
+ function highlightWithin(code, lang, seed) {
15
+ const state = seeded(seed);
13
16
  return runSlice(code, lang, state) ? state.out : null;
14
17
  }
15
- function highlightDeferred(code, lang) {
16
- const state = { pos: 0, out: "" };
18
+ function highlightDeferred(code, lang, seed) {
19
+ const state = seeded(seed);
17
20
  if (runSlice(code, lang, state)) {
18
21
  return { html: state.out, rest: null, cancel: noop };
19
22
  }
@@ -0,0 +1,102 @@
1
+ import { type HighlightState } from "./hi.js";
2
+ /**
3
+ * How an unbounded opener recognizes its terminator, with the O(1) state each
4
+ * one carries between patches:
5
+ *
6
+ * - `char` — the first occurrence of `ch` closes (`[^ch]*ch` forms: Go raw
7
+ * strings, HTML/CSS attribute strings, Rust `#[…]`, Bash `'…'` and `${…}`).
8
+ * - `starSlash` — `prevStar`, for `/* … *\/`.
9
+ * - `dashGt` — `dashRun`, for `<!-- … -->`.
10
+ * - `esc` — `esc`, for backslash-escaped strings and template literals. A
11
+ * backslash immediately before a newline is {@link DEAD}, not an escape: the
12
+ * pattern's `\\.` does not match a newline, so the form can never close.
13
+ * - `triple` — `runLen`, for Python `"""` / `'''`.
14
+ * - `dbl` — `pendingQuote`, for SQL's doubled-quote escaping (`'a''b'`).
15
+ */
16
+ type PredKind = {
17
+ k: "char";
18
+ ch: string;
19
+ } | {
20
+ k: "starSlash";
21
+ } | {
22
+ k: "dashGt";
23
+ } | {
24
+ k: "esc";
25
+ ch: string;
26
+ } | {
27
+ k: "triple";
28
+ ch: string;
29
+ } | {
30
+ k: "dbl";
31
+ ch: string;
32
+ };
33
+ /** @internal */
34
+ export interface Opener {
35
+ /** Sticky regex for the OPENING delimiter only — bounded, O(1) to test. */
36
+ re: RegExp;
37
+ /** The pattern class the tokenizer emits when this form IS terminated. */
38
+ cls: string;
39
+ /** The literal terminator. */
40
+ end: string;
41
+ pred: PredKind;
42
+ }
43
+ /** @internal The carried state of one opener's continuation predicate. */
44
+ export interface OpenerScanState {
45
+ n: number;
46
+ f: boolean;
47
+ }
48
+ /** Per-block incremental state. Create with {@link createInc}, feed {@link incHighlight}. */
49
+ export interface IncState {
50
+ /** The language key this state's tables were chosen for. */
51
+ readonly lang: string;
52
+ /** Settled through here: `[0, c)` of the source will never re-tokenize. */
53
+ c: number;
54
+ /** Markup for `[0, c)`. Always a byte-prefix of the block's final markup. */
55
+ frozenHtml: string;
56
+ /** The checkpoint BEFORE `c`, and the `frozenHtml` length that went with it —
57
+ * the one step of rewind a tail revision needs (see {@link adopt}). */
58
+ c0: number;
59
+ frozenLen0: number;
60
+ /** The unbounded opener live at the tail, or `null`. */
61
+ opener: Opener | null;
62
+ /** `opener`'s carried predicate state. */
63
+ scan: OpenerScanState;
64
+ /** `opener` can never close — stop re-scanning and render the tail plain. */
65
+ sealed: boolean;
66
+ /** The source last fed in, for the append/revision guard. */
67
+ text: string;
68
+ /** Escaped source for `[c, plainUpto)` — the plain tail, extended in place. */
69
+ plain: string;
70
+ plainFrom: number;
71
+ plainUpto: number;
72
+ /** The markup last handed out, so a repeated feed of the same text is free. */
73
+ html: string | null;
74
+ }
75
+ /**
76
+ * State for a block in `lang`, or `null` when the language has no table (the
77
+ * plain-escape fallback has no token boundaries to checkpoint on).
78
+ */
79
+ export declare function createInc(lang: string): IncState | null;
80
+ /**
81
+ * Feed the block's CURRENT full source and get the markup for all of it, or
82
+ * `null` when the incremental path has bowed out (past {@link CLIFF}) and the
83
+ * caller should render the plain escaped body exactly as it does today.
84
+ *
85
+ * Calls must be append-only. Anything else — a `reset()`, a speculative tail
86
+ * revision, one block's id being reused for different content — is detected
87
+ * (`text.startsWith(prev)`, the same guard the DOM renderer's prefix-append fast
88
+ * path uses) and simply restarts the state from scratch.
89
+ */
90
+ export declare function incHighlight(st: IncState, text: string): string | null;
91
+ /**
92
+ * The `{pos, out}` a close-time `highlightWithin`/`highlightDeferred` run should
93
+ * resume from, or `undefined` when nothing was frozen or the state does not
94
+ * belong to `text`/`lang` (a revised block, a different language, a block that
95
+ * crossed the cliff). Then the close-time run is the unseeded one it always was.
96
+ */
97
+ export declare function incSeed(st: IncState, text: string, lang: string): HighlightState | undefined;
98
+ /** @internal Test-only: total source bytes re-tokenized since the last reset. */
99
+ export declare function __getIncScanned(): number;
100
+ /** @internal Test-only. */
101
+ export declare function __resetIncScanned(): void;
102
+ export {};
package/dist/hi-inc.js ADDED
@@ -0,0 +1,317 @@
1
+ import { escapeHtml, stepHighlight } from "./hi.js";
2
+ const GAP = 3;
3
+ const CAP = 8192;
4
+ const CLIFF = 5e4;
5
+ const OPEN = 0;
6
+ const CLOSED = 1;
7
+ const DEAD = 2;
8
+ const BLOCK_COMMENT = { re: /\/\*/y, cls: "com", end: "*/", pred: { k: "starSlash" } };
9
+ const JS_OPENERS = [
10
+ BLOCK_COMMENT,
11
+ { re: /`/y, cls: "str", end: "`", pred: { k: "esc", ch: "`" } }
12
+ ];
13
+ const RUST_OPENERS = [
14
+ BLOCK_COMMENT,
15
+ { re: /b?"/y, cls: "str", end: '"', pred: { k: "esc", ch: '"' } },
16
+ { re: /#!?\[/y, cls: "attr", end: "]", pred: { k: "char", ch: "]" } }
17
+ ];
18
+ const PY_OPENERS = [
19
+ { re: /[fFrRbB]{0,2}"""/y, cls: "str", end: '"""', pred: { k: "triple", ch: '"' } },
20
+ { re: /[fFrRbB]{0,2}'''/y, cls: "str", end: "'''", pred: { k: "triple", ch: "'" } }
21
+ ];
22
+ const GO_OPENERS = [
23
+ BLOCK_COMMENT,
24
+ { re: /`/y, cls: "str", end: "`", pred: { k: "char", ch: "`" } }
25
+ ];
26
+ const BASH_OPENERS = [
27
+ { re: /"/y, cls: "str", end: '"', pred: { k: "esc", ch: '"' } },
28
+ { re: /'/y, cls: "str", end: "'", pred: { k: "char", ch: "'" } },
29
+ { re: /\$\{/y, cls: "var", end: "}", pred: { k: "char", ch: "}" } }
30
+ ];
31
+ const SQL_OPENERS = [
32
+ BLOCK_COMMENT,
33
+ { re: /'/y, cls: "str", end: "'", pred: { k: "dbl", ch: "'" } },
34
+ { re: /"/y, cls: "str", end: '"', pred: { k: "dbl", ch: '"' } }
35
+ ];
36
+ const HTML_OPENERS = [
37
+ { re: /<!--/y, cls: "com", end: "-->", pred: { k: "dashGt" } },
38
+ { re: /"/y, cls: "str", end: '"', pred: { k: "char", ch: '"' } },
39
+ { re: /'/y, cls: "str", end: "'", pred: { k: "char", ch: "'" } }
40
+ ];
41
+ const CSS_OPENERS = [
42
+ BLOCK_COMMENT,
43
+ { re: /"/y, cls: "str", end: '"', pred: { k: "char", ch: '"' } },
44
+ { re: /'/y, cls: "str", end: "'", pred: { k: "char", ch: "'" } }
45
+ ];
46
+ const OPENERS = {
47
+ js: JS_OPENERS,
48
+ javascript: JS_OPENERS,
49
+ ts: JS_OPENERS,
50
+ tsx: JS_OPENERS,
51
+ jsx: JS_OPENERS,
52
+ typescript: JS_OPENERS,
53
+ rust: RUST_OPENERS,
54
+ rs: RUST_OPENERS,
55
+ py: PY_OPENERS,
56
+ python: PY_OPENERS,
57
+ go: GO_OPENERS,
58
+ bash: BASH_OPENERS,
59
+ sh: BASH_OPENERS,
60
+ shell: BASH_OPENERS,
61
+ json: [],
62
+ sql: SQL_OPENERS,
63
+ html: HTML_OPENERS,
64
+ xml: HTML_OPENERS,
65
+ css: CSS_OPENERS
66
+ };
67
+ const GT_CHECKPOINT = /* @__PURE__ */ new Set(["html", "xml"]);
68
+ function createInc(lang) {
69
+ const key = lang.toLowerCase();
70
+ if (!Object.prototype.hasOwnProperty.call(OPENERS, key)) return null;
71
+ return {
72
+ lang: key,
73
+ c: 0,
74
+ frozenHtml: "",
75
+ c0: 0,
76
+ frozenLen0: 0,
77
+ opener: null,
78
+ scan: { n: 0, f: false },
79
+ sealed: false,
80
+ text: "",
81
+ plain: "",
82
+ plainFrom: 0,
83
+ plainUpto: 0,
84
+ html: null
85
+ };
86
+ }
87
+ function divergence(a, b) {
88
+ const n = a.length < b.length ? a.length : b.length;
89
+ let i = 0;
90
+ while (i < n && a.charCodeAt(i) === b.charCodeAt(i)) i++;
91
+ return i;
92
+ }
93
+ function adopt(st, d) {
94
+ if (st.c > 0 && d >= st.c + GAP) {
95
+ dropTail(st);
96
+ return true;
97
+ }
98
+ if (st.c0 > 0 && d >= st.c0 + GAP) {
99
+ st.frozenHtml = st.frozenHtml.slice(0, st.frozenLen0);
100
+ st.c = st.c0;
101
+ st.c0 = 0;
102
+ st.frozenLen0 = 0;
103
+ dropTail(st);
104
+ return true;
105
+ }
106
+ return false;
107
+ }
108
+ function dropTail(st) {
109
+ st.opener = null;
110
+ st.scan = { n: 0, f: false };
111
+ st.sealed = false;
112
+ st.plain = "";
113
+ st.plainFrom = st.c;
114
+ st.plainUpto = st.c;
115
+ st.html = null;
116
+ }
117
+ function reset(st) {
118
+ st.c = 0;
119
+ st.frozenHtml = "";
120
+ st.c0 = 0;
121
+ st.frozenLen0 = 0;
122
+ st.opener = null;
123
+ st.scan = { n: 0, f: false };
124
+ st.sealed = false;
125
+ st.text = "";
126
+ st.plain = "";
127
+ st.plainFrom = 0;
128
+ st.plainUpto = 0;
129
+ st.html = null;
130
+ }
131
+ function incHighlight(st, text) {
132
+ if (st.text === text) return st.html;
133
+ const d = divergence(st.text, text);
134
+ const appended = d === st.text.length && text.length > st.text.length;
135
+ let from = st.text.length;
136
+ if (!appended) {
137
+ if (!adopt(st, d)) reset(st);
138
+ from = 0;
139
+ }
140
+ st.text = text;
141
+ if (text.length > CLIFF) {
142
+ reset(st);
143
+ st.text = text;
144
+ return null;
145
+ }
146
+ const live = st.opener;
147
+ if (live !== null) {
148
+ const r = st.sealed ? OPEN : feed(live.pred, st.scan, text, from, text.length);
149
+ if (r === DEAD) st.sealed = true;
150
+ if (r !== CLOSED) {
151
+ st.html = st.frozenHtml + plainTail(st, text);
152
+ return st.html;
153
+ }
154
+ st.opener = null;
155
+ }
156
+ if (text.length - st.c > CAP) {
157
+ st.html = st.frozenHtml + plainTail(st, text);
158
+ return st.html;
159
+ }
160
+ st.html = rescan(st, text);
161
+ return st.html;
162
+ }
163
+ function incSeed(st, text, lang) {
164
+ if (st.c === 0 || st.lang !== lang.toLowerCase()) return void 0;
165
+ if (text.length > CLIFF || text.length < st.c) return void 0;
166
+ if (!text.startsWith(st.text.slice(0, st.c))) return void 0;
167
+ return { pos: st.c, out: st.frozenHtml };
168
+ }
169
+ let scanned = 0;
170
+ function __getIncScanned() {
171
+ return scanned;
172
+ }
173
+ function __resetIncScanned() {
174
+ scanned = 0;
175
+ }
176
+ function plainTail(st, text) {
177
+ if (st.plainFrom !== st.c || st.plainUpto > text.length) {
178
+ st.plainFrom = st.c;
179
+ st.plain = "";
180
+ st.plainUpto = st.c;
181
+ }
182
+ if (st.plainUpto < text.length) {
183
+ st.plain += escapeHtml(text.slice(st.plainUpto));
184
+ st.plainUpto = text.length;
185
+ }
186
+ return st.plain;
187
+ }
188
+ function rescan(st, text) {
189
+ const openers = OPENERS[st.lang];
190
+ const gt = GT_CHECKPOINT.has(st.lang);
191
+ const limit = text.length - GAP;
192
+ let cp = -1;
193
+ let cpOut = 0;
194
+ let liveAt = -1;
195
+ let liveOp = null;
196
+ let liveLen = 0;
197
+ const sink = (cls, start, end, outLen) => {
198
+ if (liveAt >= 0) return;
199
+ for (let i = 0; i < openers.length; i++) {
200
+ const op = openers[i];
201
+ op.re.lastIndex = start;
202
+ const m = op.re.exec(text);
203
+ if (m === null || m.index !== start) continue;
204
+ const terminated = cls === op.cls && end - start >= m[0].length + op.end.length && text.startsWith(op.end, end - op.end.length);
205
+ if (terminated) continue;
206
+ liveAt = start;
207
+ liveOp = op;
208
+ liveLen = m[0].length;
209
+ return;
210
+ }
211
+ if (gt) {
212
+ if (cls === "pun" && end - start === 1 && text.charCodeAt(start) === 62 && end <= limit) {
213
+ cp = end;
214
+ cpOut = outLen;
215
+ }
216
+ return;
217
+ }
218
+ if (cls !== "ws") return;
219
+ const hi = end < limit ? end : limit;
220
+ if (hi <= start) return;
221
+ const nl = text.lastIndexOf("\n", hi - 1);
222
+ if (nl < start) return;
223
+ cp = nl + 1;
224
+ cpOut = outLen - (end - cp);
225
+ };
226
+ const state = { pos: st.c, out: "" };
227
+ scanned += text.length - st.c;
228
+ while (!stepHighlight(text, st.lang, state, text.length, sink)) {
229
+ }
230
+ const full = st.frozenHtml + state.out;
231
+ if (cp > st.c) {
232
+ st.c0 = st.c;
233
+ st.frozenLen0 = st.frozenHtml.length;
234
+ st.frozenHtml += state.out.slice(0, cpOut);
235
+ st.c = cp;
236
+ }
237
+ const found = liveOp;
238
+ if (found === null) {
239
+ st.opener = null;
240
+ } else {
241
+ st.scan = { n: 0, f: false };
242
+ const r = feed(found.pred, st.scan, text, liveAt + liveLen, text.length);
243
+ st.opener = r === CLOSED ? null : found;
244
+ st.sealed = r === DEAD;
245
+ }
246
+ return full;
247
+ }
248
+ function feed(p, st, s, from, to) {
249
+ switch (p.k) {
250
+ case "char": {
251
+ const i = s.indexOf(p.ch, from);
252
+ return i >= 0 && i < to ? CLOSED : OPEN;
253
+ }
254
+ case "starSlash": {
255
+ for (let i = from; i < to; i++) {
256
+ const ch = s[i];
257
+ if (st.f && ch === "/") return CLOSED;
258
+ st.f = ch === "*";
259
+ }
260
+ return OPEN;
261
+ }
262
+ case "dashGt": {
263
+ for (let i = from; i < to; i++) {
264
+ const ch = s[i];
265
+ if (ch === ">" && st.n >= 2) return CLOSED;
266
+ st.n = ch === "-" ? st.n + 1 : 0;
267
+ }
268
+ return OPEN;
269
+ }
270
+ case "esc": {
271
+ for (let i = from; i < to; i++) {
272
+ const ch = s[i];
273
+ if (st.f) {
274
+ st.f = false;
275
+ if (ch === "\n") return DEAD;
276
+ continue;
277
+ }
278
+ if (ch === "\\") {
279
+ st.f = true;
280
+ continue;
281
+ }
282
+ if (ch === p.ch) return CLOSED;
283
+ }
284
+ return OPEN;
285
+ }
286
+ case "triple": {
287
+ for (let i = from; i < to; i++) {
288
+ if (s[i] === p.ch) {
289
+ st.n++;
290
+ if (st.n === 3) return CLOSED;
291
+ } else {
292
+ st.n = 0;
293
+ }
294
+ }
295
+ return OPEN;
296
+ }
297
+ case "dbl": {
298
+ for (let i = from; i < to; i++) {
299
+ const ch = s[i];
300
+ if (st.f) {
301
+ st.f = false;
302
+ if (ch !== p.ch) return CLOSED;
303
+ continue;
304
+ }
305
+ if (ch === p.ch) st.f = true;
306
+ }
307
+ return OPEN;
308
+ }
309
+ }
310
+ }
311
+ export {
312
+ __getIncScanned,
313
+ __resetIncScanned,
314
+ createInc,
315
+ incHighlight,
316
+ incSeed
317
+ };
package/dist/hi.d.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * highlight an open (streaming) block, which avoids re-highlighting the same
9
9
  * code on every chunk — the main perf win for streaming code.
10
10
  */
11
+ export declare function escapeHtml(s: string): string;
11
12
  /**
12
13
  * The resumable tokenizer's cursor: `pos` is the next source index to consume,
13
14
  * `out` the markup emitted so far. Start a run at `{ pos: 0, out: "" }`.
@@ -16,6 +17,21 @@ export interface HighlightState {
16
17
  pos: number;
17
18
  out: string;
18
19
  }
20
+ /**
21
+ * Called once per token the tokenizer emits, AFTER its markup is appended:
22
+ * `(cls, start, end, outLen)` where `cls` is the PATTERN class (`ws`, `str`,
23
+ * `com`, `pun`, `ident`… — not the `kw`/`fn`/`ty` refinement), `[start, end)` is
24
+ * the source span, and `outLen` is `state.out.length` once the token has been
25
+ * written. The catch-all one-character fallback reports `cls === ""`.
26
+ *
27
+ * Passing no sink is the default and costs one `undefined` test per token; the
28
+ * escape-fallback path (unknown language / over the size guard) emits no tokens
29
+ * and so reports nothing.
30
+ *
31
+ * @internal The incremental streaming path (hi-inc.ts) is the only consumer —
32
+ * it needs token boundaries to pick a checkpoint that survives an append.
33
+ */
34
+ export type TokenSink = (cls: string, start: number, end: number, outLen: number) => void;
19
35
  /**
20
36
  * One resumable slice of {@link highlight}. Consumes WHOLE tokens from
21
37
  * `state.pos` until at least `chars` source characters have been taken (or the
@@ -32,6 +48,6 @@ export interface HighlightState {
32
48
  *
33
49
  * @internal Not part of the semver surface — use {@link highlight}.
34
50
  */
35
- export declare function stepHighlight(code: string, lang: string, state: HighlightState, chars: number): boolean;
51
+ export declare function stepHighlight(code: string, lang: string, state: HighlightState, chars: number, sink?: TokenSink): boolean;
36
52
  export declare function highlight(code: string, lang: string): string;
37
53
  export declare function supportedLangs(): string[];
package/dist/hi.js CHANGED
@@ -169,7 +169,7 @@ function escapeHtml(s) {
169
169
  }
170
170
  return out + s.slice(last);
171
171
  }
172
- function stepHighlight(code, lang, state, chars) {
172
+ function stepHighlight(code, lang, state, chars, sink) {
173
173
  const conf = code.length > 5e4 ? void 0 : LANGS[lang.toLowerCase()];
174
174
  const stop = state.pos + (chars > 0 ? chars : 1);
175
175
  if (!conf) {
@@ -201,6 +201,7 @@ function stepHighlight(code, lang, state, chars) {
201
201
  finalCls = "ty";
202
202
  } else {
203
203
  out += escapeHtml(text);
204
+ if (sink) sink(cls, pos, after, out.length);
204
205
  pos = after;
205
206
  matched = true;
206
207
  break;
@@ -211,12 +212,14 @@ function stepHighlight(code, lang, state, chars) {
211
212
  } else {
212
213
  out += `<span class="t-${finalCls}">${escapeHtml(text)}</span>`;
213
214
  }
215
+ if (sink) sink(cls, pos, after, out.length);
214
216
  pos = after;
215
217
  matched = true;
216
218
  break;
217
219
  }
218
220
  if (!matched) {
219
221
  out += escapeHtml(code[pos]);
222
+ if (sink) sink("", pos, pos + 1, out.length);
220
223
  pos += 1;
221
224
  }
222
225
  }
@@ -234,6 +237,7 @@ function supportedLangs() {
234
237
  return Object.keys(LANGS);
235
238
  }
236
239
  export {
240
+ escapeHtml,
237
241
  highlight,
238
242
  stepHighlight,
239
243
  supportedLangs
package/dist/react.d.ts CHANGED
@@ -128,6 +128,22 @@ interface BrookMarkdownProps {
128
128
  * applies only to the streaming tail.
129
129
  */
130
130
  childMemo?: boolean;
131
+ /**
132
+ * Highlight a code fence **while it is still streaming**, instead of showing
133
+ * plain escaped text until it closes. On by default.
134
+ *
135
+ * An open block keeps a frozen prefix and re-tokenizes only its tail on each
136
+ * patch, so this stays linear in the block's size (it does not re-highlight
137
+ * the whole fence per chunk). The settled markup is byte-identical either way
138
+ * — only the tail's colours are provisional, and they may shift as bytes
139
+ * arrive (`"hello` is a stray quote plus an identifier until its closing quote
140
+ * lands). Set `false` for the pre-0.27 behaviour: plain body until close.
141
+ *
142
+ * No effect on SSR (the server renders closed blocks only), and none at all
143
+ * when `components.CodeBlock` / `components.pre` / `components.code` take over
144
+ * the block — an override bypasses the built-in highlighter entirely.
145
+ */
146
+ streamingHighlight?: boolean;
131
147
  /** Appended to the root's `className` (the `brook-md` class is always present). */
132
148
  className?: string;
133
149
  /** Set on the root element. */
@@ -264,6 +280,7 @@ interface BlockViewProps {
264
280
  virtualize?: boolean;
265
281
  sanitize?: (html: string) => string;
266
282
  childMemo?: boolean;
283
+ streamingHighlight?: boolean;
267
284
  onRenderMetrics?: RenderMetricsHook;
268
285
  decorators?: Decorator[];
269
286
  urlTransform?: UrlTransform;
package/dist/react.js CHANGED
@@ -52,6 +52,7 @@ function BrookMarkdownFromClient({
52
52
  stickToBottom,
53
53
  sanitize,
54
54
  childMemo,
55
+ streamingHighlight,
55
56
  className,
56
57
  id,
57
58
  role,
@@ -104,6 +105,7 @@ function BrookMarkdownFromClient({
104
105
  virtualize,
105
106
  sanitize,
106
107
  childMemo,
108
+ streamingHighlight,
107
109
  onRenderMetrics: onMetrics,
108
110
  decorators,
109
111
  urlTransform,
@@ -486,6 +488,7 @@ function renderBlockContent({
486
488
  components,
487
489
  sanitize,
488
490
  childMemo,
491
+ streamingHighlight,
489
492
  decorators,
490
493
  urlTransform
491
494
  }) {
@@ -515,7 +518,8 @@ function renderBlockContent({
515
518
  {
516
519
  html: block.html,
517
520
  open: block.open,
518
- code: typeof source === "string" ? source : void 0
521
+ code: typeof source === "string" ? source : void 0,
522
+ streamingHighlight
519
523
  }
520
524
  );
521
525
  }
@@ -580,7 +584,7 @@ function renderBlockContent({
580
584
  }
581
585
  function blocksEqual(prev, next) {
582
586
  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)
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)
584
588
  // busts the memo so every committed block re-decorates — the O(n²) footgun
585
589
  // the dev warning calls out. A hoisted/memoized value keeps the memo holding.
586
590
  prev.decorators === next.decorators && prev.urlTransform === next.urlTransform && // Same identity rule as onRenderMetrics: an inline `onBlockError={() => …}`
@@ -8,7 +8,9 @@ interface Props {
8
8
  * as the highlight itself. Absent (blockData off) the HTML is decoded here.
9
9
  */
10
10
  code?: string;
11
+ /** Highlight the block while it is still open. Default true. */
12
+ streamingHighlight?: boolean;
11
13
  }
12
- declare function CodeBlockImpl({ html, open, code }: Props): import("react/jsx-runtime").JSX.Element;
14
+ declare function CodeBlockImpl({ html, open, code, streamingHighlight }: Props): import("react/jsx-runtime").JSX.Element;
13
15
  export declare const CodeBlock: import("react").MemoExoticComponent<typeof CodeBlockImpl>;
14
16
  export {};
@@ -2,19 +2,43 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
2
  import { memo, useCallback, useEffect, 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";
5
6
  import { extractLang } from "../block-props.js";
6
7
  function decodeText(html) {
7
8
  const m = html.match(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
8
9
  if (!m) return "";
9
10
  return m[1].replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
10
11
  }
11
- function CodeBlockImpl({ html, open, code }) {
12
+ function CodeBlockImpl({ html, open, code, streamingHighlight }) {
12
13
  const lang = extractLang(html) || "text";
13
14
  const text = useMemo(() => open ? "" : code ?? decodeText(html), [html, open, code]);
15
+ const streaming = open && streamingHighlight !== false;
16
+ const openText = useMemo(
17
+ () => streaming ? code ?? decodeText(html) : "",
18
+ [streaming, code, html]
19
+ );
20
+ const incRef = useRef(null);
21
+ const [inc, setInc] = useState(null);
14
22
  const sync = useMemo(() => {
15
23
  if (!text) return null;
16
- return typeof window === "undefined" ? highlight(text, lang) : highlightWithin(text, lang);
24
+ if (typeof window === "undefined") return highlight(text, lang);
25
+ const st = incRef.current;
26
+ return highlightWithin(text, lang, st ? incSeed(st, text, lang) : void 0);
17
27
  }, [text, lang]);
28
+ useEffect(() => {
29
+ if (!streaming || typeof window === "undefined") {
30
+ incRef.current = null;
31
+ setInc((prev) => prev === null ? prev : null);
32
+ return;
33
+ }
34
+ let st = incRef.current;
35
+ if (st === null || st.lang !== lang.toLowerCase()) {
36
+ st = createInc(lang);
37
+ incRef.current = st;
38
+ }
39
+ const markup = st === null ? null : incHighlight(st, openText);
40
+ setInc(markup === null ? null : { lang, html: markup });
41
+ }, [streaming, openText, lang]);
18
42
  const [slow, setSlow] = useState(null);
19
43
  useEffect(() => {
20
44
  if (!text || sync !== null) {
@@ -38,7 +62,10 @@ function CodeBlockImpl({ html, open, code }) {
38
62
  run.cancel();
39
63
  };
40
64
  }, [text, lang, sync]);
41
- const highlighted = sync ?? (slow !== null && slow.text === text && slow.lang === lang ? slow.html : null);
65
+ const highlighted = sync ?? (slow !== null && slow.text === text && slow.lang === lang ? slow.html : null) ?? // The streaming tail. Not gated on `openText` identity: the markup lags the
66
+ // props by one commit, and showing last patch's spans beats flashing the
67
+ // whole block back to plain every tick. A language change does invalidate it.
68
+ (streaming && inc !== null && inc.lang === lang ? inc.html : null);
42
69
  const [copied, setCopied] = useState(false);
43
70
  const timerRef = useRef(null);
44
71
  useEffect(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brookmd",
3
- "version": "0.26.1",
3
+ "version": "0.27.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"],