@wdprlib/parser 3.1.2 → 3.2.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 (124) hide show
  1. package/dist/index.cjs +295 -118
  2. package/dist/index.js +272 -95
  3. package/package.json +5 -3
  4. package/src/index.ts +163 -0
  5. package/src/lexer/index.ts +20 -0
  6. package/src/lexer/lexer.ts +687 -0
  7. package/src/lexer/tokens.ts +141 -0
  8. package/src/parser/constants.ts +173 -0
  9. package/src/parser/depth.ts +251 -0
  10. package/src/parser/index.ts +18 -0
  11. package/src/parser/parse.ts +315 -0
  12. package/src/parser/postprocess/divAdjacentParagraph.ts +76 -0
  13. package/src/parser/postprocess/index.ts +15 -0
  14. package/src/parser/postprocess/spanStrip.ts +697 -0
  15. package/src/parser/preprocess/expr.ts +265 -0
  16. package/src/parser/preprocess/index.ts +38 -0
  17. package/src/parser/preprocess/typography.ts +67 -0
  18. package/src/parser/preprocess/utils.ts +250 -0
  19. package/src/parser/preprocess/whitespace.ts +111 -0
  20. package/src/parser/rules/block/align.ts +282 -0
  21. package/src/parser/rules/block/bibliography.ts +359 -0
  22. package/src/parser/rules/block/block-list.ts +689 -0
  23. package/src/parser/rules/block/blockquote.ts +238 -0
  24. package/src/parser/rules/block/center.ts +87 -0
  25. package/src/parser/rules/block/clear-float.ts +75 -0
  26. package/src/parser/rules/block/code.ts +187 -0
  27. package/src/parser/rules/block/collapsible.ts +337 -0
  28. package/src/parser/rules/block/comment.ts +73 -0
  29. package/src/parser/rules/block/content-separator.ts +79 -0
  30. package/src/parser/rules/block/definition-list.ts +270 -0
  31. package/src/parser/rules/block/div.ts +400 -0
  32. package/src/parser/rules/block/embed-block.ts +153 -0
  33. package/src/parser/rules/block/footnoteblock.ts +200 -0
  34. package/src/parser/rules/block/heading.ts +142 -0
  35. package/src/parser/rules/block/horizontal-rule.ts +61 -0
  36. package/src/parser/rules/block/html.ts +222 -0
  37. package/src/parser/rules/block/iframe.ts +239 -0
  38. package/src/parser/rules/block/iftags.ts +150 -0
  39. package/src/parser/rules/block/include.ts +179 -0
  40. package/src/parser/rules/block/index.ts +127 -0
  41. package/src/parser/rules/block/list.ts +244 -0
  42. package/src/parser/rules/block/math.ts +183 -0
  43. package/src/parser/rules/block/module/backlinks/index.ts +31 -0
  44. package/src/parser/rules/block/module/backlinks/types.ts +21 -0
  45. package/src/parser/rules/block/module/categories/index.ts +34 -0
  46. package/src/parser/rules/block/module/categories/types.ts +21 -0
  47. package/src/parser/rules/block/module/css/index.ts +37 -0
  48. package/src/parser/rules/block/module/iftags/condition.ts +109 -0
  49. package/src/parser/rules/block/module/iftags/index.ts +26 -0
  50. package/src/parser/rules/block/module/iftags/preprocess.ts +140 -0
  51. package/src/parser/rules/block/module/iftags/resolve.ts +73 -0
  52. package/src/parser/rules/block/module/iftags/types.ts +63 -0
  53. package/src/parser/rules/block/module/include/index.ts +20 -0
  54. package/src/parser/rules/block/module/include/resolve.ts +556 -0
  55. package/src/parser/rules/block/module/index.ts +122 -0
  56. package/src/parser/rules/block/module/join/index.ts +34 -0
  57. package/src/parser/rules/block/module/join/types.ts +23 -0
  58. package/src/parser/rules/block/module/listpages/compiler.ts +453 -0
  59. package/src/parser/rules/block/module/listpages/extract.ts +410 -0
  60. package/src/parser/rules/block/module/listpages/index.ts +83 -0
  61. package/src/parser/rules/block/module/listpages/normalize.ts +390 -0
  62. package/src/parser/rules/block/module/listpages/parser.ts +106 -0
  63. package/src/parser/rules/block/module/listpages/resolve.ts +130 -0
  64. package/src/parser/rules/block/module/listpages/types.ts +513 -0
  65. package/src/parser/rules/block/module/listpages/url-resolver.ts +186 -0
  66. package/src/parser/rules/block/module/listusers/compiler.ts +77 -0
  67. package/src/parser/rules/block/module/listusers/extract.ts +45 -0
  68. package/src/parser/rules/block/module/listusers/index.ts +36 -0
  69. package/src/parser/rules/block/module/listusers/parser.ts +54 -0
  70. package/src/parser/rules/block/module/listusers/resolve.ts +58 -0
  71. package/src/parser/rules/block/module/listusers/types.ts +93 -0
  72. package/src/parser/rules/block/module/mapping.ts +61 -0
  73. package/src/parser/rules/block/module/page-tree/index.ts +38 -0
  74. package/src/parser/rules/block/module/page-tree/types.ts +29 -0
  75. package/src/parser/rules/block/module/rate/index.ts +28 -0
  76. package/src/parser/rules/block/module/rate/types.ts +19 -0
  77. package/src/parser/rules/block/module/resolve.ts +411 -0
  78. package/src/parser/rules/block/module/types-common.ts +59 -0
  79. package/src/parser/rules/block/module/types.ts +61 -0
  80. package/src/parser/rules/block/module/utils.ts +43 -0
  81. package/src/parser/rules/block/module/walk.ts +380 -0
  82. package/src/parser/rules/block/module.ts +164 -0
  83. package/src/parser/rules/block/orphan-li.ts +177 -0
  84. package/src/parser/rules/block/paragraph.ts +157 -0
  85. package/src/parser/rules/block/table-block.ts +726 -0
  86. package/src/parser/rules/block/table.ts +441 -0
  87. package/src/parser/rules/block/tabview.ts +331 -0
  88. package/src/parser/rules/block/toc.ts +129 -0
  89. package/src/parser/rules/block/utils.ts +615 -0
  90. package/src/parser/rules/index.ts +49 -0
  91. package/src/parser/rules/inline/anchor-name.ts +154 -0
  92. package/src/parser/rules/inline/anchor.ts +327 -0
  93. package/src/parser/rules/inline/bibcite.ts +153 -0
  94. package/src/parser/rules/inline/bold.ts +86 -0
  95. package/src/parser/rules/inline/color.ts +140 -0
  96. package/src/parser/rules/inline/comment.ts +90 -0
  97. package/src/parser/rules/inline/equation-ref.ts +115 -0
  98. package/src/parser/rules/inline/expr.ts +526 -0
  99. package/src/parser/rules/inline/footnote.ts +223 -0
  100. package/src/parser/rules/inline/guillemet.ts +64 -0
  101. package/src/parser/rules/inline/html.ts +132 -0
  102. package/src/parser/rules/inline/image.ts +328 -0
  103. package/src/parser/rules/inline/index.ts +150 -0
  104. package/src/parser/rules/inline/italic.ts +74 -0
  105. package/src/parser/rules/inline/line-break.ts +326 -0
  106. package/src/parser/rules/inline/link-anchor.ts +147 -0
  107. package/src/parser/rules/inline/link-single.ts +164 -0
  108. package/src/parser/rules/inline/link-star.ts +134 -0
  109. package/src/parser/rules/inline/link-triple.ts +267 -0
  110. package/src/parser/rules/inline/math-inline.ts +126 -0
  111. package/src/parser/rules/inline/monospace.ts +78 -0
  112. package/src/parser/rules/inline/raw.ts +262 -0
  113. package/src/parser/rules/inline/size.ts +244 -0
  114. package/src/parser/rules/inline/span.ts +424 -0
  115. package/src/parser/rules/inline/strikethrough.ts +115 -0
  116. package/src/parser/rules/inline/subscript.ts +84 -0
  117. package/src/parser/rules/inline/superscript.ts +84 -0
  118. package/src/parser/rules/inline/text.ts +84 -0
  119. package/src/parser/rules/inline/underline.ts +127 -0
  120. package/src/parser/rules/inline/user.ts +147 -0
  121. package/src/parser/rules/inline/utils.ts +344 -0
  122. package/src/parser/rules/types.ts +252 -0
  123. package/src/parser/rules/utils.ts +155 -0
  124. package/src/parser/toc.ts +130 -0
