@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
@@ -2,29 +2,81 @@
2
2
 
3
3
  /**
4
4
  * @file parser.ts
5
- * @input Markdown string
6
- * @output Array of MarkdownNode AST nodes; heading slug helpers
7
- * (inlineText, slugify, uniqueSlug) shared by Markdown rendering and
8
- * Outline's parseOutlineFromMarkdown
9
- * @position Core parser; consumed by Markdown.tsx and Outline
5
+ * @input Markdown string, released parse options, and optional ordered plugins
6
+ * @output Canonical MDAST-aligned nodes for public/server consumers plus unchanged
7
+ * released parser-node projections; shared heading slug helpers
8
+ * @position Core parser and compatibility boundary; consumed by public parser entry,
9
+ * Markdown, and Outline
10
10
  */
11
11
 
12
+ import {
13
+ getMarkdownAstLegacyCodeLanguage,
14
+ markMarkdownAstLegacyCodeLanguage,
15
+ } from './ast';
16
+ import type {
17
+ MarkdownAstBlockContent,
18
+ MarkdownAstList,
19
+ MarkdownAstListItem,
20
+ MarkdownAstPhrasingContent,
21
+ MarkdownAstPosition,
22
+ MarkdownAstRoot,
23
+ MarkdownAstTableCell,
24
+ MarkdownAstTableRow,
25
+ } from './ast';
26
+ import {
27
+ applyMarkdownTransforms,
28
+ freezeMarkdownPluginData,
29
+ isMarkdownPluginData,
30
+ prepareMarkdownPlugins,
31
+ reportMarkdownPluginFailure,
32
+ } from './plugins/protocol';
33
+ import type {
34
+ MarkdownExtensionNode,
35
+ MarkdownExtensionsOf,
36
+ MarkdownPluginData,
37
+ MarkdownPluginEntry,
38
+ PreparedMarkdownPlugins,
39
+ PreparedSyntaxContribution,
40
+ } from './plugins/protocol';
41
+ import {isSafeMarkdownParserUrl} from './url';
42
+
12
43
  // ---------------------------------------------------------------------------
13
44
  // Types
14
45
  // ---------------------------------------------------------------------------
15
46
 
16
- export type InlineNode =
47
+ /** Nodes returned by default and legacy parser calls. */
48
+ export type InlineNode<Extension extends MarkdownExtensionNode = never> =
49
+ | {type: 'text'; content: string}
50
+ | {type: 'bold'; children: InlineNode<Extension>[]}
51
+ | {type: 'italic'; children: InlineNode<Extension>[]}
52
+ | {type: 'strikethrough'; children: InlineNode<Extension>[]}
53
+ | {type: 'code'; content: string}
54
+ | {type: 'link'; href: string; children: InlineNode<Extension>[]}
55
+ | {type: 'image'; src: string; alt: string}
56
+ | {type: 'citation'; sourceId: string}
57
+ | {type: 'break'}
58
+ | Extract<Extension, {display: 'inline'}>;
59
+
60
+ /** The additional inline node returned only when parsing with `math: true`. */
61
+ export type MathInlineNode = {type: 'math'; value: string};
62
+
63
+ /** Nodes returned by an explicitly math-enabled inline parse. */
64
+ export type InlineNodeWithMath<
65
+ Extension extends MarkdownExtensionNode = never,
66
+ > =
17
67
  | {type: 'text'; content: string}
18
- | {type: 'bold'; children: InlineNode[]}
19
- | {type: 'italic'; children: InlineNode[]}
20
- | {type: 'strikethrough'; children: InlineNode[]}
68
+ | {type: 'bold'; children: InlineNodeWithMath<Extension>[]}
69
+ | {type: 'italic'; children: InlineNodeWithMath<Extension>[]}
70
+ | {type: 'strikethrough'; children: InlineNodeWithMath<Extension>[]}
21
71
  | {type: 'code'; content: string}
22
- | {type: 'link'; href: string; children: InlineNode[]}
72
+ | MathInlineNode
73
+ | {type: 'link'; href: string; children: InlineNodeWithMath<Extension>[]}
23
74
  | {type: 'image'; src: string; alt: string}
24
75
  | {type: 'citation'; sourceId: string}
25
- | {type: 'break'};
76
+ | {type: 'break'}
77
+ | Extract<Extension, {display: 'inline'}>;
26
78
 
