@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.
- package/README.md +67 -43
- package/dist/formula/node.d.ts.map +1 -1
- package/dist/formula/node.js +17 -6
- package/dist/formula/node.js.map +1 -1
- package/dist/index.d.ts +1 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -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/dist/richtext/node.d.ts.map +1 -1
- package/dist/richtext/node.js +7 -2
- package/dist/richtext/node.js.map +1 -1
- package/dist/table/index.d.ts +15 -0
- package/dist/table/index.d.ts.map +1 -1
- package/dist/table/index.js +95 -11
- package/dist/table/index.js.map +1 -1
- package/dist/tree/index.d.ts.map +1 -1
- package/dist/tree/index.js +77 -2
- package/dist/tree/index.js.map +1 -1
- package/package.json +4 -8
- package/src/formula/node.ts +22 -34
- package/src/index.ts +4 -18
- 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
- package/src/richtext/node.ts +7 -2
- package/src/table/index.ts +114 -10
- package/src/tree/index.ts +77 -2
- package/dist/desktop-calendar/eds.d.ts +0 -102
- package/dist/desktop-calendar/eds.d.ts.map +0 -1
- package/dist/desktop-calendar/eds.js +0 -356
- package/dist/desktop-calendar/eds.js.map +0 -1
- package/dist/desktop-calendar/ical.d.ts +0 -63
- package/dist/desktop-calendar/ical.d.ts.map +0 -1
- package/dist/desktop-calendar/ical.js +0 -57
- package/dist/desktop-calendar/ical.js.map +0 -1
- package/dist/desktop-calendar/index.d.ts +0 -72
- package/dist/desktop-calendar/index.d.ts.map +0 -1
- package/dist/desktop-calendar/index.js +0 -213
- package/dist/desktop-calendar/index.js.map +0 -1
- package/src/desktop-calendar/eds.ts +0 -484
- package/src/desktop-calendar/ical.ts +0 -119
- package/src/desktop-calendar/index.ts +0 -287
package/src/markdown/ast.ts
CHANGED
|
@@ -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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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,
|
|
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
|
+
}
|
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;
|