brookmd 0.26.0 → 0.26.1

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,47 @@ 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.26.1 — 2026-07-30
8
+
9
+ Two fixes found by benchmarking 0.26.0 against real chat traffic. Requires
10
+ `brookmd-core` 0.25.1.
11
+
12
+ ### Fixed
13
+
14
+ - **A GFM table could not interrupt an open paragraph — anywhere.**
15
+ `scan_paragraph` had no table arm, so `item\n| a | b |\n|---|---|` swallowed
16
+ the delimiter row as paragraph text. The visible symptom was "tables inside
17
+ list items don't parse" (an item's de-indented body opens with a paragraph),
18
+ but the bug was position-independent and hit top level and blockquotes the
19
+ same way. The fix is an O(2-line) gate — header row with a pipe, delimiter
20
+ row next, matching cell counts, both rows indented ≤ 3 columns — checked once
21
+ per line as it arrives, on both the full-reparse and streaming paths, so a
22
+ scan started at a commit boundary renders byte-identically to a cold one.
23
+ Verified against GitHub's rendering for every non-pathological shape; the
24
+ deliberate exception (both rows must be ≤ 3-indented, so two exotic
25
+ mixed-indent shapes stay paragraphs) is pinned in `tests/nested_tables.rs`.
26
+ A nested table renders inside the item's `html` and — like nested lists —
27
+ carries no structured `blockData` of its own.
28
+
29
+ ### Performance
30
+
31
+ - **Close-time syntax highlighting no longer blocks the main thread.** The
32
+ built-in highlighter ran as one synchronous task when a block closed —
33
+ ~110 ms for a large fence on a mid desktop. The tokenizer loop was already
34
+ resumable at any offset with no carried state, so it now runs in ~5 ms
35
+ slices (`scheduler.yield()` where available, `MessageChannel` otherwise),
36
+ with the first slice synchronous so small blocks render highlighted in the
37
+ same tick with no flash. Output is byte-identical — pinned by a chunked ==
38
+ one-shot property test across all 20 languages at chunk sizes down to 1,
39
+ plus a 6,556-case differential fuzz against the previous implementation.
40
+ Also: the renderers now reuse the parser's already-decoded source
41
+ (`CodeBlockData.code`) when `blockData` is on instead of re-deriving it from
42
+ the HTML, and `escapeHtml` no longer concatenates per character. A 49 KB
43
+ block's longest main-thread task drops from ~38 ms to ≤ 6 ms on the same
44
+ hardware; `highlight()`'s public signature and bytes are unchanged, SSR
45
+ stays synchronous, and `components.CodeBlock`/`pre`/`code` overrides are
46
+ unaffected.
47
+
7
48
  ## 0.26.0 — 2026-07-30
8
49
 
9
50
  **Rendered HTML bytes change in this release.** Everything new below is opt-in
package/dist/dom.js CHANGED
@@ -1,4 +1,4 @@
1
- import { highlight } from "./hi.js";
1
+ import { highlightDeferred } from "./hi-defer.js";
2
2
  import { morph } from "./morph.js";
3
3
  import { blockProps, extractLang } from "./block-props.js";
4
4
  import { decorateSegments } from "./decorate.js";
@@ -115,6 +115,10 @@ function mountBrookMarkdown(client, container, options = {}) {
115
115
  continue;
116
116
  }
117
117
  existing.table = void 0;
118
+ if (existing.highlight) {
119
+ existing.highlight.cancel();
120
+ existing.highlight = void 0;
121
+ }
118
122
  const node = renderBlock(b, existing);
119
123
  existing.node.replaceWith(node);
120
124
  existing.node = node;
