brookmd 0.29.0 → 0.29.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,33 @@ 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.29.1 — 2026-07-31
8
+
9
+ Performance only; rendered bytes unchanged (asserted: settled markup identical
10
+ with streaming highlight on and off, and mid-stream parity suites untouched).
11
+
12
+ ### Performance
13
+
14
+ - **Two quadratic terms removed from the streaming-highlight hot path.** An
15
+ external benchmark of 0.27 showed streaming highlighting costing +36%
16
+ main-thread time on a code-heavy stream. Most of that was the 0.27 DOM
17
+ write pattern, already fixed in 0.28 (the on/off DOM-write delta is 151×
18
+ smaller at this release). Decomposing what remained found two genuinely
19
+ quadratic terms: the revision guard re-scanned the block character-by-
20
+ character on *every* patch to find a divergence point whose exact value
21
+ was never used (~2,070× the source in `charCodeAt` calls — replaced with
22
+ two `startsWith` questions against a cached prefix), and the fence body
23
+ was re-decoded out of its rendered HTML on every patch through a lazy
24
+ regex plus five unconditional entity passes (~5,169× the source — replaced
25
+ with landmark indexing and an `&`-gated entity chain). Streaming a 32 KB
26
+ code block, highlight-only JS cost drops **782 ms → 163 ms**, and both
27
+ terms now scale linearly. A scale-free regression gate
28
+ (`test/streaming-highlight-cost.test.ts`) pins ratios against source and
29
+ markup size — verified to fail on the pre-fix tree.
30
+ - Evaluated and rejected on measurement: computing the highlight in the
31
+ worker (the worker forwards patches as opaque strings; parsing them there
32
+ costs more than the work it would relocate).
33
+
7
34
  ## 0.29.0 — 2026-07-31
8
35
 
9
36
  **Sublinear resources.** Total parse work has an Ω(n) floor and stays exactly
@@ -2,8 +2,14 @@ function decodeEntities(s) {
2
2
  return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
3
3
  }
