@react-x11/components 0.4.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.
Files changed (248) hide show
  1. package/README.md +143 -8
  2. package/dist/charts/components.d.ts +3 -1
  3. package/dist/charts/components.d.ts.map +1 -1
  4. package/dist/charts/components.js +8 -4
  5. package/dist/charts/components.js.map +1 -1
  6. package/dist/charts/node.d.ts +13 -6
  7. package/dist/charts/node.d.ts.map +1 -1
  8. package/dist/charts/node.js +75 -24
  9. package/dist/charts/node.js.map +1 -1
  10. package/dist/charts/render.d.ts +4 -0
  11. package/dist/charts/render.d.ts.map +1 -1
  12. package/dist/charts/render.js +11 -6
  13. package/dist/charts/render.js.map +1 -1
  14. package/dist/code-editor/node.d.ts +47 -16
  15. package/dist/code-editor/node.d.ts.map +1 -1
  16. package/dist/code-editor/node.js +121 -51
  17. package/dist/code-editor/node.js.map +1 -1
  18. package/dist/flow/draw.d.ts +19 -1
  19. package/dist/flow/draw.d.ts.map +1 -1
  20. package/dist/flow/draw.js +64 -35
  21. package/dist/flow/draw.js.map +1 -1
  22. package/dist/flow/index.d.ts.map +1 -1
  23. package/dist/flow/index.js +24 -8
  24. package/dist/flow/index.js.map +1 -1
  25. package/dist/flow/model.d.ts +4 -2
  26. package/dist/flow/model.d.ts.map +1 -1
  27. package/dist/flow/model.js +4 -2
  28. package/dist/flow/model.js.map +1 -1
  29. package/dist/flow/node.d.ts +40 -9
  30. package/dist/flow/node.d.ts.map +1 -1
  31. package/dist/flow/node.js +142 -53
  32. package/dist/flow/node.js.map +1 -1
  33. package/dist/flow/types.d.ts +46 -21
  34. package/dist/flow/types.d.ts.map +1 -1
  35. package/dist/formula/index.d.ts +4 -2
  36. package/dist/formula/index.d.ts.map +1 -1
  37. package/dist/formula/index.js.map +1 -1
  38. package/dist/formula/node.d.ts +5 -1
  39. package/dist/formula/node.d.ts.map +1 -1
  40. package/dist/formula/node.js +19 -3
  41. package/dist/formula/node.js.map +1 -1
  42. package/dist/html/controls.d.ts +4 -1
  43. package/dist/html/controls.d.ts.map +1 -1
  44. package/dist/html/controls.js.map +1 -1
  45. package/dist/html/css/cascade.d.ts +11 -5
  46. package/dist/html/css/cascade.d.ts.map +1 -1
  47. package/dist/html/css/cascade.js +18 -8
  48. package/dist/html/css/cascade.js.map +1 -1
  49. package/dist/html/css/parse.d.ts +15 -10
  50. package/dist/html/css/parse.d.ts.map +1 -1
  51. package/dist/html/css/parse.js +41 -16
  52. package/dist/html/css/parse.js.map +1 -1
  53. package/dist/html/css/style.d.ts +3 -0
  54. package/dist/html/css/style.d.ts.map +1 -1
  55. package/dist/html/css/style.js +3 -1
  56. package/dist/html/css/style.js.map +1 -1
  57. package/dist/html/css/values.d.ts +14 -1
  58. package/dist/html/css/values.d.ts.map +1 -1
  59. package/dist/html/css/values.js +11 -8
  60. package/dist/html/css/values.js.map +1 -1
  61. package/dist/html/index.d.ts.map +1 -1
  62. package/dist/html/index.js +1 -0
  63. package/dist/html/index.js.map +1 -1
  64. package/dist/html/layout/boxes.d.ts +12 -5
  65. package/dist/html/layout/boxes.d.ts.map +1 -1
  66. package/dist/html/layout/boxes.js +7 -4
  67. package/dist/html/layout/boxes.js.map +1 -1
  68. package/dist/html/layout/inline.d.ts.map +1 -1
  69. package/dist/html/layout/inline.js +22 -1
  70. package/dist/html/layout/inline.js.map +1 -1
  71. package/dist/html/node.d.ts +22 -8
  72. package/dist/html/node.d.ts.map +1 -1
  73. package/dist/html/node.js +97 -15
  74. package/dist/html/node.js.map +1 -1
  75. package/dist/html/paint.d.ts +3 -0
  76. package/dist/html/paint.d.ts.map +1 -1
  77. package/dist/html/paint.js +2 -2
  78. package/dist/html/paint.js.map +1 -1
  79. package/dist/index.d.ts +8 -2
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +21 -0
  82. package/dist/index.js.map +1 -1
  83. package/dist/maps/clip.d.ts +43 -0
  84. package/dist/maps/clip.d.ts.map +1 -0
  85. package/dist/maps/clip.js +171 -0
  86. package/dist/maps/clip.js.map +1 -0
  87. package/dist/maps/gzip.d.ts +15 -0
  88. package/dist/maps/gzip.d.ts.map +1 -0
  89. package/dist/maps/gzip.js +56 -0
  90. package/dist/maps/gzip.js.map +1 -0
  91. package/dist/maps/index.d.ts +55 -0
  92. package/dist/maps/index.d.ts.map +1 -0
  93. package/dist/maps/index.js +141 -0
  94. package/dist/maps/index.js.map +1 -0
  95. package/dist/maps/labels.d.ts +123 -0
  96. package/dist/maps/labels.d.ts.map +1 -0
  97. package/dist/maps/labels.js +323 -0
  98. package/dist/maps/labels.js.map +1 -0
  99. package/dist/maps/mvt.d.ts +244 -0
  100. package/dist/maps/mvt.d.ts.map +1 -0
  101. package/dist/maps/mvt.js +820 -0
  102. package/dist/maps/mvt.js.map +1 -0
  103. package/dist/maps/node.d.ts +278 -0
  104. package/dist/maps/node.d.ts.map +1 -0
  105. package/dist/maps/node.js +1459 -0
  106. package/dist/maps/node.js.map +1 -0
  107. package/dist/maps/overlay.d.ts +157 -0
  108. package/dist/maps/overlay.d.ts.map +1 -0
  109. package/dist/maps/overlay.js +472 -0
  110. package/dist/maps/overlay.js.map +1 -0
  111. package/dist/maps/paint.d.ts +226 -0
  112. package/dist/maps/paint.d.ts.map +1 -0
  113. package/dist/maps/paint.js +705 -0
  114. package/dist/maps/paint.js.map +1 -0
  115. package/dist/maps/proj.d.ts +281 -0
  116. package/dist/maps/proj.d.ts.map +1 -0
  117. package/dist/maps/proj.js +451 -0
  118. package/dist/maps/proj.js.map +1 -0
  119. package/dist/maps/sources.d.ts +256 -0
  120. package/dist/maps/sources.d.ts.map +1 -0
  121. package/dist/maps/sources.js +210 -0
  122. package/dist/maps/sources.js.map +1 -0
  123. package/dist/maps/style.d.ts +141 -0
  124. package/dist/maps/style.d.ts.map +1 -0
  125. package/dist/maps/style.js +147 -0
  126. package/dist/maps/style.js.map +1 -0
  127. package/dist/maps/styles.d.ts +82 -0
  128. package/dist/maps/styles.d.ts.map +1 -0
  129. package/dist/maps/styles.js +898 -0
  130. package/dist/maps/styles.js.map +1 -0
  131. package/dist/maps/tiles.d.ts +283 -0
  132. package/dist/maps/tiles.d.ts.map +1 -0
  133. package/dist/maps/tiles.js +469 -0
  134. package/dist/maps/tiles.js.map +1 -0
  135. package/dist/maps/types.d.ts +244 -0
  136. package/dist/maps/types.d.ts.map +1 -0
  137. package/dist/maps/types.js +2 -0
  138. package/dist/maps/types.js.map +1 -0
  139. package/dist/markdown/ast.d.ts +80 -6
  140. package/dist/markdown/ast.d.ts.map +1 -1
  141. package/dist/markdown/ast.js +13 -4
  142. package/dist/markdown/ast.js.map +1 -1
  143. package/dist/markdown/expressions.d.ts +27 -0
  144. package/dist/markdown/expressions.d.ts.map +1 -0
  145. package/dist/markdown/expressions.js +203 -0
  146. package/dist/markdown/expressions.js.map +1 -0
  147. package/dist/markdown/index.d.ts +61 -2
  148. package/dist/markdown/index.d.ts.map +1 -1
  149. package/dist/markdown/index.js +101 -2
  150. package/dist/markdown/index.js.map +1 -1
  151. package/dist/markdown/parse.d.ts +1 -1
  152. package/dist/markdown/parse.d.ts.map +1 -1
  153. package/dist/markdown/parse.js +163 -15
  154. package/dist/markdown/parse.js.map +1 -1
  155. package/dist/markdown/spans.d.ts.map +1 -1
  156. package/dist/markdown/spans.js +4 -0
  157. package/dist/markdown/spans.js.map +1 -1
  158. package/dist/markdown/tags.d.ts +35 -0
  159. package/dist/markdown/tags.d.ts.map +1 -0
  160. package/dist/markdown/tags.js +213 -0
  161. package/dist/markdown/tags.js.map +1 -0
  162. package/dist/reorder/hx.d.ts +18 -0
  163. package/dist/reorder/hx.d.ts.map +1 -0
  164. package/dist/reorder/hx.js +10 -0
  165. package/dist/reorder/hx.js.map +1 -0
  166. package/dist/reorder/index.d.ts +314 -0
  167. package/dist/reorder/index.d.ts.map +1 -0
  168. package/dist/reorder/index.js +1539 -0
  169. package/dist/reorder/index.js.map +1 -0
  170. package/dist/reorder/model.d.ts +107 -0
  171. package/dist/reorder/model.d.ts.map +1 -0
  172. package/dist/reorder/model.js +162 -0
  173. package/dist/reorder/model.js.map +1 -0
  174. package/dist/richtext/node.d.ts +15 -5
  175. package/dist/richtext/node.d.ts.map +1 -1
  176. package/dist/richtext/node.js +40 -11
  177. package/dist/richtext/node.js.map +1 -1
  178. package/dist/richtext/runs.d.ts +35 -13
  179. package/dist/richtext/runs.d.ts.map +1 -1
  180. package/dist/richtext/runs.js +83 -27
  181. package/dist/richtext/runs.js.map +1 -1
  182. package/dist/tabs/index.d.ts +17 -0
  183. package/dist/tabs/index.d.ts.map +1 -1
  184. package/dist/tabs/index.js +849 -186
  185. package/dist/tabs/index.js.map +1 -1
  186. package/dist/terminal/vt/fonts.d.ts +45 -15
  187. package/dist/terminal/vt/fonts.d.ts.map +1 -1
  188. package/dist/terminal/vt/fonts.js +89 -19
  189. package/dist/terminal/vt/fonts.js.map +1 -1
  190. package/dist/terminal/vt/node.d.ts +42 -0
  191. package/dist/terminal/vt/node.d.ts.map +1 -1
  192. package/dist/terminal/vt/node.js +95 -11
  193. package/dist/terminal/vt/node.js.map +1 -1
  194. package/dist/terminal/vt/renderer.d.ts.map +1 -1
  195. package/dist/terminal/vt/renderer.js +13 -3
  196. package/dist/terminal/vt/renderer.js.map +1 -1
  197. package/package.json +14 -3
  198. package/src/charts/components.ts +14 -7
  199. package/src/charts/node.ts +81 -26
  200. package/src/charts/render.ts +16 -6
  201. package/src/code-editor/node.ts +147 -53
  202. package/src/flow/draw.ts +104 -35
  203. package/src/flow/index.ts +33 -13
  204. package/src/flow/model.ts +4 -2
  205. package/src/flow/node.ts +158 -59
  206. package/src/flow/types.ts +46 -21
  207. package/src/formula/index.ts +4 -2
  208. package/src/formula/node.ts +24 -5
  209. package/src/html/controls.ts +4 -1
  210. package/src/html/css/cascade.ts +18 -7
  211. package/src/html/css/parse.ts +46 -21
  212. package/src/html/css/style.ts +6 -1
  213. package/src/html/css/values.ts +25 -9
  214. package/src/html/index.ts +1 -0
  215. package/src/html/layout/boxes.ts +19 -9
  216. package/src/html/layout/inline.ts +23 -1
  217. package/src/html/node.ts +110 -17
  218. package/src/html/paint.ts +17 -2
  219. package/src/index.ts +102 -0
  220. package/src/maps/clip.ts +184 -0
  221. package/src/maps/gzip.ts +66 -0
  222. package/src/maps/index.ts +316 -0
  223. package/src/maps/labels.ts +442 -0
  224. package/src/maps/mvt.ts +857 -0
  225. package/src/maps/node.ts +1819 -0
  226. package/src/maps/overlay.ts +639 -0
  227. package/src/maps/paint.ts +978 -0
  228. package/src/maps/proj.ts +641 -0
  229. package/src/maps/sources.ts +425 -0
  230. package/src/maps/style.ts +309 -0
  231. package/src/maps/styles.ts +1040 -0
  232. package/src/maps/tiles.ts +676 -0
  233. package/src/maps/types.ts +260 -0
  234. package/src/markdown/ast.ts +85 -9
  235. package/src/markdown/expressions.ts +223 -0
  236. package/src/markdown/index.ts +190 -5
  237. package/src/markdown/parse.ts +230 -15
  238. package/src/markdown/spans.ts +4 -0
  239. package/src/markdown/tags.ts +240 -0
  240. package/src/reorder/hx.ts +31 -0
  241. package/src/reorder/index.ts +2223 -0
  242. package/src/reorder/model.ts +232 -0
  243. package/src/richtext/node.ts +49 -14
  244. package/src/richtext/runs.ts +115 -30
  245. package/src/tabs/index.ts +1040 -204
  246. package/src/terminal/vt/fonts.ts +128 -36
  247. package/src/terminal/vt/node.ts +105 -12
  248. package/src/terminal/vt/renderer.ts +13 -3
