@react-x11/components 0.5.0 → 0.7.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.
Files changed (68) hide show
  1. package/README.md +67 -43
  2. package/dist/formula/node.d.ts.map +1 -1
  3. package/dist/formula/node.js +17 -6
  4. package/dist/formula/node.js.map +1 -1
  5. package/dist/index.d.ts +1 -3
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +0 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/markdown/ast.d.ts +80 -6
  10. package/dist/markdown/ast.d.ts.map +1 -1
  11. package/dist/markdown/ast.js +13 -4
  12. package/dist/markdown/ast.js.map +1 -1
  13. package/dist/markdown/expressions.d.ts +27 -0
  14. package/dist/markdown/expressions.d.ts.map +1 -0
  15. package/dist/markdown/expressions.js +203 -0
  16. package/dist/markdown/expressions.js.map +1 -0
  17. package/dist/markdown/index.d.ts +61 -2
  18. package/dist/markdown/index.d.ts.map +1 -1
  19. package/dist/markdown/index.js +101 -2
  20. package/dist/markdown/index.js.map +1 -1
  21. package/dist/markdown/parse.d.ts +1 -1
  22. package/dist/markdown/parse.d.ts.map +1 -1
  23. package/dist/markdown/parse.js +163 -15
  24. package/dist/markdown/parse.js.map +1 -1
  25. package/dist/markdown/spans.d.ts.map +1 -1
  26. package/dist/markdown/spans.js +4 -0
  27. package/dist/markdown/spans.js.map +1 -1
  28. package/dist/markdown/tags.d.ts +35 -0
  29. package/dist/markdown/tags.d.ts.map +1 -0
  30. package/dist/markdown/tags.js +213 -0
  31. package/dist/markdown/tags.js.map +1 -0
  32. package/dist/richtext/node.d.ts.map +1 -1
  33. package/dist/richtext/node.js +7 -2
  34. package/dist/richtext/node.js.map +1 -1
  35. package/dist/table/index.d.ts +15 -0
  36. package/dist/table/index.d.ts.map +1 -1
  37. package/dist/table/index.js +95 -11
  38. package/dist/table/index.js.map +1 -1
  39. package/dist/tree/index.d.ts.map +1 -1
  40. package/dist/tree/index.js +77 -2
  41. package/dist/tree/index.js.map +1 -1
  42. package/package.json +4 -8
  43. package/src/formula/node.ts +22 -34
  44. package/src/index.ts +4 -18
  45. package/src/markdown/ast.ts +85 -9
  46. package/src/markdown/expressions.ts +223 -0
  47. package/src/markdown/index.ts +190 -5
  48. package/src/markdown/parse.ts +230 -15
  49. package/src/markdown/spans.ts +4 -0
  50. package/src/markdown/tags.ts +240 -0
  51. package/src/richtext/node.ts +7 -2
  52. package/src/table/index.ts +114 -10
  53. package/src/tree/index.ts +77 -2
  54. package/dist/desktop-calendar/eds.d.ts +0 -102
  55. package/dist/desktop-calendar/eds.d.ts.map +0 -1
  56. package/dist/desktop-calendar/eds.js +0 -356
  57. package/dist/desktop-calendar/eds.js.map +0 -1
  58. package/dist/desktop-calendar/ical.d.ts +0 -63
  59. package/dist/desktop-calendar/ical.d.ts.map +0 -1
  60. package/dist/desktop-calendar/ical.js +0 -57
  61. package/dist/desktop-calendar/ical.js.map +0 -1
  62. package/dist/desktop-calendar/index.d.ts +0 -72
  63. package/dist/desktop-calendar/index.d.ts.map +0 -1
  64. package/dist/desktop-calendar/index.js +0 -213
  65. package/dist/desktop-calendar/index.js.map +0 -1
  66. package/src/desktop-calendar/eds.ts +0 -484
  67. package/src/desktop-calendar/ical.ts +0 -119
  68. package/src/desktop-calendar/index.ts +0 -287
