@astryxdesign/core 0.6.1 → 0.6.2

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 (62) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/DateRangeInput/DateRangeInput.d.ts +3 -0
  3. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  4. package/dist/DateRangeInput/DateRangeInput.js +16 -9
  5. package/dist/Dialog/DialogHeader.d.ts +1 -1
  6. package/dist/Dialog/DialogHeader.d.ts.map +1 -1
  7. package/dist/Dialog/DialogHeader.js +10 -7
  8. package/dist/FileInput/FileInput.d.ts.map +1 -1
  9. package/dist/FileInput/FileInput.js +9 -3
  10. package/dist/Markdown/Markdown.d.ts +10 -2
  11. package/dist/Markdown/Markdown.d.ts.map +1 -1
  12. package/dist/Markdown/Markdown.js +58 -14
  13. package/dist/Markdown/index.d.ts +1 -1
  14. package/dist/Markdown/index.d.ts.map +1 -1
  15. package/dist/Markdown/parser.d.ts +126 -12
  16. package/dist/Markdown/parser.d.ts.map +1 -1
  17. package/dist/Markdown/parser.js +369 -34
  18. package/dist/Markdown/utils.d.ts +1 -1
  19. package/dist/Markdown/utils.d.ts.map +1 -1
  20. package/dist/Slider/Slider.d.ts.map +1 -1
  21. package/dist/Slider/Slider.js +5 -2
  22. package/dist/Spinner/Spinner.d.ts +1 -1
  23. package/dist/Spinner/Spinner.d.ts.map +1 -1
  24. package/dist/Spinner/Spinner.js +23 -15
  25. package/dist/astryx.css +2 -1
  26. package/locales/en.json +16 -0
  27. package/locales/pseudo.json +12 -0
  28. package/package.json +6 -4
  29. package/scripts/agent-doc-state.mjs +1 -1
  30. package/src/DateRangeInput/DateRangeInput.doc.mjs +35 -7
  31. package/src/DateRangeInput/DateRangeInput.spec.md +203 -0
  32. package/src/DateRangeInput/DateRangeInput.test.tsx +100 -4
  33. package/src/DateRangeInput/DateRangeInput.tsx +29 -20
  34. package/src/Dialog/Dialog.doc.mjs +3 -0
  35. package/src/Dialog/Dialog.spec.md +1 -1
  36. package/src/Dialog/DialogHeader.doc.mjs +38 -0
  37. package/src/Dialog/DialogHeader.test.tsx +49 -0
  38. package/src/Dialog/DialogHeader.tsx +23 -4
  39. package/src/Dialog/modules/DialogHeader.spec.md +152 -0
  40. package/src/FileInput/FileInput.doc.mjs +2 -0
  41. package/src/FileInput/FileInput.spec.md +199 -0
  42. package/src/FileInput/FileInput.test.tsx +14 -0
  43. package/src/FileInput/FileInput.tsx +13 -3
  44. package/src/Markdown/Markdown.doc.mjs +167 -42
  45. package/src/Markdown/Markdown.public.test.ts +157 -0
  46. package/src/Markdown/Markdown.spec.md +149 -70
  47. package/src/Markdown/Markdown.test.tsx +107 -3
  48. package/src/Markdown/Markdown.tsx +116 -35
  49. package/src/Markdown/incremental.test.ts +175 -7
  50. package/src/Markdown/index.ts +6 -0
  51. package/src/Markdown/parser.perf.test.ts +3 -1
  52. package/src/Markdown/parser.test.ts +122 -0
  53. package/src/Markdown/parser.ts +609 -81
  54. package/src/Markdown/utils.ts +6 -0
  55. package/src/Slider/Slider.doc.mjs +16 -0
  56. package/src/Slider/Slider.spec.md +61 -47
  57. package/src/Slider/Slider.test.tsx +18 -0
  58. package/src/Slider/Slider.tsx +12 -6
  59. package/src/Spinner/Spinner.doc.mjs +6 -3
  60. package/src/Spinner/Spinner.test.tsx +37 -0
  61. package/src/Spinner/Spinner.tsx +31 -14
  62. package/src/theme/derivedVarRegistry.test.ts +6 -4