@@ -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;
@@ -22,10 +22,14 @@
22
22
  // definitions pass over the whole document, and streamed model output
23
23
  // essentially never uses them.
24
24
  // - **Raw HTML is literal text.** There is no HTML pass anywhere in this
25
- // component, by design; `<Component />` syntax is reserved for a future
26
- // MDX extension (see `ComponentInline` in ast.ts).
25
+ // component, by design. `<Component />` is a component only when
26
+ // `options.isComponent` claims the name the MDX gate (docs/prd-mdx.md).
27
+ // Without it, every `<` here means exactly what it always did, which is
28
+ // how turning MDX on cannot change a document that never asked for it.
29
+ // Block position only in M1; see `ComponentInline` in ast.ts.
27
30
  import type {
28
31
  BlockNode,
32
+ ComponentBlock,
29
33
  Document,
30
34
  InlineNode,
31
35
  ListBlock,
@@ -33,6 +37,11 @@ import type {
33
37
  ParseOptions,
34
38
  TableAlign,
35
39
  } from './ast.js';
40
+ import { closeBrace, scanTag } from './tags.js';
41
+ import type { ScannedTag } from './tags.js';
42
+
43
+ /** Nothing is a component unless the caller says so. */
44
+ const NO_COMPONENTS = (): boolean => false;
36
45
 
37
46
  // --- line-level regexes, compiled once -------------------------------------
38
47
 
@@ -54,16 +63,96 @@ const RE_TABLE_DELIM_PREFIX = /^ {0,3}\|[ \t:|-]*$/;
54
63
 
55
64
  /** Would this line open something other than a paragraph? The test lazy
56
65
  * continuation and list/quote termination share. */
57
- function isBlockStart(line: string): boolean {
66
+ function isBlockStart(
67
+ line: string,
68
+ isComponent: (name: string) => boolean = NO_COMPONENTS,
69
+ expressions = false,
70
+ ): boolean {
58
71
  return (
59
72
  RE_ATX.test(line) ||
60
73
  RE_FENCE_OPEN.test(line) ||
61
74
  RE_HR.test(line) ||
62
75
  RE_QUOTE.test(line) ||
63
- RE_LIST.test(line)
76
+ RE_LIST.test(line) ||
77
+ isComponentBlockLine(line, isComponent, expressions)
64
78
  );
65
79
  }
66
80
 
81
+ /** A line that opens a component block, resolved — speculation does not
82
+ * interrupt a paragraph. */
83
+ function isComponentBlockLine(
84
+ line: string,
85
+ isComponent: (name: string) => boolean,
86
+ expressions: boolean,
87
+ ): boolean {
88
+ const found = componentBlockAt([line], 0, isComponent, expressions);
89
+ return found !== null && found !== 'incomplete';
90
+ }
91
+
92
+ /**
93
+ * The component block opening at `lines[at]`, if one does.
94
+ *
95
+ * A component block owns its whole line — `<Chart/>` with prose after it is
96
+ * a paragraph with a tag in it, which is the inline case and not this one —
97
+ * but it may spend several lines doing so, because a component with six
98
+ * props is written down the page and MDX authors expect that to work. Lines
99
+ * are joined until the tag closes; a blank line gives up, since a tag does
100
+ * not span a paragraph break.
101
+ *
102
+ * `'incomplete'` means "still arriving", which only the live tail acts on.
103
+ */
104
+ function componentBlockAt(
105
+ lines: string[],
106
+ at: number,
107
+ isComponent: (name: string) => boolean,
108
+ expressions = false,
109
+ ): { tag: ScannedTag; lastLine: number } | 'incomplete' | null {
110
+ // The identity check is not an optimisation: `scanTag` reports a bare
111
+ // `<Chart` as "still arriving" before it can know whether anyone claims
112
+ // the name, and a document that never opted in must not have its lazy
113
+ // continuation changed by a line that merely looks like a tag.
114
+ if (isComponent === NO_COMPONENTS) return null;
115
+ const indent = indentOf(lines[at]);
116
+ if (indent > 3 || lines[at][indent] !== '<') return null;
117
+
118
+ let text = lines[at];
119
+ for (let j = at; j < lines.length; j += 1) {
120
+ if (j > at) text += `\n${lines[j]}`;
121
+ const tag = scanTag(text, indent, isComponent, expressions);
122
+ if (tag === null) return null;
123
+ if (tag === 'incomplete') {
124
+ if (j + 1 < lines.length && RE_BLANK.test(lines[j + 1])) return null;
125
+ continue;
126
+ }
127
+ if (tag.kind === 'close') return null;
128
+ return text.slice(tag.end).trim() === '' ? { tag, lastLine: j } : null;
129
+ }
130
+ return 'incomplete';
131
+ }
132
+
133
+ /** The line closing `name`, counting same-name nesting, or -1. */
134
+ function componentCloseLine(
135
+ lines: string[],
136
+ from: number,
137
+ name: string,
138
+ isComponent: (name: string) => boolean,
139
+ ): number {
140
+ let depth = 0;
141
+ for (let i = from; i < lines.length; i += 1) {
142
+ const indent = indentOf(lines[i]);
143
+ if (indent > 3 || lines[i][indent] !== '<') continue;
144
+ const tag = scanTag(lines[i], indent, isComponent);
145
+ if (tag === null || tag === 'incomplete' || tag.name !== name) continue;
146
+ if (lines[i].slice(tag.end).trim() !== '') continue;
147
+ if (tag.kind === 'open') depth += 1;
148
+ else if (tag.kind === 'close') {
149
+ if (depth === 0) return i;
150
+ depth -= 1;
151
+ }
152
+ }
153
+ return -1;
154
+ }
155
+
67
156
  /** Leading tabs advance to 4-column stops; content tabs are left alone. */
68
157
  function expandLeadingTabs(line: string): string {
69
158
  let i = 0;
@@ -93,7 +182,14 @@ export function parse(source: string, options: ParseOptions = {}): Document {
93
182
  const lines = normalized.split('\n').map(expandLeadingTabs);
94
183
  const blocks: BlockNode[] = [];
95
184
  const ranges: Array<[number, number]> = [];
96
- parseBlocks(lines, partial, blocks, ranges);
185
+ parseBlocks(
186
+ lines,
187
+ partial,
188
+ blocks,
189
+ ranges,
190
+ options.isComponent ?? NO_COMPONENTS,
191
+ options.expressions === true,
192
+ );
97
193
  return {
98
194
  blocks,
99
195
  raws: ranges.map(([a, b]) => lines.slice(a, b).join('\n')),
@@ -113,6 +209,8 @@ function parseBlocks(
113
209
  tailOpen: boolean,
114
210
  out: BlockNode[],
115
211
  ranges?: Array<[number, number]>,
212
+ isComponent: (name: string) => boolean = NO_COMPONENTS,
213
+ expressions = false,
116
214
  ): void {
117
215
  let i = 0;
118
216
  const n = lines.length;
@@ -132,7 +230,7 @@ function parseBlocks(
132
230
  para.length = 0;
133
231
  paraStart = -1;
134
232
  // the paragraph owns the live tail iff its last line is the document's
135
- const children = parseInline(text, tailOpen && end === n);
233
+ const children = parseInline(text, tailOpen && end === n, expressions);
136
234
  if (children.length > 0)
137
235
  commit({ type: 'paragraph', children }, start, end);
138
236
  };
@@ -166,7 +264,7 @@ function parseBlocks(
166
264
  {
167
265
  type: 'heading',
168
266
  depth: setext[1][0] === '=' ? 1 : 2,
169
- children: parseInline(text, false),
267
+ children: parseInline(text, false, expressions),
170
268
  },
171
269
  start,
172
270
  i + 1,
@@ -203,7 +301,7 @@ function parseBlocks(
203
301
  {
204
302
  type: 'heading',
205
303
  depth: atx[1].length,
206
- children: parseInline(atx[2] ?? '', held),
304
+ children: parseInline(atx[2] ?? '', held, expressions),
207
305
  },
208
306
  i,
209
307
  i + 1,
@@ -248,6 +346,69 @@ function parseBlocks(
248
346
  continue;
249
347
  }
250
348
 
349
+ // A component standing where a paragraph would (docs/prd-mdx.md). Gated
350
+ // on `isComponent`, so a document that never opted in never gets here.
351
+ const component = componentBlockAt(lines, i, isComponent, expressions);
352
+ if (component === 'incomplete') {
353
+ // A tag still arriving: hold it back rather than flash `<Cha` on the
354
+ // screen, the way a half-arrived link is held. Only the live tail may
355
+ // do this — anywhere else the tag is simply never going to close.
356
+ if (tailOpen) {
357
+ flushPara(i);
358
+ i = n;
359
+ continue;
360
+ }
361
+ } else if (component) {
362
+ const { tag, lastLine } = component;
363
+ flushPara(i);
364
+ const start = i;
365
+ const node = (children: BlockNode[]): ComponentBlock => ({
366
+ type: 'component',
367
+ name: tag.name,
368
+ attributes: tag.attributes,
369
+ children,
370
+ });
371
+
372
+ if (tag.kind === 'self') {
373
+ i = lastLine + 1;
374
+ commit(node([]), start, i);
375
+ continue;
376
+ }
377
+
378
+ const closeAt = componentCloseLine(
379
+ lines,
380
+ lastLine + 1,
381
+ tag.name,
382
+ isComponent,
383
+ );
384
+ if (closeAt !== -1) {
385
+ const children: BlockNode[] = [];
386
+ parseBlocks(
387
+ lines.slice(lastLine + 1, closeAt),
388
+ tailOpen && closeAt >= n,
389
+ children,
390
+ undefined,
391
+ isComponent,
392
+ expressions,
393
+ );
394
+ i = closeAt + 1;
395
+ commit(node(children), start, i);
396
+ continue;
397
+ }
398
+
399
+ // Open, with no close yet. While the document is still arriving that
400
+ // is the ordinary state of an element being typed: hold the tag and
401
+ // let the children render as the markdown they are, so the reader sees
402
+ // the prose rather than nothing. Mounting the component now and
403
+ // appending to its children instead would remount it — and lose its
404
+ // state — on the chunk that finally closes it.
405
+ if (tailOpen) {
406
+ i = lastLine + 1;
407
+ continue;
408
+ }
409
+ // Final document, never closed: the tag is text, so fall through.
410
+ }
411
+
251
412
  const quote = RE_QUOTE.exec(line);
252
413
  if (quote) {
253
414
  flushPara(i);
@@ -266,7 +427,7 @@ function parseBlocks(
266
427
  }
267
428
  if (
268
429
  !RE_BLANK.test(l) &&
269
- !isBlockStart(l) &&
430
+ !isBlockStart(l, isComponent, expressions) &&
270
431
  inner.length > 0 &&
271
432
  !RE_BLANK.test(inner[inner.length - 1])
272
433
  ) {
@@ -277,7 +438,14 @@ function parseBlocks(
277
438
  break;
278
439
  }
279
440
  const children: BlockNode[] = [];
280
- parseBlocks(inner, tailOpen && i === n, children);
441
+ parseBlocks(
442
+ inner,
443
+ tailOpen && i === n,
444
+ children,
445
+ undefined,
446
+ isComponent,
447
+ expressions,
448
+ );
281
449
  if (children.length > 0) commit({ type: 'quote', children }, start, i);
282
450
  continue;
283
451
  }
@@ -288,7 +456,7 @@ function parseBlocks(
288
456
  if (list && !(para.length > 0 && !list[4]) && !(held && !list[4])) {
289
457
  flushPara(i);
290
458
  const start = i;
291
- const block = parseList(lines, i, tailOpen);
459
+ const block = parseList(lines, i, tailOpen, isComponent, expressions);
292
460
  i = block.end;
293
461
  commit(block.list, start, i);
294
462
  continue;
@@ -323,7 +491,12 @@ function parseBlocks(
323
491
  const rows: InlineNode[][][] = [];
324
492
  while (i < n) {
325
493
  const l = lines[i];
326
- if (RE_BLANK.test(l) || !l.includes('|') || isBlockStart(l)) break;
494
+ if (
495
+ RE_BLANK.test(l) ||
496
+ !l.includes('|') ||
497
+ isBlockStart(l, isComponent, expressions)
498
+ )
499
+ break;
327
500
  rows.push(
328
501
  normalizeRow(splitRow(l), align.length, tailOpen && i === n - 1),
329
502
  );
@@ -395,6 +568,8 @@ function parseList(
395
568
  lines: string[],
396
569
  from: number,
397
570
  tailOpen: boolean,
571
+ isComponent: (name: string) => boolean = NO_COMPONENTS,
572
+ expressions = false,
398
573
  ): ParsedList {
399
574
  const n = lines.length;
400
575
  const first = RE_LIST.exec(lines[from]);
@@ -452,7 +627,11 @@ function parseList(
452
627
  i += 1;
453
628
  continue;
454
629
  }
455
- if (pendingBlanks === 0 && !isBlockStart(l) && !RE_BLANK.test(l)) {
630
+ if (
631
+ pendingBlanks === 0 &&
632
+ !isBlockStart(l, isComponent, expressions) &&
633
+ !RE_BLANK.test(l)
634
+ ) {
456
635
  inner.push(l); // lazy paragraph continuation
457
636
  i += 1;
458
637
  continue;
@@ -470,7 +649,14 @@ function parseList(
470
649
  }
471
650
 
472
651
  const children: BlockNode[] = [];
473
- parseBlocks(inner, tailOpen && i >= n, children);
652
+ parseBlocks(
653
+ inner,
654
+ tailOpen && i >= n,
655
+ children,
656
+ undefined,
657
+ isComponent,
658
+ expressions,
659
+ );
474
660
  items.push({ checked, children });
475
661
  }
476
662
 
@@ -603,7 +789,11 @@ function classify(ch: string | undefined): 'ws' | 'punct' | 'other' {
603
789
  * Parse inline markdown. `atDocEnd` marks text whose end is the live end of
604
790
  * a streaming document — only then do the implicit-close rules apply.
605
791
  */
606
- export function parseInline(text: string, atDocEnd: boolean): InlineNode[] {
792
+ export function parseInline(
793
+ text: string,
794
+ atDocEnd: boolean,
795
+ expressions = false,
796
+ ): InlineNode[] {
607
797
  const items: Item[] = [];
608
798
  let buf = '';
609
799
 
@@ -700,6 +890,31 @@ export function parseInline(text: string, atDocEnd: boolean): InlineNode[] {
700
890
  continue;
701
891
  }
702
892
 
893
+ // `{expr}` in the prose — rung 2 only, so a brace in an ordinary
894
+ // document is the character it has always been.
895
+ if (expressions && ch === '{') {
896
+ const end = closeBrace(text, i);
897
+ if (end === -1) {
898
+ // Still arriving: hold the rest of the line back rather than show
899
+ // half an expression. In a final document it is just text.
900
+ if (atDocEnd) {
901
+ flushText();
902
+ i = len;
903
+ continue;
904
+ }
905
+ buf += ch;
906
+ i += 1;
907
+ continue;
908
+ }
909
+ flushText();
910
+ items.push({
911
+ kind: 'node',
912
+ node: { type: 'expression', src: text.slice(i + 1, end - 1).trim() },
913
+ });
914
+ i = end;
915
+ continue;
916
+ }
917
+
703
918
  if (ch === '<') {
704
919
  const rest = text.slice(i);
705
920
  const auto = RE_AUTOLINK.exec(rest) ?? RE_AUTOEMAIL.exec(rest);
@@ -135,6 +135,10 @@ export function plainTextOf(nodes: InlineNode[]): string {
135
135
  case 'break':
136
136
  out += '\n';
137
137
  break;
138
+ case 'expression':
139
+ // Resolved to text before rendering (`<Markdown>`'s `scope`), so a
140
+ // node reaching here was never evaluated and has no text to give.
141
+ break;
138
142
  default:
139
143
  out += plainTextOf(node.children);
140
144
  }