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 +41 -0
- package/dist/dom.js +42 -14
- package/dist/hi-defer.d.ts +35 -0
- package/dist/hi-defer.js +90 -0
- package/dist/hi.d.ts +25 -0
- package/dist/hi.js +42 -17
- package/dist/react.js +11 -1
- package/dist/renderers/CodeBlock.d.ts +8 -1
- package/dist/renderers/CodeBlock.js +29 -4
- package/dist/wasm/brook_md_core_bg.wasm +0 -0
- package/package.json +1 -1
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 {
|
|
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 ? "" :
|
|
347
|
-
const
|
|
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
|
-
|
|
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;
|
package/dist/hi-defer.js
ADDED
|
@@ -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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
else if (c === "&") out += "&";
|
|
156
|
-
else if (c === '"') out += """;
|
|
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
|
|
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 = "<";
|
|
163
|
+
else if (c === 62) esc = ">";
|
|
164
|
+
else if (c === 38) esc = "&";
|
|
165
|
+
else if (c === 34) esc = """;
|
|
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
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
if (!conf)
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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)
|
|
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(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/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
|
|
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.
|
|
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"],
|