@wdprlib/parser 3.1.1 → 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 +312 -121
  2. package/dist/index.js +289 -98
  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,526 @@
1
+ /**
2
+ *
3
+ * Parses Wikidot's inline expression and conditional block syntax:
4
+ *
5
+ * - `[[#expr expression]]` -- evaluate a mathematical expression and display the result
6
+ * - `[[#if value | then | else]]` -- simple truthy/falsy conditional
7
+ * - `[[#ifexpr expression | then | else]]` -- expression-based conditional
8
+ *
9
+ * All three forms begin with `[[#` followed by the keyword. The `#`
10
+ * prefix distinguishes these from regular `[[block]]` syntax.
11
+ *
12
+ * `[[#if]]` treats its condition as a string and considers these values
13
+ * falsy: `"false"`, `"null"`, `""`, `"0"`. Everything else is truthy.
14
+ *
15
+ * `[[#ifexpr]]` evaluates its condition as a mathematical expression
16
+ * and treats the numeric result as falsy when zero.
17
+ *
18
+ * Both conditional forms support an optional else branch: when only
19
+ * one pipe-separated branch is provided, the else branch is empty.
20
+ *
21
+ * Expressions are limited to 256 characters (after trimming) to
22
+ * prevent abuse.
23
+ *
24
+ * The pipe (`|`) delimiter is depth-aware: pipes inside nested `[[]]`
25
+ * or `[[[]]]` blocks are not treated as branch separators.
26
+ *
27
+ * @module
28
+ */
29
+ import type { Element } from "@wdprlib/ast";
30
+ import type { InlineRule, ParseContext, RuleResult } from "../types";
31
+ import { currentToken } from "../types";
32
+
33
+ /** Maximum allowed length for expression strings after trimming. */
34
+ const MAX_EXPRESSION_LENGTH = 256;
35
+
36
+ /**
37
+ * Parses inline content for the then/else branches of `[[#if]]` and `[[#ifexpr]]`.
38
+ *
39
+ * Collects inline elements (by delegating to the registered inline rules) until
40
+ * a top-level `PIPE` token or the enclosing `BLOCK_CLOSE` (`]]`) is reached.
41
+ *
42
+ * Nesting depth is tracked so that `PIPE` and `BLOCK_CLOSE` tokens inside
43
+ * nested `[[...]]` or `[[[...]]]` blocks are not mistaken for branch
44
+ * delimiters or the outer block's closing marker.
45
+ *
46
+ * @param ctx - The current parse context
47
+ * @param startPos - Token index at which to begin scanning branch content
48
+ * @returns An object containing the parsed elements, the number of tokens consumed,
49
+ * and whether the branch ended with a `PIPE` (indicating an else branch follows)
50
+ */
51
+ function parseInlineBranch(
52
+ ctx: ParseContext,
53
+ startPos: number,
54
+ ): { elements: Element[]; consumed: number; endedWithPipe: boolean } {
55
+ const elements: Element[] = [];
56
+ let consumed = 0;
57
+ let pos = startPos;
58
+ let depth = 0; // Track [[ ]] nesting depth
59
+
60
+ const { inlineRules } = ctx;
61
+
62
+ while (pos < ctx.tokens.length) {
63
+ const token = ctx.tokens[pos];
64
+ if (!token || token.type === "EOF" || token.type === "NEWLINE") {
65
+ break;
66
+ }
67
+
68
+ // Track nesting (both [[...]] and [[[...]]] blocks)
69
+ if (
70
+ token.type === "BLOCK_OPEN" ||
71
+ token.type === "BLOCK_END_OPEN" ||
72
+ token.type === "LINK_OPEN"
73
+ ) {
74
+ depth++;
75
+ } else if (token.type === "BLOCK_CLOSE" || token.type === "LINK_CLOSE") {
76
+ if (depth === 0) {
77
+ // End of the outer block
78
+ break;
79
+ }
80
+ depth--;
81
+ }
82
+
83
+ // Stop at PIPE only at top level
84
+ if (token.type === "PIPE" && depth === 0) {
85
+ return { elements, consumed, endedWithPipe: true };
86
+ }
87
+
88
+ // Try inline rules
89
+ const inlineCtx: ParseContext = { ...ctx, pos };
90
+ let matched = false;
91
+
92
+ for (const rule of inlineRules) {
93
+ // Skip rules that would consume our delimiters
94
+ if (rule.startTokens.includes("PIPE") || rule.startTokens.includes("BLOCK_CLOSE")) {
95
+ continue;
96
+ }
97
+ if (rule.startTokens.length === 0 || rule.startTokens.includes(token.type)) {
98
+ const result = rule.parse(inlineCtx);
99
+ if (result.success) {
100
+ elements.push(...result.elements);
101
+ consumed += result.consumed;
102
+ pos += result.consumed;
103
+ matched = true;
104
+ break;
105
+ }
106
+ }
107
+ }
108
+
109
+ if (!matched) {
110
+ // Fallback to text
111
+ elements.push({ element: "text", data: token.value });
112
+ consumed++;
113
+ pos++;
114
+ }
115
+ }
116
+
117
+ return { elements, consumed, endedWithPipe: false };
118
+ }
119
+
120
+ /**
121
+ * Collects raw text for the expression or condition portion of `[[#expr]]`,
122
+ * `[[#if]]`, and `[[#ifexpr]]` blocks.
123
+ *
124
+ * Unlike {@link parseInlineBranch}, this function collects raw token values
125
+ * as a concatenated string rather than parsing them as inline elements.
126
+ * This is appropriate for expressions and conditions that are evaluated
127
+ * at runtime rather than rendered as markup.
128
+ *
129
+ * Tracks nesting depth to correctly handle `PIPE` tokens that appear
130
+ * inside nested `[[...]]` or `[[[...]]]` blocks.
131
+ *
132
+ * @param ctx - The current parse context
133
+ * @param startPos - Token index at which to begin collecting text
134
+ * @returns An object containing the trimmed expression text, the number of tokens
135
+ * consumed, and whether collection ended at a `PIPE` (vs. `BLOCK_CLOSE`)
136
+ */
137
+ function collectExpressionText(
138
+ ctx: ParseContext,
139
+ startPos: number,
140
+ ): { text: string; consumed: number; endedWithPipe: boolean } {
141
+ let text = "";
142
+ let consumed = 0;
143
+ let pos = startPos;
144
+ let depth = 0;
145
+
146
+ while (pos < ctx.tokens.length) {
147
+ const token = ctx.tokens[pos];
148
+ if (!token || token.type === "EOF" || token.type === "NEWLINE") {
149
+ break;
150
+ }
151
+
152
+ // Track nesting (both [[...]] and [[[...]]] blocks)
153
+ if (
154
+ token.type === "BLOCK_OPEN" ||
155
+ token.type === "BLOCK_END_OPEN" ||
156
+ token.type === "LINK_OPEN"
157
+ ) {
158
+ depth++;
159
+ } else if (token.type === "BLOCK_CLOSE" || token.type === "LINK_CLOSE") {
160
+ if (depth === 0) {
161
+ break;
162
+ }
163
+ depth--;
164
+ }
165
+
166
+ // Stop at PIPE only at top level
167
+ if (token.type === "PIPE" && depth === 0) {
168
+ return { text: text.trim(), consumed, endedWithPipe: true };
169
+ }
170
+
171
+ text += token.value;
172
+ consumed++;
173
+ pos++;
174
+ }
175
+
176
+ return { text: text.trim(), consumed, endedWithPipe: false };
177
+ }
178
+
179
+ /**
180
+ * Inline rule for parsing `[[#expr expression]]`.
181
+ *
182
+ * Evaluates the given mathematical expression at runtime and displays
183
+ * the result inline. The expression is case-sensitive and must use
184
+ * lowercase `expr`.
185
+ *
186
+ * Produces an `"expr"` AST element whose `data.expression` field
187
+ * contains the raw expression string for later evaluation.
188
+ */
189
+ export const exprRule: InlineRule = {
190
+ name: "expr",
191
+ startTokens: ["BLOCK_OPEN"],
192
+
193
+ /**
194
+ * Attempts to parse a `[[#expr expression]]` block at the current position.
195
+ *
196
+ * @param ctx - Parse context with token stream and current position
197
+ * @returns A successful result with an `"expr"` element, or `{ success: false }`
198
+ */
199
+ parse(ctx: ParseContext): RuleResult<Element> {
200
+ const openToken = currentToken(ctx);
201
+ if (openToken.type !== "BLOCK_OPEN") {
202
+ return { success: false };
203
+ }
204
+
205
+ let pos = ctx.pos + 1;
206
+ let consumed = 1;
207
+
208
+ // Expect # immediately after [[
209
+ const hashToken = ctx.tokens[pos];
210
+ if (!hashToken || hashToken.type !== "HASH") {
211
+ return { success: false };
212
+ }
213
+ pos++;
214
+ consumed++;
215
+
216
+ // Expect identifier "expr" (case-sensitive - Wikidot only supports lowercase)
217
+ const idToken = ctx.tokens[pos];
218
+ if (!idToken || idToken.type !== "IDENTIFIER" || idToken.value !== "expr") {
219
+ return { success: false };
220
+ }
221
+ pos++;
222
+ consumed++;
223
+
224
+ // Skip whitespace
225
+ while (ctx.tokens[pos]?.type === "WHITESPACE") {
226
+ pos++;
227
+ consumed++;
228
+ }
229
+
230
+ // Collect expression until ]]
231
+ const exprResult = collectExpressionText(ctx, pos);
232
+ const expression = exprResult.text;
233
+ pos += exprResult.consumed;
234
+ consumed += exprResult.consumed;
235
+
236
+ // Validate expression length
237
+ if (expression.length > MAX_EXPRESSION_LENGTH) {
238
+ return { success: false };
239
+ }
240
+
241
+ // Expect ]]
242
+ if (ctx.tokens[pos]?.type !== "BLOCK_CLOSE") {
243
+ return { success: false };
244
+ }
245
+ pos++;
246
+ consumed++;
247
+
248
+ return {
249
+ success: true,
250
+ elements: [
251
+ {
252
+ element: "expr",
253
+ data: { expression },
254
+ },
255
+ ],
256
+ consumed,
257
+ };
258
+ },
259
+ };
260
+
261
+ /**
262
+ * Inline rule for parsing `[[#if value | then | else]]`.
263
+ *
264
+ * Performs a simple truthy/falsy check on a string value. The value
265
+ * is treated as false when it matches `"false"`, `"null"`, `""`,
266
+ * or `"0"` (case-insensitive); all other values are truthy.
267
+ *
268
+ * The then branch is required (separated from the condition by `|`).
269
+ * The else branch is optional (separated from the then branch by
270
+ * another `|`).
271
+ *
272
+ * Produces an `"if"` AST element with `condition`, `then`, and
273
+ * `else` fields.
274
+ */
275
+ export const ifRule: InlineRule = {
276
+ name: "if",
277
+ startTokens: ["BLOCK_OPEN"],
278
+
279
+ /**
280
+ * Attempts to parse a `[[#if value | then | else]]` block at the current position.
281
+ *
282
+ * @param ctx - Parse context with token stream and current position
283
+ * @returns A successful result with an `"if"` element, or `{ success: false }`
284
+ */
285
+ parse(ctx: ParseContext): RuleResult<Element> {
286
+ const openToken = currentToken(ctx);
287
+ if (openToken.type !== "BLOCK_OPEN") {
288
+ return { success: false };
289
+ }
290
+
291
+ let pos = ctx.pos + 1;
292
+ let consumed = 1;
293
+
294
+ // Expect # immediately after [[
295
+ const hashToken = ctx.tokens[pos];
296
+ if (!hashToken || hashToken.type !== "HASH") {
297
+ return { success: false };
298
+ }
299
+ pos++;
300
+ consumed++;
301
+
302
+ // Expect identifier "if" (case-sensitive - Wikidot only supports lowercase)
303
+ const idToken = ctx.tokens[pos];
304
+ if (!idToken || idToken.type !== "IDENTIFIER" || idToken.value !== "if") {
305
+ return { success: false };
306
+ }
307
+ pos++;
308
+ consumed++;
309
+
310
+ // Skip whitespace
311
+ while (ctx.tokens[pos]?.type === "WHITESPACE") {
312
+ pos++;
313
+ consumed++;
314
+ }
315
+
316
+ // Collect condition until first |
317
+ const condResult = collectExpressionText(ctx, pos);
318
+ if (!condResult.endedWithPipe) {
319
+ return { success: false }; // Must have | separator
320
+ }
321
+ const condition = condResult.text;
322
+ pos += condResult.consumed;
323
+ consumed += condResult.consumed;
324
+
325
+ // Validate condition length
326
+ if (condition.length > MAX_EXPRESSION_LENGTH) {
327
+ return { success: false };
328
+ }
329
+
330
+ // Skip the PIPE
331
+ if (ctx.tokens[pos]?.type !== "PIPE") {
332
+ return { success: false };
333
+ }
334
+ pos++;
335
+ consumed++;
336
+
337
+ // Skip whitespace after |
338
+ while (ctx.tokens[pos]?.type === "WHITESPACE") {
339
+ pos++;
340
+ consumed++;
341
+ }
342
+
343
+ // Parse "then" branch until next |
344
+ const thenResult = parseInlineBranch(ctx, pos);
345
+ pos += thenResult.consumed;
346
+ consumed += thenResult.consumed;
347
+
348
+ let elseElements: Element[] = [];
349
+
350
+ if (thenResult.endedWithPipe) {
351
+ // Skip the PIPE
352
+ if (ctx.tokens[pos]?.type !== "PIPE") {
353
+ return { success: false };
354
+ }
355
+ pos++;
356
+ consumed++;
357
+
358
+ // Skip whitespace after |
359
+ while (ctx.tokens[pos]?.type === "WHITESPACE") {
360
+ pos++;
361
+ consumed++;
362
+ }
363
+
364
+ // Parse "else" branch until ]]
365
+ const elseResult = parseInlineBranch(ctx, pos);
366
+ elseElements = elseResult.elements;
367
+ pos += elseResult.consumed;
368
+ consumed += elseResult.consumed;
369
+ }
370
+
371
+ // Expect ]]
372
+ if (ctx.tokens[pos]?.type !== "BLOCK_CLOSE") {
373
+ return { success: false };
374
+ }
375
+ pos++;
376
+ consumed++;
377
+
378
+ return {
379
+ success: true,
380
+ elements: [
381
+ {
382
+ element: "if",
383
+ data: {
384
+ condition,
385
+ then: thenResult.elements,
386
+ else: elseElements,
387
+ },
388
+ },
389
+ ],
390
+ consumed,
391
+ };
392
+ },
393
+ };
394
+
395
+ /**
396
+ * Inline rule for parsing `[[#ifexpr expression | then | else]]`.
397
+ *
398
+ * Evaluates a mathematical expression and uses the result to choose
399
+ * between the then and else branches. A zero result selects the else
400
+ * branch; any non-zero result selects the then branch.
401
+ *
402
+ * Like `[[#if]]`, the then branch is required and the else branch
403
+ * is optional.
404
+ *
405
+ * Produces an `"ifexpr"` AST element with `expression`, `then`, and
406
+ * `else` fields.
407
+ */
408
+ export const ifExprRule: InlineRule = {
409
+ name: "ifexpr",
410
+ startTokens: ["BLOCK_OPEN"],
411
+
412
+ /**
413
+ * Attempts to parse a `[[#ifexpr expression | then | else]]` block at the current position.
414
+ *
415
+ * @param ctx - Parse context with token stream and current position
416
+ * @returns A successful result with an `"ifexpr"` element, or `{ success: false }`
417
+ */
418
+ parse(ctx: ParseContext): RuleResult<Element> {
419
+ const openToken = currentToken(ctx);
420
+ if (openToken.type !== "BLOCK_OPEN") {
421
+ return { success: false };
422
+ }
423
+
424
+ let pos = ctx.pos + 1;
425
+ let consumed = 1;
426
+
427
+ // Expect # immediately after [[
428
+ const hashToken = ctx.tokens[pos];
429
+ if (!hashToken || hashToken.type !== "HASH") {
430
+ return { success: false };
431
+ }
432
+ pos++;
433
+ consumed++;
434
+
435
+ // Expect identifier "ifexpr" (case-sensitive - Wikidot only supports lowercase)
436
+ const idToken = ctx.tokens[pos];
437
+ if (!idToken || idToken.type !== "IDENTIFIER" || idToken.value !== "ifexpr") {
438
+ return { success: false };
439
+ }
440
+ pos++;
441
+ consumed++;
442
+
443
+ // Skip whitespace
444
+ while (ctx.tokens[pos]?.type === "WHITESPACE") {
445
+ pos++;
446
+ consumed++;
447
+ }
448
+
449
+ // Collect expression until first |
450
+ const exprResult = collectExpressionText(ctx, pos);
451
+ if (!exprResult.endedWithPipe) {
452
+ return { success: false }; // Must have | separator
453
+ }
454
+ const expression = exprResult.text;
455
+ pos += exprResult.consumed;
456
+ consumed += exprResult.consumed;
457
+
458
+ // Validate expression length
459
+ if (expression.length > MAX_EXPRESSION_LENGTH) {
460
+ return { success: false };
461
+ }
462
+
463
+ // Skip the PIPE
464
+ if (ctx.tokens[pos]?.type !== "PIPE") {
465
+ return { success: false };
466
+ }
467
+ pos++;
468
+ consumed++;
469
+
470
+ // Skip whitespace after |
471
+ while (ctx.tokens[pos]?.type === "WHITESPACE") {
472
+ pos++;
473
+ consumed++;
474
+ }
475
+
476
+ // Parse "then" branch until next |
477
+ const thenResult = parseInlineBranch(ctx, pos);
478
+ pos += thenResult.consumed;
479
+ consumed += thenResult.consumed;
480
+
481
+ let elseElements: Element[] = [];
482
+
483
+ if (thenResult.endedWithPipe) {
484
+ // Skip the PIPE
485
+ if (ctx.tokens[pos]?.type !== "PIPE") {
486
+ return { success: false };
487
+ }
488
+ pos++;
489
+ consumed++;
490
+
491
+ // Skip whitespace after |
492
+ while (ctx.tokens[pos]?.type === "WHITESPACE") {
493
+ pos++;
494
+ consumed++;
495
+ }
496
+
497
+ // Parse "else" branch until ]]
498
+ const elseResult = parseInlineBranch(ctx, pos);
499
+ elseElements = elseResult.elements;
500
+ pos += elseResult.consumed;
501
+ consumed += elseResult.consumed;
502
+ }
503
+
504
+ // Expect ]]
505
+ if (ctx.tokens[pos]?.type !== "BLOCK_CLOSE") {
506
+ return { success: false };
507
+ }
508
+ pos++;
509
+ consumed++;
510
+
511
+ return {
512
+ success: true,
513
+ elements: [
514
+ {
515
+ element: "ifexpr",
516
+ data: {
517
+ expression,
518
+ then: thenResult.elements,
519
+ else: elseElements,
520
+ },
521
+ },
522
+ ],
523
+ consumed,
524
+ };
525
+ },
526
+ };