@@ -1,9 +1,10 @@
1
1
  // The markdown AST `parse.ts` produces and `index.ts` renders. Deliberately
2
2
  // small: it is the GFM constructs this component draws, not a general
3
3
  // mdast — but it is shaped so that growing it stays additive. In particular
4
- // `component` is reserved for the MDX direction: a parser extension can emit
5
- // one without the renderer, the selection model or the caches changing shape
6
- // (see "MDX" in index.ts).
4
+ // `component` is the MDX direction: `ComponentBlock` is parsed and rendered
5
+ // (docs/prd-mdx.md, M1), and `ComponentInline` is still reserved see the
6
+ // note on it for why the inline half needs machinery `<richtext>` does not
7
+ // have yet.
7
8
 
8
9
  /** Inline content, inside a paragraph, heading, list item or table cell. */
9
10
  export type InlineNode =
@@ -12,7 +13,19 @@ export type InlineNode =
12
13
  | EmphasisInline
13
14
  | LinkInline
14
15
  | BreakInline
15
- | ComponentInline;
16
+ | ComponentInline
17
+ | ExpressionInline;
18
+
19
+ /**
20
+ * `{count}` in the middle of a sentence: unevaluated source, which the
21
+ * renderer compiles against its `scope` and turns into text. Only emitted
22
+ * when `ParseOptions.expressions` says so, so `{braces}` in an ordinary
23
+ * document remain the characters they have always been.
24
+ */
25
+ export interface ExpressionInline {
26
+ type: 'expression';
27
+ src: string;
28
+ }
16
29
 
17
30
  /** A run of plain text. Softbreaks arrive collapsed to spaces. */
