@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.
- package/CHANGELOG.md +33 -0
- package/dist/DateRangeInput/DateRangeInput.d.ts +3 -0
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +16 -9
- package/dist/Dialog/DialogHeader.d.ts +1 -1
- package/dist/Dialog/DialogHeader.d.ts.map +1 -1
- package/dist/Dialog/DialogHeader.js +10 -7
- package/dist/FileInput/FileInput.d.ts.map +1 -1
- package/dist/FileInput/FileInput.js +9 -3
- package/dist/Markdown/Markdown.d.ts +10 -2
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +58 -14
- package/dist/Markdown/index.d.ts +1 -1
- package/dist/Markdown/index.d.ts.map +1 -1
- package/dist/Markdown/parser.d.ts +126 -12
- package/dist/Markdown/parser.d.ts.map +1 -1
- package/dist/Markdown/parser.js +369 -34
- package/dist/Markdown/utils.d.ts +1 -1
- package/dist/Markdown/utils.d.ts.map +1 -1
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +5 -2
- package/dist/Spinner/Spinner.d.ts +1 -1
- package/dist/Spinner/Spinner.d.ts.map +1 -1
- package/dist/Spinner/Spinner.js +23 -15
- package/dist/astryx.css +2 -1
- package/locales/en.json +16 -0
- package/locales/pseudo.json +12 -0
- package/package.json +6 -4
- package/scripts/agent-doc-state.mjs +1 -1
- package/src/DateRangeInput/DateRangeInput.doc.mjs +35 -7
- package/src/DateRangeInput/DateRangeInput.spec.md +203 -0
- package/src/DateRangeInput/DateRangeInput.test.tsx +100 -4
- package/src/DateRangeInput/DateRangeInput.tsx +29 -20
- package/src/Dialog/Dialog.doc.mjs +3 -0
- package/src/Dialog/Dialog.spec.md +1 -1
- package/src/Dialog/DialogHeader.doc.mjs +38 -0
- package/src/Dialog/DialogHeader.test.tsx +49 -0
- package/src/Dialog/DialogHeader.tsx +23 -4
- package/src/Dialog/modules/DialogHeader.spec.md +152 -0
- package/src/FileInput/FileInput.doc.mjs +2 -0
- package/src/FileInput/FileInput.spec.md +199 -0
- package/src/FileInput/FileInput.test.tsx +14 -0
- package/src/FileInput/FileInput.tsx +13 -3
- package/src/Markdown/Markdown.doc.mjs +167 -42
- package/src/Markdown/Markdown.public.test.ts +157 -0
- package/src/Markdown/Markdown.spec.md +149 -70
- package/src/Markdown/Markdown.test.tsx +107 -3
- package/src/Markdown/Markdown.tsx +116 -35
- package/src/Markdown/incremental.test.ts +175 -7
- package/src/Markdown/index.ts +6 -0
- package/src/Markdown/parser.perf.test.ts +3 -1
- package/src/Markdown/parser.test.ts +122 -0
- package/src/Markdown/parser.ts +609 -81
- package/src/Markdown/utils.ts +6 -0
- package/src/Slider/Slider.doc.mjs +16 -0
- package/src/Slider/Slider.spec.md +61 -47
- package/src/Slider/Slider.test.tsx +18 -0
- package/src/Slider/Slider.tsx +12 -6
- package/src/Spinner/Spinner.doc.mjs +6 -3
- package/src/Spinner/Spinner.test.tsx +37 -0
- package/src/Spinner/Spinner.tsx +31 -14
- 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:
|
|
6
|
+
authority: current
|
|
7
7
|
archive_reason: null
|
|
8
8
|
superseded_by: null
|
|
9
|
-
approved_by:
|
|
10
|
-
approved_at:
|
|
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:
|
|
25
|
+
architecture:
|
|
26
|
+
[architecture:component-theming-surface, architecture:public-component-api]
|
|
23
27
|
contributing: []
|
|
24
|
-
system_specs:
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
40
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
76
|
-
|
|
|
77
|
-
| FR1
|
|
78
|
-
| FR2
|
|
79
|
-
| FR3
|
|
80
|
-
| FR4
|
|
81
|
-
| FR5
|
|
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
|
|
98
|
-
| ---------------------- |
|
|
99
|
-
| Default block content | Every parsed block uses its corresponding current Markdown target.
|
|
100
|
-
| Custom block renderers | The replaced Heading, Paragraph, Code block, Blockquote, Divider, or Image lacks the corresponding Markdown target.
|
|
101
|
-
| Ordered/unordered list | List carries `markdown-list`.
|
|
102
|
-
| Task list | The outer List part carries `markdown-list`.
|
|
103
|
-
| Safe block image | Default Image carries `markdown-image`, or a custom image renderer replaces it.
|
|
104
|
-
| Unsafe block image URL | Markdown renders its fallback Image part with `markdown-image`; no custom image renderer receives the rejected URL.
|
|
105
|
-
| Inline display | Document carries `markdown`; no block target renders.
|
|
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
|
-
-
|
|
110
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
125
|
-
| ---------------- |
|
|
126
|
-
| Document | Contains block or inline rendered Markdown content.
|
|
127
|
-
| Heading | Presents one parsed heading with its resolved level and optional generated ID.
|
|
128
|
-
| Paragraph | Presents one prose block using the default composition-safe paragraph structure.
|
|
129
|
-
| List | Presents ordered, unordered, or task-list items as one block.
|
|
130
|
-
| Code block | Presents fenced code and owns the outer spacing target on the default path.
|
|
131
|
-
| Blockquote | Presents quoted block content on the default path.
|
|
132
|
-
| Table | Presents parsed rows and columns in a keyboard-scrollable block wrapper.
|
|
133
|
-
| Divider | Presents a horizontal separation between blocks.
|
|
134
|
-
| Image | Presents a safe block image or the fallback for a rejected image URL.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
182
|
-
|
|
|
183
|
-
| FR1
|
|
184
|
-
|
|
|
185
|
-
|
|
|
186
|
-
|
|
|
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
|
|
189
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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 {
|
|
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()]}>
|