@astryxdesign/core 0.6.1 → 0.6.2-canary.0faf070

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 (166) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts +1 -1
  3. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheet.js +3 -1
  5. package/dist/BottomSheet/BottomSheetPanel.d.ts +7 -5
  6. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  7. package/dist/BottomSheet/BottomSheetPanel.js +56 -19
  8. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  9. package/dist/CheckboxInput/CheckboxInput.js +12 -2
  10. package/dist/Collapsible/Collapsible.d.ts.map +1 -1
  11. package/dist/Collapsible/Collapsible.js +6 -1
  12. package/dist/DateRangeInput/DateRangeInput.d.ts +3 -0
  13. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  14. package/dist/DateRangeInput/DateRangeInput.js +16 -9
  15. package/dist/Dialog/DialogHeader.d.ts +1 -1
  16. package/dist/Dialog/DialogHeader.d.ts.map +1 -1
  17. package/dist/Dialog/DialogHeader.js +10 -7
  18. package/dist/FileInput/FileInput.d.ts.map +1 -1
  19. package/dist/FileInput/FileInput.js +9 -3
  20. package/dist/Kbd/Kbd.d.ts +5 -3
  21. package/dist/Kbd/Kbd.d.ts.map +1 -1
  22. package/dist/Kbd/Kbd.js +36 -42
  23. package/dist/Link/Link.d.ts.map +1 -1
  24. package/dist/Link/Link.js +6 -2
  25. package/dist/Markdown/Markdown.d.ts +10 -2
  26. package/dist/Markdown/Markdown.d.ts.map +1 -1
  27. package/dist/Markdown/Markdown.js +58 -14
  28. package/dist/Markdown/index.d.ts +1 -1
  29. package/dist/Markdown/index.d.ts.map +1 -1
  30. package/dist/Markdown/parser.d.ts +126 -12
  31. package/dist/Markdown/parser.d.ts.map +1 -1
  32. package/dist/Markdown/parser.js +369 -34
  33. package/dist/Markdown/utils.d.ts +1 -1
  34. package/dist/Markdown/utils.d.ts.map +1 -1
  35. package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
  36. package/dist/PowerSearch/PowerSearchEditPopover.js +46 -30
  37. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  38. package/dist/RadioList/RadioListItem.js +13 -1
  39. package/dist/SegmentedControl/SegmentedControlItem.d.ts.map +1 -1
  40. package/dist/SegmentedControl/SegmentedControlItem.js +5 -5
  41. package/dist/SideNav/SideNav.d.ts +2 -1
  42. package/dist/SideNav/SideNav.d.ts.map +1 -1
  43. package/dist/SideNav/SideNav.js +9 -3
  44. package/dist/Slider/Slider.d.ts.map +1 -1
  45. package/dist/Slider/Slider.js +19 -10
  46. package/dist/Spinner/Spinner.d.ts +1 -1
  47. package/dist/Spinner/Spinner.d.ts.map +1 -1
  48. package/dist/Spinner/Spinner.js +23 -15
  49. package/dist/Switch/Switch.d.ts.map +1 -1
  50. package/dist/Switch/Switch.js +11 -0
  51. package/dist/TabList/Tab.d.ts +1 -1
  52. package/dist/TabList/Tab.d.ts.map +1 -1
  53. package/dist/TabList/Tab.js +20 -7
  54. package/dist/ToggleButton/ToggleButton.d.ts +2 -1
  55. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  56. package/dist/ToggleButton/ToggleButton.js +7 -1
  57. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  58. package/dist/Typeahead/BaseTypeahead.js +15 -6
  59. package/dist/astryx.css +13 -2
  60. package/dist/hooks/scrollKeyboardDelegation.d.ts +3 -0
  61. package/dist/hooks/scrollKeyboardDelegation.d.ts.map +1 -0
  62. package/dist/hooks/scrollKeyboardDelegation.js +146 -0
  63. package/dist/hooks/useScrollableArea.d.ts +6 -2
  64. package/dist/hooks/useScrollableArea.d.ts.map +1 -1
  65. package/dist/hooks/useScrollableArea.js +17 -5
  66. package/dist/utils/interactionOverlay.stylex.d.ts +8 -0
  67. package/dist/utils/interactionOverlay.stylex.d.ts.map +1 -1
  68. package/dist/utils/interactionOverlay.stylex.js +9 -0
  69. package/locales/en.json +16 -0
  70. package/locales/pseudo.json +12 -0
  71. package/package.json +7 -5
  72. package/scripts/agent-doc-state.mjs +1 -1
  73. package/src/BottomSheet/BottomSheet.doc.mjs +8 -1
  74. package/src/BottomSheet/BottomSheet.spec.md +46 -20
  75. package/src/BottomSheet/BottomSheet.test.tsx +6 -3
  76. package/src/BottomSheet/BottomSheet.tsx +3 -1
  77. package/src/BottomSheet/BottomSheetKeyboard.test.tsx +195 -0
  78. package/src/BottomSheet/BottomSheetPanel.test.tsx +11 -1
  79. package/src/BottomSheet/BottomSheetPanel.tsx +49 -16
  80. package/src/BottomSheet/__tests__/BottomSheetKeyboard.a11y.browser.spec.ts +344 -0
  81. package/src/Button/__tests__/Button.a11y.chromium.spec.ts +17 -1
  82. package/src/Button/__tests__/Button.a11y.known-failures.ts +0 -29
  83. package/src/Button/__tests__/Button.a11y.renders.tsx +9 -2
  84. package/src/Button/__tests__/Button.a11y.states.ts +9 -0
  85. package/src/CheckboxInput/CheckboxInput.doc.mjs +11 -0
  86. package/src/CheckboxInput/CheckboxInput.test.tsx +34 -0
  87. package/src/CheckboxInput/CheckboxInput.tsx +21 -1
  88. package/src/ClickableCard/ClickableCard.test.tsx +102 -5
  89. package/src/Collapsible/Collapsible.doc.mjs +11 -0
  90. package/src/Collapsible/Collapsible.test.tsx +21 -0
  91. package/src/Collapsible/Collapsible.tsx +5 -0
  92. package/src/DateRangeInput/DateRangeInput.doc.mjs +35 -7
  93. package/src/DateRangeInput/DateRangeInput.spec.md +203 -0
  94. package/src/DateRangeInput/DateRangeInput.test.tsx +100 -4
  95. package/src/DateRangeInput/DateRangeInput.tsx +29 -20
  96. package/src/Dialog/Dialog.doc.mjs +3 -0
  97. package/src/Dialog/Dialog.spec.md +1 -1
  98. package/src/Dialog/DialogHeader.doc.mjs +38 -0
  99. package/src/Dialog/DialogHeader.test.tsx +49 -0
  100. package/src/Dialog/DialogHeader.tsx +23 -4
  101. package/src/Dialog/modules/DialogHeader.spec.md +152 -0
  102. package/src/FileInput/FileInput.doc.mjs +2 -0
  103. package/src/FileInput/FileInput.spec.md +199 -0
  104. package/src/FileInput/FileInput.test.tsx +14 -0
  105. package/src/FileInput/FileInput.tsx +13 -3
  106. package/src/Kbd/Kbd.doc.mjs +3 -3
  107. package/src/Kbd/Kbd.test.tsx +57 -1
  108. package/src/Kbd/Kbd.tsx +57 -37
  109. package/src/Link/Link.doc.mjs +11 -0
  110. package/src/Link/Link.test.tsx +24 -0
  111. package/src/Link/Link.tsx +5 -0
  112. package/src/Markdown/Markdown.doc.mjs +167 -42
  113. package/src/Markdown/Markdown.public.test.ts +157 -0
  114. package/src/Markdown/Markdown.spec.md +255 -71
  115. package/src/Markdown/Markdown.test.tsx +107 -3
  116. package/src/Markdown/Markdown.tsx +116 -35
  117. package/src/Markdown/incremental.test.ts +175 -7
  118. package/src/Markdown/index.ts +6 -0
  119. package/src/Markdown/parser.perf.test.ts +3 -1
  120. package/src/Markdown/parser.test.ts +122 -0
  121. package/src/Markdown/parser.ts +609 -81
  122. package/src/Markdown/utils.ts +6 -0
  123. package/src/Outline/Outline.spec.md +1 -1
  124. package/src/Outline/modules/parseOutlineFromMarkdown.spec.md +142 -0
  125. package/src/PowerSearch/PowerSearchEditPopover.test.tsx +150 -1
  126. package/src/PowerSearch/PowerSearchEditPopover.tsx +51 -28
  127. package/src/RadioList/RadioList.doc.mjs +11 -0
  128. package/src/RadioList/RadioList.test.tsx +32 -0
  129. package/src/RadioList/RadioListItem.tsx +25 -1
  130. package/src/ScrollableArea/modules/useScrollableArea.spec.md +50 -22
  131. package/src/SegmentedControl/SegmentedControl.doc.mjs +2 -2
  132. package/src/SegmentedControl/SegmentedControl.test.tsx +31 -0
  133. package/src/SegmentedControl/SegmentedControlItem.tsx +6 -9
  134. package/src/SideNav/SideNav.doc.mjs +1 -1
  135. package/src/SideNav/SideNav.test.tsx +10 -0
  136. package/src/SideNav/SideNav.tsx +14 -2
  137. package/src/Slider/Slider.doc.mjs +27 -0
  138. package/src/Slider/Slider.spec.md +61 -47
  139. package/src/Slider/Slider.test.tsx +146 -0
  140. package/src/Slider/Slider.tsx +37 -13
  141. package/src/Spinner/Spinner.doc.mjs +6 -3
  142. package/src/Spinner/Spinner.test.tsx +37 -0
  143. package/src/Spinner/Spinner.tsx +31 -14
  144. package/src/Switch/Switch.doc.mjs +11 -0
  145. package/src/Switch/Switch.test.tsx +16 -0
  146. package/src/Switch/Switch.tsx +28 -0
  147. package/src/TabList/Tab.tsx +35 -7
  148. package/src/TabList/TabList.doc.mjs +11 -0
  149. package/src/TabList/TabList.test.tsx +66 -0
  150. package/src/TabList/__tests__/Tabs.a11y.known-failures.ts +1 -34
  151. package/src/ToggleButton/ToggleButton.test.tsx +133 -0
  152. package/src/ToggleButton/ToggleButton.tsx +9 -2
  153. package/src/ToggleButton/__tests__/ToggleButton.a11y.chromium.spec.ts +209 -0
  154. package/src/Tokenizer/Tokenizer.spec.md +142 -75
  155. package/src/Typeahead/BaseTypeahead.spec.md +4 -3
  156. package/src/Typeahead/BaseTypeahead.tsx +15 -6
  157. package/src/Typeahead/Typeahead.test.tsx +53 -0
  158. package/src/__tests__/PressedState.a11y.chromium.spec.ts +813 -0
  159. package/src/__tests__/pressState.ts +93 -0
  160. package/src/hooks/scrollKeyboardDelegation.test.ts +155 -0
  161. package/src/hooks/scrollKeyboardDelegation.ts +233 -0
  162. package/src/hooks/useScrollableArea.doc.mjs +15 -3
  163. package/src/hooks/useScrollableArea.test.tsx +59 -1
  164. package/src/hooks/useScrollableArea.ts +34 -10
  165. package/src/theme/derivedVarRegistry.test.ts +6 -4
  166. package/src/utils/interactionOverlay.stylex.ts +20 -0