18
31
  export interface TextInline {
@@ -51,14 +64,41 @@ export interface BreakInline {
51
64
  }
52
65
 
53
66
  /**
54
- * Reserved for MDX: `<Chart data={…} />` would parse to one of these. The
55
- * current parser never emits it; the renderer maps unknown names to plain
56
- * text so a future parser upgrade cannot crash an older renderer.
67
+ * An attribute's value. `literal` is what an attribute string or a `{json}`
68
+ * attribute resolves to at parse time; `expression` holds unevaluated source
69
+ * for the rung that compiles it, so that parsing stays pure and one AST can
70
+ * be rendered against two scopes (docs/prd-mdx.md, M2). The parser emits
71
+ * only `literal` today.
72
+ */
73
+ export type AttributeValue =
74
+ { kind: 'literal'; value: unknown } | { kind: 'expression'; src: string };
75
+
76
+ /**
77
+ * A spread (`{...props}`) is an attribute with no name, so it is keyed
78
+ * `...0`, `...1`, … in the order it appeared. An attribute name cannot start
79
+ * with `.`, so these cannot collide with one; a renderer walking
80
+ * `Object.entries` in insertion order applies each spread where it was
81
+ * written, which is what decides whether it overrides `height` or `height`
82
+ * overrides it.
83
+ */
84
+ export const SPREAD_PREFIX = '...';
85
+
86
+ /**
87
+ * **Still reserved.** `<Badge/>` in the middle of a sentence parses to one of
88
+ * these — except that nothing emits them yet, because nothing can render
89
+ * them: a paragraph is laid out as a single `<richtext>`, and a `TextRun`
90
+ * has no way to say "reserve this much advance width for an element someone
91
+ * else paints". Giving it one is the inline half of MDX and is its own piece
92
+ * of work (baseline alignment, wrapping around the element, and what a
93
+ * selection dragged across it should copy).
94
+ *
95
+ * Kept in the union so the shape stays stable, and so a renderer written
96
+ * against it today cannot be broken by the parser learning to emit them.
57
97
  */
58
98
  export interface ComponentInline {
59
99
  type: 'component';
60
100
  name: string;
61
- attributes: Record<string, string>;
101
+ attributes: Record<string, AttributeValue>;
62
102
  children: InlineNode[];
63
103
  }
64
104
 
@@ -70,7 +110,22 @@ export type BlockNode =
70
110
  | QuoteBlock
71
111
  | ListBlock
72
112
  | TableBlock
73
- | RuleBlock;
113
+ | RuleBlock
114
+ | ComponentBlock;
115
+
116
+ /**
117
+ * A component standing where a paragraph would: `<Chart data={…} />` on its
118
+ * own line, or `<Callout>` … `</Callout>` around blocks.
119
+ *
120
+ * Only emitted when `ParseOptions.isComponent` says the name is one — which
121
+ * is what keeps every document that renders today rendering the same way.
122
+ */
123
+ export interface ComponentBlock {
124
+ type: 'component';
125
+ name: string;
126
+ attributes: Record<string, AttributeValue>;
127
+ children: BlockNode[];
128
+ }
74
129
 
75
130
  export interface ParagraphBlock {
76
131
  type: 'paragraph';
@@ -160,4 +215,25 @@ export interface ParseOptions {
160
215
  * underline or a rule) is held back. Pass false for text that is final.
161
216
  */
162
217
  partial?: boolean;
218
+ /**
219
+ * Whether `name` is a component this document may use. Absent — the
220
+ * default — means no tag is a component and `<Chart/>` stays the literal
221
+ * text it has always been, so turning MDX on is opt-in and turning it off
222
+ * is byte-for-byte the old behaviour.
223
+ *
224
+ * `<Markdown>` supplies one from its `components` prop. A dotted name
225
+ * (`Card.Header`) is passed through whole; resolving it is the caller's.
226
+ */
227
+ isComponent?: (name: string) => boolean;
228
+ /**
229
+ * Whether `{…}` may hold a JavaScript expression — in an attribute, as a
230
+ * `{...spread}`, or bare in the prose. Off by default, and off is the
231
+ * whole of rung 1: a `{…}` attribute must be JSON, and a brace in a
232
+ * paragraph is a brace.
233
+ *
234
+ * `<Markdown>` turns this on when it is given a `scope`, which is the prop
235
+ * that says the application accepts a document running code. Parsing still
236
+ * evaluates nothing — an expression is kept as source.
237
+ */
238
+ expressions?: boolean;
163
239
  }
@@ -0,0 +1,223 @@
1
+ // Compiling `{…}` — the rung where a document runs code (docs/prd-mdx.md,
2
+ // M2), and the one place in this component that calls `new Function`.
3
+ //
4
+ // It is a small file on purpose. Everything hard about MDX expressions was
5
+ // decided elsewhere: the parser holds an expression as *source* rather than
6
+ // a value, so a parse is pure and one AST can be rendered against two
7
+ // scopes; and `<Markdown>` only turns any of this on when the application
8
+ // passes a `scope`, which is the prop that means "I accept that this
9
+ // document may compute". What is left here is compiling the source once, and
10
+ // not letting a half-typed expression take down a document that is still
11
+ // being typed.
12
+ //
13
+ // **There is no sandbox.** `new Function` runs in this process with this
14
+ // process's authority. That is why the gate is a prop, and why the docs say
15
+ // in as few words as they can: do not pass `scope` alongside a document you
16
+ // did not write.
17
+
18
+ import type { BlockNode, Document, InlineNode } from './ast.js';
19
+
20
+ /** A compiled expression, or null if it would not compile at all. */
21
+ type Compiled = ((...args: unknown[]) => unknown) | null;
22
+
23
+ /**
24
+ * Keyed on the scope's shape *and* the source, because a compiled function
25
+ * closes over its parameter names. Bounded the way `richtext/node.ts` bounds
26
+ * its layout cache: a streaming document recompiles its tail on every chunk,
27
+ * and an unbounded map would hold every intermediate spelling of an
28
+ * expression that was being typed.
29
+ */
30
+ const CACHE = new Map<string, Compiled>();
31
+ const LIMIT = 256;
32
+
33
+ let warned = false;
34
+
35
+ /**
36
+ * Say so, once, when an expression does not work out. A document that
37
+ * silently loses a value should not be a mystery — and saying it on every
38
+ * frame of a streaming render would be worse than saying nothing.
39
+ *
40
+ * `process` and `console` come off `globalThis` because `src/` compiles with
41
+ * `types: []` — a Node global that wandered in would fail the build rather
42
+ * than become an implicit `@types/node` dependency.
43
+ */
44
+ function warnFailed(src: string, error: unknown): void {
45
+ if (warned) return;
46
+ warned = true;
47
+ const g = globalThis as {
48
+ process?: { env?: Record<string, string | undefined> };
49
+ console?: { warn(message: string, ...rest: unknown[]): void };
50
+ };
51
+ if (g.process?.env?.NODE_ENV === 'production') return;
52
+ g.console?.warn(
53
+ '@react-x11/components: a markdown expression did not evaluate, so it ' +
54
+ `rendered as nothing: {${src}}`,
55
+ error,
56
+ );
57
+ }
58
+
59
+ function compile(src: string, keys: readonly string[]): Compiled {
60
+ const key = `${keys.join(',')} ${src}`;
61
+ const hit = CACHE.get(key);
62
+ if (hit !== undefined) return hit;
63
+ let compiled: Compiled = null;
64
+ try {
65
+ // `return (…)` rather than a bare body: an expression is what the braces
66
+ // promised, and the parentheses keep an object literal an object literal
67
+ // rather than a block.
68
+ compiled = new Function(...keys, `"use strict"; return (${src});`) as (
69
+ ...args: unknown[]
70
+ ) => unknown;
71
+ } catch (error) {
72
+ // A syntax error is the ordinary state of an expression being typed.
73
+ warnFailed(src, error);
74
+ }
75
+ if (CACHE.size >= LIMIT) CACHE.clear();
76
+ CACHE.set(key, compiled);
77
+ return compiled;
78
+ }
79
+
80
+ /**
81
+ * What a renderer calls: source in, value out, `undefined` if it did not
82
+ * work. Never throws — a document being edited is full of expressions that
83
+ * do not work yet.
84
+ */
85
+ export type Evaluate = (src: string) => unknown;
86
+
87
+ /**
88
+ * An evaluator over `scope`. The keys are read once, so a scope mutated in
89
+ * place after this is called keeps its old shape — the same contract every
90
+ * other seam here has, and the reason the prop doc asks for a stable object.
91
+ */
92
+ export function evaluator(scope: Record<string, unknown>): Evaluate {
93
+ const keys = Object.keys(scope);
94
+ const values = keys.map((k) => scope[k]);
95
+ return (src) => {
96
+ const fn = compile(src, keys);
97
+ if (!fn) return undefined;
98
+ try {
99
+ return fn(...values);
100
+ } catch (error) {
101
+ warnFailed(src, error);
102
+ return undefined;
103
+ }
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Only the tests need to reach in: a compile cache that survived between
109
+ * them would make one test's expression another's, and the warn-once flag
110
+ * would hide the second failure anyone asserted on.
111
+ */
112
+ export function clearExpressionCache(): void {
113
+ CACHE.clear();
114
+ warned = false;
115
+ }
116
+
117
+ // --- resolving a parsed document ------------------------------------------
118
+
119
+ /**
120
+ * A copy of `nodes` with every `expression` replaced by the text it
121
+ * evaluates to. Primitives stringify; anything else — an object, an array, a
122
+ * React element — renders as nothing, because a paragraph is text and there
123
+ * is nowhere in a text run to put an element (see "The inline half" in
124
+ * docs/prd-mdx.md). `null` and `undefined` are nothing on purpose, so
125
+ * `{maybe}` reads as absent rather than as the word "undefined".
126
+ */
127
+ function resolveInline(nodes: InlineNode[], evaluate: Evaluate): InlineNode[] {
128
+ let changed = false;
129
+ const out: InlineNode[] = [];
130
+ for (const node of nodes) {
131
+ if (node.type === 'expression') {
132
+ changed = true;
133
+ const value = evaluate(node.src);
134
+ const text =
135
+ value == null ||
136
+ typeof value === 'object' ||
137
+ typeof value === 'function'
138
+ ? ''
139
+ : String(value);
140
+ if (text) out.push({ type: 'text', text });
141
+ continue;
142
+ }
143
+ if ('children' in node) {
144
+ const children = resolveInline(node.children, evaluate);
145
+ if (children !== node.children) {
146
+ changed = true;
147
+ out.push({ ...node, children });
148
+ continue;
149
+ }
150
+ }
151
+ out.push(node);
152
+ }
153
+ return changed ? out : nodes;
154
+ }
155
+
156
+ function resolveBlocks(blocks: BlockNode[], evaluate: Evaluate): BlockNode[] {
157
+ let changed = false;
158
+ const out = blocks.map((block): BlockNode => {
159
+ switch (block.type) {
160
+ case 'paragraph':
161
+ case 'heading': {
162
+ const children = resolveInline(block.children, evaluate);
163
+ if (children === block.children) return block;
164
+ changed = true;
165
+ return { ...block, children };
166
+ }
167
+ case 'quote':
168
+ case 'component': {
169
+ const children = resolveBlocks(block.children, evaluate);
170
+ if (children === block.children) return block;
171
+ changed = true;
172
+ return { ...block, children };
173
+ }
174
+ case 'list': {
175
+ let itemsChanged = false;
176
+ const items = block.items.map((item) => {
177
+ const children = resolveBlocks(item.children, evaluate);
178
+ if (children === item.children) return item;
179
+ itemsChanged = true;
180
+ return { ...item, children };
181
+ });
182
+ if (!itemsChanged) return block;
183
+ changed = true;
184
+ return { ...block, items };
185
+ }
186
+ case 'table': {
187
+ let rowsChanged = false;
188
+ const rows = block.rows.map((row) =>
189
+ row.map((cell) => {
190
+ const next = resolveInline(cell, evaluate);
191
+ if (next !== cell) rowsChanged = true;
192
+ return next;
193
+ }),
194
+ );
195
+ const header = block.header.map((cell) => {
196
+ const next = resolveInline(cell, evaluate);
197
+ if (next !== cell) rowsChanged = true;
198
+ return next;
199
+ });
200
+ if (!rowsChanged) return block;
201
+ changed = true;
202
+ return { ...block, header, rows };
203
+ }
204
+ default:
205
+ return block;
206
+ }
207
+ });
208
+ return changed ? out : blocks;
209
+ }
210
+
211
+ /**
212
+ * The document with its expressions evaluated. `raws` is carried over
213
+ * untouched: it is the block cache's key, and what changed is the values an
214
+ * expression produced, not the text that produced them — a change of `scope`
215
+ * invalidates that cache through its own identity instead.
216
+ */
217
+ export function resolveExpressions(
218
+ doc: Document,
219
+ evaluate: Evaluate,
220
+ ): Document {
221
+ const blocks = resolveBlocks(doc.blocks, evaluate);
222
+ return blocks === doc.blocks ? doc : { blocks, raws: doc.raws };
223
+ }
@@ -38,7 +38,7 @@
38
38
  // answering the four accessors in core's docs/extending.md, with nothing to
39
39
  // register.
40
40
  import React from 'react';
41
- import type { ReactElement, ReactNode } from 'react';
41
+ import type { ComponentType, ReactElement, ReactNode } from 'react';
42
42
  import { Icon, useApp, useTheme } from 'react-x11';
43
43
  import type { DrawnNode, MouseEvent as X11MouseEvent } from 'react-x11';
44
44
  import { tint } from 'react-x11/style';
@@ -46,6 +46,7 @@ import type { Style } from 'react-x11/style';
46
46
 
47
47
  import type {
48
48
  BlockNode,
49
+ ComponentBlock,
49
50
  Document,
50
51
  InlineNode,
51
52
  ListBlock,
@@ -67,17 +68,27 @@ import type { CodeBlockLook } from '../codeblock/index.js';
67
68
  import type { Language } from '../code-language/index.js';
68
69
 
69
70
  import { parse } from './parse.js';
71
+ import { evaluator, resolveExpressions } from './expressions.js';
72
+ import type { Evaluate } from './expressions.js';
73
+ import { SPREAD_PREFIX } from './ast.js';
70
74
  import { runsOf, plainTextOf } from './spans.js';
71
75
  import type { InlineStyles } from './spans.js';
72
76
  import { useLinkClicks } from '../richtext/index.js';
73
77
  import { hx } from './hx.js';
74
78
 
75
79
  export type {
80
+ AttributeValue,
76
81
  BlockNode,
82
+ ComponentBlock,
83
+ ComponentInline,
84
+ ExpressionInline,
77
85
  InlineNode,
78
86
  Document as MarkdownDocument,
79
87
  ParseOptions,
80
88
  } from './ast.js';
89
+ export { SPREAD_PREFIX } from './ast.js';
90
+ export { scanTag } from './tags.js';
91
+ export type { ScannedTag, ScanResult } from './tags.js';
81
92
  export { parse, parseInline } from './parse.js';
82
93
 
83
94
  const h = React.createElement;
@@ -156,6 +167,62 @@ export interface MarkdownProps {
156
167
  * stable identity — a new object per render defeats the block cache.
157
168
  */
158
169
  fences?: Record<string, (fence: FenceInfo) => ReactNode>;
170
+ /**
171
+ * Components a document may name — the MDX gate (docs/prd-mdx.md).
172
+ *
173
+ * A tag is a component **iff its name is a key here**: there is no
174
+ * capitalisation rule and no HTML fallback, so `<Chart/>` in a document
175
+ * with no `Chart` key is the literal text it has always been, and a
176
+ * document that never passes this prop parses exactly as it did before
177
+ * the feature existed.
178
+ *
179
+ * ```tsx
180
+ * <Markdown source={doc} components={{ Chart, Callout }} />
181
+ * ```
182
+ *
183
+ * A dotted name resolves flat first (`components['Card.Header']`), then by
184
+ * walking (`components.Card.Header`), so compound components work.
185
+ *
186
+ * **Nothing is evaluated.** An attribute is a string, `true` for a bare
187
+ * name, or the `JSON.parse` of a `{…}`; a brace that is not JSON makes the
188
+ * tag unreadable and it stays text. That is what makes this safe to point
189
+ * at a document you did not write — which, for this component, is the
190
+ * usual case.
191
+ *
192
+ * Block position only: a tag on its own line(s) is a component, and one in
193
+ * the middle of a sentence is still text. See `ComponentInline` in ast.ts.
194
+ *
195
+ * Give the map a stable identity — a new object per render defeats the
196
+ * block cache, the same way a new `fences` map does.
197
+ */
198
+ components?: Record<string, ComponentType<Record<string, unknown>>>;
199
+ /**
200
+ * Bindings that `{…}` in this document may read — and, by passing it, the
201
+ * statement that this document may **run code**.
202
+ *
203
+ * ```tsx
204
+ * <Markdown source={doc} components={{ Chart }} scope={{ quarters }} />
205
+ * ```
206
+ *
207
+ * With it, an attribute `{…}` that is not JSON is compiled instead of
208
+ * making the tag text, `{...spread}` works, and `{count}` in the middle of
209
+ * a paragraph renders the value. Without it none of those exist and
210
+ * nothing is ever compiled.
211
+ *
212
+ * **There is no sandbox.** Expressions run through `new Function`, in this
213
+ * process, with this process's authority. `components` decides what a
214
+ * document may *reach*; this decides whether it may *compute* — so do not
215
+ * pass it alongside a document you did not write, which for this component
216
+ * usually means anything a model produced.
217
+ *
218
+ * An expression that throws, or does not compile, renders as nothing and
219
+ * warns once. A value that is not a primitive renders as nothing in prose:
220
+ * there is nowhere in a line of text to put an element.
221
+ *
222
+ * Stable identity, as with `components` — the keys are read once, and a new
223
+ * object per render re-parses the document.
224
+ */
225
+ scope?: Record<string, unknown>;
159
226
  /** The root `<box>`'s style — width, padding, margins, `overflow`. */
160
227
  style?: Style | Style[];
161
228
  'data-testname'?: string;
@@ -222,6 +289,33 @@ function deriveLook(
222
289
  };
223
290
  }
224
291
 
292
+ // --- components ------------------------------------------------------------
293
+
294
+ /**
295
+ * The component a tag names, or undefined. Flat key first, so a map can spell
296
+ * a dotted name literally, then the walk, so `Card.Header` finds the property
297
+ * hanging off `Card`.
298
+ */
299
+ function resolveComponent(
300
+ map: MarkdownProps['components'],
301
+ name: string,
302
+ ): ComponentType<Record<string, unknown>> | undefined {
303
+ if (!map) return undefined;
304
+ const flat = map[name];
305
+ if (flat) return flat;
306
+ if (!name.includes('.')) return undefined;
307
+ let cur: unknown = map;
308
+ for (const part of name.split('.')) {
309
+ if (cur == null || (typeof cur !== 'object' && typeof cur !== 'function')) {
310
+ return undefined;
311
+ }
312
+ cur = (cur as Record<string, unknown>)[part];
313
+ }
314
+ return cur == null
315
+ ? undefined
316
+ : (cur as ComponentType<Record<string, unknown>>);
317
+ }
318
+
225
319
  // --- rendering -------------------------------------------------------------
226
320
 
227
321
  interface RenderCtx {
@@ -230,6 +324,9 @@ interface RenderCtx {
230
324
  fonts: FontsMeasureLike | null;
231
325
  fences?: MarkdownProps['fences'];
232
326
  resolveLanguage?: MarkdownProps['resolveLanguage'];
327
+ components?: MarkdownProps['components'];
328
+ /** Present exactly when `scope` was given — the rung that compiles. */
329
+ evaluate?: Evaluate;
233
330
  /** True while rendering the live tail block of a streaming document. */
234
331
  live?: boolean;
235
332
  }
@@ -325,7 +422,56 @@ function renderBlock(block: BlockNode, ctx: RenderCtx, key: number): ReactNode {
325
422
 
326
423
  case 'table':
327
424
  return renderTable(block, ctx, key);
425
+
426
+ case 'component':
427
+ return renderComponent(block, ctx, key);
428
+ }
429
+ }
430
+
431
+ /**
432
+ * A component block. The node only exists because the parser was told this
433
+ * name resolves, so the lookup here agrees by construction — the `undefined`
434
+ * branch is for the window between a `components` prop changing and the
435
+ * re-parse that follows it.
436
+ *
437
+ * Children arrive as a laid-out column of blocks, so a component places one
438
+ * child and does not have to know what markdown is.
439
+ */
440
+ function renderComponent(
441
+ block: ComponentBlock,
442
+ ctx: RenderCtx,
443
+ key: number,
444
+ ): ReactNode {
445
+ const Component = resolveComponent(ctx.components, block.name);
446
+ if (!Component) return null;
447
+ const props: Record<string, unknown> = {};
448
+ // Insertion order is the order they were written, which is what decides
449
+ // whether a spread overrides a named attribute or the other way round.
450
+ for (const [name, value] of Object.entries(block.attributes)) {
451
+ if (name.startsWith(SPREAD_PREFIX)) {
452
+ const spread =
453
+ value.kind === 'expression' ? ctx.evaluate?.(value.src) : undefined;
454
+ if (spread && typeof spread === 'object') Object.assign(props, spread);
455
+ continue;
456
+ }
457
+ props[name] =
458
+ value.kind === 'literal' ? value.value : ctx.evaluate?.(value.src);
328
459
  }
460
+ const children =
461
+ block.children.length === 0
462
+ ? undefined
463
+ : hx(
464
+ 'box',
465
+ {
466
+ style: {
467
+ flexDirection: 'column',
468
+ gap: ctx.look.blockGap,
469
+ alignItems: 'stretch',
470
+ },
471
+ },
472
+ renderBlocks(block.children, ctx),
473
+ );
474
+ return h(React.Fragment, { key }, h(Component, props, children));
329
475
  }
330
476
 
331
477
  function renderCode(
@@ -591,11 +737,40 @@ export function Markdown(props: MarkdownProps): ReactElement {
591
737
  ],
592
738
  );
593
739
 
594
- const doc: Document = React.useMemo(
595
- () => parse(source, { partial }),
596
- [source, partial],
740
+ // The gate the parser asks. Memoised on the map's identity so that a
741
+ // stable `components` prop keeps one function, and the parse memo below
742
+ // does not re-read the whole document on every render.
743
+ const componentMap = props.components;
744
+ const isComponent = React.useMemo(
745
+ () =>
746
+ componentMap
747
+ ? (name: string) => resolveComponent(componentMap, name) !== undefined
748
+ : undefined,
749
+ [componentMap],
597
750
  );
598
751
 
752
+ // `scope` is both the gate and the bindings: giving one turns expression
753
+ // parsing on, and is where the compiled functions get their arguments.
754
+ const scope = props.scope;
755
+ const evaluate = React.useMemo(
756
+ () => (scope ? evaluator(scope) : undefined),
757
+ [scope],
758
+ );
759
+
760
+ const doc: Document = React.useMemo(() => {
761
+ const parsed = parse(source, {
762
+ partial,
763
+ ...(isComponent ? { isComponent } : null),
764
+ ...(evaluate ? { expressions: true } : null),
765
+ });
766
+ // Expressions resolve to text here rather than during the render, so
767
+ // every `runsOf` caller downstream keeps seeing an inline tree it
768
+ // already understands. `raws` is carried through, so the block cache
769
+ // still keys on the source text; a change of `scope` invalidates it
770
+ // through the seam epoch instead.
771
+ return evaluate ? resolveExpressions(parsed, evaluate) : parsed;
772
+ }, [source, partial, isComponent, evaluate]);
773
+
599
774
  // Per-block element cache, keyed on the block's raw source (+ whether it
600
775
  // is the live tail). Streaming appends re-render only the block that
601
776
  // changed; everything else is the same ReactElement, so React bails out
@@ -608,19 +783,27 @@ export function Markdown(props: MarkdownProps): ReactElement {
608
783
  const seamsRef = React.useRef<{
609
784
  fences: MarkdownProps['fences'];
610
785
  resolveLanguage: MarkdownProps['resolveLanguage'];
786
+ components: MarkdownProps['components'];
787
+ scope: MarkdownProps['scope'];
611
788
  gen: number;
612
789
  }>({
613
790
  fences: props.fences,
614
791
  resolveLanguage: props.resolveLanguage,
792
+ components: props.components,
793
+ scope: props.scope,
615
794
  gen: 0,
616
795
  });
617
796
  if (
618
797
  seamsRef.current.fences !== props.fences ||
619
- seamsRef.current.resolveLanguage !== props.resolveLanguage
798
+ seamsRef.current.resolveLanguage !== props.resolveLanguage ||
799
+ seamsRef.current.components !== props.components ||
800
+ seamsRef.current.scope !== props.scope
620
801
  ) {
621
802
  seamsRef.current = {
622
803
  fences: props.fences,
623
804
  resolveLanguage: props.resolveLanguage,
805
+ components: props.components,
806
+ scope: props.scope,
624
807
  gen: seamsRef.current.gen + 1,
625
808
  };
626
809
  }
@@ -638,6 +821,8 @@ export function Markdown(props: MarkdownProps): ReactElement {
638
821
  fonts,
639
822
  fences: props.fences,
640
823
  resolveLanguage: props.resolveLanguage,
824
+ components: props.components,
825
+ ...(evaluate ? { evaluate } : null),
641
826
  };
642
827
  for (let i = 0; i < doc.blocks.length; i += 1) {
643
828
  const live = partial && i === doc.blocks.length - 1;