27
- export type BlockNode = BlockNodeKind & {
79
+ type BlockMetadata = {
28
80
  /**
29
81
  * Where this block came from in the source, when parsed with the
30
82
  * `sourceRanges` option. Top-level blocks only.
@@ -32,11 +84,15 @@ export type BlockNode = BlockNodeKind & {
32
84
  range?: SourceRange;
33
85
  };
34
86
 
35
- type BlockNodeKind =
36
- | {type: 'heading'; level: 1 | 2 | 3 | 4 | 5 | 6; children: InlineNode[]}
37
- | {type: 'paragraph'; children: InlineNode[]}
87
+ type LegacyBlockNodeKind<Extension extends MarkdownExtensionNode = never> =
88
+ | {
89
+ type: 'heading';
90
+ level: 1 | 2 | 3 | 4 | 5 | 6;
91
+ children: InlineNode<Extension>[];
92
+ }
93
+ | {type: 'paragraph'; children: InlineNode<Extension>[]}
38
94
  | {type: 'codeblock'; language: string; content: string}
39
- | {type: 'blockquote'; children: BlockNode[]}
95
+ | {type: 'blockquote'; children: BlockNode<Extension>[]}
40
96
  | {
41
97
  type: 'list';
42
98
  ordered: boolean;
@@ -44,16 +100,57 @@ type BlockNodeKind =
44
100
  /** Ordered-list marker delimiter ('.' or ')'). Undefined for bullets. */
45
101
  delimiter?: '.' | ')';
46
102
  loose?: boolean;
47
- items: ListItemNode[];
103
+ items: ListItemNode<Extension>[];
48
104
  }
49
105
  | {
50
106
  type: 'table';
51
- headers: TableCellNode[];
107
+ headers: TableCellNode<Extension>[];
52
108
  alignments: TableAlignment[];
53
- rows: TableCellNode[][];
109
+ rows: TableCellNode<Extension>[][];
54
110
  }
55
111
  | {type: 'hr'}
56
- | {type: 'image'; src: string; alt: string};
112
+ | {type: 'image'; src: string; alt: string}
113
+ | Extract<Extension, {display: 'block'}>;
114
+
115
+ /** Blocks returned by default and legacy parser calls. */
116
+ export type BlockNode<Extension extends MarkdownExtensionNode = never> =
117
+ LegacyBlockNodeKind<Extension> & BlockMetadata;
118
+
119
+ /** The additional block returned only when parsing with `math: true`. */
120
+ export type MathBlockNode = {type: 'math'; value: string} & BlockMetadata;
121
+
122
+ type MathEnabledBlockNodeKind<Extension extends MarkdownExtensionNode = never> =
123
+ | {
124
+ type: 'heading';
125
+ level: 1 | 2 | 3 | 4 | 5 | 6;
126
+ children: InlineNodeWithMath<Extension>[];
127
+ }
128
+ | {type: 'paragraph'; children: InlineNodeWithMath<Extension>[]}
129
+ | {type: 'codeblock'; language: string; content: string}
130
+ | MathBlockNode
131
+ | {type: 'blockquote'; children: BlockNodeWithMath<Extension>[]}
132
+ | {
133
+ type: 'list';
134
+ ordered: boolean;
135
+ start?: number;
136
+ /** Ordered-list marker delimiter ('.' or ')'). Undefined for bullets. */
137
+ delimiter?: '.' | ')';
138
+ loose?: boolean;
139
+ items: ListItemNodeWithMath<Extension>[];
140
+ }
141
+ | {
142
+ type: 'table';
143
+ headers: TableCellNodeWithMath<Extension>[];
144
+ alignments: TableAlignment[];
145
+ rows: TableCellNodeWithMath<Extension>[][];
146
+ }
147
+ | {type: 'hr'}
148
+ | {type: 'image'; src: string; alt: string}
149
+ | Extract<Extension, {display: 'block'}>;
150
+
151
+ /** Blocks returned by an explicitly math-enabled block parse. */
152
+ export type BlockNodeWithMath<Extension extends MarkdownExtensionNode = never> =
153
+ MathEnabledBlockNodeKind<Extension> & BlockMetadata;
57
154
 
58
155
  /**
59
156
  * Where a block sits in the source string handed to `parseMarkdown`:
@@ -66,10 +163,288 @@ type BlockNodeKind =
66
163
  */
67
164
  export type SourceRange = {readonly start: number; readonly end: number};
68
165
 
69
- export type ListItemNode = {checked?: boolean; children: BlockNode[]};
70
- export type TableCellNode = {children: InlineNode[]};
166
+ export type ListItemNode<Extension extends MarkdownExtensionNode = never> = {
167
+ checked?: boolean;
168
+ children: BlockNode<Extension>[];
169
+ };
170
+ type ListItemNodeWithMath<Extension extends MarkdownExtensionNode = never> = {
171
+ checked?: boolean;
172
+ children: BlockNodeWithMath<Extension>[];
173
+ };
174
+ export type TableCellNode<Extension extends MarkdownExtensionNode = never> = {
175
+ children: InlineNode<Extension>[];
176
+ };
177
+ type TableCellNodeWithMath<Extension extends MarkdownExtensionNode = never> = {
178
+ children: InlineNodeWithMath<Extension>[];
179
+ };
71
180
  export type TableAlignment = 'left' | 'center' | 'right' | null;
72
181
 
182
+ type RuntimeExtensionNode =
183
+ | MarkdownExtensionNode<string, string, MarkdownPluginData, 'inline'>
184
+ | MarkdownExtensionNode<string, string, MarkdownPluginData, 'block'>;
185
+ type RuntimeInlineNode = InlineNodeWithMath<RuntimeExtensionNode>;
186
+ type RuntimeBlockNode = BlockNodeWithMath<RuntimeExtensionNode>;
187
+
188
+ type LegacyProjectionCache = WeakMap<object, object>;
189
+
190
+ function projectRange(
191
+ position: MarkdownAstPosition | undefined,
192
+ ): BlockMetadata {
193
+ const start = position?.start.offset;
194
+ const end = position?.end.offset;
195
+ return start == null || end == null ? {} : {range: {start, end}};
196
+ }
197
+
198
+ /**
199
+ * Canonical nodes always carry Core-authored positions; the released block
200
+ * shape exposes them as `range` only when the caller passed `sourceRanges`,
201
+ * so this projection stays byte-for-byte what it has always returned.
202
+ *
203
+ * An extension node is the one kind the projection passes through instead of
204
+ * rebuilding field by field, so it is the one kind that could carry a
205
+ * canonical `position` out into released output. Strip it unless the caller
206
+ * asked for provenance, in which case the node keeps exactly the `position`
207
+ * it has always exposed.
208
+ */
209
+ function projectExtensionNode<Node extends RuntimeExtensionNode>(
210
+ node: Node,
211
+ withRanges: boolean,
212
+ ): Node {
213
+ if (withRanges || node.position === undefined) {
214
+ return node;
215
+ }
216
+ const {position: _canonicalPosition, ...released} = node;
217
+ return released as Node;
218
+ }
219
+
220
+ function projectInlineNode(
221
+ node: MarkdownAstPhrasingContent<RuntimeExtensionNode>,
222
+ cache: LegacyProjectionCache,
223
+ withRanges: boolean,
224
+ ): RuntimeInlineNode {
225
+ const cached = cache.get(node);
226
+ if (cached != null) {
227
+ return cached as RuntimeInlineNode;
228
+ }
229
+ let projected: RuntimeInlineNode;
230
+ switch (node.type) {
231
+ case 'text':
232
+ projected = {type: 'text', content: node.value};
233
+ break;
234
+ case 'strong':
235
+ projected = {
236
+ type: 'bold',
237
+ children: node.children.map(child =>
238
+ projectInlineNode(child, cache, withRanges),
239
+ ),
240
+ };
241
+ break;
242
+ case 'emphasis':
243
+ projected = {
244
+ type: 'italic',
245
+ children: node.children.map(child =>
246
+ projectInlineNode(child, cache, withRanges),
247
+ ),
248
+ };
249
+ break;
250
+ case 'delete':
251
+ projected = {
252
+ type: 'strikethrough',
253
+ children: node.children.map(child =>
254
+ projectInlineNode(child, cache, withRanges),
255
+ ),
256
+ };
257
+ break;
258
+ case 'inlineCode':
259
+ projected = {type: 'code', content: node.value};
260
+ break;
261
+ case 'inlineMath':
262
+ projected = {type: 'math', value: node.value};
263
+ break;
264
+ case 'link':
265
+ projected = {
266
+ type: 'link',
267
+ href: node.url,
268
+ children: node.children.map(child =>
269
+ projectInlineNode(child, cache, withRanges),
270
+ ),
271
+ };
272
+ break;
273
+ case 'image':
274
+ projected = {type: 'image', src: node.url, alt: node.alt};
275
+ break;
276
+ case 'citation':
277
+ projected = {type: 'citation', sourceId: node.sourceId};
278
+ break;
279
+ case 'break':
280
+ projected = {type: 'break'};
281
+ break;
282
+ case 'extension':
283
+ projected = projectExtensionNode(node, withRanges);
284
+ break;
285
+ }
286
+ cache.set(node, projected);
287
+ return projected;
288
+ }
289
+
290
+ function projectTableCell(
291
+ node: MarkdownAstTableCell<RuntimeExtensionNode>,
292
+ cache: LegacyProjectionCache,
293
+ withRanges: boolean,
294
+ ): TableCellNodeWithMath<RuntimeExtensionNode> {
295
+ const cached = cache.get(node);
296
+ if (cached != null) {
297
+ return cached as TableCellNodeWithMath<RuntimeExtensionNode>;
298
+ }
299
+ const projected = {
300
+ children: node.children.map(child =>
301
+ projectInlineNode(child, cache, withRanges),
302
+ ),
303
+ };
304
+ cache.set(node, projected);
305
+ return projected;
306
+ }
307
+
308
+ function projectListItem(
309
+ node: MarkdownAstListItem<RuntimeExtensionNode>,
310
+ cache: LegacyProjectionCache,
311
+ withRanges: boolean,
312
+ ): ListItemNodeWithMath<RuntimeExtensionNode> {
313
+ const cached = cache.get(node);
314
+ if (cached != null) {
315
+ return cached as ListItemNodeWithMath<RuntimeExtensionNode>;
316
+ }
317
+ const projected = {
318
+ checked: node.checked,
319
+ children: node.children.map(child =>
320
+ projectBlockNode(child, cache, withRanges),
321
+ ),
322
+ };
323
+ cache.set(node, projected);
324
+ return projected;
325
+ }
326
+
327
+ function projectBlockNode(
328
+ node: MarkdownAstBlockContent<RuntimeExtensionNode>,
329
+ cache: LegacyProjectionCache,
330
+ withRanges: boolean,
331
+ ): RuntimeBlockNode {
332
+ const cached = cache.get(node);
333
+ if (cached != null) {
334
+ return cached as RuntimeBlockNode;
335
+ }
336
+ const metadata = withRanges ? projectRange(node.position) : {};
337
+ let projected: RuntimeBlockNode;
338
+ switch (node.type) {
339
+ case 'heading':
340
+ projected = {
341
+ type: 'heading',
342
+ level: node.depth,
343
+ children: node.children.map(child =>
344
+ projectInlineNode(child, cache, withRanges),
345
+ ),
346
+ ...metadata,
347
+ };
348
+ break;
349
+ case 'paragraph':
350
+ projected = {
351
+ type: 'paragraph',
352
+ children: node.children.map(child =>
353
+ projectInlineNode(child, cache, withRanges),
354
+ ),
355
+ ...metadata,
356
+ };
357
+ break;
358
+ case 'code':
359
+ projected = {
360
+ type: 'codeblock',
361
+ language: getMarkdownAstLegacyCodeLanguage(node) ?? 'plaintext',
362
+ content: node.value,
363
+ ...metadata,
364
+ };
365
+ break;
366
+ case 'math':
367
+ projected = {type: 'math', value: node.value, ...metadata};
368
+ break;
369
+ case 'blockquote':
370
+ projected = {
371
+ type: 'blockquote',
372
+ children: node.children.map(child =>
373
+ projectBlockNode(child, cache, withRanges),
374
+ ),
375
+ ...metadata,
376
+ };
377
+ break;
378
+ case 'list':
379
+ projected = {
380
+ type: 'list',
381
+ ordered: node.ordered,
382
+ start: node.start,
383
+ delimiter: node.delimiter,
384
+ loose: node.spread,
385
+ items: node.children.map(item =>
386
+ projectListItem(item, cache, withRanges),
387
+ ),
388
+ ...metadata,
389
+ };
390
+ break;
391
+ case 'table': {
392
+ const [header = {type: 'tableRow' as const, children: []}, ...rows] =
393
+ node.children;
394
+ projected = {
395
+ type: 'table',
396
+ headers: header.children.map(cell =>
397
+ projectTableCell(cell, cache, withRanges),
398
+ ),
399
+ alignments: [...node.align],
400
+ rows: rows.map(row =>
401
+ row.children.map(cell => projectTableCell(cell, cache, withRanges)),
402
+ ),
403
+ ...metadata,
404
+ };
405
+ break;
406
+ }
407
+ case 'thematicBreak':
408
+ projected = {type: 'hr', ...metadata};
409
+ break;
410
+ case 'image':
411
+ projected = {type: 'image', alt: node.alt, src: node.url, ...metadata};
412
+ break;
413
+ case 'extension':
414
+ projected = projectExtensionNode(node, withRanges);
415
+ break;
416
+ }
417
+ cache.set(node, projected);
418
+ return projected;
419
+ }
420
+
421
+ function projectInlineNodes(
422
+ nodes: ReadonlyArray<MarkdownAstPhrasingContent<RuntimeExtensionNode>>,
423
+ withRanges: boolean,
424
+ cache: LegacyProjectionCache = new WeakMap(),
425
+ ): RuntimeInlineNode[] {
426
+ return nodes.map(node => projectInlineNode(node, cache, withRanges));
427
+ }
428
+
429
+ function projectMarkdownRoot(
430
+ root: MarkdownAstRoot<RuntimeExtensionNode>,
431
+ withRanges: boolean,
432
+ cache: LegacyProjectionCache = new WeakMap(),
433
+ ): RuntimeBlockNode[] {
434
+ return root.children.map(node => projectBlockNode(node, cache, withRanges));
435
+ }
436
+
437
+ /** Whether a caller asked for the released `range` field on its blocks. */
438
+ function wantsLegacyRanges(
439
+ arg: ReadonlySet<string> | RuntimeParseOptions | undefined,
440
+ ): boolean {
441
+ return (
442
+ arg != null &&
443
+ typeof (arg as {has?: unknown}).has !== 'function' &&
444
+ (arg as RuntimeParseOptions).sourceRanges === true
445
+ );
446
+ }
447
+
73
448
  // ---------------------------------------------------------------------------
74
449
  // Parse options
75
450
  // ---------------------------------------------------------------------------
@@ -81,10 +456,14 @@ export type TableAlignment = 'left' | 'center' | 'right' | null;
81
456
  * `parseMarkdownIncremental` functions also accept the legacy
82
457
  * `ReadonlySet<string>` shape as the second argument.
83
458
  */
84
- export type ParseOptions = {
459
+ type CommonParseOptions<
460
+ Plugins extends ReadonlyArray<MarkdownPluginEntry> = readonly [],
461
+ > = {
85
462
  /** Set of citation source ids — `[id]` / `【id】` markers in this set
86
463
  * become citation nodes instead of plain text / links. */
87
464
  sourceIds?: ReadonlySet<string>;
465
+ /** Ordered opt-in Markdown extensions. */
466
+ plugins?: Plugins;
88
467
  /**
89
468
  * Autolink mode. When set to `'gfm'`, the parser turns bare
90
469
  * `https?://…` / `www.…` URLs, `<URL>` / `<email>` angle-bracket
@@ -109,10 +488,57 @@ export type ParseOptions = {
109
488
  sourceRanges?: boolean;
110
489
  };
111
490
 
491
+ /** Options for default and legacy parser results. */
492
+ export type ParseOptions<
493
+ Plugins extends ReadonlyArray<MarkdownPluginEntry> = readonly [],
494
+ > = CommonParseOptions<Plugins> & {math?: false | undefined};
495
+
496
+ /**
497
+ * Options that explicitly parse `$…$` and `$$…$$` into math-enabled result
498
+ * unions. Keeping this separate prevents a legacy `ParseOptions` annotation
499
+ * from silently widening an exhaustive node switch.
500
+ */
501
+ export type MathParseOptions<
502
+ Plugins extends ReadonlyArray<MarkdownPluginEntry> = readonly [],
503
+ > = CommonParseOptions<Plugins> & {math: true};
504
+
505
+ export type IncrementalParseOptions<
506
+ Plugins extends ReadonlyArray<MarkdownPluginEntry> = readonly [],
507
+ > = ParseOptions<Plugins> & {
508
+ /** False while more source may arrive; true for the terminal snapshot. */
509
+ readonly isFinal?: boolean;
510
+ };
511
+
512
+ export type IncrementalMathParseOptions<
513
+ Plugins extends ReadonlyArray<MarkdownPluginEntry> = readonly [],
514
+ > = MathParseOptions<Plugins> & {
515
+ /** False while more source may arrive; true for the terminal snapshot. */
516
+ readonly isFinal?: boolean;
517
+ };
518
+
519
+ type RuntimeParseOptions = CommonParseOptions<
520
+ ReadonlyArray<MarkdownPluginEntry>
521
+ > & {
522
+ math?: boolean;
523
+ isFinal?: boolean;
524
+ };
525
+
112
526
  type ResolvedOptions = {
113
527
  readonly sourceIds: ReadonlySet<string> | undefined;
114
528
  readonly autolink: 'gfm' | undefined;
529
+ readonly math?: boolean;
530
+ /** Whether the caller asked for the released `range` projection. */
115
531
  readonly sourceRanges?: boolean;
532
+ /**
533
+ * Whether this parse authors canonical `position` offsets on its blocks.
534
+ * Internal only, and independent of `sourceRanges`: the canonical tree
535
+ * transforms observe always carries provenance, while `sourceRanges` alone
536
+ * decides whether the released projection exposes it as `range`.
537
+ */
538
+ readonly astPositions?: boolean;
539
+ readonly plugins?: PreparedMarkdownPlugins;
540
+ readonly isFinal: boolean;
541
+ readonly allowBlockSyntax?: boolean;
116
542
  /**
117
543
  * Offset of this parse's input within the document the ranges are reported
118
544
  * against. Internal only — the incremental parser parses slices and needs
@@ -128,13 +554,42 @@ type ResolvedOptions = {
128
554
  readonly linkDefs?: ReadonlyMap<string, string>;
129
555
  };
130
556
 
131
- const EMPTY_OPTS: ResolvedOptions = {sourceIds: undefined, autolink: undefined};
557
+ /**
558
+ * Every resolved options object is built here, so all of them share one key
559
+ * set in one order — and therefore one hidden class. The parser reads these
560
+ * fields in its hottest loops; letting an omitted-options parse and an
561
+ * options-bag parse produce differently-shaped objects makes those reads
562
+ * polymorphic in any process that does both, which is exactly what a caller
563
+ * comparing an empty pipeline against a configured one does.
564
+ */
565
+ function makeResolvedOptions(
566
+ fields: Partial<ResolvedOptions> & {readonly isFinal: boolean},
567
+ ): ResolvedOptions {
568
+ return {
569
+ sourceIds: fields.sourceIds,
570
+ autolink: fields.autolink,
571
+ math: fields.math,
572
+ sourceRanges: fields.sourceRanges,
573
+ astPositions: fields.astPositions ?? true,
574
+ plugins: fields.plugins,
575
+ isFinal: fields.isFinal,
576
+ allowBlockSyntax: fields.allowBlockSyntax ?? true,
577
+ baseOffset: fields.baseOffset,
578
+ linkDefs: fields.linkDefs,
579
+ };
580
+ }
581
+
582
+ const EMPTY_OPTS: ResolvedOptions = makeResolvedOptions({isFinal: true});
583
+ const EMPTY_INCREMENTAL_OPTS: ResolvedOptions = makeResolvedOptions({
584
+ isFinal: false,
585
+ });
132
586
 
133
587
  function resolveOptions(
134
- arg: ReadonlySet<string> | ParseOptions | undefined,
588
+ arg: ReadonlySet<string> | RuntimeParseOptions | undefined,
589
+ incremental = false,
135
590
  ): ResolvedOptions {
136
591
  if (arg == null) {
137
- return EMPTY_OPTS;
592
+ return incremental ? EMPTY_INCREMENTAL_OPTS : EMPTY_OPTS;
138
593
  }
139
594
  // Duck-type the legacy `ReadonlySet<string>` form: any object whose
140
595
  // `.has` is callable is treated as the legacy sourceIds set. This is
@@ -142,14 +597,23 @@ function resolveOptions(
142
597
  // or polyfilled `ReadonlySet` implementations as a `ParseOptions` bag
143
598
  // and silently lose citation resolution.
144
599
  if (typeof (arg as {has?: unknown}).has === 'function') {
145
- return {sourceIds: arg as ReadonlySet<string>, autolink: undefined};
600
+ return makeResolvedOptions({
601
+ sourceIds: arg as ReadonlySet<string>,
602
+ isFinal: !incremental,
603
+ });
146
604
  }
147
- const opts = arg as ParseOptions;
148
- return {
605
+ const opts = arg as RuntimeParseOptions;
606
+ return makeResolvedOptions({
149
607
  sourceIds: opts.sourceIds,
150
608
  autolink: opts.autolink,
609
+ math: opts.math === true ? true : undefined,
151
610
  sourceRanges: opts.sourceRanges,
152
- };
611
+ plugins:
612
+ opts.plugins != null && opts.plugins.length > 0
613
+ ? prepareMarkdownPlugins(opts.plugins)
614
+ : undefined,
615
+ isFinal: incremental ? opts.isFinal === true : true,
616
+ });
153
617
  }
154
618
 
155
619
  // ---------------------------------------------------------------------------
@@ -175,6 +639,149 @@ function normalizeLinkLabel(label: string): string {
175
639
  return label.trim().replace(/\s+/g, ' ').toLowerCase();
176
640
  }
177
641
 
642
+ type DisplayMathContainer = {
643
+ outerQuoteDepth: number;
644
+ listBaseIndent: number | null;
645
+ innerQuoteDepth: number;
646
+ };
647
+
648
+ type DisplayMathMatch = {
649
+ value: string;
650
+ nextIndex: number;
651
+ endLine: number;
652
+ };
653
+
654
+ function stripBlockquoteMarkers(line: string): {
655
+ content: string;
656
+ quoteDepth: number;
657
+ } {
658
+ let content = line.endsWith('\r') ? line.slice(0, -1) : line;
659
+ let quoteDepth = 0;
660
+ while (true) {
661
+ const marker = /^ {0,3}> ?/.exec(content);
662
+ if (marker == null) {
663
+ return {content, quoteDepth};
664
+ }
665
+ content = content.slice(marker[0].length);
666
+ quoteDepth++;
667
+ }
668
+ }
669
+
670
+ function stripExactBlockquoteDepth(
671
+ line: string,
672
+ quoteDepth: number,
673
+ ): string | null {
674
+ let content = line;
675
+ for (let depth = 0; depth < quoteDepth; depth++) {
676
+ const marker = /^ {0,3}> ?/.exec(content);
677
+ if (marker == null) {
678
+ return null;
679
+ }
680
+ content = content.slice(marker[0].length);
681
+ }
682
+ return content;
683
+ }
684
+
685
+ /**
686
+ * Recognize a standalone display-math marker at the current container boundary.
687
+ * A list opener carries its base indent so an indented continuation marker can
688
+ * close it; blockquotes must keep the same quote depth.
689
+ */
690
+ function displayMathContainer(line: string): DisplayMathContainer | null {
691
+ const outer = stripBlockquoteMarkers(line);
692
+ const listMarker = /^( {0,9})(?:[-*+]|\d+[.)]) +(.*)$/.exec(outer.content);
693
+ if (listMarker != null) {
694
+ const taskMarker = /^\[[ xX]\] +(.*)$/.exec(listMarker[2]);
695
+ const inner = stripBlockquoteMarkers(taskMarker?.[1] ?? listMarker[2]);
696
+ return inner.content.trim() === '$$'
697
+ ? {
698
+ outerQuoteDepth: outer.quoteDepth,
699
+ listBaseIndent: listMarker[1].length,
700
+ innerQuoteDepth: inner.quoteDepth,
701
+ }
702
+ : null;
703
+ }
704
+ return outer.content.trim() === '$$'
705
+ ? {
706
+ outerQuoteDepth: outer.quoteDepth,
707
+ listBaseIndent: null,
708
+ innerQuoteDepth: 0,
709
+ }
710
+ : null;
711
+ }
712
+
713
+ type DisplayMathLineState = 'close' | 'inside' | 'outside';
714
+
715
+ function displayMathLineState(
716
+ line: string,
717
+ container: DisplayMathContainer,
718
+ ): DisplayMathLineState {
719
+ const outerContent = stripExactBlockquoteDepth(
720
+ line,
721
+ container.outerQuoteDepth,
722
+ );
723
+ if (outerContent == null) {
724
+ return 'outside';
725
+ }
726
+
727
+ if (container.listBaseIndent == null) {
728
+ // A deeper quote starts a different container. It cannot close or continue
729
+ // the math expression owned by the shallower quote.
730
+ if (/^ {0,3}> ?/.test(outerContent)) {
731
+ return 'outside';
732
+ }
733
+ return outerContent.trim() === '$$' ? 'close' : 'inside';
734
+ }
735
+
736
+ const continuationIndent =
737
+ outerContent.length - outerContent.trimStart().length;
738
+ if (continuationIndent <= container.listBaseIndent) {
739
+ return 'outside';
740
+ }
741
+ const innerContent = stripExactBlockquoteDepth(
742
+ outerContent.trimStart(),
743
+ container.innerQuoteDepth,
744
+ );
745
+ if (innerContent == null || /^ {0,3}> ?/.test(innerContent)) {
746
+ return 'outside';
747
+ }
748
+ return innerContent.trim() === '$$' ? 'close' : 'inside';
749
+ }
750
+
751
+ /** Match a complete `$$…$$` display-math block without consuming partial input. */
752
+ function matchDisplayMathBlock(
753
+ lines: string[],
754
+ lineIndex: number,
755
+ ): DisplayMathMatch | null {
756
+ const trimmed = lines[lineIndex].trim();
757
+ if (
758
+ trimmed.length > 4 &&
759
+ trimmed.startsWith('$$') &&
760
+ trimmed.endsWith('$$')
761
+ ) {
762
+ const value = trimmed.slice(2, -2);
763
+ return value.trim() === ''
764
+ ? null
765
+ : {value, nextIndex: lineIndex + 1, endLine: lineIndex};
766
+ }
767
+ if (trimmed !== '$$') {
768
+ return null;
769
+ }
770
+ for (let index = lineIndex + 1; index < lines.length; index++) {
771
+ if (lines[index].trim() === '$$') {
772
+ const value = lines.slice(lineIndex + 1, index).join('\n');
773
+ return value.trim() === ''
774
+ ? null
775
+ : {
776
+ value,
777
+ nextIndex: index + 1,
778
+ endLine: index,
779
+ };
780
+ }
781
+ }
782
+ return null;
783
+ }
784
+
178
785
  function matchLinkDefinition(
179
786
  line: string,
180
787
  ): {label: string; destination: string; hasTitle: boolean} | null {
@@ -211,7 +818,10 @@ function matchLinkDefinition(
211
818
  * where every definition is global. Separating a footer definition block with
212
819
  * a blank line — the usual form — always works.
213
820
  */
214
- function extractLinkDefinitions(input: string): {
821
+ function extractLinkDefinitions(
822
+ input: string,
823
+ math = false,
824
+ ): {
215
825
  defs: ReadonlyMap<string, string>;
216
826
  cleaned: string;
217
827
  /**
@@ -240,6 +850,16 @@ function extractLinkDefinitions(input: string): {
240
850
  }
241
851
  continue;
242
852
  }
853
+ if (math) {
854
+ const displayMath = matchDisplayMathBlock(lines, index);
855
+ if (displayMath != null) {
856
+ // Math is opaque Markdown content: definition-shaped TeX must not leak
857
+ // into the document-wide link-definition map.
858
+ index = displayMath.endLine;
859
+ atBoundary = true;
860
+ continue;
861
+ }
862
+ }
243
863
  const fenceMatch = line.match(/^(`{3,}|~{3,})/);
244
864
  if (fenceMatch) {
245
865
  inFence = true;
@@ -290,6 +910,15 @@ function extractLinkDefinitions(input: string): {
290
910
  return {defs, cleaned, lineMap};
291
911
  }
292
912
 
913
+ /** Order-independent signature of a citation-source set, for cache checks. */
914
+ function sourceIdsSignature(
915
+ sourceIds: ReadonlySet<string> | undefined,
916
+ ): string {
917
+ return sourceIds == null || sourceIds.size === 0
918
+ ? ''
919
+ : [...sourceIds].sort().join('\u0000');
920
+ }
921
+
293
922
  /** Order-independent signature of a link-definition set, for cache checks. */
294
923
  function linkDefsSignature(defs: ReadonlyMap<string, string>): string {
295
924
  if (defs.size === 0) {
@@ -307,12 +936,19 @@ function linkDefsSignature(defs: ReadonlyMap<string, string>): string {
307
936
  * Returns the node plus the index just past the reference, or null when it is
308
937
  * not a resolvable reference (caller falls through to literal handling).
309
938
  */
939
+ function protectedInlineOptions(opts: ResolvedOptions): ResolvedOptions {
940
+ return opts.plugins == null ? opts : {...opts, plugins: undefined};
941
+ }
942
+
310
943
  function matchReferenceLink(
311
944
  text: string,
312
945
  start: number,
313
946
  linkDefs: ReadonlyMap<string, string>,
314
947
  opts: ResolvedOptions,
315
- ): {node: InlineNode; end: number} | null {
948
+ ): {
949
+ node: MarkdownAstPhrasingContent<RuntimeExtensionNode>;
950
+ end: number;
951
+ } | null {
316
952
  const textClose = text.indexOf(']', start + 1);
317
953
  if (textClose === -1) {
318
954
  return null;
@@ -328,9 +964,13 @@ function matchReferenceLink(
328
964
  // matches nothing.
329
965
  const label = rawLabel === '' ? linkText : rawLabel;
330
966
  const href = linkDefs.get(normalizeLinkLabel(label));
331
- if (href != null && isSafeUrl(href)) {
967
+ if (href != null && isSafeMarkdownParserUrl(href)) {
332
968
  return {
333
- node: {type: 'link', href, children: parseInlineImpl(linkText, opts)},
969
+ node: {
970
+ type: 'link',
971
+ url: href,
972
+ children: parseInlineImpl(linkText, protectedInlineOptions(opts)),
973
+ },
334
974
  end: labelClose + 1,
335
975
  };
336
976
  }
@@ -343,11 +983,15 @@ function matchReferenceLink(
343
983
  return null;
344
984
  }
345
985
  const href = linkDefs.get(normalizeLinkLabel(linkText));
346
- if (href == null || !isSafeUrl(href)) {
986
+ if (href == null || !isSafeMarkdownParserUrl(href)) {
347
987
  return null;
348
988
  }
349
989
  return {
350
- node: {type: 'link', href, children: parseInlineImpl(linkText, opts)},
990
+ node: {
991
+ type: 'link',
992
+ url: href,
993
+ children: parseInlineImpl(linkText, protectedInlineOptions(opts)),
994
+ },
351
995
  end: textClose + 1,
352
996
  };
353
997
  }
@@ -357,7 +1001,10 @@ function matchReferenceImage(
357
1001
  text: string,
358
1002
  start: number,
359
1003
  linkDefs: ReadonlyMap<string, string>,
360
- ): {node: InlineNode; end: number} | null {
1004
+ ): {
1005
+ node: MarkdownAstPhrasingContent<RuntimeExtensionNode>;
1006
+ end: number;
1007
+ } | null {
361
1008
  const altClose = text.indexOf(']', start + 2);
362
1009
  if (altClose === -1) {
363
1010
  return null;
@@ -369,8 +1016,8 @@ function matchReferenceImage(
369
1016
  const rawLabel = text.slice(altClose + 2, labelClose);
370
1017
  const label = rawLabel === '' ? alt : rawLabel;
371
1018
  const src = linkDefs.get(normalizeLinkLabel(label));
372
- if (src != null && isSafeUrl(src)) {
373
- return {node: {type: 'image', src, alt}, end: labelClose + 1};
1019
+ if (src != null && isSafeMarkdownParserUrl(src)) {
1020
+ return {node: {type: 'image', url: src, alt}, end: labelClose + 1};
374
1021
  }
375
1022
  // No match — fall back to a shortcut `![alt]`.
376
1023
  }
@@ -379,10 +1026,10 @@ function matchReferenceImage(
379
1026
  return null;
380
1027
  }
381
1028
  const src = linkDefs.get(normalizeLinkLabel(alt));
382
- if (src == null || !isSafeUrl(src)) {
1029
+ if (src == null || !isSafeMarkdownParserUrl(src)) {
383
1030
  return null;
384
1031
  }
385
- return {node: {type: 'image', src, alt}, end: altClose + 1};
1032
+ return {node: {type: 'image', url: src, alt}, end: altClose + 1};
386
1033
  }
387
1034
 
388
1035
  // ---------------------------------------------------------------------------
@@ -412,31 +1059,65 @@ function isWordChar(ch: string | undefined): boolean {
412
1059
  return /\w/.test(ch);
413
1060
  }
414
1061
 
415
- // ---------------------------------------------------------------------------
416
- // URL scheme sanitization
417
- // ---------------------------------------------------------------------------
1062
+ /** True when the character at `index` is preceded by an odd backslash run. */
1063
+ function isEscaped(text: string, index: number): boolean {
1064
+ let backslashes = 0;
1065
+ for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) {
1066
+ backslashes++;
1067
+ }
1068
+ return backslashes % 2 === 1;
1069
+ }
418
1070
 
419
1071
  /**
420
- * Reject URLs with dangerous schemes (javascript:, vbscript:, data:) that
421
- * could execute arbitrary code when rendered as link hrefs or image srcs.
422
- * Returns true if the URL is safe to use, false otherwise.
1072
+ * Find the closing delimiter for `$…$` math on the same line.
1073
+ *
1074
+ * The whitespace and numeric-edge rules mirror common dollar-math parsers:
1075
+ * whitespace cannot hug the delimiters, a digit cannot sit immediately before
1076
+ * the opener or after the closer, and `$$` is reserved for display math. The
1077
+ * numeric guard prevents ordinary prose such as "$20 and $30" from becoming a
1078
+ * formula even in a math-enabled document.
423
1079
  */
424
- function isSafeUrl(url: string): boolean {
425
- // Trim and collapse whitespace/control chars that browsers tolerate but
426
- // could bypass a naive prefix check (e.g. "java\nscript:alert(1)").
427
- // eslint-disable-next-line no-control-regex -- control chars are the bypass
428
- const normalized = url.replace(/[\x00-\x1f\x7f]/g, '').trim();
429
- const lower = normalized.toLowerCase();
430
- if (
431
- lower.startsWith('javascript:') ||
432
- lower.startsWith('vbscript:') ||
433
- lower.startsWith('data:text/html')
434
- ) {
435
- return false;
1080
+ function isInlineMathStart(text: string, index: number): boolean {
1081
+ return (
1082
+ text[index] === '$' &&
1083
+ text[index - 1] !== '$' &&
1084
+ text[index + 1] !== '$' &&
1085
+ text[index + 1] != null &&
1086
+ !/\s/.test(text[index + 1]) &&
1087
+ !/\d/.test(text[index - 1] ?? '') &&
1088
+ !isEscaped(text, index)
1089
+ );
1090
+ }
1091
+
1092
+ function findInlineMathEnd(text: string, start: number): number {
1093
+ if (!isInlineMathStart(text, start)) {
1094
+ return -1;
436
1095
  }
437
- return true;
1096
+
1097
+ for (let index = start + 1; index < text.length; index++) {
1098
+ if (text[index] === '\n') {
1099
+ return -1;
1100
+ }
1101
+ if (text[index] !== '$' || isEscaped(text, index)) {
1102
+ continue;
1103
+ }
1104
+ // An unescaped dollar ends this candidate: it either forms a valid closer
1105
+ // or makes the whole span literal. Never skip over one and pair with a
1106
+ // later dollar, which would swallow currency or another expression.
1107
+ if (
1108
+ text[index - 1] === '$' ||
1109
+ text[index + 1] === '$' ||
1110
+ /\s/.test(text[index - 1]) ||
1111
+ /\d/.test(text[index + 1] ?? '')
1112
+ ) {
1113
+ return -1;
1114
+ }
1115
+ return index;
1116
+ }
1117
+ return -1;
438
1118
  }
439
1119
 
1120
+ // ---------------------------------------------------------------------------
440
1121
  // ---------------------------------------------------------------------------
441
1122
  // Inline parser
442
1123
  // ---------------------------------------------------------------------------
@@ -490,16 +1171,246 @@ function matchBracketCitation(
490
1171
  return {sourceId: id, end: closeIndex + 1};
491
1172
  }
492
1173
 
1174
+ type ExtensionMatch =
1175
+ | {readonly status: 'none'}
1176
+ | {readonly status: 'defer'}
1177
+ | {
1178
+ readonly status: 'match';
1179
+ readonly node: RuntimeExtensionNode;
1180
+ readonly end: number;
1181
+ };
1182
+
1183
+ function currentLineStart(source: string, offset: number): number {
1184
+ return source.lastIndexOf('\n', offset - 1) + 1;
1185
+ }
1186
+
1187
+ function matchExtensionSyntax(
1188
+ source: string,
1189
+ offset: number,
1190
+ context: 'inline' | 'block',
1191
+ opts: ResolvedOptions,
1192
+ ): ExtensionMatch {
1193
+ const byFirstCharacter =
1194
+ context === 'inline'
1195
+ ? opts.plugins?.inlineByFirstCharacter
1196
+ : opts.allowBlockSyntax === false
1197
+ ? undefined
1198
+ : opts.plugins?.blockByFirstCharacter;
1199
+ const candidates = byFirstCharacter?.get(source[offset]);
1200
+ if (candidates == null) {
1201
+ return {status: 'none'};
1202
+ }
1203
+
1204
+ const seen = new Set<PreparedSyntaxContribution>();
1205
+ for (const candidate of candidates) {
1206
+ if (seen.has(candidate)) {
1207
+ continue;
1208
+ }
1209
+ seen.add(candidate);
1210
+ let fullPrefix = false;
1211
+ for (const prefix of candidate.contribution.startsWith) {
1212
+ if (source.startsWith(prefix, offset)) {
1213
+ fullPrefix = true;
1214
+ break;
1215
+ }
1216
+ if (!opts.isFinal) {
1217
+ const remaining = source.slice(offset);
1218
+ if (remaining.length < prefix.length && prefix.startsWith(remaining)) {
1219
+ return {status: 'defer'};
1220
+ }
1221
+ }
1222
+ }
1223
+ if (!fullPrefix) {
1224
+ continue;
1225
+ }
1226
+
1227
+ const end = Math.min(
1228
+ source.length,
1229
+ offset + candidate.contribution.maxSpan,
1230
+ );
1231
+ let result: ReturnType<typeof candidate.contribution.tokenize>;
1232
+ try {
1233
+ result = candidate.contribution.tokenize({
1234
+ source: end === source.length ? source : source.slice(0, end),
1235
+ offset,
1236
+ end,
1237
+ isFinal: opts.isFinal,
1238
+ context,
1239
+ lineStart: currentLineStart(source, offset),
1240
+ column: offset - currentLineStart(source, offset),
1241
+ });
1242
+ } catch (error) {
1243
+ reportMarkdownPluginFailure(candidate.pluginName, 'syntax', error);
1244
+ continue;
1245
+ }
1246
+ if (
1247
+ result == null ||
1248
+ typeof result !== 'object' ||
1249
+ typeof (result as {then?: unknown}).then === 'function'
1250
+ ) {
1251
+ reportMarkdownPluginFailure(
1252
+ candidate.pluginName,
1253
+ 'syntax',
1254
+ new TypeError('Tokenizer returned an invalid result'),
1255
+ );
1256
+ continue;
1257
+ }
1258
+ if (result.status === 'no-match') {
1259
+ continue;
1260
+ }
1261
+ if (result.status === 'defer') {
1262
+ return !opts.isFinal &&
1263
+ end === source.length &&
1264
+ source.length - offset < candidate.contribution.maxSpan
1265
+ ? {status: 'defer'}
1266
+ : {status: 'none'};
1267
+ }
1268
+ if (
1269
+ result.status !== 'match' ||
1270
+ !Number.isInteger(result.end) ||
1271
+ result.end <= offset ||
1272
+ result.end > end ||
1273
+ result.node.type !== 'extension' ||
1274
+ result.node.plugin !== candidate.pluginName ||
1275
+ result.node.display !== context ||
1276
+ result.node.name.trim() === '' ||
1277
+ !isMarkdownPluginData(result.node.data) ||
1278
+ !opts.plugins?.renderers.has(
1279
+ `${candidate.pluginName}\0${result.node.name}`,
1280
+ )
1281
+ ) {
1282
+ reportMarkdownPluginFailure(
1283
+ candidate.pluginName,
1284
+ 'syntax',
1285
+ new TypeError('Tokenizer returned an invalid extension node'),
1286
+ );
1287
+ continue;
1288
+ }
1289
+ const rawNode = result.node as typeof result.node & {
1290
+ readonly source?: unknown;
1291
+ readonly position?: unknown;
1292
+ };
1293
+ const {
1294
+ source: _ignoredSource,
1295
+ position: _ignoredPosition,
1296
+ ...safeNode
1297
+ } = rawNode;
1298
+ const absoluteStart = (opts.baseOffset ?? 0) + offset;
1299
+ const absoluteEnd = (opts.baseOffset ?? 0) + result.end;
1300
+ return {
1301
+ status: 'match',
1302
+ end: result.end,
1303
+ node: Object.freeze({
1304
+ ...safeNode,
1305
+ data: freezeMarkdownPluginData(result.node.data),
1306
+ source: source.slice(offset, result.end),
1307
+ ...(opts.sourceRanges && context === 'block'
1308
+ ? {
1309
+ position: Object.freeze({
1310
+ start: Object.freeze({offset: absoluteStart}),
1311
+ end: Object.freeze({offset: absoluteEnd}),
1312
+ }),
1313
+ }
1314
+ : null),
1315
+ }) as RuntimeExtensionNode,
1316
+ };
1317
+ }
1318
+ return {status: 'none'};
1319
+ }
1320
+
1321
+ type ParseOptionsWithoutPlugins = Omit<ParseOptions, 'plugins'>;
1322
+ type MathParseOptionsWithoutPlugins = Omit<MathParseOptions, 'plugins'>;
1323
+ type IncrementalParseOptionsWithoutPlugins = Omit<
1324
+ IncrementalParseOptions,
1325
+ 'plugins'
1326
+ >;
1327
+ type IncrementalMathParseOptionsWithoutPlugins = Omit<
1328
+ IncrementalMathParseOptions,
1329
+ 'plugins'
1330
+ >;
1331
+
493
1332
  export function parseInline(
494
1333
  text: string,
495
1334
  sourceIds?: ReadonlySet<string>,
496
1335
  ): InlineNode[];
497
- export function parseInline(text: string, options: ParseOptions): InlineNode[];
498
1336
  export function parseInline(
499
1337
  text: string,
500
- arg?: ReadonlySet<string> | ParseOptions,
501
- ): InlineNode[] {
502
- return parseInlineEntry(text, resolveOptions(arg));
1338
+ options: MathParseOptionsWithoutPlugins,
1339
+ ): InlineNodeWithMath[];
1340
+ export function parseInline(
1341
+ text: string,
1342
+ options: ParseOptionsWithoutPlugins,
1343
+ ): InlineNode[];
1344
+ export function parseInline<
1345
+ const Plugins extends ReadonlyArray<MarkdownPluginEntry>,
1346
+ >(
1347
+ text: string,
1348
+ options: ParseOptionsWithoutPlugins & {plugins: Plugins},
1349
+ ): InlineNode<MarkdownExtensionsOf<Plugins>>[];
1350
+ export function parseInline<
1351
+ const Plugins extends ReadonlyArray<MarkdownPluginEntry>,
1352
+ >(
1353
+ text: string,
1354
+ options: MathParseOptionsWithoutPlugins & {plugins: Plugins},
1355
+ ): InlineNodeWithMath<MarkdownExtensionsOf<Plugins>>[];
1356
+ export function parseInline(
1357
+ text: string,
1358
+ arg?: ReadonlySet<string> | RuntimeParseOptions,
1359
+ ): RuntimeInlineNode[] {
1360
+ return projectInlineNodes(
1361
+ parseInlineAstRuntime(text, arg),
1362
+ wantsLegacyRanges(arg),
1363
+ );
1364
+ }
1365
+
1366
+ /**
1367
+ * Parse inline Markdown into the canonical immutable Astryx AST.
1368
+ *
1369
+ * Unlike `parseInline`, this returns MDAST-aligned node names and fields. Math
1370
+ * nodes are present only when `math: true`, and plugin extension nodes are
1371
+ * inferred from the ordered `plugins` tuple.
1372
+ */
1373
+ export function parseInlineAst(
1374
+ text: string,
1375
+ sourceIds?: ReadonlySet<string>,
1376
+ ): MarkdownAstPhrasingContent[];
1377
+ export function parseInlineAst(
1378
+ text: string,
1379
+ options: ParseOptionsWithoutPlugins | MathParseOptionsWithoutPlugins,
1380
+ ): MarkdownAstPhrasingContent[];
1381
+ export function parseInlineAst<
1382
+ const Plugins extends ReadonlyArray<MarkdownPluginEntry>,
1383
+ >(
1384
+ text: string,
1385
+ options: (ParseOptionsWithoutPlugins | MathParseOptionsWithoutPlugins) & {
1386
+ plugins: Plugins;
1387
+ },
1388
+ ): MarkdownAstPhrasingContent<MarkdownExtensionsOf<Plugins>>[];
1389
+ export function parseInlineAst(
1390
+ text: string,
1391
+ arg?: ReadonlySet<string> | RuntimeParseOptions,
1392
+ ): MarkdownAstPhrasingContent<RuntimeExtensionNode>[] {
1393
+ return parseInlineAstRuntime(text, arg);
1394
+ }
1395
+
1396
+ function parseInlineAstRuntime(
1397
+ text: string,
1398
+ arg?: ReadonlySet<string> | RuntimeParseOptions,
1399
+ ): MarkdownAstPhrasingContent<RuntimeExtensionNode>[] {
1400
+ const opts = resolveOptions(arg);
1401
+ const nodes = parseInlineEntry(text, opts);
1402
+ if ((opts.plugins?.transforms.length ?? 0) === 0) {
1403
+ return nodes;
1404
+ }
1405
+ const transformed = applyMarkdownTransforms(
1406
+ {type: 'root', children: [{type: 'paragraph', children: nodes}]},
1407
+ opts.plugins,
1408
+ text,
1409
+ opts.isFinal,
1410
+ 'inline',
1411
+ );
1412
+ const paragraph = transformed.children[0];
1413
+ return paragraph?.type === 'paragraph' ? [...paragraph.children] : nodes;
503
1414
  }
504
1415
 
505
1416
  /**
@@ -509,21 +1420,35 @@ export function parseInline(
509
1420
  * wrapper and call `parseInlineImpl` directly so the transform runs only on
510
1421
  * the outermost block's inline tree — letting `transformAutolinks` decide
511
1422
  * which subtrees to descend into (text, bold, italic, strikethrough) and
512
- * which to skip (link, code, image, citation, break).
1423
+ * which to skip (link, code, math, image, citation, break).
513
1424
  */
514
- function parseInlineEntry(text: string, opts: ResolvedOptions): InlineNode[] {
1425
+ function parseInlineEntry(
1426
+ text: string,
1427
+ opts: ResolvedOptions,
1428
+ ): MarkdownAstPhrasingContent<RuntimeExtensionNode>[] {
515
1429
  const nodes = parseInlineImpl(text, opts);
516
1430
  return opts.autolink === 'gfm' ? transformAutolinks(nodes) : nodes;
517
1431
  }
518
1432
 
519
- function parseInlineImpl(text: string, opts: ResolvedOptions): InlineNode[] {
520
- const nodes: InlineNode[] = [];
1433
+ function parseInlineImpl(
1434
+ text: string,
1435
+ opts: ResolvedOptions,
1436
+ ): MarkdownAstPhrasingContent<RuntimeExtensionNode>[] {
1437
+ const nodes: MarkdownAstPhrasingContent<RuntimeExtensionNode>[] = [];
1438
+ // Only a plugin that actually contributes INLINE syntax may cost anything
1439
+ // per source position. A transform-only list contributes none, so it takes
1440
+ // the same path as an omitted or empty one: no candidate probe per
1441
+ // position, and no map lookup per character in the plain-text scan below.
1442
+ const inlineExtensionStarts =
1443
+ opts.plugins != null && opts.plugins.inlineByFirstCharacter.size > 0
1444
+ ? opts.plugins.inlineByFirstCharacter
1445
+ : undefined;
521
1446
  let i = 0;
522
1447
 
523
1448
  while (i < text.length) {
524
1449
  // --- Escape ---
525
1450
  if (text[i] === '\\' && i + 1 < text.length) {
526
- nodes.push({type: 'text', content: text[i + 1]});
1451
+ nodes.push({type: 'text', value: text[i + 1]});
527
1452
  i += 2;
528
1453
  continue;
529
1454
  }
@@ -534,12 +1459,28 @@ function parseInlineImpl(text: string, opts: ResolvedOptions): InlineNode[] {
534
1459
  const openIndex = i + tickCount;
535
1460
  const closeIndex = text.indexOf('`'.repeat(tickCount), openIndex);
536
1461
  if (closeIndex !== -1) {
537
- nodes.push({type: 'code', content: text.slice(openIndex, closeIndex)});
1462
+ nodes.push({
1463
+ type: 'inlineCode',
1464
+ value: text.slice(openIndex, closeIndex),
1465
+ });
538
1466
  i = closeIndex + tickCount;
539
1467
  continue;
540
1468
  }
541
1469
  }
542
1470
 
1471
+ // --- Inline math (opt-in; code takes precedence) ---
1472
+ if (opts.math && text[i] === '$') {
1473
+ const closeIndex = findInlineMathEnd(text, i);
1474
+ if (closeIndex !== -1) {
1475
+ nodes.push({
1476
+ type: 'inlineMath',
1477
+ value: text.slice(i + 1, closeIndex),
1478
+ });
1479
+ i = closeIndex + 1;
1480
+ continue;
1481
+ }
1482
+ }
1483
+
543
1484
  // --- Citation: fullwidth 【id】 ---
544
1485
  {
545
1486
  const citation = matchFullwidthCitation(text, i, opts);
@@ -557,13 +1498,13 @@ function parseInlineImpl(text: string, opts: ResolvedOptions): InlineNode[] {
557
1498
  const srcClose = findClosingParen(text, altClose + 2);
558
1499
  if (srcClose !== -1) {
559
1500
  const src = text.slice(altClose + 2, srcClose);
560
- if (!isSafeUrl(src)) {
1501
+ if (!isSafeMarkdownParserUrl(src)) {
561
1502
  // Dangerous scheme — emit as plain text.
562
- nodes.push({type: 'text', content: text.slice(i, srcClose + 1)});
1503
+ nodes.push({type: 'text', value: text.slice(i, srcClose + 1)});
563
1504
  } else {
564
1505
  nodes.push({
565
1506
  type: 'image',
566
- src,
1507
+ url: src,
567
1508
  alt: text.slice(i + 2, altClose),
568
1509
  });
569
1510
  }
@@ -600,14 +1541,17 @@ function parseInlineImpl(text: string, opts: ResolvedOptions): InlineNode[] {
600
1541
  const urlClose = findClosingParen(text, textClose + 2);
601
1542
  if (urlClose !== -1) {
602
1543
  const href = text.slice(textClose + 2, urlClose);
603
- if (!isSafeUrl(href)) {
1544
+ if (!isSafeMarkdownParserUrl(href)) {
604
1545
  // Dangerous scheme — emit as plain text instead of a link.
605
- nodes.push({type: 'text', content: text.slice(i, urlClose + 1)});
1546
+ nodes.push({type: 'text', value: text.slice(i, urlClose + 1)});
606
1547
  } else {
607
1548
  nodes.push({
608
1549
  type: 'link',
609
- href,
610
- children: parseInlineImpl(text.slice(i + 1, textClose), opts),
1550
+ url: href,
1551
+ children: parseInlineImpl(
1552
+ text.slice(i + 1, textClose),
1553
+ protectedInlineOptions(opts),
1554
+ ),
611
1555
  });
612
1556
  }
613
1557
  i = urlClose + 1;
@@ -642,10 +1586,10 @@ function parseInlineImpl(text: string, opts: ResolvedOptions): InlineNode[] {
642
1586
  (!isUnderscore || !isWordChar(text[closeIndex + 3]))
643
1587
  ) {
644
1588
  nodes.push({
645
- type: 'bold',
1589
+ type: 'strong',
646
1590
  children: [
647
1591
  {
648
- type: 'italic',
1592
+ type: 'emphasis',
649
1593
  children: parseInlineImpl(text.slice(i + 3, closeIndex), opts),
650
1594
  },
651
1595
  ],
@@ -672,7 +1616,7 @@ function parseInlineImpl(text: string, opts: ResolvedOptions): InlineNode[] {
672
1616
  (!isUnderscore || !isWordChar(text[closeIndex + 2]))
673
1617
  ) {
674
1618
  nodes.push({
675
- type: 'bold',
1619
+ type: 'strong',
676
1620
  children: parseInlineImpl(text.slice(i + 2, closeIndex), opts),
677
1621
  });
678
1622
  i = closeIndex + 2;
@@ -686,7 +1630,7 @@ function parseInlineImpl(text: string, opts: ResolvedOptions): InlineNode[] {
686
1630
  const closeIndex = text.indexOf('~~', i + 2);
687
1631
  if (closeIndex !== -1) {
688
1632
  nodes.push({
689
- type: 'strikethrough',
1633
+ type: 'delete',
690
1634
  children: parseInlineImpl(text.slice(i + 2, closeIndex), opts),
691
1635
  });
692
1636
  i = closeIndex + 2;
@@ -707,7 +1651,7 @@ function parseInlineImpl(text: string, opts: ResolvedOptions): InlineNode[] {
707
1651
  (!isUnderscore || !isWordChar(text[closeIndex + 1]))
708
1652
  ) {
709
1653
  nodes.push({
710
- type: 'italic',
1654
+ type: 'emphasis',
711
1655
  children: parseInlineImpl(text.slice(i + 1, closeIndex), opts),
712
1656
  });
713
1657
  i = closeIndex + 1;
@@ -716,9 +1660,30 @@ function parseInlineImpl(text: string, opts: ResolvedOptions): InlineNode[] {
716
1660
  }
717
1661
  }
718
1662
 
1663
+ // --- Extension syntax (built-ins and protected contexts win) ---
1664
+ if (inlineExtensionStarts !== undefined) {
1665
+ const extension = matchExtensionSyntax(text, i, 'inline', opts);
1666
+ if (extension.status === 'match') {
1667
+ nodes.push(
1668
+ extension.node as Extract<RuntimeExtensionNode, {display: 'inline'}>,
1669
+ );
1670
+ i = extension.end;
1671
+ continue;
1672
+ }
1673
+ if (extension.status === 'defer') {
1674
+ break;
1675
+ }
1676
+ }
1677
+
719
1678
  // --- Plain text (with line-break detection) ---
720
1679
  let end = i + 1;
721
- while (end < text.length && !'*_~`[!\\\n\u3010'.includes(text[end])) {
1680
+ while (
1681
+ end < text.length &&
1682
+ !'*_~`[!\\\n\u3010'.includes(text[end]) &&
1683
+ !(opts.math && text[end] === '$') &&
1684
+ (inlineExtensionStarts === undefined ||
1685
+ !inlineExtensionStarts.has(text[end]))
1686
+ ) {
722
1687
  end++;
723
1688
  }
724
1689
 
@@ -731,9 +1696,12 @@ function parseInlineImpl(text: string, opts: ResolvedOptions): InlineNode[] {
731
1696
  if (trimmed.length > 0) {
732
1697
  const last = nodes[nodes.length - 1];
733
1698
  if (last?.type === 'text') {
734
- last.content += trimmed;
1699
+ nodes[nodes.length - 1] = {
1700
+ ...last,
1701
+ value: last.value + trimmed,
1702
+ };
735
1703
  } else {
736
- nodes.push({type: 'text', content: trimmed});
1704
+ nodes.push({type: 'text', value: trimmed});
737
1705
  }
738
1706
  }
739
1707
  nodes.push({type: 'break'});
@@ -744,9 +1712,9 @@ function parseInlineImpl(text: string, opts: ResolvedOptions): InlineNode[] {
744
1712
 
745
1713
  const last = nodes[nodes.length - 1];
746
1714
  if (last?.type === 'text') {
747
- last.content += content;
1715
+ nodes[nodes.length - 1] = {...last, value: last.value + content};
748
1716
  } else {
749
- nodes.push({type: 'text', content});
1717
+ nodes.push({type: 'text', value: content});
750
1718
  }
751
1719
  i = end;
752
1720
  }
@@ -871,7 +1839,7 @@ function scanAutolinksInText(text: string): AutolinkMatch[] {
871
1839
  while ((m = re.exec(text)) !== null) {
872
1840
  const url = m[1];
873
1841
  // Skip dangerous URL schemes (javascript:, vbscript:, data:text/html)
874
- if (!isSafeUrl(url)) {
1842
+ if (!isSafeMarkdownParserUrl(url)) {
875
1843
  continue;
876
1844
  }
877
1845
  matches.push({
@@ -991,26 +1959,28 @@ function scanAutolinksInText(text: string): AutolinkMatch[] {
991
1959
  * Split a text-node `content` string into a sequence of text + link nodes
992
1960
  * based on autolink matches.
993
1961
  */
994
- function splitTextOnAutolinks(content: string): InlineNode[] {
1962
+ function splitTextOnAutolinks(
1963
+ content: string,
1964
+ ): MarkdownAstPhrasingContent<RuntimeExtensionNode>[] {
995
1965
  const matches = scanAutolinksInText(content);
996
1966
  if (matches.length === 0) {
997
- return [{type: 'text', content}];
1967
+ return [{type: 'text', value: content}];
998
1968
  }
999
- const out: InlineNode[] = [];
1969
+ const out: MarkdownAstPhrasingContent<RuntimeExtensionNode>[] = [];
1000
1970
  let cursor = 0;
1001
1971
  for (const m of matches) {
1002
1972
  if (m.start > cursor) {
1003
- out.push({type: 'text', content: content.slice(cursor, m.start)});
1973
+ out.push({type: 'text', value: content.slice(cursor, m.start)});
1004
1974
  }
1005
1975
  out.push({
1006
1976
  type: 'link',
1007
- href: m.href,
1008
- children: [{type: 'text', content: m.display}],
1977
+ url: m.href,
1978
+ children: [{type: 'text', value: m.display}],
1009
1979
  });
1010
1980
  cursor = m.end;
1011
1981
  }
1012
1982
  if (cursor < content.length) {
1013
- out.push({type: 'text', content: content.slice(cursor)});
1983
+ out.push({type: 'text', value: content.slice(cursor)});
1014
1984
  }
1015
1985
  return out;
1016
1986
  }
@@ -1023,23 +1993,25 @@ function splitTextOnAutolinks(content: string): InlineNode[] {
1023
1993
  * `image` alt text, `citation`, or `break`. Runs only on the outermost
1024
1994
  * block's inline tree (see `parseInlineEntry`).
1025
1995
  */
1026
- function transformAutolinks(nodes: InlineNode[]): InlineNode[] {
1027
- const out: InlineNode[] = [];
1996
+ function transformAutolinks(
1997
+ nodes: ReadonlyArray<MarkdownAstPhrasingContent<RuntimeExtensionNode>>,
1998
+ ): MarkdownAstPhrasingContent<RuntimeExtensionNode>[] {
1999
+ const out: MarkdownAstPhrasingContent<RuntimeExtensionNode>[] = [];
1028
2000
  for (const node of nodes) {
1029
2001
  if (node.type === 'text') {
1030
- const split = splitTextOnAutolinks(node.content);
2002
+ const split = splitTextOnAutolinks(node.value);
1031
2003
  for (const seg of split) {
1032
2004
  const last = out[out.length - 1];
1033
2005
  if (seg.type === 'text' && last?.type === 'text') {
1034
- last.content += seg.content;
2006
+ out[out.length - 1] = {...last, value: last.value + seg.value};
1035
2007
  } else {
1036
2008
  out.push(seg);
1037
2009
  }
1038
2010
  }
1039
2011
  } else if (
1040
- node.type === 'bold' ||
1041
- node.type === 'italic' ||
1042
- node.type === 'strikethrough'
2012
+ node.type === 'strong' ||
2013
+ node.type === 'emphasis' ||
2014
+ node.type === 'delete'
1043
2015
  ) {
1044
2016
  out.push({...node, children: transformAutolinks(node.children)});
1045
2017
  } else {
@@ -1122,7 +2094,16 @@ function isTableSeparator(line: string): boolean {
1122
2094
  * offset into it would not address the document.
1123
2095
  */
1124
2096
  function nested(opts: ResolvedOptions): ResolvedOptions {
1125
- return opts.sourceRanges ? {...opts, sourceRanges: false} : opts;
2097
+ const nestedOptions =
2098
+ opts.allowBlockSyntax === false ? opts : {...opts, allowBlockSyntax: false};
2099
+ return nestedOptions.sourceRanges || nestedOptions.astPositions
2100
+ ? {...nestedOptions, sourceRanges: false, astPositions: false}
2101
+ : nestedOptions;
2102
+ }
2103
+
2104
+ function blockExtensionColumn(line: string): number | null {
2105
+ const indentation = line.length - line.trimStart().length;
2106
+ return indentation <= 3 ? indentation : null;
1126
2107
  }
1127
2108
 
1128
2109
  /**
@@ -1198,10 +2179,10 @@ function parseTable(
1198
2179
  lines: string[],
1199
2180
  lineIndex: number,
1200
2181
  opts: ResolvedOptions,
1201
- ): {node: BlockNode; nextIndex: number} {
1202
- const headers: TableCellNode[] = splitTableRow(lines[lineIndex]).map(
1203
- cell => ({children: parseInlineEntry(cell, opts)}),
1204
- );
2182
+ ): {node: MarkdownAstBlockContent<RuntimeExtensionNode>; nextIndex: number} {
2183
+ const headers: MarkdownAstTableCell<RuntimeExtensionNode>[] = splitTableRow(
2184
+ lines[lineIndex],
2185
+ ).map(cell => ({type: 'tableCell', children: parseInlineEntry(cell, opts)}));
1205
2186
  const alignments: TableAlignment[] = splitTableRow(lines[lineIndex + 1]).map(
1206
2187
  cell => {
1207
2188
  const trimmed = cell.trim();
@@ -1216,22 +2197,32 @@ function parseTable(
1216
2197
  : null;
1217
2198
  },
1218
2199
  );
1219
- const rows: TableCellNode[][] = [];
2200
+ const rows: MarkdownAstTableRow<RuntimeExtensionNode>[] = [];
1220
2201
  let rowIndex = lineIndex + 2;
1221
2202
  while (
1222
2203
  rowIndex < lines.length &&
1223
2204
  lines[rowIndex].includes('|') &&
1224
2205
  lines[rowIndex].trim() !== ''
1225
2206
  ) {
1226
- rows.push(
1227
- splitTableRow(lines[rowIndex]).map(cell => ({
2207
+ rows.push({
2208
+ type: 'tableRow',
2209
+ children: splitTableRow(lines[rowIndex]).map(cell => ({
2210
+ type: 'tableCell',
1228
2211
  children: parseInlineEntry(cell, opts),
1229
2212
  })),
1230
- );
2213
+ });
1231
2214
  rowIndex++;
1232
2215
  }
2216
+ const header: MarkdownAstTableRow<RuntimeExtensionNode> = {
2217
+ type: 'tableRow',
2218
+ children: headers,
2219
+ };
1233
2220
  return {
1234
- node: {type: 'table', headers, alignments, rows},
2221
+ node: {
2222
+ type: 'table',
2223
+ align: alignments,
2224
+ children: [header, ...rows],
2225
+ },
1235
2226
  nextIndex: rowIndex,
1236
2227
  };
1237
2228
  }
@@ -1241,8 +2232,8 @@ function parseList(
1241
2232
  startIndex: number,
1242
2233
  ordered: boolean,
1243
2234
  opts: ResolvedOptions,
1244
- ): {node: BlockNode; nextIndex: number} {
1245
- const items: ListItemNode[] = [];
2235
+ ): {node: MarkdownAstBlockContent<RuntimeExtensionNode>; nextIndex: number} {
2236
+ const items: MarkdownAstListItem<RuntimeExtensionNode>[] = [];
1246
2237
  const baseIndent = getIndent(lines[startIndex]);
1247
2238
  // Ordered lists may use either '.' or ')' as the marker delimiter
1248
2239
  // (CommonMark 5.2). Capture which one this list starts with so its items
@@ -1296,7 +2287,11 @@ function parseList(
1296
2287
  itemText += '\n' + deindented.join('\n');
1297
2288
  }
1298
2289
 
1299
- items.push({checked, children: parseMarkdownImpl(itemText, nested(opts))});
2290
+ items.push({
2291
+ type: 'listItem',
2292
+ checked,
2293
+ children: parseMarkdownImpl(itemText, nested(opts)),
2294
+ });
1300
2295
 
1301
2296
  // CommonMark loose list: blank line(s) between items of the same style
1302
2297
  // and indent still form one list. Skip the blanks and continue if the
@@ -1314,17 +2309,15 @@ function parseList(
1314
2309
  index = lookahead;
1315
2310
  }
1316
2311
  }
1317
- return {
1318
- node: {
1319
- type: 'list',
1320
- ordered,
1321
- start,
1322
- delimiter: ordered ? (delim as '.' | ')') : undefined,
1323
- loose: loose || undefined,
1324
- items,
1325
- },
1326
- nextIndex: index,
2312
+ const node: MarkdownAstList<RuntimeExtensionNode> = {
2313
+ type: 'list',
2314
+ ordered,
2315
+ start,
2316
+ delimiter: ordered ? (delim as '.' | ')') : undefined,
2317
+ spread: loose || undefined,
2318
+ children: items,
1327
2319
  };
2320
+ return {node, nextIndex: index};
1328
2321
  }
1329
2322
 
1330
2323
  // ---------------------------------------------------------------------------
@@ -1337,19 +2330,89 @@ export function parseMarkdown(
1337
2330
  ): BlockNode[];
1338
2331
  export function parseMarkdown(
1339
2332
  input: string,
1340
- options: ParseOptions,
2333
+ options: MathParseOptionsWithoutPlugins,
2334
+ ): BlockNodeWithMath[];
2335
+ export function parseMarkdown(
2336
+ input: string,
2337
+ options: ParseOptionsWithoutPlugins,
1341
2338
  ): BlockNode[];
2339
+ export function parseMarkdown<
2340
+ const Plugins extends ReadonlyArray<MarkdownPluginEntry>,
2341
+ >(
2342
+ input: string,
2343
+ options: ParseOptionsWithoutPlugins & {plugins: Plugins},
2344
+ ): BlockNode<MarkdownExtensionsOf<Plugins>>[];
2345
+ export function parseMarkdown<
2346
+ const Plugins extends ReadonlyArray<MarkdownPluginEntry>,
2347
+ >(
2348
+ input: string,
2349
+ options: MathParseOptionsWithoutPlugins & {plugins: Plugins},
2350
+ ): BlockNodeWithMath<MarkdownExtensionsOf<Plugins>>[];
1342
2351
  export function parseMarkdown(
1343
2352
  input: string,
1344
- arg?: ReadonlySet<string> | ParseOptions,
1345
- ): BlockNode[] {
1346
- return parseMarkdownImpl(input, resolveOptions(arg));
2353
+ arg?: ReadonlySet<string> | RuntimeParseOptions,
2354
+ ): RuntimeBlockNode[] {
2355
+ return projectMarkdownRoot(
2356
+ parseMarkdownAstInternal(input, arg, true),
2357
+ wantsLegacyRanges(arg),
2358
+ );
2359
+ }
2360
+
2361
+ /**
2362
+ * Parse block Markdown into the canonical immutable Astryx AST.
2363
+ *
2364
+ * Unlike `parseMarkdown`, this returns the MDAST-aligned root used by Markdown,
2365
+ * transforms, and Outline. Math nodes are present only when `math: true`, and
2366
+ * plugin extension nodes are inferred from the ordered `plugins` tuple.
2367
+ */
2368
+ export function parseMarkdownAst(
2369
+ input: string,
2370
+ sourceIds?: ReadonlySet<string>,
2371
+ ): MarkdownAstRoot;
2372
+ export function parseMarkdownAst(
2373
+ input: string,
2374
+ options: ParseOptionsWithoutPlugins | MathParseOptionsWithoutPlugins,
2375
+ ): MarkdownAstRoot;
2376
+ export function parseMarkdownAst<
2377
+ const Plugins extends ReadonlyArray<MarkdownPluginEntry>,
2378
+ >(
2379
+ input: string,
2380
+ options: (ParseOptionsWithoutPlugins | MathParseOptionsWithoutPlugins) & {
2381
+ plugins: Plugins;
2382
+ },
2383
+ ): MarkdownAstRoot<MarkdownExtensionsOf<Plugins>>;
2384
+ export function parseMarkdownAst(
2385
+ input: string,
2386
+ arg?: ReadonlySet<string> | RuntimeParseOptions,
2387
+ ): MarkdownAstRoot<RuntimeExtensionNode> {
2388
+ return parseMarkdownAstInternal(input, arg, true);
2389
+ }
2390
+
2391
+ /** @internal Full-parse entry point with streaming finality for Outline. */
2392
+ export function parseMarkdownAstInternal(
2393
+ input: string,
2394
+ arg: ReadonlySet<string> | RuntimeParseOptions | undefined,
2395
+ isFinal: boolean,
2396
+ ): MarkdownAstRoot<RuntimeExtensionNode> {
2397
+ const resolved = resolveOptions(arg);
2398
+ const opts = isFinal ? resolved : {...resolved, isFinal: false};
2399
+ const root: MarkdownAstRoot<RuntimeExtensionNode> = {
2400
+ type: 'root',
2401
+ children: parseMarkdownImpl(input, opts),
2402
+ };
2403
+ return applyMarkdownTransforms(
2404
+ root,
2405
+ opts.plugins,
2406
+ input,
2407
+ opts.isFinal,
2408
+ 'block',
2409
+ );
1347
2410
  }
1348
2411
 
1349
2412
  function parseMarkdownImpl(
1350
2413
  input: string,
1351
2414
  baseOpts: ResolvedOptions,
1352
- ): BlockNode[] {
2415
+ ): MarkdownAstBlockContent<RuntimeExtensionNode>[] {
1353
2416
  // Collect this input's link reference definitions and strip their lines,
1354
2417
  // then merge them with any definitions inherited from an enclosing parse
1355
2418
  // (the incremental parser passes the whole document's definitions in; a
@@ -1357,7 +2420,7 @@ function parseMarkdownImpl(
1357
2420
  // definitions win on conflict, matching CommonMark's first-definition-wins
1358
2421
  // in document order; locally-nested definitions still resolve within this
1359
2422
  // parse.
1360
- const {defs, cleaned, lineMap} = extractLinkDefinitions(input);
2423
+ const {defs, cleaned, lineMap} = extractLinkDefinitions(input, baseOpts.math);
1361
2424
  const inherited = baseOpts.linkDefs;
1362
2425
  let linkDefs: ReadonlyMap<string, string> | undefined;
1363
2426
  if (defs.size === 0) {
@@ -1370,18 +2433,35 @@ function parseMarkdownImpl(
1370
2433
  const opts: ResolvedOptions =
1371
2434
  linkDefs != null ? {...baseOpts, linkDefs} : baseOpts;
1372
2435
  const lines = cleaned.split('\n');
1373
- const blocks: BlockNode[] = [];
2436
+ const hasBlockExtensionSyntax =
2437
+ opts.allowBlockSyntax !== false &&
2438
+ (opts.plugins?.blockByFirstCharacter.size ?? 0) > 0;
2439
+ // Derived from the already-split lines rather than a second character
2440
+ // scan: every line contributes its own length plus the newline it ended on.
2441
+ const lineOffsets = [0];
2442
+ if (opts.astPositions === true || hasBlockExtensionSyntax) {
2443
+ let offset = 0;
2444
+ for (let index = 0; index < lines.length; index++) {
2445
+ offset += lines[index].length + 1;
2446
+ lineOffsets.push(offset);
2447
+ }
2448
+ }
2449
+ const blockExtensionMatches = new Map<number, ExtensionMatch>();
2450
+ const blocks: MarkdownAstBlockContent<RuntimeExtensionNode>[] = [];
1374
2451
  // The line each block started on, parallel to `blocks`. Only collected when
1375
2452
  // ranges were asked for; a block's end is resolved after the loop, since the
1376
2453
  // branch that produced it has already moved `index` past whatever it read.
1377
- const blockStartLines: number[] | null = opts.sourceRanges ? [] : null;
2454
+ const blockStartLines: number[] | null =
2455
+ opts.astPositions === true ? [] : null;
1378
2456
  // Set only by a block that consumes blank lines as content, where the
1379
2457
  // positional end derivation would trim them away.
1380
- const blockEndLines: (number | undefined)[] | null = opts.sourceRanges
1381
- ? []
1382
- : null;
2458
+ const blockEndLines: (number | undefined)[] | null =
2459
+ opts.astPositions === true ? [] : null;
1383
2460
  let blockStartLine = 0;
1384
- const pushBlock = (node: BlockNode, endLine?: number) => {
2461
+ const pushBlock = (
2462
+ node: MarkdownAstBlockContent<RuntimeExtensionNode>,
2463
+ endLine?: number,
2464
+ ) => {
1385
2465
  blocks.push(node);
1386
2466
  blockStartLines?.push(blockStartLine);
1387
2467
  blockEndLines?.push(endLine);
@@ -1397,10 +2477,16 @@ function parseMarkdownImpl(
1397
2477
  }
1398
2478
 
1399
2479
  // --- Fenced code block ---
1400
- const fenceMatch = line.match(/^(`{3,}|~{3,})(\w*)/);
2480
+ const fenceMatch = line.match(/^(`{3,}|~{3,})/);
1401
2481
  if (fenceMatch) {
1402
2482
  const fence = fenceMatch[1];
1403
- const language = fenceMatch[2] || 'plaintext';
2483
+ const info = line.slice(fence.length);
2484
+ const language = info.match(/^(\S+)/)?.[1] ?? null;
2485
+ const legacyLanguage = info.match(/^(\w*)/)?.[1] || null;
2486
+ const meta =
2487
+ language == null
2488
+ ? undefined
2489
+ : info.slice(language.length).trim() || undefined;
1404
2490
  const codeLines: string[] = [];
1405
2491
  index++;
1406
2492
  while (index < lines.length && !lines[index].startsWith(fence)) {
@@ -1412,18 +2498,39 @@ function parseMarkdownImpl(
1412
2498
  // can end on them, so it states its own end rather than letting the
1413
2499
  // positional derivation trim them off.
1414
2500
  pushBlock(
1415
- {type: 'codeblock', language, content: codeLines.join('\n')},
2501
+ markMarkdownAstLegacyCodeLanguage(
2502
+ {
2503
+ type: 'code',
2504
+ lang: language,
2505
+ ...(meta == null ? {} : {meta}),
2506
+ value: codeLines.join('\n'),
2507
+ },
2508
+ legacyLanguage,
2509
+ ),
1416
2510
  Math.min(index, lines.length) - 1,
1417
2511
  );
1418
2512
  continue;
1419
2513
  }
1420
2514
 
2515
+ // --- Display math (opt-in; fenced code takes precedence) ---
2516
+ if (opts.math) {
2517
+ const displayMath = matchDisplayMathBlock(lines, index);
2518
+ if (displayMath != null) {
2519
+ pushBlock(
2520
+ {type: 'math', value: displayMath.value},
2521
+ displayMath.endLine,
2522
+ );
2523
+ index = displayMath.nextIndex;
2524
+ continue;
2525
+ }
2526
+ }
2527
+
1421
2528
  // --- Heading ---
1422
2529
  const headingMatch = line.match(/^(#{1,6}) +(.*)/);
1423
2530
  if (headingMatch) {
1424
2531
  pushBlock({
1425
2532
  type: 'heading',
1426
- level: headingMatch[1].length as 1 | 2 | 3 | 4 | 5 | 6,
2533
+ depth: headingMatch[1].length as 1 | 2 | 3 | 4 | 5 | 6,
1427
2534
  children: parseInlineEntry(headingMatch[2], opts),
1428
2535
  });
1429
2536
  index++;
@@ -1432,7 +2539,7 @@ function parseMarkdownImpl(
1432
2539
 
1433
2540
  // --- HR (must precede list check to handle `- - -`, `* * *`, `_ _ _`) ---
1434
2541
  if (isHorizontalRule(line)) {
1435
- pushBlock({type: 'hr'});
2542
+ pushBlock({type: 'thematicBreak'});
1436
2543
  index++;
1437
2544
  continue;
1438
2545
  }
@@ -1444,9 +2551,9 @@ function parseMarkdownImpl(
1444
2551
  if (
1445
2552
  imageMatch &&
1446
2553
  line.trim() === imageMatch[0] &&
1447
- isSafeUrl(imageMatch[2])
2554
+ isSafeMarkdownParserUrl(imageMatch[2])
1448
2555
  ) {
1449
- pushBlock({type: 'image', alt: imageMatch[1], src: imageMatch[2]});
2556
+ pushBlock({type: 'image', alt: imageMatch[1], url: imageMatch[2]});
1450
2557
  index++;
1451
2558
  continue;
1452
2559
  }
@@ -1496,15 +2603,80 @@ function parseMarkdownImpl(
1496
2603
  continue;
1497
2604
  }
1498
2605
 
2606
+ // --- Extension block syntax (built-in blocks take precedence) ---
2607
+ if (hasBlockExtensionSyntax) {
2608
+ const extensionColumn = blockExtensionColumn(line);
2609
+ const extensionOffset =
2610
+ extensionColumn == null
2611
+ ? lineOffsets[index]
2612
+ : lineOffsets[index] + extensionColumn;
2613
+ const extension =
2614
+ blockExtensionMatches.get(extensionOffset) ??
2615
+ (extensionColumn == null
2616
+ ? {status: 'none' as const}
2617
+ : matchExtensionSyntax(cleaned, extensionOffset, 'block', opts));
2618
+ blockExtensionMatches.delete(extensionOffset);
2619
+ if (extension.status === 'match') {
2620
+ const consumedEnd = extension.end;
2621
+ const consumed = cleaned.slice(lineOffsets[index], consumedEnd);
2622
+ if (
2623
+ consumedEnd < cleaned.length &&
2624
+ cleaned[consumedEnd] !== '\n' &&
2625
+ cleaned[consumedEnd - 1] !== '\n'
2626
+ ) {
2627
+ reportMarkdownPluginFailure(
2628
+ extension.node.plugin,
2629
+ 'syntax',
2630
+ new TypeError('Block syntax must consume complete lines'),
2631
+ );
2632
+ } else {
2633
+ const newlineCount = consumed.split('\n').length - 1;
2634
+ const consumedLines = Math.max(
2635
+ 1,
2636
+ newlineCount + (consumed.endsWith('\n') ? 0 : 1),
2637
+ );
2638
+ pushBlock(
2639
+ extension.node as Extract<RuntimeExtensionNode, {display: 'block'}>,
2640
+ index + consumedLines - 1,
2641
+ );
2642
+ index += consumedLines;
2643
+ continue;
2644
+ }
2645
+ }
2646
+ if (extension.status === 'defer') {
2647
+ break;
2648
+ }
2649
+ }
2650
+
1499
2651
  // --- Paragraph ---
1500
2652
  const paraLines: string[] = [line];
1501
2653
  index++;
1502
- while (
1503
- index < lines.length &&
1504
- !isBlockStart(lines[index]) &&
1505
- lines[index].trim() !== ''
1506
- ) {
1507
- paraLines.push(lines[index]);
2654
+ while (index < lines.length) {
2655
+ const nextLine = lines[index];
2656
+ if (
2657
+ isBlockStart(nextLine) ||
2658
+ (opts.math && matchDisplayMathBlock(lines, index) != null) ||
2659
+ nextLine.trim() === ''
2660
+ ) {
2661
+ break;
2662
+ }
2663
+ if (hasBlockExtensionSyntax) {
2664
+ const extensionColumn = blockExtensionColumn(nextLine);
2665
+ if (extensionColumn != null) {
2666
+ const extensionOffset = lineOffsets[index] + extensionColumn;
2667
+ const extension = matchExtensionSyntax(
2668
+ cleaned,
2669
+ extensionOffset,
2670
+ 'block',
2671
+ opts,
2672
+ );
2673
+ if (extension.status !== 'none') {
2674
+ blockExtensionMatches.set(extensionOffset, extension);
2675
+ break;
2676
+ }
2677
+ }
2678
+ }
2679
+ paraLines.push(nextLine);
1508
2680
  index++;
1509
2681
  }
1510
2682
  pushBlock({
@@ -1536,7 +2708,7 @@ function parseMarkdownImpl(
1536
2708
  * `lineMap` says which input line each surviving line came from.
1537
2709
  */
1538
2710
  function stampSourceRanges(
1539
- blocks: BlockNode[],
2711
+ blocks: MarkdownAstBlockContent<RuntimeExtensionNode>[],
1540
2712
  blockStartLines: number[],
1541
2713
  blockEndLines: (number | undefined)[],
1542
2714
  lines: string[],
@@ -1573,7 +2745,13 @@ function stampSourceRanges(
1573
2745
  // range that dropped it would slice to something that re-parses
1574
2746
  // differently.
1575
2747
  const end = lineStart(endLine) + lines[endLine].length;
1576
- blocks[i] = {...blocks[i], range: {start: lineStart(startLine), end}};
2748
+ blocks[i] = {
2749
+ ...blocks[i],
2750
+ position: {
2751
+ start: {offset: lineStart(startLine)},
2752
+ end: {offset: end},
2753
+ },
2754
+ };
1577
2755
  }
1578
2756
  }
1579
2757
 
@@ -1581,10 +2759,17 @@ function stampSourceRanges(
1581
2759
  // Incremental parsing
1582
2760
  // ---------------------------------------------------------------------------
1583
2761
 
1584
- export interface IncrementalState {
2762
+ type IncrementalBlockNode<MathEnabled extends boolean> =
2763
+ MathEnabled extends true ? BlockNodeWithMath : BlockNode;
2764
+
2765
+ declare const incrementalStateMode: unique symbol;
2766
+
2767
+ export interface IncrementalState<MathEnabled extends boolean = false> {
2768
+ /** @internal Nominally couples a factory-created cache to its node union. */
2769
+ readonly [incrementalStateMode]: MathEnabled;
1585
2770
  prevInput: string;
1586
2771
  settledText: string;
1587
- settledBlocks: BlockNode[];
2772
+ settledBlocks: IncrementalBlockNode<MathEnabled>[];
1588
2773
  settledUpTo: number;
1589
2774
  /**
1590
2775
  * The `autolink` option the cached `settledBlocks` were parsed with.
@@ -1593,12 +2778,18 @@ export interface IncrementalState {
1593
2778
  * with newly-arriving content.
1594
2779
  */
1595
2780
  autolink?: 'gfm';
2781
+ /** Whether the cached settled blocks were parsed with math enabled. */
2782
+ math?: MathEnabled;
1596
2783
  /**
1597
2784
  * The `sourceRanges` option the cached `settledBlocks` were parsed with.
1598
2785
  * Flipping it invalidates them the same way `autolink` does: they either
1599
2786
  * lack the ranges the caller now asks for, or carry ones it did not.
1600
2787
  */
1601
2788
  sourceRanges?: boolean;
2789
+ /** Identity of citation sources used by settled nodes. */
2790
+ sourceIdsKey?: string;
2791
+ /** Ordered syntax-only identity used by settled nodes. */
2792
+ pluginSyntaxIdentity?: string;
1602
2793
  /**
1603
2794
  * Signature of the link reference definitions the cached `settledBlocks`
1604
2795
  * were parsed with. Definitions are document-global and typically arrive
@@ -1629,19 +2820,36 @@ type IncrementalCache = {
1629
2820
  /** The effective document-global definitions used by slice parses. */
1630
2821
  linkDefs: ReadonlyMap<string, string>;
1631
2822
  linkDefsKey: string;
2823
+ /** Canonical settled blocks shared by rendering and compatibility projection. */
2824
+ settledAstBlocks: MarkdownAstBlockContent<RuntimeExtensionNode>[];
2825
+ settledRevision: number;
2826
+ projectedRevision: number;
2827
+ projectedSettledBlocks: RuntimeBlockNode[];
2828
+ /** Preserves released settled-node identity across projected snapshots. */
2829
+ projectionCache: LegacyProjectionCache;
1632
2830
  work: IncrementalWork;
1633
2831
  };
1634
2832
 
1635
- const incrementalCaches = new WeakMap<IncrementalState, IncrementalCache>();
2833
+ const incrementalCaches = new WeakMap<
2834
+ IncrementalState<boolean>,
2835
+ IncrementalCache
2836
+ >();
1636
2837
 
1637
- function makeIncrementalCache(state: IncrementalState): IncrementalCache {
1638
- const {defs} = extractLinkDefinitions(state.settledText);
2838
+ function makeIncrementalCache(
2839
+ state: IncrementalState<boolean>,
2840
+ ): IncrementalCache {
2841
+ const {defs} = extractLinkDefinitions(state.settledText, state.math);
1639
2842
  const cache: IncrementalCache = {
1640
2843
  settledEnd: state.settledText.length,
1641
2844
  settledLinkDefs: new Map(defs),
1642
2845
  tailLinkDefs: new Map(),
1643
2846
  linkDefs: defs,
1644
2847
  linkDefsKey: linkDefsSignature(defs),
2848
+ settledAstBlocks: [],
2849
+ settledRevision: 0,
2850
+ projectedRevision: -1,
2851
+ projectedSettledBlocks: [],
2852
+ projectionCache: new WeakMap(),
1645
2853
  work: {
1646
2854
  splitCharacters: 0,
1647
2855
  boundaryLines: 0,
@@ -1653,13 +2861,19 @@ function makeIncrementalCache(state: IncrementalState): IncrementalCache {
1653
2861
  return cache;
1654
2862
  }
1655
2863
 
1656
- export function createIncrementalState(): IncrementalState {
1657
- const state: IncrementalState = {
2864
+ /**
2865
+ * Create an incremental parser cache. Use the `<true>` type argument with
2866
+ * `MathParseOptions` so the cache and returned nodes share the math contract.
2867
+ */
2868
+ export function createIncrementalState<
2869
+ MathEnabled extends boolean = false,
2870
+ >(): IncrementalState<MathEnabled> {
2871
+ const state = {
1658
2872
  prevInput: '',
1659
2873
  settledText: '',
1660
2874
  settledBlocks: [],
1661
2875
  settledUpTo: 0,
1662
- };
2876
+ } as unknown as IncrementalState<MathEnabled>;
1663
2877
  makeIncrementalCache(state);
1664
2878
  return state;
1665
2879
  }
@@ -1669,7 +2883,7 @@ export function createIncrementalState(): IncrementalState {
1669
2883
  * @internal Exported from this module for performance regression tests only.
1670
2884
  */
1671
2885
  export function getIncrementalParseWork(
1672
- state: IncrementalState,
2886
+ state: IncrementalState<boolean>,
1673
2887
  ): IncrementalWork {
1674
2888
  return (
1675
2889
  incrementalCaches.get(state)?.work ?? {
@@ -1695,47 +2909,91 @@ export function getIncrementalParseWork(
1695
2909
  * the boundary to -1 even though the content before the fence opened cannot be
1696
2910
  * changed by anything typed inside it.
1697
2911
  */
1698
- function findSettledBoundary(lines: string[]): {
2912
+ function findSettledBoundary(
2913
+ lines: string[],
2914
+ math = false,
2915
+ ): {
1699
2916
  boundary: number;
1700
2917
  openFence: boolean;
2918
+ openMath: boolean;
1701
2919
  } {
1702
2920
  let inFence = false;
1703
2921
  let fenceMarker = '';
2922
+ let mathContainer: DisplayMathContainer | null = null;
2923
+ let suppressMathUntilBoundary = false;
1704
2924
  let lastBoundary = -1;
1705
2925
  let boundaryBeforeFence = -1;
2926
+ let boundaryBeforeMath = -1;
1706
2927
 
1707
2928
  for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
1708
2929
  const line = lines[lineIndex];
1709
2930
 
1710
- // Fence open / close — match the specific marker character and length
1711
- const fenceMatch = line.match(/^(`{3,}|~{3,})/);
1712
- if (fenceMatch) {
1713
- if (!inFence) {
1714
- inFence = true;
1715
- fenceMarker = fenceMatch[1];
1716
- boundaryBeforeFence = lastBoundary;
1717
- } else if (
2931
+ if (inFence) {
2932
+ const fenceMatch = line.match(/^(`{3,}|~{3,})/);
2933
+ if (
2934
+ fenceMatch &&
1718
2935
  fenceMatch[1].startsWith(fenceMarker[0]) &&
1719
2936
  fenceMatch[1].length >= fenceMarker.length
1720
2937
  ) {
1721
2938
  inFence = false;
1722
2939
  fenceMarker = '';
1723
2940
  }
2941
+ continue;
1724
2942
  }
1725
2943
 
1726
- if (
1727
- !inFence &&
1728
- line.trim() === '' &&
1729
- lineIndex > 0 &&
1730
- lineIndex < lines.length - 1
1731
- ) {
1732
- lastBoundary = lineIndex;
2944
+ if (mathContainer != null) {
2945
+ const state = displayMathLineState(line, mathContainer);
2946
+ if (state === 'close') {
2947
+ mathContainer = null;
2948
+ continue;
2949
+ }
2950
+ if (state === 'inside') {
2951
+ continue;
2952
+ }
2953
+ // The list item or blockquote ended before a closer arrived. The parser
2954
+ // treats that unmatched opener literally, so resume ordinary boundary
2955
+ // detection on this first line outside the container.
2956
+ mathContainer = null;
2957
+ suppressMathUntilBoundary = true;
2958
+ }
2959
+
2960
+ // A complete same-line `$$…$$` expression never changes boundary state.
2961
+ // A standalone marker may belong to the top level, a blockquote, or one
2962
+ // list item; remember that container so its continuation marker closes the
2963
+ // same expression instead of opening a new one.
2964
+ if (math && !suppressMathUntilBoundary) {
2965
+ const container = displayMathContainer(line);
2966
+ if (container != null) {
2967
+ mathContainer = container;
2968
+ boundaryBeforeMath = lastBoundary;
2969
+ continue;
2970
+ }
2971
+ }
2972
+
2973
+ const fenceMatch = line.match(/^(`{3,}|~{3,})/);
2974
+ if (fenceMatch) {
2975
+ inFence = true;
2976
+ fenceMarker = fenceMatch[1];
2977
+ boundaryBeforeFence = lastBoundary;
2978
+ continue;
2979
+ }
2980
+
2981
+ if (line.trim() === '') {
2982
+ suppressMathUntilBoundary = false;
2983
+ if (lineIndex > 0 && lineIndex < lines.length - 1) {
2984
+ lastBoundary = lineIndex;
2985
+ }
1733
2986
  }
1734
2987
  }
1735
2988
 
1736
2989
  return {
1737
- boundary: inFence ? boundaryBeforeFence : lastBoundary,
2990
+ boundary: inFence
2991
+ ? boundaryBeforeFence
2992
+ : mathContainer != null
2993
+ ? boundaryBeforeMath
2994
+ : lastBoundary,
1738
2995
  openFence: inFence,
2996
+ openMath: mathContainer != null,
1739
2997
  };
1740
2998
  }
1741
2999
 
@@ -1743,10 +3001,55 @@ function findSettledBoundary(lines: string[]): {
1743
3001
  * Strip trailing incomplete inline syntax that appears during streaming.
1744
3002
  * Only affects the tail of the last line — safe to apply to the full string.
1745
3003
  */
1746
- export function trimStreamingArtifacts(input: string): string {
1747
- const lastNL = input.lastIndexOf('\n');
1748
- const prefix = lastNL === -1 ? '' : input.slice(0, lastNL + 1);
1749
- let tail = lastNL === -1 ? input : input.slice(lastNL + 1);
3004
+ export function trimStreamingArtifacts(
3005
+ input: string,
3006
+ options?: {math?: boolean},
3007
+ ): string {
3008
+ // First remove an incomplete display expression as one structural unit. This
3009
+ // full-input scan distinguishes a terminal nested closer from a new opener;
3010
+ // looking only at the final `$$` line cannot.
3011
+ const displayTrimmed = options?.math ? trimOpenDisplayMath(input) : input;
3012
+ const lastNL = displayTrimmed.lastIndexOf('\n');
3013
+ const prefix = lastNL === -1 ? '' : displayTrimmed.slice(0, lastNL + 1);
3014
+ let tail = lastNL === -1 ? displayTrimmed : displayTrimmed.slice(lastNL + 1);
3015
+
3016
+ if (options?.math) {
3017
+ // Hold an unmatched inline opener so raw TeX syntax does not flash while
3018
+ // streaming. If another unescaped dollar is already present but fails the
3019
+ // closing-boundary rule, keep both literal (the currency case).
3020
+ for (let index = 0; index < tail.length; index++) {
3021
+ if (
3022
+ tail[index] === '$' &&
3023
+ tail[index + 1] == null &&
3024
+ tail[index - 1] !== '$' &&
3025
+ !/\d/.test(tail[index - 1] ?? '') &&
3026
+ !isEscaped(tail, index)
3027
+ ) {
3028
+ tail = tail.slice(0, index);
3029
+ break;
3030
+ }
3031
+ if (!isInlineMathStart(tail, index)) {
3032
+ continue;
3033
+ }
3034
+ const close = findInlineMathEnd(tail, index);
3035
+ if (close !== -1) {
3036
+ index = close;
3037
+ continue;
3038
+ }
3039
+ let laterDollar = -1;
3040
+ for (let next = index + 1; next < tail.length; next++) {
3041
+ if (tail[next] === '$' && !isEscaped(tail, next)) {
3042
+ laterDollar = next;
3043
+ break;
3044
+ }
3045
+ }
3046
+ if (laterDollar === -1) {
3047
+ tail = tail.slice(0, index);
3048
+ break;
3049
+ }
3050
+ index = laterDollar;
3051
+ }
3052
+ }
1750
3053
 
1751
3054
  // Scan backwards for unclosed syntax markers — no regex to avoid ReDoS
1752
3055
  // Find the last unclosed [ or ![ (link/image start)
@@ -1904,6 +3207,67 @@ export function trimStreamingArtifacts(input: string): string {
1904
3207
  * Once a table is established (header + separator exist), new data rows
1905
3208
  * render immediately — no suppression.
1906
3209
  */
3210
+ function trimOpenDisplayMath(text: string): string {
3211
+ const lines = text.split('\n');
3212
+ let inFence = false;
3213
+ let fenceMarker = '';
3214
+ let mathContainer: DisplayMathContainer | null = null;
3215
+ let suppressMathUntilBoundary = false;
3216
+ let mathStartLine = -1;
3217
+
3218
+ for (let index = 0; index < lines.length; index++) {
3219
+ const line = lines[index];
3220
+ if (inFence) {
3221
+ const fence = line.match(/^(`{3,}|~{3,})/);
3222
+ if (
3223
+ fence != null &&
3224
+ fence[1].startsWith(fenceMarker[0]) &&
3225
+ fence[1].length >= fenceMarker.length
3226
+ ) {
3227
+ inFence = false;
3228
+ fenceMarker = '';
3229
+ }
3230
+ continue;
3231
+ }
3232
+ if (mathContainer != null) {
3233
+ const state = displayMathLineState(line, mathContainer);
3234
+ if (state === 'close') {
3235
+ mathContainer = null;
3236
+ mathStartLine = -1;
3237
+ continue;
3238
+ }
3239
+ if (state === 'inside') {
3240
+ continue;
3241
+ }
3242
+ mathContainer = null;
3243
+ mathStartLine = -1;
3244
+ suppressMathUntilBoundary = true;
3245
+ }
3246
+
3247
+ if (line.trim() === '') {
3248
+ suppressMathUntilBoundary = false;
3249
+ }
3250
+
3251
+ const fence = line.match(/^(`{3,}|~{3,})/);
3252
+ if (fence != null) {
3253
+ inFence = true;
3254
+ fenceMarker = fence[1];
3255
+ continue;
3256
+ }
3257
+ const container = suppressMathUntilBoundary
3258
+ ? null
3259
+ : displayMathContainer(line);
3260
+ if (container != null) {
3261
+ mathContainer = container;
3262
+ mathStartLine = index;
3263
+ }
3264
+ }
3265
+
3266
+ return mathContainer != null && mathStartLine >= 0
3267
+ ? lines.slice(0, mathStartLine).join('\n').trimEnd()
3268
+ : text;
3269
+ }
3270
+
1907
3271
  function trimUnsettledStructural(text: string): string {
1908
3272
  const lines = text.split('\n');
1909
3273
 
@@ -1970,7 +3334,7 @@ function trimUnsettledStructural(text: string): string {
1970
3334
  * rather than into the slice.
1971
3335
  */
1972
3336
  function atOffset(opts: ResolvedOptions, offset: number): ResolvedOptions {
1973
- return opts.sourceRanges ? {...opts, baseOffset: offset} : opts;
3337
+ return opts.astPositions === true ? {...opts, baseOffset: offset} : opts;
1974
3338
  }
1975
3339
 
1976
3340
  /**
@@ -1981,9 +3345,9 @@ function atOffset(opts: ResolvedOptions, offset: number): ResolvedOptions {
1981
3345
  * lists even though the full-text parser joins them per CommonMark §5.3.
1982
3346
  */
1983
3347
  function mergeSettledBlocks(
1984
- prev: BlockNode[],
1985
- delta: BlockNode[],
1986
- ): BlockNode[] {
3348
+ prev: MarkdownAstBlockContent<RuntimeExtensionNode>[],
3349
+ delta: MarkdownAstBlockContent<RuntimeExtensionNode>[],
3350
+ ): MarkdownAstBlockContent<RuntimeExtensionNode>[] {
1987
3351
  if (prev.length === 0 || delta.length === 0) {
1988
3352
  return [...prev, ...delta];
1989
3353
  }
@@ -1995,17 +3359,21 @@ function mergeSettledBlocks(
1995
3359
  prevLast.ordered === deltaFirst.ordered &&
1996
3360
  prevLast.delimiter === deltaFirst.delimiter
1997
3361
  ) {
1998
- const merged: BlockNode = {
3362
+ const merged: MarkdownAstBlockContent<RuntimeExtensionNode> = {
1999
3363
  type: 'list',
2000
3364
  ordered: prevLast.ordered,
2001
3365
  start: prevLast.start,
2002
3366
  delimiter: prevLast.delimiter,
2003
- loose: true,
2004
- items: [...prevLast.items, ...deltaFirst.items],
2005
- // One list now, so one range: from where the first half started to
2006
- // where the second half ended.
2007
- ...(prevLast.range != null && deltaFirst.range != null
2008
- ? {range: {start: prevLast.range.start, end: deltaFirst.range.end}}
3367
+ spread: true,
3368
+ children: [...prevLast.children, ...deltaFirst.children],
3369
+ // One list now, so one position spans both halves.
3370
+ ...(prevLast.position != null && deltaFirst.position != null
3371
+ ? {
3372
+ position: {
3373
+ start: prevLast.position.start,
3374
+ end: deltaFirst.position.end,
3375
+ },
3376
+ }
2009
3377
  : null),
2010
3378
  };
2011
3379
  return [...prev.slice(0, -1), merged, ...delta.slice(1)];
@@ -2014,13 +3382,16 @@ function mergeSettledBlocks(
2014
3382
  }
2015
3383
 
2016
3384
  /** Append a newly-settled slice without copying the already-settled prefix. */
2017
- function appendSettledBlocks(prev: BlockNode[], delta: BlockNode[]): void {
3385
+ function appendSettledBlocks(
3386
+ prev: MarkdownAstBlockContent<RuntimeExtensionNode>[],
3387
+ delta: MarkdownAstBlockContent<RuntimeExtensionNode>[],
3388
+ ): boolean {
2018
3389
  if (delta.length === 0) {
2019
- return;
3390
+ return false;
2020
3391
  }
2021
3392
  if (prev.length === 0) {
2022
3393
  prev.push(...delta);
2023
- return;
3394
+ return false;
2024
3395
  }
2025
3396
  const prevLast = prev[prev.length - 1];
2026
3397
  const deltaFirst = delta[0];
@@ -2035,16 +3406,22 @@ function appendSettledBlocks(prev: BlockNode[], delta: BlockNode[]): void {
2035
3406
  ordered: prevLast.ordered,
2036
3407
  start: prevLast.start,
2037
3408
  delimiter: prevLast.delimiter,
2038
- loose: true,
2039
- items: [...prevLast.items, ...deltaFirst.items],
2040
- ...(prevLast.range != null && deltaFirst.range != null
2041
- ? {range: {start: prevLast.range.start, end: deltaFirst.range.end}}
3409
+ spread: true,
3410
+ children: [...prevLast.children, ...deltaFirst.children],
3411
+ ...(prevLast.position != null && deltaFirst.position != null
3412
+ ? {
3413
+ position: {
3414
+ start: prevLast.position.start,
3415
+ end: deltaFirst.position.end,
3416
+ },
3417
+ }
2042
3418
  : null),
2043
3419
  };
2044
3420
  prev.push(...delta.slice(1));
2045
- return;
3421
+ return true;
2046
3422
  }
2047
3423
  prev.push(...delta);
3424
+ return false;
2048
3425
  }
2049
3426
 
2050
3427
  function sameUnsettledDefinitions(
@@ -2066,7 +3443,7 @@ function sameUnsettledDefinitions(
2066
3443
  }
2067
3444
 
2068
3445
  function resetIncrementalCache(
2069
- state: IncrementalState,
3446
+ state: IncrementalState<boolean>,
2070
3447
  cache: IncrementalCache,
2071
3448
  ): void {
2072
3449
  state.prevInput = '';
@@ -2074,11 +3451,19 @@ function resetIncrementalCache(
2074
3451
  state.settledBlocks = [];
2075
3452
  state.settledUpTo = 0;
2076
3453
  state.linkDefsKey = undefined;
3454
+ state.sourceIdsKey = undefined;
3455
+ state.pluginSyntaxIdentity = undefined;
3456
+ state.math = undefined;
2077
3457
  cache.settledEnd = 0;
2078
3458
  cache.settledLinkDefs.clear();
2079
3459
  cache.tailLinkDefs = new Map();
2080
3460
  cache.linkDefs = new Map();
2081
3461
  cache.linkDefsKey = '';
3462
+ cache.settledAstBlocks = [];
3463
+ cache.settledRevision++;
3464
+ cache.projectedRevision = -1;
3465
+ cache.projectedSettledBlocks = [];
3466
+ cache.projectionCache = new WeakMap();
2082
3467
  cache.work = {
2083
3468
  splitCharacters: 0,
2084
3469
  boundaryLines: 0,
@@ -2100,39 +3485,108 @@ function resetIncrementalCache(
2100
3485
  */
2101
3486
  export function parseMarkdownIncremental(
2102
3487
  input: string,
2103
- state: IncrementalState,
3488
+ state: IncrementalState<false>,
2104
3489
  sourceIds?: ReadonlySet<string>,
2105
3490
  ): BlockNode[];
2106
3491
  export function parseMarkdownIncremental(
2107
3492
  input: string,
2108
- state: IncrementalState,
2109
- options: ParseOptions,
3493
+ state: IncrementalState<true>,
3494
+ options: IncrementalMathParseOptionsWithoutPlugins,
3495
+ ): BlockNodeWithMath[];
3496
+ export function parseMarkdownIncremental(
3497
+ input: string,
3498
+ state: IncrementalState<false>,
3499
+ options: IncrementalParseOptionsWithoutPlugins,
2110
3500
  ): BlockNode[];
3501
+ export function parseMarkdownIncremental<
3502
+ const Plugins extends ReadonlyArray<MarkdownPluginEntry>,
3503
+ >(
3504
+ input: string,
3505
+ state: IncrementalState<false>,
3506
+ options: IncrementalParseOptionsWithoutPlugins & {plugins: Plugins},
3507
+ ): BlockNode<MarkdownExtensionsOf<Plugins>>[];
3508
+ export function parseMarkdownIncremental<
3509
+ const Plugins extends ReadonlyArray<MarkdownPluginEntry>,
3510
+ >(
3511
+ input: string,
3512
+ state: IncrementalState<true>,
3513
+ options: IncrementalMathParseOptionsWithoutPlugins & {plugins: Plugins},
3514
+ ): BlockNodeWithMath<MarkdownExtensionsOf<Plugins>>[];
2111
3515
  export function parseMarkdownIncremental(
2112
3516
  input: string,
2113
- state: IncrementalState,
2114
- arg?: ReadonlySet<string> | ParseOptions,
2115
- ): BlockNode[] {
2116
- const opts = resolveOptions(arg);
3517
+ state: IncrementalState<boolean>,
3518
+ arg?: ReadonlySet<string> | RuntimeParseOptions,
3519
+ ): RuntimeBlockNode[] {
3520
+ const withRanges = wantsLegacyRanges(arg);
3521
+ const root = parseMarkdownAstIncremental(input, state, arg);
3522
+ const cache = incrementalCaches.get(state) ?? makeIncrementalCache(state);
3523
+ const projected = projectMarkdownRoot(
3524
+ root,
3525
+ withRanges,
3526
+ cache.projectionCache,
3527
+ );
3528
+ if (cache.projectedRevision !== cache.settledRevision) {
3529
+ cache.projectedSettledBlocks = cache.settledAstBlocks.map(block =>
3530
+ projectBlockNode(block, cache.projectionCache, withRanges),
3531
+ );
3532
+ cache.projectedRevision = cache.settledRevision;
3533
+ }
3534
+ state.settledBlocks =
3535
+ cache.projectedSettledBlocks as typeof state.settledBlocks;
3536
+ return projected;
3537
+ }
3538
+
3539
+ /** @internal Canonical incremental parse used by Markdown rendering. */
3540
+ export function parseMarkdownAstIncremental(
3541
+ input: string,
3542
+ state: IncrementalState<boolean>,
3543
+ arg?: ReadonlySet<string> | RuntimeParseOptions,
3544
+ ): MarkdownAstRoot<RuntimeExtensionNode> {
3545
+ const opts = resolveOptions(arg, true);
3546
+ const root: MarkdownAstRoot<RuntimeExtensionNode> = {
3547
+ type: 'root',
3548
+ children: parseMarkdownIncrementalAstBlocks(input, state, opts),
3549
+ };
3550
+ return applyMarkdownTransforms(
3551
+ root,
3552
+ opts.plugins,
3553
+ input,
3554
+ opts.isFinal,
3555
+ 'block',
3556
+ );
3557
+ }
3558
+
3559
+ function parseMarkdownIncrementalAstBlocks(
3560
+ input: string,
3561
+ state: IncrementalState<boolean>,
3562
+ opts: ResolvedOptions,
3563
+ ): MarkdownAstBlockContent<RuntimeExtensionNode>[] {
2117
3564
  const cache = incrementalCaches.get(state) ?? makeIncrementalCache(state);
2118
3565
  let reparseSettled = false;
2119
3566
 
2120
- // Invalidate cache when the autolink or sourceRanges option flips — cached
3567
+ const nextSourceIdsKey = sourceIdsSignature(opts.sourceIds);
3568
+ const nextPluginSyntaxIdentity = opts.plugins?.syntaxIdentity ?? '';
3569
+ // Invalidate cache when an option that changes parsed nodes flips — cached
2121
3570
  // settled blocks were parsed with the previous setting and would otherwise
2122
3571
  // be reused unchanged.
2123
3572
  if (
2124
3573
  state.autolink !== opts.autolink ||
2125
- Boolean(state.sourceRanges) !== Boolean(opts.sourceRanges)
3574
+ state.math !== opts.math ||
3575
+ Boolean(state.sourceRanges) !== Boolean(opts.sourceRanges) ||
3576
+ state.sourceIdsKey !== nextSourceIdsKey ||
3577
+ state.pluginSyntaxIdentity !== nextPluginSyntaxIdentity
2126
3578
  ) {
2127
3579
  reparseSettled = true;
2128
3580
  state.autolink = opts.autolink;
3581
+ state.math = opts.math;
2129
3582
  state.sourceRanges = opts.sourceRanges;
3583
+ state.sourceIdsKey = nextSourceIdsKey;
3584
+ state.pluginSyntaxIdentity = nextPluginSyntaxIdentity;
2130
3585
  }
2131
3586
  if (input === '') {
2132
3587
  resetIncrementalCache(state, cache);
2133
3588
  return [];
2134
3589
  }
2135
-
2136
3590
  // The settled prefix is only reusable while the input still contains it
2137
3591
  // verbatim. Lengths alone cannot tell: a same-length or longer replacement
2138
3592
  // (new args after reusing a state) disagrees with the prefix without ever
@@ -2147,7 +3601,10 @@ export function parseMarkdownIncremental(
2147
3601
  ) {
2148
3602
  resetIncrementalCache(state, cache);
2149
3603
  state.autolink = opts.autolink;
3604
+ state.math = opts.math;
2150
3605
  state.sourceRanges = opts.sourceRanges;
3606
+ state.sourceIdsKey = nextSourceIdsKey;
3607
+ state.pluginSyntaxIdentity = nextPluginSyntaxIdentity;
2151
3608
  }
2152
3609
 
2153
3610
  // The recurring parse costs are confined to the mutable suffix: splitting,
@@ -2156,7 +3613,10 @@ export function parseMarkdownIncremental(
2156
3613
  const oldSettledEnd = cache.settledEnd;
2157
3614
  const tailRaw = input.slice(oldSettledEnd);
2158
3615
  const tailLines = tailRaw.split('\n');
2159
- const {boundary, openFence} = findSettledBoundary(tailLines);
3616
+ const {boundary, openFence, openMath} = findSettledBoundary(
3617
+ tailLines,
3618
+ opts.math,
3619
+ );
2160
3620
  const settledDelta =
2161
3621
  boundary >= 0 ? tailLines.slice(0, boundary).join('\n') : '';
2162
3622
  const nextSettledEnd = oldSettledEnd + settledDelta.length;
@@ -2168,7 +3628,7 @@ export function parseMarkdownIncremental(
2168
3628
  // blocks: document-global references may precede their footer definition.
2169
3629
  let definitionsChanged = false;
2170
3630
  if (settledDelta !== '') {
2171
- const {defs: deltaDefs} = extractLinkDefinitions(settledDelta);
3631
+ const {defs: deltaDefs} = extractLinkDefinitions(settledDelta, opts.math);
2172
3632
  for (const [label, destination] of deltaDefs) {
2173
3633
  if (!cache.settledLinkDefs.has(label)) {
2174
3634
  cache.settledLinkDefs.set(label, destination);
@@ -2178,7 +3638,10 @@ export function parseMarkdownIncremental(
2178
3638
  }
2179
3639
  }
2180
3640
  }
2181
- const {defs: tailLinkDefs} = extractLinkDefinitions(unsettledInput);
3641
+ const {defs: tailLinkDefs} = extractLinkDefinitions(
3642
+ unsettledInput,
3643
+ opts.math,
3644
+ );
2182
3645
  if (
2183
3646
  !sameUnsettledDefinitions(
2184
3647
  cache.tailLinkDefs,
@@ -2209,36 +3672,69 @@ export function parseMarkdownIncremental(
2209
3672
  cache.settledEnd = nextSettledEnd;
2210
3673
  }
2211
3674
 
2212
- const unsettledRaw = unsettledInput.trim();
3675
+ const trimmedUnsettledInput = unsettledInput.trim();
3676
+ // String#trim removes the CR that belongs to the final content line of a
3677
+ // CRLF snapshot along with trailing blank lines. Keep that one byte so
3678
+ // source ranges and delimiter content remain identical to a full parse.
3679
+ const unsettledRaw =
3680
+ trimmedUnsettledInput !== '' && /\r(?:\n[\s]*)?$/.test(unsettledInput)
3681
+ ? `${trimmedUnsettledInput}\r`
3682
+ : trimmedUnsettledInput;
2213
3683
  // Structural trimming holds back lines that look like an incomplete list or
2214
3684
  // table, which inside a fence is ordinary code: a TypeScript union or a `- `
2215
3685
  // would disappear from the code block as it streams.
2216
3686
  const unsettledText = openFence
2217
3687
  ? unsettledRaw
2218
- : trimUnsettledStructural(unsettledRaw);
3688
+ : openMath
3689
+ ? trimOpenDisplayMath(unsettledRaw)
3690
+ : trimUnsettledStructural(unsettledRaw);
2219
3691
 
3692
+ const legacyRanges = opts.sourceRanges === true;
2220
3693
  let parsedSettledBlocks = 0;
2221
3694
  if (reparseSettled) {
2222
- state.settledBlocks = state.settledText
3695
+ cache.settledAstBlocks = state.settledText
2223
3696
  ? parseMarkdownImpl(state.settledText, parseOpts)
2224
3697
  : [];
2225
- parsedSettledBlocks = state.settledBlocks.length;
3698
+ parsedSettledBlocks = cache.settledAstBlocks.length;
3699
+ cache.settledRevision++;
3700
+ cache.projectedSettledBlocks = cache.settledAstBlocks.map(block =>
3701
+ projectBlockNode(block, cache.projectionCache, legacyRanges),
3702
+ );
3703
+ cache.projectedRevision = cache.settledRevision;
2226
3704
  } else if (settledDelta !== '') {
2227
3705
  const deltaBlocks = parseMarkdownImpl(
2228
3706
  settledDelta,
2229
3707
  atOffset(parseOpts, oldSettledEnd),
2230
3708
  );
2231
- appendSettledBlocks(state.settledBlocks, deltaBlocks);
3709
+ const mergeIndex = cache.settledAstBlocks.length - 1;
3710
+ const mergedList = appendSettledBlocks(cache.settledAstBlocks, deltaBlocks);
2232
3711
  parsedSettledBlocks = deltaBlocks.length;
3712
+ cache.settledRevision++;
3713
+ const projectedDelta = deltaBlocks.map(block =>
3714
+ projectBlockNode(block, cache.projectionCache, legacyRanges),
3715
+ );
3716
+ if (mergedList) {
3717
+ const canonicalMerged = cache.settledAstBlocks[mergeIndex];
3718
+ const projectedMerged = projectBlockNode(
3719
+ canonicalMerged,
3720
+ cache.projectionCache,
3721
+ legacyRanges,
3722
+ );
3723
+ cache.projectedSettledBlocks[mergeIndex] = projectedMerged;
3724
+ cache.projectedSettledBlocks.push(...projectedDelta.slice(1));
3725
+ } else {
3726
+ cache.projectedSettledBlocks.push(...projectedDelta);
3727
+ }
3728
+ cache.projectedRevision = cache.settledRevision;
2233
3729
  }
2234
3730
 
2235
3731
  // The unsettled tail is trimmed before parsing, so its offset in the
2236
3732
  // document is where that trimmed text actually starts — not the boundary,
2237
- // which is a line index. If it somehow can't be located, parse it without
2238
- // ranges rather than report wrong ones. Only worth searching for when
2239
- // ranges were asked for: this runs on every streamed chunk.
3733
+ // which is a line index. If it somehow cannot be located, parse it without
3734
+ // positions rather than report wrong ones. The search starts at the
3735
+ // settled end, so it scans the tail and never the prefix.
2240
3736
  const unsettledStart =
2241
- unsettledText && opts.sourceRanges
3737
+ unsettledText && opts.astPositions === true
2242
3738
  ? input.indexOf(unsettledText, cache.settledEnd)
2243
3739
  : -1;
2244
3740
  const unsettledBlocks = unsettledText
@@ -2263,7 +3759,7 @@ export function parseMarkdownIncremental(
2263
3759
  definitionCharacters: settledDelta.length + unsettledInput.length,
2264
3760
  renderedBlocks: parsedSettledBlocks + unsettledBlocks.length,
2265
3761
  };
2266
- return mergeSettledBlocks(state.settledBlocks, unsettledBlocks);
3762
+ return mergeSettledBlocks(cache.settledAstBlocks, unsettledBlocks);
2267
3763
  }
2268
3764
 
2269
3765
  // ---------------------------------------------------------------------------
@@ -2274,29 +3770,6 @@ export function parseMarkdownIncremental(
2274
3770
  // derives its item ids from the same functions, so outline hash links always
2275
3771
  // resolve to a rendered heading by construction.
2276
3772
 
2277
- /** Flatten inline nodes into their plain text content. */
2278
- export function inlineText(nodes: InlineNode[]): string {
2279
- return nodes
2280
- .map(node => {
2281
- switch (node.type) {
2282
- case 'text':
2283
- case 'code':
2284
- return node.content;
2285
- case 'bold':
2286
- case 'italic':
2287
- case 'strikethrough':
2288
- case 'link':
2289
- return inlineText(node.children);
2290
- case 'image':
2291
- return node.alt;
2292
- case 'citation':
2293
- case 'break':
2294
- return '';
2295
- }
2296
- })
2297
- .join('');
2298
- }
2299
-
2300
3773
  /** Turn heading text into a URL-safe slug (lowercase, hyphen-separated). */
2301
3774
  export function slugify(value: string): string {
2302
3775
  return value