@@ -3,41 +3,61 @@ 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-15
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,
19
+ packages/core/src/Outline/parseOutlineFromMarkdown.test.ts,
16
20
  packages/core/src/theme/themingTargets.test.ts,
17
21
  scripts/check-knowledge.mjs,
18
22
  ]
19
23
  modules: []
20
24
  families: [family:navigation-destinations]
21
25
  design_specs: []
22
- architecture: [architecture:component-theming-surface]
26
+ architecture:
27
+ [architecture:component-theming-surface, architecture:public-component-api]
23
28
  contributing: []
24
- system_specs: [spec:AST-005/DEC-1, spec:AST-005/DEC-2]
29
+ system_specs:
30
+ [
31
+ spec:AST-002/DEC-1,
32
+ spec:AST-002/DEC-5,
33
+ spec:AST-005/DEC-1,
34
+ spec:AST-005/DEC-2,
35
+ spec:AST-036/DEC-1,
36
+ spec:AST-036/DEC-2,
37
+ spec:AST-036/DEC-3,
38
+ spec:AST-036/DEC-4,
39
+ ]
25
40
  ---
26
41
 
27
42
  # Markdown component contract
28
43
 
29
44
  ## Intent
30
45
 
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.
46
+ Markdown renders parsed content in a Document with stable default block parts and
47
+ constrained renderer seams. Callers may opt into the canonical plugin protocol for
48
+ bounded source syntax, immutable document transformation, and typed extension
49
+ rendering. They may separately opt into dollar-delimited math by supplying one typed
50
+ renderer for both inline and display expressions. The parser accepts matching
51
+ explicit options. Existing parsing, rendering, styling, and streaming behavior
52
+ remain unchanged when plugins and math are absent.
35
53
 