@@ -0,0 +1,157 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Markdown.public.test.ts
5
+ * @input Imports Markdown parser functions and node types from the public barrel
6
+ * @output Compile-time compatibility coverage for legacy and math-enabled results
7
+ * @position Public API test guarding @astryxdesign/core/Markdown
8
+ */
9
+
10
+ import {describe, expectTypeOf, it} from 'vitest';
11
+ import {
12
+ createIncrementalState,
13
+ parseInline,
14
+ parseMarkdown,
15
+ parseMarkdownIncremental,
16
+ } from './index';
17
+ import type {
18
+ BlockNode,
19
+ BlockNodeWithMath,
20
+ InlineNode,
21
+ InlineNodeWithMath,
22
+ ParseOptions,
23
+ } from './index';
24
+
25
+ function assertNever(value: never): never {
26
+ throw new Error(`Unexpected node: ${JSON.stringify(value)}`);
27
+ }
28
+
29
+ // Existing exhaustive consumers must not gain a new case when they do not opt
30
+ // into math parsing. These functions fail to compile if the legacy unions widen.
31
+ function legacyInlineText(node: InlineNode): string {
32
+ switch (node.type) {
33
+ case 'text':
34
+ case 'code':
35
+ return node.content;
36
+ case 'bold':
37
+ case 'italic':
38
+ case 'strikethrough':
39
+ case 'link':
40
+ return node.children.map(legacyInlineText).join('');
41
+ case 'image':
42
+ return node.alt;
43
+ case 'citation':
44
+ return node.sourceId;
45
+ case 'break':
46
+ return '\n';
47
+ default:
48
+ return assertNever(node);
49
+ }
50
+ }
51
+
52
+ function legacyBlockText(node: BlockNode): string {
53
+ switch (node.type) {
54
+ case 'heading':
55
+ case 'paragraph':
56
+ return node.children.map(legacyInlineText).join('');
57
+ case 'codeblock':
58
+ return node.content;
59
+ case 'blockquote':
60
+ return node.children.map(legacyBlockText).join('\n');
61
+ case 'list':
62
+ return node.items
63
+ .flatMap(item => item.children.map(legacyBlockText))
64
+ .join('\n');
65
+ case 'table':
66
+ return [...node.headers, ...node.rows.flat()]
67
+ .flatMap(cell => cell.children.map(legacyInlineText))
68
+ .join(' ');
69
+ case 'image':
70
+ return node.alt;
71
+ case 'hr':
72
+ return '';
73
+ default:
74
+ return assertNever(node);
75
+ }
76
+ }
77
+
78
+ describe('Markdown public parser types', () => {
79
+ it('keeps default and legacy parser calls on the legacy unions', () => {
80
+ expectTypeOf(parseInline('plain')).toEqualTypeOf<InlineNode[]>();
81
+ expectTypeOf(parseInline('plain', new Set<string>())).toEqualTypeOf<
82
+ InlineNode[]
83
+ >();
84
+ expectTypeOf(parseMarkdown('plain')).toEqualTypeOf<BlockNode[]>();
85
+ expectTypeOf(parseMarkdown('plain', {math: false})).toEqualTypeOf<
86
+ BlockNode[]
87
+ >();
88
+ const annotatedOptions: ParseOptions = {autolink: 'gfm'};
89
+ expectTypeOf(parseInline('plain', annotatedOptions)).toEqualTypeOf<
90
+ InlineNode[]
91
+ >();
92
+ expectTypeOf(parseMarkdown('plain', annotatedOptions)).toEqualTypeOf<
93
+ BlockNode[]
94
+ >();
95
+ expectTypeOf(
96
+ parseMarkdownIncremental(
97
+ 'plain',
98
+ createIncrementalState(),
99
+ annotatedOptions,
100
+ ),
101
+ ).toEqualTypeOf<BlockNode[]>();
102
+ expectTypeOf(
103
+ parseMarkdownIncremental('plain', createIncrementalState()),
104
+ ).toEqualTypeOf<BlockNode[]>();
105
+ expectTypeOf(createIncrementalState().settledBlocks).toEqualTypeOf<
106
+ BlockNode[]
107
+ >();
108
+ expectTypeOf(legacyInlineText).returns.toBeString();
109
+ expectTypeOf(legacyBlockText).returns.toBeString();
110
+ });
111
+
112
+ it('rejects ambiguous math options and structurally forged state', () => {
113
+ function compileOnlyGuards() {
114
+ const dynamicOptions: {math: boolean} = {math: true};
115
+ // @ts-expect-error callers must narrow to ParseOptions or MathParseOptions
116
+ parseMarkdown('$$x$$', dynamicOptions);
117
+
118
+ const structuralState = {
119
+ prevInput: '',
120
+ settledText: '',
121
+ settledBlocks: [] as BlockNode[],
122
+ settledUpTo: 0,
123
+ };
124
+ // @ts-expect-error math caches must come from createIncrementalState<true>()
125
+ parseMarkdownIncremental('$$x$$', structuralState, {math: true});
126
+ }
127
+
128
+ expectTypeOf(compileOnlyGuards).toBeFunction();
129
+ });
130
+
131
+ it('types direct and incremental math opt-ins with explicit math unions', () => {
132
+ const inline = parseInline('$x$', {math: true});
133
+ const direct = parseMarkdown('$$x$$', {math: true});
134
+ const incremental = parseMarkdownIncremental(
135
+ '$$x$$',
136
+ createIncrementalState<true>(),
137
+ {math: true},
138
+ );
139
+
140
+ // A state carries the same node contract as the parser result it caches.
141
+ // @ts-expect-error math parsing requires a math-enabled incremental state
142
+ parseMarkdownIncremental('$$x$$', createIncrementalState(), {math: true});
143
+
144
+ expectTypeOf(inline).toEqualTypeOf<InlineNodeWithMath[]>();
145
+ expectTypeOf(direct).toEqualTypeOf<BlockNodeWithMath[]>();
146
+ expectTypeOf(incremental).toEqualTypeOf<BlockNodeWithMath[]>();
147
+ expectTypeOf(createIncrementalState<true>().settledBlocks).toEqualTypeOf<
148
+ BlockNodeWithMath[]
149
+ >();
150
+ expectTypeOf<
151
+ Extract<InlineNodeWithMath, {type: 'math'}>['value']
152
+ >().toBeString();
153
+ expectTypeOf<
154
+ Extract<BlockNodeWithMath, {type: 'math'}>['value']
155
+ >().toBeString();
156
+ });
157
+ });
@@ -3,41 +3,54 @@ schema_version: 3
3
3
  template_version: 3
