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/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,127 @@
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
+ /**
57
+ * Bumped whenever `frozenHtml` is TRUNCATED or cleared — the one checkpoint of
58
+ * rewind {@link adopt} performs, or a restart. It never moves while the prefix
59
+ * merely GROWS.
60
+ *
61
+ * A renderer that mirrors the frozen prefix into the DOM append-only reads it
62
+ * as the "may I splice?" token: same rev ⇒ the prefix only grew, so appending
63
+ * `frozenHtml.slice(alreadyWritten)` is exact; a changed rev means the prefix
64
+ * was rewritten underneath and the mirror must be re-seeded. Length alone is
65
+ * NOT enough — one call can rewind to `c0` and then re-freeze past the old
66
+ * length, which looks like a plain append but is not one.
67
+ */
68
+ frozenRev: number;
69
+ /**
70
+ * The length `frozenHtml` was TRUNCATED to at the last {@link frozenRev} bump:
71
+ * `frozenLen0` for {@link adopt}'s one-checkpoint rewind, `0` for a restart.
72
+ *
73
+ * The rev alone says "rewritten"; this says *from where*, which is what lets a
74
+ * DOM mirror rewind to a boundary it already holds instead of re-seeding the
75
+ * whole run. It is load-bearing that this is reported rather than inferred:
76
+ * `adopt` frequently rewinds and then re-freezes PAST the old length within
77
+ * the same call, so the observable `frozenHtml.length` can come back unchanged
78
+ * while its bytes have moved.
79
+ */
80
+ frozenCut: number;
81
+ /** The checkpoint BEFORE `c`, and the `frozenHtml` length that went with it —
82
+ * the one step of rewind a tail revision needs (see {@link adopt}). */
83
+ c0: number;
84
+ frozenLen0: number;
85
+ /** The unbounded opener live at the tail, or `null`. */
86
+ opener: Opener | null;
87
+ /** `opener`'s carried predicate state. */
88
+ scan: OpenerScanState;
89
+ /** `opener` can never close — stop re-scanning and render the tail plain. */
90
+ sealed: boolean;
91
+ /** The source last fed in, for the append/revision guard. */
92
+ text: string;
93
+ /** Escaped source for `[c, plainUpto)` — the plain tail, extended in place. */
94
+ plain: string;
95
+ plainFrom: number;
96
+ plainUpto: number;
97
+ /** The markup last handed out, so a repeated feed of the same text is free. */
98
+ html: string | null;
99
+ }
100
+ /**
101
+ * State for a block in `lang`, or `null` when the language has no table (the
102
+ * plain-escape fallback has no token boundaries to checkpoint on).
103
+ */
104
+ export declare function createInc(lang: string): IncState | null;
105
+ /**
106
+ * Feed the block's CURRENT full source and get the markup for all of it, or
107
+ * `null` when the incremental path has bowed out (past {@link CLIFF}) and the
108
+ * caller should render the plain escaped body exactly as it does today.
109
+ *
110
+ * Calls must be append-only. Anything else — a `reset()`, a speculative tail
111
+ * revision, one block's id being reused for different content — is detected
112
+ * (`text.startsWith(prev)`, the same guard the DOM renderer's prefix-append fast
113
+ * path uses) and simply restarts the state from scratch.
114
+ */
115
+ export declare function incHighlight(st: IncState, text: string): string | null;
116
+ /**
117
+ * The `{pos, out}` a close-time `highlightWithin`/`highlightDeferred` run should
118
+ * resume from, or `undefined` when nothing was frozen or the state does not
119
+ * belong to `text`/`lang` (a revised block, a different language, a block that
120
+ * crossed the cliff). Then the close-time run is the unseeded one it always was.
121
+ */
122
+ export declare function incSeed(st: IncState, text: string, lang: string): HighlightState | undefined;
123
+ /** @internal Test-only: total source bytes re-tokenized since the last reset. */
124
+ export declare function __getIncScanned(): number;
125
+ /** @internal Test-only. */
126
+ export declare function __resetIncScanned(): void;
127
+ export {};
package/dist/hi-inc.js ADDED
@@ -0,0 +1,323 @@
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
+ frozenRev: 0,
76
+ frozenCut: 0,
77
+ c0: 0,
78
+ frozenLen0: 0,
79
+ opener: null,
80
+ scan: { n: 0, f: false },
81
+ sealed: false,
82
+ text: "",
83
+ plain: "",
84
+ plainFrom: 0,
85
+ plainUpto: 0,
86
+ html: null
87
+ };
88
+ }
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) {
97
+ dropTail(st);
98
+ return true;
99
+ }
100
+ if (st.c0 > 0 && d >= st.c0 + GAP) {
101
+ st.frozenHtml = st.frozenHtml.slice(0, st.frozenLen0);
102
+ st.frozenRev++;
103
+ st.frozenCut = st.frozenLen0;
104
+ st.c = st.c0;
105
+ st.c0 = 0;
106
+ st.frozenLen0 = 0;
107
+ dropTail(st);
108
+ return true;
109
+ }
110
+ return false;
111
+ }
112
+ function dropTail(st) {
113
+ st.opener = null;
114
+ st.scan = { n: 0, f: false };
115
+ st.sealed = false;
116
+ st.plain = "";
117
+ st.plainFrom = st.c;
118
+ st.plainUpto = st.c;
119
+ st.html = null;
120
+ }
121
+ function reset(st) {
122
+ st.c = 0;
123
+ st.frozenHtml = "";
124
+ st.frozenRev++;
125
+ st.frozenCut = 0;
126
+ st.c0 = 0;
127
+ st.frozenLen0 = 0;
128
+ st.opener = null;
129
+ st.scan = { n: 0, f: false };
130
+ st.sealed = false;
131
+ st.text = "";
132
+ st.plain = "";
133
+ st.plainFrom = 0;
134
+ st.plainUpto = 0;
135
+ st.html = null;
136
+ }
137
+ function incHighlight(st, text) {
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;
141
+ let from = st.text.length;
142
+ if (!appended) {
143
+ if (!adopt(st, d)) reset(st);
144
+ from = 0;
145
+ }
146
+ st.text = text;
147
+ if (text.length > CLIFF) {
148
+ reset(st);
149
+ st.text = text;
150
+ return null;
151
+ }
152
+ const live = st.opener;
153
+ if (live !== null) {
154
+ const r = st.sealed ? OPEN : feed(live.pred, st.scan, text, from, text.length);
155
+ if (r === DEAD) st.sealed = true;
156
+ if (r !== CLOSED) {
157
+ st.html = st.frozenHtml + plainTail(st, text);
158
+ return st.html;
159
+ }
160
+ st.opener = null;
161
+ }
162
+ if (text.length - st.c > CAP) {
163
+ st.html = st.frozenHtml + plainTail(st, text);
164
+ return st.html;
165
+ }
166
+ st.html = rescan(st, text);
167
+ return st.html;
168
+ }
169
+ function incSeed(st, text, lang) {
170
+ if (st.c === 0 || st.lang !== lang.toLowerCase()) return void 0;
171
+ if (text.length > CLIFF || text.length < st.c) return void 0;
172
+ if (!text.startsWith(st.text.slice(0, st.c))) return void 0;
173
+ return { pos: st.c, out: st.frozenHtml };
174
+ }
175
+ let scanned = 0;
176
+ function __getIncScanned() {
177
+ return scanned;
178
+ }
179
+ function __resetIncScanned() {
180
+ scanned = 0;
181
+ }
182
+ function plainTail(st, text) {
183
+ if (st.plainFrom !== st.c || st.plainUpto > text.length) {
184
+ st.plainFrom = st.c;
185
+ st.plain = "";
186
+ st.plainUpto = st.c;
187
+ }
188
+ if (st.plainUpto < text.length) {
189
+ st.plain += escapeHtml(text.slice(st.plainUpto));
190
+ st.plainUpto = text.length;
191
+ }
192
+ return st.plain;
193
+ }
194
+ function rescan(st, text) {
195
+ const openers = OPENERS[st.lang];
196
+ const gt = GT_CHECKPOINT.has(st.lang);
197
+ const limit = text.length - GAP;
198
+ let cp = -1;
199
+ let cpOut = 0;
200
+ let liveAt = -1;
201
+ let liveOp = null;
202
+ let liveLen = 0;
203
+ const sink = (cls, start, end, outLen) => {
204
+ if (liveAt >= 0) return;
205
+ for (let i = 0; i < openers.length; i++) {
206
+ const op = openers[i];
207
+ op.re.lastIndex = start;
208
+ const m = op.re.exec(text);
209
+ if (m === null || m.index !== start) continue;
210
+ const terminated = cls === op.cls && end - start >= m[0].length + op.end.length && text.startsWith(op.end, end - op.end.length);
211
+ if (terminated) continue;
212
+ liveAt = start;
213
+ liveOp = op;
214
+ liveLen = m[0].length;
215
+ return;
216
+ }
217
+ if (gt) {
218
+ if (cls === "pun" && end - start === 1 && text.charCodeAt(start) === 62 && end <= limit) {
219
+ cp = end;
220
+ cpOut = outLen;
221
+ }
222
+ return;
223
+ }
224
+ if (cls !== "ws") return;
225
+ const hi = end < limit ? end : limit;
226
+ if (hi <= start) return;
227
+ const nl = text.lastIndexOf("\n", hi - 1);
228
+ if (nl < start) return;
229
+ cp = nl + 1;
230
+ cpOut = outLen - (end - cp);
231
+ };
232
+ const state = { pos: st.c, out: "" };
233
+ scanned += text.length - st.c;
234
+ while (!stepHighlight(text, st.lang, state, text.length, sink)) {
235
+ }
236
+ const full = st.frozenHtml + state.out;
237
+ if (cp > st.c) {
238
+ st.c0 = st.c;
239
+ st.frozenLen0 = st.frozenHtml.length;
240
+ st.frozenHtml += state.out.slice(0, cpOut);
241
+ st.c = cp;
242
+ }
243
+ const found = liveOp;
244
+ if (found === null) {
245
+ st.opener = null;
246
+ } else {
247
+ st.scan = { n: 0, f: false };
248
+ const r = feed(found.pred, st.scan, text, liveAt + liveLen, text.length);
249
+ st.opener = r === CLOSED ? null : found;
250
+ st.sealed = r === DEAD;
251
+ }
252
+ return full;
253
+ }
254
+ function feed(p, st, s, from, to) {
255
+ switch (p.k) {
256
+ case "char": {
257
+ const i = s.indexOf(p.ch, from);
258
+ return i >= 0 && i < to ? CLOSED : OPEN;
259
+ }
260
+ case "starSlash": {
261
+ for (let i = from; i < to; i++) {
262
+ const ch = s[i];
263
+ if (st.f && ch === "/") return CLOSED;
264
+ st.f = ch === "*";
265
+ }
266
+ return OPEN;
267
+ }
268
+ case "dashGt": {
269
+ for (let i = from; i < to; i++) {
270
+ const ch = s[i];
271
+ if (ch === ">" && st.n >= 2) return CLOSED;
272
+ st.n = ch === "-" ? st.n + 1 : 0;
273
+ }
274
+ return OPEN;
275
+ }
276
+ case "esc": {
277
+ for (let i = from; i < to; i++) {
278
+ const ch = s[i];
279
+ if (st.f) {
280
+ st.f = false;
281
+ if (ch === "\n") return DEAD;
282
+ continue;
283
+ }
284
+ if (ch === "\\") {
285
+ st.f = true;
286
+ continue;
287
+ }
288
+ if (ch === p.ch) return CLOSED;
289
+ }
290
+ return OPEN;
291
+ }
292
+ case "triple": {
293
+ for (let i = from; i < to; i++) {
294
+ if (s[i] === p.ch) {
295
+ st.n++;
296
+ if (st.n === 3) return CLOSED;
297
+ } else {
298
+ st.n = 0;
299
+ }
300
+ }
301
+ return OPEN;
302
+ }
303
+ case "dbl": {
304
+ for (let i = from; i < to; i++) {
305
+ const ch = s[i];
306
+ if (st.f) {
307
+ st.f = false;
308
+ if (ch !== p.ch) return CLOSED;
309
+ continue;
310
+ }
311
+ if (ch === p.ch) st.f = true;
312
+ }
313
+ return OPEN;
314
+ }
315
+ }
316
+ }
317
+ export {
318
+ __getIncScanned,
319
+ __resetIncScanned,
320
+ createInc,
321
+ incHighlight,
322
+ incSeed
323
+ };
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
@@ -19,6 +19,8 @@ type HNode = {
19
19
  */
20
20
  export declare function parseStyle(css: string): Record<string, string>;
21
21
  export declare function getParseCount(): number;
22
+ /** @internal Test-only. Characters of markup handed to the tokenizer so far. */
23
+ export declare function getParseChars(): number;
22
24
  export declare function resetParseCount(): void;
23
25
  export declare function parseTrustedHtml(html: string): HNode[];
24
26
  /**
@@ -131,14 +131,20 @@ function parseOpenTag(html, start) {
131
131
  return { tag, attrs, selfClose: false, next: i };
132
132
  }
133
133
  let parseCount = 0;
134
+ let parseChars = 0;
134
135
  function getParseCount() {
135
136
  return parseCount;
136
137
  }
138
+ function getParseChars() {
139
+ return parseChars;
140
+ }
137
141
  function resetParseCount() {
138
142
  parseCount = 0;
143
+ parseChars = 0;
139
144
  }
140
145
  function parseTrustedHtml(html) {
141
146
  parseCount++;
147
+ parseChars += html.length;
142
148
  const root = [];
143
149
  const stack = [];
144
150
  let i = 0;
@@ -357,6 +363,7 @@ function wrapLink(text, attrs) {
357
363
  }
358
364
  export {
359
365
  decodeEntities,
366
+ getParseChars,
360
367
  getParseCount,
361
368
  htmlToReact,
362
369
  parseStyle,
@@ -0,0 +1,32 @@
1
+ import { type MutableRefObject } from "react";
2
+ import type { Block } from "./types-core.js";
3
+ /**
4
+ * Let a layout effect own an element's children so an OPEN block's patch is
5
+ * applied incrementally (see splice.ts) instead of re-setting the whole
6
+ * `dangerouslySetInnerHTML` every time it grows.
7
+ *
8
+ * ## How React and the effect share the node
9
+ *
10
+ * The returned string is the html captured on the FIRST render and never
11
+ * changes again. Render it as the node's `__html` and React writes the element
12
+ * exactly once, at mount — its own `lastHtml !== nextHtml` check then keeps it
13
+ * from touching the children on any later commit, and the effect owns them from
14
+ * there.
15
+ *
16
+ * That is what makes this safe under concurrent rendering: a render that is
17
+ * thrown away commits nothing and runs no effect, and the effect's own
18
+ * bookkeeping makes a repeat run (StrictMode's double-invoke) a no-op. It also
19
+ * leaves SSR and hydration byte-identical, because the first markup React
20
+ * produces is still the block's full html.
21
+ *
22
+ * The caller hands the node BACK to React by rendering a different element
23
+ * (a closed block's plain `<div dangerouslySetInnerHTML>`), which remounts the
24
+ * subtree and re-renders the settled html in one pass.
25
+ *
26
+ * @param hostRef ref attached to the element whose children are managed
27
+ * @param block the block's CURRENT version (identity matters — `spliceKeep`
28
+ * is keyed on it)
29
+ * @param enabled false to stay entirely out of the way
30
+ * @returns the html to render as `__html`, or `null` when not managing
31
+ */
32
+ export declare function useHtmlSplice(hostRef: MutableRefObject<HTMLElement | null>, block: Block | undefined, enabled: boolean): string | null;
@@ -0,0 +1,33 @@
1
+ import { useEffect, useLayoutEffect, useRef } from "react";
2
+ import { spliceHtml, spliceKeep } from "./splice.js";
3
+ const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
4
+ function useHtmlSplice(hostRef, block, enabled) {
5
+ const seed = useRef(block);
6
+ const applied = useRef(null);
7
+ useIsoLayoutEffect(() => {
8
+ const base = seed.current;
9
+ if (!enabled || block === void 0 || base === void 0) {
10
+ applied.current = null;
11
+ return;
12
+ }
13
+ const node = hostRef.current;
14
+ if (node === null) return;
15
+ let prev = applied.current;
16
+ if (prev === null || prev.node !== node) prev = { node, block: base };
17
+ if (prev.block === block) {
18
+ applied.current = prev;
19
+ return;
20
+ }
21
+ const keep = spliceKeep(prev.block, block);
22
+ if (keep !== void 0 && spliceHtml(node, prev.block.html, block.html, keep)) {
23
+ applied.current = { node, block };
24
+ return;
25
+ }
26
+ node.innerHTML = block.html;
27
+ applied.current = { node, block };
28
+ });
29
+ return enabled && seed.current !== void 0 ? seed.current.html : null;
30
+ }
31
+ export {
32
+ useHtmlSplice
33
+ };
package/dist/react.d.ts CHANGED
@@ -128,6 +128,31 @@ 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;
147
+ /**
148
+ * @internal TEST-ONLY. Turn off the incremental apply paths (the open code
149
+ * block's frozen/tail mirror and the open generic block's delta splice) so
150
+ * every patch re-renders the whole block through React, exactly as it did
151
+ * before they existed. The DOM-parity fuzz renders one tree with it on and one
152
+ * with it off and asserts their markup matches after every commit. Not part of
153
+ * the supported API.
154
+ */
155
+ __fullRebuild?: boolean;
131
156
  /** Appended to the root's `className` (the `brook-md` class is always present). */
132
157
  className?: string;
133
158
  /** Set on the root element. */
@@ -264,6 +289,9 @@ interface BlockViewProps {
264
289
  virtualize?: boolean;
265
290
  sanitize?: (html: string) => string;
266
291
  childMemo?: boolean;
292
+ streamingHighlight?: boolean;
293
+ /** @internal TEST-ONLY — see BrookMarkdownProps.__fullRebuild. */
294
+ __fullRebuild?: boolean;
267
295
  onRenderMetrics?: RenderMetricsHook;
268
296
  decorators?: Decorator[];
269
297
  urlTransform?: UrlTransform;