@stll/template-conditions 0.3.0 → 0.4.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/dist/index.js CHANGED
@@ -192,11 +192,12 @@ const COMPARE_SYMBOL_TO_OP = {
192
192
  };
193
193
  const escapeRegexLiteral = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
194
194
  const COMPARE_SYMBOL_PATTERN = Object.keys(COMPARE_SYMBOL_TO_OP).sort((left, right) => right.length - left.length).map(escapeRegexLiteral).join("|");
195
- const NON_STRING_TOKEN_RE = new RegExp(String.raw`(?<token>${COMPARE_SYMBOL_PATTERN}|!(?!=)|and\b|or\b|contains\b|[()]|-?\d[\p{N}_.]*|[\p{L}\p{N}_.]+(?:-[\p{L}\p{N}_.]+)*)`, "uy");
195
+ const NON_STRING_TOKEN_RE = new RegExp(String.raw`(?<token>${COMPARE_SYMBOL_PATTERN}|and\b|or\b|not\b|in\b|is\b|defined\b|[()]|-?\d[\p{N}_.]*|[\p{L}\p{N}_.]+(?:-[\p{L}\p{N}_.]+)*)`, "uy");
196
196
  const STARTS_WITH_DIGIT_RE = /^-?\d/u;
197
197
  const isCompareSymbol = (raw) => Object.hasOwn(COMPARE_SYMBOL_TO_OP, raw);
198
198
  /**
199
- * Scan a `"..."` literal starting at `expr[start]` (the opening quote),
199
+ * Scan a `"..."` or `'...'` literal starting at `expr[start]` (the opening
200
+ * quote),
200
201
  * honoring `\\`-escapes without interpreting them. A single linear pass:
201
202
  * each character is visited at most once, so this is safe on adversarial
202
203
  * input regardless of how many `"` or `\\` characters it contains.
@@ -206,9 +207,10 @@ const isCompareSymbol = (raw) => Object.hasOwn(COMPARE_SYMBOL_TO_OP, raw);
206
207
  * closing quote simply closes at end of input.
207
208
  */