4
4
  kind: component
5
5
  id: component:Markdown
6
- authority: draft
6
+ authority: current
7
7
  archive_reason: null
8
8
  superseded_by: null
9
- approved_by: null
10
- approved_at: null
9
+ approved_by: cixzhang
10
+ approved_at: 2026-09-13
11
11
  owners: [cixzhang]
12
- review_triggers: [theming]
12
+ review_triggers: [api, theming]
13
13
  verified_by:
14
14
  [
15
15
  packages/core/src/Markdown/Markdown.test.tsx,
16
+ packages/core/src/Markdown/Markdown.public.test.ts,
17
+ packages/core/src/Markdown/parser.test.ts,
18
+ packages/core/src/Markdown/incremental.test.ts,
16
19
  packages/core/src/theme/themingTargets.test.ts,
17
20
  scripts/check-knowledge.mjs,
18
21
  ]
19
22
  modules: []
20
23
  families: [family:navigation-destinations]
21
24
  design_specs: []
22
- architecture: [architecture:component-theming-surface]
25
+ architecture:
26
+ [architecture:component-theming-surface, architecture:public-component-api]
23
27
  contributing: []
24
- system_specs: [spec:AST-005/DEC-1, spec:AST-005/DEC-2]
28
+ system_specs:
29
+ [
30
+ spec:AST-002/DEC-1,
31
+ spec:AST-002/DEC-5,
32
+ spec:AST-005/DEC-1,
33
+ spec:AST-005/DEC-2,
34
+ ]
25
35
  ---
26
36
 
27
37
  # Markdown component contract
28
38
 
29
39
  ## Intent
30
40
 
31
- Markdown renders parsed content in a Document with stable default block parts.
32
- This draft records the nine current Markdown targets and the custom-renderer
33
- boundary without changing parsing, runtime behavior, styling, targets, or public
34
- API.
41
+ Markdown renders parsed content in a Document with stable default block parts and
42
+ constrained renderer seams. In addition to the existing element overrides and
43
+ prose-only inline plugins, a caller may opt a document into dollar-delimited math
44
+ by supplying one typed renderer for both inline and display expressions. The
45
+ parser exposes the same syntax only through an explicit option. Existing parsing,
46
+ rendering, styling, and streaming behavior remain unchanged when math is absent.
35
47
 
36
48
  ## Compatibility and migration
37
49
 
38
50
  - Released default preserved: `yes`
39
- - Compatibility class: additive documentation only; runtime, DOM, styling,
40
- targets, aliases, and public API remain unchanged
51
+ - Compatibility class: additive, opt-in public API; existing parser nodes, DOM,
52
+ styling, targets, and dollar-delimited text remain unchanged unless the caller
53
+ supplies `components.math` or passes `{math: true}` to a parser.
41
54
  - Controlled/uncontrolled behavior: not applicable
42
55
  - Migration decision: none
43
56
 
@@ -52,33 +65,51 @@ Consumer migration instructions belong in consumer docs and release notes.
52
65
  Image block presentation and the eight current block targets documented below.
53
66
  - Applying block spacing and reflected density (plus Heading level) to those
54
67
  targets on the default render path.
68
+ - Opt-in recognition of `$…$` inline math and `$$…$$` display math, including
69
+ delimiter boundaries, escape behavior, parser nodes, and streaming parity.
70
+ - Passing each recognized expression as inert text to the caller's one math
71
+ renderer with an `inline` or `block` display value.
55
72
 
56
73
  **Does not own / non-goals**
57
74
 
58
- - Output supplied by custom `heading`, `paragraph`, `code`, `blockquote`, `hr`,
59
- or `image` renderers; the custom component owns that replacement's structure
60
- and styling.
61
- - Inline emphasis, link, inline-code, citation, or plugin output as additional
62
- block anatomy.
75
+ - Output supplied by custom renderers; each custom component owns its replacement's
76
+ structure, styling, and accessibility semantics.
77
+ - Inline emphasis, link, inline-code, citation, plugin, or math-renderer output as
78
+ additional default block anatomy.
63
79
  - Nested anatomy or targets owned by CodeBlock, Blockquote, List, CheckboxList,
64
80
  or Table.
