@stll/template-conditions 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +182 -27
  2. package/dist/index.js +358 -176
  3. package/package.json +3 -2
package/dist/index.d.ts CHANGED
@@ -47,8 +47,8 @@ declare const evaluateNumericExpression: (expression: string, data: Record<strin
47
47
  //#region src/field-values.d.ts
48
48
  /**
49
49
  * Single source of truth for the DETERMINISTIC field-value transforms a
50
- * template fill applies — composite (parts joined by a `{{key}}` format),
51
- * formula (arithmetic over the other values), and locale-aware date rendering.
50
+ * template fill applies: formula (arithmetic over the other values) and
51
+ * locale-aware date rendering.
52
52
  *
53
53
  * Both the API fill engine (apps/api/src/handlers/docx) and the web live
54
54
  * preview (template-studio.tsx) MUST route their rendering through
@@ -83,18 +83,6 @@ type FieldDateFormat = {
83
83
  * returns null.
84
84
  */
85
85
  declare const formatDate: (value: string, dateFormat: FieldDateFormat) => string | null;
86
- /** Minimal structural shape of one composite field part: only the `key`
87
- * referenced by the join format is needed to render. Callers' richer part
88
- * types (api FieldPart, web EditablePart) structurally satisfy this. */
89
- type PartConfig = {
90
- key: string;
91
- };
92
- /**
93
- * Render a `{{key}}` format over part values. Markers whose key has no part
94
- * value are left as-is (a visible authoring artifact, not a render error), so
95
- * a partially filled composite previews exactly as the fill engine renders it.
96
- */
97
- declare const renderComposite: (parts: readonly PartConfig[], format: string, partValues: Readonly<Record<string, string>>) => string;
98
86
  /**
99
87
  * Minimal STRUCTURAL config of a field, carrying only what the deterministic
100
88
  * transforms read. Callers (api FieldMeta, web StudioField) structurally
@@ -104,8 +92,6 @@ declare const renderComposite: (parts: readonly PartConfig[], format: string, pa
104
92
  type DeterministicFieldConfig = {
105
93
  path: string;
106
94
  inputType?: string | undefined;
107
- parts?: readonly PartConfig[] | undefined;
108
- format?: string | undefined;
109
95
  formula?: string | undefined;
110
96
  dateFormat?: FieldDateFormat | undefined;
111
97
  };
@@ -115,16 +101,13 @@ type DeterministicFieldConfig = {
115
101
  * no deterministic transform (a scalar, lookup, or AI field the CALLER renders
116
102
  * itself).
117
103
  *
118
- * Dispatch order mirrors the API fill pipeline (composite → formula → date):
119
- * - composite (parts + format present) → {@link renderComposite}
120
- * - else formula present → {@link evaluateNumericExpression}, stringified
104
+ * Dispatch order mirrors the API fill pipeline (formula → date):
105
+ * - formula present → {@link evaluateNumericExpression}, stringified
121
106
  * - else date (inputType "date" + dateFormat) → {@link formatDate}
122
107
  * - else null
123
108
  *
124
- * Composite returns null when its value is not an object of part values (a
125
- * plain string passes through unchanged on the api side, so the caller's
126
- * scalar path handles it). Formula and date return null when the expression /
127
- * value does not yield a value, so the caller leaves the field as-is.
109
+ * Formula and date return null when the expression or value does not yield a
110
+ * value, so the caller leaves the field as-is.
128
111
  */
129
112
  declare const renderDeterministicFieldValue: (field: DeterministicFieldConfig, values: Record<string, unknown>) => string | null;
130
113
  //#endregion
@@ -249,10 +232,16 @@ declare const normalizeMarkerInner: (inner: string) => string;
249
232
  * not a nested marker. An apostrophe stays an ordinary character, so
250
233
  * `label("Owner's")` reads as it always did.
251
234
  *
235
+ * A quoted run honours `\`-escapes, the way the argument scanner reads them,
236
+ * so `label("she said \"yes\"")` is one span rather than three: the span and
237
+ * the scanner have to agree on where a literal ends or a marker the scanner
238
+ * would parse is one the recognizer never sees.
239
+ *
252
240
  * The alternatives cannot overlap (the plain one excludes every character the
253
- * quoted one may start with) and a quoted run's end is forced by its body, so
254
- * the scan stays linear on adversarial input: a `{{` with no closing `}}`
255
- * costs one failed pass, not a polynomial retry.
241
+ * quoted one may start with, and inside a quoted run the escape branch is the
242
+ * only one that starts with a backslash), so the scan stays linear on
243
+ * adversarial input: a `{{` with no closing `}}` costs one failed pass, not a
244
+ * polynomial retry.
256
245
  */
257
246
  declare const MARKER_OUTPUT_BODY: string;
258
247
  /** What a tag holds between `{%` and `%}`: anything but a brace, and a `%`
@@ -389,6 +378,172 @@ declare const classifyMarkerDefect: (innerRaw: string, form?: MarkerForm) => Mar
389
378
  /** Exhaustiveness guard — pass the discriminant in a `switch` default branch. */
390
379
  declare const assertNever: (value: never) => never;
391
380
  //#endregion
381
+ //#region src/loop-scope.d.ts
382
+ /**
383
+ * The path a marker names, once the loops around it are taken into account.
384
+ *
385
+ * `{% for attorney in attorneys %}{{ attorney.name }}{% endfor %}` writes one
386
+ * field, and the manifest calls it `attorneys.name`: the loop's alias is the
387
+ * body's name for the item, the array path is the manifest's. Discovery reads
388
+ * that mapping, and every writer that puts a configuration back into a marker
389
+ * has to reverse it, so the rule lives beside the grammar rather than in each
390
+ * of them; two readings of one loop are two different documents.
391
+ */
392
+ /** One enclosing `{% for %}`, as the body sees it and as the manifest does. */
393
+ type RowScope = {
394
+ /** The loop variable the body addresses items through. */
395
+ alias: string;
396
+ /** The array path the opener was written with, before its own enclosing
397
+ * loops were applied. */
398
+ declaredPath: string;
399
+ /** The array path the manifest speaks, enclosing loops included. */
400
+ scopedPath: string;
401
+ };
402
+ declare const rowScopePaths: (rowScopes: readonly RowScope[]) => string[];
403
+ /** A path already under the innermost repeat is left alone; anything else is
404
+ * read as a name relative to it. */
405
+ declare const qualifyRowScopedPath: (path: string, rowPaths?: readonly string[]) => string;
406
+ /** The manifest path for a marker written inside these loops. */
407
+ declare const qualifyRowScopedPlaceholder: (path: string, rowScopes: readonly RowScope[]) => string;
408
+ /** The manifest path for an array a `{% for %}` opens inside these loops. */
409
+ declare const qualifyLoopPath: (declaredPath: string, rowScopes: readonly RowScope[]) => string;
410
+ //#endregion
411
+ //#region src/field-config.d.ts
412
+ /** Where a field's value comes from, when a record supplies it. Only the shape
413
+ * the filters read: each caller's own union satisfies it. */
414
+ type MarkerFieldSource = {
415
+ kind: "matter";
416
+ field: string;
417
+ } | {
418
+ kind: "contact";
419
+ field: string;
420
+ } | {
421
+ kind: "firm";
422
+ field: string;
423
+ } | {
424
+ kind: "party";
425
+ role: string;
426
+ field: string;
427
+ } | {
428
+ kind: "attorney";
429
+ ref: string;
430
+ field: string;
431
+ };
432
+ /** The constraints a chain can express. */
433
+ type MarkerFieldValidation = {
434
+ required?: boolean | undefined;
435
+ pattern?: string | undefined;
436
+ min?: number | undefined;
437
+ max?: number | undefined;
438
+ minLength?: number | undefined;
439
+ maxLength?: number | undefined;
440
+ minItems?: number | undefined;
441
+ maxItems?: number | undefined;
442
+ };
443
+ /** A registry lookup and the renderings of its one resolved hit. */
444
+ type MarkerFieldLookup = {
445
+ registry: string;
446
+ formats: readonly {
447
+ key: string;
448
+ template: string;
449
+ }[];
450
+ };
451
+ /** The input controls a marker can name. */
452
+ type MarkerFieldInputType = "text" | "number" | "boolean" | "date" | "select";
453
+ /**
454
+ * Everything about a field that a marker's filter chain can carry. A caller's
455
+ * own field type may hold more (a derived condition AST, a discovery count);
456
+ * those keys are simply not read here.
457
+ */
458
+ type MarkerFieldConfig = {
459
+ path: string;
460
+ label?: string | undefined;
461
+ hint?: string | undefined;
462
+ inputType?: MarkerFieldInputType | undefined;
463
+ options?: readonly string[] | undefined;
464
+ optionsFrom?: string | undefined;
465
+ validation?: MarkerFieldValidation | undefined;
466
+ required?: boolean | undefined;
467
+ aiPrompt?: string | undefined;
468
+ aiAdapt?: boolean | undefined;
469
+ aiSeesDocument?: boolean | undefined;
470
+ lookup?: MarkerFieldLookup | undefined;
471
+ source?: MarkerFieldSource | undefined;
472
+ formula?: string | undefined;
473
+ condition?: string | undefined;
474
+ dateFormat?: FieldDateFormat | undefined;
475
+ };
476
+ /**
477
+ * The filters that describe a REPEAT rather than a value: how many rows it
478
+ * takes and what to call it. These are the ones a `{% for %}` tag may carry,
479
+ * since the loop path names the array, not one of its values.
480
+ */
481
+ declare const ARRAY_FILTER_NAMES: readonly ["label", "hint", "required", "min_items", "max_items"];
482
+ declare const isArrayFilterName: (name: FilterName) => boolean;
483
+ /**
484
+ * The filter chain that declares this field in a document, in the catalogue's
485
+ * own order so a chain the writer produces and one an author wrote read the
486
+ * same.
487
+ */
488
+ declare const filtersFromFieldConfig: (field: MarkerFieldConfig) => FilterCall[];
489
+ /**
490
+ * The chain a `{% for %}` opener carries for this array: the repeat's own
491
+ * filters and nothing else, derived from {@link ARRAY_FILTER_NAMES}, so a
492
+ * value filter can never be written where the reader refuses it.
493
+ */
494
+ declare const arrayFiltersFromFieldConfig: (field: MarkerFieldConfig) => FilterCall[];
495
+ //#endregion
496
+ //#region src/render.d.ts
497
+ /** Why a value has no spelling the scanner reads back as itself. */
498
+ type UnwritableReason = "tag-delimiter" | "number-spelling";
499
+ /** The reason this literal cannot be written in a marker of this form, or
500
+ * `null` when it can. A boolean always can; a string can in a `{{ }}` marker,
501
+ * because the writer quotes and escapes it. */
502
+ declare const unwritableMarkerLiteral: (value: MarkerLiteral, form?: MarkerForm) => UnwritableReason | null;
503
+ /** Every value in a chain the grammar has no spelling for, with the filter it
504
+ * was written on and why, so a caller can refuse a configuration before it
505
+ * rewrites a document into something the scanner would read differently. A
506
+ * repeat's chain goes in a tag, so pass `"statement"` for one. */
507
+ declare const unwritableFilterValues: (filters: readonly FilterCall[], form?: MarkerForm) => {
508
+ filter: string;
509
+ value: MarkerLiteral;
510
+ reason: UnwritableReason;
511
+ }[];
512
+ /**
513
+ * One filter chain as marker text, without the leading pipe: `label("A") |
514
+ * required`. An empty chain renders the empty string, which is what a marker
515
+ * that configures nothing carries.
516
+ */
517
+ declare const renderFilterChain: (filters: readonly FilterCall[]) => string;
518
+ /** One value marker: `{{ path }}` or `{{ path | filters }}`. */
519
+ declare const renderValueMarker: (path: string, filters: readonly FilterCall[]) => string;
520
+ type ConditionTagOptions = {
521
+ /** Which branch opener this is; `elif` keeps its place in the chain. */
522
+ kind: "if" | "elif";
523
+ /** The `{% if %}` expression, already serialized. */
524
+ expression: string;
525
+ /** The placement the tag already carried. */
526
+ prefix: MarkerPrefix;
527
+ };
528
+ /**
529
+ * One `{% if %}` / `{% elif %}` opener carrying its expression.
530
+ *
531
+ * A rule that decides whether a block shows belongs in the tag that shows it:
532
+ * the tag is the only place a document has for it, so a builder's rule is
533
+ * serialized here rather than stored beside the document under a name.
534
+ */
535
+ declare const renderConditionTag: ({ expression, kind, prefix }: ConditionTagOptions) => string;
536
+ type ForOpenerOptions = {
537
+ alias: string;
538
+ path: string;
539
+ filters: readonly FilterCall[];
540
+ /** The placement the opener already carried, so rewriting its filters does
541
+ * not move a row-form loop off its table row. */
542
+ prefix: MarkerPrefix;
543
+ };
544
+ /** One `{% for %}` opener, keeping its placement prefix. */
545
+ declare const renderForOpener: ({ alias, filters, path, prefix }: ForOpenerOptions) => string;
546
+ //#endregion
392
547
  //#region src/row-blocks.d.ts
393
548
  /** One end of a row block, addressed within the row that was scanned. */
394
549
  type RowBlockMarker = {
@@ -451,4 +606,4 @@ declare const MAX_CONDITION_DEPTH = 50;
451
606
  */
452
607
  declare const evaluateCondition: (expression: string, data: Record<string, unknown>, namedConditions?: readonly NamedCondition[], _resolved?: ReadonlySet<string>, _depth?: number) => boolean;
453
608
  //#endregion
454
- export { BLOCK_DIRECTIVE_KINDS, type BlockDirectiveKind, type CompareNode, type CompareOp, type Condition, type ConditionNode, DATE_FORMAT_EXAMPLE_ISO, DATE_FORMAT_STYLES, DIRECTIVE_KINDS, type DateFormatStyle, type DeterministicFieldConfig, type DirectiveKind, FILTER_NAMES, type FieldDateFormat, type FilterArgument, type FilterCall, type FilterName, type GroupNode, type InvalidMarker, LOOP_PROPERTIES, type LoopProperty, MARKER_DEFECT_KINDS, MARKER_OUTPUT_BODY, MARKER_STATEMENT_BODY, MAX_CONDITION_DEPTH, type MarkerDefect, type MarkerDefectKind, type MarkerForm, type MarkerLiteral, type MarkerMeta, type MarkerPrefix, NUMERIC_FUNCTION_NAMES, NamedCondition, type NumericFunctionName, type Operand, type PartConfig, type PredicateNode, type PredicateOp, type RowBlockMarker, type RowBlockPair, type ScannedMarker, 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 };
609
+ export { ARRAY_FILTER_NAMES, BLOCK_DIRECTIVE_KINDS, type BlockDirectiveKind, type CompareNode, type CompareOp, type Condition, type ConditionNode, type ConditionTagOptions, DATE_FORMAT_EXAMPLE_ISO, DATE_FORMAT_STYLES, DIRECTIVE_KINDS, type DateFormatStyle, type DeterministicFieldConfig, type DirectiveKind, FILTER_NAMES, type FieldDateFormat, type FilterArgument, type FilterCall, type FilterName, type ForOpenerOptions, type GroupNode, type InvalidMarker, LOOP_PROPERTIES, type LoopProperty, MARKER_DEFECT_KINDS, MARKER_OUTPUT_BODY, MARKER_STATEMENT_BODY, MAX_CONDITION_DEPTH, type MarkerDefect, type MarkerDefectKind, type MarkerFieldConfig, type MarkerFieldInputType, type MarkerFieldLookup, type MarkerFieldSource, type MarkerFieldValidation, type MarkerForm, type MarkerLiteral, type MarkerMeta, type MarkerPrefix, NUMERIC_FUNCTION_NAMES, NamedCondition, type NumericFunctionName, type Operand, type PredicateNode, type PredicateOp, type RowBlockMarker, type RowBlockPair, type RowScope, type ScannedMarker, type UnwritableReason, arrayFiltersFromFieldConfig, assertNever, blockDirectiveLinePattern, classifyMarker, classifyMarkerDefect, clauseSlotKey, clauseSlotPattern, conditionHasFormula, conditionNodeSchema, conditionSchema, detectRowBlockPair, emptyCondition, evaluateCondition, evaluateNumericExpression, filtersFromFieldConfig, formatDate, hasBlockDirectivePattern, hasNumberingPattern, isArrayFilterName, isBlockDirectiveKind, isClauseSlotName, isFieldPath, isSafeFieldPath, legacyLoopAlias, legacyMarkerReplacement, loopPattern, markerPattern, normalizeMarkerInner, numPattern, parseCondition, placeholderPattern, qualifyLoopPath, qualifyRowScopedPath, qualifyRowScopedPlaceholder, refPattern, referencedConditionPaths, renderConditionTag, renderDeterministicFieldValue, renderFilterChain, renderForOpener, renderValueMarker, replaceOutputMarkers, resolvePath, rowScopePaths, scanInvalidMarkers, scanMarkers, serializeCondition, substitutionKey, translateLegacyExpression, unwritableFilterValues, unwritableMarkerLiteral };
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { conditionHasFormula, conditionNodeSchema, conditionSchema, emptyCondition, evaluateCondition as evaluateCondition$1 } from "@stll/conditions";
2
- import { panic } from "better-result";
2
+ import { Result, panic } from "better-result";
3
+ import { Temporal } from "temporal-polyfill/full";
3
4
  //#region src/path.ts
4
5
  /**
5
6
  * Dotted-path resolution against a data record. Extracted from index.ts so both
@@ -454,6 +455,164 @@ const referencedConditionPaths = (expression) => {
454
455
  return node === null ? null : nodePaths(node);
455
456
  };
456
457
  //#endregion
458
+ //#region src/field-values.ts
459
+ /**
460
+ * Single source of truth for the DETERMINISTIC field-value transforms a
461
+ * template fill applies: formula (arithmetic over the other values) and
462
+ * locale-aware date rendering.
463
+ *
464
+ * Both the API fill engine (apps/api/src/handlers/docx) and the web live
465
+ * preview (template-studio.tsx) MUST route their rendering through
466
+ * {@link renderDeterministicFieldValue} so the preview and the generated
467
+ * document produce byte-identical strings. The api/field-value-parity.test.ts
468
+ * asserts this; reintroducing a bespoke transform on either side fails CI.
469
+ *
470
+ * Out of scope (non-deterministic / server-only, handled by callers):
471
+ * - registry lookup (needs a network hit; the preview overlays it async)
472
+ * - AI drafting / adaptation
473
+ *
474
+ * Pure: no IO, no model/provider dependency. The config types here are minimal
475
+ * STRUCTURAL shapes so the package stays free of api/web imports; callers pass
476
+ * their own field objects, which structurally satisfy these.
477
+ */
478
+ const DATE_FORMAT_STYLES = [
479
+ "long",
480
+ "medium",
481
+ "short",
482
+ "iso"
483
+ ];
484
+ /** Stable exemplar for date-format configuration previews. */
485
+ const DATE_FORMAT_EXAMPLE_ISO = "2028-06-13";
486
+ const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u;
487
+ const STYLE_OPTIONS = {
488
+ long: { dateStyle: "long" },
489
+ medium: { dateStyle: "medium" },
490
+ short: { dateStyle: "short" }
491
+ };
492
+ const dateFormatters = /* @__PURE__ */ new Map();
493
+ /** `Intl.DateTimeFormat` for a locale + style pair, cached per pair so it
494
+ * isn't rebuilt on every {@link formatDate} call. */
495
+ const getDateFormatter = (locale, style) => {
496
+ const key = `${locale}:${style}`;
497
+ const cached = dateFormatters.get(key);
498
+ if (cached) return cached;
499
+ const formatter = new Intl.DateTimeFormat(locale, {
500
+ ...STYLE_OPTIONS[style],
501
+ timeZone: "UTC"
502
+ });
503
+ dateFormatters.set(key, formatter);
504
+ return formatter;
505
+ };
506
+ /**
507
+ * Parse a strict YYYY-MM-DD calendar date; null when malformed or not a real
508
+ * date.
509
+ */
510
+ const parseIsoDate = (value) => {
511
+ if (!ISO_DATE_PATTERN.test(value)) return null;
512
+ return Result.try(() => Temporal.PlainDate.from(value)).unwrapOr(null);
513
+ };
514
+ /**
515
+ * Format an ISO date (YYYY-MM-DD) per the field's locale + style via
516
+ * `Intl.DateTimeFormat` — ICU produces the correct localized (and, in date
517
+ * contexts, correctly inflected) month names, e.g. cs long → "13. června
518
+ * 2028". The "iso" style returns the value unchanged; an invalid calendar date
519
+ * returns null.
520
+ */
521
+ const formatDate = (value, dateFormat) => {
522
+ const date = parseIsoDate(value);
523
+ if (date === null) return null;
524
+ if (dateFormat.style === "iso") return value;
525
+ const epochMilliseconds = date.toZonedDateTime({
526
+ plainTime: Temporal.PlainTime.from("00:00"),
527
+ timeZone: "UTC"
528
+ }).epochMilliseconds;
529
+ return getDateFormatter(dateFormat.locale, dateFormat.style).format(epochMilliseconds);
530
+ };
531
+ /**
532
+ * THE deterministic field-value dispatcher: returns the string the fill engine
533
+ * writes for `field` given the submitted `values`, or null when the field has
534
+ * no deterministic transform (a scalar, lookup, or AI field the CALLER renders
535
+ * itself).
536
+ *
537
+ * Dispatch order mirrors the API fill pipeline (formula → date):
538
+ * - formula present → {@link evaluateNumericExpression}, stringified
539
+ * - else date (inputType "date" + dateFormat) → {@link formatDate}
540
+ * - else null
541
+ *
542
+ * Formula and date return null when the expression or value does not yield a
543
+ * value, so the caller leaves the field as-is.
544
+ */
545
+ const renderDeterministicFieldValue = (field, values) => {
546
+ if (field.formula !== void 0) {
547
+ const result = evaluateNumericExpression(field.formula, values);
548
+ return result === void 0 ? null : String(result);
549
+ }
550
+ if (field.inputType === "date" && field.dateFormat !== void 0) {
551
+ const raw = resolvePath(field.path, values);
552
+ if (typeof raw !== "string" || raw.trim() === "") return null;
553
+ return formatDate(raw, field.dateFormat);
554
+ }
555
+ return null;
556
+ };
557
+ //#endregion
558
+ //#region src/condition-builder.ts
559
+ const COMPARE_OP_TO_SYMBOL = {
560
+ eq: "==",
561
+ neq: "!=",
562
+ gt: ">",
563
+ lt: "<",
564
+ gte: ">=",
565
+ lte: "<="
566
+ };
567
+ /** Render a literal: strings are quoted/escaped, booleans and numbers are
568
+ * emitted bare (matching the parser's literal handling); an array literal
569
+ * joins on `,` (rare; the template builder edits scalar rules). */
570
+ const serializeLiteral = (value) => {
571
+ if (typeof value === "string") return `"${value.replace(/\\/gu, "\\\\").replace(/"/gu, "\\\"")}"`;
572
+ if (Array.isArray(value)) return `"${value.join(",")}"`;
573
+ return String(value);
574
+ };
575
+ const serializeOperand = (operand) => {
576
+ if (operand.type === "literal") return serializeLiteral(operand.value);
577
+ if (operand.type === "path") return operand.path;
578
+ if (operand.type === "formula") panic("serializeCondition: formula operand requires AST storage");
579
+ return "";
580
+ };
581
+ const serializeCompare = (node) => `${serializeOperand(node.left)} ${COMPARE_OP_TO_SYMBOL[node.op]} ${serializeOperand(node.right)}`;
582
+ const serializePredicate = (node) => {
583
+ const operand = serializeOperand(node.operand);
584
+ if (node.op === "contains") {
585
+ const value = Array.isArray(node.value) ? node.value.join(",") : node.value ?? "";
586
+ return `${serializeLiteral(value)} in ${operand}`;
587
+ }
588
+ if (node.op === "is_not_empty") return `${operand} is defined`;
589
+ if (node.op === "is_empty") return `${operand} is not defined`;
590
+ return operand;
591
+ };
592
+ const serializeGroup = (group, top) => {
593
+ const parts = [];
594
+ for (const child of group.children) {
595
+ const part = serializeNode(child, false);
596
+ if (part.length > 0) parts.push(part);
597
+ }
598
+ if (parts.length === 0) return "";
599
+ const body = parts.length === 1 ? parts[0] ?? "" : parts.join(group.combinator === "and" ? " and " : " or ");
600
+ if (group.negated) return `not (${body})`;
601
+ if (parts.length === 1) return body;
602
+ return top ? body : `(${body})`;
603
+ };
604
+ const serializeNode = (node, top) => {
605
+ if (node.type === "compare") return serializeCompare(node);
606
+ if (node.type === "predicate") return serializePredicate(node);
607
+ return serializeGroup(node, top);
608
+ };
609
+ /**
610
+ * Serialize a condition tree into an expression for `evaluateCondition`.
611
+ * Returns an empty string for an empty tree (caller treats that as "no
612
+ * condition" / always-visible).
613
+ */
614
+ const serializeCondition = (node) => serializeNode(node, true);
615
+ //#endregion
457
616
  //#region src/markers.ts