208
209
  const scanString = (expr, start) => {
210
+ const quote = expr[start];
209
211
  let i = start + 1;
210
212
  while (i < expr.length) {
211
- if (expr[i] === "\"") return {
213
+ if (expr[i] === quote) return {
212
214
  content: expr.slice(start + 1, i),
213
215
  end: i + 1
214
216
  };
@@ -222,8 +224,11 @@ const scanString = (expr, start) => {
222
224
  const classifyNonString = (raw) => {
223
225
  if (raw === "and") return { type: "and" };
224
226
  if (raw === "or") return { type: "or" };
225
- if (raw === "!") return { type: "not" };
226
- if (raw === "contains" || isCompareSymbol(raw)) return {
227
+ if (raw === "not") return { type: "not" };
228
+ if (raw === "in") return { type: "in" };
229
+ if (raw === "is") return { type: "is" };
230
+ if (raw === "defined") return { type: "defined" };
231
+ if (isCompareSymbol(raw)) return {
227
232
  type: "op",
228
233
  raw
229
234
  };
@@ -238,11 +243,11 @@ const tokenize = (expr) => {
238
243
  const tokens = [];
239
244
  let pos = 0;
240
245
  while (pos < expr.length) {
241
- if (expr[pos] === "\"") {
246
+ if (expr[pos] === "\"" || expr[pos] === "'") {
242
247
  const { content, end } = scanString(expr, pos);
243
248
  tokens.push({
244
249
  type: "string",
245
- raw: content.replace(/\\"/gu, "\"")
250
+ raw: content.replace(/\\(?<quote>["'])/gu, "$<quote>")
246
251
  });
247
252
  pos = end;
248
253
  continue;
@@ -289,9 +294,9 @@ const operandFromToken = (token) => {
289
294
  path: token.raw
290
295
  };
291
296
  };
292
- /** `contains` carries a literal payload (`string`), not an operand: render the
293
- * right-hand token as a string. */
294
- const containsValue = (token) => {
297
+ /** `"x" in path` carries a literal payload (`string`), not an operand: render
298
+ * the left-hand token as a string. */
299
+ const membershipValue = (token) => {
295
300
  const operand = operandFromToken(token);
296
301
  return operand.type === "literal" ? String(operand.value) : token.raw;
297
302
  };
@@ -358,6 +363,39 @@ const parseCondition = (expression) => {
358
363
  }
359
364
  pos += 1;
360
365
  const left = operandFromToken(leftTok);
366
+ if (peek()?.type === "is") {
367
+ pos += 1;
368
+ const negated = peek()?.type === "not";
369
+ if (negated) pos += 1;
370
+ if (peek()?.type === "defined") pos += 1;
371
+ return {
372
+ type: "predicate",
373
+ operand: left,
374
+ op: negated ? "is_empty" : "is_not_empty"
375
+ };
376
+ }
377
+ if (peek()?.type === "in") {
378
+ pos += 1;
379
+ const rightTok = peek();
380
+ if (!rightTok || rightTok.type !== "value" && rightTok.type !== "string") return {
381
+ type: "predicate",
382
+ operand: left,
383
+ op: "is_truthy"
384
+ };
385
+ pos += 1;
386
+ const right = operandFromToken(rightTok);
387
+ if (right.type !== "path") return {
388
+ type: "predicate",
389
+ operand: right,
390
+ op: "is_truthy"
391
+ };
392
+ return {
393
+ type: "predicate",
394
+ operand: right,
395
+ op: "contains",
396
+ value: membershipValue(leftTok)
397
+ };
398
+ }
361
399
  const opTok = peek();
362
400
  if (opTok?.type !== "op") return {
363
401
  type: "predicate",
@@ -372,12 +410,6 @@ const parseCondition = (expression) => {
372
410
  op: "is_truthy"
373
411
  };
374
412
  pos += 1;
375
- if (opTok.raw === "contains") return {
376
- type: "predicate",
377
- operand: left,
378
- op: "contains",
379
- value: containsValue(rightTok)
380
- };
381
413
  return {
382
414
  type: "compare",
383
415
  left,
@@ -387,51 +419,187 @@ const parseCondition = (expression) => {
387
419
  };
388
420
  return parseOr();
389
421
  };
422
+ //#endregion
423
+ //#region src/referenced-paths.ts
424
+ const operandsOf = (node) => node.type === "compare" ? [node.left, node.right] : [node.operand];
425
+ /** Every field path the tree reads, or `null` when an operand names something
426
+ * whose own references cannot be enumerated (a formula over other fields). */
427
+ const nodePaths = (node) => {
428
+ if (node.type === "group") {
429
+ const paths = [];
430
+ for (const child of node.children) {
431
+ const childPaths = nodePaths(child);
432
+ if (childPaths === null) return null;
433
+ paths.push(...childPaths);
434
+ }
435
+ return paths;
436
+ }
437
+ const paths = [];
438
+ for (const operand of operandsOf(node)) {
439
+ if (operand.type === "formula") return null;
440
+ if (operand.type === "path") paths.push(operand.path);
441
+ }
442
+ return paths;
443
+ };
444
+ /**
445
+ * Every field path an expression reads, in source order.
446
+ *
447
+ * `null` says the expression does not answer the question: it did not parse,
448
+ * or it reads an operand whose own references cannot be enumerated. A caller
449
+ * deciding what a condition depends on must not read that as "depends on
450
+ * nothing".
451
+ */
452
+ const referencedConditionPaths = (expression) => {
453
+ const node = parseCondition(expression);
454
+ return node === null ? null : nodePaths(node);
455
+ };
456
+ //#endregion
457
+ //#region src/markers.ts
458
+ /**
459
+ * Canonical grammar for the docxtpl dialect of Jinja — the single source of
460
+ * truth shared by the fill pipeline (api), the editor highlighter (folio), and
461
+ * the read-only preview (web).
462
+ *
463
+ * Two spans carry meaning: an output marker `{{ ... }}` prints a value, and a
464
+ * tag `{% ... %}` opens or closes a block. A tag may carry a docxtpl placement
465
+ * prefix — `{%p ... %}` owns its paragraph, `{%tr ... %}` owns its table row —
466
+ * and a bare tag is placed by the leniency rules the consumer applies.
467
+ *
468
+ * Add a new directive to `MarkerMeta` and teach {@link classifyMarker} to
469
+ * recognize it. Compile-time totality checks then require its ordered runtime
470
+ * value and its block/inline disposition, while exhaustive consumer switches
471
+ * fail until they handle it.
472
+ *
473
+ * No consumer should define its own marker regex. The low-level pattern
474
+ * factories below exist so handlers that mutate raw XML/text reuse the exact
475
+ * same literals; recognizers should prefer the high-level scanner.
476
+ */
477
+ /** The loop properties addressable inside a `{% for %}` body. */
478
+ const LOOP_PROPERTIES = [
479
+ "index",
480
+ "index0",
481
+ "first",
482
+ "last",
483
+ "length"
484
+ ];
390
485
  const DIRECTIVE_KINDS = [
391
486
  "placeholder",
392
487
  "clause",
393
488
  "num",
394
489
  "ref",
395
- "index",
396
- "count",
490
+ "loop",
397
491
  "if",
398
- "elseif",
492
+ "elif",
399
493
  "else",
400
494
  "endif",
401
- "each",
402
- "endeach"
495
+ "for",
496
+ "endfor"
403
497
  ];
404
498
  /** Directives that occupy their own paragraph, in canonical grammar order. */
405
499
  const BLOCK_DIRECTIVE_KINDS = [
406
500
  "if",
407
- "elseif",
501
+ "elif",
408
502
  "else",
409
503
  "endif",
410
- "each",
411
- "endeach"
504
+ "for",
505
+ "endfor"
412
506
  ];
413
507
  const isBlockDirectiveKind = (kind) => typeof kind === "string" && BLOCK_DIRECTIVE_KINDS.some((blockKind) => blockKind === kind);
414
- /** Any `{{...}}` marker; group 1 is the inner text (untrimmed — callers trim). */
415
- const markerPattern = () => /\{\{(?<inner>[^{}]*)\}\}/gu;
416
- /** Inline field / clause / numbering marker (inner allows the directive sigils). */
417
- const placeholderPattern = () => /\{\{\s*(?<name>[\p{L}\p{N}_.@:-]+)\s*\}\}/gu;
418
- /** `{{@clause:Name}}` / `{{@clause:Name:v3}}` — `name` and `modifier` groups. */
419
- const clauseSlotPattern = () => /\{\{\s*@clause:(?<name>[^:}\s]+)(?::(?<modifier>[^}\s]+))?\s*\}\}/gu;
420
- /** `{{@num:Key}}` — the `key` group. */
421
- const numPattern = () => /\{\{\s*@num:(?<key>[\p{L}\p{N}_.-]+)\s*\}\}/gu;
422
- /** `{{@ref:Key}}` — the `key` group. */
423
- const refPattern = () => /\{\{\s*@ref:(?<key>[\p{L}\p{N}_.-]+)\s*\}\}/gu;
424
- /** `{{@index}}` — the 1-based position within the innermost enclosing loop. */
425
- const indexPattern = () => /\{\{\s*@index\s*\}\}/gu;
426
- /** `{{@count}}` — the item count of the innermost enclosing loop. */
427
- const countPattern = () => /\{\{\s*@count\s*\}\}/gu;
508
+ /** Callable forms inside `{{ }}` that address document machinery rather than a
509
+ * fill value. */
510
+ const FUNCTION_NAMES = [
511
+ "clause",
512
+ "num",
513
+ "ref"
514
+ ];
515
+ /**
516
+ * Every filter the dialect accepts on a fillable value. The names are the
517
+ * grammar's; which manifest property each one writes is decided by the field
518
+ * catalogue in the api, which is total over BOTH this list and the manifest
519
+ * keys, so neither side can gain an entry the other has not decided.
520
+ */
521
+ const FILTER_NAMES = [
522
+ "text",
523
+ "number",
524
+ "date",
525
+ "checkbox",
526
+ "select",
527
+ "options_from",
528
+ "label",
529
+ "hint",
530
+ "required",
531
+ "pattern",
532
+ "min",
533
+ "max",
534
+ "min_length",
535
+ "max_length",
536
+ "min_items",
537
+ "max_items",
538
+ "ai",
539
+ "lookup",
540
+ "matter",
541
+ "contact",
542
+ "party",
543
+ "attorney",
544
+ "firm",
545
+ "formula",
546
+ "condition"
547
+ ];
548
+ const isFilterName = (value) => FILTER_NAMES.some((name) => name === value);
549
+ const isFunctionName = (value) => FUNCTION_NAMES.some((name) => name === value);
550
+ const SMART_DOUBLE_QUOTES = /[“”„«»]/gu;
551
+ const SMART_SINGLE_QUOTES = /[‘’‚]/gu;
552
+ const FIXED_SPACES = /[    ]/gu;
553
+ /** Normalise one marker's inner text: typographic quotes become straight
554
+ * quotes, fixed-width spaces become ordinary ones. */
555
+ const normalizeMarkerInner = (inner) => inner.replace(SMART_DOUBLE_QUOTES, "\"").replace(SMART_SINGLE_QUOTES, "'").replace(FIXED_SPACES, " ");
556
+ /**
557
+ * What an output marker holds between its braces: anything but a brace or a
558
+ * double quote, plus whole double-quoted arguments, whose braces are content
559
+ * rather than the marker's own delimiters — `pattern("^[0-9]{5}$")` is a regex,
560
+ * not a nested marker. An apostrophe stays an ordinary character, so
561
+ * `label("Owner's")` reads as it always did.
562
+ *
563
+ * The alternatives cannot overlap (the plain one excludes every character the
564
+ * quoted one may start with) and a quoted run's end is forced by its body, so
565
+ * the scan stays linear on adversarial input: a `{{` with no closing `}}`
566
+ * costs one failed pass, not a polynomial retry.
567
+ */
568
+ const MARKER_OUTPUT_BODY = String.raw`(?:[^{}"“”„«»]|["“„«][^"”»]*["”»])*`;
569
+ /** What a tag holds between `{%` and `%}`: anything but a brace, and a `%`
570
+ * only where it does not close the tag. */
571
+ const MARKER_STATEMENT_BODY = String.raw`(?:[^{}%]|%(?!\}))*`;
572
+ /**
573
+ * Any marker span: an output `{{...}}` (group `output`) or a tag `{%...%}`
574
+ * (group `statement`, with the docxtpl placement in group `prefix`).
575
+ */
576
+ const markerPattern = () => new RegExp(String.raw`\{\{(?<output>${MARKER_OUTPUT_BODY})\}\}|\{%(?:(?<prefix>tr|p)(?=\s))?(?<statement>${MARKER_STATEMENT_BODY})%\}`, "gu");
577
+ /** Inline value marker, including its filter chain; group `name` is the path
578
+ * the value comes from and group `filters` the untouched chain. */
579
+ const placeholderPattern = () => new RegExp(String.raw`\{\{\s*(?<name>[\p{L}\p{N}_.-]+)\s*(?<filters>\|${MARKER_OUTPUT_BODY})?\}\}`, "gu");
580
+ /** Any character that opens a quoted function argument. */
581
+ const OPEN_QUOTE = String.raw`["'“„«‘‚]`;
582
+ /** Any character that closes one. */
583
+ const CLOSE_QUOTE = String.raw`["'”»’]`;
584
+ /** Everything a quoted argument may hold: no quote of either shape. */
585
+ const QUOTED_BODY = String.raw`[^"'“”„«»‘’‚]*`;
586
+ /** `{{ clause("Name") }}` / `{{ clause("Name", "v3") }}`. */
587
+ const clauseSlotPattern = () => new RegExp(String.raw`\{\{\s*clause\(\s*${OPEN_QUOTE}(?<name>${QUOTED_BODY})${CLOSE_QUOTE}\s*(?:,\s*${OPEN_QUOTE}(?<modifier>${QUOTED_BODY})${CLOSE_QUOTE}\s*)?\)\s*\}\}`, "gu");
588
+ const numberingPattern = (fn) => new RegExp(String.raw`\{\{\s*${fn}\(\s*${OPEN_QUOTE}(?<key>[\p{L}\p{N}_.-]+)${CLOSE_QUOTE}\s*\)\s*\}\}`, "gu");
589
+ /** `{{ num("key") }}` — the `key` group. */
590
+ const numPattern = () => numberingPattern("num");
591
+ /** `{{ ref("key") }}` — the `key` group. */
592
+ const refPattern = () => numberingPattern("ref");
593
+ /** `{{ loop.index }}` and its siblings — the `property` group. */
594
+ const loopPattern = () => /\{\{\s*loop\.(?<property>index0|index|first|last|length)\s*\}\}/gu;
428
595
  /** Cheap presence test for any numbering marker (no capture). */
429
- const hasNumberingPattern = () => /\{\{\s*@(?:num|ref):/u;
430
- /** Cheap presence test for any block directive (no capture). */
431
- const hasBlockDirectivePattern = () => /\{\{\s*[#/]/u;
432
- /** A whole line that is a single block directive — `tag` and `expr` groups. */
433
- const blockDirectiveLinePattern = () => /^\s*\{\{\s*(?<tag>#if|#elseif|#else|#each|\/if|\/each)\b(?<expr>[^{}]*)\}\}\s*$/u;
596
+ const hasNumberingPattern = () => /\{\{\s*(?:num|ref)\s*\(/u;
597
+ /** Cheap presence test for any tag (no capture). */
598
+ const hasBlockDirectivePattern = () => /\{%/u;
599
+ /** A whole line that is a single block tag — `prefix`, `tag` and `expr`. */
600
+ const blockDirectiveLinePattern = () => /^\s*\{%(?:(?<prefix>tr|p)(?=\s))?\s*(?<tag>if|elif|else|endif|for|endfor)\b(?<expr>(?:[^{}%]|%(?!\}))*)%\}\s*$/u;
434
601
  const FIELD_PATH_RE = /^[\p{L}\p{N}_.-]+$/u;
602
+ const IDENTIFIER_RE = /^[\p{L}_][\p{L}\p{N}_-]*$/u;
435
603
  const UNSAFE_FIELD_PATH_SEGMENTS = /* @__PURE__ */ new Set([
436
604
  "__proto__",
437
605
  "constructor",
@@ -443,130 +611,483 @@ const isFieldPath = (value) => FIELD_PATH_RE.test(value);
443
611
  /** Whether `value` is safe to use as a dotted object path. */
444
612
  const isSafeFieldPath = (value) => isFieldPath(value) && value.split(".").every((segment) => !UNSAFE_FIELD_PATH_SEGMENTS.has(segment));
445
613
  const CLAUSE_SLOT_NAME_RE = /^[\p{L}\p{N}_.-]+$/u;
446
- /** Whether `value` is a valid name for authoring a `{{@clause:NAME}}` slot:
447
- * the intersection of the clause-slot parse grammar and the fill pipeline's
448
- * placeholder charset, so every accepted name round-trips through fill. */
614
+ /** Whether `value` is a valid name for authoring a `clause("NAME")` slot. */
449
615
  const isClauseSlotName = (value) => CLAUSE_SLOT_NAME_RE.test(value);
450
- const CLAUSE_INNER_RE = /^@clause:(?<name>[^:}\s]+)(?::(?<version>[^}\s]+))?$/u;
451
- const NUM_INNER_RE = /^@num:(?<key>[\p{L}\p{N}_.-]+)$/u;
452
- const REF_INNER_RE = /^@ref:(?<key>[\p{L}\p{N}_.-]+)$/u;
453
- const INDEX_INNER_RE = /^@index$/u;
454
- const COUNT_INNER_RE = /^@count$/u;
455
- const BLOCK_INNER_RE = /^(?<token>#if|#elseif|#else|#each|\/if|\/each)\b(?<expr>.*)$/u;
456
- /**
457
- * Classify the inner text of a `{{...}}` marker. Returns `null` when the text is
458
- * not a recognized directive (so a recognizer can leave stray braces alone).
459
- */
460
- const classifyMarker = (innerRaw) => {
461
- const inner = innerRaw.trim();
462
- const block = BLOCK_INNER_RE.exec(inner);
463
- if (block) {
464
- const token = block.groups?.["token"] ?? "";
465
- const expr = (block.groups?.["expr"] ?? "").trim();
466
- if (token === "#if") return {
616
+ const isLoopProperty = (value) => LOOP_PROPERTIES.some((property) => property === value);
617
+ const NUMBER_LITERAL_RE = /^-?\d+(?:\.\d+)?$/u;
618
+ const literalFromBareToken = (raw) => {
619
+ if (raw === "true") return true;
620
+ if (raw === "false") return false;
621
+ if (NUMBER_LITERAL_RE.test(raw)) return Number(raw);
622
+ return null;
623
+ };
624
+ /**
625
+ * Scan a `"..."` / `'...'` literal starting at the opening quote, honouring
626
+ * `\`-escapes. One linear pass; an unterminated literal closes at end of
627
+ * input, matching the degrade-gracefully policy of the condition parser.
628
+ */
629
+ const scanQuoted = (text, start) => {
630
+ const quote = text[start];
631
+ let i = start + 1;
632
+ let value = "";
633
+ while (i < text.length) {
634
+ const ch = text[i];
635
+ if (ch === quote) return {
636
+ value,
637
+ end: i + 1
638
+ };
639
+ if (ch === "\\" && i + 1 < text.length) {
640
+ value += text[i + 1];
641
+ i += 2;
642
+ continue;
643
+ }
644
+ value += ch;
645
+ i += 1;
646
+ }
647
+ return {
648
+ value,
649
+ end: text.length
650
+ };
651
+ };
652
+ /**
653
+ * Scan a parenthesised argument list starting at `(`. Arguments are string,
654
+ * number or boolean literals, optionally named (`adapt=true`). Anything else
655
+ * (an identifier, an operator, a nested call) fails the scan, which is what
656
+ * makes `min(a + 1)` a rejected expression rather than a silently odd filter.
657
+ */
658
+ const scanArguments = (text, start) => {
659
+ const args = [];
660
+ let i = start + 1;
661
+ let expectValue = true;
662
+ let pendingName;
663
+ while (i < text.length) {
664
+ const ch = text[i];
665
+ if (ch === void 0) break;
666
+ if (ch === " " || ch === " " || ch === "\n" || ch === "\r") {
667
+ i += 1;
668
+ continue;
669
+ }
670
+ if (ch === ")") {
671
+ if (pendingName !== void 0) return null;
672
+ if (!expectValue || args.length === 0) return {
673
+ args,
674
+ end: i + 1
675
+ };
676
+ return null;
677
+ }
678
+ if (ch === ",") {
679
+ if (expectValue) return null;
680
+ expectValue = true;
681
+ i += 1;
682
+ continue;
683
+ }
684
+ if (!expectValue) return null;
685
+ if (ch === "\"" || ch === "'") {
686
+ const { value, end } = scanQuoted(text, i);
687
+ args.push(pendingName === void 0 ? {
688
+ kind: "positional",
689
+ value
690
+ } : {
691
+ kind: "keyword",
692
+ name: pendingName,
693
+ value
694
+ });
695
+ pendingName = void 0;
696
+ expectValue = false;
697
+ i = end;
698
+ continue;
699
+ }
700
+ let j = i;
701
+ while (j < text.length && /[\p{L}\p{N}_.-]/u.test(text[j] ?? "")) j += 1;
702
+ if (j === i) return null;
703
+ const raw = text.slice(i, j);
704
+ if (text[j] === "=" && text[j + 1] !== "=") {
705
+ if (pendingName !== void 0 || !IDENTIFIER_RE.test(raw)) return null;
706
+ pendingName = raw;
707
+ i = j + 1;
708
+ continue;
709
+ }
710
+ const value = literalFromBareToken(raw);
711
+ if (value === null) return null;
712
+ args.push(pendingName === void 0 ? {
713
+ kind: "positional",
714
+ value
715
+ } : {
716
+ kind: "keyword",
717
+ name: pendingName,
718
+ value
719
+ });
720
+ pendingName = void 0;
721
+ expectValue = false;
722
+ i = j;
723
+ }
724
+ return null;
725
+ };
726
+ /**
727
+ * Parse the `| name | name(args)` tail of an output marker. Returns the named
728
+ * unknown filter instead of a chain when one step is not in the catalogue, so
729
+ * the rejection can quote it.
730
+ */
731
+ const scanFilterChain = (tail) => {
732
+ const filters = [];
733
+ let i = 0;
734
+ while (i < tail.length) {
735
+ while (i < tail.length && /\s/u.test(tail[i] ?? "")) i += 1;
736
+ if (i >= tail.length) break;
737
+ if (tail[i] !== "|") return null;
738
+ i += 1;
739
+ while (i < tail.length && /\s/u.test(tail[i] ?? "")) i += 1;
740
+ let j = i;
741
+ while (j < tail.length && /[\p{L}\p{N}_]/u.test(tail[j] ?? "")) j += 1;
742
+ const name = tail.slice(i, j);
743
+ if (name === "") return null;
744
+ if (!isFilterName(name)) return { unknownFilter: name };
745
+ i = j;
746
+ while (i < tail.length && /\s/u.test(tail[i] ?? "")) i += 1;
747
+ if (tail[i] === "(") {
748
+ const scanned = scanArguments(tail, i);
749
+ if (scanned === null) return null;
750
+ filters.push({
751
+ name,
752
+ args: scanned.args
753
+ });
754
+ i = scanned.end;
755
+ continue;
756
+ }
757
+ filters.push({
758
+ name,
759
+ args: []
760
+ });
761
+ }
762
+ return { filters };
763
+ };
764
+ const PREFIX_BY_TOKEN = {
765
+ p: "paragraph",
766
+ tr: "row"
767
+ };
768
+ const readPrefix = (token) => token === "p" || token === "tr" ? PREFIX_BY_TOKEN[token] : "none";
769
+ const TAG_RE = /^(?<tag>[\p{L}_][\p{L}\p{N}_]*)\b(?<rest>[\s\S]*)$/u;
770
+ const FOR_RE = /^(?<alias>[\p{L}_][\p{L}\p{N}_-]*)\s+in\s+(?<path>[\p{L}\p{N}_.-]+)\s*(?<filters>\|[\s\S]*)?$/u;
771
+ const CALL_RE = /^(?<name>[\p{L}_][\p{L}\p{N}_]*)\s*\(/u;
772
+ /** Classify a `{% ... %}` tag. */
773
+ const classifyStatement = (inner) => {
774
+ const match = TAG_RE.exec(inner);
775
+ if (!match) return null;
776
+ const tag = match.groups?.["tag"] ?? "";
777
+ const rest = (match.groups?.["rest"] ?? "").trim();
778
+ switch (tag) {
779
+ case "if": return {
467
780
  kind: "if",
468
- expr
781
+ expr: rest
469
782
  };
470
- if (token === "#elseif") return {
471
- kind: "elseif",
472
- expr
783
+ case "elif": return {
784
+ kind: "elif",
785
+ expr: rest
473
786
  };
474
- if (token === "#each") return {
475
- kind: "each",
476
- expr
787
+ case "else": return rest === "" ? { kind: "else" } : null;
788
+ case "endif": return rest === "" ? { kind: "endif" } : null;
789
+ case "endfor": return rest === "" ? { kind: "endfor" } : null;
790
+ case "for": {
791
+ const loop = FOR_RE.exec(rest);
792
+ if (!loop) return null;
793
+ const tail = loop.groups?.["filters"];
794
+ const chain = tail === void 0 ? { filters: [] } : scanFilterChain(tail);
795
+ if (chain === null || "unknownFilter" in chain) return null;
796
+ return {
797
+ kind: "for",
798
+ alias: loop.groups?.["alias"] ?? "",
799
+ path: loop.groups?.["path"] ?? "",
800
+ filters: chain.filters
801
+ };
802
+ }
803
+ default: return null;
804
+ }
805
+ };
806
+ /** Classify a `{{ ... }}` output marker. */
807
+ const classifyOutput = (inner) => {
808
+ const call = CALL_RE.exec(inner);
809
+ if (call) {
810
+ const name = call.groups?.["name"] ?? "";
811
+ if (!isFunctionName(name)) return null;
812
+ const scanned = scanArguments(inner, inner.indexOf("("));
813
+ if (scanned === null) return null;
814
+ if (inner.slice(scanned.end).trim() !== "") return null;
815
+ const positional = scanned.args.filter((arg) => arg.kind === "positional" && typeof arg.value === "string");
816
+ if (positional.length !== scanned.args.length) return null;
817
+ const [first, second] = positional.map((arg) => String(arg.value));
818
+ if (first === void 0) return null;
819
+ if (name === "clause") return scanned.args.length <= 2 ? {
820
+ kind: "clause",
821
+ name: first,
822
+ version: second
823
+ } : null;
824
+ if (scanned.args.length !== 1 || !isFieldPath(first)) return null;
825
+ return name === "num" ? {
826
+ kind: "num",
827
+ key: first
828
+ } : {
829
+ kind: "ref",
830
+ key: first
477
831
  };
478
- if (token === "#else") return { kind: "else" };
479
- if (token === "/if") return { kind: "endif" };
480
- if (token === "/each") return { kind: "endeach" };
481
832
  }
482
- const clause = CLAUSE_INNER_RE.exec(inner);
483
- if (clause) return {
484
- kind: "clause",
485
- name: clause.groups?.["name"] ?? "",
486
- version: clause.groups?.["version"]
487
- };
488
- const num = NUM_INNER_RE.exec(inner);
489
- if (num) return {
490
- kind: "num",
491
- key: num.groups?.["key"] ?? ""
492
- };
493
- const ref = REF_INNER_RE.exec(inner);
494
- if (ref) return {
495
- kind: "ref",
496
- key: ref.groups?.["key"] ?? ""
833
+ const pipe = inner.indexOf("|");
834
+ const head = (pipe === -1 ? inner : inner.slice(0, pipe)).trim();
835
+ const tail = pipe === -1 ? "" : inner.slice(pipe);
836
+ const loopMatch = /^loop\.(?<property>[\p{L}\p{N}_]+)$/u.exec(head);
837
+ if (loopMatch) {
838
+ const property = loopMatch.groups?.["property"] ?? "";
839
+ return isLoopProperty(property) && tail === "" ? {
840
+ kind: "loop",
841
+ property
842
+ } : null;
843
+ }
844
+ if (!FIELD_PATH_RE.test(head)) return null;
845
+ if (tail === "") return {
846
+ kind: "placeholder",
847
+ expr: head,
848
+ filters: []
497
849
  };
498
- if (INDEX_INNER_RE.test(inner)) return { kind: "index" };
499
- if (COUNT_INNER_RE.test(inner)) return { kind: "count" };
500
- if (FIELD_PATH_RE.test(inner)) return {
850
+ const chain = scanFilterChain(tail);
851
+ if (chain === null || "unknownFilter" in chain) return null;
852
+ return {
501
853
  kind: "placeholder",
502
- expr: inner
854
+ expr: head,
855
+ filters: chain.filters
503
856
  };
504
- return null;
505
857
  };
506
858
  /**
507
- * Find every recognized `{{...}}` marker in `text`, in document order, with
508
- * offsets and classified metadata. Unrecognized `{{...}}` spans are skipped.
859
+ * Classify the inner text of one marker. Returns `null` when the text is not a
860
+ * recognized directive (so a recognizer can leave stray braces alone).
509
861
  */
510
- const scanMarkers = (text) => {
862
+ const classifyMarker = (innerRaw, form = "output") => {
863
+ const inner = normalizeMarkerInner(innerRaw).trim();
864
+ return form === "statement" ? classifyStatement(inner) : classifyOutput(inner);
865
+ };
866
+ /** Every marker-shaped span in `text`, in document order, before classification. */
867
+ const scanSpans = (text) => {
511
868
  const re = markerPattern();
512
- const out = [];
869
+ const spans = [];
513
870
  let match = re.exec(text);
514
871
  while (match !== null) {
515
- const inner = (match[1] ?? "").trim();
516
- const meta = classifyMarker(inner);
517
- if (meta) out.push({
872
+ const output = match.groups?.["output"];
873
+ const statement = match.groups?.["statement"];
874
+ const raw = match[0];
875
+ const form = output === void 0 ? "statement" : "output";
876
+ spans.push({
518
877
  start: match.index,
519
- end: match.index + match[0].length,
520
- raw: match[0],
521
- inner,
522
- meta
878
+ end: match.index + raw.length,
879
+ raw,
880
+ inner: normalizeMarkerInner(output ?? statement ?? "").trim(),
881
+ form,
882
+ prefix: readPrefix(match.groups?.["prefix"])
523
883
  });
524
884
  match = re.exec(text);
525
885
  }
886
+ return spans;
887
+ };
888
+ /**
889
+ * Find every recognized marker in `text`, in document order, with offsets and
890
+ * classified metadata. Unrecognized spans are skipped.
891
+ */
892
+ const scanMarkers = (text) => {
893
+ const out = [];
894
+ for (const span of scanSpans(text)) {
895
+ const meta = classifyMarker(span.inner, span.form);
896
+ if (meta) out.push({
897
+ ...span,
898
+ meta
899
+ });
900
+ }
526
901
  return out;
527
902
  };
528
903
  /**
529
- * Find every `{{...}}` span whose inner text is NOT a recognized directive —
530
- * near-misses an author clearly meant as markers but that every recognizer
531
- * skips, so they print literally at fill time (e.g. `{{my field}}` with a
532
- * space, `{{@clause:}}` with no name). Offsets and raw text are shaped like
533
- * {@link scanMarkers} results, minus the classified `meta`. This is the exact
904
+ * Rewrite every `{{ ... }}` output marker in `text` through `replace`, which
905
+ * receives the whole marker and its raw inner text and returns the replacement.
906
+ * Tags are left untouched. The single scan is the same one the classifier uses,
907
+ * so a caller that rewrites marker text never needs its own regex.
908
+ */
909
+ const replaceOutputMarkers = (text, replace) => {
910
+ let out = "";
911
+ let cursor = 0;
912
+ for (const span of scanSpans(text)) {
913
+ if (span.form !== "output") continue;
914
+ out += text.slice(cursor, span.start) + replace(span.raw, span.inner);
915
+ cursor = span.end;
916
+ }
917
+ return out + text.slice(cursor);
918
+ };
919
+ /**
920
+ * The values-map key one marker substitutes from, or `null` when the marker is
921
+ * not a substitution target (a tag, or a `loop.*` token the loop expander
922
+ * resolves first). One function so the key discovery writes is the key the
923
+ * patcher reads.
924
+ */
925
+ const substitutionKey = (meta) => {
926
+ switch (meta.kind) {
927
+ case "placeholder": return meta.expr;
928
+ case "clause": return meta.version === void 0 ? `${CLAUSE_KEY_PREFIX}${meta.name}` : `${CLAUSE_KEY_PREFIX}${meta.name}:${meta.version}`;
929
+ case "num":
930
+ case "ref":
931
+ case "loop":
932
+ case "if":
933
+ case "elif":
934
+ case "else":
935
+ case "endif":
936
+ case "for":
937
+ case "endfor": return null;
938
+ default: return assertNever(meta);
939
+ }
940
+ };
941
+ /** Namespace of the synthetic clause-slot key, kept out of the field-path
942
+ * charset so it can never collide with an author's path. */
943
+ const CLAUSE_KEY_PREFIX = "@clause:";
944
+ /** The values-map key for one clause slot. */
945
+ const clauseSlotKey = (name, version) => version === void 0 ? `${CLAUSE_KEY_PREFIX}${name}` : `${CLAUSE_KEY_PREFIX}${name}:${version}`;
946
+ /**
947
+ * Find every marker-shaped span whose inner text is NOT a recognized
948
+ * directive — near-misses an author clearly meant as markers but that every
949
+ * recognizer skips, so they print literally at fill time. This is the exact
534
950
  * complement of {@link scanMarkers}: a span is in one list or the other.
535
951
  */
536
952
  const scanInvalidMarkers = (text) => {
537
- const re = markerPattern();
538
953
  const out = [];
539
- let match = re.exec(text);
540
- while (match !== null) {
541
- const inner = (match[1] ?? "").trim();
542
- if (classifyMarker(inner) === null) out.push({
543
- start: match.index,
544
- end: match.index + match[0].length,
545
- raw: match[0],
546
- inner
547
- });
548
- match = re.exec(text);
549
- }
954
+ for (const { prefix: _prefix, ...span } of scanSpans(text)) if (classifyMarker(span.inner, span.form) === null) out.push(span);
550
955
  return out;
551
956
  };
552
- const MARKER_DEFECT_KINDS = ["unknown_directive", "bracket_index"];
553
- /** A `{{#...}}` / `{{/...}}` shape: an author reaching for a block directive. */
554
- const DIRECTIVE_SHAPE_RE = /^[#/]/u;
957
+ const LEGACY_BLOCK_RE = /^(?<token>#if|#elseif|#else|#each|\/if|\/each)\b(?<expr>[\s\S]*)$/u;
958
+ const LEGACY_CLAUSE_RE = /^@clause:(?<name>[^:}\s]+)(?::(?<version>[^}\s]+))?$/u;
959
+ const LEGACY_NUM_RE = /^@(?<fn>num|ref):(?<key>[\p{L}\p{N}_.-]+)$/u;
960
+ /** Singular of a plain-word path segment, for a generated loop alias. */
961
+ const singularize = (segment) => {
962
+ if (segment.endsWith("ies") && segment.length > 3) return `${segment.slice(0, -3)}y`;
963
+ if (/(?:s|ss|sh|ch|x|z)es$/u.test(segment)) return segment.slice(0, -2);
964
+ if (/[^s]s$/u.test(segment)) return segment.slice(0, -1);
965
+ return segment;
966
+ };
967
+ /** The loop alias the codemod (and the legacy-marker message) generates for a
968
+ * `{{#each path}}`: the singular of the last plain-word segment, else `item`. */
969
+ const legacyLoopAlias = (path) => {
970
+ const segment = path.split(".").at(-1) ?? "";
971
+ if (!IDENTIFIER_RE.test(segment)) return "item";
972
+ const alias = singularize(segment);
973
+ return alias === "" || alias === segment ? "item" : alias;
974
+ };
975
+ /** Translate an old-dialect condition expression to the Jinja surface:
976
+ * `!a` becomes `not a`, `a contains "x"` becomes `"x" in a`. */
977
+ const translateLegacyExpression = (expr) => expr.replace(/(?<![\p{L}\p{N}_.-])(?<path>[\p{L}\p{N}_.-]+)\s+contains\s+(?<value>"[^"]*"|[\p{L}\p{N}_.-]+)/gu, (_m, path, value) => `${value.startsWith("\"") ? value : `"${value}"`} in ${path}`).replace(/!(?!=)\s*/gu, "not ");
978
+ /**
979
+ * The Jinja marker that replaces one old-dialect marker's inner text, or
980
+ * `null` when the text is not old-dialect. Shared by the rejection message and
981
+ * the codemod, so the fix an author is told to make is the fix the script
982
+ * would have made.
983
+ */
984
+ const legacyMarkerReplacement = (innerRaw) => {
985
+ const inner = normalizeMarkerInner(innerRaw).trim();
986
+ const block = LEGACY_BLOCK_RE.exec(inner);
987
+ if (block) {
988
+ const token = block.groups?.["token"] ?? "";
989
+ const expr = translateLegacyExpression((block.groups?.["expr"] ?? "").trim());
990
+ switch (token) {
991
+ case "#if": return `{% if ${expr} %}`;
992
+ case "#elseif": return `{% elif ${expr} %}`;
993
+ case "#else": return "{% else %}";
994
+ case "/if": return "{% endif %}";
995
+ case "#each": return `{% for ${legacyLoopAlias(expr)} in ${expr} %}`;
996
+ case "/each": return "{% endfor %}";
997
+ default: return null;
998
+ }
999
+ }
1000
+ const clause = LEGACY_CLAUSE_RE.exec(inner);
1001
+ if (clause) {
1002
+ const name = clause.groups?.["name"] ?? "";
1003
+ const version = clause.groups?.["version"];
1004
+ return version === void 0 ? `{{ clause("${name}") }}` : `{{ clause("${name}", "${version}") }}`;
1005
+ }
1006
+ const numbering = LEGACY_NUM_RE.exec(inner);
1007
+ if (numbering) return `{{ ${numbering.groups?.["fn"] ?? ""}("${numbering.groups?.["key"] ?? ""}") }}`;
1008
+ if (inner === "@index") return "{{ loop.index }}";
1009
+ if (inner === "@count") return "{{ loop.length }}";
1010
+ return null;
1011
+ };
1012
+ const MARKER_DEFECT_KINDS = [
1013
+ "legacy_marker",
1014
+ "unsupported_tag",
1015
+ "unknown_filter",
1016
+ "python_expression",
1017
+ "bracket_index"
1018
+ ];
555
1019
  /** Bracket indexing (`items[0].name`), which no directive kind admits. */
556
1020
  const BRACKET_INDEX_RE = /[[\]]/u;
1021
+ /** A legacy directive sigil at the head of an output marker. */
1022
+ const LEGACY_SHAPE_RE = /^[#/@]/u;
557
1023
  /**
558
- * Name the authoring mistake behind one marker's inner text, or `null` when the
559
- * text is a recognized directive ({@link classifyMarker} accepts it) or an
560
- * unrecognizable span with no specific diagnosis (`{{my field}}`). The
561
- * recognized-directive check is what makes `DIRECTIVE_KINDS` the single
562
- * authority on which `{{#...}}` tokens exist: adding a directive to the grammar
563
- * stops it being reported here, with no list to update.
564
- */
565
- const classifyMarkerDefect = (innerRaw) => {
566
- const inner = innerRaw.trim();
567
- if (classifyMarker(inner) !== null) return null;
568
- if (DIRECTIVE_SHAPE_RE.test(inner)) return "unknown_directive";
569
- if (BRACKET_INDEX_RE.test(inner)) return "bracket_index";
1024
+ * Arithmetic, a call, a quoted string or a Python literal inside `{{ }}`. A
1025
+ * span that merely holds a space (`{{my field}}`) is a mistyped path, not an
1026
+ * expression, and stays undiagnosed so the check reports it as an unreadable
1027
+ * marker rather than guessing at Python.
1028
+ */
1029
+ const PYTHON_SHAPE_RE = /["'()+*/%]|\bNone\b|\bTrue\b|\bFalse\b/u;
1030
+ /**
1031
+ * Name the authoring mistake behind one marker's inner text, or `null` when
1032
+ * the text is a recognized directive ({@link classifyMarker} accepts it) or an
1033
+ * unrecognizable span with no specific diagnosis (`{{my field}}`).
1034
+ */
1035
+ const classifyMarkerDefect = (innerRaw, form = "output") => {
1036
+ const inner = normalizeMarkerInner(innerRaw).trim();
1037
+ if (classifyMarker(inner, form) !== null) return null;
1038
+ if (form === "statement") {
1039
+ const tag = TAG_RE.exec(inner)?.groups?.["tag"];
1040
+ if (tag === void 0) return null;
1041
+ const legacy = legacyMarkerReplacement(inner);
1042
+ if (legacy !== null) return {
1043
+ kind: "legacy_marker",
1044
+ construct: inner,
1045
+ replacement: legacy
1046
+ };
1047
+ return {
1048
+ kind: "unsupported_tag",
1049
+ construct: tag
1050
+ };
1051
+ }
1052
+ const legacy = legacyMarkerReplacement(inner);
1053
+ if (legacy !== null) return {
1054
+ kind: "legacy_marker",
1055
+ construct: inner,
1056
+ replacement: legacy
1057
+ };
1058
+ if (LEGACY_SHAPE_RE.test(inner)) return {
1059
+ kind: "legacy_marker",
1060
+ construct: inner
1061
+ };
1062
+ if (BRACKET_INDEX_RE.test(inner)) return {
1063
+ kind: "bracket_index",
1064
+ construct: inner
1065
+ };
1066
+ const pipe = inner.indexOf("|");
1067
+ if (pipe !== -1) {
1068
+ const chain = scanFilterChain(inner.slice(pipe));
1069
+ if (chain !== null && "unknownFilter" in chain) return {
1070
+ kind: "unknown_filter",
1071
+ construct: chain.unknownFilter
1072
+ };
1073
+ }
1074
+ const call = CALL_RE.exec(inner);
1075
+ if (call) {
1076
+ const name = call.groups?.["name"] ?? "";
1077
+ if (!isFunctionName(name)) return {
1078
+ kind: "python_expression",
1079
+ construct: `${name}(...)`
1080
+ };
1081
+ return {
1082
+ kind: "python_expression",
1083
+ construct: inner
1084
+ };
1085
+ }
1086
+ const head = (pipe === -1 ? inner : inner.slice(0, pipe)).trim();
1087
+ if (head !== "" && !FIELD_PATH_RE.test(head) && PYTHON_SHAPE_RE.test(head)) return {
1088
+ kind: "python_expression",
1089
+ construct: head
1090
+ };
570
1091
  return null;
571
1092
  };
572
1093
  /** Exhaustiveness guard — pass the discriminant in a `switch` default branch. */
@@ -650,7 +1171,7 @@ const formatDate = (value, dateFormat) => {
650
1171
  * value are left as-is (a visible authoring artifact, not a render error), so
651
1172
  * a partially filled composite previews exactly as the fill engine renders it.
652
1173
  */
653
- const renderComposite = (parts, format, partValues) => format.replace(markerPattern(), (raw, inner) => {
1174
+ const renderComposite = (parts, format, partValues) => replaceOutputMarkers(format, (raw, inner) => {
654
1175
  const key = inner.trim();
655
1176
  if (!parts.some((part) => part.key === key)) return raw;
656
1177
  return partValues[key] ?? raw;
@@ -732,8 +1253,10 @@ const serializePredicate = (node) => {
732
1253
  const operand = serializeOperand(node.operand);
733
1254
  if (node.op === "contains") {
734
1255
  const value = Array.isArray(node.value) ? node.value.join(",") : node.value ?? "";
735
- return `${operand} contains ${serializeLiteral(value)}`;
1256
+ return `${serializeLiteral(value)} in ${operand}`;
736
1257
  }
1258
+ if (node.op === "is_not_empty") return `${operand} is defined`;
1259
+ if (node.op === "is_empty") return `${operand} is not defined`;
737
1260
  return operand;
738
1261
  };
739
1262
  const serializeGroup = (group, top) => {
@@ -744,7 +1267,7 @@ const serializeGroup = (group, top) => {
744
1267
  }
745
1268
  if (parts.length === 0) return "";
746
1269
  const body = parts.length === 1 ? parts[0] ?? "" : parts.join(group.combinator === "and" ? " and " : " or ");
747
- if (group.negated) return `!(${body})`;
1270
+ if (group.negated) return `not (${body})`;
748
1271
  if (parts.length === 1) return body;
749
1272
  return top ? body : `(${body})`;
750
1273
  };
@@ -770,7 +1293,7 @@ const serializeCondition = (node) => serializeNode(node, true);
770
1293
  * an author instead types the opener in front of the first cell's text and the
771
1294
  * closer behind the last cell's text:
772
1295
  *
773
- * | {{#each deliverables}}{{deliverables.item}} | {{deliverables.fee}}{{/each}} |
1296
+ * | {% for d in deliverables %}{{ d.item }} | {{ d.fee }}{% endfor %} |
774
1297
  *
775
1298
  * Both placements mean the same thing — the `w:tr` is the unit — so this
776
1299
  * module recognizes the row form purely from text, and the consumer rewrites
@@ -781,18 +1304,18 @@ const serializeCondition = (node) => serializeNode(node, true);
781
1304
  * and neither can drift from the other's idea of what a row block is.
782
1305
  */
783
1306
  const CLOSER_OF = {
784
- each: "endeach",
1307
+ for: "endfor",
785
1308
  if: "endif"
786
1309
  };
787
- const isOpenKind = (kind) => kind === "each" || kind === "if";
1310
+ const isOpenKind = (kind) => kind === "for" || kind === "if";
788
1311
  /**
789
1312
  * Block markers in one paragraph that do not pair inside it. A pair that opens
790
- * and closes within the paragraph is an inline span (`the Buyer{{#if x}} and
791
- * spouse{{/if}}`), which the inline engine already owns; only what is left
1313
+ * and closes within the paragraph is an inline span (`the Buyer{% if x %} and
1314
+ * spouse{% endif %}`), which the inline engine already owns; only what is left
792
1315
  * over can reach across cells.
793
1316
  *
794
- * A branch marker (`{{#elseif}}`, `{{#else}}`) counts as left over unless its
795
- * `{{#if}}` also closes in this paragraph. Only the opener and the closer of a
1317
+ * A branch marker (`{% elif %}`, `{% else %}`) counts as left over unless its
1318
+ * `{% if %}` also closes in this paragraph. Only the opener and the closer of a
796
1319
  * row block are hoisted, so a branch marker stranded between them would stay
797
1320
  * buried in the cell text: a false condition would drop the whole row and the
798
1321
  * branch that should have rendered with it, and a true one would leave the
@@ -816,13 +1339,13 @@ const danglingBlockMarkers = (text) => {
816
1339
  });
817
1340
  continue;
818
1341
  }
819
- if (kind === "endeach" || kind === "endif") {
1342
+ if (kind === "endfor" || kind === "endif") {
820
1343
  const innermost = open.at(-1);
821
1344
  if (innermost && CLOSER_OF[innermost.kind] === kind) open.pop();
822
1345
  else dangling.push(marker);
823
1346
  continue;
824
1347
  }
825
- if (kind === "elseif" || kind === "else") {
1348
+ if (kind === "elif" || kind === "else") {
826
1349
  const innermost = open.at(-1);
827
1350
  if (innermost?.kind === "if") innermost.branches.push(marker);
828
1351
  else dangling.push(marker);
@@ -847,7 +1370,7 @@ const suffixesCell = (paragraphs, { marker, paragraphIndex }) => paragraphIndex
847
1370
  * paragraph, they are a matching opener/closer pair, the closer sits in a LATER
848
1371
  * cell than the opener, the opener prefixes its cell and the closer suffixes
849
1372
  * its cell. Everything else — one half of a pair, two nested pairs, a stray
850
- * `{{#else}}`, a pair wrapping the paragraphs of a single cell — is left to the
1373
+ * `{% else %}`, a pair wrapping the paragraphs of a single cell — is left to the
851
1374
  * engine's existing structure errors rather than guessed at.
852
1375
  */
853
1376
  const detectRowBlockPair = (cells) => {
@@ -878,7 +1401,7 @@ const detectRowBlockPair = (cells) => {
878
1401
  *
879
1402
  * Boolean conditions are evaluated through the canonical `@stll/conditions`
880
1403
  * AST and its single evaluator, so view filters, AI-extraction gating, and
881
- * template `{{#if ...}}` conditionals share one set of operators and semantics
1404
+ * template `{% if ... %}` conditionals share one set of operators and semantics
882
1405
  * and never drift apart. This module owns the template surface: the string
883
1406
  * parser (`./parse`), the named-condition resolver, and the no-code builder
884
1407
  * serializer (`./condition-builder`).
@@ -892,7 +1415,7 @@ const MAX_CONDITION_DEPTH = 50;
892
1415
  /**
893
1416
  * Normalize a fill-bag value into a `ConditionValue` the shared evaluator
894
1417
  * accepts: arrays become string arrays (multi-select fields hold their options
895
- * as strings, so membership tests like `parties contains "guarantor"` work
1418
+ * as strings, so membership tests like `"guarantor" in parties` work
896
1419
  * even when an element is numeric), primitives pass through, anything else is
897
1420
  * stringified.
898
1421
  */
@@ -905,7 +1428,7 @@ const toConditionValue = (raw) => {
905
1428
  * Build the operand resolver for one evaluation. Only `path` operands need
906
1429
  * resolving (the evaluator handles literals). A path is first matched against
907
1430
  * the named conditions: a hit evaluates that condition's expression — so
908
- * `{{#if isCompany}}` and `{{#if isCompany and signed}}` both work — guarded
1431
+ * `{% if isCompany %}` and `{% if isCompany and signed %}` both work — guarded
909
1432
  * against cycles (`resolved`) and runaway chains (`MAX_CONDITION_DEPTH`).
910
1433
  * Otherwise the path resolves against the fill data.
911
1434
  */
@@ -927,12 +1450,13 @@ const makeResolver = (data, namedConditions, resolved, depth) => {
927
1450
  return resolve;
928
1451
  };
929
1452
  /**
930
- * Evaluate a template `{{#if ...}}` condition. The expression is parsed into
1453
+ * Evaluate a template `{% if ... %}` condition. The expression is parsed into
931
1454
  * the canonical `@stll/conditions` AST and evaluated by the shared evaluator.
932
1455
  *
933
- * Supports comparisons (`==`, `!=`, `>`, `<`, `>=`, `<=`), `contains`,
934
- * truthiness, negation (`!`), logical operators (`and`, `or`), parentheses,
935
- * dotted paths, numeric underscores, and named-condition references.
1456
+ * Supports comparisons (`==`, `!=`, `>`, `<`, `>=`, `<=`), membership
1457
+ * (`"x" in path`), presence (`is defined` / `is not defined`), truthiness,
1458
+ * negation (`not`), logical operators (`and`, `or`), parentheses, dotted
1459
+ * paths, numeric underscores, and named-condition references.
936
1460
  *
937
1461
  * An empty or unparseable expression is `false` ("no condition" never gates a
938
1462
  * branch true).
@@ -943,4 +1467,4 @@ const evaluateCondition = (expression, data, namedConditions, _resolved = /* @__
943
1467
  return evaluateCondition$1(node, makeResolver(data, namedConditions, _resolved, _depth));
944
1468
  };
945
1469
  //#endregion
946
- export { BLOCK_DIRECTIVE_KINDS, DATE_FORMAT_EXAMPLE_ISO, DATE_FORMAT_STYLES, DIRECTIVE_KINDS, MARKER_DEFECT_KINDS, MAX_CONDITION_DEPTH, NUMERIC_FUNCTION_NAMES, assertNever, blockDirectiveLinePattern, classifyMarker, classifyMarkerDefect, clauseSlotPattern, conditionHasFormula, conditionNodeSchema, conditionSchema, countPattern, detectRowBlockPair, emptyCondition, evaluateCondition, evaluateNumericExpression, formatDate, hasBlockDirectivePattern, hasNumberingPattern, indexPattern, isBlockDirectiveKind, isClauseSlotName, isFieldPath, isSafeFieldPath, markerPattern, numPattern, parseCondition, placeholderPattern, refPattern, renderComposite, renderDeterministicFieldValue, resolvePath, scanInvalidMarkers, scanMarkers, serializeCondition };
1470
+ export { BLOCK_DIRECTIVE_KINDS, DATE_FORMAT_EXAMPLE_ISO, DATE_FORMAT_STYLES, DIRECTIVE_KINDS, FILTER_NAMES, LOOP_PROPERTIES, MARKER_DEFECT_KINDS, MARKER_OUTPUT_BODY, MARKER_STATEMENT_BODY, MAX_CONDITION_DEPTH, NUMERIC_FUNCTION_NAMES, assertNever, blockDirectiveLinePattern, classifyMarker, classifyMarkerDefect, clauseSlotKey, clauseSlotPattern, conditionHasFormula, conditionNodeSchema, conditionSchema, detectRowBlockPair, emptyCondition, evaluateCondition, evaluateNumericExpression, formatDate, hasBlockDirectivePattern, hasNumberingPattern, isBlockDirectiveKind, isClauseSlotName, isFieldPath, isSafeFieldPath, legacyLoopAlias, legacyMarkerReplacement, loopPattern, markerPattern, normalizeMarkerInner, numPattern, parseCondition, placeholderPattern, refPattern, referencedConditionPaths, renderComposite, renderDeterministicFieldValue, replaceOutputMarkers, resolvePath, scanInvalidMarkers, scanMarkers, serializeCondition, substitutionKey, translateLegacyExpression };