brookmd 0.23.2 → 0.25.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/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,16 @@ 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
+ warnOnce(
23
+ "bad-block",
24
+ `brookmd: snapshot position ${index} has no block kind and was skipped. This indicates a corrupted block store \u2014 please report it.`
25
+ );
26
+ return null;
27
+ }
18
28
  const warnedUnstable = /* @__PURE__ */ new Set();
19
29
  function useUnstablePropWarning(name, value) {
20
30
  const ref = useRef(value);
@@ -49,7 +59,8 @@ function BrookMarkdownFromClient({
49
59
  onRenderMetrics,
50
60
  deferTail,
51
61
  decorators,
52
- urlTransform
62
+ urlTransform,
63
+ onBlockError
53
64
  }) {
54
65
  const blocks = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot);
55
66
  useUnstablePropWarning("decorators", decorators);
@@ -61,6 +72,7 @@ function BrookMarkdownFromClient({
61
72
  () => components && Object.keys(components).length > 0 ? components : void 0,
62
73
  [components]
63
74
  );
75
+ const componentKeys = useMemo(() => comps ? Object.keys(comps) : EMPTY_KEYS, [comps]);
64
76
  const onMetrics = useMemo(
65
77
  () => onRenderMetrics ? (id2, m) => {
66
78
  client.__noteRender();
@@ -78,29 +90,48 @@ function BrookMarkdownFromClient({
78
90
  "aria-live": ariaLive,
79
91
  "aria-atomic": ariaAtomic,
80
92
  children: [
81
- rendered.map((b) => /* @__PURE__ */ jsx(
82
- BlockView,
83
- {
84
- block: b,
85
- components: comps,
86
- virtualize,
87
- sanitize,
88
- childMemo,
89
- onRenderMetrics: onMetrics,
90
- decorators,
91
- urlTransform
92
- },
93
- b.id
94
- )),
93
+ rendered.map(
94
+ (b, i) => (
95
+ // The guard runs BEFORE `key={b.id}` is evaluated: a malformed entry
96
+ // must not throw here (it would take the whole document down), and the
97
+ // store's density invariant is not something a renderer should bet on.
98
+ b == null || b.kind == null ? skipBadBlock(i) : /* @__PURE__ */ jsx(
99
+ BlockView,
100
+ {
101
+ block: b,
102
+ components: comps,
103
+ virtualize,
104
+ sanitize,
105
+ childMemo,
106
+ onRenderMetrics: onMetrics,
107
+ decorators,
108
+ urlTransform,
109
+ componentKeys,
110
+ onBlockError
111
+ },
112
+ b.id
113
+ )
114
+ )
115
+ ),
95
116
  stickToBottom && /* @__PURE__ */ jsx("div", { "aria-hidden": "true", style: { scrollSnapAlign: "end" }, className: "brook-bottom-anchor" })
96
117
  ]
97
118
  }
98
119
  );
99
120
  }
100
121
  function useBrookStream(stream, options) {
101
- const [client] = useState(() => new BrookClient({ config: options?.config, coalesce: true }));
102
122
  const onErrorRef = useRef(options?.onError);
103
123
  onErrorRef.current = options?.onError;
124
+ const [client] = useState(
125
+ () => new BrookClient({
126
+ config: options?.config,
127
+ coalesce: true,
128
+ onError: (err) => {
129
+ const e = Object.assign(new Error(err.message), { fatal: err.fatal });
130
+ if (onErrorRef.current) onErrorRef.current(e);
131
+ else console.error(e);
132
+ }
133
+ })
134
+ );
104
135
  const prevStream = useRef(void 0);
105
136
  useEffect(() => {
106
137
  client.reattach();
@@ -123,7 +154,19 @@ function useBrookStream(stream, options) {
123
154
  return client;
124
155
  }
125
156
  function useBrookMarkdownString(content, options) {
126
- const [client] = useState(() => new BrookClient({ config: options?.config, coalesce: true }));
157
+ const onErrorRef = useRef(options?.onError);
158
+ onErrorRef.current = options?.onError;
159
+ const [client] = useState(
160
+ () => new BrookClient({
161
+ config: options?.config,
162
+ coalesce: true,
163
+ onError: (err) => {
164
+ const e = Object.assign(new Error(err.message), { fatal: err.fatal });
165
+ if (onErrorRef.current) onErrorRef.current(e);
166
+ else console.error(e);
167
+ }
168
+ })
169
+ );
127
170
  useEffect(() => {
128
171
  client.reattach();
129
172
  return () => client.destroy();
@@ -444,7 +487,8 @@ function renderBlockContent({
444
487
  decorators,
445
488
  urlTransform
446
489
  }) {
447
- const kind = block.kind.type;
490
+ const kind = block?.kind?.type;
491
+ if (kind === void 0) return null;
448
492
  const hasInlineTransforms = !!decorators || !!urlTransform;
449
493
  if (components) {
450
494
  if (kind === "Component") {
@@ -523,14 +567,79 @@ function renderBlockContent({
523
567
  );
524
568
  }
525
569
  function blocksEqual(prev, next) {
570
+ if (prev.block == null || next.block == null) return prev.block === next.block;
526
571
  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)
527
572
  // busts the memo so every committed block re-decorates — the O(n²) footgun
528
573
  // the dev warning calls out. A hoisted/memoized value keeps the memo holding.
529
- prev.decorators === next.decorators && prev.urlTransform === next.urlTransform;
574
+ prev.decorators === next.decorators && prev.urlTransform === next.urlTransform && // Same identity rule as onRenderMetrics: an inline `onBlockError={() => …}`
575
+ // is a fresh closure per render and would re-render every block on every
576
+ // patch. Hoist or memoize it (documented alongside the other hooks).
577
+ prev.onBlockError === next.onBlockError && prev.componentKeys === next.componentKeys;
578
+ }
579
+ let boundaryRenders = 0;
580
+ function __getBoundaryRenders() {
581
+ return boundaryRenders;
582
+ }
583
+ function __resetBoundaryRenders() {
584
+ boundaryRenders = 0;
585
+ }
586
+ function BlockViewOuter(props) {
587
+ const { block, componentKeys, onBlockError } = props;
588
+ return /* @__PURE__ */ jsx(
589
+ BlockBoundary,
590
+ {
591
+ blockId: block.id,
592
+ kind: block.kind.type,
593
+ html: block.html,
594
+ componentKeys: componentKeys ?? EMPTY_KEYS,
595
+ onBlockError,
596
+ children: /* @__PURE__ */ jsx(BlockViewImpl, { ...props })
597
+ }
598
+ );
599
+ }
600
+ const BlockView = memo(BlockViewOuter, blocksEqual);
601
+ class BlockBoundary extends Component {
602
+ state = { caught: false, failedHtml: null };
603
+ static getDerivedStateFromError() {
604
+ return { caught: true };
605
+ }
606
+ /** Retry once the block's HTML moves on. A streaming-tail failure — a
607
+ * speculatively-closed tag, a prop that is only transiently absent — then
608
+ * heals itself when the block settles, instead of leaving a hole for the rest
609
+ * of the session. */
610
+ static getDerivedStateFromProps(props, state) {
611
+ if (state.caught && state.failedHtml !== null && state.failedHtml !== props.html) {
612
+ return { caught: false, failedHtml: null };
613
+ }
614
+ return null;
615
+ }
616
+ componentDidCatch(error) {
617
+ const info = {
618
+ blockId: this.props.blockId,
619
+ kind: this.props.kind,
620
+ componentKeys: this.props.componentKeys,
621
+ html: this.props.html.slice(0, 200)
622
+ };
623
+ this.setState({ failedHtml: this.props.html });
624
+ if (this.props.onBlockError) {
625
+ this.props.onBlockError(error, info);
626
+ return;
627
+ }
628
+ console.error(
629
+ `brookmd: block ${info.blockId} (${info.kind}) failed to render and was skipped. This is almost always a \`components\` override throwing. If the override 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
+ { componentKeys: info.componentKeys, html: info.html },
631
+ error
632
+ );
633
+ }
634
+ render() {
635
+ boundaryRenders++;
636
+ return this.state.caught ? null : this.props.children;
637
+ }
530
638
  }
531
- const BlockView = memo(BlockViewImpl, blocksEqual);
532
639
  export {
533
640
  BrookMarkdown,
641
+ __getBoundaryRenders,
642
+ __resetBoundaryRenders,
534
643
  __resetUnstableWarnings,
535
644
  blockKindProps,
536
645
  blocksEqual,
@@ -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.kind.type;
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;
@@ -450,8 +450,19 @@ export type FromWorker = {
450
450
  */
451
451
  export interface WorkerLike {
452
452
  postMessage(msg: ToWorker): void;
453
- addEventListener(type: "message", listener: (ev: {
453
+ /**
454
+ * Structural superset of DOM `Worker.addEventListener` for the three channels
455
+ * the pool listens on: `message` (patches / ready / in-band errors — read via
456
+ * `ev.data`) plus the out-of-band failure channels `error` (a script that
457
+ * 404s or throws at load — `ev.message`) and `messageerror` (an
458
+ * undeserializable posted message). One widened signature keeps the unit-test
459
+ * fakes that declare only `"message"` compiling — method parameters are
460
+ * checked bivariantly — while letting the pool attach all three without a
461
+ * structural cast.
462
+ */
463
+ addEventListener(type: "message" | "error" | "messageerror", listener: (ev: {
454
464
  data: FromWorker;
465
+ message?: string;
455
466
  }) => void): void;
456
467
  terminate(): void;
457
468
  }
@@ -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
- * Tag-level components receive the element's parsed attributes (with
10
- * `class`→`className`, `style` as an object) plus `children`. Block-kind
11
- * components receive `BlockComponentProps`. There is no `node` prop.
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 = Record<string, ComponentType<any> | string>;
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,9 @@
1
+ /** True unless the bundler/runtime says NODE_ENV === "production". Read off
2
+ * `globalThis` so there is no @types/node dependency; bundlers inline
3
+ * `process.env.NODE_ENV`, and absence is treated as dev (same rule as the
4
+ * unstable-prop tripwire in react.tsx). */
5
+ export declare function isDev(): boolean;
6
+ /** Warn once per `id` (dev only). Returns true if it actually warned. */
7
+ export declare function warnOnce(id: string, message: string): boolean;
8
+ /** Test-only: clear the latch so a test can assert a warning fires. */
9
+ export declare function __resetWarnOnce(): void;
package/dist/warn.js ADDED
@@ -0,0 +1,19 @@
1
+ const warned = /* @__PURE__ */ new Set();
2
+ function isDev() {
3
+ const env = globalThis.process?.env;
4
+ return !env || env.NODE_ENV !== "production";
5
+ }
6
+ function warnOnce(id, message) {
7
+ if (!isDev() || warned.has(id)) return false;
8
+ warned.add(id);
9
+ console.warn(message);
10
+ return true;
11
+ }
12
+ function __resetWarnOnce() {
13
+ warned.clear();
14
+ }
15
+ export {
16
+ __resetWarnOnce,
17
+ isDev,
18
+ warnOnce
19
+ };
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brookmd",
3
- "version": "0.23.2",
3
+ "version": "0.25.0",
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"],