@astryxdesign/core 0.6.1 → 0.6.2-canary.023c3e4

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 (305) 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/Chat/ChatComposerInput.d.ts.map +1 -1
  9. package/dist/Chat/ChatComposerInput.js +2 -2
  10. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  11. package/dist/CheckboxInput/CheckboxInput.js +12 -2
  12. package/dist/Collapsible/Collapsible.d.ts.map +1 -1
  13. package/dist/Collapsible/Collapsible.js +6 -1
  14. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  15. package/dist/CommandPalette/CommandPaletteInput.js +1 -1
  16. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  17. package/dist/ComplexSelector/ComplexSelector.js +1 -1
  18. package/dist/DateInput/DateInput.d.ts.map +1 -1
  19. package/dist/DateInput/DateInput.js +4 -4
  20. package/dist/DateInput/NativeDateField.d.ts.map +1 -1
  21. package/dist/DateInput/NativeDateField.js +16 -16
  22. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  23. package/dist/DateInput/TouchDateField.js +2 -2
  24. package/dist/DateRangeInput/DateRangeInput.d.ts +3 -0
  25. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  26. package/dist/DateRangeInput/DateRangeInput.js +20 -13
  27. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  28. package/dist/DateTimeInput/DateTimeInput.js +8 -8
  29. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  30. package/dist/DateTimeInput/TouchDateTimeField.js +4 -4
  31. package/dist/DateTimeInput/nativePickerSegmentStyles.d.ts +9 -3
  32. package/dist/DateTimeInput/nativePickerSegmentStyles.d.ts.map +1 -1
  33. package/dist/DateTimeInput/nativePickerSegmentStyles.js +3 -3
  34. package/dist/Dialog/DialogHeader.d.ts +1 -1
  35. package/dist/Dialog/DialogHeader.d.ts.map +1 -1
  36. package/dist/Dialog/DialogHeader.js +10 -7
  37. package/dist/Field/PanelSearchInput.d.ts.map +1 -1
  38. package/dist/Field/PanelSearchInput.js +1 -1
  39. package/dist/FileInput/FileInput.d.ts.map +1 -1
  40. package/dist/FileInput/FileInput.js +16 -10
  41. package/dist/Kbd/Kbd.d.ts +5 -3
  42. package/dist/Kbd/Kbd.d.ts.map +1 -1
  43. package/dist/Kbd/Kbd.js +36 -42
  44. package/dist/Link/Link.d.ts.map +1 -1
  45. package/dist/Link/Link.js +6 -2
  46. package/dist/Markdown/Markdown.d.ts +18 -4
  47. package/dist/Markdown/Markdown.d.ts.map +1 -1
  48. package/dist/Markdown/Markdown.fr23.bench.d.ts +2 -0
  49. package/dist/Markdown/Markdown.fr23.bench.d.ts.map +1 -0
  50. package/dist/Markdown/Markdown.fr23.bench.js +150 -0
  51. package/dist/Markdown/Markdown.fr23.sampling.d.ts +2 -0
  52. package/dist/Markdown/Markdown.fr23.sampling.d.ts.map +1 -0
  53. package/dist/Markdown/Markdown.fr23.sampling.js +43 -0
  54. package/dist/Markdown/Markdown.js +311 -114
  55. package/dist/Markdown/ast.d.ts +162 -0
  56. package/dist/Markdown/ast.d.ts.map +1 -0
  57. package/dist/Markdown/ast.js +74 -0
  58. package/dist/Markdown/index.d.ts +5 -3
  59. package/dist/Markdown/index.d.ts.map +1 -1
  60. package/dist/Markdown/index.js +3 -2
  61. package/dist/Markdown/parser/index.d.ts +10 -0
  62. package/dist/Markdown/parser/index.d.ts.map +1 -0
  63. package/dist/Markdown/parser/index.js +10 -0
  64. package/dist/Markdown/parser.d.ts +220 -38
  65. package/dist/Markdown/parser.d.ts.map +1 -1
  66. package/dist/Markdown/parser.js +1072 -188
  67. package/dist/Markdown/plugins/frontmatter.d.ts +32 -0
  68. package/dist/Markdown/plugins/frontmatter.d.ts.map +1 -0
  69. package/dist/Markdown/plugins/frontmatter.js +132 -0
  70. package/dist/Markdown/plugins/index.d.ts +16 -0
  71. package/dist/Markdown/plugins/index.d.ts.map +1 -0
  72. package/dist/Markdown/plugins/index.js +13 -0
  73. package/dist/Markdown/plugins/protocol.d.ts +191 -0
  74. package/dist/Markdown/plugins/protocol.d.ts.map +1 -0
  75. package/dist/Markdown/plugins/protocol.js +957 -0
  76. package/dist/Markdown/plugins/semanticFence.d.ts +36 -0
  77. package/dist/Markdown/plugins/semanticFence.d.ts.map +1 -0
  78. package/dist/Markdown/plugins/semanticFence.js +213 -0
  79. package/dist/Markdown/plugins/sourceDecoration.d.ts +76 -0
  80. package/dist/Markdown/plugins/sourceDecoration.d.ts.map +1 -0
  81. package/dist/Markdown/plugins/sourceDecoration.js +393 -0
  82. package/dist/Markdown/plugins/textTransform.d.ts +30 -0
  83. package/dist/Markdown/plugins/textTransform.d.ts.map +1 -0
  84. package/dist/Markdown/plugins/textTransform.js +374 -0
  85. package/dist/Markdown/remark.d.ts +196 -0
  86. package/dist/Markdown/remark.d.ts.map +1 -0
  87. package/dist/Markdown/remark.js +1062 -0
  88. package/dist/Markdown/url.d.ts +4 -0
  89. package/dist/Markdown/url.d.ts.map +1 -0
  90. package/dist/Markdown/url.js +25 -0
  91. package/dist/Markdown/utils.d.ts +4 -2
  92. package/dist/Markdown/utils.d.ts.map +1 -1
  93. package/dist/Markdown/utils.js +2 -1
  94. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  95. package/dist/NumberInput/NumberInput.js +4 -4
  96. package/dist/Outline/index.d.ts +1 -0
  97. package/dist/Outline/index.d.ts.map +1 -1
  98. package/dist/Outline/parseOutlineFromMarkdown.d.ts +7 -1
  99. package/dist/Outline/parseOutlineFromMarkdown.d.ts.map +1 -1
  100. package/dist/Outline/parseOutlineFromMarkdown.js +14 -8
  101. package/dist/Outline/useOutlineFromMarkdown.d.ts +3 -1
  102. package/dist/Outline/useOutlineFromMarkdown.d.ts.map +1 -1
  103. package/dist/Outline/useOutlineFromMarkdown.js +8 -3
  104. package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
  105. package/dist/PowerSearch/PowerSearchEditPopover.js +46 -30
  106. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  107. package/dist/ProgressBar/ProgressBar.js +36 -31
  108. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  109. package/dist/RadioList/RadioListItem.js +13 -1
  110. package/dist/SegmentedControl/SegmentedControlItem.d.ts.map +1 -1
  111. package/dist/SegmentedControl/SegmentedControlItem.js +5 -5
  112. package/dist/Selector/Selector.d.ts.map +1 -1
  113. package/dist/Selector/Selector.js +1 -1
  114. package/dist/SideNav/SideNav.d.ts +2 -1
  115. package/dist/SideNav/SideNav.d.ts.map +1 -1
  116. package/dist/SideNav/SideNav.js +9 -3
  117. package/dist/Slider/Slider.d.ts.map +1 -1
  118. package/dist/Slider/Slider.js +19 -10
  119. package/dist/Spinner/Spinner.d.ts +1 -1
  120. package/dist/Spinner/Spinner.d.ts.map +1 -1
  121. package/dist/Spinner/Spinner.js +45 -21
  122. package/dist/Switch/Switch.d.ts.map +1 -1
  123. package/dist/Switch/Switch.js +11 -0
  124. package/dist/TabList/Tab.d.ts +1 -1
  125. package/dist/TabList/Tab.d.ts.map +1 -1
  126. package/dist/TabList/Tab.js +20 -7
  127. package/dist/TextArea/TextArea.d.ts.map +1 -1
  128. package/dist/TextArea/TextArea.js +1 -1
  129. package/dist/TextInput/TextInput.d.ts.map +1 -1
  130. package/dist/TextInput/TextInput.js +2 -2
  131. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  132. package/dist/TimeInput/TimeInput.js +4 -4
  133. package/dist/ToggleButton/ToggleButton.d.ts +2 -1
  134. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  135. package/dist/ToggleButton/ToggleButton.js +7 -1
  136. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  137. package/dist/Typeahead/BaseTypeahead.js +16 -7
  138. package/dist/astryx.css +19 -8
  139. package/dist/hooks/scrollKeyboardDelegation.d.ts +3 -0
  140. package/dist/hooks/scrollKeyboardDelegation.d.ts.map +1 -0
  141. package/dist/hooks/scrollKeyboardDelegation.js +146 -0
  142. package/dist/hooks/useScrollableArea.d.ts +6 -2
  143. package/dist/hooks/useScrollableArea.d.ts.map +1 -1
  144. package/dist/hooks/useScrollableArea.js +17 -5
  145. package/dist/utils/interactionOverlay.stylex.d.ts +8 -0
  146. package/dist/utils/interactionOverlay.stylex.d.ts.map +1 -1
  147. package/dist/utils/interactionOverlay.stylex.js +9 -0
  148. package/locales/en.json +16 -0
  149. package/locales/pseudo.json +12 -0
  150. package/package.json +22 -5
  151. package/scripts/agent-doc-state.mjs +1 -1
  152. package/src/BottomSheet/BottomSheet.doc.mjs +8 -1
  153. package/src/BottomSheet/BottomSheet.spec.md +46 -20
  154. package/src/BottomSheet/BottomSheet.test.tsx +6 -3
  155. package/src/BottomSheet/BottomSheet.tsx +3 -1
  156. package/src/BottomSheet/BottomSheetKeyboard.test.tsx +195 -0
  157. package/src/BottomSheet/BottomSheetPanel.test.tsx +11 -1
  158. package/src/BottomSheet/BottomSheetPanel.tsx +49 -16
  159. package/src/BottomSheet/__tests__/BottomSheetKeyboard.a11y.browser.spec.ts +344 -0
  160. package/src/Button/__tests__/Button.a11y.chromium.spec.ts +17 -1
  161. package/src/Button/__tests__/Button.a11y.known-failures.ts +0 -29
  162. package/src/Button/__tests__/Button.a11y.renders.tsx +9 -2
  163. package/src/Button/__tests__/Button.a11y.states.ts +9 -0
  164. package/src/Chat/ChatComposerInput.doc.mjs +3 -3
  165. package/src/Chat/ChatComposerInput.tsx +10 -2
  166. package/src/CheckboxInput/CheckboxInput.doc.mjs +11 -0
  167. package/src/CheckboxInput/CheckboxInput.test.tsx +34 -0
  168. package/src/CheckboxInput/CheckboxInput.tsx +21 -1
  169. package/src/ClickableCard/ClickableCard.test.tsx +102 -5
  170. package/src/Collapsible/Collapsible.doc.mjs +11 -0
  171. package/src/Collapsible/Collapsible.test.tsx +21 -0
  172. package/src/Collapsible/Collapsible.tsx +5 -0
  173. package/src/CommandPalette/CommandPaletteInput.tsx +6 -1
  174. package/src/ComplexSelector/ComplexSelector.tsx +6 -1
  175. package/src/DateInput/DateInput.tsx +6 -1
  176. package/src/DateInput/NativeDateField.tsx +24 -8
  177. package/src/DateInput/TouchDateField.tsx +6 -2
  178. package/src/DateRangeInput/DateRangeInput.doc.mjs +35 -7
  179. package/src/DateRangeInput/DateRangeInput.spec.md +203 -0
  180. package/src/DateRangeInput/DateRangeInput.test.tsx +100 -4
  181. package/src/DateRangeInput/DateRangeInput.tsx +35 -21
  182. package/src/DateTimeInput/DateTimeInput.tsx +6 -1
  183. package/src/DateTimeInput/TouchDateTimeField.tsx +6 -1
  184. package/src/DateTimeInput/nativePickerSegmentStyles.ts +24 -8
  185. package/src/Dialog/Dialog.doc.mjs +3 -0
  186. package/src/Dialog/Dialog.spec.md +1 -1
  187. package/src/Dialog/DialogHeader.doc.mjs +38 -0
  188. package/src/Dialog/DialogHeader.test.tsx +49 -0
  189. package/src/Dialog/DialogHeader.tsx +23 -4
  190. package/src/Dialog/modules/DialogHeader.spec.md +152 -0
  191. package/src/Field/PanelSearchInput.tsx +5 -2
  192. package/src/FieldStatus/__tests__/StatusMessage.a11y.states.ts +1 -1
  193. package/src/FileInput/FileInput.doc.mjs +2 -0
  194. package/src/FileInput/FileInput.spec.md +199 -0
  195. package/src/FileInput/FileInput.test.tsx +14 -0
  196. package/src/FileInput/FileInput.tsx +24 -5
  197. package/src/Kbd/Kbd.doc.mjs +3 -3
  198. package/src/Kbd/Kbd.test.tsx +57 -1
  199. package/src/Kbd/Kbd.tsx +57 -37
  200. package/src/Layout/LayoutFooter.doc.mjs +17 -0
  201. package/src/Link/Link.doc.mjs +11 -0
  202. package/src/Link/Link.test.tsx +24 -0
  203. package/src/Link/Link.tsx +5 -0
  204. package/src/Markdown/Markdown.doc.mjs +439 -42
  205. package/src/Markdown/Markdown.fr23.bench.ts +174 -0
  206. package/src/Markdown/Markdown.fr23.perf.test.ts +249 -0
  207. package/src/Markdown/Markdown.fr23.sampling.ts +49 -0
  208. package/src/Markdown/Markdown.helpers.perf.test.ts +489 -0
  209. package/src/Markdown/Markdown.public.test.ts +280 -0
  210. package/src/Markdown/Markdown.spec.md +304 -72
  211. package/src/Markdown/Markdown.test.tsx +118 -3
  212. package/src/Markdown/Markdown.tsx +489 -129
  213. package/src/Markdown/ast.test.ts +307 -0
  214. package/src/Markdown/ast.ts +337 -0
  215. package/src/Markdown/incremental.test.ts +187 -7
  216. package/src/Markdown/index.ts +43 -1
  217. package/src/Markdown/modules/remark.spec.md +269 -0
  218. package/src/Markdown/parser/index.ts +64 -0
  219. package/src/Markdown/parser.perf.test.ts +3 -1
  220. package/src/Markdown/parser.public.test.ts +103 -0
  221. package/src/Markdown/parser.test.ts +122 -0
  222. package/src/Markdown/parser.ts +1721 -248
  223. package/src/Markdown/plugins/frontmatter.test.tsx +125 -0
  224. package/src/Markdown/plugins/frontmatter.ts +198 -0
  225. package/src/Markdown/plugins/index.ts +53 -0
  226. package/src/Markdown/plugins/protocol.test.tsx +1022 -0
  227. package/src/Markdown/plugins/protocol.ts +1618 -0
  228. package/src/Markdown/plugins/semanticFence.test.tsx +546 -0
  229. package/src/Markdown/plugins/semanticFence.ts +424 -0
  230. package/src/Markdown/plugins/sourceDecoration.test.tsx +799 -0
  231. package/src/Markdown/plugins/sourceDecoration.ts +539 -0
  232. package/src/Markdown/plugins/textTransform.test.ts +467 -0
  233. package/src/Markdown/plugins/textTransform.ts +554 -0
  234. package/src/Markdown/remark.test.tsx +1877 -0
  235. package/src/Markdown/remark.ts +1574 -0
  236. package/src/Markdown/url.ts +30 -0
  237. package/src/Markdown/utils.ts +19 -1
  238. package/src/MultiSelector/MultiSelector.test.tsx +1 -35
  239. package/src/MultiSelector/__tests__/MultiSelector.listbox.a11y.test.tsx +128 -0
  240. package/src/NumberInput/NumberInput.tsx +6 -1
  241. package/src/Outline/Outline.doc.mjs +7 -5
  242. package/src/Outline/Outline.spec.md +1 -1
  243. package/src/Outline/index.ts +1 -0
  244. package/src/Outline/modules/parseOutlineFromMarkdown.spec.md +142 -0
  245. package/src/Outline/parseOutlineFromMarkdown.ts +40 -10
  246. package/src/Outline/useOutlineFromMarkdown.ts +15 -3
  247. package/src/PowerSearch/PowerSearch.doc.mjs +2 -2
  248. package/src/PowerSearch/PowerSearchEditPopover.test.tsx +150 -1
  249. package/src/PowerSearch/PowerSearchEditPopover.tsx +51 -28
  250. package/src/ProgressBar/ProgressBar.test.tsx +29 -11
  251. package/src/ProgressBar/ProgressBar.tsx +59 -72
  252. package/src/RadioList/RadioList.doc.mjs +11 -0
  253. package/src/RadioList/RadioList.test.tsx +32 -0
  254. package/src/RadioList/RadioListItem.tsx +25 -1
  255. package/src/ScrollableArea/modules/useScrollableArea.spec.md +50 -22
  256. package/src/SegmentedControl/SegmentedControl.doc.mjs +2 -2
  257. package/src/SegmentedControl/SegmentedControl.test.tsx +31 -0
  258. package/src/SegmentedControl/SegmentedControlItem.tsx +6 -9
  259. package/src/Selector/Selector.spec.md +12 -9
  260. package/src/Selector/Selector.tsx +6 -1
  261. package/src/Selector/__tests__/Listbox.a11y.chromium.spec.ts +130 -0
  262. package/src/Selector/__tests__/Listbox.a11y.dom.ts +38 -0
  263. package/src/Selector/__tests__/Listbox.a11y.inventory.test.ts +63 -0
  264. package/src/Selector/__tests__/Listbox.a11y.known-failures.ts +37 -0
  265. package/src/Selector/__tests__/Listbox.a11y.states.ts +232 -0
  266. package/src/Selector/__tests__/Selector.listbox.a11y.test.tsx +121 -0
  267. package/src/SideNav/SideNav.doc.mjs +1 -1
  268. package/src/SideNav/SideNav.test.tsx +10 -0
  269. package/src/SideNav/SideNav.tsx +14 -2
  270. package/src/Slider/Slider.doc.mjs +27 -0
  271. package/src/Slider/Slider.spec.md +61 -47
  272. package/src/Slider/Slider.test.tsx +146 -0
  273. package/src/Slider/Slider.tsx +37 -13
  274. package/src/Spinner/Spinner.doc.mjs +6 -3
  275. package/src/Spinner/Spinner.test.tsx +41 -0
  276. package/src/Spinner/Spinner.tsx +62 -32
  277. package/src/Switch/Switch.doc.mjs +11 -0
  278. package/src/Switch/Switch.test.tsx +16 -0
  279. package/src/Switch/Switch.tsx +28 -0
  280. package/src/TabList/Tab.tsx +35 -7
  281. package/src/TabList/TabList.doc.mjs +11 -0
  282. package/src/TabList/TabList.test.tsx +66 -0
  283. package/src/TabList/__tests__/Tabs.a11y.known-failures.ts +1 -34
  284. package/src/TextArea/TextArea.tsx +6 -1
  285. package/src/TextInput/TextInput.test.tsx +20 -1
  286. package/src/TextInput/TextInput.tsx +6 -1
  287. package/src/TimeInput/TimeInput.tsx +6 -1
  288. package/src/ToggleButton/ToggleButton.test.tsx +133 -0
  289. package/src/ToggleButton/ToggleButton.tsx +9 -2
  290. package/src/ToggleButton/__tests__/ToggleButton.a11y.chromium.spec.ts +209 -0
  291. package/src/Tokenizer/Tokenizer.spec.md +142 -75
  292. package/src/TreeList/TreeList.spec.md +123 -75
  293. package/src/Typeahead/BaseTypeahead.spec.md +4 -3
  294. package/src/Typeahead/BaseTypeahead.tsx +21 -7
  295. package/src/Typeahead/Typeahead.test.tsx +53 -0
  296. package/src/__tests__/PressedState.a11y.chromium.spec.ts +813 -0
  297. package/src/__tests__/pressState.ts +93 -0
  298. package/src/hooks/scrollKeyboardDelegation.test.ts +155 -0
  299. package/src/hooks/scrollKeyboardDelegation.ts +233 -0
  300. package/src/hooks/useScrollableArea.doc.mjs +15 -3
  301. package/src/hooks/useScrollableArea.test.tsx +59 -1
  302. package/src/hooks/useScrollableArea.ts +34 -10
  303. package/src/inputFontFloor.test.ts +106 -0
  304. package/src/theme/derivedVarRegistry.test.ts +6 -4
  305. package/src/utils/interactionOverlay.stylex.ts +20 -0
