@fuzdev/fuz_ui 0.169.0

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 (323) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -0
  3. package/dist/Alert.svelte +108 -0
  4. package/dist/Alert.svelte.d.ts +16 -0
  5. package/dist/Alert.svelte.d.ts.map +1 -0
  6. package/dist/ApiDeclarationList.svelte +35 -0
  7. package/dist/ApiDeclarationList.svelte.d.ts +9 -0
  8. package/dist/ApiDeclarationList.svelte.d.ts.map +1 -0
  9. package/dist/ApiIndex.svelte +65 -0
  10. package/dist/ApiIndex.svelte.d.ts +23 -0
  11. package/dist/ApiIndex.svelte.d.ts.map +1 -0
  12. package/dist/ApiModule.svelte +124 -0
  13. package/dist/ApiModule.svelte.d.ts +22 -0
  14. package/dist/ApiModule.svelte.d.ts.map +1 -0
  15. package/dist/Breadcrumb.svelte +83 -0
  16. package/dist/Breadcrumb.svelte.d.ts +23 -0
  17. package/dist/Breadcrumb.svelte.d.ts.map +1 -0
  18. package/dist/Card.svelte +157 -0
  19. package/dist/Card.svelte.d.ts +13 -0
  20. package/dist/Card.svelte.d.ts.map +1 -0
  21. package/dist/ColorSchemeInput.svelte +65 -0
  22. package/dist/ColorSchemeInput.svelte.d.ts +11 -0
  23. package/dist/ColorSchemeInput.svelte.d.ts.map +1 -0
  24. package/dist/Contextmenu.svelte +30 -0
  25. package/dist/Contextmenu.svelte.d.ts +32 -0
  26. package/dist/Contextmenu.svelte.d.ts.map +1 -0
  27. package/dist/ContextmenuEntry.svelte +74 -0
  28. package/dist/ContextmenuEntry.svelte.d.ts +12 -0
  29. package/dist/ContextmenuEntry.svelte.d.ts.map +1 -0
  30. package/dist/ContextmenuLinkEntry.svelte +112 -0
  31. package/dist/ContextmenuLinkEntry.svelte.d.ts +12 -0
  32. package/dist/ContextmenuLinkEntry.svelte.d.ts.map +1 -0
  33. package/dist/ContextmenuRoot.svelte +372 -0
  34. package/dist/ContextmenuRoot.svelte.d.ts +71 -0
  35. package/dist/ContextmenuRoot.svelte.d.ts.map +1 -0
  36. package/dist/ContextmenuRootForSafariCompatibility.svelte +541 -0
  37. package/dist/ContextmenuRootForSafariCompatibility.svelte.d.ts +79 -0
  38. package/dist/ContextmenuRootForSafariCompatibility.svelte.d.ts.map +1 -0
  39. package/dist/ContextmenuSeparator.svelte +16 -0
  40. package/dist/ContextmenuSeparator.svelte.d.ts +4 -0
  41. package/dist/ContextmenuSeparator.svelte.d.ts.map +1 -0
  42. package/dist/ContextmenuSubmenu.svelte +116 -0
  43. package/dist/ContextmenuSubmenu.svelte.d.ts +10 -0
  44. package/dist/ContextmenuSubmenu.svelte.d.ts.map +1 -0
  45. package/dist/ContextmenuTextEntry.svelte +21 -0
  46. package/dist/ContextmenuTextEntry.svelte.d.ts +10 -0
  47. package/dist/ContextmenuTextEntry.svelte.d.ts.map +1 -0
  48. package/dist/CopyToClipboard.svelte +81 -0
  49. package/dist/CopyToClipboard.svelte.d.ts +18 -0
  50. package/dist/CopyToClipboard.svelte.d.ts.map +1 -0
  51. package/dist/DeclarationDetail.svelte +340 -0
  52. package/dist/DeclarationDetail.svelte.d.ts +8 -0
  53. package/dist/DeclarationDetail.svelte.d.ts.map +1 -0
  54. package/dist/DeclarationLink.svelte +50 -0
  55. package/dist/DeclarationLink.svelte.d.ts +8 -0
  56. package/dist/DeclarationLink.svelte.d.ts.map +1 -0
  57. package/dist/Details.svelte +51 -0
  58. package/dist/Details.svelte.d.ts +20 -0
  59. package/dist/Details.svelte.d.ts.map +1 -0
  60. package/dist/Dialog.svelte +217 -0
  61. package/dist/Dialog.svelte.d.ts +30 -0
  62. package/dist/Dialog.svelte.d.ts.map +1 -0
  63. package/dist/Dialogs.svelte +28 -0
  64. package/dist/Dialogs.svelte.d.ts +11 -0
  65. package/dist/Dialogs.svelte.d.ts.map +1 -0
  66. package/dist/Docs.svelte +179 -0
  67. package/dist/Docs.svelte.d.ts +13 -0
  68. package/dist/Docs.svelte.d.ts.map +1 -0
  69. package/dist/DocsContent.svelte +40 -0
  70. package/dist/DocsContent.svelte.d.ts +14 -0
  71. package/dist/DocsContent.svelte.d.ts.map +1 -0
  72. package/dist/DocsFooter.svelte +64 -0
  73. package/dist/DocsFooter.svelte.d.ts +15 -0
  74. package/dist/DocsFooter.svelte.d.ts.map +1 -0
  75. package/dist/DocsLink.svelte +41 -0
  76. package/dist/DocsLink.svelte.d.ts +12 -0
  77. package/dist/DocsLink.svelte.d.ts.map +1 -0
  78. package/dist/DocsList.svelte +44 -0
  79. package/dist/DocsList.svelte.d.ts +11 -0
  80. package/dist/DocsList.svelte.d.ts.map +1 -0
  81. package/dist/DocsMenu.svelte +55 -0
  82. package/dist/DocsMenu.svelte.d.ts +11 -0
  83. package/dist/DocsMenu.svelte.d.ts.map +1 -0
  84. package/dist/DocsMenuHeader.svelte +15 -0
  85. package/dist/DocsMenuHeader.svelte.d.ts +9 -0
  86. package/dist/DocsMenuHeader.svelte.d.ts.map +1 -0
  87. package/dist/DocsModulesList.svelte +32 -0
  88. package/dist/DocsModulesList.svelte.d.ts +7 -0
  89. package/dist/DocsModulesList.svelte.d.ts.map +1 -0
  90. package/dist/DocsPageLinks.svelte +61 -0
  91. package/dist/DocsPageLinks.svelte.d.ts +8 -0
  92. package/dist/DocsPageLinks.svelte.d.ts.map +1 -0
  93. package/dist/DocsPrimaryNav.svelte +93 -0
  94. package/dist/DocsPrimaryNav.svelte.d.ts +11 -0
  95. package/dist/DocsPrimaryNav.svelte.d.ts.map +1 -0
  96. package/dist/DocsSearch.svelte +48 -0
  97. package/dist/DocsSearch.svelte.d.ts +11 -0
  98. package/dist/DocsSearch.svelte.d.ts.map +1 -0
  99. package/dist/DocsSecondaryNav.svelte +63 -0
  100. package/dist/DocsSecondaryNav.svelte.d.ts +9 -0
  101. package/dist/DocsSecondaryNav.svelte.d.ts.map +1 -0
  102. package/dist/DocsTertiaryNav.svelte +118 -0
  103. package/dist/DocsTertiaryNav.svelte.d.ts +10 -0
  104. package/dist/DocsTertiaryNav.svelte.d.ts.map +1 -0
  105. package/dist/EcosystemLinks.svelte +53 -0
  106. package/dist/EcosystemLinks.svelte.d.ts +7 -0
  107. package/dist/EcosystemLinks.svelte.d.ts.map +1 -0
  108. package/dist/EcosystemLinksPanel.svelte +22 -0
  109. package/dist/EcosystemLinksPanel.svelte.d.ts +8 -0
  110. package/dist/EcosystemLinksPanel.svelte.d.ts.map +1 -0
  111. package/dist/GithubLink.svelte +75 -0
  112. package/dist/GithubLink.svelte.d.ts +14 -0
  113. package/dist/GithubLink.svelte.d.ts.map +1 -0
  114. package/dist/Glyph.svelte +28 -0
  115. package/dist/Glyph.svelte.d.ts +9 -0
  116. package/dist/Glyph.svelte.d.ts.map +1 -0
  117. package/dist/Hashlink.svelte +41 -0
  118. package/dist/Hashlink.svelte.d.ts +8 -0
  119. package/dist/Hashlink.svelte.d.ts.map +1 -0
  120. package/dist/HiddenPersonalLinks.svelte +6 -0
  121. package/dist/HiddenPersonalLinks.svelte.d.ts +27 -0
  122. package/dist/HiddenPersonalLinks.svelte.d.ts.map +1 -0
  123. package/dist/HueInput.svelte +127 -0
  124. package/dist/HueInput.svelte.d.ts +11 -0
  125. package/dist/HueInput.svelte.d.ts.map +1 -0
  126. package/dist/ImgOrSvg.svelte +58 -0
  127. package/dist/ImgOrSvg.svelte.d.ts +25 -0
  128. package/dist/ImgOrSvg.svelte.d.ts.map +1 -0
  129. package/dist/LibraryDetail.svelte +297 -0
  130. package/dist/LibraryDetail.svelte.d.ts +15 -0
  131. package/dist/LibraryDetail.svelte.d.ts.map +1 -0
  132. package/dist/LibrarySummary.svelte +151 -0
  133. package/dist/LibrarySummary.svelte.d.ts +16 -0
  134. package/dist/LibrarySummary.svelte.d.ts.map +1 -0
  135. package/dist/MdnLink.svelte +40 -0
  136. package/dist/MdnLink.svelte.d.ts +8 -0
  137. package/dist/MdnLink.svelte.d.ts.map +1 -0
  138. package/dist/Mdz.svelte +30 -0
  139. package/dist/Mdz.svelte.d.ts +10 -0
  140. package/dist/Mdz.svelte.d.ts.map +1 -0
  141. package/dist/MdzNodeView.svelte +93 -0
  142. package/dist/MdzNodeView.svelte.d.ts +9 -0
  143. package/dist/MdzNodeView.svelte.d.ts.map +1 -0
  144. package/dist/ModuleLink.svelte +48 -0
  145. package/dist/ModuleLink.svelte.d.ts +8 -0
  146. package/dist/ModuleLink.svelte.d.ts.map +1 -0
  147. package/dist/PasteFromClipboard.svelte +35 -0
  148. package/dist/PasteFromClipboard.svelte.d.ts +9 -0
  149. package/dist/PasteFromClipboard.svelte.d.ts.map +1 -0
  150. package/dist/PendingAnimation.svelte +62 -0
  151. package/dist/PendingAnimation.svelte.d.ts +13 -0
  152. package/dist/PendingAnimation.svelte.d.ts.map +1 -0
  153. package/dist/PendingButton.svelte +75 -0
  154. package/dist/PendingButton.svelte.d.ts +17 -0
  155. package/dist/PendingButton.svelte.d.ts.map +1 -0
  156. package/dist/ProjectLinks.svelte +54 -0
  157. package/dist/ProjectLinks.svelte.d.ts +19 -0
  158. package/dist/ProjectLinks.svelte.d.ts.map +1 -0
  159. package/dist/Redirect.svelte +44 -0
  160. package/dist/Redirect.svelte.d.ts +23 -0
  161. package/dist/Redirect.svelte.d.ts.map +1 -0
  162. package/dist/Spiders.svelte +57 -0
  163. package/dist/Spiders.svelte.d.ts +9 -0
  164. package/dist/Spiders.svelte.d.ts.map +1 -0
  165. package/dist/Svg.svelte +99 -0
  166. package/dist/Svg.svelte.d.ts +54 -0
  167. package/dist/Svg.svelte.d.ts.map +1 -0
  168. package/dist/Teleport.svelte +48 -0
  169. package/dist/Teleport.svelte.d.ts +15 -0
  170. package/dist/Teleport.svelte.d.ts.map +1 -0
  171. package/dist/ThemeInput.svelte +75 -0
  172. package/dist/ThemeInput.svelte.d.ts +15 -0
  173. package/dist/ThemeInput.svelte.d.ts.map +1 -0
  174. package/dist/Themed.svelte +101 -0
  175. package/dist/Themed.svelte.d.ts +24 -0
  176. package/dist/Themed.svelte.d.ts.map +1 -0
  177. package/dist/TomeContent.svelte +67 -0
  178. package/dist/TomeContent.svelte.d.ts +12 -0
  179. package/dist/TomeContent.svelte.d.ts.map +1 -0
  180. package/dist/TomeHeader.svelte +56 -0
  181. package/dist/TomeHeader.svelte.d.ts +4 -0
  182. package/dist/TomeHeader.svelte.d.ts.map +1 -0
  183. package/dist/TomeLink.svelte +29 -0
  184. package/dist/TomeLink.svelte.d.ts +10 -0
  185. package/dist/TomeLink.svelte.d.ts.map +1 -0
  186. package/dist/TomeSection.svelte +65 -0
  187. package/dist/TomeSection.svelte.d.ts +24 -0
  188. package/dist/TomeSection.svelte.d.ts.map +1 -0
  189. package/dist/TomeSectionHeader.svelte +90 -0
  190. package/dist/TomeSectionHeader.svelte.d.ts +13 -0
  191. package/dist/TomeSectionHeader.svelte.d.ts.map +1 -0
  192. package/dist/TypeLink.svelte +19 -0
  193. package/dist/TypeLink.svelte.d.ts +7 -0
  194. package/dist/TypeLink.svelte.d.ts.map +1 -0
  195. package/dist/alert.d.ts +7 -0
  196. package/dist/alert.d.ts.map +1 -0
  197. package/dist/alert.js +6 -0
  198. package/dist/api_search.svelte.d.ts +16 -0
  199. package/dist/api_search.svelte.d.ts.map +1 -0
  200. package/dist/api_search.svelte.js +61 -0
  201. package/dist/constants.d.ts +2 -0
  202. package/dist/constants.d.ts.map +1 -0
  203. package/dist/constants.js +3 -0
  204. package/dist/context_helpers.d.ts +17 -0
  205. package/dist/context_helpers.d.ts.map +1 -0
  206. package/dist/context_helpers.js +19 -0
  207. package/dist/contextmenu_helpers.d.ts +16 -0
  208. package/dist/contextmenu_helpers.d.ts.map +1 -0
  209. package/dist/contextmenu_helpers.js +39 -0
  210. package/dist/contextmenu_state.svelte.d.ts +152 -0
  211. package/dist/contextmenu_state.svelte.d.ts.map +1 -0
  212. package/dist/contextmenu_state.svelte.js +424 -0
  213. package/dist/csp.d.ts +160 -0
  214. package/dist/csp.d.ts.map +1 -0
  215. package/dist/csp.js +354 -0
  216. package/dist/csp_of_ryanatkn.d.ts +6 -0
  217. package/dist/csp_of_ryanatkn.d.ts.map +1 -0
  218. package/dist/csp_of_ryanatkn.js +14 -0
  219. package/dist/declaration.svelte.d.ts +84 -0
  220. package/dist/declaration.svelte.d.ts.map +1 -0
  221. package/dist/declaration.svelte.js +66 -0
  222. package/dist/declaration_contextmenu.d.ts +4 -0
  223. package/dist/declaration_contextmenu.d.ts.map +1 -0
  224. package/dist/declaration_contextmenu.js +14 -0
  225. package/dist/dialog.d.ts +24 -0
  226. package/dist/dialog.d.ts.map +1 -0
  227. package/dist/dialog.js +12 -0
  228. package/dist/dimensions.svelte.d.ts +5 -0
  229. package/dist/dimensions.svelte.d.ts.map +1 -0
  230. package/dist/dimensions.svelte.js +4 -0
  231. package/dist/docs_helpers.svelte.d.ts +48 -0
  232. package/dist/docs_helpers.svelte.d.ts.map +1 -0
  233. package/dist/docs_helpers.svelte.js +99 -0
  234. package/dist/helpers.d.ts +2 -0
  235. package/dist/helpers.d.ts.map +1 -0
  236. package/dist/helpers.js +16 -0
  237. package/dist/intersect.svelte.d.ts +47 -0
  238. package/dist/intersect.svelte.d.ts.map +1 -0
  239. package/dist/intersect.svelte.js +92 -0
  240. package/dist/library.svelte.d.ts +197 -0
  241. package/dist/library.svelte.d.ts.map +1 -0
  242. package/dist/library.svelte.js +130 -0
  243. package/dist/library_gen.d.ts +34 -0
  244. package/dist/library_gen.d.ts.map +1 -0
  245. package/dist/library_gen.js +123 -0
  246. package/dist/library_gen_helpers.d.ts +85 -0
  247. package/dist/library_gen_helpers.d.ts.map +1 -0
  248. package/dist/library_gen_helpers.js +188 -0
  249. package/dist/library_helpers.d.ts +54 -0
  250. package/dist/library_helpers.d.ts.map +1 -0
  251. package/dist/library_helpers.js +102 -0
  252. package/dist/logos.d.ts +134 -0
  253. package/dist/logos.d.ts.map +1 -0
  254. package/dist/logos.js +281 -0
  255. package/dist/mdz.d.ts +106 -0
  256. package/dist/mdz.d.ts.map +1 -0
  257. package/dist/mdz.js +1481 -0
  258. package/dist/mdz_components.d.ts +37 -0
  259. package/dist/mdz_components.d.ts.map +1 -0
  260. package/dist/mdz_components.js +12 -0
  261. package/dist/module.svelte.d.ts +47 -0
  262. package/dist/module.svelte.d.ts.map +1 -0
  263. package/dist/module.svelte.js +56 -0
  264. package/dist/module_contextmenu.d.ts +4 -0
  265. package/dist/module_contextmenu.d.ts.map +1 -0
  266. package/dist/module_contextmenu.js +14 -0
  267. package/dist/module_helpers.d.ts +69 -0
  268. package/dist/module_helpers.d.ts.map +1 -0
  269. package/dist/module_helpers.js +87 -0
  270. package/dist/rune_helpers.svelte.d.ts +6 -0
  271. package/dist/rune_helpers.svelte.d.ts.map +1 -0
  272. package/dist/rune_helpers.svelte.js +10 -0
  273. package/dist/storage.d.ts +13 -0
  274. package/dist/storage.d.ts.map +1 -0
  275. package/dist/storage.js +43 -0
  276. package/dist/svelte_helpers.d.ts +37 -0
  277. package/dist/svelte_helpers.d.ts.map +1 -0
  278. package/dist/svelte_helpers.js +245 -0
  279. package/dist/themer.svelte.d.ts +24 -0
  280. package/dist/themer.svelte.d.ts.map +1 -0
  281. package/dist/themer.svelte.js +43 -0
  282. package/dist/tome.d.ts +80 -0
  283. package/dist/tome.d.ts.map +1 -0
  284. package/dist/tome.js +27 -0
  285. package/dist/ts_helpers.d.ts +110 -0
  286. package/dist/ts_helpers.d.ts.map +1 -0
  287. package/dist/ts_helpers.js +533 -0
  288. package/dist/tsdoc_helpers.d.ts +98 -0
  289. package/dist/tsdoc_helpers.d.ts.map +1 -0
  290. package/dist/tsdoc_helpers.js +221 -0
  291. package/package.json +128 -0
  292. package/src/lib/alert.ts +14 -0
  293. package/src/lib/api_search.svelte.ts +85 -0
  294. package/src/lib/constants.ts +3 -0
  295. package/src/lib/context_helpers.ts +47 -0
  296. package/src/lib/contextmenu_helpers.ts +63 -0
  297. package/src/lib/contextmenu_state.svelte.ts +515 -0
  298. package/src/lib/csp.ts +576 -0
  299. package/src/lib/csp_of_ryanatkn.ts +16 -0
  300. package/src/lib/declaration.svelte.ts +102 -0
  301. package/src/lib/declaration_contextmenu.ts +22 -0
  302. package/src/lib/dialog.ts +35 -0
  303. package/src/lib/dimensions.svelte.ts +4 -0
  304. package/src/lib/docs_helpers.svelte.ts +149 -0
  305. package/src/lib/helpers.ts +10 -0
  306. package/src/lib/intersect.svelte.ts +152 -0
  307. package/src/lib/library.svelte.ts +162 -0
  308. package/src/lib/library_gen.ts +160 -0
  309. package/src/lib/library_gen_helpers.ts +262 -0
  310. package/src/lib/library_helpers.ts +123 -0
  311. package/src/lib/logos.ts +302 -0
  312. package/src/lib/mdz.ts +1819 -0
  313. package/src/lib/mdz_components.ts +34 -0
  314. package/src/lib/module.svelte.ts +78 -0
  315. package/src/lib/module_contextmenu.ts +20 -0
  316. package/src/lib/module_helpers.ts +113 -0
  317. package/src/lib/rune_helpers.svelte.ts +10 -0
  318. package/src/lib/storage.ts +48 -0
  319. package/src/lib/svelte_helpers.ts +303 -0
  320. package/src/lib/themer.svelte.ts +68 -0
  321. package/src/lib/tome.ts +38 -0
  322. package/src/lib/ts_helpers.ts +662 -0
  323. package/src/lib/tsdoc_helpers.ts +259 -0