36
54
  ## Compatibility and migration
37
55
 
38
56
  - Released default preserved: `yes`
39
- - Compatibility class: additive documentation only; runtime, DOM, styling,
40
- targets, aliases, and public API remain unchanged
57
+ - Compatibility class: additive, opt-in public API; existing parser nodes, DOM,
58
+ styling, targets, dollar-delimited text, `components`, and `inlinePlugins` remain
59
+ unchanged unless the caller supplies `plugins`, supplies `components.math`, or
60
+ passes the matching explicit parser option.
41
61
  - Controlled/uncontrolled behavior: not applicable
42
62
  - Migration decision: none
43
63
 
@@ -52,33 +72,92 @@ Consumer migration instructions belong in consumer docs and release notes.
52
72
  Image block presentation and the eight current block targets documented below.
53
73
  - Applying block spacing and reflected density (plus Heading level) to those
54
74
  targets on the default render path.
75
+ - Opt-in recognition of `$…$` inline math and `$$…$$` display math, including
76
+ delimiter boundaries, escape behavior, parser nodes, and streaming parity.
77
+ - Passing each recognized expression as inert text to the caller's one math
78
+ renderer with an `inline` or `block` display value.
79
+ - Applying the canonical `plugins` protocol in the fixed syntax → immutable
80
+ transform → render order while preserving built-in lexical shields, Core-owned
81
+ semantics, and local readable fallback.
82
+ - Validating and freezing replacement document roots before later transforms or
83
+ rendering observe them.
84
+ - Sharing plugin-enabled parse configuration, transformed heading projection, and
85
+ collision-safe heading IDs with Markdown-derived Outline utilities.
55
86
 
