@stll/template-conditions 0.1.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/dist/index.d.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { CompareNode, CompareOp, Condition, ConditionNode, ConditionNode as ConditionNode$1, GroupNode, Operand, PredicateNode, PredicateOp, conditionHasFormula, conditionNodeSchema, conditionSchema, emptyCondition } from "@stll/conditions";
2
-
3
2
  //#region src/path.d.ts
4
3
  /** Resolve a dotted path like `company.name` against data. */
5
4
  declare const resolvePath: (path: string, data: Record<string, unknown>) => unknown;
@@ -25,6 +24,8 @@ declare const parseCondition: (expression: string) => ConditionNode$1 | null;
25
24
  * non-numeric variable, division producing a non-finite result) so callers
26
25
  * can fall back to leaving the field unfilled rather than emitting `NaN`.
27
26
  */
27
+ declare const NUMERIC_FUNCTION_NAMES: readonly ["min", "max", "round", "abs", "floor", "ceil"];
28
+ type NumericFunctionName = (typeof NUMERIC_FUNCTION_NAMES)[number];
28
29
  declare const evaluateNumericExpression: (expression: string, data: Record<string, unknown>) => number | undefined;
29
30
  //#endregion
30
31
  //#region src/field-values.d.ts
@@ -47,11 +48,16 @@ declare const evaluateNumericExpression: (expression: string, data: Record<strin
47
48
  * STRUCTURAL shapes so the package stays free of api/web imports; callers pass
48
49
  * their own field objects, which structurally satisfy these.
49
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";
50
55
  /** Locale-aware date rendering config. "iso" leaves the submitted value as-is;
51
56
  * the other styles map to `Intl.DateTimeFormat` `dateStyle`. */
52
57
  type FieldDateFormat = {
53
- /** BCP-47 language tag of the document, e.g. "cs", "de", "pl". */locale: string;
54
- style: "long" | "medium" | "short" | "iso";
58
+ /** BCP-47 language tag of the document, e.g. "cs", "de", "pl". */
59
+ locale: string;
60
+ style: DateFormatStyle;
55
61
  };
56
62
  /**
57
63
  * Format an ISO date (YYYY-MM-DD) per the field's locale + style via
@@ -120,23 +126,15 @@ declare const serializeCondition: (node: ConditionNode$1) => string;
120
126
  * shared by the fill pipeline (api), the editor highlighter (folio), and the
121
127
  * read-only preview (web).
122
128
  *
123
- * Add a new directive in ONE place: append its `kind` to {@link DIRECTIVE_KINDS},
124
- * add a `MarkerMeta` member, and teach {@link classifyMarker} to recognize it.
125
- * Every scanner that uses {@link scanMarkers} / {@link classifyMarker} then sees
126
- * it automatically, and every exhaustive `switch` over a marker's kind (closed
127
- * with {@link assertNever}) fails to compile at any consumer that has not yet
128
- * handled it. That is what makes "added a directive server-side but a recognizer
129
- * elsewhere silently ignored it" a build error rather than a latent bug.
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.
130
133
  *
131
134
  * No consumer should define its own `{{...}}` regex. The low-level pattern
132
135
  * factories below exist so handlers that mutate raw XML/text reuse the exact
133
136
  * same literals; recognizers should prefer the high-level scanner.
134
137
  */
135
- declare const DIRECTIVE_KINDS: readonly ["placeholder", "clause", "num", "ref", "index", "count", "if", "elseif", "else", "endif", "each", "endeach"];
136
- type DirectiveKind = (typeof DIRECTIVE_KINDS)[number];
137
- /** Directives that occupy their own paragraph (block-level), opener→closer. */
138
- declare const BLOCK_DIRECTIVE_KINDS: readonly ["if", "elseif", "else", "endif", "each", "endeach"];
139
- declare const isBlockDirectiveKind: (kind: DirectiveKind) => boolean;
140
138
  type MarkerMeta = {
141
139
  kind: "placeholder";
142
140
  expr: string;
@@ -170,6 +168,26 @@ type MarkerMeta = {
170
168
  } | {
171
169
  kind: "endeach";
172
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;
173
191
  /** Any `{{...}}` marker; group 1 is the inner text (untrimmed — callers trim). */
174
192
  declare const markerPattern: () => RegExp;
175
193
  /** Inline field / clause / numbering marker (inner allows the directive sigils). */
@@ -193,15 +211,25 @@ declare const blockDirectiveLinePattern: () => RegExp;
193
211
  /** Whether `value` is a valid field path per the marker grammar (dotted
194
212
  * segments of letters/digits/underscore/dash — no brackets or spaces). */
195
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;
196
220
  /**
197
221
  * Classify the inner text of a `{{...}}` marker. Returns `null` when the text is
198
222
  * not a recognized directive (so a recognizer can leave stray braces alone).
199
223
  */
200
224
  declare const classifyMarker: (innerRaw: string) => MarkerMeta | null;
201
225
  type ScannedMarker = {
202
- /** Offset of `{{` in the source text. */start: number; /** Offset just past `}}`. */
203
- end: number; /** The full matched marker, e.g. `{{@num:scope}}`. */
204
- raw: string; /** The (untrimmed) inner text. */
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. */
205
233
  inner: string;
206
234
  meta: MarkerMeta;
207
235
  };
@@ -210,12 +238,33 @@ type ScannedMarker = {
210
238
  * offsets and classified metadata. Unrecognized `{{...}}` spans are skipped.
211
239
  */
212
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[];
213
261
  /** Exhaustiveness guard — pass the discriminant in a `switch` default branch. */
214
262
  declare const assertNever: (value: never) => never;
215
263
  //#endregion
216
264
  //#region src/index.d.ts
217
265
  type NamedCondition = {
218
- name: string; /** The `{{#if}}` expression string. Authoritative unless `node` is set. */
266
+ name: string;
267
+ /** The `{{#if}}` expression string. Authoritative unless `node` is set. */
219
268
  expression: string;
220
269
  /**
221
270
  * The canonical AST, set when the condition cannot round-trip through the
@@ -245,4 +294,4 @@ declare const MAX_CONDITION_DEPTH = 50;
245
294
  */
246
295
  declare const evaluateCondition: (expression: string, data: Record<string, unknown>, namedConditions?: readonly NamedCondition[], _resolved?: ReadonlySet<string>, _depth?: number) => boolean;
247
296
  //#endregion
248
- export { BLOCK_DIRECTIVE_KINDS, type CompareNode, type CompareOp, type Condition, type ConditionNode, DIRECTIVE_KINDS, type DeterministicFieldConfig, type DirectiveKind, type FieldDateFormat, type GroupNode, MAX_CONDITION_DEPTH, type MarkerMeta, NamedCondition, 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, isFieldPath, markerPattern, numPattern, parseCondition, placeholderPattern, refPattern, renderComposite, renderDeterministicFieldValue, resolvePath, scanMarkers, serializeCondition };
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 CHANGED
@@ -38,7 +38,16 @@ const resolvePath = (path, data) => {
38
38
  * non-numeric variable, division producing a non-finite result) so callers
39
39
  * can fall back to leaving the field unfilled rather than emitting `NaN`.
40
40
  */
41
- const TOKEN_RE$1 = /(?<numeric>[0-9][0-9_]*(?:\.[0-9]+)?)|(?<ident>[\p{L}_][\p{L}\p{N}_.]*(?:-[\p{L}\p{N}_.]+)*)|(?<punct>[+\-*/%(),])|(?<invalid>\S)/gu;
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;
42
51
  const PRECEDENCE = {
43
52
  "+": 1,
44
53
  "-": 1,
@@ -48,7 +57,7 @@ const PRECEDENCE = {
48
57
  };
49
58
  const tokenize$1 = (expression) => {
50
59
  const tokens = [];
51
- for (const match of expression.matchAll(TOKEN_RE$1)) {
60
+ for (const match of expression.matchAll(TOKEN_RE)) {
52
61
  const { numeric, ident, punct, invalid } = match.groups ?? {};
53
62
  if (invalid !== void 0) throw new Error(`Unexpected token: ${invalid}`);
54
63
  if (numeric !== void 0) tokens.push({
@@ -79,6 +88,7 @@ const applyOp = (op, a, b) => {
79
88
  }
80
89
  };
81
90
  const callFunction = (name, args) => {
91
+ if (!isNumericFunctionName(name)) throw new Error(`Unknown function: ${name}`);
82
92
  switch (name) {
83
93
  case "min": return Math.min(...args);
84
94
  case "max": return Math.max(...args);
@@ -91,7 +101,7 @@ const callFunction = (name, args) => {
91
101
  const factor = 10 ** Math.trunc(digits);
92
102
  return Math.round(value * factor) / factor;
93
103
  }
94
- default: throw new Error(`Unknown function: ${name}`);
104
+ default: return name;
95
105
  }
96
106
  };
97
107
  const evaluateNumericExpression = (expression, data) => {
@@ -165,7 +175,7 @@ const evaluateNumericExpression = (expression, data) => {
165
175
  };
166
176
  //#endregion
167
177
  //#region src/parse.ts
168
- const TOKEN_RE = /(?<token>"[^"\\]*(?:\\.[^"\\]*)*"|==|!=|>=|<=|>|<|!(?!=)|and\b|or\b|contains\b|[()]|-?\d[\p{N}_.]*|[\p{L}\p{N}_.]+(?:-[\p{L}\p{N}_.]+)*)/gu;
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;
169
179
  const STARTS_WITH_DIGIT_RE = /^-?\d/u;
170
180
  const COMPARE_SYMBOL_TO_OP = {
171
181
  "==": "eq",
@@ -175,27 +185,66 @@ const COMPARE_SYMBOL_TO_OP = {
175
185
  ">=": "gte",
176
186
  "<=": "lte"
177
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
+ };
178
227
  const tokenize = (expr) => {
179
228
  const tokens = [];
180
- for (const m of expr.matchAll(TOKEN_RE)) {
181
- const raw = m.groups?.["token"] ?? m[0];
182
- if (raw === "and") tokens.push({ type: "and" });
183
- else if (raw === "or") tokens.push({ type: "or" });
184
- else if (raw === "!") tokens.push({ type: "not" });
185
- else if (raw === "==" || raw === "!=" || raw === ">" || raw === "<" || raw === ">=" || raw === "<=" || raw === "contains") tokens.push({
186
- type: "op",
187
- raw
188
- });
189
- else if (raw === "(") tokens.push({ type: "lparen" });
190
- else if (raw === ")") tokens.push({ type: "rparen" });
191
- else if (raw.startsWith("\"")) tokens.push({
192
- type: "string",
193
- raw: raw.slice(1, -1).replace(/\\"/gu, "\"")
194
- });
195
- else tokens.push({
196
- type: "value",
197
- raw
198
- });
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;
199
248
  }
200
249
  return tokens;
201
250
  };
@@ -328,25 +377,6 @@ const parseCondition = (expression) => {
328
377
  };
329
378
  return parseOr();
330
379
  };
331
- //#endregion
332
- //#region src/markers.ts
333
- /**
334
- * Canonical grammar for `{{...}}` template markers — the single source of truth
335
- * shared by the fill pipeline (api), the editor highlighter (folio), and the
336
- * read-only preview (web).
337
- *
338
- * Add a new directive in ONE place: append its `kind` to {@link DIRECTIVE_KINDS},
339
- * add a `MarkerMeta` member, and teach {@link classifyMarker} to recognize it.
340
- * Every scanner that uses {@link scanMarkers} / {@link classifyMarker} then sees
341
- * it automatically, and every exhaustive `switch` over a marker's kind (closed
342
- * with {@link assertNever}) fails to compile at any consumer that has not yet
343
- * handled it. That is what makes "added a directive server-side but a recognizer
344
- * elsewhere silently ignored it" a build error rather than a latent bug.
345
- *
346
- * No consumer should define its own `{{...}}` regex. The low-level pattern
347
- * factories below exist so handlers that mutate raw XML/text reuse the exact
348
- * same literals; recognizers should prefer the high-level scanner.
349
- */
350
380
  const DIRECTIVE_KINDS = [
351
381
  "placeholder",
352
382
  "clause",
@@ -361,7 +391,7 @@ const DIRECTIVE_KINDS = [
361
391
  "each",
362
392
  "endeach"
363
393
  ];
364
- /** Directives that occupy their own paragraph (block-level), opener→closer. */
394
+ /** Directives that occupy their own paragraph, in canonical grammar order. */
365
395
  const BLOCK_DIRECTIVE_KINDS = [
366
396
  "if",
367
397
  "elseif",
@@ -370,8 +400,7 @@ const BLOCK_DIRECTIVE_KINDS = [
370
400
  "each",
371
401
  "endeach"
372
402
  ];
373
- const BLOCK_KIND_SET = new Set(BLOCK_DIRECTIVE_KINDS);
374
- const isBlockDirectiveKind = (kind) => BLOCK_KIND_SET.has(kind);
403
+ const isBlockDirectiveKind = (kind) => typeof kind === "string" && BLOCK_DIRECTIVE_KINDS.some((blockKind) => blockKind === kind);
375
404
  /** Any `{{...}}` marker; group 1 is the inner text (untrimmed — callers trim). */
376
405
  const markerPattern = () => /\{\{(?<inner>[^{}]*)\}\}/gu;
377
406
  /** Inline field / clause / numbering marker (inner allows the directive sigils). */
@@ -393,9 +422,21 @@ const hasBlockDirectivePattern = () => /\{\{\s*[#/]/u;
393
422
  /** A whole line that is a single block directive — `tag` and `expr` groups. */
394
423
  const blockDirectiveLinePattern = () => /^\s*\{\{\s*(?<tag>#if|#elseif|#else|#each|\/if|\/each)\b(?<expr>[^{}]*)\}\}\s*$/u;
395
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
+ ]);
396
430
  /** Whether `value` is a valid field path per the marker grammar (dotted
397
431
  * segments of letters/digits/underscore/dash — no brackets or spaces). */
398
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);
399
440
  const CLAUSE_INNER_RE = /^@clause:(?<name>[^:}\s]+)(?::(?<version>[^}\s]+))?$/u;
400
441
  const NUM_INNER_RE = /^@num:(?<key>[\p{L}\p{N}_.-]+)$/u;
401
442
  const REF_INNER_RE = /^@ref:(?<key>[\p{L}\p{N}_.-]+)$/u;
@@ -474,6 +515,30 @@ const scanMarkers = (text) => {
474
515
  }
475
516
  return out;
476
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
+ };
477
542
  /** Exhaustiveness guard — pass the discriminant in a `switch` default branch. */
478
543
  const assertNever = (value) => panic(`Unhandled template directive: ${JSON.stringify(value)}`);
479
544
  //#endregion
@@ -497,12 +562,34 @@ const assertNever = (value) => panic(`Unhandled template directive: ${JSON.strin
497
562
  * STRUCTURAL shapes so the package stays free of api/web imports; callers pass
498
563
  * their own field objects, which structurally satisfy these.
499
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";
500
573
  const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u;
501
574
  const STYLE_OPTIONS = {
502
575
  long: { dateStyle: "long" },
503
576
  medium: { dateStyle: "medium" },
504
577
  short: { dateStyle: "short" }
505
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
+ };
506
593
  /**
507
594
  * Parse a strict YYYY-MM-DD calendar date; null when malformed or not a real
508
595
  * date. UTC-anchored so the rendered day never shifts with the timezone.
@@ -526,10 +613,7 @@ const formatDate = (value, dateFormat) => {
526
613
  const date = parseIsoDate(value);
527
614
  if (date === null) return null;
528
615
  if (dateFormat.style === "iso") return value;
529
- return new Intl.DateTimeFormat(dateFormat.locale, {
530
- ...STYLE_OPTIONS[dateFormat.style],
531
- timeZone: "UTC"
532
- }).format(date);
616
+ return getDateFormatter(dateFormat.locale, dateFormat.style).format(date);
533
617
  };
534
618
  /**
535
619
  * Render a `{{key}}` format over part values. Markers whose key has no part
@@ -616,11 +700,18 @@ const serializeOperand = (operand) => {
616
700
  const serializeCompare = (node) => `${serializeOperand(node.left)} ${COMPARE_OP_TO_SYMBOL[node.op]} ${serializeOperand(node.right)}`;
617
701
  const serializePredicate = (node) => {
618
702
  const operand = serializeOperand(node.operand);
619
- if (node.op === "contains") return `${operand} contains ${serializeLiteral(Array.isArray(node.value) ? node.value.join(",") : node.value ?? "")}`;
703
+ if (node.op === "contains") {
704
+ const value = Array.isArray(node.value) ? node.value.join(",") : node.value ?? "";
705
+ return `${operand} contains ${serializeLiteral(value)}`;
706
+ }
620
707
  return operand;
621
708
  };
622
709
  const serializeGroup = (group, top) => {
623
- const parts = group.children.map((child) => serializeNode(child, false)).filter((part) => part.length > 0);
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
+ }
624
715
  if (parts.length === 0) return "";
625
716
  const body = parts.length === 1 ? parts[0] ?? "" : parts.join(group.combinator === "and" ? " and " : " or ");
626
717
  if (group.negated) return `!(${body})`;
@@ -712,4 +803,4 @@ const evaluateCondition = (expression, data, namedConditions, _resolved = /* @__
712
803
  return evaluateCondition$1(node, makeResolver(data, namedConditions, _resolved, _depth));
713
804
  };
714
805
  //#endregion
715
- export { BLOCK_DIRECTIVE_KINDS, DIRECTIVE_KINDS, MAX_CONDITION_DEPTH, assertNever, blockDirectiveLinePattern, classifyMarker, clauseSlotPattern, conditionHasFormula, conditionNodeSchema, conditionSchema, countPattern, emptyCondition, evaluateCondition, evaluateNumericExpression, formatDate, hasBlockDirectivePattern, hasNumberingPattern, indexPattern, isBlockDirectiveKind, isFieldPath, markerPattern, numPattern, parseCondition, placeholderPattern, refPattern, renderComposite, renderDeterministicFieldValue, resolvePath, scanMarkers, serializeCondition };
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/template-conditions",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A document-template marker and condition engine: {{...}} marker grammar, parse, compute, serialize, field values.",
5
5
  "keywords": [
6
6
  "condition",
@@ -42,22 +42,22 @@
42
42
  "pack:dry-run": "bun pm pack --dry-run",
43
43
  "test": "bun test",
44
44
  "test:property": "bun test $(grep -rlF 'fc.assert' src --include='*.test.ts')",
45
- "typecheck": "tsgo --noEmit",
45
+ "typecheck": "bun ../../packages/scripts/src/tsc-native.ts --noEmit",
46
46
  "lint": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --report-unused-disable-directives-severity=error --deny-warnings --type-aware packages/template-conditions",
47
47
  "lint:fix": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --type-aware --fix packages/template-conditions",
48
48
  "format": "oxfmt .",
49
49
  "prepack": "bun run build"
50
50
  },
51
51
  "dependencies": {
52
- "@stll/conditions": "^0.1.0",
53
- "better-result": "2.9.2"
52
+ "@stll/conditions": "^0.2.0",
53
+ "better-result": "3.0.0"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@stll/property-testing": "0.0.0",
57
57
  "@stll/typescript-config": "0.0.0",
58
58
  "@types/bun": "1.3.14",
59
- "fast-check": "^4.8.0",
60
- "tsdown": "0.22.3"
59
+ "fast-check": "^4.9.0",
60
+ "tsdown": "0.22.14"
61
61
  },
62
62
  "main": "./dist/index.js",
63
63
  "types": "./dist/index.d.ts"