package/src/lib/mdz.ts ADDED
@@ -0,0 +1,1819 @@
1
+ /**
2
+ * mdz - minimal markdown dialect for Fuz documentation.
3
+ *
4
+ * Parses an enhanced markdown dialect with:
5
+ * - inline formatting: `code`, **bold**, _italic_, ~strikethrough~
6
+ * - auto-detected links: external URLs (`https://...`) and internal paths (`/path`)
7
+ * - markdown links: `[text](url)` with custom display text
8
+ * - inline code in backticks (creates `Code` nodes; auto-linking to identifiers/modules
9
+ * is handled by the rendering layer via `MdzNodeView.svelte`)
10
+ * - paragraph breaks (double newline)
11
+ * - block elements: headings, horizontal rules, code blocks
12
+ * - HTML elements and Svelte components (opt-in via context)
13
+ *
14
+ * Key constraint: preserves ALL whitespace exactly as authored,
15
+ * and is rendered with white-space pre or pre-wrap.
16
+ *
17
+ * ## Design philosophy
18
+ *
19
+ * - **False negatives over false positives**: Strict syntax prevents accidentally
20
+ * interpreting plain text as formatting. When in doubt, treat as plain text.
21
+ * - **One way to do things**: Single unambiguous syntax per feature. No alternatives.
22
+ * - **Explicit over implicit**: Clear delimiters and column-0 requirements avoid ambiguity.
23
+ * - **Simple over complete**: Prefer simple parsing rules over complex edge case handling.
24
+ *
25
+ * ## Status
26
+ *
27
+ * This is an early proof of concept with missing features and edge cases.
28
+ */
29
+
30
+ // TODO design incremental parsing or some system that preserves Svelte components across re-renders when possible
31
+
32
+ /**
33
+ * Parses text to an array of `MdzNode`.
34
+ */
35
+ export const mdz_parse = (text: string): Array<MdzNode> => new MdzParser(text).parse();
36
+
37
+ export type MdzNode =
38
+ | MdzTextNode
39
+ | MdzCodeNode
40
+ | MdzCodeblockNode
41
+ | MdzBoldNode
42
+ | MdzItalicNode
43
+ | MdzStrikethroughNode
44
+ | MdzLinkNode
45
+ | MdzParagraphNode
46
+ | MdzHrNode
47
+ | MdzHeadingNode
48
+ | MdzElementNode
49
+ | MdzComponentNode;
50
+
51
+ export interface MdzBaseNode {
52
+ type: string;
53
+ start: number;
54
+ end: number;
55
+ }
56
+
57
+ export interface MdzTextNode extends MdzBaseNode {
58
+ type: 'Text';
59
+ content: string;
60
+ }
61
+
62
+ export interface MdzCodeNode extends MdzBaseNode {
63
+ type: 'Code';
64
+ content: string; // The code content (identifier/module name)
65
+ }
66
+
67
+ export interface MdzCodeblockNode extends MdzBaseNode {
68
+ type: 'Codeblock';
69
+ lang: string | null; // language hint, if provided
70
+ content: string; // raw code content
71
+ }
72
+
73
+ export interface MdzBoldNode extends MdzBaseNode {
74
+ type: 'Bold';
75
+ children: Array<MdzNode>;
76
+ }
77
+
78
+ export interface MdzItalicNode extends MdzBaseNode {
79
+ type: 'Italic';
80
+ children: Array<MdzNode>;
81
+ }
82
+
83
+ export interface MdzStrikethroughNode extends MdzBaseNode {
84
+ type: 'Strikethrough';
85
+ children: Array<MdzNode>;
86
+ }
87
+
88
+ export interface MdzLinkNode extends MdzBaseNode {
89
+ type: 'Link';
90
+ reference: string; // URL or path
91
+ children: Array<MdzNode>; // Display content (can include inline formatting)
92
+ link_type: 'external' | 'internal'; // external: https/http, internal: /path
93
+ }
94
+
95
+ export interface MdzParagraphNode extends MdzBaseNode {
96
+ type: 'Paragraph';
97
+ children: Array<MdzNode>;
98
+ }
99
+
100
+ export interface MdzHrNode extends MdzBaseNode {
101
+ type: 'Hr';
102
+ }
103
+
104
+ export interface MdzHeadingNode extends MdzBaseNode {
105
+ type: 'Heading';
106
+ level: 1 | 2 | 3 | 4 | 5 | 6;
107
+ children: Array<MdzNode>; // inline formatting allowed
108
+ }
109
+
110
+ export interface MdzElementNode extends MdzBaseNode {
111
+ type: 'Element';
112
+ name: string; // HTML element name (e.g., 'div', 'span', 'code')
113
+ children: Array<MdzNode>;
114
+ }
115
+
116
+ export interface MdzComponentNode extends MdzBaseNode {
117
+ type: 'Component';
118
+ name: string; // Svelte component name (e.g., 'Alert', 'Card')
119
+ children: Array<MdzNode>;
120
+ }
121
+
122
+ // Character codes for performance
123
+ const BACKTICK = 96; // `
124
+ const ASTERISK = 42; // *
125
+ const UNDERSCORE = 95; // _
126
+ const TILDE = 126; // ~
127
+ const NEWLINE = 10; // \n
128
+ const HYPHEN = 45; // -
129
+ const HASH = 35; // #
130
+ const SPACE = 32; // (space)
131
+ const TAB = 9; // \t
132
+ const LEFT_ANGLE = 60; // <
133
+ const RIGHT_ANGLE = 62; // >
134
+ const SLASH = 47; // /
135
+ const LEFT_BRACKET = 91; // [
136
+ const RIGHT_BRACKET = 93; // ]
137
+ const LEFT_PAREN = 40; // (
138
+ const RIGHT_PAREN = 41; // )
139
+ const COLON = 58; // :
140
+ const PERIOD = 46; // .
141
+ const COMMA = 44; // ,
142
+ const SEMICOLON = 59; // ;
143
+ const EXCLAMATION = 33; // !
144
+ const QUESTION = 63; // ?
145
+ // RFC 3986 URI characters
146
+ const DOLLAR = 36; // $
147
+ const PERCENT = 37; // %
148
+ const AMPERSAND = 38; // &
149
+ const APOSTROPHE = 39; // '
150
+ const PLUS = 43; // +
151
+ const EQUALS = 61; // =
152
+ const AT = 64; // @
153
+ // Character ranges
154
+ const A_UPPER = 65; // A
155
+ const Z_UPPER = 90; // Z
156
+ const A_LOWER = 97; // a
157
+ const Z_LOWER = 122; // z
158
+ const ZERO = 48; // 0
159
+ const NINE = 57; // 9
160
+ // mdz specification constants
161
+ const HR_HYPHEN_COUNT = 3; // Horizontal rule requires exactly 3 hyphens
162
+ const MIN_CODEBLOCK_BACKTICKS = 3; // Code blocks require minimum 3 backticks
163
+ const MAX_HEADING_LEVEL = 6; // Headings support levels 1-6
164
+ const HTTPS_PREFIX_LENGTH = 8; // Length of "https://"
165
+ const HTTP_PREFIX_LENGTH = 7; // Length of "http://"
166
+
167
+ /**
168
+ * Parser for mdz format.
169
+ * Single-pass lexer/parser with text accumulation for efficiency.
170
+ * Used by `mdz_parse`, which should be preferred for simple usage.
171
+ */
172
+ export class MdzParser {
173
+ #index: number = 0;
174
+ #template: string;
175
+ #accumulated_text: string = '';
176
+ #accumulated_start: number = 0;
177
+ #nodes: Array<MdzNode> = [];
178
+ #max_search_index: number = Number.MAX_SAFE_INTEGER; // Boundary for delimiter searches
179
+
180
+ constructor(template: string) {
181
+ this.#template = template;
182
+ }
183
+
184
+ /**
185
+ * Main parse method. Returns flat array of nodes,
186
+ * with paragraph nodes wrapping content between double newlines.
187
+ */
188
+ parse(): Array<MdzNode> {
189
+ this.#nodes.length = 0;
190
+ const root_nodes: Array<MdzNode> = [];
191
+ const paragraph_children: Array<MdzNode> = [];
192
+
193
+ // Check for block element at document start
194
+ const start_block = this.#try_parse_block_element();
195
+ if (start_block) {
196
+ root_nodes.push(start_block);
197
+ }
198
+
199
+ while (this.#index < this.#template.length) {
200
+ // Check for paragraph break (double newline)
201
+ if (this.#is_at_paragraph_break()) {
202
+ this.#flush_text();
203
+ // Move flushed nodes to paragraph_children
204
+ if (this.#nodes.length > 0) {
205
+ paragraph_children.push(...this.#nodes);
206
+ this.#nodes.length = 0;
207
+ }
208
+ // Wrap accumulated nodes in paragraph (or add single tag directly)
209
+ if (paragraph_children.length > 0) {
210
+ if (this.#is_single_tag(paragraph_children)) {
211
+ // Single tag (component/element) - add directly without paragraph wrapper (MDX convention)
212
+ const tag = paragraph_children.find(
213
+ (n) => n.type === 'Component' || n.type === 'Element',
214
+ )!;
215
+ root_nodes.push(tag);
216
+ } else {
217
+ // Regular paragraph
218
+ root_nodes.push({
219
+ type: 'Paragraph',
220
+ children: paragraph_children.slice(),
221
+ start: paragraph_children[0]!.start,
222
+ end: paragraph_children[paragraph_children.length - 1]!.end,
223
+ });
224
+ }
225
+ paragraph_children.length = 0;
226
+ }
227
+ // Consume the paragraph break
228
+ this.#eat('\n\n');
229
+
230
+ // Check for block element after paragraph break
231
+ const block = this.#try_parse_block_element();
232
+ if (block) {
233
+ root_nodes.push(block);
234
+ }
235
+ } else {
236
+ const node = this.#parse_node();
237
+ if (node.type === 'Text') {
238
+ this.#accumulate_text(node.content, node.start);
239
+ } else {
240
+ this.#flush_text();
241
+ this.#nodes.push(node);
242
+ }
243
+ if (this.#nodes.length > 0) {
244
+ paragraph_children.push(...this.#nodes);
245
+ this.#nodes.length = 0;
246
+ }
247
+ }
248
+ }
249
+
250
+ this.#flush_text();
251
+ if (this.#nodes.length > 0) {
252
+ paragraph_children.push(...this.#nodes);
253
+ }
254
+
255
+ // Wrap remaining nodes in final paragraph if any (or add single tag directly)
256
+ if (paragraph_children.length > 0) {
257
+ if (this.#is_single_tag(paragraph_children)) {
258
+ // Single tag (component/element) - add directly without paragraph wrapper (MDX convention)
259
+ const tag = paragraph_children.find((n) => n.type === 'Component' || n.type === 'Element')!;
260
+ root_nodes.push(tag);
261
+ } else {
262
+ // Regular paragraph
263
+ root_nodes.push({
264
+ type: 'Paragraph',
265
+ children: paragraph_children,
266
+ start: paragraph_children[0]!.start,
267
+ end: paragraph_children[paragraph_children.length - 1]!.end,
268
+ });
269
+ }
270
+ }
271
+
272
+ return root_nodes;
273
+ }
274
+
275
+ /**
276
+ * Accumulate text for later flushing (performance optimization).
277
+ */
278
+ #accumulate_text(text: string, start: number): void {
279
+ if (this.#accumulated_text === '') {
280
+ this.#accumulated_start = start;
281
+ }
282
+ this.#accumulated_text += text;
283
+ }
284
+
285
+ #flush_text(): void {
286
+ if (this.#accumulated_text !== '') {
287
+ this.#nodes.push({
288
+ type: 'Text',
289
+ content: this.#accumulated_text,
290
+ start: this.#accumulated_start,
291
+ end: this.#accumulated_start + this.#accumulated_text.length,
292
+ });
293
+ this.#accumulated_text = '';
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Create a text node and advance index past the content.
299
+ * Used when formatting delimiters fail to match and need to be treated as literal text.
300
+ */
301
+ #make_text_node(content: string, start: number): MdzTextNode {
302
+ this.#index = start + content.length;
303
+ return {
304
+ type: 'Text',
305
+ content,
306
+ start,
307
+ end: this.#index,
308
+ };
309
+ }
310
+
311
+ /**
312
+ * Parse next node based on current character.
313
+ * Uses switch for performance (avoids regex in hot loop).
314
+ */
315
+ #parse_node(): MdzNode {
316
+ const char_code = this.#current_char();
317
+
318
+ // Use character codes for performance in hot path
319
+ switch (char_code) {
320
+ case BACKTICK:
321
+ return this.#parse_code();
322
+ case ASTERISK:
323
+ return this.#parse_bold();
324
+ case UNDERSCORE:
325
+ return this.#parse_italic();
326
+ case TILDE:
327
+ return this.#parse_strikethrough();
328
+ case LEFT_BRACKET:
329
+ return this.#parse_markdown_link();
330
+ case LEFT_ANGLE:
331
+ return this.#parse_tag();
332
+ default:
333
+ return this.#parse_text();
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Parse backtick code: `code`
339
+ * Auto-links to identifiers/modules if match found.
340
+ * Falls back to text if unclosed, empty, or if newline encountered before closing backtick.
341
+ */
342
+ #parse_code(): MdzCodeNode | MdzTextNode {
343
+ const start = this.#index;
344
+ this.#eat('`');
345
+ const content_start = this.#index;
346
+
347
+ // Find closing backtick, but stop at newline (respect boundary for greedy matching)
348
+ let content_end = -1;
349
+ const search_limit = Math.min(this.#max_search_index, this.#template.length);
350
+ for (let i = this.#index; i < search_limit; i++) {
351
+ const char_code = this.#template.charCodeAt(i);
352
+ if (char_code === BACKTICK) {
353
+ content_end = i;
354
+ break;
355
+ }
356
+ if (char_code === NEWLINE) {
357
+ // Newline before closing backtick - treat as unclosed
358
+ break;
359
+ }
360
+ }
361
+
362
+ if (content_end === -1) {
363
+ // Unclosed backtick or newline encountered, treat as text
364
+ return this.#make_text_node('`', start);
365
+ }
366
+
367
+ const content = this.#template.slice(content_start, content_end);
368
+
369
+ // Empty inline code has no semantic meaning, treat as literal text
370
+ if (content.length === 0) {
371
+ return this.#make_text_node('``', start);
372
+ }
373
+
374
+ this.#index = content_end + 1;
375
+
376
+ return {
377
+ type: 'Code',
378
+ content,
379
+ start,
380
+ end: this.#index,
381
+ };
382
+ }
383
+
384
+ /**
385
+ * Parse bold starting with double asterisk.
386
+ *
387
+ * - **bold** = Bold node
388
+ *
389
+ * Falls back to text if unclosed or single asterisk.
390
+ *
391
+ * Bold has no word boundary restrictions and works everywhere including intraword.
392
+ * Examples:
393
+ * - `foo**bar**baz` → foo<strong>bar</strong>baz (creates bold)
394
+ * - `word **bold** word` → word <strong>bold</strong> word (also works)
395
+ */
396
+ #parse_bold(): MdzBoldNode | MdzTextNode {
397
+ const start = this.#index;
398
+
399
+ // Check for ** (bold)
400
+ if (this.#match('**')) {
401
+ // Bold (**) has no word boundary restrictions - works everywhere including intraword
402
+ this.#eat('**');
403
+
404
+ // Find closing ** (greedy matching - first occurrence within boundary)
405
+ const search_end = Math.min(this.#max_search_index, this.#template.length);
406
+ let close_index = this.#template.indexOf('**', this.#index);
407
+ // Check if close_index exceeds search boundary
408
+ if (close_index !== -1 && close_index >= search_end) {
409
+ close_index = -1;
410
+ }
411
+ if (close_index === -1) {
412
+ // Unclosed, treat as text
413
+ return this.#make_text_node('**', start);
414
+ }
415
+
416
+ // No word boundary check for closing ** - works everywhere
417
+ // Parse children up to closing delimiter (bounded parsing)
418
+ const children = this.#parse_nodes_until('**', close_index);
419
+
420
+ // Verify we're at the closing delimiter (could have stopped early due to paragraph break)
421
+ if (!this.#match('**')) {
422
+ // Interrupted before closing - treat as unclosed
423
+ return this.#make_text_node('**', start);
424
+ }
425
+
426
+ // Empty bold has no semantic meaning, treat as literal text
427
+ if (children.length === 0) {
428
+ this.#index = start;
429
+ return this.#make_text_node('****', start);
430
+ }
431
+
432
+ // Consume closing **
433
+ this.#eat('**');
434
+ return {
435
+ type: 'Bold',
436
+ children,
437
+ start,
438
+ end: this.#index,
439
+ };
440
+ }
441
+
442
+ // Single asterisk - treat as text
443
+ const content = this.#template[this.#index]!;
444
+ return this.#make_text_node(content, start);
445
+ }
446
+
447
+ /**
448
+ * Common parser for single-delimiter formatting (italic and strikethrough).
449
+ * Both formats use identical parsing logic with different delimiters and node types.
450
+ *
451
+ * Word boundary requirements:
452
+ * - Opening delimiter must be at word boundary (not preceded by alphanumeric)
453
+ * - Closing delimiter must be at word boundary (not followed by alphanumeric)
454
+ * - This prevents false positives with `snake_case` and `foo~bar~baz` text
455
+ *
456
+ * Falls back to literal text if:
457
+ * - Delimiter not at word boundary
458
+ * - Unclosed (no matching closing delimiter found)
459
+ * - Empty content (e.g., `__` or `~~`)
460
+ * - Paragraph break interrupts before closing delimiter
461
+ *
462
+ * @param delimiter - The delimiter character (`_` for italic, `~` for strikethrough)
463
+ * @param node_type - The node type to create ('Italic' or 'Strikethrough')
464
+ * @returns Formatted node or text node if validation fails
465
+ */
466
+ #parse_single_delimiter_formatting(
467
+ delimiter: '_',
468
+ node_type: 'Italic',
469
+ ): MdzItalicNode | MdzTextNode;
470
+ #parse_single_delimiter_formatting(
471
+ delimiter: '~',
472
+ node_type: 'Strikethrough',
473
+ ): MdzStrikethroughNode | MdzTextNode;
474
+ #parse_single_delimiter_formatting(
475
+ delimiter: '_' | '~',
476
+ node_type: 'Italic' | 'Strikethrough',
477
+ ): MdzItalicNode | MdzStrikethroughNode | MdzTextNode {
478
+ const start = this.#index;
479
+
480
+ // Check if opening delimiter is at word boundary
481
+ if (!this.#is_at_word_boundary(this.#index, true, false)) {
482
+ // Intraword delimiter - treat as literal text
483
+ const content = this.#template[this.#index]!;
484
+ return this.#make_text_node(content, start);
485
+ }
486
+
487
+ this.#eat(delimiter);
488
+
489
+ // Find closing delimiter (greedy matching - first occurrence within boundary)
490
+ const search_end = Math.min(this.#max_search_index, this.#template.length);
491
+ let close_index = this.#template.indexOf(delimiter, this.#index);
492
+ // Check if close_index exceeds search boundary
493
+ if (close_index !== -1 && close_index >= search_end) {
494
+ close_index = -1;
495
+ }
496
+ if (close_index === -1) {
497
+ // Unclosed, treat as text
498
+ return this.#make_text_node(delimiter, start);
499
+ }
500
+
501
+ // Check if closing delimiter is at word boundary
502
+ if (!this.#is_at_word_boundary(close_index + 1, false, true)) {
503
+ // Closing delimiter not at boundary - treat whole thing as text
504
+ return this.#make_text_node(delimiter, start);
505
+ }
506
+
507
+ // Parse children up to closing delimiter (bounded parsing)
508
+ const children = this.#parse_nodes_until(delimiter, close_index);
509
+
510
+ // Verify we're at the closing delimiter (could have stopped early due to paragraph break)
511
+ if (!this.#match(delimiter)) {
512
+ // Interrupted before closing - treat as unclosed
513
+ return this.#make_text_node(delimiter, start);
514
+ }
515
+
516
+ // Empty formatting has no semantic meaning, treat as literal text
517
+ if (children.length === 0) {
518
+ this.#index = start;
519
+ return this.#make_text_node(delimiter + delimiter, start);
520
+ }
521
+
522
+ // Consume closing delimiter
523
+ this.#eat(delimiter);
524
+ return {
525
+ type: node_type,
526
+ children,
527
+ start,
528
+ end: this.#index,
529
+ };
530
+ }
531
+
532
+ /**
533
+ * Parse italic starting with underscore.
534
+ * _italic_ = Italic node
535
+ * Falls back to text if unclosed or not at word boundary.
536
+ *
537
+ * Following GFM spec: underscores cannot create emphasis in middle of words.
538
+ * Examples:
539
+ * - `foo_bar_baz` → literal text (intraword)
540
+ * - `word _emphasis_ word` → emphasis (at word boundaries)
541
+ */
542
+ #parse_italic(): MdzItalicNode | MdzTextNode {
543
+ return this.#parse_single_delimiter_formatting('_', 'Italic');
544
+ }
545
+
546
+ /**
547
+ * Parse strikethrough starting with tilde.
548
+ * ~strikethrough~ = Strikethrough node
549
+ * Falls back to text if unclosed or not at word boundary.
550
+ *
551
+ * Following mdz philosophy (false negatives over false positives):
552
+ * Strikethrough requires word boundaries to prevent intraword formatting.
553
+ * Examples:
554
+ * - `foo~bar~baz` → literal text (intraword)
555
+ * - `word ~strike~ word` → strikethrough (at word boundaries)
556
+ */
557
+ #parse_strikethrough(): MdzStrikethroughNode | MdzTextNode {
558
+ return this.#parse_single_delimiter_formatting('~', 'Strikethrough');
559
+ }
560
+
561
+ /**
562
+ * Parse markdown link: `[text](url)`.
563
+ * Falls back to text if malformed.
564
+ */
565
+ #parse_markdown_link(): MdzLinkNode | MdzTextNode {
566
+ const start = this.#index;
567
+
568
+ // Consume opening [
569
+ if (!this.#match('[')) {
570
+ const content = this.#template[this.#index]!;
571
+ this.#index++;
572
+ return {
573
+ type: 'Text',
574
+ content,
575
+ start,
576
+ end: this.#index,
577
+ };
578
+ }
579
+ this.#index++;
580
+
581
+ // Parse children nodes until closing ]
582
+ const children = this.#parse_nodes_until(']');
583
+
584
+ // Check if we found the closing ]
585
+ if (!this.#match(']')) {
586
+ // No closing ], treat as text
587
+ this.#index = start + 1;
588
+ return {
589
+ type: 'Text',
590
+ content: '[',
591
+ start,
592
+ end: this.#index,
593
+ };
594
+ }
595
+ this.#index++; // consume ]
596
+
597
+ // Check for opening (
598
+ if (
599
+ this.#index >= this.#template.length ||
600
+ this.#template.charCodeAt(this.#index) !== LEFT_PAREN
601
+ ) {
602
+ // No opening (, treat as text
603
+ this.#index = start + 1;
604
+ return {
605
+ type: 'Text',
606
+ content: '[',
607
+ start,
608
+ end: this.#index,
609
+ };
610
+ }
611
+ this.#index++;
612
+
613
+ // Find closing )
614
+ const close_paren = this.#template.indexOf(')', this.#index);
615
+ if (close_paren === -1) {
616
+ // No closing ), treat as text
617
+ this.#index = start + 1;
618
+ return {
619
+ type: 'Text',
620
+ content: '[',
621
+ start,
622
+ end: this.#index,
623
+ };
624
+ }
625
+
626
+ // Extract URL/path
627
+ const reference = this.#template.slice(this.#index, close_paren);
628
+
629
+ // Validate reference is not empty or whitespace-only
630
+ if (!reference.trim()) {
631
+ // Empty reference, treat as text
632
+ this.#index = start + 1;
633
+ return {
634
+ type: 'Text',
635
+ content: '[',
636
+ start,
637
+ end: this.#index,
638
+ };
639
+ }
640
+
641
+ // Validate all characters in reference are valid URI characters per RFC 3986
642
+ // This prevents spaces and other invalid characters from being in markdown link URLs
643
+ // Follows GFM behavior: invalid chars cause fallback to text, then auto-detection
644
+ for (let i = 0; i < reference.length; i++) {
645
+ const char_code = reference.charCodeAt(i);
646
+ if (!this.#is_valid_path_char(char_code)) {
647
+ // Invalid character in URL, treat as text and let auto-detection handle it
648
+ this.#index = start + 1;
649
+ return {
650
+ type: 'Text',
651
+ content: '[',
652
+ start,
653
+ end: this.#index,
654
+ };
655
+ }
656
+ }
657
+
658
+ this.#index = close_paren + 1;
659
+
660
+ // Determine link type (external vs internal)
661
+ const link_type =
662
+ reference.startsWith('https://') || reference.startsWith('http://') ? 'external' : 'internal';
663
+
664
+ return {
665
+ type: 'Link',
666
+ reference,
667
+ children,
668
+ link_type,
669
+ start,
670
+ end: this.#index,
671
+ };
672
+ }
673
+
674
+ /**
675
+ * Parse component/element tag: `<TagName>content</TagName>` or `<TagName />`
676
+ *
677
+ * Formats:
678
+ * - `<Alert>content</Alert>` - Svelte component with children (uppercase first letter)
679
+ * - `<div>content</div>` - HTML element with children (lowercase first letter)
680
+ * - `<Alert />` - self-closing component/element
681
+ *
682
+ * Tag names must start with a letter and can contain letters, numbers, hyphens, underscores.
683
+ *
684
+ * Falls back to text if malformed or unclosed.
685
+ *
686
+ * TODO: Add attribute support like `<Alert status="error">` or `<div class="container">`
687
+ */
688
+ #parse_tag(): MdzElementNode | MdzComponentNode | MdzTextNode {
689
+ const start = this.#index;
690
+
691
+ // Save parent accumulation state to avoid polluting component children with parent's accumulated text
692
+ const saved_state = this.#save_accumulation_state();
693
+
694
+ // Clear accumulation state for parsing component children
695
+ this.#accumulated_text = '';
696
+ this.#nodes.length = 0;
697
+
698
+ // Consume <
699
+ if (!this.#match('<')) {
700
+ // Restore parent state before returning
701
+ this.#restore_accumulation_state(saved_state);
702
+
703
+ const content = this.#template[this.#index]!;
704
+ this.#index++;
705
+ return {
706
+ type: 'Text',
707
+ content,
708
+ start,
709
+ end: this.#index,
710
+ };
711
+ }
712
+ this.#index++;
713
+
714
+ // Parse tag name
715
+ const tag_name_start = this.#index;
716
+ let tag_name_end = this.#index;
717
+
718
+ // Tag name must start with a letter
719
+ if (this.#index >= this.#template.length) {
720
+ // Just a `<` at EOF - restore parent state
721
+ this.#restore_accumulation_state(saved_state);
722
+ return {
723
+ type: 'Text',
724
+ content: '<',
725
+ start,
726
+ end: this.#index,
727
+ };
728
+ }
729
+
730
+ const first_char = this.#template.charCodeAt(this.#index);
731
+ if (!this.#is_letter(first_char)) {
732
+ // Not a valid tag, treat as text - restore parent state
733
+ this.#restore_accumulation_state(saved_state);
734
+ return {
735
+ type: 'Text',
736
+ content: '<',
737
+ start,
738
+ end: start + 1,
739
+ };
740
+ }
741
+
742
+ // Collect tag name (letters, numbers, hyphens, underscores)
743
+ while (this.#index < this.#template.length) {
744
+ const char_code = this.#template.charCodeAt(this.#index);
745
+ if (this.#is_tag_name_char(char_code)) {
746
+ this.#index++;
747
+ } else {
748
+ break;
749
+ }
750
+ }
751
+
752
+ tag_name_end = this.#index;
753
+ const tag_name = this.#template.slice(tag_name_start, tag_name_end);
754
+
755
+ if (tag_name.length === 0) {
756
+ // Empty tag name - restore parent state
757
+ this.#restore_accumulation_state(saved_state);
758
+ return {
759
+ type: 'Text',
760
+ content: '<',
761
+ start,
762
+ end: start + 1,
763
+ };
764
+ }
765
+
766
+ // Determine if this is a Component (uppercase) or Element (lowercase)
767
+ const first_char_code = tag_name.charCodeAt(0);
768
+ const is_component = first_char_code >= A_UPPER && first_char_code <= Z_UPPER;
769
+ const node_type: 'Component' | 'Element' = is_component ? 'Component' : 'Element';
770
+
771
+ // Skip whitespace after tag name (for future attribute support)
772
+ while (
773
+ this.#index < this.#template.length &&
774
+ this.#template.charCodeAt(this.#index) === SPACE
775
+ ) {
776
+ this.#index++;
777
+ }
778
+
779
+ // TODO: Parse attributes here
780
+
781
+ // Check for self-closing />
782
+ if (
783
+ this.#index + 1 < this.#template.length &&
784
+ this.#template.charCodeAt(this.#index) === SLASH &&
785
+ this.#template.charCodeAt(this.#index + 1) === RIGHT_ANGLE
786
+ ) {
787
+ this.#index += 2;
788
+
789
+ // Restore parent state before returning
790
+ this.#restore_accumulation_state(saved_state);
791
+
792
+ return {
793
+ type: node_type,
794
+ name: tag_name,
795
+ children: [],
796
+ start,
797
+ end: this.#index,
798
+ };
799
+ }
800
+
801
+ // Check for closing >
802
+ if (
803
+ this.#index >= this.#template.length ||
804
+ this.#template.charCodeAt(this.#index) !== RIGHT_ANGLE
805
+ ) {
806
+ // Unclosed opening tag, treat as text - restore parent state
807
+ this.#restore_accumulation_state(saved_state);
808
+
809
+ this.#index = start + 1;
810
+ return {
811
+ type: 'Text',
812
+ content: '<',
813
+ start,
814
+ end: this.#index,
815
+ };
816
+ }
817
+
818
+ // Consume >
819
+ this.#index++;
820
+
821
+ // Parse children until closing tag
822
+ const closing_tag = `</${tag_name}>`;
823
+ const children: Array<MdzNode> = [];
824
+
825
+ while (this.#index < this.#template.length) {
826
+ // Check for closing tag
827
+ if (this.#match(closing_tag)) {
828
+ // Flush any accumulated text from children
829
+ this.#flush_text();
830
+ children.push(...this.#nodes);
831
+ this.#nodes.length = 0;
832
+
833
+ this.#index += closing_tag.length;
834
+
835
+ // Restore parent state before returning
836
+ this.#restore_accumulation_state(saved_state);
837
+
838
+ return {
839
+ type: node_type,
840
+ name: tag_name,
841
+ children,
842
+ start,
843
+ end: this.#index,
844
+ };
845
+ }
846
+
847
+ const node = this.#parse_node();
848
+ if (node.type === 'Text') {
849
+ this.#accumulate_text(node.content, node.start);
850
+ } else {
851
+ this.#flush_text();
852
+ children.push(...this.#nodes);
853
+ this.#nodes.length = 0;
854
+ children.push(node);
855
+ }
856
+ }
857
+
858
+ // Unclosed tag - reached EOF without finding closing tag
859
+ // Treat the opening tag as text - restore parent state
860
+ this.#restore_accumulation_state(saved_state);
861
+
862
+ this.#index = start + 1;
863
+ return {
864
+ type: 'Text',
865
+ content: '<',
866
+ start,
867
+ end: this.#index,
868
+ };
869
+ }
870
+
871
+ /**
872
+ * Check if nodes represent a single tag (component or element) with only whitespace text nodes.
873
+ * Used to determine if paragraph wrapping should be skipped (MDX convention).
874
+ * Returns true if there's exactly one Component/Element node and all other nodes are whitespace-only Text nodes.
875
+ */
876
+ #is_single_tag(nodes: Array<MdzNode>): boolean {
877
+ let found_tag = false;
878
+
879
+ for (const node of nodes) {
880
+ if (node.type === 'Component' || node.type === 'Element') {
881
+ if (found_tag) return false; // Multiple tags
882
+ found_tag = true;
883
+ } else if (node.type === 'Text') {
884
+ // Allow only whitespace-only text nodes
885
+ if (node.content.trim() !== '') return false;
886
+ } else {
887
+ // Any other node type means not a single tag
888
+ return false;
889
+ }
890
+ }
891
+
892
+ return found_tag;
893
+ }
894
+
895
+ /**
896
+ * Try to parse a block element (heading, hr, or codeblock) at current position.
897
+ * Returns the parsed block node if a match is found, null otherwise.
898
+ *
899
+ * Block elements must:
900
+ * - Start at column 0 (no leading whitespace)
901
+ * - Be followed by blank line or EOF
902
+ *
903
+ * This helper eliminates duplication between document start and post-paragraph-break parsing.
904
+ */
905
+ #try_parse_block_element(): MdzHeadingNode | MdzHrNode | MdzCodeblockNode | null {
906
+ if (this.#match_heading()) {
907
+ return this.#parse_heading();
908
+ } else if (this.#match_hr()) {
909
+ return this.#parse_hr();
910
+ } else if (this.#match_code_block()) {
911
+ return this.#parse_code_block();
912
+ }
913
+ return null;
914
+ }
915
+
916
+ /**
917
+ * Save current text accumulation state.
918
+ * Used when parsing nested structures (like components/elements) that need isolated accumulation.
919
+ * Returns state object that can be passed to `#restore_accumulation_state()`.
920
+ */
921
+ #save_accumulation_state(): {
922
+ accumulated_text: string;
923
+ accumulated_start: number;
924
+ nodes: Array<MdzNode>;
925
+ } {
926
+ return {
927
+ accumulated_text: this.#accumulated_text,
928
+ accumulated_start: this.#accumulated_start,
929
+ nodes: this.#nodes.slice(),
930
+ };
931
+ }
932
+
933
+ /**
934
+ * Restore previously saved text accumulation state.
935
+ * Used to restore parent state when exiting nested structure parsing.
936
+ * @param state - State object returned from `#save_accumulation_state()`
937
+ */
938
+ #restore_accumulation_state(state: {
939
+ accumulated_text: string;
940
+ accumulated_start: number;
941
+ nodes: Array<MdzNode>;
942
+ }): void {
943
+ this.#accumulated_text = state.accumulated_text;
944
+ this.#accumulated_start = state.accumulated_start;
945
+ this.#nodes.length = 0;
946
+ this.#nodes.push(...state.nodes);
947
+ }
948
+
949
+ /**
950
+ * Check if character code is a letter (A-Z, a-z).
951
+ */
952
+ #is_letter(char_code: number): boolean {
953
+ return (
954
+ (char_code >= A_UPPER && char_code <= Z_UPPER) ||
955
+ (char_code >= A_LOWER && char_code <= Z_LOWER)
956
+ );
957
+ }
958
+
959
+ /**
960
+ * Check if character code is valid for tag name (letter, number, hyphen, underscore).
961
+ */
962
+ #is_tag_name_char(char_code: number): boolean {
963
+ return (
964
+ this.#is_letter(char_code) ||
965
+ (char_code >= ZERO && char_code <= NINE) ||
966
+ char_code === HYPHEN ||
967
+ char_code === UNDERSCORE
968
+ );
969
+ }
970
+
971
+ /**
972
+ * Check if character is part of a word for word boundary detection.
973
+ * Used to prevent intraword emphasis with `_` and `~` delimiters.
974
+ *
975
+ * Formatting delimiters (`*`, `_`, `~`) are NOT word characters - they're transparent.
976
+ * Only alphanumeric characters (A-Z, a-z, 0-9) are considered word characters.
977
+ *
978
+ * This prevents false positives with snake_case identifiers while allowing
979
+ * adjacent formatting like `**bold**_italic_`.
980
+ *
981
+ * @param char_code - Character code to check
982
+ */
983
+ #is_word_char(char_code: number): boolean {
984
+ // Formatting delimiters are never word chars (transparent for boundary checks)
985
+ if (char_code === ASTERISK || char_code === UNDERSCORE || char_code === TILDE) {
986
+ return false;
987
+ }
988
+
989
+ // Alphanumeric characters are word chars for all delimiters
990
+ return (
991
+ (char_code >= A_UPPER && char_code <= Z_UPPER) ||
992
+ (char_code >= A_LOWER && char_code <= Z_LOWER) ||
993
+ (char_code >= ZERO && char_code <= NINE)
994
+ );
995
+ }
996
+
997
+ /**
998
+ * Check if position is at a word boundary.
999
+ * Word boundary = not surrounded by word characters (A-Z, a-z, 0-9).
1000
+ * Used to prevent intraword emphasis for underscores and tildes.
1001
+ *
1002
+ * @param index - Position to check
1003
+ * @param check_before - Whether to check the character before this position
1004
+ * @param check_after - Whether to check the character after this position
1005
+ */
1006
+ #is_at_word_boundary(index: number, check_before: boolean, check_after: boolean): boolean {
1007
+ if (check_before && index > 0) {
1008
+ const prev = this.#template.charCodeAt(index - 1);
1009
+ // If preceded by word char, not at boundary
1010
+ if (this.#is_word_char(prev)) {
1011
+ return false;
1012
+ }
1013
+ }
1014
+
1015
+ if (check_after && index < this.#template.length) {
1016
+ const next = this.#template.charCodeAt(index);
1017
+ // If followed by word char, not at boundary
1018
+ if (this.#is_word_char(next)) {
1019
+ return false;
1020
+ }
1021
+ }
1022
+
1023
+ return true;
1024
+ }
1025
+
1026
+ /**
1027
+ * Check if character code is valid in URI path per RFC 3986.
1028
+ * Validates against the `pchar` production plus path/query/fragment separators.
1029
+ *
1030
+ * Valid characters:
1031
+ * - unreserved: A-Z a-z 0-9 - . _ ~
1032
+ * - sub-delims: ! $ & ' ( ) * + , ; =
1033
+ * - path allowed: : @
1034
+ * - separators: / ? #
1035
+ * - percent-encoding: %
1036
+ */
1037
+ #is_valid_path_char(char_code: number): boolean {
1038
+ return (
1039
+ (char_code >= A_UPPER && char_code <= Z_UPPER) ||
1040
+ (char_code >= A_LOWER && char_code <= Z_LOWER) ||
1041
+ (char_code >= ZERO && char_code <= NINE) ||
1042
+ // unreserved: - . _ ~
1043
+ char_code === HYPHEN ||
1044
+ char_code === PERIOD ||
1045
+ char_code === UNDERSCORE ||
1046
+ char_code === TILDE ||
1047
+ // sub-delims: ! $ & ' ( ) * + , ; =
1048
+ char_code === EXCLAMATION ||
1049
+ char_code === DOLLAR ||
1050
+ char_code === AMPERSAND ||
1051
+ char_code === APOSTROPHE ||
1052
+ char_code === LEFT_PAREN ||
1053
+ char_code === RIGHT_PAREN ||
1054
+ char_code === ASTERISK ||
1055
+ char_code === PLUS ||
1056
+ char_code === COMMA ||
1057
+ char_code === SEMICOLON ||
1058
+ char_code === EQUALS ||
1059
+ // path allowed: : @
1060
+ char_code === COLON ||
1061
+ char_code === AT ||
1062
+ // separators: / ? #
1063
+ char_code === SLASH ||
1064
+ char_code === QUESTION ||
1065
+ char_code === HASH ||
1066
+ // percent-encoding: %
1067
+ char_code === PERCENT
1068
+ );
1069
+ }
1070
+
1071
+ /**
1072
+ * Check if current position is the start of an external URL (https:// or http://).
1073
+ */
1074
+ #is_at_url(): boolean {
1075
+ if (this.#match('https://')) {
1076
+ // Check for protocol-only (e.g., just "https://")
1077
+ // Must have at least one non-whitespace character after protocol
1078
+ if (this.#index + HTTPS_PREFIX_LENGTH >= this.#template.length) {
1079
+ return false;
1080
+ }
1081
+ const next_char = this.#template.charCodeAt(this.#index + HTTPS_PREFIX_LENGTH);
1082
+ return next_char !== SPACE && next_char !== NEWLINE;
1083
+ }
1084
+ if (this.#match('http://')) {
1085
+ // Check for protocol-only (e.g., just "http://")
1086
+ // Must have at least one non-whitespace character after protocol
1087
+ if (this.#index + HTTP_PREFIX_LENGTH >= this.#template.length) {
1088
+ return false;
1089
+ }
1090
+ const next_char = this.#template.charCodeAt(this.#index + HTTP_PREFIX_LENGTH);
1091
+ return next_char !== SPACE && next_char !== NEWLINE;
1092
+ }
1093
+ return false;
1094
+ }
1095
+
1096
+ /**
1097
+ * Check if current position is the start of an internal path (starts with /).
1098
+ */
1099
+ #is_at_internal_path(): boolean {
1100
+ if (this.#template.charCodeAt(this.#index) !== SLASH) {
1101
+ return false;
1102
+ }
1103
+ // Check previous character - must be whitespace or start of string
1104
+ // (to avoid matching / within relative paths like ./a/b or ../a/b)
1105
+ if (this.#index > 0) {
1106
+ const prev_char = this.#template.charCodeAt(this.#index - 1);
1107
+ if (prev_char !== SPACE && prev_char !== NEWLINE && prev_char !== TAB) {
1108
+ return false;
1109
+ }
1110
+ }
1111
+ // Must have at least one more character after /, and it must NOT be:
1112
+ // - another / (to avoid matching // which is used for comments or protocol-relative URLs)
1113
+ // - whitespace (a bare / followed by space is not a useful link)
1114
+ if (this.#index + 1 >= this.#template.length) {
1115
+ return false;
1116
+ }
1117
+ const next_char = this.#template.charCodeAt(this.#index + 1);
1118
+ return next_char !== SLASH && next_char !== SPACE && next_char !== NEWLINE;
1119
+ }
1120
+
1121
+ /**
1122
+ * Parse auto-detected external URL (https:// or http://).
1123
+ * Uses RFC 3986 whitelist validation for valid URI characters.
1124
+ */
1125
+ #parse_auto_link_url(): MdzLinkNode {
1126
+ const start = this.#index;
1127
+
1128
+ // Consume protocol
1129
+ if (this.#match('https://')) {
1130
+ this.#index += HTTPS_PREFIX_LENGTH;
1131
+ } else if (this.#match('http://')) {
1132
+ this.#index += HTTP_PREFIX_LENGTH;
1133
+ }
1134
+
1135
+ // Collect URL characters using RFC 3986 whitelist
1136
+ // Stop at whitespace or any character invalid in URIs
1137
+ while (this.#index < this.#template.length) {
1138
+ const char_code = this.#current_char();
1139
+ if (char_code === SPACE || char_code === NEWLINE || !this.#is_valid_path_char(char_code)) {
1140
+ break;
1141
+ }
1142
+ this.#index++;
1143
+ }
1144
+
1145
+ let reference = this.#template.slice(start, this.#index);
1146
+
1147
+ // Apply GFM trailing punctuation trimming with balanced parentheses
1148
+ reference = this.#trim_trailing_punctuation(reference);
1149
+
1150
+ // Update index after trimming
1151
+ this.#index = start + reference.length;
1152
+
1153
+ return {
1154
+ type: 'Link',
1155
+ reference,
1156
+ children: [{type: 'Text', content: reference, start, end: this.#index}],
1157
+ link_type: 'external',
1158
+ start,
1159
+ end: this.#index,
1160
+ };
1161
+ }
1162
+
1163
+ /**
1164
+ * Parse auto-detected internal path (starts with /).
1165
+ * Uses RFC 3986 whitelist validation for valid URI characters.
1166
+ */
1167
+ #parse_auto_link_internal(): MdzLinkNode {
1168
+ const start = this.#index;
1169
+
1170
+ // Collect path characters using RFC 3986 whitelist
1171
+ // Stop at whitespace or any character invalid in URIs
1172
+ while (this.#index < this.#template.length) {
1173
+ const char_code = this.#current_char();
1174
+ if (char_code === SPACE || char_code === NEWLINE || !this.#is_valid_path_char(char_code)) {
1175
+ break;
1176
+ }
1177
+ this.#index++;
1178
+ }
1179
+
1180
+ let reference = this.#template.slice(start, this.#index);
1181
+
1182
+ // Apply GFM trailing punctuation trimming
1183
+ reference = this.#trim_trailing_punctuation(reference);
1184
+
1185
+ // Update index after trimming
1186
+ this.#index = start + reference.length;
1187
+
1188
+ return {
1189
+ type: 'Link',
1190
+ reference,
1191
+ children: [{type: 'Text', content: reference, start, end: this.#index}],
1192
+ link_type: 'internal',
1193
+ start,
1194
+ end: this.#index,
1195
+ };
1196
+ }
1197
+
1198
+ /**
1199
+ * Trim trailing punctuation from URL/path per RFC 3986 and GFM rules.
1200
+ * - Trims simple trailing: .,;:!?]
1201
+ * - Balanced logic for () only (valid in path components)
1202
+ * - Invalid chars like [] {} are already stopped by whitelist, but ] trimmed as fallback
1203
+ *
1204
+ * Optimized to avoid O(n²) string slicing - tracks end index and slices once at the end.
1205
+ */
1206
+ #trim_trailing_punctuation(url: string): string {
1207
+ let end = url.length;
1208
+
1209
+ // Trim simple trailing punctuation (] as fallback - whitelist should prevent it)
1210
+ while (end > 0) {
1211
+ const last_char = url.charCodeAt(end - 1);
1212
+ if (
1213
+ last_char === PERIOD ||
1214
+ last_char === COMMA ||
1215
+ last_char === SEMICOLON ||
1216
+ last_char === COLON ||
1217
+ last_char === EXCLAMATION ||
1218
+ last_char === QUESTION ||
1219
+ last_char === RIGHT_BRACKET
1220
+ ) {
1221
+ end--;
1222
+ } else {
1223
+ break;
1224
+ }
1225
+ }
1226
+
1227
+ // Handle balanced parentheses ONLY (parens are valid in URI path components)
1228
+ // Count parentheses in the trimmed portion
1229
+ let open_count = 0;
1230
+ let close_count = 0;
1231
+ for (let i = 0; i < end; i++) {
1232
+ const char = url.charCodeAt(i);
1233
+ if (char === LEFT_PAREN) open_count++;
1234
+ if (char === RIGHT_PAREN) close_count++;
1235
+ }
1236
+
1237
+ // Trim unmatched trailing closing parens
1238
+ while (end > 0 && close_count > open_count) {
1239
+ const last_char = url.charCodeAt(end - 1);
1240
+ if (last_char === RIGHT_PAREN) {
1241
+ end--;
1242
+ close_count--;
1243
+ } else {
1244
+ break;
1245
+ }
1246
+ }
1247
+
1248
+ // Return original string if no trimming, otherwise slice once
1249
+ return end === url.length ? url : url.slice(0, end);
1250
+ }
1251
+
1252
+ /**
1253
+ * Parse plain text until special character encountered.
1254
+ * Preserves all whitespace (except paragraph breaks handled separately).
1255
+ * Detects and delegates to URL/path parsing when encountered.
1256
+ */
1257
+ #parse_text(): MdzTextNode | MdzLinkNode {
1258
+ const start = this.#index;
1259
+
1260
+ // Check for URL or internal path at current position
1261
+ if (this.#is_at_url()) {
1262
+ return this.#parse_auto_link_url();
1263
+ }
1264
+ if (this.#is_at_internal_path()) {
1265
+ return this.#parse_auto_link_internal();
1266
+ }
1267
+
1268
+ while (this.#index < this.#template.length) {
1269
+ const char_code = this.#current_char();
1270
+
1271
+ // Stop at special characters (but preserve single newlines)
1272
+ if (
1273
+ char_code === BACKTICK ||
1274
+ char_code === ASTERISK ||
1275
+ char_code === UNDERSCORE ||
1276
+ char_code === TILDE ||
1277
+ char_code === LEFT_BRACKET ||
1278
+ char_code === RIGHT_BRACKET ||
1279
+ char_code === RIGHT_PAREN ||
1280
+ char_code === LEFT_ANGLE
1281
+ ) {
1282
+ break;
1283
+ }
1284
+
1285
+ // Check for paragraph break (double newline)
1286
+ if (this.#is_at_paragraph_break()) {
1287
+ break;
1288
+ }
1289
+
1290
+ // Check for URL or internal path mid-text
1291
+ if (this.#is_at_url() || this.#is_at_internal_path()) {
1292
+ break;
1293
+ }
1294
+
1295
+ this.#index++;
1296
+ }
1297
+
1298
+ // Ensure we always consume at least one character to prevent infinite loops
1299
+ if (this.#index === start && this.#index < this.#template.length) {
1300
+ this.#index++;
1301
+ }
1302
+
1303
+ // Use slice instead of concatenation for performance
1304
+ const content = this.#template.slice(start, this.#index);
1305
+
1306
+ return {
1307
+ type: 'Text',
1308
+ content,
1309
+ start,
1310
+ end: this.#index,
1311
+ };
1312
+ }
1313
+
1314
+ /**
1315
+ * Parse nodes until delimiter string is found.
1316
+ * Used for parsing children of inline formatting (bold, italic, strikethrough) and markdown links.
1317
+ *
1318
+ * Implements greedy/bounded parsing to prevent nested formatters from consuming parent delimiters:
1319
+ * - When parsing `**bold with _italic_**`, the outer `**` parser finds its closing delimiter at position Y
1320
+ * - Sets `#max_search_index = Y` to create a boundary
1321
+ * - Parses children only within range, preventing `_italic_` from finding delimiters beyond Y
1322
+ * - This ensures proper nesting without backtracking
1323
+ *
1324
+ * Stops parsing when:
1325
+ * - Delimiter string is found
1326
+ * - Paragraph break (double newline) is encountered (allows block elements to interrupt inline formatting)
1327
+ * - `end_index` boundary is reached
1328
+ *
1329
+ * @param delimiter - The delimiter string to stop at (e.g., '**', '_', ']')
1330
+ * @param end_index - Optional maximum index to parse up to (for greedy/bounded parsing)
1331
+ * @returns Array of parsed nodes (may be empty if delimiter found immediately)
1332
+ */
1333
+ #parse_nodes_until(delimiter: string, end_index?: number): Array<MdzNode> {
1334
+ const nodes: Array<MdzNode> = [];
1335
+ const max_index = end_index ?? this.#template.length;
1336
+
1337
+ // Save and set max search boundary for nested parsers
1338
+ const saved_max_search_index = this.#max_search_index;
1339
+ this.#max_search_index = max_index;
1340
+
1341
+ while (this.#index < max_index) {
1342
+ if (this.#match(delimiter)) {
1343
+ break;
1344
+ }
1345
+
1346
+ // Check for paragraph break (block element interruption)
1347
+ if (this.#is_at_paragraph_break()) {
1348
+ // Paragraph break interrupts inline formatting
1349
+ break;
1350
+ }
1351
+
1352
+ const node = this.#parse_node();
1353
+ nodes.push(node);
1354
+ }
1355
+
1356
+ // Restore previous boundary
1357
+ this.#max_search_index = saved_max_search_index;
1358
+
1359
+ return nodes;
1360
+ }
1361
+
1362
+ /**
1363
+ * Get character code at current index, or -1 if at EOF.
1364
+ */
1365
+ #current_char(): number {
1366
+ return this.#index < this.#template.length ? this.#template.charCodeAt(this.#index) : -1;
1367
+ }
1368
+
1369
+ /**
1370
+ * Check if current position is at a paragraph break (double newline).
1371
+ */
1372
+ #is_at_paragraph_break(): boolean {
1373
+ return (
1374
+ this.#current_char() === NEWLINE &&
1375
+ this.#index + 1 < this.#template.length &&
1376
+ this.#template.charCodeAt(this.#index + 1) === NEWLINE
1377
+ );
1378
+ }
1379
+
1380
+ #match(str: string): boolean {
1381
+ return this.#template.startsWith(str, this.#index);
1382
+ }
1383
+
1384
+ /**
1385
+ * Consume string at current index, or throw error.
1386
+ */
1387
+ #eat(str: string): void {
1388
+ if (this.#match(str)) {
1389
+ this.#index += str.length;
1390
+ } else {
1391
+ throw Error(`Expected "${str}" at index ${this.#index}`);
1392
+ }
1393
+ }
1394
+
1395
+ /**
1396
+ * Check if current position matches a horizontal rule.
1397
+ * HR must be exactly `---` at column 0, followed by blank line or EOF.
1398
+ *
1399
+ * Blank line requirement rationale:
1400
+ * Prevents block elements from accidentally consuming following content.
1401
+ * Without this, `---` followed by regular text would create an hr and treat
1402
+ * the next line as a new paragraph, which could be surprising. The blank line
1403
+ * makes block element boundaries explicit and predictable.
1404
+ */
1405
+ #match_hr(): boolean {
1406
+ let i = this.#index;
1407
+
1408
+ // Must start at column 0 (no leading whitespace)
1409
+ if (i < this.#template.length && this.#template.charCodeAt(i) === SPACE) {
1410
+ return false;
1411
+ }
1412
+
1413
+ // Must have exactly three hyphens
1414
+ if (
1415
+ i + HR_HYPHEN_COUNT > this.#template.length ||
1416
+ this.#template.charCodeAt(i) !== HYPHEN ||
1417
+ this.#template.charCodeAt(i + 1) !== HYPHEN ||
1418
+ this.#template.charCodeAt(i + 2) !== HYPHEN
1419
+ ) {
1420
+ return false;
1421
+ }
1422
+ i += HR_HYPHEN_COUNT;
1423
+
1424
+ // After the three hyphens, only whitespace and newline (or EOF) allowed
1425
+ while (i < this.#template.length) {
1426
+ const char_code = this.#template.charCodeAt(i);
1427
+ if (char_code === NEWLINE) {
1428
+ // Found newline - check if followed by another newline (blank line) or EOF
1429
+ const next_i = i + 1;
1430
+ if (next_i >= this.#template.length) {
1431
+ return true; // hr followed by newline + EOF
1432
+ }
1433
+ if (this.#template.charCodeAt(next_i) === NEWLINE) {
1434
+ return true; // hr followed by blank line
1435
+ }
1436
+ return false; // hr followed by single newline + content
1437
+ }
1438
+ if (char_code !== SPACE) {
1439
+ return false; // Non-whitespace after ---, not an hr
1440
+ }
1441
+ i++;
1442
+ }
1443
+
1444
+ // Reached EOF after ---, valid hr
1445
+ return true;
1446
+ }
1447
+
1448
+ /**
1449
+ * Parse horizontal rule: `---`
1450
+ * Assumes #match_hr() already verified this is an hr.
1451
+ */
1452
+ #parse_hr(): MdzHrNode {
1453
+ const start = this.#index;
1454
+
1455
+ // Consume the three hyphens (no leading whitespace - already verified)
1456
+ this.#index += HR_HYPHEN_COUNT;
1457
+
1458
+ // Skip trailing whitespace
1459
+ while (
1460
+ this.#index < this.#template.length &&
1461
+ this.#template.charCodeAt(this.#index) === SPACE
1462
+ ) {
1463
+ this.#index++;
1464
+ }
1465
+
1466
+ // Don't consume the newline - let the main parse loop handle it
1467
+
1468
+ return {
1469
+ type: 'Hr',
1470
+ start,
1471
+ end: this.#index,
1472
+ };
1473
+ }
1474
+
1475
+ /**
1476
+ * Check if current position matches a heading.
1477
+ * Heading must be 1-6 hashes at column 0, followed by space and content,
1478
+ * followed by blank line or EOF.
1479
+ *
1480
+ * Blank line requirement rationale:
1481
+ * Ensures headings are visually and semantically separate from following content.
1482
+ * Without this, `# Heading\nText` would be ambiguous - is the text part of the
1483
+ * heading or a new paragraph? The blank line makes document structure explicit.
1484
+ */
1485
+ #match_heading(): boolean {
1486
+ let i = this.#index;
1487
+
1488
+ // Must start at column 0 (no leading whitespace)
1489
+ if (i < this.#template.length && this.#template.charCodeAt(i) === SPACE) {
1490
+ return false;
1491
+ }
1492
+
1493
+ // Count hashes (must be 1-6)
1494
+ let hash_count = 0;
1495
+ while (
1496
+ i < this.#template.length &&
1497
+ this.#template.charCodeAt(i) === HASH &&
1498
+ hash_count <= MAX_HEADING_LEVEL
1499
+ ) {
1500
+ hash_count++;
1501
+ i++;
1502
+ }
1503
+
1504
+ if (hash_count === 0 || hash_count > MAX_HEADING_LEVEL) {
1505
+ return false;
1506
+ }
1507
+
1508
+ // Must have space after hashes
1509
+ if (i >= this.#template.length || this.#template.charCodeAt(i) !== SPACE) {
1510
+ return false;
1511
+ }
1512
+ i++; // consume the space
1513
+
1514
+ // Must have non-whitespace content after the space (not just whitespace until newline)
1515
+ let has_content = false;
1516
+ while (i < this.#template.length && this.#template.charCodeAt(i) !== NEWLINE) {
1517
+ const char_code = this.#template.charCodeAt(i);
1518
+ if (char_code !== SPACE && char_code !== TAB) {
1519
+ has_content = true;
1520
+ }
1521
+ i++;
1522
+ }
1523
+
1524
+ if (!has_content) {
1525
+ return false; // heading with only whitespace, treat as plain text
1526
+ }
1527
+
1528
+ // At this point we're at newline or EOF
1529
+ // Check for blank line after (newline + newline) or EOF
1530
+ if (i >= this.#template.length) {
1531
+ return true; // heading at EOF
1532
+ }
1533
+
1534
+ // Must have newline
1535
+ if (this.#template.charCodeAt(i) !== NEWLINE) {
1536
+ return false;
1537
+ }
1538
+
1539
+ const next_i = i + 1;
1540
+ if (next_i >= this.#template.length) {
1541
+ return true; // heading followed by newline + EOF
1542
+ }
1543
+
1544
+ if (this.#template.charCodeAt(next_i) === NEWLINE) {
1545
+ return true; // heading followed by blank line
1546
+ }
1547
+
1548
+ return false; // heading followed by single newline + content
1549
+ }
1550
+
1551
+ /**
1552
+ * Parse heading: `# Heading text`
1553
+ * Assumes #match_heading() already verified this is a heading.
1554
+ */
1555
+ #parse_heading(): MdzHeadingNode {
1556
+ const start = this.#index;
1557
+
1558
+ // Count and consume hashes
1559
+ let level = 0;
1560
+ while (this.#index < this.#template.length && this.#template.charCodeAt(this.#index) === HASH) {
1561
+ level++;
1562
+ this.#index++;
1563
+ }
1564
+
1565
+ // Consume the space after hashes (already verified to exist)
1566
+ this.#index++;
1567
+
1568
+ // Parse inline content until newline
1569
+ const content_nodes: Array<MdzNode> = [];
1570
+
1571
+ while (this.#index < this.#template.length) {
1572
+ const char_code = this.#template.charCodeAt(this.#index);
1573
+
1574
+ if (char_code === NEWLINE) {
1575
+ break;
1576
+ }
1577
+
1578
+ const node = this.#parse_node();
1579
+ if (node.type === 'Text') {
1580
+ // Check if text node includes a newline (since #parse_text doesn't stop at single newlines)
1581
+ // If so, trim it and move index back
1582
+ const newline_index = node.content.indexOf('\n');
1583
+ if (newline_index !== -1) {
1584
+ const trimmed_content = node.content.slice(0, newline_index);
1585
+ if (trimmed_content) {
1586
+ this.#accumulate_text(trimmed_content, node.start);
1587
+ }
1588
+ this.#index = node.start + newline_index;
1589
+ break;
1590
+ } else {
1591
+ this.#accumulate_text(node.content, node.start);
1592
+ }
1593
+ } else {
1594
+ this.#flush_text();
1595
+ content_nodes.push(...this.#nodes);
1596
+ this.#nodes.length = 0;
1597
+ content_nodes.push(node);
1598
+ }
1599
+ }
1600
+
1601
+ this.#flush_text();
1602
+ content_nodes.push(...this.#nodes);
1603
+ this.#nodes.length = 0;
1604
+
1605
+ // Don't consume the newline - let the main parse loop handle it
1606
+
1607
+ return {
1608
+ type: 'Heading',
1609
+ level: level as 1 | 2 | 3 | 4 | 5 | 6,
1610
+ children: content_nodes,
1611
+ start,
1612
+ end: this.#index,
1613
+ };
1614
+ }
1615
+
1616
+ /**
1617
+ * Check if current position matches a code block.
1618
+ * Code block must be 3+ backticks at column 0, followed by blank line or EOF.
1619
+ * Empty code blocks (no content) are treated as invalid.
1620
+ *
1621
+ * Blank line requirement rationale:
1622
+ * Separates code blocks from following content to prevent ambiguity.
1623
+ * Codeblocks are distinct semantic units that should be visually isolated.
1624
+ * The blank line makes it explicit where the code block ends and regular
1625
+ * content begins, following the "explicit over implicit" design principle.
1626
+ */
1627
+ #match_code_block(): boolean {
1628
+ let i = this.#index;
1629
+
1630
+ // Must start at column 0 (no leading whitespace)
1631
+ if (i < this.#template.length && this.#template.charCodeAt(i) === SPACE) {
1632
+ return false;
1633
+ }
1634
+
1635
+ // Must have at least three backticks
1636
+ let backtick_count = 0;
1637
+ while (i < this.#template.length && this.#template.charCodeAt(i) === BACKTICK) {
1638
+ backtick_count++;
1639
+ i++;
1640
+ }
1641
+
1642
+ if (backtick_count < MIN_CODEBLOCK_BACKTICKS) {
1643
+ return false;
1644
+ }
1645
+
1646
+ // Skip optional language hint (consume until space or newline)
1647
+ while (i < this.#template.length) {
1648
+ const char_code = this.#template.charCodeAt(i);
1649
+ if (char_code === SPACE || char_code === NEWLINE) {
1650
+ break;
1651
+ }
1652
+ i++;
1653
+ }
1654
+
1655
+ // Skip any trailing spaces on opening fence line
1656
+ while (i < this.#template.length && this.#template.charCodeAt(i) === SPACE) {
1657
+ i++;
1658
+ }
1659
+
1660
+ // Must have newline after opening fence (or be at EOF)
1661
+ if (i >= this.#template.length) {
1662
+ return false; // No newline, can't be a valid code block
1663
+ }
1664
+
1665
+ if (this.#template.charCodeAt(i) !== NEWLINE) {
1666
+ return false;
1667
+ }
1668
+ i++; // consume the newline
1669
+
1670
+ // Mark content start position (after opening fence newline)
1671
+ const content_start = i;
1672
+
1673
+ // Now search for closing fence
1674
+ const closing_fence = '`'.repeat(backtick_count);
1675
+ while (i < this.#template.length) {
1676
+ // Check if we're at a potential closing fence (must be at start of line)
1677
+ if (this.#template.startsWith(closing_fence, i)) {
1678
+ // Verify it's at column 0 by checking previous character
1679
+ const prev_char = i > 0 ? this.#template.charCodeAt(i - 1) : NEWLINE;
1680
+ if (prev_char === NEWLINE || i === 0) {
1681
+ // Found closing fence - check for empty content first
1682
+ const content = this.#template.slice(content_start, i);
1683
+ const final_content = content.endsWith('\n') ? content.slice(0, -1) : content;
1684
+ if (final_content.length === 0) {
1685
+ return false; // Empty code block has no semantic meaning
1686
+ }
1687
+
1688
+ // Now verify what comes after closing fence
1689
+ let j = i + backtick_count;
1690
+
1691
+ // Skip trailing whitespace on closing fence line
1692
+ while (j < this.#template.length && this.#template.charCodeAt(j) === SPACE) {
1693
+ j++;
1694
+ }
1695
+
1696
+ // Must have newline after closing fence (or be at EOF)
1697
+ if (j >= this.#template.length) {
1698
+ return true;
1699
+ }
1700
+
1701
+ if (this.#template.charCodeAt(j) !== NEWLINE) {
1702
+ // closing fence has non-whitespace after it on same line - not a code block
1703
+ return false;
1704
+ }
1705
+
1706
+ // Check if followed by blank line or EOF
1707
+ const next_j = j + 1;
1708
+ if (next_j >= this.#template.length) {
1709
+ return true; // code block followed by newline + EOF
1710
+ }
1711
+ if (this.#template.charCodeAt(next_j) === NEWLINE) {
1712
+ return true; // code block followed by blank line
1713
+ }
1714
+ return false; // code block followed by single newline + content
1715
+ }
1716
+ }
1717
+ i++;
1718
+ }
1719
+
1720
+ // No closing fence found - not a valid code block
1721
+ return false;
1722
+ }
1723
+
1724
+ /**
1725
+ * Parse code block: ```lang\ncode\n```
1726
+ * Assumes #match_code_block() already verified this is a code block.
1727
+ */
1728
+ #parse_code_block(): MdzCodeblockNode {
1729
+ const start = this.#index;
1730
+
1731
+ // Count and consume opening backticks
1732
+ let backtick_count = 0;
1733
+ while (
1734
+ this.#index < this.#template.length &&
1735
+ this.#template.charCodeAt(this.#index) === BACKTICK
1736
+ ) {
1737
+ backtick_count++;
1738
+ this.#index++;
1739
+ }
1740
+
1741
+ // Parse optional language hint (consume until space or newline)
1742
+ let lang: string | null = null;
1743
+ const lang_start = this.#index;
1744
+ while (this.#index < this.#template.length) {
1745
+ const char_code = this.#template.charCodeAt(this.#index);
1746
+ if (char_code === SPACE || char_code === NEWLINE) {
1747
+ break;
1748
+ }
1749
+ this.#index++;
1750
+ }
1751
+ if (this.#index > lang_start) {
1752
+ lang = this.#template.slice(lang_start, this.#index);
1753
+ }
1754
+
1755
+ // Skip any trailing spaces on opening fence line
1756
+ while (
1757
+ this.#index < this.#template.length &&
1758
+ this.#template.charCodeAt(this.#index) === SPACE
1759
+ ) {
1760
+ this.#index++;
1761
+ }
1762
+
1763
+ // Consume the newline after opening fence (first newline is consumed per spec)
1764
+ if (this.#index < this.#template.length && this.#template.charCodeAt(this.#index) === NEWLINE) {
1765
+ this.#index++;
1766
+ }
1767
+
1768
+ // Collect content until closing fence
1769
+ const content_start = this.#index;
1770
+ const closing_fence = '`'.repeat(backtick_count);
1771
+
1772
+ while (this.#index < this.#template.length) {
1773
+ // Check if we're at the closing fence (must be at start of line)
1774
+ if (this.#template.startsWith(closing_fence, this.#index)) {
1775
+ // Verify it's at column 0 by checking previous character
1776
+ const prev_char = this.#index > 0 ? this.#template.charCodeAt(this.#index - 1) : NEWLINE;
1777
+ if (prev_char === NEWLINE || this.#index === 0) {
1778
+ // Check if it's exactly the right number of backticks at line start
1779
+ let j = this.#index + backtick_count;
1780
+ // After closing fence, only whitespace and newline allowed
1781
+ while (j < this.#template.length && this.#template.charCodeAt(j) === SPACE) {
1782
+ j++;
1783
+ }
1784
+ if (j >= this.#template.length || this.#template.charCodeAt(j) === NEWLINE) {
1785
+ // Valid closing fence
1786
+ const content = this.#template.slice(content_start, this.#index);
1787
+ // Remove trailing newline if present (closing fence comes after a newline)
1788
+ const final_content = content.endsWith('\n') ? content.slice(0, -1) : content;
1789
+
1790
+ // Consume closing fence
1791
+ this.#index += backtick_count;
1792
+
1793
+ // Skip trailing whitespace on closing fence line
1794
+ while (
1795
+ this.#index < this.#template.length &&
1796
+ this.#template.charCodeAt(this.#index) === SPACE
1797
+ ) {
1798
+ this.#index++;
1799
+ }
1800
+
1801
+ // Don't consume the newline - let the main parse loop handle it
1802
+
1803
+ return {
1804
+ type: 'Codeblock',
1805
+ lang,
1806
+ content: final_content,
1807
+ start,
1808
+ end: this.#index,
1809
+ };
1810
+ }
1811
+ }
1812
+ }
1813
+ this.#index++;
1814
+ }
1815
+
1816
+ // Should not reach here if #match_code_block() validated correctly
1817
+ throw Error('Code block not properly closed');
1818
+ }
1819
+ }