@stll/template-conditions 0.2.2 → 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,112 +611,485 @@ 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
  };
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
+ ];
1019
+ /** Bracket indexing (`items[0].name`), which no directive kind admits. */
1020
+ const BRACKET_INDEX_RE = /[[\]]/u;
1021
+ /** A legacy directive sigil at the head of an output marker. */
1022
+ const LEGACY_SHAPE_RE = /^[#/@]/u;
1023
+ /**
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
+ };
1091
+ return null;
1092
+ };
552
1093
  /** Exhaustiveness guard — pass the discriminant in a `switch` default branch. */
553
1094
  const assertNever = (value) => panic(`Unhandled template directive: ${JSON.stringify(value)}`);
554
1095
  //#endregion
@@ -630,7 +1171,7 @@ const formatDate = (value, dateFormat) => {
630
1171
  * value are left as-is (a visible authoring artifact, not a render error), so
631
1172
  * a partially filled composite previews exactly as the fill engine renders it.
632
1173
  */
633
- const renderComposite = (parts, format, partValues) => format.replace(markerPattern(), (raw, inner) => {
1174
+ const renderComposite = (parts, format, partValues) => replaceOutputMarkers(format, (raw, inner) => {
634
1175
  const key = inner.trim();
635
1176
  if (!parts.some((part) => part.key === key)) return raw;
636
1177
  return partValues[key] ?? raw;
@@ -712,8 +1253,10 @@ const serializePredicate = (node) => {
712
1253
  const operand = serializeOperand(node.operand);
713
1254
  if (node.op === "contains") {
714
1255
  const value = Array.isArray(node.value) ? node.value.join(",") : node.value ?? "";
715
- return `${operand} contains ${serializeLiteral(value)}`;
1256
+ return `${serializeLiteral(value)} in ${operand}`;
716
1257
  }
1258
+ if (node.op === "is_not_empty") return `${operand} is defined`;
1259
+ if (node.op === "is_empty") return `${operand} is not defined`;
717
1260
  return operand;
718
1261
  };
719
1262
  const serializeGroup = (group, top) => {
@@ -724,7 +1267,7 @@ const serializeGroup = (group, top) => {
724
1267
  }
725
1268
  if (parts.length === 0) return "";
726
1269
  const body = parts.length === 1 ? parts[0] ?? "" : parts.join(group.combinator === "and" ? " and " : " or ");
727
- if (group.negated) return `!(${body})`;
1270
+ if (group.negated) return `not (${body})`;
728
1271
  if (parts.length === 1) return body;
729
1272
  return top ? body : `(${body})`;
730
1273
  };
@@ -740,6 +1283,116 @@ const serializeNode = (node, top) => {
740
1283
  */
741
1284
  const serializeCondition = (node) => serializeNode(node, true);
742
1285
  //#endregion
1286
+ //#region src/row-blocks.ts
1287
+ /**
1288
+ * Row-form block markers: the lenient placement authoring agents actually
1289
+ * write for a "one row per item" table.
1290
+ *
1291
+ * The canonical placement gives a block marker its own paragraph. In a table
1292
+ * that costs an extra empty-looking paragraph in the first and last cell, so
1293
+ * an author instead types the opener in front of the first cell's text and the
1294
+ * closer behind the last cell's text:
1295
+ *
1296
+ * | {% for d in deliverables %}{{ d.item }} | {{ d.fee }}{% endfor %} |
1297
+ *
1298
+ * Both placements mean the same thing — the `w:tr` is the unit — so this
1299
+ * module recognizes the row form purely from text, and the consumer rewrites
1300
+ * it into the canonical form before any engine runs.
1301
+ *
1302
+ * Text-only and structure-free on purpose: the fill/discovery pipeline feeds
1303
+ * it OOXML run text, the authoring eval feeds it the cells a model produced,
1304
+ * and neither can drift from the other's idea of what a row block is.
1305
+ */
1306
+ const CLOSER_OF = {
1307
+ for: "endfor",
1308
+ if: "endif"
1309
+ };
1310
+ const isOpenKind = (kind) => kind === "for" || kind === "if";
1311
+ /**
1312
+ * Block markers in one paragraph that do not pair inside it. A pair that opens
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
1315
+ * over can reach across cells.
1316
+ *
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
1319
+ * row block are hoisted, so a branch marker stranded between them would stay
1320
+ * buried in the cell text: a false condition would drop the whole row and the
1321
+ * branch that should have rendered with it, and a true one would leave the
1322
+ * marker behind as an inline orphan. Counting it here refuses the placement
1323
+ * instead.
1324
+ *
1325
+ * A paragraph that is nothing but a directive is the canonical form and
1326
+ * belongs to the block engine, so it contributes nothing here.
1327
+ */
1328
+ const danglingBlockMarkers = (text) => {
1329
+ if (blockDirectiveLinePattern().test(text)) return [];
1330
+ const open = [];
1331
+ const dangling = [];
1332
+ for (const marker of scanMarkers(text)) {
1333
+ const { kind } = marker.meta;
1334
+ if (isOpenKind(kind)) {
1335
+ open.push({
1336
+ kind,
1337
+ marker,
1338
+ branches: []
1339
+ });
1340
+ continue;
1341
+ }
1342
+ if (kind === "endfor" || kind === "endif") {
1343
+ const innermost = open.at(-1);
1344
+ if (innermost && CLOSER_OF[innermost.kind] === kind) open.pop();
1345
+ else dangling.push(marker);
1346
+ continue;
1347
+ }
1348
+ if (kind === "elif" || kind === "else") {
1349
+ const innermost = open.at(-1);
1350
+ if (innermost?.kind === "if") innermost.branches.push(marker);
1351
+ else dangling.push(marker);
1352
+ }
1353
+ }
1354
+ for (const { branches, marker } of open) dangling.push(marker, ...branches);
1355
+ return dangling.toSorted((a, b) => a.start - b.start);
1356
+ };
1357
+ const firstContentParagraph = (paragraphs) => paragraphs.findIndex((text) => text.trim() !== "");
1358
+ const lastContentParagraph = (paragraphs) => paragraphs.findLastIndex((text) => text.trim() !== "");
1359
+ /** The marker stands in front of everything the cell says. */
1360
+ const prefixesCell = (paragraphs, { marker, paragraphIndex }) => paragraphIndex === firstContentParagraph(paragraphs) && (paragraphs[paragraphIndex] ?? "").slice(0, marker.start).trim() === "";
1361
+ /** The marker stands behind everything the cell says. */
1362
+ const suffixesCell = (paragraphs, { marker, paragraphIndex }) => paragraphIndex === lastContentParagraph(paragraphs) && (paragraphs[paragraphIndex] ?? "").slice(marker.end).trim() === "";
1363
+ /**
1364
+ * The one row block a table row declares in the lenient form, or `null`.
1365
+ *
1366
+ * `cells` is the row's cells in document order, each as its paragraph texts in
1367
+ * document order.
1368
+ *
1369
+ * A row qualifies when exactly two block markers reach outside their own
1370
+ * paragraph, they are a matching opener/closer pair, the closer sits in a LATER
1371
+ * cell than the opener, the opener prefixes its cell and the closer suffixes
1372
+ * its cell. Everything else — one half of a pair, two nested pairs, a stray
1373
+ * `{% else %}`, a pair wrapping the paragraphs of a single cell — is left to the
1374
+ * engine's existing structure errors rather than guessed at.
1375
+ */
1376
+ const detectRowBlockPair = (cells) => {
1377
+ const located = [];
1378
+ for (const [cellIndex, paragraphs] of cells.entries()) for (const [paragraphIndex, text] of paragraphs.entries()) for (const marker of danglingBlockMarkers(text)) located.push({
1379
+ cellIndex,
1380
+ paragraphIndex,
1381
+ marker
1382
+ });
1383
+ if (located.length !== 2) return null;
1384
+ const [open, close] = located;
1385
+ if (!open || !close) return null;
1386
+ const openKind = open.marker.meta.kind;
1387
+ if (!isOpenKind(openKind) || close.marker.meta.kind !== CLOSER_OF[openKind]) return null;
1388
+ if (open.cellIndex >= close.cellIndex) return null;
1389
+ if (!prefixesCell(cells[open.cellIndex] ?? [], open) || !suffixesCell(cells[close.cellIndex] ?? [], close)) return null;
1390
+ return {
1391
+ open,
1392
+ close
1393
+ };
1394
+ };
1395
+ //#endregion
743
1396
  //#region src/index.ts
744
1397
  /**
745
1398
  * Shared condition evaluation + marker grammar for DOCX templates. Small,
@@ -748,7 +1401,7 @@ const serializeCondition = (node) => serializeNode(node, true);
748
1401
  *
749
1402
  * Boolean conditions are evaluated through the canonical `@stll/conditions`
750
1403
  * AST and its single evaluator, so view filters, AI-extraction gating, and
751
- * template `{{#if ...}}` conditionals share one set of operators and semantics
1404
+ * template `{% if ... %}` conditionals share one set of operators and semantics
752
1405
  * and never drift apart. This module owns the template surface: the string
753
1406
  * parser (`./parse`), the named-condition resolver, and the no-code builder
754
1407
  * serializer (`./condition-builder`).
@@ -762,7 +1415,7 @@ const MAX_CONDITION_DEPTH = 50;
762
1415
  /**
763
1416
  * Normalize a fill-bag value into a `ConditionValue` the shared evaluator
764
1417
  * accepts: arrays become string arrays (multi-select fields hold their options
765
- * as strings, so membership tests like `parties contains "guarantor"` work
1418
+ * as strings, so membership tests like `"guarantor" in parties` work
766
1419
  * even when an element is numeric), primitives pass through, anything else is
767
1420
  * stringified.
768
1421
  */
@@ -775,7 +1428,7 @@ const toConditionValue = (raw) => {
775
1428
  * Build the operand resolver for one evaluation. Only `path` operands need
776
1429
  * resolving (the evaluator handles literals). A path is first matched against
777
1430
  * the named conditions: a hit evaluates that condition's expression — so
778
- * `{{#if isCompany}}` and `{{#if isCompany and signed}}` both work — guarded
1431
+ * `{% if isCompany %}` and `{% if isCompany and signed %}` both work — guarded
779
1432
  * against cycles (`resolved`) and runaway chains (`MAX_CONDITION_DEPTH`).
780
1433
  * Otherwise the path resolves against the fill data.
781
1434
  */
@@ -797,12 +1450,13 @@ const makeResolver = (data, namedConditions, resolved, depth) => {
797
1450
  return resolve;
798
1451
  };
799
1452
  /**
800
- * Evaluate a template `{{#if ...}}` condition. The expression is parsed into
1453
+ * Evaluate a template `{% if ... %}` condition. The expression is parsed into
801
1454
  * the canonical `@stll/conditions` AST and evaluated by the shared evaluator.
802
1455
  *
803
- * Supports comparisons (`==`, `!=`, `>`, `<`, `>=`, `<=`), `contains`,
804
- * truthiness, negation (`!`), logical operators (`and`, `or`), parentheses,
805
- * 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.
806
1460
  *
807
1461
  * An empty or unparseable expression is `false` ("no condition" never gates a
808
1462
  * branch true).
@@ -813,4 +1467,4 @@ const evaluateCondition = (expression, data, namedConditions, _resolved = /* @__
813
1467
  return evaluateCondition$1(node, makeResolver(data, namedConditions, _resolved, _depth));
814
1468
  };
815
1469
  //#endregion
816
- export { BLOCK_DIRECTIVE_KINDS, DATE_FORMAT_EXAMPLE_ISO, DATE_FORMAT_STYLES, DIRECTIVE_KINDS, MAX_CONDITION_DEPTH, NUMERIC_FUNCTION_NAMES, assertNever, blockDirectiveLinePattern, classifyMarker, clauseSlotPattern, conditionHasFormula, conditionNodeSchema, conditionSchema, countPattern, 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 };