@@ -128,6 +132,7 @@ function mountBrookMarkdown(client, container, options = {}) {
128
132
  if (mounted.size > seen.size) {
129
133
  for (const [id, mb] of mounted) {
130
134
  if (!seen.has(id)) {
135
+ if (mb.highlight) mb.highlight.cancel();
131
136
  mb.node.remove();
132
137
  mounted.delete(id);
133
138
  }
@@ -184,7 +189,7 @@ function mountBrookMarkdown(client, container, options = {}) {
184
189
  }
185
190
  switch (kind) {
186
191
  case "CodeBlock":
187
- if (highlightCode) return renderCodeBlock(b);
192
+ if (highlightCode) return renderCodeBlock(b, mb);
188
193
  break;
189
194
  // fall through to the generic path
190
195
  case "MathBlock":
@@ -341,10 +346,11 @@ function mountBrookMarkdown(client, container, options = {}) {
341
346
  }
342
347
  return result;
343
348
  }
344
- function renderCodeBlock(b) {
349
+ function renderCodeBlock(b, mb) {
345
350
  const lang = extractLang(b.html) || "text";
346
- const text = b.open ? "" : decodeCodeText(b.html);
347
- const highlighted = text ? highlight(text, lang) : null;
351
+ const text = b.open ? "" : codeText(b);
352
+ const run = text ? highlightDeferred(text, lang) : null;
353
+ const highlighted = run ? run.html : null;
348
354
  const block = document.createElement("div");
349
355
  block.className = "brook-code-block" + (b.open ? " brook-streaming" : "");
350
356
  const header = document.createElement("div");
@@ -364,15 +370,8 @@ function mountBrookMarkdown(client, container, options = {}) {
364
370
  block.appendChild(header);
365
371
  const body = document.createElement("div");
366
372
  body.className = "brook-code-body";
367
- if (highlighted) {
368
- const pre = document.createElement("pre");
369
- pre.tabIndex = 0;
370
- pre.setAttribute("role", "region");
371
- pre.setAttribute("aria-label", `${lang} code`);
372
- const code = document.createElement("code");
373
- code.innerHTML = highlighted;
374
- pre.appendChild(code);
375
- body.appendChild(pre);
373
+ if (highlighted !== null) {
374
+ body.appendChild(highlightedPre(lang, highlighted));
376
375
  } else {
377
376
  const div = document.createElement("div");
378
377
  div.tabIndex = 0;
@@ -380,10 +379,29 @@ function mountBrookMarkdown(client, container, options = {}) {
380
379
  div.setAttribute("aria-label", `${lang} code`);
381
380
  div.innerHTML = b.html;
382
381
  body.appendChild(div);
382
+ if (run !== null && run.rest !== null) {
383
+ mb.highlight = run;
384
+ run.rest.then((markup) => {
385
+ if (markup === null || dead) return;
386
+ if (mb.highlight !== run || mb.node !== block) return;
387
+ mb.highlight = void 0;
388
+ body.replaceChild(highlightedPre(lang, markup), div);
389
+ });
390
+ }
383
391
  }
384
392
  block.appendChild(body);
385
393
  return block;
386
394
  }
395
+ function highlightedPre(lang, markup) {
396
+ const pre = document.createElement("pre");
397
+ pre.tabIndex = 0;
398
+ pre.setAttribute("role", "region");
399
+ pre.setAttribute("aria-label", `${lang} code`);
400
+ const code = document.createElement("code");
401
+ code.innerHTML = markup;
402
+ pre.appendChild(code);
403
+ return pre;
404
+ }
387
405
  function renderMathBlock(b) {
388
406
  const block = document.createElement("div");
389
407
  block.className = "brook-math-block" + (b.open ? " brook-streaming" : "");
@@ -476,6 +494,12 @@ function mountBrookMarkdown(client, container, options = {}) {
476
494
  frame = 0;
477
495
  }
478
496
  unsubscribe();
497
+ for (const mb of mounted.values()) {
498
+ if (mb.highlight) {
499
+ mb.highlight.cancel();
500
+ mb.highlight = void 0;
501
+ }
502
+ }
479
503
  root.remove();
480
504
  },
481
505
  refresh() {
@@ -558,6 +582,10 @@ function tailOpenBlockId(snapshot) {
558
582
  const tail = snapshot.length > 0 ? snapshot[snapshot.length - 1] : void 0;
559
583
  return tail && tail.open ? tail.id : null;
560
584
  }
585
+ function codeText(b) {
586
+ const data = b.kind.data;
587
+ return typeof data?.code === "string" ? data.code : decodeCodeText(b.html);
588
+ }
561
589
  function decodeCodeText(html) {
562
590
  const m = html.match(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
563
591
  if (!m) return "";
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Test-only: shrink the per-slice budget so a suite can force the deferred path
3
+ * deterministically instead of betting on how fast the machine is. Call with no
4
+ * argument to restore the default. Not part of the public API.
5
+ */
6
+ export declare function __setSliceMs(ms?: number): void;
7
+ export interface DeferredHighlight {
8
+ /**
9
+ * The finished markup when the whole block tokenized inside the first
10
+ * (synchronous) slice — the common case. Apply it in this same tick: no
11
+ * swap, no second paint, no flash.
12
+ */
13
+ html: string | null;
14
+ /**
15
+ * Resolves with the finished markup once the remaining slices have run, or
16
+ * with `null` if the run was {@link DeferredHighlight.cancel}led. `null` (the
17
+ * property, not the resolution) when `html` already holds the answer. Never
18
+ * rejects.
19
+ */
20
+ rest: Promise<string | null> | null;
21
+ /** Abandon the remaining slices — the block was superseded or unmounted. */
22
+ cancel(): void;
23
+ }
24
+ /**
25
+ * Tokenize `code` for at most one slice and return the finished markup, or
26
+ * `null` when it did not fit. Pure and synchronous — it schedules nothing, so a
27
+ * renderer can call it from a render pass and only reach for
28
+ * {@link highlightDeferred} when this comes back empty.
29
+ */
30
+ export declare function highlightWithin(code: string, lang: string): string | null;
31
+ /**
32
+ * Highlight `code` without blocking: the first slice runs here, synchronously,
33
+ * and the rest (if any) continues on later tasks. See {@link DeferredHighlight}.
34
+ */
35
+ export declare function highlightDeferred(code: string, lang: string): DeferredHighlight;
@@ -0,0 +1,90 @@
1
+ import { stepHighlight } from "./hi.js";
2
+ const SLICE_MS = 5;
3
+ const CHUNK = 1024;
4
+ const now = () => typeof performance !== "undefined" ? performance.now() : Date.now();
5
+ let sliceMs = SLICE_MS;
6
+ function __setSliceMs(ms) {
7
+ sliceMs = ms === void 0 ? SLICE_MS : ms;
8
+ }
9
+ const noop = () => {
10
+ };
11
+ function highlightWithin(code, lang) {
12
+ const state = { pos: 0, out: "" };
13
+ return runSlice(code, lang, state) ? state.out : null;
14
+ }
15
+ function highlightDeferred(code, lang) {
16
+ const state = { pos: 0, out: "" };
17
+ if (runSlice(code, lang, state)) {
18
+ return { html: state.out, rest: null, cancel: noop };
19
+ }
20
+ let cancelled = false;
21
+ let channel = null;
22
+ let pending = null;
23
+ function closeChannel() {
24
+ if (channel !== null) {
25
+ channel.port1.close();
26
+ channel.port2.close();
27
+ channel = null;
28
+ }
29
+ pending = null;
30
+ }
31
+ function post() {
32
+ return new Promise((resolve) => {
33
+ if (typeof MessageChannel !== "function") {
34
+ setTimeout(resolve, 0);
35
+ return;
36
+ }
37
+ if (channel === null) {
38
+ channel = new MessageChannel();
39
+ channel.port1.onmessage = () => {
40
+ const next = pending;
41
+ pending = null;
42
+ if (next) next();
43
+ };
44
+ }
45
+ pending = resolve;
46
+ channel.port2.postMessage(0);
47
+ });
48
+ }
49
+ function nextTask() {
50
+ const scheduler = globalThis.scheduler;
51
+ if (scheduler && typeof scheduler.yield === "function") {
52
+ try {
53
+ const p = scheduler.yield();
54
+ if (p && typeof p.then === "function") return p.then(void 0, post);
55
+ } catch {
56
+ }
57
+ }
58
+ return post();
59
+ }
60
+ const rest = (async () => {
61
+ try {
62
+ for (; ; ) {
63
+ await nextTask();
64
+ if (cancelled) return null;
65
+ if (runSlice(code, lang, state)) return state.out;
66
+ }
67
+ } finally {
68
+ closeChannel();
69
+ }
70
+ })();
71
+ return {
72
+ html: null,
73
+ rest,
74
+ cancel() {
75
+ cancelled = true;
76
+ }
77
+ };
78
+ }
79
+ function runSlice(code, lang, state) {
80
+ const started = now();
81
+ for (; ; ) {
82
+ if (stepHighlight(code, lang, state, CHUNK)) return true;
83
+ if (now() - started >= sliceMs) return false;
84
+ }
85
+ }
86
+ export {
87
+ __setSliceMs,
88
+ highlightDeferred,
89
+ highlightWithin
90
+ };
package/dist/hi.d.ts CHANGED
@@ -8,5 +8,30 @@
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
+ /**
12
+ * The resumable tokenizer's cursor: `pos` is the next source index to consume,
13
+ * `out` the markup emitted so far. Start a run at `{ pos: 0, out: "" }`.
14
+ */
15
+ export interface HighlightState {
16
+ pos: number;
17
+ out: string;
18
+ }
19
+ /**
20
+ * One resumable slice of {@link highlight}. Consumes WHOLE tokens from
21
+ * `state.pos` until at least `chars` source characters have been taken (or the
22
+ * input ends), appending to `state.out`; returns true once the input is fully
23
+ * consumed.
24
+ *
25
+ * The pass carries NO state between tokens beyond `pos` — every pattern is
26
+ * sticky and matched against the immutable `code` — so stopping and resuming is
27
+ * invisible: for ANY sequence of chunk sizes the final `state.out` is
28
+ * byte-identical to `highlight(code, lang)` (test/hi-chunked.test.ts proves it
29
+ * over the language corpus, down to one token per slice). That is what lets a
30
+ * renderer spread a big block's highlight across several tasks without changing
31
+ * a byte of markup.
32
+ *
33
+ * @internal Not part of the semver surface — use {@link highlight}.
34
+ */
35
+ export declare function stepHighlight(code: string, lang: string, state: HighlightState, chars: number): boolean;
11
36
  export declare function highlight(code: string, lang: string): string;
12
37
  export declare function supportedLangs(): string[];
package/dist/hi.js CHANGED
@@ -147,26 +147,42 @@ const LANGS = {
147
147
  css: { pats: cssPats }
148
148
  };
149
149
  function escapeHtml(s) {
150
- let out = "";
151
- for (let i = 0; i < s.length; i++) {
152
- const c = s[i];
153
- if (c === "<") out += "&lt;";
154
- else if (c === ">") out += "&gt;";
155
- else if (c === "&") out += "&amp;";
156
- else if (c === '"') out += "&quot;";
157
- else out += c;
150
+ const n = s.length;
151
+ let i = 0;
152
+ for (; i < n; i++) {
153
+ const c = s.charCodeAt(i);
154
+ if (c === 60 || c === 62 || c === 38 || c === 34) break;
158
155
  }
159
- return out;
156
+ if (i === n) return s;
157
+ let out = s.slice(0, i);
158
+ let last = i;
159
+ for (; i < n; i++) {
160
+ const c = s.charCodeAt(i);
161
+ let esc;
162
+ if (c === 60) esc = "&lt;";
163
+ else if (c === 62) esc = "&gt;";
164
+ else if (c === 38) esc = "&amp;";
165
+ else if (c === 34) esc = "&quot;";
166
+ else continue;
167
+ out += s.slice(last, i) + esc;
168
+ last = i + 1;
169
+ }
170
+ return out + s.slice(last);
160
171
  }
161
- function highlight(code, lang) {
162
- if (code.length > 5e4) return escapeHtml(code);
163
- const conf = LANGS[lang.toLowerCase()];
164
- if (!conf) return escapeHtml(code);
165
- let out = "";
166
- let pos = 0;
172
+ function stepHighlight(code, lang, state, chars) {
173
+ const conf = code.length > 5e4 ? void 0 : LANGS[lang.toLowerCase()];
174
+ const stop = state.pos + (chars > 0 ? chars : 1);
175
+ if (!conf) {
176
+ const end = stop < code.length ? stop : code.length;
177
+ state.out += escapeHtml(code.slice(state.pos, end));
178
+ state.pos = end;
179
+ return state.pos >= code.length;
180
+ }
181
+ let out = state.out;
182
+ let pos = state.pos;
167
183
  const pats = conf.pats;
168
184
  const kw = conf.kw;
169
- while (pos < code.length) {
185
+ while (pos < code.length && pos < stop) {
170
186
  let matched = false;
171
187
  for (let i = 0; i < pats.length; i++) {
172
188
  const [cls, re] = pats[i];
@@ -204,12 +220,21 @@ function highlight(code, lang) {
204
220
  pos += 1;
205
221
  }
206
222
  }
207
- return out;
223
+ state.out = out;
224
+ state.pos = pos;
225
+ return pos >= code.length;
226
+ }
227
+ function highlight(code, lang) {
228
+ const state = { pos: 0, out: "" };
229
+ while (!stepHighlight(code, lang, state, code.length)) {
230
+ }
231
+ return state.out;
208
232
  }
209
233
  function supportedLangs() {
210
234
  return Object.keys(LANGS);
211
235
  }
212
236
  export {
213
237
  highlight,
238
+ stepHighlight,
214
239
  supportedLangs
215
240
  };
package/dist/react.js CHANGED
@@ -508,7 +508,17 @@ function renderBlockContent({
508
508
  switch (kind) {
509
509
  case "CodeBlock": {
510
510
  const wantsCodeOverride = !!components && (!!components.pre || !!components.code);
511
- if (!wantsCodeOverride) return /* @__PURE__ */ jsx(CodeBlock, { html: block.html, open: block.open });
511
+ if (!wantsCodeOverride) {
512
+ const source = block.kind.data?.code;
513
+ return /* @__PURE__ */ jsx(
514
+ CodeBlock,
515
+ {
516
+ html: block.html,
517
+ open: block.open,
518
+ code: typeof source === "string" ? source : void 0
519
+ }
520
+ );
521
+ }
512
522
  break;
513
523
  }
514
524
  case "MathBlock":
@@ -1,7 +1,14 @@
1
1
  interface Props {
2
2
  html: string;
3
3
  open: boolean;
4
+ /**
5
+ * The block's DECODED source, carried by `kind.data.code` when `blockData` is
6
+ * on. Identical to `decodeText(html)` — supplying it skips that whole-body
7
+ * regex + five entity passes, which on a big fence is the same order of work
8
+ * as the highlight itself. Absent (blockData off) the HTML is decoded here.
9
+ */
10
+ code?: string;
4
11
  }
5
- declare function CodeBlockImpl({ html, open }: Props): import("react/jsx-runtime").JSX.Element;
12
+ declare function CodeBlockImpl({ html, open, code }: Props): import("react/jsx-runtime").JSX.Element;
6
13
  export declare const CodeBlock: import("react").MemoExoticComponent<typeof CodeBlockImpl>;
7
14
  export {};
@@ -1,19 +1,44 @@
1
1
  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
+ import { highlightDeferred, highlightWithin } from "../hi-defer.js";
4
5
  import { extractLang } from "../block-props.js";
5
6
  function decodeText(html) {
6
7
  const m = html.match(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
7
8
  if (!m) return "";
8
9
  return m[1].replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
9
10
  }
10
- function CodeBlockImpl({ html, open }) {
11
+ function CodeBlockImpl({ html, open, code }) {
11
12
  const lang = extractLang(html) || "text";
12
- const text = useMemo(() => open ? "" : decodeText(html), [html, open]);
13
- const highlighted = useMemo(() => {
13
+ const text = useMemo(() => open ? "" : code ?? decodeText(html), [html, open, code]);
14
+ const sync = useMemo(() => {
14
15
  if (!text) return null;
15
- return highlight(text, lang);
16
+ return typeof window === "undefined" ? highlight(text, lang) : highlightWithin(text, lang);
16
17
  }, [text, lang]);
18
+ const [slow, setSlow] = useState(null);
19
+ useEffect(() => {
20
+ if (!text || sync !== null) {
21
+ setSlow((prev) => prev === null ? prev : null);
22
+ return;
23
+ }
24
+ const run = highlightDeferred(text, lang);
25
+ if (run.html !== null) {
26
+ setSlow({ text, lang, html: run.html });
27
+ return;
28
+ }
29
+ let live = true;
30
+ const rest = run.rest;
31
+ if (rest) {
32
+ rest.then((out) => {
33
+ if (live && out !== null) setSlow({ text, lang, html: out });
34
+ });
35
+ }
36
+ return () => {
37
+ live = false;
38
+ run.cancel();
39
+ };
40
+ }, [text, lang, sync]);
41
+ const highlighted = sync ?? (slow !== null && slow.text === text && slow.lang === lang ? slow.html : null);
17
42
  const [copied, setCopied] = useState(false);
18
43
  const timerRef = useRef(null);
19
44
  useEffect(() => {
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brookmd",
3
- "version": "0.26.0",
3
+ "version": "0.26.1",
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"],