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