@@ -0,0 +1,153 @@
1
+ /**
2
+ *
3
+ * Block rule for Wikidot embed blocks: `[[embed]]`, `[[embedvideo]]`,
4
+ * and `[[embedaudio]]` (each with a matching closing tag).
5
+ *
6
+ * In original Wikidot, only HTML that matches a server-side allow-list is
7
+ * rendered. This parser does not perform that filtering -- the raw content
8
+ * between the tags is stored verbatim as an `embed-block` element. Validation
9
+ * and sanitisation are expected to happen at rendering time or on the server.
10
+ *
11
+ * The embed block is wrapped in a paragraph container in the AST, matching
12
+ * Wikidot's rendering behaviour where embeds sit inside `<p>` tags.
13
+ *
14
+ * If no closing tag is found, the rule fails to prevent consuming the rest
15
+ * of the document.
16
+ *
17
+ * @module
18
+ */
19
+ import type { Element } from "@wdprlib/ast";
20
+ import type { BlockRule, ParseContext, RuleResult } from "../types";
21
+ import { currentToken } from "../types";
22
+ import { parseBlockName } from "./utils";
23
+
24
+ /**
25
+ * Block rule for `[[embed]]`, `[[embedvideo]]`, and `[[embedaudio]]`.
26
+ *
27
+ * Content between the opening and closing tags is captured as raw text.
28
+ * The block name matching is case-insensitive; the closing tag may use
29
+ * any of the three names (`embed`, `embedvideo`, `embedaudio`) regardless
30
+ * of which was used to open.
31
+ */
32
+ export const embedBlockRule: BlockRule = {
33
+ name: "embed-block",
34
+ startTokens: ["BLOCK_OPEN"],
35
+ requiresLineStart: false,
36
+
37
+ parse(ctx: ParseContext): RuleResult<Element> {
38
+ const openToken = currentToken(ctx);
39
+ if (openToken.type !== "BLOCK_OPEN") {
40
+ return { success: false };
41
+ }
42
+
43
+ let pos = ctx.pos + 1;
44
+ let consumed = 1;
45
+
46
+ // Parse block name
47
+ const nameResult = parseBlockName(ctx, pos);
48
+ if (!nameResult) {
49
+ return { success: false };
50
+ }
51
+
52
+ const blockName = nameResult.name.toLowerCase();
53
+ if (blockName !== "embed" && blockName !== "embedvideo" && blockName !== "embedaudio") {
54
+ return { success: false };
55
+ }
56
+ pos += nameResult.consumed;
57
+ consumed += nameResult.consumed;
58
+
59
+ // Skip whitespace
60
+ while (ctx.tokens[pos]?.type === "WHITESPACE") {
61
+ pos++;
62
+ consumed++;
63
+ }
64
+
65
+ // Expect ]]
66
+ if (ctx.tokens[pos]?.type !== "BLOCK_CLOSE") {
67
+ return { success: false };
68
+ }
69
+ pos++;
70
+ consumed++;
71
+
72
+ // Collect content until [[/embed]], [[/embedvideo]], or [[/embedaudio]]
73
+ let contents = "";
74
+ let foundClose = false;
75
+
76
+ while (pos < ctx.tokens.length) {
77
+ const token = ctx.tokens[pos];
78
+ if (!token) break;
79
+
80
+ // Check for closing [[/embed*]]
81
+ if (token.type === "BLOCK_END_OPEN") {
82
+ const closeNameResult = parseBlockName(ctx, pos + 1);
83
+ if (closeNameResult) {
84
+ const closeName = closeNameResult.name.toLowerCase();
85
+ if (closeName === "embed" || closeName === "embedvideo" || closeName === "embedaudio") {
86
+ foundClose = true;
87
+ break;
88
+ }
89
+ }
90
+ }
91
+
92
+ contents += token.value;
93
+ pos++;
94
+ consumed++;
95
+ }
96
+
97
+ // Require closing tag - without it, fail to prevent consuming entire document
98
+ if (!foundClose) {
99
+ ctx.diagnostics.push({
100
+ severity: "warning",
101
+ code: "unclosed-block",
102
+ message: `Missing closing tag [[/${blockName}]] for [[${blockName}]]`,
103
+ position: openToken.position,
104
+ });
105
+ return { success: false };
106
+ }
107
+
108
+ // Consume [[/embed*]]
109
+ if (ctx.tokens[pos]?.type === "BLOCK_END_OPEN") {
110
+ pos++;
111
+ consumed++;
112
+ const closeNameResult = parseBlockName(ctx, pos);
113
+ if (closeNameResult) {
114
+ pos += closeNameResult.consumed;
115
+ consumed += closeNameResult.consumed;
116
+ }
117
+ if (ctx.tokens[pos]?.type === "BLOCK_CLOSE") {
118
+ pos++;
119
+ consumed++;
120
+ }
121
+ if (ctx.tokens[pos]?.type === "NEWLINE") {
122
+ pos++;
123
+ consumed++;
124
+ }
125
+ }
126
+
127
+ // Trim the contents
128
+ contents = contents.trim();
129
+
130
+ // Wikidotと同じく、embed-blockをparagraphで囲む
131
+ return {
132
+ success: true,
133
+ elements: [
134
+ {
135
+ element: "container",
136
+ data: {
137
+ type: "paragraph",
138
+ attributes: {},
139
+ elements: [
140
+ {
141
+ element: "embed-block",
142
+ data: {
143
+ contents,
144
+ },
145
+ },
146
+ ],
147
+ },
148
+ },
149
+ ],
150
+ consumed,
151
+ };
152
+ },
153
+ };
@@ -0,0 +1,200 @@
1
+ /**
2
+ *
3
+ * Block rule for the Wikidot footnote block: `[[footnoteblock]]`.
4
+ *
5
+ * This self-closing block tag marks the location in the page where all
6
+ * collected footnotes (from `[[footnote]]...[[/footnote]]` inline markers)
7
+ * should be rendered. It is analogous to a "footnotes section" placeholder.
8
+ *
9
+ * Optional attributes:
10
+ * - `title` -- custom heading text for the footnotes section.
11
+ * - `hide` -- when `"true"` or `"yes"`, suppresses footnote rendering.
12
+ *
13
+ * Wikidot only honours the FIRST `[[footnoteblock]]` in a document;
14
+ * subsequent occurrences are treated as plain text. The parser tracks
15
+ * this via `ctx.scope.footnoteBlockParsed`, but note: that flag is per
16
+ * spread copy of `ParseContext` (see {@link ScopeContext.footnoteBlockParsed}).
17
+ * In practice the duplicate-rejection only fires for two top-level
18
+ * `[[footnoteblock]]` tokens; siblings inside the same body or across
19
+ * nested bodies both succeed today. The auto-append decision in
20
+ * `Parser.parse` uses a post-parse AST walk, so it is unaffected.
21
+ *
22
+ * @module
23
+ */
24
+ import type { Element } from "@wdprlib/ast";
25
+ import type { BlockRule, ParseContext, RuleResult } from "../types";
26
+ import { currentToken } from "../types";
27
+
28
+ /**
29
+ * Parses key/value attributes from tokens (e.g. `title="Custom title"`).
30
+ *
31
+ * This is a local attribute parser specific to the footnoteblock rule.
32
+ * It handles TEXT or IDENTIFIER names, optional `=` with quoted or
33
+ * unquoted values, and boolean attributes (name without value).
34
+ *
35
+ * @param ctx - Parse context.
36
+ * @param startPos - Token index to start scanning.
37
+ * @returns Parsed attribute map and the number of tokens consumed.
38
+ */
39
+ function parseAttributes(
40
+ ctx: ParseContext,
41
+ startPos: number,
42
+ ): { attrs: Record<string, string>; consumed: number } {
43
+ const attrs: Record<string, string> = {};
44
+ let pos = startPos;
45
+ let consumed = 0;
46
+
47
+ while (pos < ctx.tokens.length) {
48
+ const token = ctx.tokens[pos];
49
+ if (
50
+ !token ||
51
+ token.type === "BLOCK_CLOSE" ||
52
+ token.type === "NEWLINE" ||
53
+ token.type === "EOF"
54
+ ) {
55
+ break;
56
+ }
57
+
58
+ // Skip whitespace
59
+ if (token.type === "WHITESPACE") {
60
+ pos++;
61
+ consumed++;
62
+ continue;
63
+ }
64
+
65
+ // Attribute name (TEXT or IDENTIFIER token)
66
+ if (token.type === "TEXT" || token.type === "IDENTIFIER") {
67
+ const name = token.value.toLowerCase();
68
+ pos++;
69
+ consumed++;
70
+
71
+ // Check for =
72
+ const eqToken = ctx.tokens[pos];
73
+ if (eqToken?.type === "EQUALS") {
74
+ pos++;
75
+ consumed++;
76
+
77
+ // Get value (quoted string or text)
78
+ const valueToken = ctx.tokens[pos];
79
+ if (valueToken?.type === "QUOTED_STRING") {
80
+ // Remove quotes
81
+ let value = valueToken.value;
82
+ if (value.startsWith('"') && value.endsWith('"')) {
83
+ value = value.slice(1, -1);
84
+ }
85
+ attrs[name] = value;
86
+ pos++;
87
+ consumed++;
88
+ } else if (valueToken?.type === "TEXT" || valueToken?.type === "IDENTIFIER") {
89
+ attrs[name] = valueToken.value;
90
+ pos++;
91
+ consumed++;
92
+ }
93
+ } else {
94
+ // Boolean attribute
95
+ attrs[name] = "true";
96
+ }
97
+ } else {
98
+ // Unknown token, skip
99
+ pos++;
100
+ consumed++;
101
+ }
102
+ }
103
+
104
+ return { attrs, consumed };
105
+ }
106
+
107
+ /**
108
+ * Block rule for `[[footnoteblock]]`.
109
+ *
110
+ * Parsing strategy:
111
+ * 1. Match BLOCK_OPEN + name "footnoteblock" (case-insensitive).
112
+ * 2. Parse optional attributes (`title`, `hide`).
113
+ * 3. Consume closing `]]`.
114
+ * 4. If `ctx.scope.footnoteBlockParsed` is already `true` on the current
115
+ * `ParseContext` copy, fail. Because `parseBlocksUntil` spreads a
116
+ * fresh `ctx` per sibling rule, this only rejects a second
117
+ * `[[footnoteblock]]` that arrives via the parser's top-level
118
+ * dispatch loop in practice. See {@link ScopeContext.footnoteBlockParsed}.
119
+ * 5. Replace `ctx.scope` with the flag set to `true` and emit a `footnote-block`
120
+ * element.
121
+ */
122
+ export const footnoteBlockRule: BlockRule = {
123
+ name: "footnoteBlock",
124
+ startTokens: ["BLOCK_OPEN"],
125
+ requiresLineStart: true,
126
+
127
+ parse(ctx: ParseContext): RuleResult<Element> {
128
+ const openToken = currentToken(ctx);
129
+ if (openToken.type !== "BLOCK_OPEN") {
130
+ return { success: false };
131
+ }
132
+
133
+ let pos = ctx.pos + 1;
134
+ let consumed = 1;
135
+
136
+ // Skip whitespace
137
+ while (ctx.tokens[pos]?.type === "WHITESPACE") {
138
+ pos++;
139
+ consumed++;
140
+ }
141
+
142
+ // Check for block name
143
+ const nameToken = ctx.tokens[pos];
144
+ if (!nameToken || (nameToken.type !== "TEXT" && nameToken.type !== "IDENTIFIER")) {
145
+ return { success: false };
146
+ }
147
+
148
+ const blockName = nameToken.value.toLowerCase();
149
+ if (blockName !== "footnoteblock") {
150
+ return { success: false };
151
+ }
152
+ pos++;
153
+ consumed++;
154
+
155
+ // Parse optional attributes (title="...")
156
+ const { attrs, consumed: attrConsumed } = parseAttributes(ctx, pos);
157
+ pos += attrConsumed;
158
+ consumed += attrConsumed;
159
+
160
+ // Expect ]]
161
+ while (ctx.tokens[pos]?.type === "WHITESPACE") {
162
+ pos++;
163
+ consumed++;
164
+ }
165
+ if (ctx.tokens[pos]?.type !== "BLOCK_CLOSE") {
166
+ return { success: false };
167
+ }
168
+ pos++;
169
+ consumed++;
170
+
171
+ // Reject a second `[[footnoteblock]]` that arrives on the same
172
+ // `ParseContext` copy (in practice: at the top level — siblings
173
+ // inside `parseBlocksUntil` each get a fresh spread). The flag
174
+ // lives in the immutable `scope` group, so the mutation is
175
+ // expressed as a scope replacement. The cross-scope duplicate-
176
+ // rejection is a separate, known limitation.
177
+ if (ctx.scope.footnoteBlockParsed) {
178
+ return { success: false };
179
+ }
180
+ ctx.scope = { ...ctx.scope, footnoteBlockParsed: true };
181
+
182
+ // Extract title and hide from attributes
183
+ const title = attrs.title !== undefined ? attrs.title : null;
184
+ const hide = attrs.hide === "true" || attrs.hide === "yes";
185
+
186
+ return {
187
+ success: true,
188
+ elements: [
189
+ {
190
+ element: "footnote-block",
191
+ data: {
192
+ title,
193
+ hide,
194
+ },
195
+ },
196
+ ],
197
+ consumed,
198
+ };
199
+ },
200
+ };
@@ -0,0 +1,142 @@
1
+ /**
2
+ *
3
+ * Block rule for Wikidot headings: `+ Heading` through `++++++ Heading`.
4
+ *
5
+ * Headings are written with one to six `+` characters at the start of a
6
+ * line, followed by mandatory whitespace and then inline content:
7
+ *
8
+ * ```
9
+ * + H1 heading
10
+ * ++ H2 heading
11
+ * +++ H3 heading
12
+ * ```
13
+ *
14
+ * An optional `*` immediately after the `+` markers hides the heading
15
+ * from the table of contents:
16
+ *
17
+ * ```
18
+ * +* Hidden H1
19
+ * ```
20
+ *
21
+ * Seven or more `+` characters are NOT valid headings in Wikidot and
22
+ * the rule will fail, letting them fall through to paragraph parsing.
23
+ *
24
+ * Non-hidden headings are registered in `ctx.tocEntries` for later use
25
+ * by the table-of-contents module.
26
+ *
27
+ * @module
28
+ */
29
+ import type { Element, HeadingLevel } from "@wdprlib/ast";
30
+ import type { BlockRule, ParseContext, RuleResult } from "../types";
31
+ import { currentToken } from "../types";
32
+ import { parseInlineUntil } from "../inline/utils";
33
+
34
+ /**
35
+ * Block rule for Wikidot headings (`+ ` through `++++++ `).
36
+ *
37
+ * Produces a container element with `type: { header: { level, "has-toc" } }`.
38
+ * The heading text is parsed for inline markup (bold, links, etc.).
39
+ */
40
+ export const headingRule: BlockRule = {
41
+ name: "heading",
42
+ startTokens: ["HEADING_MARKER"],
43
+ requiresLineStart: true,
44
+
45
+ parse(ctx: ParseContext): RuleResult<Element> {
46
+ const marker = currentToken(ctx);
47
+
48
+ if (!marker.lineStart) {
49
+ return { success: false };
50
+ }
51
+
52
+ // Wikidot requires whitespace after heading marker
53
+ // Check format: + (space)content OR +* (space)content
54
+ let pos = ctx.pos + 1;
55
+ let consumed = 1;
56
+ let hidden = false;
57
+
58
+ // Check for hidden marker (*) - must come immediately after + markers
59
+ if (ctx.tokens[pos]?.type === "STAR") {
60
+ hidden = true;
61
+ pos++;
62
+ consumed++;
63
+ }
64
+
65
+ // Whitespace is required after + or +*
66
+ if (ctx.tokens[pos]?.type !== "WHITESPACE") {
67
+ return { success: false };
68
+ }
69
+
70
+ // Wikidot only supports h1-h6 (1-6 plus signs). 7+ is not a heading.
71
+ if (marker.value.length > 6) {
72
+ return { success: false };
73
+ }
74
+ const depth = marker.value.length as 1 | 2 | 3 | 4 | 5 | 6;
75
+
76
+ // Skip whitespace
77
+ while (ctx.tokens[pos]?.type === "WHITESPACE") {
78
+ pos++;
79
+ consumed++;
80
+ }
81
+
82
+ // Parse inline content until newline
83
+ const inlineCtx: ParseContext = { ...ctx, pos };
84
+ const inlineResult = parseInlineUntil(inlineCtx, "NEWLINE");
85
+ const children: Element[] = inlineResult.elements;
86
+ consumed += inlineResult.consumed;
87
+ pos += inlineResult.consumed;
88
+
89
+ // Consume newline
90
+ if (ctx.tokens[pos]?.type === "NEWLINE") {
91
+ consumed++;
92
+ }
93
+
94
+ // Store TOC entry (only non-hidden headings)
95
+ if (!hidden) {
96
+ const headingText = extractText(children);
97
+ ctx.tocEntries.push({ level: depth, text: headingText });
98
+ }
99
+
100
+ return {
101
+ success: true,
102
+ elements: [
103
+ {
104
+ element: "container",
105
+ data: {
106
+ type: { header: { level: depth as HeadingLevel, "has-toc": !hidden } },
107
+ attributes: {},
108
+ elements: children,
109
+ },
110
+ },
111
+ ],
112
+ consumed,
113
+ };
114
+ },
115
+ };
116
+
117
+ /**
118
+ * Recursively extracts the plain-text content from a tree of elements.
119
+ *
120
+ * Used to build the `text` field for table-of-contents entries. Only
121
+ * `text` elements and containers with nested `elements` are traversed;
122
+ * other element types (images, etc.) are ignored.
123
+ *
124
+ * @param elements - The heading's inline child elements.
125
+ * @returns Concatenated plain text.
126
+ */
127
+ function extractText(elements: Element[]): string {
128
+ let text = "";
129
+ for (const el of elements) {
130
+ if (el.element === "text" && typeof el.data === "string") {
131
+ text += el.data;
132
+ } else if (
133
+ el.element === "container" &&
134
+ el.data &&
135
+ typeof el.data === "object" &&
136
+ "elements" in el.data
137
+ ) {
138
+ text += extractText(el.data.elements as Element[]);
139
+ }
140
+ }
141
+ return text;
142
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ *
3
+ * Block rule for Wikidot horizontal rules: `----` (four or more hyphens
4
+ * at the start of a line).
5
+ *
6
+ * The lexer emits an HR_MARKER token for sequences of four or more `-`
7
+ * characters at line start. This rule consumes the marker, any remaining
8
+ * tokens on the line, and an optional trailing newline, producing a single
9
+ * `horizontal-rule` element (rendered as `<hr />`).
10
+ *
11
+ * Any text after the `----` on the same line is silently discarded,
12
+ * matching Wikidot's behaviour.
13
+ *
14
+ * @module
15
+ */
16
+ import type { Element } from "@wdprlib/ast";
17
+ import type { BlockRule, ParseContext, RuleResult } from "../types";
18
+ import { currentToken } from "../types";
19
+
20
+ /**
21
+ * Block rule for horizontal rules (`----`).
22
+ *
23
+ * Produces a `horizontal-rule` element with no data payload.
24
+ */
25
+ export const horizontalRuleRule: BlockRule = {
26
+ name: "horizontalRule",
27
+ startTokens: ["HR_MARKER"],
28
+ requiresLineStart: true,
29
+
30
+ parse(ctx: ParseContext): RuleResult<Element> {
31
+ const marker = currentToken(ctx);
32
+
33
+ if (!marker.lineStart) {
34
+ return { success: false };
35
+ }
36
+
37
+ let pos = ctx.pos + 1;
38
+ let consumed = 1;
39
+
40
+ // Skip to end of line
41
+ while (pos < ctx.tokens.length) {
42
+ const token = ctx.tokens[pos];
43
+ if (!token || token.type === "NEWLINE" || token.type === "EOF") {
44
+ break;
45
+ }
46
+ pos++;
47
+ consumed++;
48
+ }
49
+
50
+ // Consume newline
51
+ if (ctx.tokens[pos]?.type === "NEWLINE") {
52
+ consumed++;
53
+ }
54
+
55
+ return {
56
+ success: true,
57
+ elements: [{ element: "horizontal-rule" }],
58
+ consumed,
59
+ };
60
+ },
61
+ };