@@ -0,0 +1,1022 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file protocol.test.tsx
5
+ * @input Syntax, immutable transform, renderer, parser, and Outline plugin APIs
6
+ * @output Regression coverage for ordering, typing, fallback, identity, and rendering
7
+ * @position Focused acceptance tests for the core Markdown plugin protocol
8
+ */
9
+
10
+ import {PassThrough} from 'node:stream';
11
+ import {renderToPipeableStream, renderToString} from 'react-dom/server';
12
+ import {act, render, screen} from '@testing-library/react';
13
+ import {describe, expect, expectTypeOf, it, vi} from 'vitest';
14
+ import {Markdown} from '../Markdown';
15
+ import {
16
+ createIncrementalState,
17
+ parseInline,
18
+ parseMarkdown,
19
+ parseMarkdownIncremental,
20
+ } from '../parser';
21
+ import type {InlineNode} from '../parser';
22
+ import {createMarkdownPlugin, isMarkdownExtensionNode} from './protocol';
23
+ import {visitMarkdownNodes} from '../ast';
24
+ import type {
25
+ MarkdownExtensionNode,
26
+ MarkdownSyntaxPluginDefinition,
27
+ MarkdownTransformPluginDefinition,
28
+ } from './protocol';
29
+ import {parseOutlineFromMarkdown} from '../../Outline/parseOutlineFromMarkdown';
30
+
31
+ type MentionNode = MarkdownExtensionNode<
32
+ 'mentions',
33
+ 'mention',
34
+ {readonly label: string},
35
+ 'inline'
36
+ >;
37
+
38
+ type BrokenMentionNode = MarkdownExtensionNode<
39
+ 'broken-mentions',
40
+ 'mention',
41
+ {readonly label: string},
42
+ 'inline'
43
+ >;
44
+
45
+ function ThrowingRendererChild(): never {
46
+ throw new Error('broken renderer child');
47
+ }
48
+
49
+ type InvalidSyntaxNode = MarkdownExtensionNode<
50
+ 'invalid-syntax',
51
+ 'mention',
52
+ {readonly label: string},
53
+ 'inline'
54
+ >;
55
+
56
+ type BadgeNode = MarkdownExtensionNode<
57
+ 'badges',
58
+ 'badge',
59
+ {readonly label: string},
60
+ 'inline'
61
+ >;
62
+
63
+ type CalloutNode = MarkdownExtensionNode<
64
+ 'callouts',
65
+ 'callout',
66
+ {readonly body: string},
67
+ 'block'
68
+ >;
69
+
70
+ const mentionDefinition = {
71
+ name: 'mentions',
72
+ apiVersion: 1,
73
+ parseKey: 'v1',
74
+ syntax: {
75
+ inline: [
76
+ {
77
+ startsWith: ['@{'],
78
+ maxSpan: 80,
79
+ tokenize({source, offset, end, isFinal}) {
80
+ const close = source.indexOf('}', offset + 2);
81
+ if (close < 0 || close >= end) {
82
+ return isFinal ? {status: 'no-match'} : {status: 'defer'};
83
+ }
84
+ return {
85
+ status: 'match',
86
+ end: close + 1,
87
+ node: {
88
+ type: 'extension',
89
+ plugin: 'mentions',
90
+ name: 'mention',
91
+ display: 'inline',
92
+ data: {label: source.slice(offset + 2, close)},
93
+ },
94
+ };
95
+ },
96
+ },
97
+ ],
98
+ },
99
+ renderers: {
100
+ mention: {
101
+ render: ({node}) => <span data-testid="mention">@{node.data.label}</span>,
102
+ toText: node => `@${node.data.label}`,
103
+ },
104
+ },
105
+ } satisfies MarkdownSyntaxPluginDefinition<'mentions', MentionNode>;
106
+
107
+ const mentionPlugin = createMarkdownPlugin<'mentions', MentionNode>(
108
+ mentionDefinition,
109
+ );
110
+
111
+ const calloutDefinition = {
112
+ name: 'callouts',
113
+ apiVersion: 1,
114
+ parseKey: 'v1',
115
+ syntax: {
116
+ block: [
117
+ {
118
+ startsWith: [':::note'],
119
+ maxSpan: 500,
120
+ tokenize({source, offset, end, isFinal}) {
121
+ const close = source.indexOf('\n:::', offset + 7);
122
+ if (close < 0 || close + 4 > end) {
123
+ return isFinal ? {status: 'no-match'} : {status: 'defer'};
124
+ }
125
+ return {
126
+ status: 'match',
127
+ end: close + 4,
128
+ node: {
129
+ type: 'extension',
130
+ plugin: 'callouts',
131
+ name: 'callout',
132
+ display: 'block',
133
+ data: {body: source.slice(offset + 7, close).trim()},
134
+ },
135
+ };
136
+ },
137
+ },
138
+ ],
139
+ },
140
+ renderers: {
141
+ callout: {
142
+ render: ({node}) => <aside data-testid="callout">{node.data.body}</aside>,
143
+ toText: node => node.data.body,
144
+ },
145
+ },
146
+ } satisfies MarkdownSyntaxPluginDefinition<'callouts', CalloutNode>;
147
+
148
+ const calloutPlugin = createMarkdownPlugin<'callouts', CalloutNode>(
149
+ calloutDefinition,
150
+ );
151
+
152
+ const badgeDefinition = {
153
+ name: 'badges',
154
+ apiVersion: 1,
155
+ transform(root) {
156
+ return {
157
+ ...root,
158
+ children: root.children.map(block =>
159
+ block.type === 'paragraph'
160
+ ? {
161
+ ...block,
162
+ children: [
163
+ ...block.children,
164
+ {
165
+ type: 'extension' as const,
166
+ plugin: 'badges' as const,
167
+ name: 'badge' as const,
168
+ display: 'inline' as const,
169
+ data: {label: 'New'},
170
+ },
171
+ ],
172
+ }
173
+ : block,
174
+ ),
175
+ };
176
+ },
177
+ renderers: {
178
+ badge: {
179
+ render: ({node}) => <mark data-testid="badge">{node.data.label}</mark>,
180
+ toText: node => node.data.label,
181
+ },
182
+ },
183
+ } satisfies MarkdownTransformPluginDefinition<'badges', BadgeNode>;
184
+
185
+ const badgePlugin = createMarkdownPlugin<'badges', BadgeNode>(badgeDefinition);
186
+
187
+ function replaceText(value: string, replacement: string) {
188
+ return createMarkdownPlugin({
189
+ name: `replace-${value}-${replacement}`,
190
+ apiVersion: 1,
191
+ transform(root) {
192
+ return {
193
+ ...root,
194
+ children: root.children.map(block =>
195
+ block.type === 'paragraph' || block.type === 'heading'
196
+ ? {
197
+ ...block,
198
+ children: block.children.map(node =>
199
+ node.type === 'text'
200
+ ? {
201
+ ...node,
202
+ value: node.value.replaceAll(value, replacement),
203
+ }
204
+ : node,
205
+ ),
206
+ }
207
+ : block,
208
+ ),
209
+ };
210
+ },
211
+ });
212
+ }
213
+
214
+ describe('Markdown plugin protocol', () => {
215
+ it('keeps omitted and explicit empty pipelines identical', () => {
216
+ const source = '# Heading\n\nPlain **text**.';
217
+ const omitted = render(<Markdown>{source}</Markdown>);
218
+ const baseline = omitted.container.innerHTML;
219
+ omitted.unmount();
220
+
221
+ const empty = render(<Markdown plugins={[]}>{source}</Markdown>);
222
+ expect(empty.container.innerHTML).toBe(baseline);
223
+ });
224
+
225
+ it('infers typed inline extensions and keeps protected contexts opaque', () => {
226
+ const nodes = parseInline('**@{Ada}** [@{Grace}](/people) `@{Linus}`', {
227
+ plugins: [mentionPlugin] as const,
228
+ });
229
+ expectTypeOf(nodes).toEqualTypeOf<InlineNode<MentionNode>[]>();
230
+ expect(nodes[0]).toMatchObject({
231
+ type: 'bold',
232
+ children: [{type: 'extension', data: {label: 'Ada'}}],
233
+ });
234
+ expect(nodes[2]).toMatchObject({
235
+ type: 'link',
236
+ children: [{type: 'text', content: '@{Grace}'}],
237
+ });
238
+ expect(nodes[4]).toEqual({type: 'code', content: '@{Linus}'});
239
+
240
+ const forgedProvenance = createMarkdownPlugin<'mentions', MentionNode>({
241
+ ...mentionDefinition,
242
+ syntax: {
243
+ inline: [
244
+ {
245
+ ...mentionDefinition.syntax.inline[0],
246
+ tokenize(input) {
247
+ const result = mentionDefinition.syntax.inline[0].tokenize(input);
248
+ return result.status === 'match'
249
+ ? ({
250
+ ...result,
251
+ node: {
252
+ ...result.node,
253
+ source: 'forged',
254
+ position: {start: {offset: 99}, end: {offset: 100}},
255
+ },
256
+ } as never)
257
+ : result;
258
+ },
259
+ },
260
+ ],
261
+ },
262
+ });
263
+ const [forgedNode] = parseInline('@{Ada}', {
264
+ plugins: [forgedProvenance],
265
+ });
266
+ expect(forgedNode).toMatchObject({
267
+ type: 'extension',
268
+ source: '@{Ada}',
269
+ });
270
+ expect(forgedNode).not.toHaveProperty('position');
271
+ });
272
+
273
+ it('renders inline and top-level block extension nodes', () => {
274
+ render(
275
+ <Markdown plugins={[mentionPlugin, calloutPlugin]}>
276
+ {'Hello @{Ada}.\n\n:::note\nRead this\n:::'}
277
+ </Markdown>,
278
+ );
279
+ expect(screen.getByTestId('mention')).toHaveTextContent('@Ada');
280
+ expect(screen.getByTestId('callout')).toHaveTextContent('Read this');
281
+ });
282
+
283
+ it('isolates a suspending renderer behind its node source', async () => {
284
+ let resolved = false;
285
+ let resolve: (() => void) | undefined;
286
+ const pending = new Promise<void>(done => {
287
+ resolve = () => {
288
+ resolved = true;
289
+ done();
290
+ };
291
+ });
292
+
293
+ function AsyncMention({label}: {readonly label: string}) {
294
+ if (!resolved) {
295
+ // React Suspense requires throwing the pending thenable.
296
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
297
+ throw pending;
298
+ }
299
+ return <span data-testid="async-mention">@{label} loaded</span>;
300
+ }
301
+
302
+ const asyncMentionPlugin = createMarkdownPlugin<'mentions', MentionNode>({
303
+ ...mentionDefinition,
304
+ renderers: {
305
+ mention: {
306
+ render: ({node}) => <AsyncMention label={node.data.label} />,
307
+ toText: node => `@${node.data.label}`,
308
+ },
309
+ },
310
+ });
311
+
312
+ render(
313
+ <Markdown plugins={[asyncMentionPlugin]}>
314
+ {'Before @{Ada} after.'}
315
+ </Markdown>,
316
+ );
317
+
318
+ expect(screen.getByText('Before', {exact: false})).toBeInTheDocument();
319
+ expect(screen.getByText('@{Ada}', {exact: false})).toBeInTheDocument();
320
+ expect(screen.getByText('after.', {exact: false})).toBeInTheDocument();
321
+ expect(screen.queryByTestId('async-mention')).not.toBeInTheDocument();
322
+
323
+ await act(async () => resolve?.());
324
+
325
+ expect(await screen.findByTestId('async-mention')).toHaveTextContent(
326
+ '@Ada loaded',
327
+ );
328
+ expect(screen.getByText('Before', {exact: false})).toBeInTheDocument();
329
+ expect(screen.getByText('after.', {exact: false})).toBeInTheDocument();
330
+ });
331
+
332
+ it('streams readable node fallback and siblings before a renderer resolves', async () => {
333
+ let resolved = false;
334
+ let resolve: (() => void) | undefined;
335
+ const pending = new Promise<void>(done => {
336
+ resolve = () => {
337
+ resolved = true;
338
+ done();
339
+ };
340
+ });
341
+
342
+ function StreamingMention({label}: {readonly label: string}) {
343
+ if (!resolved) {
344
+ // React Suspense uses a thrown thenable as control flow.
345
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
346
+ throw pending;
347
+ }
348
+ return <span>@{label} streamed</span>;
349
+ }
350
+
351
+ const plugin = createMarkdownPlugin<'mentions', MentionNode>({
352
+ ...mentionDefinition,
353
+ renderers: {
354
+ mention: {
355
+ render: ({node}) => <StreamingMention label={node.data.label} />,
356
+ toText: node => `@${node.data.label}`,
357
+ },
358
+ },
359
+ });
360
+ const destination = new PassThrough();
361
+ destination.setEncoding('utf8');
362
+ const chunks: string[] = [];
363
+ destination.on('data', chunk => chunks.push(String(chunk)));
364
+ const finished = new Promise<void>((done, reject) => {
365
+ destination.on('end', done);
366
+ destination.on('error', reject);
367
+ });
368
+ let rejectShell: (error: unknown) => void = () => {};
369
+ const shellReady = new Promise<void>((done, reject) => {
370
+ rejectShell = reject;
371
+ const {pipe} = renderToPipeableStream(
372
+ <Markdown plugins={[plugin]}>{'Before @{Ada} after.'}</Markdown>,
373
+ {
374
+ onShellReady() {
375
+ pipe(destination);
376
+ done();
377
+ },
378
+ onShellError(error) {
379
+ reject(error);
380
+ },
381
+ },
382
+ );
383
+ });
384
+ destination.on('error', error => rejectShell(error));
385
+
386
+ await shellReady;
387
+ await new Promise<void>(done => setImmediate(done));
388
+ const shell = chunks.join('');
389
+ expect(shell).toContain('Before ');
390
+ expect(shell).toContain('@{Ada}');
391
+ expect(shell).toContain(' after.');
392
+ expect(shell).not.toContain('@Ada streamed');
393
+
394
+ resolve?.();
395
+ await finished;
396
+ expect(chunks.join('').replaceAll('<!-- -->', '')).toContain(
397
+ '@Ada streamed',
398
+ );
399
+ });
400
+
401
+ it('rejects duplicate entries and contains invalid tokenizer output', () => {
402
+ expect(() =>
403
+ parseMarkdown('plain', {plugins: [mentionPlugin, mentionPlugin]}),
404
+ ).toThrow(/duplicate name/);
405
+ const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
406
+ const duplicate = render(
407
+ <Markdown plugins={[mentionPlugin, mentionPlugin]}>plain</Markdown>,
408
+ );
409
+ expect(duplicate.getByText('plain')).toBeInTheDocument();
410
+ duplicate.unmount();
411
+
412
+ const invalid = createMarkdownPlugin<'invalid-syntax', InvalidSyntaxNode>({
413
+ ...mentionDefinition,
414
+ name: 'invalid-syntax',
415
+ syntax: {
416
+ inline: [
417
+ {
418
+ startsWith: ['@{'],
419
+ maxSpan: 80,
420
+ tokenize: (async () => ({status: 'no-match'})) as never,
421
+ },
422
+ ],
423
+ },
424
+ renderers: mentionDefinition.renderers,
425
+ } as never);
426
+ expect(parseMarkdown('Hello @{Ada}', {plugins: [invalid]})).toEqual([
427
+ {
428
+ type: 'paragraph',
429
+ children: [{type: 'text', content: 'Hello @{Ada}'}],
430
+ },
431
+ ]);
432
+ warning.mockRestore();
433
+ });
434
+
435
+ it('renders synthetic extension nodes introduced by transforms', () => {
436
+ const blocks = parseMarkdown('Status: ', {plugins: [badgePlugin]});
437
+ expect(blocks).toMatchObject([
438
+ {
439
+ type: 'paragraph',
440
+ children: [
441
+ {type: 'text', content: 'Status: '},
442
+ {
443
+ type: 'extension',
444
+ plugin: 'badges',
445
+ name: 'badge',
446
+ data: {label: 'New'},
447
+ },
448
+ ],
449
+ },
450
+ ]);
451
+ render(<Markdown plugins={[badgePlugin]}>Status: </Markdown>);
452
+ expect(screen.getByTestId('badge')).toHaveTextContent('New');
453
+ });
454
+
455
+ it('does not enable block syntax inside blockquotes or list items', () => {
456
+ const source = '> :::note\n> quoted\n> :::\n\n- :::note\n listed\n :::';
457
+ const blocks = parseMarkdown(source, {plugins: [calloutPlugin]});
458
+ expect(JSON.stringify(blocks)).not.toContain('"type":"extension"');
459
+ });
460
+
461
+ it('runs immutable transforms in plugin order', () => {
462
+ const first = replaceText('original', 'first');
463
+ const second = replaceText('first', 'second');
464
+ expect(parseMarkdown('original', {plugins: [first, second]})).toMatchObject(
465
+ [{type: 'paragraph', children: [{type: 'text', content: 'second'}]}],
466
+ );
467
+ expect(parseMarkdown('original', {plugins: [second, first]})).toMatchObject(
468
+ [{type: 'paragraph', children: [{type: 'text', content: 'first'}]}],
469
+ );
470
+ });
471
+
472
+ it('accepts plugins created by a duplicate Core copy', async () => {
473
+ vi.resetModules();
474
+ const duplicateCore = await import('./protocol');
475
+ const duplicatePlugin = duplicateCore.createMarkdownPlugin({
476
+ name: 'duplicate-core-copy',
477
+ apiVersion: 1,
478
+ transform(root) {
479
+ return {
480
+ ...root,
481
+ children: root.children.map(block =>
482
+ block.type === 'paragraph'
483
+ ? {
484
+ ...block,
485
+ children: block.children.map(node =>
486
+ node.type === 'text'
487
+ ? {...node, value: node.value.replace('before', 'after')}
488
+ : node,
489
+ ),
490
+ }
491
+ : block,
492
+ ),
493
+ };
494
+ },
495
+ });
496
+
497
+ expect(parseMarkdown('before', {plugins: [duplicatePlugin]})).toMatchObject(
498
+ [{type: 'paragraph', children: [{type: 'text', content: 'after'}]}],
499
+ );
500
+
501
+ const brand = Symbol.for('@astryxdesign/core/MarkdownPluginEntry');
502
+ const incompatible = Object.freeze({
503
+ name: 'incompatible-copy',
504
+ apiVersion: 1 as const,
505
+ [brand]: Object.freeze({
506
+ kind: '@astryxdesign/core/MarkdownPluginEntry',
507
+ apiVersion: 2,
508
+ definition: Object.freeze({
509
+ name: 'incompatible-copy',
510
+ apiVersion: 1,
511
+ transform: () => ({type: 'root', children: []}),
512
+ }),
513
+ }),
514
+ });
515
+ expect(() =>
516
+ parseMarkdown('safe', {plugins: [incompatible as never]}),
517
+ ).toThrow(/compatible createMarkdownPlugin/);
518
+ });
519
+
520
+ it('defuses rejected transform promises in runtime and server rendering', async () => {
521
+ const source = 'Private source must not escape';
522
+ const rejection = 'Private plugin rejection must not escape';
523
+ const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
524
+ const unhandled = vi.fn();
525
+ process.on('unhandledRejection', unhandled);
526
+ const rejected = createMarkdownPlugin({
527
+ name: 'rejected-promise',
528
+ apiVersion: 1,
529
+ transform: (async () => Promise.reject(new Error(rejection))) as never,
530
+ });
531
+
532
+ try {
533
+ expect(parseMarkdown(source, {plugins: [rejected]})).toMatchObject([
534
+ {type: 'paragraph', children: [{type: 'text', content: source}]},
535
+ ]);
536
+ expect(
537
+ renderToString(<Markdown plugins={[rejected]}>{source}</Markdown>),
538
+ ).toContain(source);
539
+ await new Promise(resolve => setTimeout(resolve, 0));
540
+ expect(unhandled).not.toHaveBeenCalled();
541
+ expect(JSON.stringify(warning.mock.calls)).not.toContain(rejection);
542
+ expect(JSON.stringify(warning.mock.calls)).not.toContain(source);
543
+ } finally {
544
+ process.off('unhandledRejection', unhandled);
545
+ warning.mockRestore();
546
+ }
547
+ });
548
+
549
+ it('emits one source-free production diagnostic for plugin failure', () => {
550
+ vi.stubEnv('NODE_ENV', 'production');
551
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
552
+ const source = 'Private production source';
553
+ const thrownText = 'Private plugin-thrown text';
554
+ const broken = createMarkdownPlugin({
555
+ name: 'production-failure',
556
+ apiVersion: 1,
557
+ transform() {
558
+ throw new Error(thrownText);
559
+ },
560
+ });
561
+
562
+ try {
563
+ parseMarkdown(source, {plugins: [broken]});
564
+ parseMarkdown(source, {plugins: [broken]});
565
+ expect(error).toHaveBeenCalledTimes(1);
566
+ expect(error).toHaveBeenCalledWith(
567
+ 'Markdown: plugin "production-failure" failed in transform; rendered readable fallback.',
568
+ );
569
+ expect(JSON.stringify(error.mock.calls)).not.toContain(source);
570
+ expect(JSON.stringify(error.mock.calls)).not.toContain(thrownText);
571
+ } finally {
572
+ error.mockRestore();
573
+ vi.unstubAllEnvs();
574
+ }
575
+ });
576
+
577
+ it('keeps the last valid tree after mutation, async, or semantic failure', () => {
578
+ const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
579
+ const mutating = createMarkdownPlugin({
580
+ name: 'mutating',
581
+ apiVersion: 1,
582
+ transform(root) {
583
+ (root.children as unknown as unknown[]).push({type: 'thematicBreak'});
584
+ return root;
585
+ },
586
+ });
587
+ const asyncPlugin = createMarkdownPlugin({
588
+ name: 'async',
589
+ apiVersion: 1,
590
+ transform: (async () =>
591
+ Promise.resolve({type: 'root', children: []})) as never,
592
+ });
593
+ const headingChange = createMarkdownPlugin({
594
+ name: 'heading-change',
595
+ apiVersion: 1,
596
+ transform(root) {
597
+ return {
598
+ ...root,
599
+ children: root.children.map(block =>
600
+ block.type === 'heading' ? {...block, depth: 6 as const} : block,
601
+ ),
602
+ };
603
+ },
604
+ });
605
+
606
+ for (const plugin of [mutating, asyncPlugin, headingChange]) {
607
+ expect(parseMarkdown('# Safe', {plugins: [plugin]})).toEqual([
608
+ {
609
+ type: 'heading',
610
+ level: 1,
611
+ children: [{type: 'text', content: 'Safe'}],
612
+ },
613
+ ]);
614
+ }
615
+ warning.mockRestore();
616
+ });
617
+
618
+ it('allows source heading removal and synthetic headings without changing surviving depth', () => {
619
+ const addHeading = createMarkdownPlugin({
620
+ name: 'add-heading',
621
+ apiVersion: 1,
622
+ transform(root) {
623
+ return {
624
+ ...root,
625
+ children: [
626
+ ...root.children,
627
+ {
628
+ type: 'heading' as const,
629
+ depth: 2 as const,
630
+ children: [{type: 'text' as const, value: 'Generated'}],
631
+ },
632
+ ],
633
+ };
634
+ },
635
+ });
636
+
637
+ const removeHeading = createMarkdownPlugin({
638
+ name: 'remove-heading',
639
+ apiVersion: 1,
640
+ transform(root) {
641
+ return {
642
+ ...root,
643
+ children: root.children.filter(block => block.type !== 'heading'),
644
+ };
645
+ },
646
+ });
647
+
648
+ expect(parseMarkdown('# Source', {plugins: [addHeading]})).toMatchObject([
649
+ {type: 'heading', level: 1},
650
+ {type: 'heading', level: 2, children: [{content: 'Generated'}]},
651
+ ]);
652
+ expect(
653
+ parseMarkdown('# Removed\n\nSurviving body.', {
654
+ plugins: [removeHeading],
655
+ }),
656
+ ).toMatchObject([
657
+ {
658
+ type: 'paragraph',
659
+ children: [{type: 'text', content: 'Surviving body.'}],
660
+ },
661
+ ]);
662
+ });
663
+
664
+ it('rejects unsafe destinations, forged provenance, and foreign deletion', () => {
665
+ const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
666
+ const unsafeDestination = createMarkdownPlugin({
667
+ name: 'unsafe-destination',
668
+ apiVersion: 1,
669
+ transform(root) {
670
+ return {
671
+ ...root,
672
+ children: root.children.map(block =>
673
+ block.type === 'paragraph'
674
+ ? {
675
+ ...block,
676
+ children: [
677
+ {
678
+ type: 'link' as const,
679
+ url: 'javascript:alert(1)',
680
+ children: [{type: 'text' as const, value: 'unsafe'}],
681
+ },
682
+ ],
683
+ }
684
+ : block,
685
+ ),
686
+ };
687
+ },
688
+ });
689
+ const nestedLink = createMarkdownPlugin({
690
+ name: 'nested-link',
691
+ apiVersion: 1,
692
+ transform(root) {
693
+ return {
694
+ ...root,
695
+ children: root.children.map(block =>
696
+ block.type === 'paragraph'
697
+ ? {
698
+ ...block,
699
+ children: [
700
+ {
701
+ type: 'link' as const,
702
+ url: '/outer',
703
+ children: [
704
+ {
705
+ type: 'strong' as const,
706
+ children: [
707
+ {
708
+ type: 'link' as const,
709
+ url: '/inner',
710
+ children: [
711
+ {type: 'text' as const, value: 'nested'},
712
+ ],
713
+ },
714
+ ],
715
+ },
716
+ ],
717
+ },
718
+ ],
719
+ }
720
+ : block,
721
+ ),
722
+ };
723
+ },
724
+ });
725
+ const forgedSource = createMarkdownPlugin<'badges', BadgeNode>({
726
+ ...badgeDefinition,
727
+ transform(root) {
728
+ return {
729
+ ...root,
730
+ children: root.children.map(block =>
731
+ block.type === 'paragraph'
732
+ ? {
733
+ ...block,
734
+ children: [
735
+ ...block.children,
736
+ {
737
+ type: 'extension' as const,
738
+ plugin: 'badges' as const,
739
+ name: 'badge' as const,
740
+ display: 'inline' as const,
741
+ data: {label: 'forged'},
742
+ source: 'forged',
743
+ },
744
+ ],
745
+ }
746
+ : block,
747
+ ),
748
+ };
749
+ },
750
+ } as MarkdownTransformPluginDefinition<'badges', BadgeNode>);
751
+ const deleteForeign = createMarkdownPlugin({
752
+ name: 'delete-foreign',
753
+ apiVersion: 1,
754
+ transform(root) {
755
+ return {
756
+ ...root,
757
+ children: root.children.map(block =>
758
+ block.type === 'paragraph'
759
+ ? {
760
+ ...block,
761
+ children: block.children.filter(
762
+ astNode => astNode.type !== 'extension',
763
+ ),
764
+ }
765
+ : block,
766
+ ),
767
+ };
768
+ },
769
+ });
770
+
771
+ expect(parseMarkdown('Safe', {plugins: [unsafeDestination]})).toMatchObject(
772
+ [{type: 'paragraph', children: [{type: 'text', content: 'Safe'}]}],
773
+ );
774
+ expect(parseMarkdown('Safe', {plugins: [nestedLink]})).toMatchObject([
775
+ {type: 'paragraph', children: [{type: 'text', content: 'Safe'}]},
776
+ ]);
777
+ const identityReplacement = replaceText('missing', 'unchanged');
778
+ expect(
779
+ parseMarkdown('![pic](data:image/png;base64,abc)', {
780
+ plugins: [identityReplacement],
781
+ }),
782
+ ).toMatchObject([{type: 'image', src: 'data:image/png;base64,abc'}]);
783
+ expect(
784
+ parseMarkdown('[empty]()', {plugins: [identityReplacement]}),
785
+ ).toMatchObject([
786
+ {type: 'paragraph', children: [{type: 'link', href: ''}]},
787
+ ]);
788
+ expect(parseMarkdown('Safe', {plugins: [forgedSource]})).toMatchObject([
789
+ {type: 'paragraph', children: [{type: 'text', content: 'Safe'}]},
790
+ ]);
791
+ expect(
792
+ parseMarkdown('@{Ada}', {plugins: [mentionPlugin, deleteForeign]}),
793
+ ).toMatchObject([
794
+ {
795
+ type: 'paragraph',
796
+ children: [{type: 'extension', plugin: 'mentions', name: 'mention'}],
797
+ },
798
+ ]);
799
+ warning.mockRestore();
800
+ });
801
+
802
+ it('narrows visitor callbacks by node kind', () => {
803
+ const plugin = createMarkdownPlugin({
804
+ name: 'typed-visitor',
805
+ apiVersion: 1,
806
+ transform(root) {
807
+ visitMarkdownNodes(root, 'heading', heading => {
808
+ expectTypeOf(heading.depth).toEqualTypeOf<1 | 2 | 3 | 4 | 5 | 6>();
809
+ });
810
+ visitMarkdownNodes(root, 'extension', extension => {
811
+ if (
812
+ isMarkdownExtensionNode<MentionNode>(
813
+ extension,
814
+ 'mentions',
815
+ 'mention',
816
+ )
817
+ ) {
818
+ expectTypeOf(extension.data.label).toBeString();
819
+ }
820
+ });
821
+ return root;
822
+ },
823
+ });
824
+ expect(parseMarkdown('# Heading', {plugins: [plugin]})).toHaveLength(1);
825
+ });
826
+
827
+ it('keeps syntax identity independent from live transforms', () => {
828
+ let tokenizations = 0;
829
+ const pluginWithTransform = (replacement: string) =>
830
+ createMarkdownPlugin<'mentions', MentionNode>({
831
+ ...mentionDefinition,
832
+ transform(root) {
833
+ tokenizations += 0;
834
+ return {
835
+ ...root,
836
+ children: root.children.map(block =>
837
+ block.type === 'paragraph'
838
+ ? {
839
+ ...block,
840
+ children: block.children.map(node =>
841
+ node.type === 'text'
842
+ ? {
843
+ ...node,
844
+ value: node.value.replace('Tail', replacement),
845
+ }
846
+ : node,
847
+ ),
848
+ }
849
+ : block,
850
+ ),
851
+ };
852
+ },
853
+ syntax: {
854
+ inline: [
855
+ {
856
+ ...mentionDefinition.syntax.inline[0],
857
+ tokenize(input) {
858
+ tokenizations++;
859
+ return mentionDefinition.syntax.inline[0].tokenize(input);
860
+ },
861
+ },
862
+ ],
863
+ },
864
+ });
865
+ const state = createIncrementalState();
866
+ parseMarkdownIncremental('@{Ada}\n\nTail', state, {
867
+ plugins: [pluginWithTransform('First')],
868
+ });
869
+ const before = tokenizations;
870
+ const updated = parseMarkdownIncremental('@{Ada}\n\nTail', state, {
871
+ plugins: [pluginWithTransform('Second')],
872
+ });
873
+ expect(tokenizations).toBe(before);
874
+ expect(updated.at(-1)).toMatchObject({
875
+ type: 'paragraph',
876
+ children: [{type: 'text', content: 'Second'}],
877
+ });
878
+ });
879
+
880
+ it('preserves settled identities when finalizing deferred syntax', () => {
881
+ const state = createIncrementalState();
882
+ const streaming = parseMarkdownIncremental('First\n\n@{', state, {
883
+ plugins: [mentionPlugin],
884
+ });
885
+ const firstBlock = streaming[0];
886
+ const final = parseMarkdownIncremental('First\n\n@{', state, {
887
+ plugins: [mentionPlugin],
888
+ isFinal: true,
889
+ });
890
+
891
+ expect(final[0]).toBe(firstBlock);
892
+ expect(final[1]).toMatchObject({
893
+ type: 'paragraph',
894
+ children: [{type: 'text', content: '@{'}],
895
+ });
896
+ });
897
+
898
+ it('uses transformed heading text for both Markdown and Outline', () => {
899
+ const plugin = replaceText('Draft', 'Final');
900
+ const source = '# Draft';
901
+ const outline = parseOutlineFromMarkdown(source, {plugins: [plugin]});
902
+ render(<Markdown plugins={[plugin]}>{source}</Markdown>);
903
+
904
+ expect(outline).toEqual([{id: 'final', label: 'Final', level: 1}]);
905
+ expect(screen.getByRole('heading', {name: 'Final'})).toHaveAttribute(
906
+ 'id',
907
+ 'final',
908
+ );
909
+ });
910
+
911
+ it('passes matching transform finality to Markdown-derived outlines', () => {
912
+ const plugin = createMarkdownPlugin({
913
+ name: 'finality-label',
914
+ apiVersion: 1,
915
+ transform(root, context) {
916
+ return {
917
+ ...root,
918
+ children: root.children.map(block =>
919
+ block.type === 'heading'
920
+ ? {
921
+ ...block,
922
+ children: [
923
+ {
924
+ type: 'text' as const,
925
+ value: context.isFinal ? 'Final' : 'Draft',
926
+ },
927
+ ],
928
+ }
929
+ : block,
930
+ ),
931
+ };
932
+ },
933
+ });
934
+
935
+ expect(
936
+ parseOutlineFromMarkdown('# Pending', {
937
+ plugins: [plugin],
938
+ isFinal: false,
939
+ }),
940
+ ).toEqual([{id: 'draft', label: 'Draft', level: 1}]);
941
+ expect(
942
+ parseOutlineFromMarkdown('# Pending', {
943
+ plugins: [plugin],
944
+ isFinal: true,
945
+ }),
946
+ ).toEqual([{id: 'final', label: 'Final', level: 1}]);
947
+ });
948
+
949
+ it('falls back to readable source when an extension renderer throws', () => {
950
+ const broken = createMarkdownPlugin<'broken-mentions', BrokenMentionNode>({
951
+ ...mentionDefinition,
952
+ name: 'broken-mentions',
953
+ syntax: {
954
+ inline: [
955
+ {
956
+ ...mentionDefinition.syntax.inline[0],
957
+ tokenize(input) {
958
+ const result = mentionDefinition.syntax.inline[0].tokenize(input);
959
+ return result.status === 'match'
960
+ ? {
961
+ ...result,
962
+ node: {...result.node, plugin: 'broken-mentions' as const},
963
+ }
964
+ : result;
965
+ },
966
+ },
967
+ ],
968
+ },
969
+ renderers: {
970
+ mention: {
971
+ render() {
972
+ throw new Error('broken renderer');
973
+ },
974
+ toText: node => `@${node.data.label}`,
975
+ },
976
+ },
977
+ });
978
+ const descendant = createMarkdownPlugin<
979
+ 'broken-mentions',
980
+ BrokenMentionNode
981
+ >({
982
+ ...mentionDefinition,
983
+ name: 'broken-mentions',
984
+ syntax: {
985
+ inline: [
986
+ {
987
+ ...mentionDefinition.syntax.inline[0],
988
+ tokenize(input) {
989
+ const result = mentionDefinition.syntax.inline[0].tokenize(input);
990
+ return result.status === 'match'
991
+ ? {
992
+ ...result,
993
+ node: {...result.node, plugin: 'broken-mentions' as const},
994
+ }
995
+ : result;
996
+ },
997
+ },
998
+ ],
999
+ },
1000
+ renderers: {
1001
+ mention: {
1002
+ render: () => <ThrowingRendererChild />,
1003
+ toText: node => `@${node.data.label}`,
1004
+ },
1005
+ },
1006
+ });
1007
+ const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
1008
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
1009
+ expect(
1010
+ renderToString(<Markdown plugins={[broken]}>{'Hello @{Ada}'}</Markdown>),
1011
+ ).toContain('@{Ada}');
1012
+ expect(
1013
+ renderToString(
1014
+ <Markdown plugins={[descendant]}>{'Hello @{Ada}'}</Markdown>,
1015
+ ),
1016
+ ).toContain('@{Ada}');
1017
+ render(<Markdown plugins={[broken]}>{'Hello @{Ada}'}</Markdown>);
1018
+ expect(screen.getByText(/@\{Ada\}/)).toBeInTheDocument();
1019
+ warning.mockRestore();
1020
+ error.mockRestore();
1021
+ });
1022
+ });