@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.
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/dist/Alert.svelte +108 -0
- package/dist/Alert.svelte.d.ts +16 -0
- package/dist/Alert.svelte.d.ts.map +1 -0
- package/dist/ApiDeclarationList.svelte +35 -0
- package/dist/ApiDeclarationList.svelte.d.ts +9 -0
- package/dist/ApiDeclarationList.svelte.d.ts.map +1 -0
- package/dist/ApiIndex.svelte +65 -0
- package/dist/ApiIndex.svelte.d.ts +23 -0
- package/dist/ApiIndex.svelte.d.ts.map +1 -0
- package/dist/ApiModule.svelte +124 -0
- package/dist/ApiModule.svelte.d.ts +22 -0
- package/dist/ApiModule.svelte.d.ts.map +1 -0
- package/dist/Breadcrumb.svelte +83 -0
- package/dist/Breadcrumb.svelte.d.ts +23 -0
- package/dist/Breadcrumb.svelte.d.ts.map +1 -0
- package/dist/Card.svelte +157 -0
- package/dist/Card.svelte.d.ts +13 -0
- package/dist/Card.svelte.d.ts.map +1 -0
- package/dist/ColorSchemeInput.svelte +65 -0
- package/dist/ColorSchemeInput.svelte.d.ts +11 -0
- package/dist/ColorSchemeInput.svelte.d.ts.map +1 -0
- package/dist/Contextmenu.svelte +30 -0
- package/dist/Contextmenu.svelte.d.ts +32 -0
- package/dist/Contextmenu.svelte.d.ts.map +1 -0
- package/dist/ContextmenuEntry.svelte +74 -0
- package/dist/ContextmenuEntry.svelte.d.ts +12 -0
- package/dist/ContextmenuEntry.svelte.d.ts.map +1 -0
- package/dist/ContextmenuLinkEntry.svelte +112 -0
- package/dist/ContextmenuLinkEntry.svelte.d.ts +12 -0
- package/dist/ContextmenuLinkEntry.svelte.d.ts.map +1 -0
- package/dist/ContextmenuRoot.svelte +372 -0
- package/dist/ContextmenuRoot.svelte.d.ts +71 -0
- package/dist/ContextmenuRoot.svelte.d.ts.map +1 -0
- package/dist/ContextmenuRootForSafariCompatibility.svelte +541 -0
- package/dist/ContextmenuRootForSafariCompatibility.svelte.d.ts +79 -0
- package/dist/ContextmenuRootForSafariCompatibility.svelte.d.ts.map +1 -0
- package/dist/ContextmenuSeparator.svelte +16 -0
- package/dist/ContextmenuSeparator.svelte.d.ts +4 -0
- package/dist/ContextmenuSeparator.svelte.d.ts.map +1 -0
- package/dist/ContextmenuSubmenu.svelte +116 -0
- package/dist/ContextmenuSubmenu.svelte.d.ts +10 -0
- package/dist/ContextmenuSubmenu.svelte.d.ts.map +1 -0
- package/dist/ContextmenuTextEntry.svelte +21 -0
- package/dist/ContextmenuTextEntry.svelte.d.ts +10 -0
- package/dist/ContextmenuTextEntry.svelte.d.ts.map +1 -0
- package/dist/CopyToClipboard.svelte +81 -0
- package/dist/CopyToClipboard.svelte.d.ts +18 -0
- package/dist/CopyToClipboard.svelte.d.ts.map +1 -0
- package/dist/DeclarationDetail.svelte +340 -0
- package/dist/DeclarationDetail.svelte.d.ts +8 -0
- package/dist/DeclarationDetail.svelte.d.ts.map +1 -0
- package/dist/DeclarationLink.svelte +50 -0
- package/dist/DeclarationLink.svelte.d.ts +8 -0
- package/dist/DeclarationLink.svelte.d.ts.map +1 -0
- package/dist/Details.svelte +51 -0
- package/dist/Details.svelte.d.ts +20 -0
- package/dist/Details.svelte.d.ts.map +1 -0
- package/dist/Dialog.svelte +217 -0
- package/dist/Dialog.svelte.d.ts +30 -0
- package/dist/Dialog.svelte.d.ts.map +1 -0
- package/dist/Dialogs.svelte +28 -0
- package/dist/Dialogs.svelte.d.ts +11 -0
- package/dist/Dialogs.svelte.d.ts.map +1 -0
- package/dist/Docs.svelte +179 -0
- package/dist/Docs.svelte.d.ts +13 -0
- package/dist/Docs.svelte.d.ts.map +1 -0
- package/dist/DocsContent.svelte +40 -0
- package/dist/DocsContent.svelte.d.ts +14 -0
- package/dist/DocsContent.svelte.d.ts.map +1 -0
- package/dist/DocsFooter.svelte +64 -0
- package/dist/DocsFooter.svelte.d.ts +15 -0
- package/dist/DocsFooter.svelte.d.ts.map +1 -0
- package/dist/DocsLink.svelte +41 -0
- package/dist/DocsLink.svelte.d.ts +12 -0
- package/dist/DocsLink.svelte.d.ts.map +1 -0
- package/dist/DocsList.svelte +44 -0
- package/dist/DocsList.svelte.d.ts +11 -0
- package/dist/DocsList.svelte.d.ts.map +1 -0
- package/dist/DocsMenu.svelte +55 -0
- package/dist/DocsMenu.svelte.d.ts +11 -0
- package/dist/DocsMenu.svelte.d.ts.map +1 -0
- package/dist/DocsMenuHeader.svelte +15 -0
- package/dist/DocsMenuHeader.svelte.d.ts +9 -0
- package/dist/DocsMenuHeader.svelte.d.ts.map +1 -0
- package/dist/DocsModulesList.svelte +32 -0
- package/dist/DocsModulesList.svelte.d.ts +7 -0
- package/dist/DocsModulesList.svelte.d.ts.map +1 -0
- package/dist/DocsPageLinks.svelte +61 -0
- package/dist/DocsPageLinks.svelte.d.ts +8 -0
- package/dist/DocsPageLinks.svelte.d.ts.map +1 -0
- package/dist/DocsPrimaryNav.svelte +93 -0
- package/dist/DocsPrimaryNav.svelte.d.ts +11 -0
- package/dist/DocsPrimaryNav.svelte.d.ts.map +1 -0
- package/dist/DocsSearch.svelte +48 -0
- package/dist/DocsSearch.svelte.d.ts +11 -0
- package/dist/DocsSearch.svelte.d.ts.map +1 -0
- package/dist/DocsSecondaryNav.svelte +63 -0
- package/dist/DocsSecondaryNav.svelte.d.ts +9 -0
- package/dist/DocsSecondaryNav.svelte.d.ts.map +1 -0
- package/dist/DocsTertiaryNav.svelte +118 -0
- package/dist/DocsTertiaryNav.svelte.d.ts +10 -0
- package/dist/DocsTertiaryNav.svelte.d.ts.map +1 -0
- package/dist/EcosystemLinks.svelte +53 -0
- package/dist/EcosystemLinks.svelte.d.ts +7 -0
- package/dist/EcosystemLinks.svelte.d.ts.map +1 -0
- package/dist/EcosystemLinksPanel.svelte +22 -0
- package/dist/EcosystemLinksPanel.svelte.d.ts +8 -0
- package/dist/EcosystemLinksPanel.svelte.d.ts.map +1 -0
- package/dist/GithubLink.svelte +75 -0
- package/dist/GithubLink.svelte.d.ts +14 -0
- package/dist/GithubLink.svelte.d.ts.map +1 -0
- package/dist/Glyph.svelte +28 -0
- package/dist/Glyph.svelte.d.ts +9 -0
- package/dist/Glyph.svelte.d.ts.map +1 -0
- package/dist/Hashlink.svelte +41 -0
- package/dist/Hashlink.svelte.d.ts +8 -0
- package/dist/Hashlink.svelte.d.ts.map +1 -0
- package/dist/HiddenPersonalLinks.svelte +6 -0
- package/dist/HiddenPersonalLinks.svelte.d.ts +27 -0
- package/dist/HiddenPersonalLinks.svelte.d.ts.map +1 -0
- package/dist/HueInput.svelte +127 -0
- package/dist/HueInput.svelte.d.ts +11 -0
- package/dist/HueInput.svelte.d.ts.map +1 -0
- package/dist/ImgOrSvg.svelte +58 -0
- package/dist/ImgOrSvg.svelte.d.ts +25 -0
- package/dist/ImgOrSvg.svelte.d.ts.map +1 -0
- package/dist/LibraryDetail.svelte +297 -0
- package/dist/LibraryDetail.svelte.d.ts +15 -0
- package/dist/LibraryDetail.svelte.d.ts.map +1 -0
- package/dist/LibrarySummary.svelte +151 -0
- package/dist/LibrarySummary.svelte.d.ts +16 -0
- package/dist/LibrarySummary.svelte.d.ts.map +1 -0
- package/dist/MdnLink.svelte +40 -0
- package/dist/MdnLink.svelte.d.ts +8 -0
- package/dist/MdnLink.svelte.d.ts.map +1 -0
- package/dist/Mdz.svelte +30 -0
- package/dist/Mdz.svelte.d.ts +10 -0
- package/dist/Mdz.svelte.d.ts.map +1 -0
- package/dist/MdzNodeView.svelte +93 -0
- package/dist/MdzNodeView.svelte.d.ts +9 -0
- package/dist/MdzNodeView.svelte.d.ts.map +1 -0
- package/dist/ModuleLink.svelte +48 -0
- package/dist/ModuleLink.svelte.d.ts +8 -0
- package/dist/ModuleLink.svelte.d.ts.map +1 -0
- package/dist/PasteFromClipboard.svelte +35 -0
- package/dist/PasteFromClipboard.svelte.d.ts +9 -0
- package/dist/PasteFromClipboard.svelte.d.ts.map +1 -0
- package/dist/PendingAnimation.svelte +62 -0
- package/dist/PendingAnimation.svelte.d.ts +13 -0
- package/dist/PendingAnimation.svelte.d.ts.map +1 -0
- package/dist/PendingButton.svelte +75 -0
- package/dist/PendingButton.svelte.d.ts +17 -0
- package/dist/PendingButton.svelte.d.ts.map +1 -0
- package/dist/ProjectLinks.svelte +54 -0
- package/dist/ProjectLinks.svelte.d.ts +19 -0
- package/dist/ProjectLinks.svelte.d.ts.map +1 -0
- package/dist/Redirect.svelte +44 -0
- package/dist/Redirect.svelte.d.ts +23 -0
- package/dist/Redirect.svelte.d.ts.map +1 -0
- package/dist/Spiders.svelte +57 -0
- package/dist/Spiders.svelte.d.ts +9 -0
- package/dist/Spiders.svelte.d.ts.map +1 -0
- package/dist/Svg.svelte +99 -0
- package/dist/Svg.svelte.d.ts +54 -0
- package/dist/Svg.svelte.d.ts.map +1 -0
- package/dist/Teleport.svelte +48 -0
- package/dist/Teleport.svelte.d.ts +15 -0
- package/dist/Teleport.svelte.d.ts.map +1 -0
- package/dist/ThemeInput.svelte +75 -0
- package/dist/ThemeInput.svelte.d.ts +15 -0
- package/dist/ThemeInput.svelte.d.ts.map +1 -0
- package/dist/Themed.svelte +101 -0
- package/dist/Themed.svelte.d.ts +24 -0
- package/dist/Themed.svelte.d.ts.map +1 -0
- package/dist/TomeContent.svelte +67 -0
- package/dist/TomeContent.svelte.d.ts +12 -0
- package/dist/TomeContent.svelte.d.ts.map +1 -0
- package/dist/TomeHeader.svelte +56 -0
- package/dist/TomeHeader.svelte.d.ts +4 -0
- package/dist/TomeHeader.svelte.d.ts.map +1 -0
- package/dist/TomeLink.svelte +29 -0
- package/dist/TomeLink.svelte.d.ts +10 -0
- package/dist/TomeLink.svelte.d.ts.map +1 -0
- package/dist/TomeSection.svelte +65 -0
- package/dist/TomeSection.svelte.d.ts +24 -0
- package/dist/TomeSection.svelte.d.ts.map +1 -0
- package/dist/TomeSectionHeader.svelte +90 -0
- package/dist/TomeSectionHeader.svelte.d.ts +13 -0
- package/dist/TomeSectionHeader.svelte.d.ts.map +1 -0
- package/dist/TypeLink.svelte +19 -0
- package/dist/TypeLink.svelte.d.ts +7 -0
- package/dist/TypeLink.svelte.d.ts.map +1 -0
- package/dist/alert.d.ts +7 -0
- package/dist/alert.d.ts.map +1 -0
- package/dist/alert.js +6 -0
- package/dist/api_search.svelte.d.ts +16 -0
- package/dist/api_search.svelte.d.ts.map +1 -0
- package/dist/api_search.svelte.js +61 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +3 -0
- package/dist/context_helpers.d.ts +17 -0
- package/dist/context_helpers.d.ts.map +1 -0
- package/dist/context_helpers.js +19 -0
- package/dist/contextmenu_helpers.d.ts +16 -0
- package/dist/contextmenu_helpers.d.ts.map +1 -0
- package/dist/contextmenu_helpers.js +39 -0
- package/dist/contextmenu_state.svelte.d.ts +152 -0
- package/dist/contextmenu_state.svelte.d.ts.map +1 -0
- package/dist/contextmenu_state.svelte.js +424 -0
- package/dist/csp.d.ts +160 -0
- package/dist/csp.d.ts.map +1 -0
- package/dist/csp.js +354 -0
- package/dist/csp_of_ryanatkn.d.ts +6 -0
- package/dist/csp_of_ryanatkn.d.ts.map +1 -0
- package/dist/csp_of_ryanatkn.js +14 -0
- package/dist/declaration.svelte.d.ts +84 -0
- package/dist/declaration.svelte.d.ts.map +1 -0
- package/dist/declaration.svelte.js +66 -0
- package/dist/declaration_contextmenu.d.ts +4 -0
- package/dist/declaration_contextmenu.d.ts.map +1 -0
- package/dist/declaration_contextmenu.js +14 -0
- package/dist/dialog.d.ts +24 -0
- package/dist/dialog.d.ts.map +1 -0
- package/dist/dialog.js +12 -0
- package/dist/dimensions.svelte.d.ts +5 -0
- package/dist/dimensions.svelte.d.ts.map +1 -0
- package/dist/dimensions.svelte.js +4 -0
- package/dist/docs_helpers.svelte.d.ts +48 -0
- package/dist/docs_helpers.svelte.d.ts.map +1 -0
- package/dist/docs_helpers.svelte.js +99 -0
- package/dist/helpers.d.ts +2 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.js +16 -0
- package/dist/intersect.svelte.d.ts +47 -0
- package/dist/intersect.svelte.d.ts.map +1 -0
- package/dist/intersect.svelte.js +92 -0
- package/dist/library.svelte.d.ts +197 -0
- package/dist/library.svelte.d.ts.map +1 -0
- package/dist/library.svelte.js +130 -0
- package/dist/library_gen.d.ts +34 -0
- package/dist/library_gen.d.ts.map +1 -0
- package/dist/library_gen.js +123 -0
- package/dist/library_gen_helpers.d.ts +85 -0
- package/dist/library_gen_helpers.d.ts.map +1 -0
- package/dist/library_gen_helpers.js +188 -0
- package/dist/library_helpers.d.ts +54 -0
- package/dist/library_helpers.d.ts.map +1 -0
- package/dist/library_helpers.js +102 -0
- package/dist/logos.d.ts +134 -0
- package/dist/logos.d.ts.map +1 -0
- package/dist/logos.js +281 -0
- package/dist/mdz.d.ts +106 -0
- package/dist/mdz.d.ts.map +1 -0
- package/dist/mdz.js +1481 -0
- package/dist/mdz_components.d.ts +37 -0
- package/dist/mdz_components.d.ts.map +1 -0
- package/dist/mdz_components.js +12 -0
- package/dist/module.svelte.d.ts +47 -0
- package/dist/module.svelte.d.ts.map +1 -0
- package/dist/module.svelte.js +56 -0
- package/dist/module_contextmenu.d.ts +4 -0
- package/dist/module_contextmenu.d.ts.map +1 -0
- package/dist/module_contextmenu.js +14 -0
- package/dist/module_helpers.d.ts +69 -0
- package/dist/module_helpers.d.ts.map +1 -0
- package/dist/module_helpers.js +87 -0
- package/dist/rune_helpers.svelte.d.ts +6 -0
- package/dist/rune_helpers.svelte.d.ts.map +1 -0
- package/dist/rune_helpers.svelte.js +10 -0
- package/dist/storage.d.ts +13 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +43 -0
- package/dist/svelte_helpers.d.ts +37 -0
- package/dist/svelte_helpers.d.ts.map +1 -0
- package/dist/svelte_helpers.js +245 -0
- package/dist/themer.svelte.d.ts +24 -0
- package/dist/themer.svelte.d.ts.map +1 -0
- package/dist/themer.svelte.js +43 -0
- package/dist/tome.d.ts +80 -0
- package/dist/tome.d.ts.map +1 -0
- package/dist/tome.js +27 -0
- package/dist/ts_helpers.d.ts +110 -0
- package/dist/ts_helpers.d.ts.map +1 -0
- package/dist/ts_helpers.js +533 -0
- package/dist/tsdoc_helpers.d.ts +98 -0
- package/dist/tsdoc_helpers.d.ts.map +1 -0
- package/dist/tsdoc_helpers.js +221 -0
- package/package.json +128 -0
- package/src/lib/alert.ts +14 -0
- package/src/lib/api_search.svelte.ts +85 -0
- package/src/lib/constants.ts +3 -0
- package/src/lib/context_helpers.ts +47 -0
- package/src/lib/contextmenu_helpers.ts +63 -0
- package/src/lib/contextmenu_state.svelte.ts +515 -0
- package/src/lib/csp.ts +576 -0
- package/src/lib/csp_of_ryanatkn.ts +16 -0
- package/src/lib/declaration.svelte.ts +102 -0
- package/src/lib/declaration_contextmenu.ts +22 -0
- package/src/lib/dialog.ts +35 -0
- package/src/lib/dimensions.svelte.ts +4 -0
- package/src/lib/docs_helpers.svelte.ts +149 -0
- package/src/lib/helpers.ts +10 -0
- package/src/lib/intersect.svelte.ts +152 -0
- package/src/lib/library.svelte.ts +162 -0
- package/src/lib/library_gen.ts +160 -0
- package/src/lib/library_gen_helpers.ts +262 -0
- package/src/lib/library_helpers.ts +123 -0
- package/src/lib/logos.ts +302 -0
- package/src/lib/mdz.ts +1819 -0
- package/src/lib/mdz_components.ts +34 -0
- package/src/lib/module.svelte.ts +78 -0
- package/src/lib/module_contextmenu.ts +20 -0
- package/src/lib/module_helpers.ts +113 -0
- package/src/lib/rune_helpers.svelte.ts +10 -0
- package/src/lib/storage.ts +48 -0
- package/src/lib/svelte_helpers.ts +303 -0
- package/src/lib/themer.svelte.ts +68 -0
- package/src/lib/tome.ts +38 -0
- package/src/lib/ts_helpers.ts +662 -0
- 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
|
+
}
|