@stll/template-conditions 0.0.1-placeholder.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @stll/template-conditions
2
+
3
+ A document-template marker and condition engine: the `{{...}}` marker grammar,
4
+ an expression parser, numeric/arithmetic compute, a condition-builder
5
+ serializer, and deterministic field-value rendering. Small, side-effect-free
6
+ functions usable on both backend (Bun) and frontend (browser).
7
+
8
+ Boolean conditions are evaluated through the canonical
9
+ [`@stll/conditions`](https://github.com/stella/stella/tree/main/packages/conditions)
10
+ AST and its single evaluator, so template `{{#if ...}}` conditionals share one
11
+ set of operators and semantics with the rest of the system. This package owns
12
+ the template surface: the marker grammar, the string parser, the named-condition
13
+ resolver, the numeric expression evaluator, and the no-code builder serializer.
14
+
15
+ ```ts
16
+ import { evaluateCondition, scanMarkers } from "@stll/template-conditions";
17
+
18
+ const branch = evaluateCondition("isCompany and signed", fillData);
19
+
20
+ for (const marker of scanMarkers(templateText)) {
21
+ // marker.kind, marker.path, ...
22
+ }
23
+ ```
24
+
25
+ ## Install
26
+
27
+ ```sh
28
+ bun add @stll/template-conditions
29
+ ```
30
+
31
+ ## Exports (`.`)
32
+
33
+ - Condition evaluation: `evaluateCondition`, `parseCondition`,
34
+ `evaluateNumericExpression`, `serializeCondition`, `resolvePath`.
35
+ - Field values: `renderDeterministicFieldValue`, `renderComposite`,
36
+ `formatDate`.
37
+ - Marker grammar: `scanMarkers`, `classifyMarker`, `markerPattern`,
38
+ `placeholderPattern`, and the directive-kind constants.
39
+
40
+ ## License
41
+
42
+ Apache-2.0
@@ -0,0 +1,297 @@
1
+ import { CompareNode, CompareOp, Condition, ConditionNode, ConditionNode as ConditionNode$1, GroupNode, Operand, PredicateNode, PredicateOp, conditionHasFormula, conditionNodeSchema, conditionSchema, emptyCondition } from "@stll/conditions";
2
+ //#region src/path.d.ts
3
+ /** Resolve a dotted path like `company.name` against data. */
4
+ declare const resolvePath: (path: string, data: Record<string, unknown>) => unknown;
5
+ //#endregion
6
+ //#region src/parse.d.ts
7
+ declare const parseCondition: (expression: string) => ConditionNode$1 | null;
8
+ //#endregion
9
+ //#region src/compute.d.ts
10
+ /**
11
+ * Arithmetic evaluator for computed template fields.
12
+ *
13
+ * The condition engine ([[evaluateCondition]]) is boolean-only by design.
14
+ * Computed fields need a value, so this is a separate, pure recursive-descent
15
+ * evaluator: `+ - * / %`, unary `-`, parentheses, numeric literals (with `_`
16
+ * separators and decimals), dotted-path variables resolved from the fill data,
17
+ * and the functions `min`, `max`, `round` (optional 2nd arg = decimal places),
18
+ * `abs`, `floor`, `ceil`.
19
+ *
20
+ * Example — rent indexed by CPI but capped at +5%/yr:
21
+ * `min(rent * (1 + index / 100), rent * 1.05)`
22
+ *
23
+ * Returns `undefined` on any parse or resolution failure (bad syntax, a
24
+ * non-numeric variable, division producing a non-finite result) so callers
25
+ * can fall back to leaving the field unfilled rather than emitting `NaN`.
26
+ */
27
+ declare const NUMERIC_FUNCTION_NAMES: readonly ["min", "max", "round", "abs", "floor", "ceil"];
28
+ type NumericFunctionName = (typeof NUMERIC_FUNCTION_NAMES)[number];
29
+ declare const evaluateNumericExpression: (expression: string, data: Record<string, unknown>) => number | undefined;
30
+ //#endregion
31
+ //#region src/field-values.d.ts
32
+ /**
33
+ * Single source of truth for the DETERMINISTIC field-value transforms a
34
+ * template fill applies — composite (parts joined by a `{{key}}` format),
35
+ * formula (arithmetic over the other values), and locale-aware date rendering.
36
+ *
37
+ * Both the API fill engine (apps/api/src/handlers/docx) and the web live
38
+ * preview (template-studio.tsx) MUST route their rendering through
39
+ * {@link renderDeterministicFieldValue} so the preview and the generated
40
+ * document produce byte-identical strings. The api/field-value-parity.test.ts
41
+ * asserts this; reintroducing a bespoke transform on either side fails CI.
42
+ *
43
+ * Out of scope (non-deterministic / server-only, handled by callers):
44
+ * - registry lookup (needs a network hit; the preview overlays it async)
45
+ * - AI drafting / adaptation
46
+ *
47
+ * Pure: no IO, no model/provider dependency. The config types here are minimal
48
+ * STRUCTURAL shapes so the package stays free of api/web imports; callers pass
49
+ * their own field objects, which structurally satisfy these.
50
+ */
51
+ declare const DATE_FORMAT_STYLES: readonly ["long", "medium", "short", "iso"];
52
+ type DateFormatStyle = (typeof DATE_FORMAT_STYLES)[number];
53
+ /** Stable exemplar for date-format configuration previews. */
54
+ declare const DATE_FORMAT_EXAMPLE_ISO = "2028-06-13";
55
+ /** Locale-aware date rendering config. "iso" leaves the submitted value as-is;
56
+ * the other styles map to `Intl.DateTimeFormat` `dateStyle`. */
57
+ type FieldDateFormat = {
58
+ /** BCP-47 language tag of the document, e.g. "cs", "de", "pl". */
59
+ locale: string;
60
+ style: DateFormatStyle;
61
+ };
62
+ /**
63
+ * Format an ISO date (YYYY-MM-DD) per the field's locale + style via
64
+ * `Intl.DateTimeFormat` — ICU produces the correct localized (and, in date
65
+ * contexts, correctly inflected) month names, e.g. cs long → "13. června
66
+ * 2028". The "iso" style returns the value unchanged; an invalid calendar date
67
+ * returns null.
68
+ */
69
+ declare const formatDate: (value: string, dateFormat: FieldDateFormat) => string | null;
70
+ /** Minimal structural shape of one composite field part: only the `key`
71
+ * referenced by the join format is needed to render. Callers' richer part
72
+ * types (api FieldPart, web EditablePart) structurally satisfy this. */
73
+ type PartConfig = {
74
+ key: string;
75
+ };
76
+ /**
77
+ * Render a `{{key}}` format over part values. Markers whose key has no part
78
+ * value are left as-is (a visible authoring artifact, not a render error), so
79
+ * a partially filled composite previews exactly as the fill engine renders it.
80
+ */
81
+ declare const renderComposite: (parts: readonly PartConfig[], format: string, partValues: Readonly<Record<string, string>>) => string;
82
+ /**
83
+ * Minimal STRUCTURAL config of a field, carrying only what the deterministic
84
+ * transforms read. Callers (api FieldMeta, web StudioField) structurally
85
+ * satisfy this; lookup/AI/scalar concerns are intentionally absent because the
86
+ * dispatcher does not handle them.
87
+ */
88
+ type DeterministicFieldConfig = {
89
+ path: string;
90
+ inputType?: string | undefined;
91
+ parts?: readonly PartConfig[] | undefined;
92
+ format?: string | undefined;
93
+ formula?: string | undefined;
94
+ dateFormat?: FieldDateFormat | undefined;
95
+ };
96
+ /**
97
+ * THE deterministic field-value dispatcher: returns the string the fill engine
98
+ * writes for `field` given the submitted `values`, or null when the field has
99
+ * no deterministic transform (a scalar, lookup, or AI field the CALLER renders
100
+ * itself).
101
+ *
102
+ * Dispatch order mirrors the API fill pipeline (composite → formula → date):
103
+ * - composite (parts + format present) → {@link renderComposite}
104
+ * - else formula present → {@link evaluateNumericExpression}, stringified
105
+ * - else date (inputType "date" + dateFormat) → {@link formatDate}
106
+ * - else null
107
+ *
108
+ * Composite returns null when its value is not an object of part values (a
109
+ * plain string passes through unchanged on the api side, so the caller's
110
+ * scalar path handles it). Formula and date return null when the expression /
111
+ * value does not yield a value, so the caller leaves the field as-is.
112
+ */
113
+ declare const renderDeterministicFieldValue: (field: DeterministicFieldConfig, values: Record<string, unknown>) => string | null;
114
+ //#endregion
115
+ //#region src/condition-builder.d.ts
116
+ /**
117
+ * Serialize a condition tree into an expression for `evaluateCondition`.
118
+ * Returns an empty string for an empty tree (caller treats that as "no
119
+ * condition" / always-visible).
120
+ */
121
+ declare const serializeCondition: (node: ConditionNode$1) => string;
122
+ //#endregion
123
+ //#region src/markers.d.ts
124
+ /**
125
+ * Canonical grammar for `{{...}}` template markers — the single source of truth
126
+ * shared by the fill pipeline (api), the editor highlighter (folio), and the
127
+ * read-only preview (web).
128
+ *
129
+ * Add a new directive to `MarkerMeta` and teach {@link classifyMarker} to
130
+ * recognize it. Compile-time totality checks then require its ordered runtime
131
+ * value and its block/inline disposition, while exhaustive consumer switches
132
+ * fail until they handle it.
133
+ *
134
+ * No consumer should define its own `{{...}}` regex. The low-level pattern
135
+ * factories below exist so handlers that mutate raw XML/text reuse the exact
136
+ * same literals; recognizers should prefer the high-level scanner.
137
+ */
138
+ type MarkerMeta = {
139
+ kind: "placeholder";
140
+ expr: string;
141
+ } | {
142
+ kind: "clause";
143
+ name: string;
144
+ version: string | undefined;
145
+ } | {
146
+ kind: "num";
147
+ key: string;
148
+ } | {
149
+ kind: "ref";
150
+ key: string;
151
+ } | {
152
+ kind: "index";
153
+ } | {
154
+ kind: "count";
155
+ } | {
156
+ kind: "if";
157
+ expr: string;
158
+ } | {
159
+ kind: "elseif";
160
+ expr: string;
161
+ } | {
162
+ kind: "else";
163
+ } | {
164
+ kind: "endif";
165
+ } | {
166
+ kind: "each";
167
+ expr: string;
168
+ } | {
169
+ kind: "endeach";
170
+ };
171
+ type DirectiveKind = MarkerMeta["kind"];
172
+ declare const DIRECTIVE_KINDS: readonly ["placeholder", "clause", "num", "ref", "index", "count", "if", "elseif", "else", "endif", "each", "endeach"];
173
+ declare const DIRECTIVE_PLACEMENT: {
174
+ readonly placeholder: "inline";
175
+ readonly clause: "inline";
176
+ readonly num: "inline";
177
+ readonly ref: "inline";
178
+ readonly index: "inline";
179
+ readonly count: "inline";
180
+ readonly if: "block";
181
+ readonly elseif: "block";
182
+ readonly else: "block";
183
+ readonly endif: "block";
184
+ readonly each: "block";
185
+ readonly endeach: "block";
186
+ };
187
+ type BlockDirectiveKind = { [TKind in DirectiveKind]: (typeof DIRECTIVE_PLACEMENT)[TKind] extends "block" ? TKind : never; }[DirectiveKind];
188
+ /** Directives that occupy their own paragraph, in canonical grammar order. */
189
+ declare const BLOCK_DIRECTIVE_KINDS: readonly ["if", "elseif", "else", "endif", "each", "endeach"];
190
+ declare const isBlockDirectiveKind: (kind: unknown) => kind is BlockDirectiveKind;
191
+ /** Any `{{...}}` marker; group 1 is the inner text (untrimmed — callers trim). */
192
+ declare const markerPattern: () => RegExp;
193
+ /** Inline field / clause / numbering marker (inner allows the directive sigils). */
194
+ declare const placeholderPattern: () => RegExp;
195
+ /** `{{@clause:Name}}` / `{{@clause:Name:v3}}` — `name` and `modifier` groups. */
196
+ declare const clauseSlotPattern: () => RegExp;
197
+ /** `{{@num:Key}}` — the `key` group. */
198
+ declare const numPattern: () => RegExp;
199
+ /** `{{@ref:Key}}` — the `key` group. */
200
+ declare const refPattern: () => RegExp;
201
+ /** `{{@index}}` — the 1-based position within the innermost enclosing loop. */
202
+ declare const indexPattern: () => RegExp;
203
+ /** `{{@count}}` — the item count of the innermost enclosing loop. */
204
+ declare const countPattern: () => RegExp;
205
+ /** Cheap presence test for any numbering marker (no capture). */
206
+ declare const hasNumberingPattern: () => RegExp;
207
+ /** Cheap presence test for any block directive (no capture). */
208
+ declare const hasBlockDirectivePattern: () => RegExp;
209
+ /** A whole line that is a single block directive — `tag` and `expr` groups. */
210
+ declare const blockDirectiveLinePattern: () => RegExp;
211
+ /** Whether `value` is a valid field path per the marker grammar (dotted
212
+ * segments of letters/digits/underscore/dash — no brackets or spaces). */
213
+ declare const isFieldPath: (value: string) => boolean;
214
+ /** Whether `value` is safe to use as a dotted object path. */
215
+ declare const isSafeFieldPath: (value: string) => boolean;
216
+ /** Whether `value` is a valid name for authoring a `{{@clause:NAME}}` slot:
217
+ * the intersection of the clause-slot parse grammar and the fill pipeline's
218
+ * placeholder charset, so every accepted name round-trips through fill. */
219
+ declare const isClauseSlotName: (value: string) => boolean;
220
+ /**
221
+ * Classify the inner text of a `{{...}}` marker. Returns `null` when the text is
222
+ * not a recognized directive (so a recognizer can leave stray braces alone).
223
+ */
224
+ declare const classifyMarker: (innerRaw: string) => MarkerMeta | null;
225
+ type ScannedMarker = {
226
+ /** Offset of `{{` in the source text. */
227
+ start: number;
228
+ /** Offset just past `}}`. */
229
+ end: number;
230
+ /** The full matched marker, e.g. `{{@num:scope}}`. */
231
+ raw: string;
232
+ /** The (untrimmed) inner text. */
233
+ inner: string;
234
+ meta: MarkerMeta;
235
+ };
236
+ /**
237
+ * Find every recognized `{{...}}` marker in `text`, in document order, with
238
+ * offsets and classified metadata. Unrecognized `{{...}}` spans are skipped.
239
+ */
240
+ declare const scanMarkers: (text: string) => ScannedMarker[];
241
+ /** A `{{...}}` span that looks like a marker but classifies to nothing. */
242
+ type InvalidMarker = {
243
+ /** Offset of `{{` in the source text. */
244
+ start: number;
245
+ /** Offset just past `}}`. */
246
+ end: number;
247
+ /** The full matched span, e.g. `{{my field}}`. */
248
+ raw: string;
249
+ /** The (trimmed) inner text. */
250
+ inner: string;
251
+ };
252
+ /**
253
+ * Find every `{{...}}` span whose inner text is NOT a recognized directive —
254
+ * near-misses an author clearly meant as markers but that every recognizer
255
+ * skips, so they print literally at fill time (e.g. `{{my field}}` with a
256
+ * space, `{{@clause:}}` with no name). Offsets and raw text are shaped like
257
+ * {@link scanMarkers} results, minus the classified `meta`. This is the exact
258
+ * complement of {@link scanMarkers}: a span is in one list or the other.
259
+ */
260
+ declare const scanInvalidMarkers: (text: string) => InvalidMarker[];
261
+ /** Exhaustiveness guard — pass the discriminant in a `switch` default branch. */
262
+ declare const assertNever: (value: never) => never;
263
+ //#endregion
264
+ //#region src/index.d.ts
265
+ type NamedCondition = {
266
+ name: string;
267
+ /** The `{{#if}}` expression string. Authoritative unless `node` is set. */
268
+ expression: string;
269
+ /**
270
+ * The canonical AST, set when the condition cannot round-trip through the
271
+ * expression string — i.e. it uses a `formula` operand the `{{#if}}` grammar
272
+ * has no syntax for. When present it is evaluated directly and `expression`
273
+ * is ignored.
274
+ */
275
+ node?: ConditionNode$1;
276
+ label?: string;
277
+ };
278
+ /**
279
+ * Maximum recursion depth for named condition resolution.
280
+ * Prevents stack overflow when a long chain of named
281
+ * conditions (A→B→C→...→Z) is evaluated without cycles.
282
+ */
283
+ declare const MAX_CONDITION_DEPTH = 50;
284
+ /**
285
+ * Evaluate a template `{{#if ...}}` condition. The expression is parsed into
286
+ * the canonical `@stll/conditions` AST and evaluated by the shared evaluator.
287
+ *
288
+ * Supports comparisons (`==`, `!=`, `>`, `<`, `>=`, `<=`), `contains`,
289
+ * truthiness, negation (`!`), logical operators (`and`, `or`), parentheses,
290
+ * dotted paths, numeric underscores, and named-condition references.
291
+ *
292
+ * An empty or unparseable expression is `false` ("no condition" never gates a
293
+ * branch true).
294
+ */
295
+ declare const evaluateCondition: (expression: string, data: Record<string, unknown>, namedConditions?: readonly NamedCondition[], _resolved?: ReadonlySet<string>, _depth?: number) => boolean;
296
+ //#endregion
297
+ 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, type FieldDateFormat, type GroupNode, type InvalidMarker, MAX_CONDITION_DEPTH, type MarkerMeta, NUMERIC_FUNCTION_NAMES, NamedCondition, type NumericFunctionName, type Operand, type PartConfig, type PredicateNode, type PredicateOp, type ScannedMarker, 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 };
package/dist/index.js ADDED
@@ -0,0 +1,806 @@
1
+ import { conditionHasFormula, conditionNodeSchema, conditionSchema, emptyCondition, evaluateCondition as evaluateCondition$1 } from "@stll/conditions";
2
+ import { panic } from "better-result";
3
+ //#region src/path.ts
4
+ /**
5
+ * Dotted-path resolution against a data record. Extracted from index.ts so both
6
+ * the condition engine (index.ts) and the arithmetic evaluator (compute.ts) can
7
+ * import it without a dependency cycle.
8
+ */
9
+ /** Narrow `unknown` to a string-keyed record. */
10
+ const isRecord = (v) => typeof v === "object" && v !== null;
11
+ /** Resolve a dotted path like `company.name` against data. */
12
+ const resolvePath = (path, data) => {
13
+ if (Object.hasOwn(data, path)) return data[path];
14
+ const parts = path.split(".");
15
+ let current = data;
16
+ for (const part of parts) {
17
+ if (!isRecord(current)) return;
18
+ current = current[part];
19
+ }
20
+ return current;
21
+ };
22
+ //#endregion
23
+ //#region src/compute.ts
24
+ /**
25
+ * Arithmetic evaluator for computed template fields.
26
+ *
27
+ * The condition engine ([[evaluateCondition]]) is boolean-only by design.
28
+ * Computed fields need a value, so this is a separate, pure recursive-descent
29
+ * evaluator: `+ - * / %`, unary `-`, parentheses, numeric literals (with `_`
30
+ * separators and decimals), dotted-path variables resolved from the fill data,
31
+ * and the functions `min`, `max`, `round` (optional 2nd arg = decimal places),
32
+ * `abs`, `floor`, `ceil`.
33
+ *
34
+ * Example — rent indexed by CPI but capped at +5%/yr:
35
+ * `min(rent * (1 + index / 100), rent * 1.05)`
36
+ *
37
+ * Returns `undefined` on any parse or resolution failure (bad syntax, a
38
+ * non-numeric variable, division producing a non-finite result) so callers
39
+ * can fall back to leaving the field unfilled rather than emitting `NaN`.
40
+ */
41
+ const NUMERIC_FUNCTION_NAMES = Object.freeze([
42
+ "min",
43
+ "max",
44
+ "round",
45
+ "abs",
46
+ "floor",
47
+ "ceil"
48
+ ]);
49
+ const isNumericFunctionName = (name) => NUMERIC_FUNCTION_NAMES.some((candidate) => candidate === name);
50
+ const TOKEN_RE = /(?<numeric>[0-9][0-9_]*(?:\.[0-9]+)?)|(?<ident>[\p{L}_][\p{L}\p{N}_.]*(?:-[\p{L}\p{N}_.]+)*)|(?<punct>[+\-*/%(),])|(?<invalid>\S)/gu;
51
+ const PRECEDENCE = {
52
+ "+": 1,
53
+ "-": 1,
54
+ "*": 2,
55
+ "/": 2,
56
+ "%": 2
57
+ };
58
+ const tokenize$1 = (expression) => {
59
+ const tokens = [];
60
+ for (const match of expression.matchAll(TOKEN_RE)) {
61
+ const { numeric, ident, punct, invalid } = match.groups ?? {};
62
+ if (invalid !== void 0) throw new Error(`Unexpected token: ${invalid}`);
63
+ if (numeric !== void 0) tokens.push({
64
+ t: "num",
65
+ v: Number(numeric.replace(/_/gu, ""))
66
+ });
67
+ else if (ident !== void 0) tokens.push({
68
+ t: "id",
69
+ v: ident
70
+ });
71
+ else if (punct === "(") tokens.push({ t: "lp" });
72
+ else if (punct === ")") tokens.push({ t: "rp" });
73
+ else if (punct === ",") tokens.push({ t: "comma" });
74
+ else if (punct !== void 0) tokens.push({
75
+ t: "op",
76
+ v: punct
77
+ });
78
+ }
79
+ return tokens;
80
+ };
81
+ const applyOp = (op, a, b) => {
82
+ switch (op) {
83
+ case "+": return a + b;
84
+ case "-": return a - b;
85
+ case "*": return a * b;
86
+ case "/": return a / b;
87
+ default: return a % b;
88
+ }
89
+ };
90
+ const callFunction = (name, args) => {
91
+ if (!isNumericFunctionName(name)) throw new Error(`Unknown function: ${name}`);
92
+ switch (name) {
93
+ case "min": return Math.min(...args);
94
+ case "max": return Math.max(...args);
95
+ case "abs": return Math.abs(args[0] ?? NaN);
96
+ case "floor": return Math.floor(args[0] ?? NaN);
97
+ case "ceil": return Math.ceil(args[0] ?? NaN);
98
+ case "round": {
99
+ const value = args[0] ?? NaN;
100
+ const digits = args[1] ?? 0;
101
+ const factor = 10 ** Math.trunc(digits);
102
+ return Math.round(value * factor) / factor;
103
+ }
104
+ default: return name;
105
+ }
106
+ };
107
+ const evaluateNumericExpression = (expression, data) => {
108
+ try {
109
+ const tokens = tokenize$1(expression);
110
+ if (tokens.length === 0) return;
111
+ let pos = 0;
112
+ const resolveIdentifier = (path) => {
113
+ const raw = resolvePath(path, data);
114
+ const num = typeof raw === "number" ? raw : Number(raw);
115
+ if (!Number.isFinite(num)) throw new TypeError(`Non-numeric variable: ${path}`);
116
+ return num;
117
+ };
118
+ const parseAtom = () => {
119
+ const tok = tokens[pos];
120
+ if (!tok) throw new Error("Unexpected end of expression");
121
+ if (tok.t === "op" && (tok.v === "-" || tok.v === "+")) {
122
+ pos += 1;
123
+ const operand = parseAtom();
124
+ return tok.v === "-" ? -operand : operand;
125
+ }
126
+ if (tok.t === "num") {
127
+ pos += 1;
128
+ return tok.v;
129
+ }
130
+ if (tok.t === "lp") {
131
+ pos += 1;
132
+ const value = parseBinary(0);
133
+ if (tokens[pos]?.t !== "rp") throw new Error("Expected )");
134
+ pos += 1;
135
+ return value;
136
+ }
137
+ if (tok.t === "id") {
138
+ pos += 1;
139
+ if (tokens[pos]?.t === "lp") {
140
+ pos += 1;
141
+ const args = [];
142
+ if (tokens[pos]?.t !== "rp") {
143
+ args.push(parseBinary(0));
144
+ while (tokens[pos]?.t === "comma") {
145
+ pos += 1;
146
+ args.push(parseBinary(0));
147
+ }
148
+ }
149
+ if (tokens[pos]?.t !== "rp") throw new Error("Expected )");
150
+ pos += 1;
151
+ return callFunction(tok.v, args);
152
+ }
153
+ return resolveIdentifier(tok.v);
154
+ }
155
+ throw new Error("Unexpected token in expression");
156
+ };
157
+ const parseBinary = (minPrecedence) => {
158
+ let left = parseAtom();
159
+ let next = tokens[pos];
160
+ while (next?.t === "op" && (PRECEDENCE[next.v] ?? 0) >= minPrecedence) {
161
+ const op = next.v;
162
+ pos += 1;
163
+ const right = parseBinary((PRECEDENCE[op] ?? 0) + 1);
164
+ left = applyOp(op, left, right);
165
+ next = tokens[pos];
166
+ }
167
+ return left;
168
+ };
169
+ const result = parseBinary(0);
170
+ if (pos !== tokens.length) return;
171
+ return Number.isFinite(result) ? result : void 0;
172
+ } catch {
173
+ return;
174
+ }
175
+ };
176
+ //#endregion
177
+ //#region src/parse.ts
178
+ const NON_STRING_TOKEN_RE = /(?<token>==|!=|>=|<=|>|<|!(?!=)|and\b|or\b|contains\b|[()]|-?\d[\p{N}_.]*|[\p{L}\p{N}_.]+(?:-[\p{L}\p{N}_.]+)*)/uy;
179
+ const STARTS_WITH_DIGIT_RE = /^-?\d/u;
180
+ const COMPARE_SYMBOL_TO_OP = {
181
+ "==": "eq",
182
+ "!=": "neq",
183
+ ">": "gt",
184
+ "<": "lt",
185
+ ">=": "gte",
186
+ "<=": "lte"
187
+ };
188
+ /**
189
+ * Scan a `"..."` literal starting at `expr[start]` (the opening quote),
190
+ * honoring `\\`-escapes without interpreting them. A single linear pass:
191
+ * each character is visited at most once, so this is safe on adversarial
192
+ * input regardless of how many `"` or `\\` characters it contains.
193
+ *
194
+ * A closing quote is not required, consistent with the module's
195
+ * degrade-gracefully policy for an unmatched `(`, a literal missing its
196
+ * closing quote simply closes at end of input.
197
+ */
198
+ const scanString = (expr, start) => {
199
+ let i = start + 1;
200
+ while (i < expr.length) {
201
+ if (expr[i] === "\"") return {
202
+ content: expr.slice(start + 1, i),
203
+ end: i + 1
204
+ };
205
+ i += expr[i] === "\\" && i + 1 < expr.length ? 2 : 1;
206
+ }
207
+ return {
208
+ content: expr.slice(start + 1),
209
+ end: expr.length
210
+ };
211
+ };
212
+ const classifyNonString = (raw) => {
213
+ if (raw === "and") return { type: "and" };
214
+ if (raw === "or") return { type: "or" };
215
+ if (raw === "!") return { type: "not" };
216
+ if (raw === "contains" || Object.hasOwn(COMPARE_SYMBOL_TO_OP, raw)) return {
217
+ type: "op",
218
+ raw
219
+ };
220
+ if (raw === "(") return { type: "lparen" };
221
+ if (raw === ")") return { type: "rparen" };
222
+ return {
223
+ type: "value",
224
+ raw
225
+ };
226
+ };
227
+ const tokenize = (expr) => {
228
+ const tokens = [];
229
+ let pos = 0;
230
+ while (pos < expr.length) {
231
+ if (expr[pos] === "\"") {
232
+ const { content, end } = scanString(expr, pos);
233
+ tokens.push({
234
+ type: "string",
235
+ raw: content.replace(/\\"/gu, "\"")
236
+ });
237
+ pos = end;
238
+ continue;
239
+ }
240
+ NON_STRING_TOKEN_RE.lastIndex = pos;
241
+ const m = NON_STRING_TOKEN_RE.exec(expr);
242
+ if (!m) {
243
+ pos += 1;
244
+ continue;
245
+ }
246
+ tokens.push(classifyNonString(m.groups?.["token"] ?? m[0]));
247
+ pos = NON_STRING_TOKEN_RE.lastIndex;
248
+ }
249
+ return tokens;
250
+ };
251
+ /** Parse a numeric literal, supporting `_` separators. */
252
+ const parseNumeric = (raw) => {
253
+ if (!STARTS_WITH_DIGIT_RE.test(raw)) return;
254
+ const n = Number(raw.replace(/_/gu, ""));
255
+ return Number.isFinite(n) ? n : void 0;
256
+ };
257
+ /** A `value`/`string` token becomes a literal (string / number / boolean) or a
258
+ * dotted `path` operand. */
259
+ const operandFromToken = (token) => {
260
+ if (token.type === "string") return {
261
+ type: "literal",
262
+ value: token.raw
263
+ };
264
+ const num = parseNumeric(token.raw);
265
+ if (num !== void 0) return {
266
+ type: "literal",
267
+ value: num
268
+ };
269
+ if (token.raw === "true") return {
270
+ type: "literal",
271
+ value: true
272
+ };
273
+ if (token.raw === "false") return {
274
+ type: "literal",
275
+ value: false
276
+ };
277
+ return {
278
+ type: "path",
279
+ path: token.raw
280
+ };
281
+ };
282
+ /** `contains` carries a literal payload (`string`), not an operand: render the
283
+ * right-hand token as a string. */
284
+ const containsValue = (token) => {
285
+ const operand = operandFromToken(token);
286
+ return operand.type === "literal" ? String(operand.value) : token.raw;
287
+ };
288
+ const parseCondition = (expression) => {
289
+ const tokens = tokenize(expression);
290
+ if (tokens.length === 0) return null;
291
+ let pos = 0;
292
+ const peek = () => tokens[pos];
293
+ const parseOr = () => {
294
+ const first = parseAnd();
295
+ if (peek()?.type !== "or") return first;
296
+ const children = [first];
297
+ while (peek()?.type === "or") {
298
+ pos += 1;
299
+ children.push(parseAnd());
300
+ }
301
+ return {
302
+ type: "group",
303
+ combinator: "or",
304
+ children
305
+ };
306
+ };
307
+ const parseAnd = () => {
308
+ const first = parseNot();
309
+ if (peek()?.type !== "and") return first;
310
+ const children = [first];
311
+ while (peek()?.type === "and") {
312
+ pos += 1;
313
+ children.push(parseNot());
314
+ }
315
+ return {
316
+ type: "group",
317
+ combinator: "and",
318
+ children
319
+ };
320
+ };
321
+ const parseNot = () => {
322
+ if (peek()?.type === "not") {
323
+ pos += 1;
324
+ return {
325
+ type: "group",
326
+ combinator: "and",
327
+ negated: true,
328
+ children: [parseNot()]
329
+ };
330
+ }
331
+ return parseComparison();
332
+ };
333
+ const parseComparison = () => {
334
+ if (peek()?.type === "lparen") {
335
+ pos += 1;
336
+ const inner = parseOr();
337
+ if (peek()?.type === "rparen") pos += 1;
338
+ return inner;
339
+ }
340
+ const leftTok = peek();
341
+ if (!leftTok || leftTok.type !== "value" && leftTok.type !== "string") {
342
+ pos += 1;
343
+ return {
344
+ type: "group",
345
+ combinator: "and",
346
+ children: []
347
+ };
348
+ }
349
+ pos += 1;
350
+ const left = operandFromToken(leftTok);
351
+ const opTok = peek();
352
+ if (opTok?.type !== "op") return {
353
+ type: "predicate",
354
+ operand: left,
355
+ op: "is_truthy"
356
+ };
357
+ pos += 1;
358
+ const rightTok = peek();
359
+ if (!rightTok || rightTok.type !== "value" && rightTok.type !== "string") return {
360
+ type: "predicate",
361
+ operand: left,
362
+ op: "is_truthy"
363
+ };
364
+ pos += 1;
365
+ if (opTok.raw === "contains") return {
366
+ type: "predicate",
367
+ operand: left,
368
+ op: "contains",
369
+ value: containsValue(rightTok)
370
+ };
371
+ return {
372
+ type: "compare",
373
+ left,
374
+ op: COMPARE_SYMBOL_TO_OP[opTok.raw] ?? "eq",
375
+ right: operandFromToken(rightTok)
376
+ };
377
+ };
378
+ return parseOr();
379
+ };
380
+ const DIRECTIVE_KINDS = [
381
+ "placeholder",
382
+ "clause",
383
+ "num",
384
+ "ref",
385
+ "index",
386
+ "count",
387
+ "if",
388
+ "elseif",
389
+ "else",
390
+ "endif",
391
+ "each",
392
+ "endeach"
393
+ ];
394
+ /** Directives that occupy their own paragraph, in canonical grammar order. */
395
+ const BLOCK_DIRECTIVE_KINDS = [
396
+ "if",
397
+ "elseif",
398
+ "else",
399
+ "endif",
400
+ "each",
401
+ "endeach"
402
+ ];
403
+ const isBlockDirectiveKind = (kind) => typeof kind === "string" && BLOCK_DIRECTIVE_KINDS.some((blockKind) => blockKind === kind);
404
+ /** Any `{{...}}` marker; group 1 is the inner text (untrimmed — callers trim). */
405
+ const markerPattern = () => /\{\{(?<inner>[^{}]*)\}\}/gu;
406
+ /** Inline field / clause / numbering marker (inner allows the directive sigils). */
407
+ const placeholderPattern = () => /\{\{\s*(?<name>[\p{L}\p{N}_.@:-]+)\s*\}\}/gu;
408
+ /** `{{@clause:Name}}` / `{{@clause:Name:v3}}` — `name` and `modifier` groups. */
409
+ const clauseSlotPattern = () => /\{\{\s*@clause:(?<name>[^:}\s]+)(?::(?<modifier>[^}\s]+))?\s*\}\}/gu;
410
+ /** `{{@num:Key}}` — the `key` group. */
411
+ const numPattern = () => /\{\{\s*@num:(?<key>[\p{L}\p{N}_.-]+)\s*\}\}/gu;
412
+ /** `{{@ref:Key}}` — the `key` group. */
413
+ const refPattern = () => /\{\{\s*@ref:(?<key>[\p{L}\p{N}_.-]+)\s*\}\}/gu;
414
+ /** `{{@index}}` — the 1-based position within the innermost enclosing loop. */
415
+ const indexPattern = () => /\{\{\s*@index\s*\}\}/gu;
416
+ /** `{{@count}}` — the item count of the innermost enclosing loop. */
417
+ const countPattern = () => /\{\{\s*@count\s*\}\}/gu;
418
+ /** Cheap presence test for any numbering marker (no capture). */
419
+ const hasNumberingPattern = () => /\{\{\s*@(?:num|ref):/u;
420
+ /** Cheap presence test for any block directive (no capture). */
421
+ const hasBlockDirectivePattern = () => /\{\{\s*[#/]/u;
422
+ /** A whole line that is a single block directive — `tag` and `expr` groups. */
423
+ const blockDirectiveLinePattern = () => /^\s*\{\{\s*(?<tag>#if|#elseif|#else|#each|\/if|\/each)\b(?<expr>[^{}]*)\}\}\s*$/u;
424
+ const FIELD_PATH_RE = /^[\p{L}\p{N}_.-]+$/u;
425
+ const UNSAFE_FIELD_PATH_SEGMENTS = /* @__PURE__ */ new Set([
426
+ "__proto__",
427
+ "constructor",
428
+ "prototype"
429
+ ]);
430
+ /** Whether `value` is a valid field path per the marker grammar (dotted
431
+ * segments of letters/digits/underscore/dash — no brackets or spaces). */
432
+ const isFieldPath = (value) => FIELD_PATH_RE.test(value);
433
+ /** Whether `value` is safe to use as a dotted object path. */
434
+ const isSafeFieldPath = (value) => isFieldPath(value) && value.split(".").every((segment) => !UNSAFE_FIELD_PATH_SEGMENTS.has(segment));
435
+ const CLAUSE_SLOT_NAME_RE = /^[\p{L}\p{N}_.-]+$/u;
436
+ /** Whether `value` is a valid name for authoring a `{{@clause:NAME}}` slot:
437
+ * the intersection of the clause-slot parse grammar and the fill pipeline's
438
+ * placeholder charset, so every accepted name round-trips through fill. */
439
+ const isClauseSlotName = (value) => CLAUSE_SLOT_NAME_RE.test(value);
440
+ const CLAUSE_INNER_RE = /^@clause:(?<name>[^:}\s]+)(?::(?<version>[^}\s]+))?$/u;
441
+ const NUM_INNER_RE = /^@num:(?<key>[\p{L}\p{N}_.-]+)$/u;
442
+ const REF_INNER_RE = /^@ref:(?<key>[\p{L}\p{N}_.-]+)$/u;
443
+ const INDEX_INNER_RE = /^@index$/u;
444
+ const COUNT_INNER_RE = /^@count$/u;
445
+ const BLOCK_INNER_RE = /^(?<token>#if|#elseif|#else|#each|\/if|\/each)\b(?<expr>.*)$/u;
446
+ /**
447
+ * Classify the inner text of a `{{...}}` marker. Returns `null` when the text is
448
+ * not a recognized directive (so a recognizer can leave stray braces alone).
449
+ */
450
+ const classifyMarker = (innerRaw) => {
451
+ const inner = innerRaw.trim();
452
+ const block = BLOCK_INNER_RE.exec(inner);
453
+ if (block) {
454
+ const token = block.groups?.["token"] ?? "";
455
+ const expr = (block.groups?.["expr"] ?? "").trim();
456
+ if (token === "#if") return {
457
+ kind: "if",
458
+ expr
459
+ };
460
+ if (token === "#elseif") return {
461
+ kind: "elseif",
462
+ expr
463
+ };
464
+ if (token === "#each") return {
465
+ kind: "each",
466
+ expr
467
+ };
468
+ if (token === "#else") return { kind: "else" };
469
+ if (token === "/if") return { kind: "endif" };
470
+ if (token === "/each") return { kind: "endeach" };
471
+ }
472
+ const clause = CLAUSE_INNER_RE.exec(inner);
473
+ if (clause) return {
474
+ kind: "clause",
475
+ name: clause.groups?.["name"] ?? "",
476
+ version: clause.groups?.["version"]
477
+ };
478
+ const num = NUM_INNER_RE.exec(inner);
479
+ if (num) return {
480
+ kind: "num",
481
+ key: num.groups?.["key"] ?? ""
482
+ };
483
+ const ref = REF_INNER_RE.exec(inner);
484
+ if (ref) return {
485
+ kind: "ref",
486
+ key: ref.groups?.["key"] ?? ""
487
+ };
488
+ if (INDEX_INNER_RE.test(inner)) return { kind: "index" };
489
+ if (COUNT_INNER_RE.test(inner)) return { kind: "count" };
490
+ if (FIELD_PATH_RE.test(inner)) return {
491
+ kind: "placeholder",
492
+ expr: inner
493
+ };
494
+ return null;
495
+ };
496
+ /**
497
+ * Find every recognized `{{...}}` marker in `text`, in document order, with
498
+ * offsets and classified metadata. Unrecognized `{{...}}` spans are skipped.
499
+ */
500
+ const scanMarkers = (text) => {
501
+ const re = markerPattern();
502
+ const out = [];
503
+ let match = re.exec(text);
504
+ while (match !== null) {
505
+ const inner = (match[1] ?? "").trim();
506
+ const meta = classifyMarker(inner);
507
+ if (meta) out.push({
508
+ start: match.index,
509
+ end: match.index + match[0].length,
510
+ raw: match[0],
511
+ inner,
512
+ meta
513
+ });
514
+ match = re.exec(text);
515
+ }
516
+ return out;
517
+ };
518
+ /**
519
+ * Find every `{{...}}` span whose inner text is NOT a recognized directive —
520
+ * near-misses an author clearly meant as markers but that every recognizer
521
+ * skips, so they print literally at fill time (e.g. `{{my field}}` with a
522
+ * space, `{{@clause:}}` with no name). Offsets and raw text are shaped like
523
+ * {@link scanMarkers} results, minus the classified `meta`. This is the exact
524
+ * complement of {@link scanMarkers}: a span is in one list or the other.
525
+ */
526
+ const scanInvalidMarkers = (text) => {
527
+ const re = markerPattern();
528
+ const out = [];
529
+ let match = re.exec(text);
530
+ while (match !== null) {
531
+ const inner = (match[1] ?? "").trim();
532
+ if (classifyMarker(inner) === null) out.push({
533
+ start: match.index,
534
+ end: match.index + match[0].length,
535
+ raw: match[0],
536
+ inner
537
+ });
538
+ match = re.exec(text);
539
+ }
540
+ return out;
541
+ };
542
+ /** Exhaustiveness guard — pass the discriminant in a `switch` default branch. */
543
+ const assertNever = (value) => panic(`Unhandled template directive: ${JSON.stringify(value)}`);
544
+ //#endregion
545
+ //#region src/field-values.ts
546
+ /**
547
+ * Single source of truth for the DETERMINISTIC field-value transforms a
548
+ * template fill applies — composite (parts joined by a `{{key}}` format),
549
+ * formula (arithmetic over the other values), and locale-aware date rendering.
550
+ *
551
+ * Both the API fill engine (apps/api/src/handlers/docx) and the web live
552
+ * preview (template-studio.tsx) MUST route their rendering through
553
+ * {@link renderDeterministicFieldValue} so the preview and the generated
554
+ * document produce byte-identical strings. The api/field-value-parity.test.ts
555
+ * asserts this; reintroducing a bespoke transform on either side fails CI.
556
+ *
557
+ * Out of scope (non-deterministic / server-only, handled by callers):
558
+ * - registry lookup (needs a network hit; the preview overlays it async)
559
+ * - AI drafting / adaptation
560
+ *
561
+ * Pure: no IO, no model/provider dependency. The config types here are minimal
562
+ * STRUCTURAL shapes so the package stays free of api/web imports; callers pass
563
+ * their own field objects, which structurally satisfy these.
564
+ */
565
+ const DATE_FORMAT_STYLES = [
566
+ "long",
567
+ "medium",
568
+ "short",
569
+ "iso"
570
+ ];
571
+ /** Stable exemplar for date-format configuration previews. */
572
+ const DATE_FORMAT_EXAMPLE_ISO = "2028-06-13";
573
+ const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u;
574
+ const STYLE_OPTIONS = {
575
+ long: { dateStyle: "long" },
576
+ medium: { dateStyle: "medium" },
577
+ short: { dateStyle: "short" }
578
+ };
579
+ const dateFormatters = /* @__PURE__ */ new Map();
580
+ /** `Intl.DateTimeFormat` for a locale + style pair, cached per pair so it
581
+ * isn't rebuilt on every {@link formatDate} call. */
582
+ const getDateFormatter = (locale, style) => {
583
+ const key = `${locale}:${style}`;
584
+ const cached = dateFormatters.get(key);
585
+ if (cached) return cached;
586
+ const formatter = new Intl.DateTimeFormat(locale, {
587
+ ...STYLE_OPTIONS[style],
588
+ timeZone: "UTC"
589
+ });
590
+ dateFormatters.set(key, formatter);
591
+ return formatter;
592
+ };
593
+ /**
594
+ * Parse a strict YYYY-MM-DD calendar date; null when malformed or not a real
595
+ * date. UTC-anchored so the rendered day never shifts with the timezone.
596
+ * `Date` rolls out-of-range components over (2028-02-30 → March 1), so the
597
+ * round-trip comparison catches non-existent dates.
598
+ */
599
+ const parseIsoDate = (value) => {
600
+ if (!ISO_DATE_PATTERN.test(value)) return null;
601
+ const date = /* @__PURE__ */ new Date(`${value}T00:00:00Z`);
602
+ if (Number.isNaN(date.getTime()) || !date.toISOString().startsWith(value)) return null;
603
+ return date;
604
+ };
605
+ /**
606
+ * Format an ISO date (YYYY-MM-DD) per the field's locale + style via
607
+ * `Intl.DateTimeFormat` — ICU produces the correct localized (and, in date
608
+ * contexts, correctly inflected) month names, e.g. cs long → "13. června
609
+ * 2028". The "iso" style returns the value unchanged; an invalid calendar date
610
+ * returns null.
611
+ */
612
+ const formatDate = (value, dateFormat) => {
613
+ const date = parseIsoDate(value);
614
+ if (date === null) return null;
615
+ if (dateFormat.style === "iso") return value;
616
+ return getDateFormatter(dateFormat.locale, dateFormat.style).format(date);
617
+ };
618
+ /**
619
+ * Render a `{{key}}` format over part values. Markers whose key has no part
620
+ * value are left as-is (a visible authoring artifact, not a render error), so
621
+ * a partially filled composite previews exactly as the fill engine renders it.
622
+ */
623
+ const renderComposite = (parts, format, partValues) => format.replace(markerPattern(), (raw, inner) => {
624
+ const key = inner.trim();
625
+ if (!parts.some((part) => part.key === key)) return raw;
626
+ return partValues[key] ?? raw;
627
+ });
628
+ /** A plain object of part values: a non-null, non-array object. The api
629
+ * composite step accepts exactly this shape (an array or primitive is not a
630
+ * part-values object), so the dispatcher mirrors it. */
631
+ const isPartValuesObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
632
+ /** Build the `{{key}} → value` map for a composite field from its raw object
633
+ * value, keeping only string part values for declared part keys. */
634
+ const compositePartValues = (parts, raw) => {
635
+ const partValues = {};
636
+ for (const part of parts) {
637
+ const value = raw[part.key];
638
+ if (typeof value === "string") partValues[part.key] = value;
639
+ }
640
+ return partValues;
641
+ };
642
+ /**
643
+ * THE deterministic field-value dispatcher: returns the string the fill engine
644
+ * writes for `field` given the submitted `values`, or null when the field has
645
+ * no deterministic transform (a scalar, lookup, or AI field the CALLER renders
646
+ * itself).
647
+ *
648
+ * Dispatch order mirrors the API fill pipeline (composite → formula → date):
649
+ * - composite (parts + format present) → {@link renderComposite}
650
+ * - else formula present → {@link evaluateNumericExpression}, stringified
651
+ * - else date (inputType "date" + dateFormat) → {@link formatDate}
652
+ * - else null
653
+ *
654
+ * Composite returns null when its value is not an object of part values (a
655
+ * plain string passes through unchanged on the api side, so the caller's
656
+ * scalar path handles it). Formula and date return null when the expression /
657
+ * value does not yield a value, so the caller leaves the field as-is.
658
+ */
659
+ const renderDeterministicFieldValue = (field, values) => {
660
+ if (field.parts !== void 0 && field.format !== void 0) {
661
+ const raw = resolvePath(field.path, values);
662
+ if (!isPartValuesObject(raw)) return null;
663
+ return renderComposite(field.parts, field.format, compositePartValues(field.parts, raw));
664
+ }
665
+ if (field.formula !== void 0) {
666
+ const result = evaluateNumericExpression(field.formula, values);
667
+ return result === void 0 ? null : String(result);
668
+ }
669
+ if (field.inputType === "date" && field.dateFormat !== void 0) {
670
+ const raw = resolvePath(field.path, values);
671
+ if (typeof raw !== "string" || raw.trim() === "") return null;
672
+ return formatDate(raw, field.dateFormat);
673
+ }
674
+ return null;
675
+ };
676
+ //#endregion
677
+ //#region src/condition-builder.ts
678
+ const COMPARE_OP_TO_SYMBOL = {
679
+ eq: "==",
680
+ neq: "!=",
681
+ gt: ">",
682
+ lt: "<",
683
+ gte: ">=",
684
+ lte: "<="
685
+ };
686
+ /** Render a literal: strings are quoted/escaped, booleans and numbers are
687
+ * emitted bare (matching the parser's literal handling); an array literal
688
+ * joins on `,` (rare; the template builder edits scalar rules). */
689
+ const serializeLiteral = (value) => {
690
+ if (typeof value === "string") return `"${value.replace(/\\/gu, "\\\\").replace(/"/gu, "\\\"")}"`;
691
+ if (Array.isArray(value)) return `"${value.join(",")}"`;
692
+ return String(value);
693
+ };
694
+ const serializeOperand = (operand) => {
695
+ if (operand.type === "literal") return serializeLiteral(operand.value);
696
+ if (operand.type === "path") return operand.path;
697
+ if (operand.type === "formula") panic("serializeCondition: formula operand requires AST storage");
698
+ return "";
699
+ };
700
+ const serializeCompare = (node) => `${serializeOperand(node.left)} ${COMPARE_OP_TO_SYMBOL[node.op]} ${serializeOperand(node.right)}`;
701
+ const serializePredicate = (node) => {
702
+ const operand = serializeOperand(node.operand);
703
+ if (node.op === "contains") {
704
+ const value = Array.isArray(node.value) ? node.value.join(",") : node.value ?? "";
705
+ return `${operand} contains ${serializeLiteral(value)}`;
706
+ }
707
+ return operand;
708
+ };
709
+ const serializeGroup = (group, top) => {
710
+ const parts = [];
711
+ for (const child of group.children) {
712
+ const part = serializeNode(child, false);
713
+ if (part.length > 0) parts.push(part);
714
+ }
715
+ if (parts.length === 0) return "";
716
+ const body = parts.length === 1 ? parts[0] ?? "" : parts.join(group.combinator === "and" ? " and " : " or ");
717
+ if (group.negated) return `!(${body})`;
718
+ if (parts.length === 1) return body;
719
+ return top ? body : `(${body})`;
720
+ };
721
+ const serializeNode = (node, top) => {
722
+ if (node.type === "compare") return serializeCompare(node);
723
+ if (node.type === "predicate") return serializePredicate(node);
724
+ return serializeGroup(node, top);
725
+ };
726
+ /**
727
+ * Serialize a condition tree into an expression for `evaluateCondition`.
728
+ * Returns an empty string for an empty tree (caller treats that as "no
729
+ * condition" / always-visible).
730
+ */
731
+ const serializeCondition = (node) => serializeNode(node, true);
732
+ //#endregion
733
+ //#region src/index.ts
734
+ /**
735
+ * Shared condition evaluation + marker grammar for DOCX templates. Small,
736
+ * side-effect-free functions usable on both backend (Bun) and frontend
737
+ * (browser).
738
+ *
739
+ * Boolean conditions are evaluated through the canonical `@stll/conditions`
740
+ * AST and its single evaluator, so view filters, AI-extraction gating, and
741
+ * template `{{#if ...}}` conditionals share one set of operators and semantics
742
+ * and never drift apart. This module owns the template surface: the string
743
+ * parser (`./parse`), the named-condition resolver, and the no-code builder
744
+ * serializer (`./condition-builder`).
745
+ */
746
+ /**
747
+ * Maximum recursion depth for named condition resolution.
748
+ * Prevents stack overflow when a long chain of named
749
+ * conditions (A→B→C→...→Z) is evaluated without cycles.
750
+ */
751
+ const MAX_CONDITION_DEPTH = 50;
752
+ /**
753
+ * Normalize a fill-bag value into a `ConditionValue` the shared evaluator
754
+ * accepts: arrays become string arrays (multi-select fields hold their options
755
+ * as strings, so membership tests like `parties contains "guarantor"` work
756
+ * even when an element is numeric), primitives pass through, anything else is
757
+ * stringified.
758
+ */
759
+ const toConditionValue = (raw) => {
760
+ if (raw === null || raw === void 0) return raw;
761
+ if (Array.isArray(raw)) return raw.map(String);
762
+ if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean") return raw;
763
+ };
764
+ /**
765
+ * Build the operand resolver for one evaluation. Only `path` operands need
766
+ * resolving (the evaluator handles literals). A path is first matched against
767
+ * the named conditions: a hit evaluates that condition's expression — so
768
+ * `{{#if isCompany}}` and `{{#if isCompany and signed}}` both work — guarded
769
+ * against cycles (`resolved`) and runaway chains (`MAX_CONDITION_DEPTH`).
770
+ * Otherwise the path resolves against the fill data.
771
+ */
772
+ const makeResolver = (data, namedConditions, resolved, depth) => {
773
+ const resolve = (operand) => {
774
+ if (operand.type === "formula") return evaluateNumericExpression(operand.expr, data);
775
+ if (operand.type !== "path") return;
776
+ const { path } = operand;
777
+ const named = namedConditions?.find((c) => c.name === path);
778
+ if (named) {
779
+ if (depth >= 50 || resolved.has(path)) return false;
780
+ const next = new Set(resolved);
781
+ next.add(path);
782
+ if (named.node) return evaluateCondition$1(named.node, makeResolver(data, namedConditions, next, depth + 1));
783
+ return evaluateCondition(named.expression, data, namedConditions, next, depth + 1);
784
+ }
785
+ return toConditionValue(resolvePath(path, data));
786
+ };
787
+ return resolve;
788
+ };
789
+ /**
790
+ * Evaluate a template `{{#if ...}}` condition. The expression is parsed into
791
+ * the canonical `@stll/conditions` AST and evaluated by the shared evaluator.
792
+ *
793
+ * Supports comparisons (`==`, `!=`, `>`, `<`, `>=`, `<=`), `contains`,
794
+ * truthiness, negation (`!`), logical operators (`and`, `or`), parentheses,
795
+ * dotted paths, numeric underscores, and named-condition references.
796
+ *
797
+ * An empty or unparseable expression is `false` ("no condition" never gates a
798
+ * branch true).
799
+ */
800
+ const evaluateCondition = (expression, data, namedConditions, _resolved = /* @__PURE__ */ new Set(), _depth = 0) => {
801
+ const node = parseCondition(expression);
802
+ if (node === null) return false;
803
+ return evaluateCondition$1(node, makeResolver(data, namedConditions, _resolved, _depth));
804
+ };
805
+ //#endregion
806
+ 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 };
package/package.json CHANGED
@@ -1,5 +1,64 @@
1
1
  {
2
2
  "name": "@stll/template-conditions",
3
- "version": "0.0.1-placeholder.0",
4
- "description": "Placeholder to bootstrap npm trusted publishing. The real package ships via the next @stll release."
5
- }
3
+ "version": "0.2.0",
4
+ "description": "A document-template marker and condition engine: {{...}} marker grammar, parse, compute, serialize, field values.",
5
+ "keywords": [
6
+ "condition",
7
+ "field-values",
8
+ "marker",
9
+ "parse",
10
+ "placeholder",
11
+ "serialize",
12
+ "template"
13
+ ],
14
+ "homepage": "https://github.com/stella/stella/tree/main/packages/template-conditions",
15
+ "bugs": {
16
+ "url": "https://github.com/stella/stella/issues"
17
+ },
18
+ "license": "Apache-2.0",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/stella/stella.git",
22
+ "directory": "packages/template-conditions"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "README.md"
27
+ ],
28
+ "type": "module",
29
+ "sideEffects": false,
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js"
34
+ }
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "scripts": {
40
+ "clean": "git clean -xdf dist .cache .turbo node_modules",
41
+ "build": "tsdown",
42
+ "pack:dry-run": "bun pm pack --dry-run",
43
+ "test": "bun test",
44
+ "test:property": "bun test $(grep -rlF 'fc.assert' src --include='*.test.ts')",
45
+ "typecheck": "bun ../../packages/scripts/src/tsc-native.ts --noEmit",
46
+ "lint": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --report-unused-disable-directives-severity=error --deny-warnings --type-aware packages/template-conditions",
47
+ "lint:fix": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --type-aware --fix packages/template-conditions",
48
+ "format": "oxfmt .",
49
+ "prepack": "bun run build"
50
+ },
51
+ "dependencies": {
52
+ "@stll/conditions": "^0.2.0",
53
+ "better-result": "3.0.0"
54
+ },
55
+ "devDependencies": {
56
+ "@stll/property-testing": "0.0.0",
57
+ "@stll/typescript-config": "0.0.0",
58
+ "@types/bun": "1.3.14",
59
+ "fast-check": "^4.9.0",
60
+ "tsdown": "0.22.14"
61
+ },
62
+ "main": "./dist/index.js",
63
+ "types": "./dist/index.d.ts"
64
+ }