56
87
  **Does not own / non-goals**
57
88
 
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.
89
+ - Output supplied by custom renderers; each custom component owns its replacement's
90
+ structure, styling, and accessibility semantics.
91
+ - Inline emphasis, link, inline-code, citation, plugin, or math-renderer output as
92
+ additional default block anatomy.
63
93
  - Nested anatomy or targets owned by CodeBlock, Blockquote, List, CheckboxList,
64
94
  or Table.
65
- - New block types, custom-renderer behavior, target names, public API, or runtime
66
- behavior.
95
+ - Executing or sanitizing a renderer's math or plugin output, raw HTML parsing,
96
+ mutable or unrestricted AST plugins, package discovery, or new
97
+ list/table/inline-style override slots.
67
98
 
68
99
  ## Public concepts
69
100
 
70
- No new public concept is introduced. Consumer props, renderer hooks, defaults,
71
- and usage remain documented in `Markdown.doc.mjs`.
101
+ `plugins` is one optional ordered list of opaque entries created by
102
+ `createMarkdownPlugin()`. A plugin declares only the `syntax`, `transform`, and
103
+ `renderers` capabilities it uses. Syntax-bearing entries supply stable parse
104
+ identity. Parsing, transforms, rendering, and Outline use one stable, strictly typed,
105
+ MDAST-aligned canonical tree; `MarkdownAstNodeMap` and `visitMarkdownNodes` provide
106
+ node-kind narrowing. Released parser functions preserve their existing result shape
107
+ through a compatibility projection. Transforms return validated replacement roots
108
+ without entering parse identity. Every extension node introduced
109
+ by syntax or transformation has complete renderer ownership and a deterministic
110
+ text projection. Text matching, semantic fences, and source decoration are helpers
111
+ that compile to transforms rather than separate protocol phases. `spec:AST-036`
112
+ owns the shared protocol and limited Remark compatibility profile; this component
113
+ owns aggregate application and fallback.
114
+
115
+ ### Acceptance and implementation state
116
+
117
+ The plugin clauses below are the accepted target contract for the AST-036 rollout,
118
+ not a claim that the APIs already ship. Until every clause's implementation and
119
+ verification land, the currently released no-plugin, parser, `components`, and
120
+ `inlinePlugins` behavior remains the only available contract. Each implementation PR
121
+ must identify the clauses it completes without weakening the zero-breaking baseline.
122
+
123
+ `MarkdownComponents.math` is one optional renderer with the signature
124
+ `({value: string, display: 'inline' | 'block'}) => ReactNode`. Supplying it opts
125
+ the component into math parsing because the caller owns both whether dollar
126
+ syntax means math and how formulas are rendered. Direct parser callers make the
127
+ same choice with `MathParseOptions` (`{math: true}`); incremental callers pair
128
+ that option with `createIncrementalState<true>()`, which returns the exported
129
+ `IncrementalParseState<true>`. Default calls and values
130
+ annotated as `ParseOptions` keep the released `InlineNode` and `BlockNode`
131
+ unions. Enabled calls return the explicit `InlineNodeWithMath` and
132
+ `BlockNodeWithMath` supersets, whose added leaves are `MathInlineNode` and
133
+ `MathBlockNode`. Exact syntax and examples remain in `Markdown.doc.mjs`.
72
134
 
73
135
  ## Behavioral and layout contract