65
- - New block types, custom-renderer behavior, target names, public API, or runtime
66
- behavior.
81
+ - Executing or sanitizing a renderer's math library output, raw HTML parsing,
82
+ arbitrary AST plugins, or new list/table/inline-style override slots.
67
83
 
68
84
  ## Public concepts
69
85
 
70
- No new public concept is introduced. Consumer props, renderer hooks, defaults,
71
- and usage remain documented in `Markdown.doc.mjs`.
86
+ `MarkdownComponents.math` is one optional renderer with the signature
87
+ `({value: string, display: 'inline' | 'block'}) => ReactNode`. Supplying it opts
88
+ the component into math parsing because the caller owns both whether dollar
89
+ syntax means math and how formulas are rendered. Direct parser callers make the
90
+ same choice with `MathParseOptions` (`{math: true}`); incremental callers pair
91
+ that option with `createIncrementalState<true>()`, which returns the exported
92
+ `IncrementalParseState<true>`. Default calls and values
93
+ annotated as `ParseOptions` keep the released `InlineNode` and `BlockNode`
94
+ unions. Enabled calls return the explicit `InlineNodeWithMath` and
95
+ `BlockNodeWithMath` supersets, whose added leaves are `MathInlineNode` and
96
+ `MathBlockNode`. Exact syntax and examples remain in `Markdown.doc.mjs`.
72
97
 
73
98
  ## Behavioral and layout contract
74
99
 
75
- | ID | Candidate invariant | Basis | Draft review state |
76
- | --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------ |
77
- | FR1 | Block and inline displays render one Document root carrying the current `markdown` target. Inline display renders no block anatomy. | Current source, docs, and tests | Verified current behavior; no new behavior decided |
78
- | FR2 | On the default block render path, Heading, Paragraph, List, Code block, Blockquote, Table, Divider, and Image carry the eight current local block targets documented below. | Current source, docs, tests, and history | Verified current inventory and placement |
79
- | FR3 | A supplied `heading`, `paragraph`, `code`, `blockquote`, `hr`, or safe-URL `image` renderer replaces the corresponding default part, so Markdown does not impose that part's local target on the replacement. | Current source, docs, and history | Verified behavior; focused absence coverage is partial |
80
- | FR4 | The released Code block target is spelled `markdown-codeblock`. Although this runs together a compound name and predates the current naming rule, it is a frozen public target and this factual backfill neither renames it nor adds an alias. | Current source, docs, tests, history, and architecture | Verified compatibility constraint; no target change |
81
- | FR5 | Density and Heading level remain reflected capabilities on their owning targets. Display mode, density, Heading level, streaming state, list kind, and custom-renderer selection do not become separate anatomy entries. | Current source, docs, tests, and architecture | Verified current model; no new anatomy or state target |
100
+ | ID | Invariant |
101
+ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
102
+ | FR1 | Block and inline displays render one Document root carrying the current `markdown` target. Inline display renders no block anatomy. |
103
+ | FR2 | On the default block render path, Heading, Paragraph, List, Code block, Blockquote, Table, Divider, and Image carry the eight current local block targets documented below. |
104
+ | FR3 | A supplied `heading`, `paragraph`, `code`, `blockquote`, `hr`, or safe-URL `image` renderer replaces the corresponding default part, so Markdown does not impose that part's local target on the replacement. |
105
+ | FR4 | The released Code block target remains `markdown-codeblock`; this compatibility anomaly is not renamed or aliased. |
106
+ | FR5 | Density and Heading level remain reflected capabilities on their owning targets. Display mode, streaming state, and renderer selection do not become separate anatomy entries. |
107
+ | FR6 | Without `components.math`, Markdown does not recognize math syntax. Default, legacy-set, `math: false`, and `ParseOptions`-annotated parser calls retain the released `InlineNode` / `BlockNode` result unions; only `MathParseOptions` returns the explicit math-enabled unions. |
108
+ | FR7 | With math enabled, `$…$` produces an inline `math` node and `$$…$$` produces a block `math` node. The renderer receives the delimiter-free source as `value` and its placement as `display`. |
109
+ | FR8 | Inline math stays on one line, cannot have whitespace touching either delimiter, and cannot open immediately after a digit or close immediately before one. `$$` is reserved for display math. These boundaries keep paired currency amounts literal. |
110
+ | FR9 | A backslash-escaped dollar is literal outside math and does not close math inside it. An unmatched inline or display delimiter remains literal in non-streaming output. |
111
+ | FR10 | Code spans and fenced code blocks are opaque to math parsing. Link destinations are opaque; link labels may contain inline math. Inline plugins run only on prose text and never inside math. |
112
+ | FR11 | Streaming converges to the same nodes as a full parse at every chunk boundary, including display math nested in ordinary lists, task lists, blockquotes, and their supported combinations, with LF or CRLF and with or without source ranges. Incomplete math is withheld only while its exact owning container remains open; a list/quote exit or quote-depth change restores literal parsing. Math-enabled incremental calls require `IncrementalParseState<true>`, so the cache and returned union share one contract. |
82
113
 