458
617
  /**
459
618
  * Canonical grammar for the docxtpl dialect of Jinja — the single source of
@@ -560,12 +719,18 @@ const normalizeMarkerInner = (inner) => inner.replace(SMART_DOUBLE_QUOTES, "\"")
560
719
  * not a nested marker. An apostrophe stays an ordinary character, so
561
720
  * `label("Owner's")` reads as it always did.
562
721
  *
722
+ * A quoted run honours `\`-escapes, the way the argument scanner reads them,
723
+ * so `label("she said \"yes\"")` is one span rather than three: the span and
724
+ * the scanner have to agree on where a literal ends or a marker the scanner
725
+ * would parse is one the recognizer never sees.
726
+ *
563
727
  * 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.
728
+ * quoted one may start with, and inside a quoted run the escape branch is the
729
+ * only one that starts with a backslash), so the scan stays linear on
730
+ * adversarial input: a `{{` with no closing `}}` costs one failed pass, not a
731
+ * polynomial retry.
567
732
  */
568
- const MARKER_OUTPUT_BODY = String.raw`(?:[^{}"“”„«»]|["“„«][^"”»]*["”»])*`;
733
+ const MARKER_OUTPUT_BODY = String.raw`(?:[^{}"“”„«»]|["“„«](?:\\[\s\S]|[^"”»\\])*["”»])*`;
569
734
  /** What a tag holds between `{%` and `%}`: anything but a brace, and a `%`
570
735
  * only where it does not close the tag. */
