@react-x11/components 0.5.0 → 0.6.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/README.md +18 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/markdown/ast.d.ts +80 -6
- package/dist/markdown/ast.d.ts.map +1 -1
- package/dist/markdown/ast.js +13 -4
- package/dist/markdown/ast.js.map +1 -1
- package/dist/markdown/expressions.d.ts +27 -0
- package/dist/markdown/expressions.d.ts.map +1 -0
- package/dist/markdown/expressions.js +203 -0
- package/dist/markdown/expressions.js.map +1 -0
- package/dist/markdown/index.d.ts +61 -2
- package/dist/markdown/index.d.ts.map +1 -1
- package/dist/markdown/index.js +101 -2
- package/dist/markdown/index.js.map +1 -1
- package/dist/markdown/parse.d.ts +1 -1
- package/dist/markdown/parse.d.ts.map +1 -1
- package/dist/markdown/parse.js +163 -15
- package/dist/markdown/parse.js.map +1 -1
- package/dist/markdown/spans.d.ts.map +1 -1
- package/dist/markdown/spans.js +4 -0
- package/dist/markdown/spans.js.map +1 -1
- package/dist/markdown/tags.d.ts +35 -0
- package/dist/markdown/tags.d.ts.map +1 -0
- package/dist/markdown/tags.js +213 -0
- package/dist/markdown/tags.js.map +1 -0
- package/package.json +2 -1
- package/src/index.ts +4 -0
- package/src/markdown/ast.ts +85 -9
- package/src/markdown/expressions.ts +223 -0
- package/src/markdown/index.ts +190 -5
- package/src/markdown/parse.ts +230 -15
- package/src/markdown/spans.ts +4 -0
- package/src/markdown/tags.ts +240 -0
|
@@ -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
|
+
}
|
package/src/markdown/index.ts
CHANGED
|
@@ -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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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;
|