83
114
  ### Allowed variation
84
115
 
@@ -91,51 +122,75 @@ and usage remain documented in `Markdown.doc.mjs`.
91
122
  - **AV4 — Nested primitives.** Astryx primitives used inside default blocks may
92
123
  change internal element shape while preserving their own public contracts and
93
124
  Markdown's outer block targets.
125
+ - **AV5 — Math renderer.** The caller may use any renderer that accepts the raw
126
+ expression and display value. Its DOM, styles, typesetting engine, error UI,
127
+ and accessibility representation are outside Markdown's ownership.
94
128
 
95
129
  ### Representative states
96
130
 
97
- | State | Required invariant | Allowed variation |
98
- | ---------------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
99
- | Default block content | Every parsed block uses its corresponding current Markdown target. | Block count, order, density, content width, and alignment. |
100
- | Custom block renderers | The replaced Heading, Paragraph, Code block, Blockquote, Divider, or Image lacks the corresponding Markdown target. | Replacement structure and styling. |
101
- | Ordered/unordered list | List carries `markdown-list`. | Marker kind, start value, item count, and nested content. |
102
- | Task list | The outer List part carries `markdown-list`. | Checked values and item content. |
103
- | Safe block image | Default Image carries `markdown-image`, or a custom image renderer replaces it. | Source and alternative text. |
104
- | Unsafe block image URL | Markdown renders its fallback Image part with `markdown-image`; no custom image renderer receives the rejected URL. | Alternative text shown by the fallback. |
105
- | Inline display | Document carries `markdown`; no block target renders. | Inline text, links, code, citations, and plugin output. |
131
+ | State | Required invariant | Allowed variation |
132
+ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
133
+ | Default block content | Every parsed block uses its corresponding current Markdown target. | Block count, order, density, content width, and alignment. |
134
+ | Custom block renderers | The replaced Heading, Paragraph, Code block, Blockquote, Divider, or Image lacks the corresponding Markdown target. | Replacement structure and styling. |
135
+ | Ordered/unordered list | List carries `markdown-list`. | Marker kind, start value, item count, and nested content. |
136
+ | Task list | The outer List part carries `markdown-list`. | Checked values and item content. |
137
+ | Safe block image | Default Image carries `markdown-image`, or a custom image renderer replaces it. | Source and alternative text. |
138
+ | Unsafe block image URL | Markdown renders its fallback Image part with `markdown-image`; no custom image renderer receives the rejected URL. | Alternative text shown by the fallback. |
139
+ | Inline display | Document carries `markdown`; no block target renders. | Inline text, links, code, citations, plugins, and opt-in inline math. |
140
+ | Math renderer absent | Dollar-delimited source follows the released Markdown grammar and no `math` node or renderer output exists. | Currency, unmatched delimiters, and ordinary prose. |
141
+ | Math renderer present | Complete supported delimiters are opaque to Markdown formatting and are passed to the renderer as inert text. | Inline or block display and any renderer-owned output. |
142
+ | Streaming math | Incomplete math is withheld; once complete, the streamed nodes equal the full-parse nodes at top level and inside list/blockquote containers. | Delimiters and expression text may arrive in separate chunks; source ranges remain optional. |
106
143
 
107
144
  ### Transformation and precedence order
108
145
 
109
- - No new parsing, sanitization, heading-level, renderer-selection, spacing, or
110
- styling precedence rule is introduced.
146
+ - Fenced and inline code claim their contents before math.
147
+ - Complete display math claims a block before headings, tables, lists, and
148
+ paragraphs. Complete inline math claims its source before citations, links,
149
+ emphasis, autolinks, and inline plugins.
150
+ - A custom renderer receives only the delimiter-free expression string and its
151
+ display value. Markdown never turns it into HTML or executes it.
152
+ - Existing URL sanitization remains in force for links and images; math adds no
153
+ navigation or raw-HTML sink.
111
154
 
112
155
  ### Performance and resources
113
156
 
114
- - No new parsing, streaming, render, or resource requirement is introduced.
157
+ - Math scanning is disabled unless requested.
158
+ - Inline matching is a bounded forward scan of one line. Display matching scans
159
+ only from a candidate `$$` opener to its closer.
160
+ - The incremental parser keeps completed blocks cached, treats an open display
161
+ expression like an open code fence, and tracks exact list and blockquote
162
+ container depth so an indented closer cannot become a new opener and a depth
163
+ transition cannot swallow literal content. The factory-created state carries
164
+ the same legacy or math-enabled node contract as the parser call.
115
165
 
116
166
  ## Accessibility contract
117
167
 
118
- This draft does not change or extend Markdown's existing document semantics,
119
- heading IDs, paragraph role, list semantics, scrollable Table wrapper, image
120
- alternative text, or custom-renderer responsibilities.
168
+ The default document semantics, heading IDs, paragraph role, list semantics,
169
+ scrollable Table wrapper, and image alternative text remain unchanged. Math has
170
+ no Astryx-owned default output: the caller's renderer owns an accessible
171
+ representation appropriate to its typesetting engine (for example MathML or a
172
+ labelled `role="math"` element). Markdown adds no wrapper, ARIA attributes, or
173
+ HTML injection around renderer output.
121
174
 
