brookmd 0.24.0 → 0.25.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 +138 -0
- package/README.md +71 -3
- package/dist/client.d.ts +15 -0
- package/dist/client.js +76 -3
- package/dist/dom.js +4 -1
- package/dist/html-to-react.js +30 -1
- package/dist/react.d.ts +44 -11
- package/dist/react.js +109 -20
- package/dist/server-react.js +2 -1
- package/dist/types-react.d.ts +32 -4
- package/dist/warn.d.ts +4 -0
- package/dist/warn.js +14 -0
- package/dist/wasm/brook_md_core_bg.wasm +0 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,144 @@ 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.25.1 — 2026-07-27
|
|
8
|
+
|
|
9
|
+
A correction release. 0.25.0 shipped one user-visible regression and a dev-gate
|
|
10
|
+
that failed open in production; both are fixed here, along with a pre-existing
|
|
11
|
+
memory leak on the opt-in `childMemo` path. Upgrade from 0.25.0.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **WITHDRAWN: the streaming component-tag deferral from 0.25.0.** That change
|
|
16
|
+
gated component rendering on `open_tail`, which a container propagates
|
|
17
|
+
UNCHANGED to every sub-block of every list item. A component tag alone in a
|
|
18
|
+
**non-last list item** — whose one-line body carries no trailing newline of its
|
|
19
|
+
own — therefore never satisfied the gate again, and rendered as the literal
|
|
20
|
+
text `<Thinking>` for the entire rest of the stream instead of mounting the
|
|
21
|
+
consumer's component. Measured 201/201 ticks affected in a 200-item list, and
|
|
22
|
+
the deferral bought exactly one tick across the three shapes it was written
|
|
23
|
+
for. `brookmd-core` is reverted to its pre-0.24.0 behavior here (0.24.1); the
|
|
24
|
+
convergence property 0.25.0 claimed is withdrawn with it. The one-tick issue it
|
|
25
|
+
was chasing is already contained by the per-block error boundary.
|
|
26
|
+
- **Dev-only warnings ran in production.** The gate read `globalThis.process?.env`,
|
|
27
|
+
which is a different member path from the `process.env.NODE_ENV` free
|
|
28
|
+
identifier that esbuild / Vite / Next substitute — so in every bundled browser
|
|
29
|
+
production build it evaluated to "development", printed to real users, and
|
|
30
|
+
prevented dead-code elimination of the message strings. Every gate is now at
|
|
31
|
+
the call site in the literal form bundlers fold. Verified against a real Vite
|
|
32
|
+
build: 11 dev-warning strings survived a production bundle before, 0 now.
|
|
33
|
+
(Note the trade: in a realm with no `process` at all — an unbundled CDN
|
|
34
|
+
consumer — these six development warnings no longer fire. Error reporting via
|
|
35
|
+
`onBlockError` / `console.error` is NOT gated and is unaffected.)
|
|
36
|
+
- **`childMemo` retained every intermediate tree on a single-element block**
|
|
37
|
+
(pre-existing, opt-in path). The cache key embeds the segment text, so a block
|
|
38
|
+
whose HTML is ONE top-level element — which is every core-emitted block kind —
|
|
39
|
+
could never hit: each patch wrote an entry that was never read again, and
|
|
40
|
+
`CHILD_MEMO_CAP` bounds entry count, not bytes. Peak RSS grew 154 MB → 1.65 GB
|
|
41
|
+
over 800 patches of a streaming table. Such blocks now take the ordinary
|
|
42
|
+
whole-block walk.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- Shipped bundles are smaller than 0.25.0: gzipped, minified, production —
|
|
47
|
+
`index` 14,232 → 13,407 B, `react` 14,185 → 13,365 B, `client` 4,651 → 4,283 B.
|
|
48
|
+
The boundary's `console.error` keeps everything actionable in production (block
|
|
49
|
+
id, kind, override keys, HTML excerpt, the error) and moves only the
|
|
50
|
+
explanatory prose behind the dev gate.
|
|
51
|
+
|
|
52
|
+
## brookmd-react-native 0.1.5 — 2026-07-27
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- Vendored native binaries rebuilt against `brookmd-core` 0.24.1, so the on-device
|
|
57
|
+
parser no longer carries the component-tag deferral regression withdrawn in
|
|
58
|
+
brookmd 0.25.1 (a component tag alone in a non-last list item rendered as
|
|
59
|
+
literal text for the rest of the stream). No JS changes.
|
|
60
|
+
|
|
61
|
+
## brookmd-react-native 0.1.4 — 2026-07-26
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- **The native transport shim routes worker listeners by event type.** brookmd
|
|
66
|
+
0.24.0 taught `BrookPool` to listen on `error` / `messageerror` (the browser's
|
|
67
|
+
out-of-band worker-failure channels). `NativeWorker.addEventListener` ignored
|
|
68
|
+
its `type` argument and registered every listener in one set, so the pool's
|
|
69
|
+
fatal handler received the ordinary `ready` / `patch` envelopes and read the
|
|
70
|
+
first as `brookmd worker failed to load`, killing every stream before it
|
|
71
|
+
started. Neither channel can fire for an in-process shim — there is no script
|
|
72
|
+
URL to fetch and no structured-clone step — so they are now accepted and never
|
|
73
|
+
fired. This was latent: the package pinned `brookmd` to `^0.23.0`, which
|
|
74
|
+
predates those listeners, so it only surfaced when the range moved.
|
|
75
|
+
- `brookmd` dependency range corrected `^0.23.0` → `^0.25.0`; vendored native
|
|
76
|
+
binaries rebuilt against `brookmd-core` 0.24.0.
|
|
77
|
+
|
|
78
|
+
## 0.25.0 — 2026-07-26
|
|
79
|
+
|
|
80
|
+
Fixes a crash class where a `components` override could be invoked with the
|
|
81
|
+
wrong prop shape and take the entire document down with it. If you pass
|
|
82
|
+
`components` and read `props.block` in any of them, upgrade.
|
|
83
|
+
|
|
84
|
+
### Fixed
|
|
85
|
+
|
|
86
|
+
- **A `components` override no longer receives two incompatible prop shapes
|
|
87
|
+
without warning.** The map is consulted by two dispatchers: the block-kind
|
|
88
|
+
dispatcher (which supplies `BlockComponentProps`, including `block`) and the
|
|
89
|
+
element-name walker that powers `a`/`code`/`table` overrides (which supplies
|
|
90
|
+
attributes + `children` and **no `block`**). The same key reaches both — an
|
|
91
|
+
`inlineComponentTags` chip, or a `componentTags` tag nested inside a list item
|
|
92
|
+
or blockquote — so an override reading `props.block.…` threw
|
|
93
|
+
`can't access property "kind", block is undefined`, intermittently, depending
|
|
94
|
+
on where the model put the tag. Three defenses now apply: block-kind keys are
|
|
95
|
+
typed to `BlockComponentProps` (a mismatched override is a compile error), a
|
|
96
|
+
raw element whose name collides with a block-kind key is never dispatched to
|
|
97
|
+
that override, and every block renders inside its own error boundary.
|
|
98
|
+
- **A throwing override costs one block, not the document.** React unmounts the
|
|
99
|
+
whole tree on an uncaught render error, so a single bad override blanked the
|
|
100
|
+
page. Each block now has its own boundary; the failed block is skipped and
|
|
101
|
+
retried as soon as its HTML changes, so a streaming-tail failure heals itself
|
|
102
|
+
when the block settles.
|
|
103
|
+
- **The parser no longer emits a component tag it will retract.** A component
|
|
104
|
+
open tag was recognized as soon as it looked whole-line, but during streaming
|
|
105
|
+
end-of-buffer is not end-of-line — so `> <Thinking>x</Thinking>` rendered a
|
|
106
|
+
raw `<Thinking>` element for one tick before settling to escaped text. That
|
|
107
|
+
transient raw element is what reached overrides with the wrong props. A
|
|
108
|
+
component tag now opens a block only once its line is known to be complete.
|
|
109
|
+
- **Recovery no longer loses the parser config.** The worker keeps config per
|
|
110
|
+
stream id, so the one-shot recovery re-feed landed on a worker that had never
|
|
111
|
+
seen it while `configSent` stayed latched — the healed parser was silently
|
|
112
|
+
rebuilt with library defaults, dropping `componentTags`, `blockData`,
|
|
113
|
+
`gfmMath` and the whole `kind.data` structured channel for the rest of the
|
|
114
|
+
session.
|
|
115
|
+
- **A terminal worker failure no longer corrupts the document.** The store kept
|
|
116
|
+
the dead generation's blocks while the fresh parser renumbered from zero, so
|
|
117
|
+
the next append merged two generations under colliding ids (duplicate React
|
|
118
|
+
keys, silently overwritten blocks, a shrinking document). The generation now
|
|
119
|
+
restarts cleanly, with a one-time warning. Transient failures still heal
|
|
120
|
+
invisibly through recovery, unchanged.
|
|
121
|
+
- **Per-block error containment is free for committed blocks.** The boundary
|
|
122
|
+
lives inside the per-block memo, so a settled document re-renders no
|
|
123
|
+
boundaries when the streaming tail patches — a new React-side complexity gate
|
|
124
|
+
(`test/boundary-linearity.test.tsx`) pins this, counting work rather than
|
|
125
|
+
timing it, mirroring the Rust `scaling` gate.
|
|
126
|
+
- `applyPatch` and the stale-view merge now drop a malformed entry rather than
|
|
127
|
+
publishing a hole into the snapshot, and both renderers skip a block with no
|
|
128
|
+
`kind` instead of dereferencing it.
|
|
129
|
+
|
|
130
|
+
### Added
|
|
131
|
+
|
|
132
|
+
- **`onBlockError`** on `<BrookMarkdown>` — fires when a block's render throws,
|
|
133
|
+
with `{ blockId, kind, componentKeys, html }`. Without it the same detail goes
|
|
134
|
+
to `console.error`.
|
|
135
|
+
|
|
136
|
+
### Changed
|
|
137
|
+
|
|
138
|
+
- `Components` is now a mapped type: block-kind keys (`CodeBlock`, `Table`,
|
|
139
|
+
`Alert`, …) are typed to `BlockComponentProps`; every other key stays
|
|
140
|
+
permissive. Type-only, but it will surface existing mismatches at compile time.
|
|
141
|
+
- Requires `brookmd-core` 0.24.0 (the streaming component-tag fix above).
|
|
142
|
+
- `onBlockError` is identity-sensitive like `components` / `onRenderMetrics` —
|
|
143
|
+
hoist or memoize it, or every block re-renders on every patch.
|
|
144
|
+
|
|
7
145
|
## 0.24.0 — 2026-07-24
|
|
8
146
|
|
|
9
147
|
### Added
|
package/README.md
CHANGED
|
@@ -766,6 +766,33 @@ block. The component receives [`BlockComponentProps`](#types): `{ block, html,
|
|
|
766
766
|
open, speculative }`, plus `text`/`language` for code/math blocks (the alert
|
|
767
767
|
type is at `block.kind.data.kind`).
|
|
768
768
|
|
|
769
|
+
> **One map, two prop contracts — the single biggest footgun.** The keys above
|
|
770
|
+
> are looked up by TWO dispatchers. The block-kind dispatcher passes
|
|
771
|
+
> `BlockComponentProps` (with `block`); the element dispatcher, which is what
|
|
772
|
+
> makes `a` / `code` / `table` overrides work, passes **the element's attributes
|
|
773
|
+
> and `children` only — no `block`**. The same name can hit both: an
|
|
774
|
+
> `inlineComponentTags` chip, or a `componentTags` tag that lands inside a list
|
|
775
|
+
> item or blockquote (where it is a real *nested* Component block, rendered as an
|
|
776
|
+
> element inside its container's HTML), takes the element path. So an override
|
|
777
|
+
> that reads `props.block.…` throws `can't access property "kind", block is
|
|
778
|
+
> undefined` for those occurrences — intermittently, because it depends on where
|
|
779
|
+
> the model happened to put the tag.
|
|
780
|
+
>
|
|
781
|
+
> Write any override for a name that can appear in both positions defensively:
|
|
782
|
+
>
|
|
783
|
+
> ```tsx
|
|
784
|
+
> const Thinking = ({ block, children }) =>
|
|
785
|
+
> block ? <Panel data={block.kind.data}>{children}</Panel> : <span>{children}</span>;
|
|
786
|
+
> ```
|
|
787
|
+
>
|
|
788
|
+
> Three things make this survivable rather than fatal: block-kind keys are typed
|
|
789
|
+
> to `BlockComponentProps`, so a mismatched override is a **compile** error; a raw
|
|
790
|
+
> element whose name collides with a block-kind key (`<Table>`, `<Alert>`… — only
|
|
791
|
+
> reachable with raw-HTML passthrough on) is never dispatched to that override;
|
|
792
|
+
> and every block renders inside its own **error boundary**, so a throwing
|
|
793
|
+
> override costs that one block instead of unmounting the document. Wire
|
|
794
|
+
> [`onBlockError`](#onblockerror) to see them.
|
|
795
|
+
|
|
769
796
|
Rules worth knowing:
|
|
770
797
|
|
|
771
798
|
- **There is no `node` prop / no hast tree.** Introspect via `className` /
|
|
@@ -928,9 +955,23 @@ tags only). It works everywhere inline content does — **including table cells*
|
|
|
928
955
|
Tag names match **case-sensitively** and dispatch verbatim to `components[tag]`
|
|
929
956
|
(`<tik>`→`components.tik`, `<Cite>`→`components.Cite`). The
|
|
930
957
|
two lists are independent: list a tag under `componentTags` for blocks,
|
|
931
|
-
`inlineComponentTags` for inline, or both for both.
|
|
932
|
-
|
|
933
|
-
|
|
958
|
+
`inlineComponentTags` for inline, or both for both.
|
|
959
|
+
|
|
960
|
+
Where an allowlisted tag actually lands:
|
|
961
|
+
|
|
962
|
+
| Position | Result |
|
|
963
|
+
| --- | --- |
|
|
964
|
+
| Own line, top level | block `Component` — override gets `BlockComponentProps` |
|
|
965
|
+
| Own line inside a list item / blockquote | real **nested** Component block, emitted as an element inside the container's HTML — the override is dispatched by **element name**, so it gets attributes + `children` and **no `block`** |
|
|
966
|
+
| Mid-paragraph, listed in `inlineComponentTags` | inline element — attributes + `children`, no `block` |
|
|
967
|
+
| Mid-paragraph, NOT listed in `inlineComponentTags` | escaped text |
|
|
968
|
+
| Inside a table cell | escaped text (cells are inline-only) |
|
|
969
|
+
|
|
970
|
+
An allowlisted tag in a position that is not supported degrades **inertly**
|
|
971
|
+
(escaped) — it never consumes surrounding content. But note rows 2 and 3: those
|
|
972
|
+
DO render, through the element path, which is why an override that reads
|
|
973
|
+
`props.block` must guard for its absence (see [the two prop
|
|
974
|
+
contracts](#custom-components--overrides)).
|
|
934
975
|
|
|
935
976
|
> **Link-bridge alternative.** Before `inlineComponentTags`, the way to get an
|
|
936
977
|
> inline custom element was the link bridge: emit `[$AAPL](tik://AAPL)` and
|
|
@@ -1269,6 +1310,33 @@ re-snapping during streaming, so treat smooth following there as best-effort.
|
|
|
1269
1310
|
> is the *shared* worker's heap — clients on the same worker report the same
|
|
1270
1311
|
> value. Aggregate with `Math.max`, not a sum.
|
|
1271
1312
|
|
|
1313
|
+
<a id="onblockerror"></a>
|
|
1314
|
+
|
|
1315
|
+
### When a block fails to render — `onBlockError`
|
|
1316
|
+
|
|
1317
|
+
Every block renders inside its own error boundary. React's default response to
|
|
1318
|
+
an uncaught render error is to unmount the **entire** tree, so before this a
|
|
1319
|
+
single throwing `components` override blanked the whole document. Now the
|
|
1320
|
+
failure costs that one block: the rest of the stream keeps rendering, and the
|
|
1321
|
+
block is retried as soon as its HTML changes (so a streaming-tail failure heals
|
|
1322
|
+
itself when the block settles).
|
|
1323
|
+
|
|
1324
|
+
```tsx
|
|
1325
|
+
<BrookMarkdown
|
|
1326
|
+
client={client}
|
|
1327
|
+
components={components}
|
|
1328
|
+
onBlockError={(err, { blockId, kind, componentKeys, html }) => {
|
|
1329
|
+
reportToSentry(err, { blockId, kind, componentKeys, html });
|
|
1330
|
+
}}
|
|
1331
|
+
/>
|
|
1332
|
+
```
|
|
1333
|
+
|
|
1334
|
+
`componentKeys` is the override map's keys — the shortlist of suspects — and
|
|
1335
|
+
`html` is the first 200 chars of the block that failed. Without the hook the same
|
|
1336
|
+
detail goes to `console.error`. The overwhelmingly common cause is an override
|
|
1337
|
+
reading `props.block.…` on the element path, where there is no `block`; see [the
|
|
1338
|
+
two prop contracts](#custom-components--overrides).
|
|
1339
|
+
|
|
1272
1340
|
## Architecture
|
|
1273
1341
|
|
|
1274
1342
|
```
|
package/dist/client.d.ts
CHANGED
|
@@ -156,6 +156,9 @@ export declare class BrookClient {
|
|
|
156
156
|
private streamId;
|
|
157
157
|
private config?;
|
|
158
158
|
private configSent;
|
|
159
|
+
/** Set when ensureAcquired rebound this stream onto a fresh worker+parser
|
|
160
|
+
* after a fatal failure; consumed by the next content-bearing op. */
|
|
161
|
+
private pendingRebind;
|
|
159
162
|
private listeners;
|
|
160
163
|
private store;
|
|
161
164
|
private onError?;
|
|
@@ -176,6 +179,9 @@ export declare class BrookClient {
|
|
|
176
179
|
private staleTrimmed;
|
|
177
180
|
private idNamespace;
|
|
178
181
|
private mergeCache;
|
|
182
|
+
/** Set by mergeStale when it had to compact a hole out of the view, so
|
|
183
|
+
* getSnapshot skips caching a view whose indices no longer track `base`. */
|
|
184
|
+
private mergeDropped;
|
|
179
185
|
private appendedBytes;
|
|
180
186
|
private patchCount;
|
|
181
187
|
private totalParseMicros;
|
|
@@ -245,6 +251,15 @@ export declare class BrookClient {
|
|
|
245
251
|
* multiplexing (pick() is unchanged and remains the only path to create()).
|
|
246
252
|
*/
|
|
247
253
|
private ensureAcquired;
|
|
254
|
+
/**
|
|
255
|
+
* A worker rebind left the store holding a dead generation's blocks while the
|
|
256
|
+
* fresh parser restarts ids at 0. Called by the ops that actually feed the
|
|
257
|
+
* parser, before they do. Recovery's re-feed handles this itself (it re-parses
|
|
258
|
+
* the whole document over a preserved view) and clears the flag; this is the
|
|
259
|
+
* path where recovery is off or already spent, where the honest outcome is a
|
|
260
|
+
* clean restart rather than two generations interleaved under colliding ids.
|
|
261
|
+
*/
|
|
262
|
+
private settleRebind;
|
|
248
263
|
get ready(): boolean;
|
|
249
264
|
/**
|
|
250
265
|
* The fatal error that killed this client's worker, or `null` if healthy.
|
package/dist/client.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { warnOnce } from "./warn.js";
|
|
1
2
|
import { createWorker } from "./asset-urls.js";
|
|
2
3
|
function emptyBlockStore() {
|
|
3
4
|
return { committed: /* @__PURE__ */ new Map(), committedOrder: [], active: [], snapshot: [] };
|
|
@@ -24,12 +25,31 @@ function applyPatch(store, patch) {
|
|
|
24
25
|
}
|
|
25
26
|
store.active = active;
|
|
26
27
|
const next = new Array(store.committedOrder.length + store.active.length);
|
|
28
|
+
let w = 0;
|
|
27
29
|
for (let i = 0; i < store.committedOrder.length; i++) {
|
|
28
|
-
|
|
30
|
+
const b = store.committed.get(store.committedOrder[i]);
|
|
31
|
+
if (b === void 0) {
|
|
32
|
+
if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
|
|
33
|
+
warnOnce(
|
|
34
|
+
"orphan-committed",
|
|
35
|
+
`brookmd: committed block ${store.committedOrder[i]} is missing from the store and was dropped. This is a bug in brookmd \u2014 please report it.`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
next[w++] = b;
|
|
29
41
|
}
|
|
30
42
|
for (let i = 0; i < store.active.length; i++) {
|
|
31
|
-
|
|
43
|
+
const b = store.active[i];
|
|
44
|
+
if (b === void 0) {
|
|
45
|
+
if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
|
|
46
|
+
warnOnce("orphan-active", `brookmd: active block at index ${i} is undefined and was dropped.`);
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
next[w++] = b;
|
|
32
51
|
}
|
|
52
|
+
if (w !== next.length) next.length = w;
|
|
33
53
|
store.snapshot = next;
|
|
34
54
|
}
|
|
35
55
|
class BrookPool {
|
|
@@ -273,6 +293,9 @@ class BrookClient {
|
|
|
273
293
|
streamId = 0;
|
|
274
294
|
config;
|
|
275
295
|
configSent = false;
|
|
296
|
+
/** Set when ensureAcquired rebound this stream onto a fresh worker+parser
|
|
297
|
+
* after a fatal failure; consumed by the next content-bearing op. */
|
|
298
|
+
pendingRebind = false;
|
|
276
299
|
listeners = /* @__PURE__ */ new Set();
|
|
277
300
|
store = emptyBlockStore();
|
|
278
301
|
onError;
|
|
@@ -359,6 +382,9 @@ class BrookClient {
|
|
|
359
382
|
// reads between notifies must return the SAME reference — the
|
|
360
383
|
// useSyncExternalStore cached-snapshot contract.
|
|
361
384
|
mergeCache = null;
|
|
385
|
+
/** Set by mergeStale when it had to compact a hole out of the view, so
|
|
386
|
+
* getSnapshot skips caching a view whose indices no longer track `base`. */
|
|
387
|
+
mergeDropped = false;
|
|
362
388
|
// Perf
|
|
363
389
|
appendedBytes = 0;
|
|
364
390
|
patchCount = 0;
|
|
@@ -430,12 +456,38 @@ class BrookClient {
|
|
|
430
456
|
*/
|
|
431
457
|
ensureAcquired() {
|
|
432
458
|
if (this.pw && !this.pw.failed) return this.pw;
|
|
459
|
+
const rebinding = this.pw !== null;
|
|
433
460
|
this.pw = null;
|
|
434
461
|
const { streamId, pw } = this.pool.acquire((msg) => this.onMessage(msg));
|
|
435
462
|
this.streamId = streamId;
|
|
436
463
|
this.pw = pw;
|
|
464
|
+
if (rebinding) {
|
|
465
|
+
this.configSent = false;
|
|
466
|
+
this.pendingRebind = true;
|
|
467
|
+
}
|
|
437
468
|
return pw;
|
|
438
469
|
}
|
|
470
|
+
/**
|
|
471
|
+
* A worker rebind left the store holding a dead generation's blocks while the
|
|
472
|
+
* fresh parser restarts ids at 0. Called by the ops that actually feed the
|
|
473
|
+
* parser, before they do. Recovery's re-feed handles this itself (it re-parses
|
|
474
|
+
* the whole document over a preserved view) and clears the flag; this is the
|
|
475
|
+
* path where recovery is off or already spent, where the honest outcome is a
|
|
476
|
+
* clean restart rather than two generations interleaved under colliding ids.
|
|
477
|
+
*/
|
|
478
|
+
settleRebind() {
|
|
479
|
+
if (!this.pendingRebind) return;
|
|
480
|
+
if (this.failedError === null) return;
|
|
481
|
+
this.pendingRebind = false;
|
|
482
|
+
if (this.store.snapshot.length === 0 && !this.staleSnapshot) return;
|
|
483
|
+
if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
|
|
484
|
+
warnOnce(
|
|
485
|
+
"rebind-reset",
|
|
486
|
+
"brookmd: the parser was rebuilt on a new worker after a fatal failure, so the document restarted. Blocks rendered before the failure were dropped because the fresh parser renumbers from zero. Enable `recovery` (the default) to re-feed and heal invisibly instead."
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
this.resetParser();
|
|
490
|
+
}
|
|
439
491
|
get ready() {
|
|
440
492
|
return this.pw?.ready ?? false;
|
|
441
493
|
}
|
|
@@ -466,6 +518,7 @@ class BrookClient {
|
|
|
466
518
|
}
|
|
467
519
|
append(chunk) {
|
|
468
520
|
const pw = this.ensureAcquired();
|
|
521
|
+
this.settleRebind();
|
|
469
522
|
if (this.firstAppendMs === 0) this.firstAppendMs = performance.now();
|
|
470
523
|
if (this.recovery) {
|
|
471
524
|
this.recoveryBuffer += chunk;
|
|
@@ -477,6 +530,7 @@ class BrookClient {
|
|
|
477
530
|
}
|
|
478
531
|
finalize() {
|
|
479
532
|
const pw = this.ensureAcquired();
|
|
533
|
+
this.settleRebind();
|
|
480
534
|
this.finalizePending = true;
|
|
481
535
|
this.contentDone = true;
|
|
482
536
|
this.pool.send(pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig(), epoch: this.epoch });
|
|
@@ -599,6 +653,7 @@ class BrookClient {
|
|
|
599
653
|
this.mergeCache = null;
|
|
600
654
|
this.failedError = null;
|
|
601
655
|
this.recoveryAttempted = false;
|
|
656
|
+
this.pendingRebind = false;
|
|
602
657
|
this.resetParser();
|
|
603
658
|
if (hadContent) this.emit(true);
|
|
604
659
|
}
|
|
@@ -700,7 +755,12 @@ class BrookClient {
|
|
|
700
755
|
const cache = this.mergeCache;
|
|
701
756
|
if (cache && cache.base === base && cache.trimmed === this.staleTrimmed) return cache.view;
|
|
702
757
|
const view = this.mergeStale(base, cache && cache.trimmed === this.staleTrimmed ? cache : null);
|
|
703
|
-
this.
|
|
758
|
+
if (this.mergeDropped) {
|
|
759
|
+
this.mergeDropped = false;
|
|
760
|
+
this.mergeCache = null;
|
|
761
|
+
} else {
|
|
762
|
+
this.mergeCache = { base, trimmed: this.staleTrimmed, view };
|
|
763
|
+
}
|
|
704
764
|
return view;
|
|
705
765
|
};
|
|
706
766
|
/**
|
|
@@ -731,6 +791,7 @@ class BrookClient {
|
|
|
731
791
|
const stale = this.staleSnapshot;
|
|
732
792
|
const len = this.staleTrimmed ? base.length : Math.max(base.length, stale.length);
|
|
733
793
|
const view = new Array(len);
|
|
794
|
+
let dropped = false;
|
|
734
795
|
for (let i = 0; i < len; i++) {
|
|
735
796
|
const nb = i < base.length ? base[i] : void 0;
|
|
736
797
|
if (nb !== void 0 && prev !== null && i < prev.base.length && prev.base[i] === nb) {
|
|
@@ -739,6 +800,10 @@ class BrookClient {
|
|
|
739
800
|
}
|
|
740
801
|
const ob = i < stale.length ? stale[i] : void 0;
|
|
741
802
|
if (!nb) {
|
|
803
|
+
if (ob === void 0) {
|
|
804
|
+
dropped = true;
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
742
807
|
view[i] = ob;
|
|
743
808
|
continue;
|
|
744
809
|
}
|
|
@@ -756,6 +821,13 @@ class BrookClient {
|
|
|
756
821
|
}
|
|
757
822
|
view[i] = { ...nb, id: this.idNamespace + nb.id };
|
|
758
823
|
}
|
|
824
|
+
if (dropped) {
|
|
825
|
+
if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
|
|
826
|
+
warnOnce("merge-hole", "brookmd: the stale-merge produced an empty position \u2014 compacting.");
|
|
827
|
+
}
|
|
828
|
+
this.mergeDropped = true;
|
|
829
|
+
return view.filter((b) => b !== void 0);
|
|
830
|
+
}
|
|
759
831
|
return view;
|
|
760
832
|
}
|
|
761
833
|
/**
|
|
@@ -919,6 +991,7 @@ class BrookClient {
|
|
|
919
991
|
const displayed = this.getSnapshot();
|
|
920
992
|
if (displayed.length > 0) this.softReset(displayed);
|
|
921
993
|
else this.resetParser();
|
|
994
|
+
this.pendingRebind = false;
|
|
922
995
|
this.append(doc);
|
|
923
996
|
if (done) this.finalize();
|
|
924
997
|
}
|
package/dist/dom.js
CHANGED
|
@@ -55,9 +55,11 @@ function mountBrookMarkdown(client, container, options = {}) {
|
|
|
55
55
|
const snapshot = client.getSnapshot();
|
|
56
56
|
const nextOrder = new Array(snapshot.length);
|
|
57
57
|
const seen = /* @__PURE__ */ new Set();
|
|
58
|
+
let w = 0;
|
|
58
59
|
for (let i = 0; i < snapshot.length; i++) {
|
|
59
60
|
const b = snapshot[i];
|
|
60
|
-
|
|
61
|
+
if (b == null || b.kind == null) continue;
|
|
62
|
+
nextOrder[w++] = b.id;
|
|
61
63
|
seen.add(b.id);
|
|
62
64
|
const existing = mounted.get(b.id);
|
|
63
65
|
if (!existing) {
|
|
@@ -131,6 +133,7 @@ function mountBrookMarkdown(client, container, options = {}) {
|
|
|
131
133
|
}
|
|
132
134
|
}
|
|
133
135
|
}
|
|
136
|
+
if (w !== nextOrder.length) nextOrder.length = w;
|
|
134
137
|
order = nextOrder;
|
|
135
138
|
reconcileChildren();
|
|
136
139
|
}
|
package/dist/html-to-react.js
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { createElement, Fragment } from "react";
|
|
2
2
|
import { decorateSegments } from "./decorate.js";
|
|
3
3
|
import { decodeEntities, safeUrl } from "./url-safety.js";
|
|
4
|
+
import { warnOnce } from "./warn.js";
|
|
5
|
+
const BLOCK_KIND_KEYS = /* @__PURE__ */ new Set([
|
|
6
|
+
"Paragraph",
|
|
7
|
+
"Heading",
|
|
8
|
+
"CodeBlock",
|
|
9
|
+
"MathBlock",
|
|
10
|
+
"Mermaid",
|
|
11
|
+
"List",
|
|
12
|
+
"Blockquote",
|
|
13
|
+
"Alert",
|
|
14
|
+
"Table",
|
|
15
|
+
"Rule",
|
|
16
|
+
"Html",
|
|
17
|
+
"Component"
|
|
18
|
+
]);
|
|
4
19
|
const VOID = /* @__PURE__ */ new Set([
|
|
5
20
|
"area",
|
|
6
21
|
"base",
|
|
@@ -220,6 +235,19 @@ function pushDecoratedText(out, text, decorators, ancestors, keyBase) {
|
|
|
220
235
|
out.push(createElement(Fragment, { key: keyBase + ":" + i }, replacement));
|
|
221
236
|
}
|
|
222
237
|
}
|
|
238
|
+
function resolveTagType(tag, components) {
|
|
239
|
+
const c = tag.charCodeAt(0);
|
|
240
|
+
if (c >= 65 && c <= 90 && BLOCK_KIND_KEYS.has(tag)) {
|
|
241
|
+
if (components[tag] !== void 0 && typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
|
|
242
|
+
warnOnce(
|
|
243
|
+
"kind-key-as-tag:" + tag,
|
|
244
|
+
`brookmd: raw <${tag}> in the rendered HTML collides with the block-kind override key "${tag}". A block-kind override receives \`BlockComponentProps\` (with \`block\`), which an inline element cannot supply, so the raw tag is rendered as a plain element instead. Rename the raw tag or the override key.`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return tag;
|
|
248
|
+
}
|
|
249
|
+
return components[tag] ?? tag;
|
|
250
|
+
}
|
|
223
251
|
function nodesToReact(nodes, components, keyPrefix, ctx, ancestors) {
|
|
224
252
|
const out = [];
|
|
225
253
|
for (let idx = 0; idx < nodes.length; idx++) {
|
|
@@ -233,7 +261,7 @@ function nodesToReact(nodes, components, keyPrefix, ctx, ancestors) {
|
|
|
233
261
|
continue;
|
|
234
262
|
}
|
|
235
263
|
const key = keyPrefix + idx;
|
|
236
|
-
const type =
|
|
264
|
+
const type = resolveTagType(n.tag, components);
|
|
237
265
|
const props = attrsToProps(n.tag, n.attrs, key, ctx?.urlTransform);
|
|
238
266
|
if (VOID.has(n.tag.toLowerCase())) {
|
|
239
267
|
out.push(createElement(type, props));
|
|
@@ -307,6 +335,7 @@ function htmlToReact(html, components, childMemoMap, opts) {
|
|
|
307
335
|
const ctx = opts && (opts.decorators || opts.urlTransform) ? { decorators: opts.decorators, urlTransform: opts.urlTransform } : void 0;
|
|
308
336
|
if (!childMemoMap) return nodesToReact(parseTrustedHtml(html), components, "", ctx, []);
|
|
309
337
|
const segs = topLevelSegments(html);
|
|
338
|
+
if (segs.length < 2) return nodesToReact(parseTrustedHtml(html), components, "", ctx, []);
|
|
310
339
|
const out = [];
|
|
311
340
|
for (let idx = 0; idx < segs.length; idx++) {
|
|
312
341
|
const seg = segs[idx];
|
package/dist/react.d.ts
CHANGED
|
@@ -165,6 +165,41 @@ interface BrookMarkdownProps {
|
|
|
165
165
|
* concurrent deferral of the visible tail.
|
|
166
166
|
*/
|
|
167
167
|
deferTail?: boolean;
|
|
168
|
+
/**
|
|
169
|
+
* Called when a single block's render THROWS — almost always from inside a
|
|
170
|
+
* `components` override, not from brookmd itself.
|
|
171
|
+
*
|
|
172
|
+
* Every block is wrapped in its own error boundary, so a throwing override
|
|
173
|
+
* costs you that one block instead of unmounting the whole document (React's
|
|
174
|
+
* default for an uncaught render error is to unmount the entire tree — a blank
|
|
175
|
+
* page). The failed block renders nothing; the rest of the stream keeps going,
|
|
176
|
+
* and later patches retry it.
|
|
177
|
+
*
|
|
178
|
+
* Without this hook the failure still goes to `console.error` with the block
|
|
179
|
+
* id, kind, override keys, and an HTML excerpt. Wire it to your error reporter
|
|
180
|
+
* to get the same detail in production.
|
|
181
|
+
*
|
|
182
|
+
* **HOIST / memoize this** (same trap as `components` / `onRenderMetrics`): a
|
|
183
|
+
* fresh closure each render busts every block's memo and re-renders the whole
|
|
184
|
+
* document on every patch.
|
|
185
|
+
*
|
|
186
|
+
* The most common cause by far: an override registered for a *block-kind* or
|
|
187
|
+
* *component-tag* key reading `props.block.…`, invoked on the tag path (the
|
|
188
|
+
* same tag nested inside another block, or used inline), where `block` is not
|
|
189
|
+
* supplied. Guard with `if (!block) return <>{children}</>`.
|
|
190
|
+
*/
|
|
191
|
+
onBlockError?: (error: Error, info: BlockErrorInfo) => void;
|
|
192
|
+
}
|
|
193
|
+
/** Context handed to {@link BrookMarkdownProps.onBlockError}. */
|
|
194
|
+
export interface BlockErrorInfo {
|
|
195
|
+
/** The block's stable parser-assigned id (also its React key). */
|
|
196
|
+
blockId: number;
|
|
197
|
+
/** The block kind that failed, e.g. `"Component"` / `"Table"`. */
|
|
198
|
+
kind: string;
|
|
199
|
+
/** The override keys in play — the shortlist of suspects. */
|
|
200
|
+
componentKeys: string[];
|
|
201
|
+
/** First 200 chars of the block's rendered HTML, to identify the content. */
|
|
202
|
+
html: string;
|
|
168
203
|
}
|
|
169
204
|
export declare function __resetUnstableWarnings(): void;
|
|
170
205
|
/**
|
|
@@ -223,16 +258,7 @@ export declare function useBrookMarkdownString(content: string, options?: {
|
|
|
223
258
|
declare function BrookMarkdownImpl(props: BrookMarkdownProps): import("react/jsx-runtime").JSX.Element;
|
|
224
259
|
export declare const BrookMarkdown: import("react").MemoExoticComponent<typeof BrookMarkdownImpl>;
|
|
225
260
|
export declare function blockKindProps(block: Block, components?: Components): BlockComponentProps;
|
|
226
|
-
|
|
227
|
-
block: Block;
|
|
228
|
-
components?: Components;
|
|
229
|
-
virtualize?: boolean;
|
|
230
|
-
sanitize?: (html: string) => string;
|
|
231
|
-
childMemo?: boolean;
|
|
232
|
-
onRenderMetrics?: RenderMetricsHook;
|
|
233
|
-
decorators?: Decorator[];
|
|
234
|
-
urlTransform?: UrlTransform;
|
|
235
|
-
}, next: {
|
|
261
|
+
interface BlockViewProps {
|
|
236
262
|
block: Block;
|
|
237
263
|
components?: Components;
|
|
238
264
|
virtualize?: boolean;
|
|
@@ -241,5 +267,12 @@ export declare function blocksEqual(prev: {
|
|
|
241
267
|
onRenderMetrics?: RenderMetricsHook;
|
|
242
268
|
decorators?: Decorator[];
|
|
243
269
|
urlTransform?: UrlTransform;
|
|
244
|
-
|
|
270
|
+
/** Diagnostic passthrough for the boundary; derived from `components`, so its
|
|
271
|
+
* identity is stable whenever `components` is. */
|
|
272
|
+
componentKeys?: string[];
|
|
273
|
+
onBlockError?: (error: Error, info: BlockErrorInfo) => void;
|
|
274
|
+
}
|
|
275
|
+
export declare function blocksEqual(prev: BlockViewProps, next: BlockViewProps): boolean;
|
|
276
|
+
export declare function __getBoundaryRenders(): number;
|
|
277
|
+
export declare function __resetBoundaryRenders(): void;
|
|
245
278
|
export {};
|
package/dist/react.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
2
|
import {
|
|
3
|
+
Component,
|
|
3
4
|
createElement,
|
|
4
5
|
memo,
|
|
5
6
|
useEffect,
|
|
@@ -14,7 +15,18 @@ import { CodeBlock } from "./renderers/CodeBlock.js";
|
|
|
14
15
|
import { MathBlock } from "./renderers/Math.js";
|
|
15
16
|
import { Mermaid } from "./renderers/Mermaid.js";
|
|
16
17
|
import { htmlToReact } from "./html-to-react.js";
|
|
18
|
+
import { warnOnce } from "./warn.js";
|
|
17
19
|
const NO_DEFER_BLOCKS = [];
|
|
20
|
+
const EMPTY_KEYS = [];
|
|
21
|
+
function skipBadBlock(index) {
|
|
22
|
+
if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
|
|
23
|
+
warnOnce(
|
|
24
|
+
"bad-block",
|
|
25
|
+
`brookmd: snapshot position ${index} has no block kind and was skipped. This indicates a corrupted block store \u2014 please report it.`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
18
30
|
const warnedUnstable = /* @__PURE__ */ new Set();
|
|
19
31
|
function useUnstablePropWarning(name, value) {
|
|
20
32
|
const ref = useRef(value);
|
|
@@ -22,8 +34,7 @@ function useUnstablePropWarning(name, value) {
|
|
|
22
34
|
const prevDefined = ref.current !== void 0 && ref.current !== null;
|
|
23
35
|
const nextDefined = value !== void 0 && value !== null;
|
|
24
36
|
ref.current = value;
|
|
25
|
-
|
|
26
|
-
if (prevDefined && nextDefined && !warnedUnstable.has(name) && (!env || env.NODE_ENV !== "production")) {
|
|
37
|
+
if (prevDefined && nextDefined && !warnedUnstable.has(name) && typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
|
|
27
38
|
warnedUnstable.add(name);
|
|
28
39
|
console.warn(
|
|
29
40
|
`<BrookMarkdown>: the \`${name}\` prop changed identity between renders. Hoist it to module scope or wrap it in useMemo \u2014 a fresh identity each render busts the per-block memo and re-parses every block on every patch.`
|
|
@@ -49,7 +60,8 @@ function BrookMarkdownFromClient({
|
|
|
49
60
|
onRenderMetrics,
|
|
50
61
|
deferTail,
|
|
51
62
|
decorators,
|
|
52
|
-
urlTransform
|
|
63
|
+
urlTransform,
|
|
64
|
+
onBlockError
|
|
53
65
|
}) {
|
|
54
66
|
const blocks = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot);
|
|
55
67
|
useUnstablePropWarning("decorators", decorators);
|
|
@@ -61,6 +73,7 @@ function BrookMarkdownFromClient({
|
|
|
61
73
|
() => components && Object.keys(components).length > 0 ? components : void 0,
|
|
62
74
|
[components]
|
|
63
75
|
);
|
|
76
|
+
const componentKeys = useMemo(() => comps ? Object.keys(comps) : EMPTY_KEYS, [comps]);
|
|
64
77
|
const onMetrics = useMemo(
|
|
65
78
|
() => onRenderMetrics ? (id2, m) => {
|
|
66
79
|
client.__noteRender();
|
|
@@ -78,20 +91,29 @@ function BrookMarkdownFromClient({
|
|
|
78
91
|
"aria-live": ariaLive,
|
|
79
92
|
"aria-atomic": ariaAtomic,
|
|
80
93
|
children: [
|
|
81
|
-
rendered.map(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
rendered.map(
|
|
95
|
+
(b, i) => (
|
|
96
|
+
// The guard runs BEFORE `key={b.id}` is evaluated: a malformed entry
|
|
97
|
+
// must not throw here (it would take the whole document down), and the
|
|
98
|
+
// store's density invariant is not something a renderer should bet on.
|
|
99
|
+
b == null || b.kind == null ? skipBadBlock(i) : /* @__PURE__ */ jsx(
|
|
100
|
+
BlockView,
|
|
101
|
+
{
|
|
102
|
+
block: b,
|
|
103
|
+
components: comps,
|
|
104
|
+
virtualize,
|
|
105
|
+
sanitize,
|
|
106
|
+
childMemo,
|
|
107
|
+
onRenderMetrics: onMetrics,
|
|
108
|
+
decorators,
|
|
109
|
+
urlTransform,
|
|
110
|
+
componentKeys,
|
|
111
|
+
onBlockError
|
|
112
|
+
},
|
|
113
|
+
b.id
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
),
|
|
95
117
|
stickToBottom && /* @__PURE__ */ jsx("div", { "aria-hidden": "true", style: { scrollSnapAlign: "end" }, className: "brook-bottom-anchor" })
|
|
96
118
|
]
|
|
97
119
|
}
|
|
@@ -466,7 +488,8 @@ function renderBlockContent({
|
|
|
466
488
|
decorators,
|
|
467
489
|
urlTransform
|
|
468
490
|
}) {
|
|
469
|
-
const kind = block
|
|
491
|
+
const kind = block?.kind?.type;
|
|
492
|
+
if (kind === void 0) return null;
|
|
470
493
|
const hasInlineTransforms = !!decorators || !!urlTransform;
|
|
471
494
|
if (components) {
|
|
472
495
|
if (kind === "Component") {
|
|
@@ -545,14 +568,80 @@ function renderBlockContent({
|
|
|
545
568
|
);
|
|
546
569
|
}
|
|
547
570
|
function blocksEqual(prev, next) {
|
|
571
|
+
if (prev.block == null || next.block == null) return prev.block === next.block;
|
|
548
572
|
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)
|
|
549
573
|
// busts the memo so every committed block re-decorates — the O(n²) footgun
|
|
550
574
|
// the dev warning calls out. A hoisted/memoized value keeps the memo holding.
|
|
551
|
-
prev.decorators === next.decorators && prev.urlTransform === next.urlTransform
|
|
575
|
+
prev.decorators === next.decorators && prev.urlTransform === next.urlTransform && // Same identity rule as onRenderMetrics: an inline `onBlockError={() => …}`
|
|
576
|
+
// is a fresh closure per render and would re-render every block on every
|
|
577
|
+
// patch. Hoist or memoize it (documented alongside the other hooks).
|
|
578
|
+
prev.onBlockError === next.onBlockError && prev.componentKeys === next.componentKeys;
|
|
579
|
+
}
|
|
580
|
+
let boundaryRenders = 0;
|
|
581
|
+
function __getBoundaryRenders() {
|
|
582
|
+
return boundaryRenders;
|
|
583
|
+
}
|
|
584
|
+
function __resetBoundaryRenders() {
|
|
585
|
+
boundaryRenders = 0;
|
|
586
|
+
}
|
|
587
|
+
function BlockViewOuter(props) {
|
|
588
|
+
const { block, componentKeys, onBlockError } = props;
|
|
589
|
+
return /* @__PURE__ */ jsx(
|
|
590
|
+
BlockBoundary,
|
|
591
|
+
{
|
|
592
|
+
blockId: block.id,
|
|
593
|
+
kind: block.kind.type,
|
|
594
|
+
html: block.html,
|
|
595
|
+
componentKeys: componentKeys ?? EMPTY_KEYS,
|
|
596
|
+
onBlockError,
|
|
597
|
+
children: /* @__PURE__ */ jsx(BlockViewImpl, { ...props })
|
|
598
|
+
}
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
const BlockView = memo(BlockViewOuter, blocksEqual);
|
|
602
|
+
class BlockBoundary extends Component {
|
|
603
|
+
state = { caught: false, failedHtml: null };
|
|
604
|
+
static getDerivedStateFromError() {
|
|
605
|
+
return { caught: true };
|
|
606
|
+
}
|
|
607
|
+
/** Retry once the block's HTML moves on. A streaming-tail failure — a
|
|
608
|
+
* speculatively-closed tag, a prop that is only transiently absent — then
|
|
609
|
+
* heals itself when the block settles, instead of leaving a hole for the rest
|
|
610
|
+
* of the session. */
|
|
611
|
+
static getDerivedStateFromProps(props, state) {
|
|
612
|
+
if (state.caught && state.failedHtml !== null && state.failedHtml !== props.html) {
|
|
613
|
+
return { caught: false, failedHtml: null };
|
|
614
|
+
}
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
componentDidCatch(error) {
|
|
618
|
+
const info = {
|
|
619
|
+
blockId: this.props.blockId,
|
|
620
|
+
kind: this.props.kind,
|
|
621
|
+
componentKeys: this.props.componentKeys,
|
|
622
|
+
html: this.props.html.slice(0, 200)
|
|
623
|
+
};
|
|
624
|
+
this.setState({ failedHtml: this.props.html });
|
|
625
|
+
if (this.props.onBlockError) {
|
|
626
|
+
this.props.onBlockError(error, info);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const hint = typeof process !== "undefined" && process.env.NODE_ENV !== "production" ? ` This is almost always a \`components\` override throwing. If it is registered for a block-kind or component-tag key, note that the SAME key is also dispatched for a matching element nested inside a block's HTML \u2014 that call gets attributes + children only, with no \`block\` prop. Guard with \`if (!block) return <>{children}</>\`.` : "";
|
|
630
|
+
console.error(
|
|
631
|
+
`brookmd: block ${info.blockId} (${info.kind}) failed to render and was skipped.${hint}`,
|
|
632
|
+
{ componentKeys: info.componentKeys, html: info.html },
|
|
633
|
+
error
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
render() {
|
|
637
|
+
boundaryRenders++;
|
|
638
|
+
return this.state.caught ? null : this.props.children;
|
|
639
|
+
}
|
|
552
640
|
}
|
|
553
|
-
const BlockView = memo(BlockViewImpl, blocksEqual);
|
|
554
641
|
export {
|
|
555
642
|
BrookMarkdown,
|
|
643
|
+
__getBoundaryRenders,
|
|
644
|
+
__resetBoundaryRenders,
|
|
556
645
|
__resetUnstableWarnings,
|
|
557
646
|
blockKindProps,
|
|
558
647
|
blocksEqual,
|
package/dist/server-react.js
CHANGED
|
@@ -3,7 +3,8 @@ import { htmlToReact } from "./html-to-react.js";
|
|
|
3
3
|
import { blockKindProps } from "./react.js";
|
|
4
4
|
import { parseToBlocks } from "./server.js";
|
|
5
5
|
function renderStaticBlock(block, components) {
|
|
6
|
-
const kind = block
|
|
6
|
+
const kind = block?.kind?.type;
|
|
7
|
+
if (kind === void 0) return null;
|
|
7
8
|
if (components) {
|
|
8
9
|
if (kind === "Component") {
|
|
9
10
|
const tag = block.kind.data?.tag;
|
package/dist/types-react.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ComponentType } from "react";
|
|
2
|
+
import type { BlockComponentProps, BlockKindTag } from "./types-core.js";
|
|
2
3
|
/**
|
|
3
4
|
* Override map for {@link BrookMarkdown}. Keys are either lowercase HTML tag
|
|
4
5
|
* names (`table`, `a`, `code`, `h1`… — react-markdown style, applied inside a
|
|
@@ -6,8 +7,35 @@ import type { ComponentType } from "react";
|
|
|
6
7
|
* `CodeBlock`, `Table` — replace the whole block renderer). Values are a React
|
|
7
8
|
* component or an HTML tag string.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* ## Two prop contracts — read this before writing an override
|
|
11
|
+
*
|
|
12
|
+
* The same map is consulted by two dispatchers, and they pass different props:
|
|
13
|
+
*
|
|
14
|
+
* - **Block contract.** A block-kind key, or a `componentTags` tag matched at
|
|
15
|
+
* block level, receives {@link BlockComponentProps} — `block`, `html`, `open`,
|
|
16
|
+
* `speculative` (plus `tag`/`attrs`/`children` for component tags).
|
|
17
|
+
* - **Tag contract.** The SAME key is also matched by *element name* while
|
|
18
|
+
* converting a block's HTML to React — which is how `a`/`code`/`table`
|
|
19
|
+
* overrides work, and also how an `inlineComponentTags` chip, or a component
|
|
20
|
+
* tag nested inside a list item / blockquote, is rendered. That call passes
|
|
21
|
+
* the element's attributes and `children` only: **there is no `block` prop.**
|
|
22
|
+
*
|
|
23
|
+
* So a component registered for a tag that can appear in both positions must not
|
|
24
|
+
* assume `block` exists:
|
|
25
|
+
*
|
|
26
|
+
* ```tsx
|
|
27
|
+
* const Thinking = ({ block, children }: any) =>
|
|
28
|
+
* block ? <Panel data={block.kind.data}>{children}</Panel> : <span>{children}</span>;
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* Block-kind keys are typed to {@link BlockComponentProps} below so the mismatch
|
|
32
|
+
* is a compile error rather than a runtime `undefined` deref; brookmd also
|
|
33
|
+
* refuses to dispatch a raw element whose name collides with a block-kind key,
|
|
34
|
+
* and wraps every block in an error boundary so a throwing override costs one
|
|
35
|
+
* block instead of the document.
|
|
12
36
|
*/
|
|
13
|
-
export type Components =
|
|
37
|
+
export type Components = {
|
|
38
|
+
[K in BlockKindTag]?: ComponentType<BlockComponentProps> | string;
|
|
39
|
+
} & {
|
|
40
|
+
[tag: string]: ComponentType<any> | string | undefined;
|
|
41
|
+
};
|
package/dist/warn.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Warn once per `id`. Callers own the production gate — see the contract above. */
|
|
2
|
+
export declare function warnOnce(id: string, message: string): boolean;
|
|
3
|
+
/** Test-only: clear the latch so a test can assert a warning fires. */
|
|
4
|
+
export declare function __resetWarnOnce(): void;
|
package/dist/warn.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const warned = /* @__PURE__ */ new Set();
|
|
2
|
+
function warnOnce(id, message) {
|
|
3
|
+
if (warned.has(id)) return false;
|
|
4
|
+
warned.add(id);
|
|
5
|
+
console.warn(message);
|
|
6
|
+
return true;
|
|
7
|
+
}
|
|
8
|
+
function __resetWarnOnce() {
|
|
9
|
+
warned.clear();
|
|
10
|
+
}
|
|
11
|
+
export {
|
|
12
|
+
__resetWarnOnce,
|
|
13
|
+
warnOnce
|
|
14
|
+
};
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brookmd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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"],
|