571
736
  const MARKER_STATEMENT_BODY = String.raw`(?:[^{}%]|%(?!\}))*`;
@@ -1093,195 +1258,212 @@ const classifyMarkerDefect = (innerRaw, form = "output") => {
1093
1258
  /** Exhaustiveness guard — pass the discriminant in a `switch` default branch. */
1094
1259
  const assertNever = (value) => panic(`Unhandled template directive: ${JSON.stringify(value)}`);
1095
1260
  //#endregion
1096
- //#region src/field-values.ts
1261
+ //#region src/loop-scope.ts
1262
+ const rowScopePaths = (rowScopes) => rowScopes.map(({ scopedPath }) => scopedPath);
1263
+ /** A path already under the innermost repeat is left alone; anything else is
1264
+ * read as a name relative to it. */
1265
+ const qualifyRowScopedPath = (path, rowPaths = []) => {
1266
+ if (rowPaths.some((rowPath) => path === rowPath || path.startsWith(`${rowPath}.`))) return path;
1267
+ const innermostRowPath = rowPaths.at(-1);
1268
+ return innermostRowPath === void 0 ? path : `${innermostRowPath}.${path}`;
1269
+ };
1270
+ /** The manifest path for a marker written inside these loops. */
1271
+ const qualifyRowScopedPlaceholder = (path, rowScopes) => {
1272
+ if (rowScopes.some(({ scopedPath }) => path === scopedPath || path.startsWith(`${scopedPath}.`))) return path;
1273
+ for (const { alias, declaredPath, scopedPath } of rowScopes.toReversed()) for (const head of [alias, declaredPath]) {
1274
+ if (path === head) return scopedPath;
1275
+ if (path.startsWith(`${head}.`)) return `${scopedPath}.${path.slice(head.length + 1)}`;
1276
+ }
1277
+ return path;
1278
+ };
1279
+ /** The manifest path for an array a `{% for %}` opens inside these loops. */
1280
+ const qualifyLoopPath = (declaredPath, rowScopes) => qualifyRowScopedPath(qualifyRowScopedPlaceholder(declaredPath, rowScopes), rowScopePaths(rowScopes));
1281
+ //#endregion
1282
+ //#region src/field-config.ts
1097
1283
  /**
1098
- * Single source of truth for the DETERMINISTIC field-value transforms a
1099
- * template fill applies — composite (parts joined by a `{{key}}` format),
1100
- * formula (arithmetic over the other values), and locale-aware date rendering.
1101
- *
1102
- * Both the API fill engine (apps/api/src/handlers/docx) and the web live
1103
- * preview (template-studio.tsx) MUST route their rendering through
1104
- * {@link renderDeterministicFieldValue} so the preview and the generated
1105
- * document produce byte-identical strings. The api/field-value-parity.test.ts
1106
- * asserts this; reintroducing a bespoke transform on either side fails CI.
1107
- *
1108
- * Out of scope (non-deterministic / server-only, handled by callers):
1109
- * - registry lookup (needs a network hit; the preview overlays it async)
1110
- * - AI drafting / adaptation
1111
- *
1112
- * Pure: no IO, no model/provider dependency. The config types here are minimal
1113
- * STRUCTURAL shapes so the package stays free of api/web imports; callers pass
1114
- * their own field objects, which structurally satisfy these.
1284
+ * The filters that describe a REPEAT rather than a value: how many rows it
1285
+ * takes and what to call it. These are the ones a `{% for %}` tag may carry,
1286
+ * since the loop path names the array, not one of its values.
1115
1287
  */
1116
- const DATE_FORMAT_STYLES = [
1117
- "long",
1118
- "medium",
1119
- "short",
1120
- "iso"
1288
+ const ARRAY_FILTER_NAMES = [
1289
+ "label",
1290
+ "hint",
1291
+ "required",
1292
+ "min_items",
1293
+ "max_items"
1121
1294
  ];
1122
- /** Stable exemplar for date-format configuration previews. */
1123
- const DATE_FORMAT_EXAMPLE_ISO = "2028-06-13";
1124
- const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u;
1125
- const STYLE_OPTIONS = {
1126
- long: { dateStyle: "long" },
1127
- medium: { dateStyle: "medium" },
1128
- short: { dateStyle: "short" }
1295
+ const isArrayFilterName = (name) => ARRAY_FILTER_NAMES.some((candidate) => candidate === name);
1296
+ const NO_FILTERS = [];
1297
+ const filterCall = (name, ...args) => ({
1298
+ name,
1299
+ args
1300
+ });
1301
+ const positionalArg = (value) => ({
1302
+ kind: "positional",
1303
+ value
1304
+ });
1305
+ const keywordArg = (name, value) => ({
1306
+ kind: "keyword",
1307
+ name,
1308
+ value
1309
+ });
1310
+ /** The `date()` spec that reads back as this pair: a locale, then the style it
1311
+ * renders with. An ISO date reads the same in every language, so `iso` is
1312
+ * written as the bare style the reader canonicalizes. */
1313
+ const dateSpec = ({ locale, style }) => style === "iso" ? style : `${locale}-${style}`;
1314
+ /** The numeric bounds, in the order the reader lists them, so a chain a
1315
+ * document already carries and one this writes are the same text. */
1316
+ const VALIDATION_BOUNDS = [
1317
+ ["min", "min"],
1318
+ ["max", "max"],
1319
+ ["minLength", "min_length"],
1320
+ ["maxLength", "max_length"],
1321
+ ["minItems", "min_items"],
1322
+ ["maxItems", "max_items"]
1323
+ ];
1324
+ const writeInputType = (field) => {
1325
+ if (field.dateFormat !== void 0) return [filterCall("date", positionalArg(dateSpec(field.dateFormat)))];
1326
+ const { inputType } = field;
1327
+ if (inputType === void 0) return NO_FILTERS;
1328
+ switch (inputType) {
1329
+ case "boolean": return [filterCall("checkbox")];
1330
+ case "date": return [filterCall("date", positionalArg("iso"))];
1331
+ case "select": return [filterCall("select", ...(field.options ?? []).map((option) => positionalArg(option)))];
1332
+ case "number":
1333
+ case "text": return [filterCall(inputType)];
1334
+ default: return assertNever(inputType);
1335
+ }
1129
1336
  };
1130
- const dateFormatters = /* @__PURE__ */ new Map();
1131
- /** `Intl.DateTimeFormat` for a locale + style pair, cached per pair so it
1132
- * isn't rebuilt on every {@link formatDate} call. */
1133
- const getDateFormatter = (locale, style) => {
1134
- const key = `${locale}:${style}`;
1135
- const cached = dateFormatters.get(key);
1136
- if (cached) return cached;
1137
- const formatter = new Intl.DateTimeFormat(locale, {
1138
- ...STYLE_OPTIONS[style],
1139
- timeZone: "UTC"
1140
- });
1141
- dateFormatters.set(key, formatter);
1142
- return formatter;
1337
+ const writeSource = (field) => {
1338
+ const { source } = field;
1339
+ if (source === void 0) return NO_FILTERS;
1340
+ switch (source.kind) {
1341
+ case "party": return [filterCall("party", positionalArg(source.role), positionalArg(source.field))];
1342
+ case "attorney": return [filterCall("attorney", positionalArg(source.ref), positionalArg(source.field))];
1343
+ case "contact":
1344
+ case "firm":
1345
+ case "matter": return [filterCall(source.kind, positionalArg(source.field))];
1346
+ default: return assertNever(source);
1347
+ }
1143
1348
  };
1144
- /**
1145
- * Parse a strict YYYY-MM-DD calendar date; null when malformed or not a real
1146
- * date. UTC-anchored so the rendered day never shifts with the timezone.
1147
- * `Date` rolls out-of-range components over (2028-02-30 → March 1), so the
1148
- * round-trip comparison catches non-existent dates.
1149
- */
1150
- const parseIsoDate = (value) => {
1151
- if (!ISO_DATE_PATTERN.test(value)) return null;
1152
- const date = /* @__PURE__ */ new Date(`${value}T00:00:00Z`);
1153
- if (Number.isNaN(date.getTime()) || !date.toISOString().startsWith(value)) return null;
1154
- return date;
1349
+ const writeAi = (field) => {
1350
+ const { aiAdapt, aiPrompt, aiSeesDocument } = field;
1351
+ if (aiPrompt === void 0 && aiAdapt !== true) return NO_FILTERS;
1352
+ return [filterCall("ai", ...aiPrompt === void 0 ? [] : [positionalArg(aiPrompt)], ...aiAdapt === void 0 ? [] : [keywordArg("adapt", aiAdapt)], ...aiSeesDocument === void 0 ? [] : [keywordArg("sees_document", aiSeesDocument)])];
1353
+ };
1354
+ const writeValidation = (field) => {
1355
+ const validation = field.validation;
1356
+ if (validation === void 0) return NO_FILTERS;
1357
+ const calls = [];
1358
+ if (validation.pattern !== void 0) calls.push(filterCall("pattern", positionalArg(validation.pattern)));
1359
+ for (const [key, name] of VALIDATION_BOUNDS) {
1360
+ const bound = validation[key];
1361
+ if (bound !== void 0) calls.push(filterCall(name, positionalArg(bound)));
1362
+ }
1363
+ return calls;
1155
1364
  };
1156
1365
  /**
1157
- * Format an ISO date (YYYY-MM-DD) per the field's locale + style via
1158
- * `Intl.DateTimeFormat` — ICU produces the correct localized (and, in date
1159
- * contexts, correctly inflected) month names, e.g. cs long → "13. června
1160
- * 2028". The "iso" style returns the value unchanged; an invalid calendar date
1161
- * returns null.
1366
+ * Every key of {@link MarkerFieldConfig} and the filters that write it. Total
1367
+ * over the shape, so a property cannot join it without a decision about how an
1368
+ * author writes it in the document.
1369
+ *
1370
+ * A filter several properties feed (`ai`, `select`, `date`) is written by one
1371
+ * of them and skipped by the rest; the round-trip property in the api's
1372
+ * `field-filters.property.test.ts` is what proves nothing is dropped.
1162
1373
  */
1163
- const formatDate = (value, dateFormat) => {
1164
- const date = parseIsoDate(value);
1165
- if (date === null) return null;
1166
- if (dateFormat.style === "iso") return value;
1167
- return getDateFormatter(dateFormat.locale, dateFormat.style).format(date);
1374
+ const MARKER_FIELD_FILTERS = {
1375
+ path: () => NO_FILTERS,
1376
+ inputType: writeInputType,
1377
+ options: () => NO_FILTERS,
1378
+ dateFormat: () => NO_FILTERS,
1379
+ optionsFrom: ({ optionsFrom }) => optionsFrom === void 0 ? NO_FILTERS : [filterCall("options_from", positionalArg(optionsFrom))],
1380
+ label: ({ label }) => label === void 0 ? NO_FILTERS : [filterCall("label", positionalArg(label))],
1381
+ hint: ({ hint }) => hint === void 0 ? NO_FILTERS : [filterCall("hint", positionalArg(hint))],
1382
+ required: (field) => field.required === true || field.validation?.required === true ? [filterCall("required")] : NO_FILTERS,
1383
+ validation: writeValidation,
1384
+ aiPrompt: writeAi,
1385
+ aiAdapt: () => NO_FILTERS,
1386
+ aiSeesDocument: () => NO_FILTERS,
1387
+ lookup: ({ lookup }) => lookup === void 0 ? NO_FILTERS : [filterCall("lookup", positionalArg(lookup.registry), ...lookup.formats.map(({ key, template }) => keywordArg(key, template)))],
1388
+ source: writeSource,
1389
+ formula: ({ formula }) => formula === void 0 ? NO_FILTERS : [filterCall("formula", positionalArg(formula))],
1390
+ condition: ({ condition }) => condition === void 0 ? NO_FILTERS : [filterCall("condition", positionalArg(condition))]
1168
1391
  };
1169
1392
  /**
1170
- * Render a `{{key}}` format over part values. Markers whose key has no part
1171
- * value are left as-is (a visible authoring artifact, not a render error), so
1172
- * a partially filled composite previews exactly as the fill engine renders it.
1393
+ * The filter chain that declares this field in a document, in the catalogue's
1394
+ * own order so a chain the writer produces and one an author wrote read the
1395
+ * same.
1173
1396
  */
1174
- const renderComposite = (parts, format, partValues) => replaceOutputMarkers(format, (raw, inner) => {
1175
- const key = inner.trim();
1176
- if (!parts.some((part) => part.key === key)) return raw;
1177
- return partValues[key] ?? raw;
1178
- });
1179
- /** A plain object of part values: a non-null, non-array object. The api
1180
- * composite step accepts exactly this shape (an array or primitive is not a
1181
- * part-values object), so the dispatcher mirrors it. */
1182
- const isPartValuesObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1183
- /** Build the `{{key}} → value` map for a composite field from its raw object
1184
- * value, keeping only string part values for declared part keys. */
1185
- const compositePartValues = (parts, raw) => {
1186
- const partValues = {};
1187
- for (const part of parts) {
1188
- const value = raw[part.key];
1189
- if (typeof value === "string") partValues[part.key] = value;
1190
- }
1191
- return partValues;
1192
- };
1397
+ const filtersFromFieldConfig = (field) => Object.values(MARKER_FIELD_FILTERS).flatMap((write) => write(field));
1193
1398
  /**
1194
- * THE deterministic field-value dispatcher: returns the string the fill engine
1195
- * writes for `field` given the submitted `values`, or null when the field has
1196
- * no deterministic transform (a scalar, lookup, or AI field the CALLER renders
1197
- * itself).
1198
- *
1199
- * Dispatch order mirrors the API fill pipeline (composite → formula → date):
1200
- * - composite (parts + format present) → {@link renderComposite}
1201
- * - else formula present → {@link evaluateNumericExpression}, stringified
1202
- * - else date (inputType "date" + dateFormat) → {@link formatDate}
1203
- * - else null
1204
- *
1205
- * Composite returns null when its value is not an object of part values (a
1206
- * plain string passes through unchanged on the api side, so the caller's
1207
- * scalar path handles it). Formula and date return null when the expression /
1208
- * value does not yield a value, so the caller leaves the field as-is.
1399
+ * The chain a `{% for %}` opener carries for this array: the repeat's own
1400
+ * filters and nothing else, derived from {@link ARRAY_FILTER_NAMES}, so a
1401
+ * value filter can never be written where the reader refuses it.
1209
1402
  */
1210
- const renderDeterministicFieldValue = (field, values) => {
1211
- if (field.parts !== void 0 && field.format !== void 0) {
1212
- const raw = resolvePath(field.path, values);
1213
- if (!isPartValuesObject(raw)) return null;
1214
- return renderComposite(field.parts, field.format, compositePartValues(field.parts, raw));
1215
- }
1216
- if (field.formula !== void 0) {
1217
- const result = evaluateNumericExpression(field.formula, values);
1218
- return result === void 0 ? null : String(result);
1219
- }
1220
- if (field.inputType === "date" && field.dateFormat !== void 0) {
1221
- const raw = resolvePath(field.path, values);
1222
- if (typeof raw !== "string" || raw.trim() === "") return null;
1223
- return formatDate(raw, field.dateFormat);
1224
- }
1225
- return null;
1226
- };
1403
+ const arrayFiltersFromFieldConfig = (field) => filtersFromFieldConfig(field).filter(({ name }) => isArrayFilterName(name));
1227
1404
  //#endregion
1228
- //#region src/condition-builder.ts
1229
- const COMPARE_OP_TO_SYMBOL = {
1230
- eq: "==",
1231
- neq: "!=",
1232
- gt: ">",
1233
- lt: "<",
1234
- gte: ">=",
1235
- lte: "<="
1236
- };
1237
- /** Render a literal: strings are quoted/escaped, booleans and numbers are
1238
- * emitted bare (matching the parser's literal handling); an array literal
1239
- * joins on `,` (rare; the template builder edits scalar rules). */
1240
- const serializeLiteral = (value) => {
1241
- if (typeof value === "string") return `"${value.replace(/\\/gu, "\\\\").replace(/"/gu, "\\\"")}"`;
1242
- if (Array.isArray(value)) return `"${value.join(",")}"`;
1243
- return String(value);
1244
- };
1245
- const serializeOperand = (operand) => {
1246
- if (operand.type === "literal") return serializeLiteral(operand.value);
1247
- if (operand.type === "path") return operand.path;
1248
- if (operand.type === "formula") panic("serializeCondition: formula operand requires AST storage");
1249
- return "";
1250
- };
1251
- const serializeCompare = (node) => `${serializeOperand(node.left)} ${COMPARE_OP_TO_SYMBOL[node.op]} ${serializeOperand(node.right)}`;
1252
- const serializePredicate = (node) => {
1253
- const operand = serializeOperand(node.operand);
1254
- if (node.op === "contains") {
1255
- const value = Array.isArray(node.value) ? node.value.join(",") : node.value ?? "";
1256
- return `${serializeLiteral(value)} in ${operand}`;
1257
- }
1258
- if (node.op === "is_not_empty") return `${operand} is defined`;
1259
- if (node.op === "is_empty") return `${operand} is not defined`;
1260
- return operand;
1405
+ //#region src/render.ts
1406
+ /** The number literals the scanner reads: no exponent, no `Infinity`. */
1407
+ const WRITABLE_NUMBER = /^-?\d+(?:\.\d+)?$/u;
1408
+ /** What a tag's body cannot carry: it has no quoted-run escape hatch, so a
1409
+ * brace or a tag closer inside one would end the tag early. */
1410
+ const RESERVED_IN_TAG = /[{}]|%\}/u;
1411
+ /** The reason this literal cannot be written in a marker of this form, or
1412
+ * `null` when it can. A boolean always can; a string can in a `{{ }}` marker,
1413
+ * because the writer quotes and escapes it. */
1414
+ const unwritableMarkerLiteral = (value, form = "output") => {
1415
+ if (typeof value === "number") return WRITABLE_NUMBER.test(String(value)) ? null : "number-spelling";
1416
+ if (typeof value === "string" && form === "statement") return RESERVED_IN_TAG.test(value) ? "tag-delimiter" : null;
1417
+ return null;
1261
1418
  };
1262
- const serializeGroup = (group, top) => {
1263
- const parts = [];
1264
- for (const child of group.children) {
1265
- const part = serializeNode(child, false);
1266
- if (part.length > 0) parts.push(part);
1267
- }
1268
- if (parts.length === 0) return "";
1269
- const body = parts.length === 1 ? parts[0] ?? "" : parts.join(group.combinator === "and" ? " and " : " or ");
1270
- if (group.negated) return `not (${body})`;
1271
- if (parts.length === 1) return body;
1272
- return top ? body : `(${body})`;
1419
+ /** One string literal, double-quoted, with backslashes and quotes escaped the
1420
+ * way `scanQuoted` reads them back. */
1421
+ const renderString = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`;
1422
+ const renderLiteral = (value) => typeof value === "string" ? renderString(value) : String(value);
1423
+ const renderArgument = (arg) => arg.kind === "positional" ? renderLiteral(arg.value) : `${arg.name}=${renderLiteral(arg.value)}`;
1424
+ /** Every value in a chain the grammar has no spelling for, with the filter it
1425
+ * was written on and why, so a caller can refuse a configuration before it
1426
+ * rewrites a document into something the scanner would read differently. A
1427
+ * repeat's chain goes in a tag, so pass `"statement"` for one. */
1428
+ const unwritableFilterValues = (filters, form = "output") => filters.flatMap(({ args, name }) => args.flatMap((arg) => {
1429
+ const reason = unwritableMarkerLiteral(arg.value, form);
1430
+ return reason === null ? [] : [{
1431
+ filter: name,
1432
+ value: arg.value,
1433
+ reason
1434
+ }];
1435
+ }));
1436
+ /**
1437
+ * One filter chain as marker text, without the leading pipe: `label("A") |
1438
+ * required`. An empty chain renders the empty string, which is what a marker
1439
+ * that configures nothing carries.
1440
+ */
1441
+ const renderFilterChain = (filters) => filters.map(({ args, name }) => args.length === 0 ? name : `${name}(${args.map(renderArgument).join(", ")})`).join(" | ");
1442
+ /** One value marker: `{{ path }}` or `{{ path | filters }}`. */
1443
+ const renderValueMarker = (path, filters) => {
1444
+ const chain = renderFilterChain(filters);
1445
+ return chain === "" ? `{{ ${path} }}` : `{{ ${path} | ${chain} }}`;
1273
1446
  };
1274
- const serializeNode = (node, top) => {
1275
- if (node.type === "compare") return serializeCompare(node);
1276
- if (node.type === "predicate") return serializePredicate(node);
1277
- return serializeGroup(node, top);
1447
+ /** The docxtpl placement token a prefix is written with; `none` writes none. */
1448
+ const PREFIX_TOKEN = {
1449
+ none: "",
1450
+ paragraph: "p",
1451
+ row: "tr"
1278
1452
  };
1279
1453
  /**
1280
- * Serialize a condition tree into an expression for `evaluateCondition`.
1281
- * Returns an empty string for an empty tree (caller treats that as "no
1282
- * condition" / always-visible).
1454
+ * One `{% if %}` / `{% elif %}` opener carrying its expression.
1455
+ *
1456
+ * A rule that decides whether a block shows belongs in the tag that shows it:
1457
+ * the tag is the only place a document has for it, so a builder's rule is
1458
+ * serialized here rather than stored beside the document under a name.
1283
1459
  */
1284
- const serializeCondition = (node) => serializeNode(node, true);
1460
+ const renderConditionTag = ({ expression, kind, prefix }) => `{%${PREFIX_TOKEN[prefix]} ${kind} ${expression} %}`;
1461
+ /** One `{% for %}` opener, keeping its placement prefix. */
1462
+ const renderForOpener = ({ alias, filters, path, prefix }) => {
1463
+ const chain = renderFilterChain(filters);
1464
+ const head = `{%${PREFIX_TOKEN[prefix]} for ${alias} in ${path}`;
1465
+ return chain === "" ? `${head} %}` : `${head} | ${chain} %}`;
1466
+ };
1285
1467
  //#endregion
1286
1468
  //#region src/row-blocks.ts
1287
1469
  /**
@@ -1467,4 +1649,4 @@ const evaluateCondition = (expression, data, namedConditions, _resolved = /* @__
1467
1649
  return evaluateCondition$1(node, makeResolver(data, namedConditions, _resolved, _depth));
1468
1650
  };
1469
1651
  //#endregion
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 };
1652
+ export { ARRAY_FILTER_NAMES, 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, arrayFiltersFromFieldConfig, assertNever, blockDirectiveLinePattern, classifyMarker, classifyMarkerDefect, clauseSlotKey, clauseSlotPattern, conditionHasFormula, conditionNodeSchema, conditionSchema, detectRowBlockPair, emptyCondition, evaluateCondition, evaluateNumericExpression, filtersFromFieldConfig, formatDate, hasBlockDirectivePattern, hasNumberingPattern, isArrayFilterName, isBlockDirectiveKind, isClauseSlotName, isFieldPath, isSafeFieldPath, legacyLoopAlias, legacyMarkerReplacement, loopPattern, markerPattern, normalizeMarkerInner, numPattern, parseCondition, placeholderPattern, qualifyLoopPath, qualifyRowScopedPath, qualifyRowScopedPlaceholder, refPattern, referencedConditionPaths, renderConditionTag, renderDeterministicFieldValue, renderFilterChain, renderForOpener, renderValueMarker, replaceOutputMarkers, resolvePath, rowScopePaths, scanInvalidMarkers, scanMarkers, serializeCondition, substitutionKey, translateLegacyExpression, unwritableFilterValues, unwritableMarkerLiteral };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/template-conditions",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "A document-template marker and condition engine: {{...}} marker grammar, parse, compute, serialize, field values.",
5
5
  "keywords": [
6
6
  "condition",
@@ -50,7 +50,8 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@stll/conditions": "^0.3.1",
53
- "better-result": "3.0.1"
53
+ "better-result": "3.0.1",
54
+ "temporal-polyfill": "1.0.4"
54
55
  },
55
56
  "devDependencies": {
56
57
  "@stll/property-testing": "0.0.0",