122
175
  ## Design relationships
123
176
 
124
- | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
125
- | ---------------- | -------------------------------------------------------------------------------- | ------------------------------ | -------------- | ------------------ |
126
- | Document | Contains block or inline rendered Markdown content. | Current source and public docs | Supporting | FR1, FR5 |
127
- | Heading | Presents one parsed heading with its resolved level and optional generated ID. | Current source and public docs | Prominent | FR2, FR3, FR5 |
128
- | Paragraph | Presents one prose block using the default composition-safe paragraph structure. | Current source and public docs | Prominent | FR2, FR3 |
129
- | List | Presents ordered, unordered, or task-list items as one block. | Current source and public docs | Prominent | FR2, FR5 |
130
- | Code block | Presents fenced code and owns the outer spacing target on the default path. | Current source and public docs | Prominent | FR2, FR3, FR4 |
131
- | Blockquote | Presents quoted block content on the default path. | Current source and public docs | Prominent | FR2, FR3 |
132
- | Table | Presents parsed rows and columns in a keyboard-scrollable block wrapper. | Current source and public docs | Prominent | FR2 |
133
- | Divider | Presents a horizontal separation between blocks. | Current source and public docs | Supporting | FR2, FR3 |
134
- | Image | Presents a safe block image or the fallback for a rejected image URL. | Current source and public docs | Prominent | FR2, FR3 |
135
-
136
- Custom renderers replace six default parts rather than becoming nested Markdown
137
- anatomy. Lists and Tables have no corresponding custom block renderer. The
138
- Document remains Markdown-owned in every display mode.
177
+ | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
178
+ | ---------------- | ---------------------------------------------------------------------------------- | ------------------------------ | -------------- | ------------------ |
179
+ | Document | Contains block or inline rendered Markdown content. | Current source and public docs | Supporting | FR1, FR5 |
180
+ | Heading | Presents one parsed heading with its resolved level and optional generated ID. | Current source and public docs | Prominent | FR2, FR3, FR5 |
181
+ | Paragraph | Presents one prose block using the default composition-safe paragraph structure. | Current source and public docs | Prominent | FR2, FR3 |
182
+ | List | Presents ordered, unordered, or task-list items as one block. | Current source and public docs | Prominent | FR2, FR5 |
183
+ | Code block | Presents fenced code and owns the outer spacing target on the default path. | Current source and public docs | Prominent | FR2, FR3, FR4 |
184
+ | Blockquote | Presents quoted block content on the default path. | Current source and public docs | Prominent | FR2, FR3 |
185
+ | Table | Presents parsed rows and columns in a keyboard-scrollable block wrapper. | Current source and public docs | Prominent | FR2 |
186
+ | Divider | Presents a horizontal separation between blocks. | Current source and public docs | Supporting | FR2, FR3 |
187
+ | Image | Presents a safe block image or the fallback for a rejected image URL. | Current source and public docs | Prominent | FR2, FR3 |
188
+ | Math | Delegates an explicitly enabled expression to the caller's renderer as inert text. | Component contract | Supporting | FR6–FR11 |
189
+
190
+ Custom renderers replace the existing default parts rather than becoming nested
191
+ Markdown anatomy. The opt-in math renderer is also not default anatomy and gets no
192
+ Markdown theme target or wrapper. Lists and Tables have no corresponding custom
193
+ block renderer. The Document remains Markdown-owned in every display mode.
139
194
 
140
195
  ### Theming anatomy
141
196
 
@@ -167,6 +222,12 @@ and this change preserves the existing spelling exactly.
167
222
  - `architecture:component-theming-surface` owns anatomy qualification, target
168
223
  mapping, target-capability state, composition boundaries, and compatibility for
169
224
  frozen targets.
225
+ - `architecture:public-component-api` and `spec:AST-002/DEC-1` own API
226
+ admission. The caller knows whether dollar syntax is math and must choose the
227
+ renderer; Markdown cannot derive either from the source without changing the
228
+ meaning of existing documents.
229
+ - `spec:AST-002/DEC-5` requires this accepted component-local contract to be
230
+ current with the implementation.
170
231
  - `family:navigation-destinations` owns the shared accept/block result for parsed
171
232
  links and every Astryx-owned navigation sink. `spec:AST-005/DEC-1` requires
172
233
  Markdown navigation to remain conformant with Core link plumbing.
@@ -178,22 +239,40 @@ and this change preserves the existing spelling exactly.
178
239
 
179
240
  ## Verification map
180
241
 