74
136
 
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 |
137
+ | ID | Invariant |
138
+ | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
139
+ | FR1 | Block and inline displays render one Document root carrying the current `markdown` target. Inline display renders no block anatomy. |
140
+ | 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. |
141
+ | 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. |
142
+ | FR4 | The released Code block target remains `markdown-codeblock`; this compatibility anomaly is not renamed or aliased. |
143
+ | 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. |
144
+ | 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. |
145
+ | 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`. |
146
+ | 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. |
147
+ | 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. |
148
+ | 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. |
149
+ | 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. |
150
+ | FR12 | Omitting `plugins` and passing an empty list are one semantic empty pipeline with identical parser unions, AST, DOM, styling, targets, IDs, and streaming behavior. Core may skip empty preparation and allocation without creating a separate behavior model. `components`, `inlinePlugins`, citations, autolinking, sources, and math retain their released meaning. |
151
+ | FR13 | Plugin-enabled parsing follows built-in lexical shields and ordered syntax claims, then uses one stable, strictly typed, MDAST-aligned canonical tree for transforms, rendering, and Outline. `MarkdownAstNodeMap` and `visitMarkdownNodes` narrow callbacks by node kind. Released parser functions preserve their existing result shape through a compatibility projection. Every returned root is finite, acyclic, representable, validated, and frozen before later plugins or rendering observe it. |
152
+ | FR14 | Plugin failures preserve the last valid document and readable authored source. Core retains heading, navigation, image, list, table, and document semantics and exposes no raw-markup parser channel, registry, package discovery, mutable shared AST, or unrestricted DOM hook. URL-like plugin data remains untrusted; Astryx-owned sinks follow `family:navigation-destinations`. |
153
+ | FR15 | Incremental parse identity contains every parse-affecting Markdown option and only ordered syntax-bearing plugin name, protocol version, and `parseKey`. Transform or renderer changes reuse settled parse output, rerun transformation, and do not remount unaffected extension output. |
154
+ | FR16 | Plugin-enabled Markdown and Markdown-derived Outline use the same parse options, ordered transforms, extension text projection, slugger, and collision allocator so every visible heading, Outline label, heading ID, and target agree. A limited Remark adapter may run only synchronous transform plugins whose input and output round-trip through the documented supported MDAST subset. |
155
+ | FR17 | An extension node declares `content` as `'none'`, `'phrasing'`, `'flow'`, or an explicit `{allow, min?, max?}` allowlist that narrows the category its `display` implies. Markdown parses every container's inner source span under the same grammar and shields, validates children at each transform boundary, renders children through the same built-in renderers and `components` seams, counts nesting toward the built-in depth bound, leaves FR16 heading traversal unchanged, and renders children in place when a container renderer fails. |
156
+ | FR18 | A transform may read and remove another plugin's extension nodes, including a subtree containing them, and may insert or remove headings; it may not create, edit, internally reorder, or duplicate another plugin's nodes, change a source heading's depth, or forge or duplicate heading identity. `dependsOn` is validated at preparation; an unmet or misordered dependency skips only that plugin's transform. Every rejection names the rule and owning plugin. |
157
+ | FR19 | `onPluginDiagnostic` is available on the component and parser options and receives one source-free event — plugin, phase, stable code, severity — per failure, advisory, or silent degradation, in development and production, rate-limited with a suppression code. Admission, duplicate-name, and protocol-version failures behave identically through the component and every parser entrypoint: the call succeeds with the last valid configuration and never throws into the caller. |
158
+ | FR20 | `parseMarkdownAst()` and `parseInlineAst()` return the canonical tree and accept the same options and plugin list as the component; `@astryxdesign/core/Markdown/parser` exposes parsing, canonical AST types, and plugin admission with no client boundary. `createMarkdownPlugin()` infers the extension-node union, so no callsite needs explicit type arguments, and a declaration that yields no usable extension type is a type error rather than a silent `never`. |
159
+ | FR21 | A transform runs again for every streamed update and must be idempotent and convergent; transforms whose effect requires complete input use the final-input signal. Semantically equal plugin lists reuse prepared work whether or not the array reference is stable, and development reports one diagnostic when a recreated list prevents reuse. |
160
+ | FR22 | An extension renderer may opt into the Markdown-owned extension theme target so themes reach plugin output. Opting out leaves output untargeted. The target adds no default styling or anatomy beyond the block spacing and content width Core already applies. |
82
161
 
83
162
  ### Allowed variation
84
163
 
@@ -91,51 +170,92 @@ and usage remain documented in `Markdown.doc.mjs`.
91
170
  - **AV4 — Nested primitives.** Astryx primitives used inside default blocks may
92
171
  change internal element shape while preserving their own public contracts and
93
172
  Markdown's outer block targets.
173
+ - **AV5 — Math renderer.** The caller may use any renderer that accepts the raw
174
+ expression and display value. Its DOM, styles, typesetting engine, error UI,
175
+ and accessibility representation are outside Markdown's ownership.
176
+ - **AV6 — Installed plugins.** A host may supply any ordered set of compatible
177
+ opaque plugin entries. Syntax, immutable transform behavior, renderer-owned
178
+ output, and helper implementation may vary while validation, readable fallback,
179
+ Core semantics, and heading identity stay fixed.
94
180
 
95
181
  ### Representative states
96
182
 
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. |
183
+ | State | Required invariant | Allowed variation |
184
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
185
+ | Default block content | Every parsed block uses its corresponding current Markdown target. | Block count, order, density, content width, and alignment. |
186
+ | Custom block renderers | The replaced Heading, Paragraph, Code block, Blockquote, Divider, or Image lacks the corresponding Markdown target. | Replacement structure and styling. |
187
+ | Ordered/unordered list | List carries `markdown-list`. | Marker kind, start value, item count, and nested content. |
188
+ | Task list | The outer List part carries `markdown-list`. | Checked values and item content. |
189
+ | Safe block image | Default Image carries `markdown-image`, or a custom image renderer replaces it. | Source and alternative text. |
190
+ | 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. |
191
+ | Inline display | Document carries `markdown`; no block target renders. | Inline text, links, code, citations, plugins, and opt-in inline math. |
192
+ | 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. |
193
+ | 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. |
194
+ | 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. |
195
+ | Plugins omitted | Released parser unions, AST, DOM, targets, heading IDs, and performance remain unchanged. | Omitted or empty list; both are one empty transform pipeline. |
196
+ | Plugins enabled | Fixed syntax → immutable transform → render order, validated roots, readable fallback, and matching Markdown/Outline heading identity remain invariant. | Syntax, transforms, renderers, helper execution plans, plugin order, and live post-parse state. |
106
197
 
107
198
  ### Transformation and precedence order
108
199
 
109
- - No new parsing, sanitization, heading-level, renderer-selection, spacing, or
110
- styling precedence rule is introduced.
200
+ - Fenced and inline code claim their contents before math.
201
+ - Complete display math claims a block before headings, tables, lists, and
202
+ paragraphs. Complete inline math claims its source before citations, links,
203
+ emphasis, autolinks, and inline plugins.
204
+ - A custom renderer receives only the delimiter-free expression string and its
205
+ display value. Markdown never turns it into HTML or executes it.
206
+ - Existing URL sanitization remains in force for links and images; math adds no
207
+ navigation or raw-HTML sink.
208
+ - Built-in syntax and protected contexts claim first; extension syntax claims only
209
+ eligible source; ordered transforms then receive deeply readonly document roots;
210
+ Core validates each returned root before rendering.
211
+ - Text matching, semantic fences, and source decorations use transform helpers. Core
212
+ may compile those helpers into indexed internal plans without exposing additional
213
+ public phases.
111
214
 
112
215
  ### Performance and resources
113
216
 
114
- - No new parsing, streaming, render, or resource requirement is introduced.
217
+ - Math scanning is disabled unless requested.
218
+ - Inline matching is a bounded forward scan of one line. Display matching scans
219
+ only from a candidate `$$` opener to its closer.
220
+ - The incremental parser keeps completed blocks cached, treats an open display
221
+ expression like an open code fence, and tracks exact list and blockquote
222
+ container depth so an indented closer cannot become a new opener and a depth
223
+ transition cannot swallow literal content. The factory-created state carries
224
+ the same legacy or math-enabled node contract as the parser call.
225
+ - Stable and semantically equal plugin lists reuse prepared syntax and transform plans. Only syntax enters parse identity; transform and renderer changes reuse parsed output. Zero-work and representative transforms remain within `spec:AST-036` FR21–FR23 budgets, including the plugin-authored and streaming paths.
226
+ - Remark compatibility adapters, the conformance kit, and optional renderers stay outside Core bundles unless explicitly imported.
115
227
 
116
228
  ## Accessibility contract
117
229
 
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.
230
+ The default document semantics, heading IDs, paragraph role, list semantics,
231
+ scrollable Table wrapper, and image alternative text remain unchanged. Math has
232
+ no Astryx-owned default output: the caller's renderer owns an accessible
233
+ representation appropriate to its typesetting engine (for example MathML or a
234
+ labelled `role="math"` element). Markdown adds no wrapper, ARIA attributes, or
235
+ HTML injection around renderer output. Plugin renderers likewise own their
236
+ complete documented semantic pattern, while Core preserves its own document,
237
+ heading, navigation, image, list, and table semantics. Transforms cannot erase
238
+ required accessible meaning or make meaning color-only.
121
239
 
122
240
  ## Design relationships
123
241
 
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.
242
+ | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
243
+ | ---------------- | ---------------------------------------------------------------------------------- | ------------------------------ | -------------- | ------------------ |
244
+ | Document | Contains block or inline rendered Markdown content. | Current source and public docs | Supporting | FR1, FR5 |
245
+ | Heading | Presents one parsed heading with its resolved level and optional generated ID. | Current source and public docs | Prominent | FR2, FR3, FR5 |
246
+ | Paragraph | Presents one prose block using the default composition-safe paragraph structure. | Current source and public docs | Prominent | FR2, FR3 |
247
+ | List | Presents ordered, unordered, or task-list items as one block. | Current source and public docs | Prominent | FR2, FR5 |
248
+ | Code block | Presents fenced code and owns the outer spacing target on the default path. | Current source and public docs | Prominent | FR2, FR3, FR4 |
249
+ | Blockquote | Presents quoted block content on the default path. | Current source and public docs | Prominent | FR2, FR3 |
250
+ | Table | Presents parsed rows and columns in a keyboard-scrollable block wrapper. | Current source and public docs | Prominent | FR2 |
251
+ | Divider | Presents a horizontal separation between blocks. | Current source and public docs | Supporting | FR2, FR3 |
252
+ | Image | Presents a safe block image or the fallback for a rejected image URL. | Current source and public docs | Prominent | FR2, FR3 |
253
+ | Math | Delegates an explicitly enabled expression to the caller's renderer as inert text. | Component contract | Supporting | FR6–FR11 |
254
+
255
+ Custom renderers replace the existing default parts rather than becoming nested
256
+ Markdown anatomy. The opt-in math renderer is also not default anatomy and gets no
257
+ Markdown theme target or wrapper. Lists and Tables have no corresponding custom
258
+ block renderer. The Document remains Markdown-owned in every display mode.
139
259
 
140
260
  ### Theming anatomy
141
261
 
@@ -167,33 +287,97 @@ and this change preserves the existing spelling exactly.
167
287
  - `architecture:component-theming-surface` owns anatomy qualification, target
168
288
  mapping, target-capability state, composition boundaries, and compatibility for
169
289
  frozen targets.
290
+ - `architecture:public-component-api` and `spec:AST-002/DEC-1` own API
291
+ admission. The caller knows whether dollar syntax is math and must choose the
292
+ renderer; Markdown cannot derive either from the source without changing the
293
+ meaning of existing documents.
294
+ - `spec:AST-002/DEC-5` requires this accepted component-local contract to be
295
+ current with the implementation.
170
296
  - `family:navigation-destinations` owns the shared accept/block result for parsed
171
297
  links and every Astryx-owned navigation sink. `spec:AST-005/DEC-1` requires
172
298
  Markdown navigation to remain conformant with Core link plumbing.
173
299
  - `spec:AST-005/DEC-2` keeps embedded-resource policy separate. Markdown may
174
300
  reject a broader set of image/resource URLs without narrowing the shared
175
301
  navigation contract.
302
+ - `spec:AST-036` owns the opaque syntax/transform/renderer protocol, immutable AST
303
+ validation, limited Remark compatibility, performance, and resource boundaries.
304
+ This record owns aggregate Markdown behavior in FR12–FR22;
305
+ `module:Outline/parseOutlineFromMarkdown` owns the corresponding Outline
306
+ projection.
176
307
  - Nested Astryx primitives retain ownership of their own anatomy and targets;
177
308
  Markdown owns the outer block targets listed here.
178
309
 
179
310
  ## Verification map
180
311
 
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` |
187
-
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.
312
+ | Contract | Verification | Representative states | Failure signal |
313
+ | ---------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
314
+ | 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. |
315
+ | 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. |
316
+ | 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. |
317
+ | FR12–FR16 | plugin, transform, adapter, performance, and Outline parser tests | omitted/empty lists, immutable transforms, invalid outputs, live updates, one compatible Remark plugin, duplicate headings | Empty behavior forks, input mutates, invalid structure escapes, transforms reparse, adapter loses content, budgets fail, or heading targets diverge. |
318
+ | FR17–FR18 | container parse/validation, ownership, and dependency tests | leaf/container declarations, nested containers, foreign read/remove/mint/edit, unmet/misordered dependencies | Plugin-built parsed children, invalid content, lost fallback children, foreign mint/edit, or generic ownership codes. |
319
+ | FR19 | diagnostic-channel tests in development and production | every phase, advisory reports, rate suppression, no handler, malformed list, duplicate Core, version skew | A silent production failure, document content in a diagnostic, or one entrypoint throwing where another recovers. |
320
+ | FR20–FR22 | canonical/server imports, inference, streaming, preparation, theming tests | server imports, no explicit type args, chunk boundaries, recreated lists, themed/unthemed extensions | Client references, explicit-type workarounds, oscillation, per-render re-preparation, or unreachable opted-in output. |
321
+ | Public syntax/types | `Markdown.public.test.ts`, core typecheck, and `Markdown.doc.mjs` | Legacy exhaustive switches, math opt-ins, inferred extension-node unions | A released union widens, an enabled union loses nodes, or docs drift from declarations. |
322
+ | 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. |
323
+
324
+ Focused tests continue to pin all nine current target names and default block
325
+ placement. Math intentionally adds no target and no default anatomy.
192
326
 