4
4
  function decodeCodeText(html) {
5
- const m = html.match(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
6
- return m ? decodeEntities(m[1]) : "";
5
+ const open = html.indexOf("<pre><code");
6
+ if (open < 0) return "";
7
+ const start = html.indexOf(">", open + 10);
8
+ if (start < 0) return "";
9
+ const end = html.indexOf("</code></pre>", start + 1);
10
+ if (end < 0) return "";
11
+ const body = html.slice(start + 1, end);
12
+ return body.indexOf("&") < 0 ? body : decodeEntities(body);
7
13
  }
8
14
  function decodeMathText(html) {
9
15
  const d = html.match(/<div class="math math-display">([\s\S]*?)<\/div>/);
package/dist/dom.js CHANGED
@@ -776,9 +776,14 @@ function codeText(b) {
776
776
  return typeof data?.code === "string" ? data.code : decodeCodeText(b.html);
777
777
  }
778
778
  function decodeCodeText(html) {
779
- const m = html.match(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
780
- if (!m) return "";
781
- return m[1].replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
779
+ const open = html.indexOf("<pre><code");
780
+ if (open < 0) return "";
781
+ const start = html.indexOf(">", open + 10);
782
+ if (start < 0) return "";
783
+ const end = html.indexOf("</code></pre>", start + 1);
784
+ if (end < 0) return "";
785
+ const body = html.slice(start + 1, end);
786
+ return body.indexOf("&") < 0 ? body : body.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
782
787
  }
783
788
  const CONTAINER_ATTR_RE = /([a-zA-Z][a-zA-Z0-9-]*)="([^"]*)"/g;
784
789
  function openTagOf(html) {
package/dist/hi-inc.d.ts CHANGED
@@ -90,6 +90,21 @@ export interface IncState {
90
90
  sealed: boolean;
91
91
  /** The source last fed in, for the append/revision guard. */
92
92
  text: string;
93
+ /**
94
+ * `text.slice(0, c + GAP)` — the settled source the frozen markup was derived
95
+ * from, and {@link adopt}'s whole question in one string: a revision keeps the
96
+ * prefix iff the new text still STARTS WITH this.
97
+ *
98
+ * Cached rather than re-sliced because it changes only when `c` does (once per
99
+ * checkpoint, so once per source line) while the question is asked on every
100
+ * patch. Asking it as `text.startsWith(pfx)` hands the compare to the engine's
101
+ * native string compare; the char-by-char loop it replaces was, on a streamed
102
+ * 32 KB fence, 373 ms of the 480 ms this module spent in total — ten times
103
+ * what the same question costs natively.
104
+ */
105
+ pfx: string;
106
+ /** The same, for the checkpoint BEFORE `c` — {@link adopt}'s second question. */
107
+ pfx0: string;
93
108
  /** Escaped source for `[c, plainUpto)` — the plain tail, extended in place. */
94
109
  plain: string;
95
110
  plainFrom: number;
package/dist/hi-inc.js CHANGED
@@ -80,29 +80,27 @@ function createInc(lang) {
80
80
  scan: { n: 0, f: false },
81
81
  sealed: false,
82
82
  text: "",
83
+ pfx: "",
84
+ pfx0: "",
83
85
  plain: "",
84
86
  plainFrom: 0,
85
87
  plainUpto: 0,
86
88
  html: null
87
89
  };
88
90
  }
89
- function divergence(a, b) {
90
- const n = a.length < b.length ? a.length : b.length;
91
- let i = 0;
92
- while (i < n && a.charCodeAt(i) === b.charCodeAt(i)) i++;
93
- return i;
94
- }
95
- function adopt(st, d) {
96
- if (st.c > 0 && d >= st.c + GAP) {
91
+ function adopt(st, text) {
92
+ if (st.c > 0 && text.startsWith(st.pfx)) {
97
93
  dropTail(st);
98
94
  return true;
99
95
  }
100
- if (st.c0 > 0 && d >= st.c0 + GAP) {
96
+ if (st.c0 > 0 && text.startsWith(st.pfx0)) {
101
97
  st.frozenHtml = st.frozenHtml.slice(0, st.frozenLen0);
102
98
  st.frozenRev++;
103
99
  st.frozenCut = st.frozenLen0;
104
100
  st.c = st.c0;
101
+ st.pfx = st.pfx0;
105
102
  st.c0 = 0;
103
+ st.pfx0 = "";
106
104
  st.frozenLen0 = 0;
107
105
  dropTail(st);
108
106
  return true;
@@ -125,6 +123,8 @@ function reset(st) {
125
123
  st.frozenCut = 0;
126
124
  st.c0 = 0;
127
125
  st.frozenLen0 = 0;
126
+ st.pfx = "";
127
+ st.pfx0 = "";
128
128
  st.opener = null;
129
129
  st.scan = { n: 0, f: false };
130
130
  st.sealed = false;
@@ -136,11 +136,10 @@ function reset(st) {
136
136
  }
137
137
  function incHighlight(st, text) {
138
138
  if (st.text === text) return st.html;
139
- const d = divergence(st.text, text);
140
- const appended = d === st.text.length && text.length > st.text.length;
139
+ const appended = text.length > st.text.length && text.startsWith(st.text);
141
140
  let from = st.text.length;
142
141
  if (!appended) {
143
- if (!adopt(st, d)) reset(st);
142
+ if (!adopt(st, text)) reset(st);
144
143
  from = 0;
145
144
  }
146
145
  st.text = text;
@@ -236,9 +235,11 @@ function rescan(st, text) {
236
235
  const full = st.frozenHtml + state.out;
237
236
  if (cp > st.c) {
238
237
  st.c0 = st.c;
238
+ st.pfx0 = st.pfx;
239
239
  st.frozenLen0 = st.frozenHtml.length;
240
240
  st.frozenHtml += state.out.slice(0, cpOut);
241
241
  st.c = cp;
242
+ st.pfx = text.slice(0, cp + GAP);
242
243
  }
243
244
  const found = liveOp;
244
245
  if (found === null) {
package/dist/react.js CHANGED
@@ -204,8 +204,14 @@ function decodeEntities(s) {
204
204
  return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
205
205
  }
206
206
  function decodeCodeText(html) {
207
- const m = html.match(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
208
- return m ? decodeEntities(m[1]) : "";
207
+ const open = html.indexOf("<pre><code");
208
+ if (open < 0) return "";
209
+ const start = html.indexOf(">", open + 10);
210
+ if (start < 0) return "";
211
+ const end = html.indexOf("</code></pre>", start + 1);
212
+ if (end < 0) return "";
213
+ const body = html.slice(start + 1, end);
214
+ return body.indexOf("&") < 0 ? body : decodeEntities(body);
209
215
  }
210
216
  function decodeMathText(html) {
211
217
  const d = html.match(/<div class="math math-display">([\s\S]*?)<\/div>/);
@@ -7,9 +7,14 @@ import { newIncCode, paintIncCode } from "../splice.js";
7
7
  import { useHtmlSplice } from "../react-splice.js";
8
8
  import { extractLang } from "../block-props.js";
9
9
  function decodeText(html) {
10
- const m = html.match(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
11
- if (!m) return "";
12
- return m[1].replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
10
+ const open = html.indexOf("<pre><code");
11
+ if (open < 0) return "";
12
+ const start = html.indexOf(">", open + 10);
13
+ if (start < 0) return "";
14
+ const end = html.indexOf("</code></pre>", start + 1);
15
+ if (end < 0) return "";
16
+ const body = html.slice(start + 1, end);
17
+ return body.indexOf("&") < 0 ? body : body.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
13
18
  }
14
19
  const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
15
20
  function CodeBlockImpl({ html, open, code, streamingHighlight, block, __fullRebuild }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brookmd",
3
- "version": "0.29.0",
3
+ "version": "0.29.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"],