181
- | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
182
- | ------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | ------------------------ |
183
- | FR1 | `Markdown.test.tsx` root-class, block-root, inline-root, and base-prop suites | Block and inline Document | Removing or moving the root target fails focused root assertions or the global inventory. | `audit:Markdown/anatomy` |
184
- | FR2, FR4, FR5 | `Markdown.test.tsx` block-spacing-target, density, Heading-level, task-list, and render suites | All eight default block types, both densities, Heading level | Removing, renaming, or moving a block target fails focused class or reflected-property assertions. | `audit:Markdown/theming` |
185
- | FR3 | `Markdown.test.tsx` custom Heading and custom Image suites plus source and target-introduction history | Six replaceable default block parts | Imposing a target on a custom replacement violates the owner boundary; only Heading absence is directly pinned today. | `audit:Markdown/theming` |
186
- | Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and all nine current targets | Missing, extra, prefixed, stale, or multiply assigned mappings fail repository validation. | `audit:Markdown/theming` |
242
+ | Contract | Verification | Representative states | Failure signal |
243
+ | ---------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
244
+ | FR1–FR5 | `Markdown.test.tsx`, theme-target tests, and `scripts/check-knowledge.mjs` | Default block/inline output and all current targets | Existing DOM, target, spacing, or renderer behavior changes. |
245
+ | FR6–FR10 | `parser.test.ts` and `Markdown.test.tsx` | Opt-out, inline/display math, escapes, currency, code, links, plugins | A delimiter is claimed without opt-in, TeX is formatted as Markdown, or opaque contexts leak. |
246
+ | FR11 | `incremental.test.ts` and `Markdown.test.tsx` | Every-character top-level/list/task-list/blockquote splits, CRLF, source ranges | Streaming diverges from a full parse, shows partial syntax, mistakes a nested closer for an opener, or crosses a closed container. |
247
+ | Public syntax/types | `Markdown.public.test.ts`, core typecheck, and `Markdown.doc.mjs` | Legacy exhaustive switches, component renderer, full and incremental math opt-ins | A legacy union widens, math-enabled results omit math nodes, or docs drift from declarations. |
248
+ | Security/accessibility | `parser.test.ts`, `Markdown.test.tsx`, and renderer guidance | Inert expression strings and renderer-owned semantics | Astryx executes math as HTML or silently claims renderer-owned accessibility. |
187
249
 
188
- Focused tests pin all nine current target names and default block placement. They
189
- pin target absence only for a custom Heading; custom Paragraph, Code block,
190
- Blockquote, Divider, and Image replacement paths currently rely on source and
191
- history for the same ownership rule.
250
+ Focused tests continue to pin all nine current target names and default block
251
+ placement. Math intentionally adds no target and no default anatomy.
192
252
 
193
253
  ## Decision log
194
254
 
195
- None. This draft records current facts and introduces no component-local design,
196
- API, behavior, or theming decision.
255
+ ### DEC-1 — Math is an opt-in renderer contract
256
+
257
+ **Reference:** `component:Markdown/DEC-1`
258
+ **Decider:** `cixzhang`, `2026-09-13`
259
+
260
+ A caller that supplies `components.math` opts the component into the constrained
261
+ dollar-math grammar and receives every complete expression through one renderer
262
+ with its source value and inline/block placement. Direct parser callers use
263
+ `MathParseOptions`; incremental callers also create
264
+ `IncrementalParseState<true>` via `createIncrementalState<true>()` so the cache
265
+ and result expose the same math-enabled node union.
266
+
267
+ This passes API admission because otherwise identical dollar-delimited source may
268
+ be prose or math, only the document host knows which meaning applies, and Astryx
269
+ cannot choose a typesetting or accessibility implementation for the host. Tying
270
+ the opt-in to the required renderer prevents an enabled-but-unrenderable state.
271
+ The default remains exactly the released Markdown grammar.
272
+
273
+ Rejected: a generic AST/plugin escape hatch, raw HTML rendering, new list/table
274
+ slots without consumer evidence, or a separate boolean on the component that
275
+ could enable math without a renderer.
197
276
 
198
277
  ## Open questions
199
278
 
@@ -1,10 +1,19 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
3
+ import {
4
+ describe,
5
+ it,
6
+ expect,
7
+ expectTypeOf,
8
+ vi,
9
+ beforeEach,
10
+ afterEach,
11
+ } from 'vitest';
4
12
  import {render, screen, fireEvent} from '@testing-library/react';
5
- import type {ReactNode} from 'react';
13
+ import type {ComponentProps, ReactNode} from 'react';
6
14
  import {Markdown} from './Markdown';
7
- import type {MarkdownInlinePlugin} from './Markdown';
15
+ import type {MarkdownComponents, MarkdownInlinePlugin} from './Markdown';
16
+ import type {ParseOptions} from './index';
8
17
  import {stubMatchMedia} from '../__tests__/stubMatchMedia';
9
18
  import {parseOutlineFromMarkdown} from '../Outline/parseOutlineFromMarkdown';
10
19
 
@@ -615,6 +624,53 @@ describe('Markdown', () => {
615
624
  expect(links[0].getAttribute('href')).toBe('https://example.com');
616
625
  expect(links[1].getAttribute('href')).toBe('/page');
617
626
  });