193
327
  ## Decision log
194
328
 
195
- None. This draft records current facts and introduces no component-local design,
196
- API, behavior, or theming decision.
329
+ ### DEC-1 — Math is an opt-in renderer contract
330
+
331
+ **Reference:** `component:Markdown/DEC-1`
332
+ **Decider:** `cixzhang`, `2026-09-13`
333
+
334
+ A caller that supplies `components.math` opts the component into the constrained
335
+ dollar-math grammar and receives every complete expression through one renderer
336
+ with its source value and inline/block placement. Direct parser callers use
337
+ `MathParseOptions`; incremental callers also create
338
+ `IncrementalParseState<true>` via `createIncrementalState<true>()` so the cache
339
+ and result expose the same math-enabled node union.
340
+
341
+ This passes API admission because otherwise identical dollar-delimited source may
342
+ be prose or math, only the document host knows which meaning applies, and Astryx
343
+ cannot choose a typesetting or accessibility implementation for the host. Tying
344
+ the opt-in to the required renderer prevents an enabled-but-unrenderable state.
345
+ The default remains exactly the released Markdown grammar.
346
+
347
+ Rejected: a generic AST/plugin escape hatch, raw HTML rendering, new list/table
348
+ slots without consumer evidence, or a separate boolean on the component that
349
+ could enable math without a renderer.
350
+
351
+ ### DEC-2 — Immutable transformation is the canonical Markdown extension seam
352
+
353
+ **Reference:** `component:Markdown/DEC-2`
354
+ **Decider:** `cixzhang`, `2026-09-15`
355
+
356
+ Markdown accepts one ordered `plugins` list whose opaque entries are created by
357
+ `createMarkdownPlugin()`. The public protocol exposes only bounded `syntax`,
358
+ immutable `transform`, and typed `renderers`. Core owns deep-readonly input,
359
+ validation and freezing of replacement roots, readable fallback, syntax-only parse
360
+ identity, preparation reuse, shared heading identity, containers, diagnostics, canonical/server parsing, and theming in FR12–FR22. Existing
361
+ `components`, `inlinePlugins`, math, citations, autolinking, and parser calls remain
362
+ compatible.
363
+
364
+ Text matching, semantic fences, and source decoration are transform helpers rather
365
+ than separate protocol phases. A tree-shakeable adapter may run only synchronous
366
+ transform-only Remark plugins over the documented MDAST subset; unsupported behavior
367
+ fails closed rather than being approximated.
368
+
369
+ This projects `spec:AST-036/DEC-1` through `DEC-4` into the component owner. It rejects a registry, package discovery, mutable shared AST, raw markup, a second plugin prop, or an unrestricted Unified runtime.
370
+
371
+ ### DEC-3 — Containers, diagnostics, and canonical APIs are Markdown-owned
372
+
373
+ **Reference:** `component:Markdown/DEC-3`
374
+ **Decider:** `cixzhang`, `2026-09-16`
375
+
376
+ Markdown parses every extension container's inner span itself and validates children against the plugin's declared content shape, so a callout holds real Markdown while heading identity, protected contexts, navigation policy, and Outline scope stay Core-owned. Containers change what a document can express, not which headings have identity: the released top-level traversal shared by heading IDs and Outline is untouched. A failed container renderer shows its children rather than literal source. Ownership rejections name the rule and owner; removal of another plugin's nodes is permitted and only minting, editing, internal reordering, duplication, and identity forgery are not.
377
+
378
+ Markdown also owns the protocol's observability and entry surface: `onPluginDiagnostic` makes every failure visible in production without carrying document content, admission failures degrade instead of throwing at any entrypoint, canonical parse entrypoints and a server-safe parser entry exist beside the released projection, extension types are inferred, and extension output may opt into one theme target without becoming default anatomy.
379
+
380
+ This projects `spec:AST-036/DEC-5` through `DEC-11` into the component owner in FR17–FR22. It rejects leaf-only extensions, plugin-authored parsed children, independent document shells, development-only or free-text diagnostics, parsers reachable only through a client barrel, required hand-written extension aliases, and default anatomy for plugin output.
197
381
 
198
382
  ## Open questions
199
383
 
@@ -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()]}>