627
+
628
+ it('preserves dollar-delimited text when no math renderer is supplied', () => {
629
+ const {container} = render(
630
+ <Markdown>{'Total $5 and formula $x_1 + *y*$.'}</Markdown>,
631
+ );
632
+ expect(container.textContent).toBe('Total $5 and formula $x_1 + y$.');
633
+ expect(container.querySelector('em')).toHaveTextContent('y');
634
+ expect(container.querySelector('[role="math"]')).toBeNull();
635
+ });
636
+
637
+ it('passes inline and display expressions to the custom math renderer', () => {
638
+ type MathRendererProps = ComponentProps<
639
+ NonNullable<MarkdownComponents['math']>
640
+ >;
641
+ function MathRenderer({value, display}: MathRendererProps) {
642
+ const Tag = display === 'block' ? 'div' : 'span';
643
+ return (
644
+ <Tag
645
+ role="math"
646
+ aria-label={`Formula: ${value}`}
647
+ data-testid={`${display}-math`}>
648
+ {value}
649
+ </Tag>
650
+ );
651
+ }
652
+
653
+ render(
654
+ <Markdown components={{math: MathRenderer}}>
655
+ {'Inline $x_1 + *y*$ here.\n\n$$\n\\sum_i x_i\n$$'}
656
+ </Markdown>,
657
+ );
658
+
659
+ expect(screen.getByTestId('inline-math')).toHaveTextContent('x_1 + *y*');
660
+ expect(screen.getByTestId('block-math')).toHaveTextContent('\\sum_i x_i');
661
+ expect(screen.getAllByRole('math')).toHaveLength(2);
662
+ });
663
+
664
+ it('exports the math renderer and parser option types', () => {
665
+ type MathRendererProps = ComponentProps<
666
+ NonNullable<MarkdownComponents['math']>
667
+ >;
668
+ expectTypeOf<MathRendererProps>().toEqualTypeOf<{
669
+ value: string;
670
+ display: 'inline' | 'block';
671
+ }>();
672
+ expectTypeOf<ParseOptions>().toMatchTypeOf<{math?: boolean}>();
673
+ });
618
674
  });
619
675
 
620
676
  // ---------------------------------------------------------------------------
@@ -667,6 +723,54 @@ describe('inlinePlugins', () => {
667
723
  expect(link!.textContent).toBe('PROJ-123');
668
724
  });
669
725
 
726
+ it('autolinks generic prefixed-number entities without rewriting source', () => {
727
+ const entityPlugin: MarkdownInlinePlugin = {
728
+ pattern: /\b([A-Z][A-Z0-9]+-\d+)\b/g,
729
+ render: (match, key) => (
730
+ <a key={key} href={`/entities/${match[1]}`} data-testid="entity-link">
731
+ {match[0]}
732
+ </a>
733
+ ),
734
+ };
735
+ const {container} = render(
736
+ <Markdown inlinePlugins={[entityPlugin]}>
737
+ {'See DOC-2048, but keep `DOC-9999` literal.'}
738
+ </Markdown>,
739
+ );
740
+ const link = screen.getByTestId('entity-link');
741
+ expect(link).toHaveAttribute('href', '/entities/DOC-2048');
742
+ expect(link).toHaveTextContent('DOC-2048');
743
+ expect(container.querySelector('code')).toHaveTextContent('DOC-9999');
744
+ expect(
745
+ container.querySelectorAll('[data-testid="entity-link"]'),
746
+ ).toHaveLength(1);
747
+ });
748
+
749
+ it('keeps math opaque to entity plugins while transforming surrounding prose', () => {
750
+ const entityPlugin: MarkdownInlinePlugin = {
751
+ pattern: /\b(DOC-\d+)\b/g,
752
+ render: (match, key) => (
753
+ <a key={key} href={`/entities/${match[1]}`} data-testid="entity-link">
754
+ {match[0]}
755
+ </a>
756
+ ),
757
+ };
758
+ const MathRenderer: NonNullable<MarkdownComponents['math']> = ({value}) => (
759
+ <span role="math">{value}</span>
760
+ );
761
+ render(
762
+ <Markdown
763
+ components={{math: MathRenderer}}
764
+ inlinePlugins={[entityPlugin]}>
765
+ {'DOC-1 and $DOC-2 + x$ and `DOC-3`'}
766
+ </Markdown>,
767
+ );
768
+ expect(screen.getAllByTestId('entity-link')).toHaveLength(1);
769
+ expect(screen.getByTestId('entity-link')).toHaveTextContent('DOC-1');
770
+ expect(screen.getByRole('math')).toHaveTextContent('DOC-2 + x');
771
+ expect(screen.getByText('DOC-3').tagName).toBe('CODE');
772
+ });
773
+
670
774
  it('supports multiple plugins', () => {
671
775
  const {container} = render(
672
776
  <Markdown inlinePlugins={[createTicketPlugin(), createXRefPlugin()]}>