@stll/template-conditions 0.1.0 → 0.2.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.
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,8 +175,6 @@ 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;
169
- const STARTS_WITH_DIGIT_RE = /^-?\d/u;
170
178
  const COMPARE_SYMBOL_TO_OP = {
171
179
  "==": "eq",
172
180
  "!=": "neq",
@@ -175,27 +183,71 @@ const COMPARE_SYMBOL_TO_OP = {
175
183
  ">=": "gte",
176
184
  "<=": "lte"
177
185
  };
186
+ const escapeRegexLiteral = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
187
+ const COMPARE_SYMBOL_PATTERN = Object.keys(COMPARE_SYMBOL_TO_OP).sort((left, right) => right.length - left.length).map(escapeRegexLiteral).join("|");
188
+ const NON_STRING_TOKEN_RE = new RegExp(String.raw`(?<token>${COMPARE_SYMBOL_PATTERN}|!(?!=)|and\b|or\b|contains\b|[()]|-?\d[\p{N}_.]*|[\p{L}\p{N}_.]+(?:-[\p{L}\p{N}_.]+)*)`, "uy");
189
+ const STARTS_WITH_DIGIT_RE = /^-?\d/u;
190
+ const isCompareSymbol = (raw) => Object.hasOwn(COMPARE_SYMBOL_TO_OP, raw);
191
+ /**
192
+ * Scan a `"..."` literal starting at `expr[start]` (the opening quote),
193
+ * honoring `\\`-escapes without interpreting them. A single linear pass:
194
+ * each character is visited at most once, so this is safe on adversarial
195
+ * input regardless of how many `"` or `\\` characters it contains.
196
+ *
197
+ * A closing quote is not required, consistent with the module's
198
+ * degrade-gracefully policy for an unmatched `(`, a literal missing its
199
+ * closing quote simply closes at end of input.
200
+ */
201
+ const scanString = (expr, start) => {
202
+ let i = start + 1;
203
+ while (i < expr.length) {
204
+ if (expr[i] === "\"") return {
205
+ content: expr.slice(start + 1, i),
206
+ end: i + 1
207
+ };
208
+ i += expr[i] === "\\" && i + 1 < expr.length ? 2 : 1;
209
+ }
210
+ return {
211
+ content: expr.slice(start + 1),
212
+ end: expr.length
213
+ };
214
+ };
215
+ const classifyNonString = (raw) => {
216
+ if (raw === "and") return { type: "and" };
217
+ if (raw === "or") return { type: "or" };
218
+ if (raw === "!") return { type: "not" };
219
+ if (raw === "contains" || isCompareSymbol(raw)) return {
220
+ type: "op",
221
+ raw
222
+ };
223
+ if (raw === "(") return { type: "lparen" };
224
+ if (raw === ")") return { type: "rparen" };
225
+ return {
226
+ type: "value",
227
+ raw
228
+ };
229
+ };
178
230
  const tokenize = (expr) => {
179
231
  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
- });
232
+ let pos = 0;
233
+ while (pos < expr.length) {
234
+ if (expr[pos] === "\"") {
235
+ const { content, end } = scanString(expr, pos);
236
+ tokens.push({
237
+ type: "string",
238
+ raw: content.replace(/\\"/gu, "\"")
239
+ });
240
+ pos = end;
241
+ continue;
242
+ }
243
+ NON_STRING_TOKEN_RE.lastIndex = pos;
244
+ const m = NON_STRING_TOKEN_RE.exec(expr);
245
+ if (!m) {
246
+ pos += 1;
247
+ continue;
248
+ }
249
+ tokens.push(classifyNonString(m.groups?.["token"] ?? m[0]));
250
+ pos = NON_STRING_TOKEN_RE.lastIndex;
199
251
  }
200
252
  return tokens;
201
253
  };
@@ -322,31 +374,12 @@ const parseCondition = (expression) => {
322
374
  return {
323
375
  type: "compare",
324
376
  left,
325
- op: COMPARE_SYMBOL_TO_OP[opTok.raw] ?? "eq",
377
+ op: COMPARE_SYMBOL_TO_OP[opTok.raw],
326
378
  right: operandFromToken(rightTok)
327
379
  };
328
380
  };
329
381
  return parseOr();
330
382
  };
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
383
  const DIRECTIVE_KINDS = [
351
384
  "placeholder",
352
385
  "clause",
@@ -361,7 +394,7 @@ const DIRECTIVE_KINDS = [
361
394
  "each",
362
395
  "endeach"
363
396
  ];
364
- /** Directives that occupy their own paragraph (block-level), opener→closer. */
397
+ /** Directives that occupy their own paragraph, in canonical grammar order. */
365
398
  const BLOCK_DIRECTIVE_KINDS = [
366
399
  "if",
367
400
  "elseif",
@@ -370,8 +403,7 @@ const BLOCK_DIRECTIVE_KINDS = [
370
403
  "each",
371
404
  "endeach"
372
405
  ];
373
- const BLOCK_KIND_SET = new Set(BLOCK_DIRECTIVE_KINDS);
374
- const isBlockDirectiveKind = (kind) => BLOCK_KIND_SET.has(kind);
406
+ const isBlockDirectiveKind = (kind) => typeof kind === "string" && BLOCK_DIRECTIVE_KINDS.some((blockKind) => blockKind === kind);
375
407
  /** Any `{{...}}` marker; group 1 is the inner text (untrimmed — callers trim). */
376
408
  const markerPattern = () => /\{\{(?<inner>[^{}]*)\}\}/gu;
377
409
  /** Inline field / clause / numbering marker (inner allows the directive sigils). */
@@ -393,9 +425,21 @@ const hasBlockDirectivePattern = () => /\{\{\s*[#/]/u;
393
425
  /** A whole line that is a single block directive — `tag` and `expr` groups. */
394
426
  const blockDirectiveLinePattern = () => /^\s*\{\{\s*(?<tag>#if|#elseif|#else|#each|\/if|\/each)\b(?<expr>[^{}]*)\}\}\s*$/u;
395
427
  const FIELD_PATH_RE = /^[\p{L}\p{N}_.-]+$/u;
428
+ const UNSAFE_FIELD_PATH_SEGMENTS = /* @__PURE__ */ new Set([
429
+ "__proto__",
430
+ "constructor",
431
+ "prototype"
432
+ ]);
396
433
  /** Whether `value` is a valid field path per the marker grammar (dotted
397
434
  * segments of letters/digits/underscore/dash — no brackets or spaces). */
398
435
  const isFieldPath = (value) => FIELD_PATH_RE.test(value);
436
+ /** Whether `value` is safe to use as a dotted object path. */
437
+ const isSafeFieldPath = (value) => isFieldPath(value) && value.split(".").every((segment) => !UNSAFE_FIELD_PATH_SEGMENTS.has(segment));
438
+ const CLAUSE_SLOT_NAME_RE = /^[\p{L}\p{N}_.-]+$/u;
439
+ /** Whether `value` is a valid name for authoring a `{{@clause:NAME}}` slot:
440
+ * the intersection of the clause-slot parse grammar and the fill pipeline's
441
+ * placeholder charset, so every accepted name round-trips through fill. */
442
+ const isClauseSlotName = (value) => CLAUSE_SLOT_NAME_RE.test(value);
399
443
  const CLAUSE_INNER_RE = /^@clause:(?<name>[^:}\s]+)(?::(?<version>[^}\s]+))?$/u;
400
444
  const NUM_INNER_RE = /^@num:(?<key>[\p{L}\p{N}_.-]+)$/u;
401
445
  const REF_INNER_RE = /^@ref:(?<key>[\p{L}\p{N}_.-]+)$/u;
@@ -474,6 +518,30 @@ const scanMarkers = (text) => {
474
518
  }
475
519
  return out;
476
520
  };
521
+ /**
522
+ * Find every `{{...}}` span whose inner text is NOT a recognized directive —
523
+ * near-misses an author clearly meant as markers but that every recognizer
524
+ * skips, so they print literally at fill time (e.g. `{{my field}}` with a
525
+ * space, `{{@clause:}}` with no name). Offsets and raw text are shaped like
526
+ * {@link scanMarkers} results, minus the classified `meta`. This is the exact
527
+ * complement of {@link scanMarkers}: a span is in one list or the other.
528
+ */
529
+ const scanInvalidMarkers = (text) => {
530
+ const re = markerPattern();
531
+ const out = [];
532
+ let match = re.exec(text);
533
+ while (match !== null) {
534
+ const inner = (match[1] ?? "").trim();
535
+ if (classifyMarker(inner) === null) out.push({
536
+ start: match.index,
537
+ end: match.index + match[0].length,
538
+ raw: match[0],
539
+ inner
540
+ });
541
+ match = re.exec(text);
542
+ }
543
+ return out;
544
+ };
477
545
  /** Exhaustiveness guard — pass the discriminant in a `switch` default branch. */
478
546
  const assertNever = (value) => panic(`Unhandled template directive: ${JSON.stringify(value)}`);
479
547
  //#endregion
@@ -497,12 +565,34 @@ const assertNever = (value) => panic(`Unhandled template directive: ${JSON.strin
497
565
  * STRUCTURAL shapes so the package stays free of api/web imports; callers pass
498
566
  * their own field objects, which structurally satisfy these.
499
567
  */
568
+ const DATE_FORMAT_STYLES = [
569
+ "long",
570
+ "medium",
571
+ "short",
572
+ "iso"
573
+ ];
574
+ /** Stable exemplar for date-format configuration previews. */
575
+ const DATE_FORMAT_EXAMPLE_ISO = "2028-06-13";
500
576
  const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u;
501
577
  const STYLE_OPTIONS = {
502
578
  long: { dateStyle: "long" },
503
579
  medium: { dateStyle: "medium" },
504
580
  short: { dateStyle: "short" }
505
581
  };
582
+ const dateFormatters = /* @__PURE__ */ new Map();
583
+ /** `Intl.DateTimeFormat` for a locale + style pair, cached per pair so it
584
+ * isn't rebuilt on every {@link formatDate} call. */
585
+ const getDateFormatter = (locale, style) => {
586
+ const key = `${locale}:${style}`;
587
+ const cached = dateFormatters.get(key);
588
+ if (cached) return cached;
589
+ const formatter = new Intl.DateTimeFormat(locale, {
590
+ ...STYLE_OPTIONS[style],
591
+ timeZone: "UTC"
592
+ });
593
+ dateFormatters.set(key, formatter);
594
+ return formatter;
595
+ };
506
596
  /**
507
597
  * Parse a strict YYYY-MM-DD calendar date; null when malformed or not a real
508
598
  * date. UTC-anchored so the rendered day never shifts with the timezone.
@@ -526,10 +616,7 @@ const formatDate = (value, dateFormat) => {
526
616
  const date = parseIsoDate(value);
527
617
  if (date === null) return null;
528
618
  if (dateFormat.style === "iso") return value;
529
- return new Intl.DateTimeFormat(dateFormat.locale, {
530
- ...STYLE_OPTIONS[dateFormat.style],
531
- timeZone: "UTC"
532
- }).format(date);
619
+ return getDateFormatter(dateFormat.locale, dateFormat.style).format(date);
533
620
  };
534
621
  /**
535
622
  * Render a `{{key}}` format over part values. Markers whose key has no part
@@ -616,11 +703,18 @@ const serializeOperand = (operand) => {
616
703
  const serializeCompare = (node) => `${serializeOperand(node.left)} ${COMPARE_OP_TO_SYMBOL[node.op]} ${serializeOperand(node.right)}`;
617
704
  const serializePredicate = (node) => {
618
705
  const operand = serializeOperand(node.operand);
619
- if (node.op === "contains") return `${operand} contains ${serializeLiteral(Array.isArray(node.value) ? node.value.join(",") : node.value ?? "")}`;
706
+ if (node.op === "contains") {
707
+ const value = Array.isArray(node.value) ? node.value.join(",") : node.value ?? "";
708
+ return `${operand} contains ${serializeLiteral(value)}`;
709
+ }
620
710
  return operand;
621
711
  };
622
712
  const serializeGroup = (group, top) => {
623
- const parts = group.children.map((child) => serializeNode(child, false)).filter((part) => part.length > 0);
713
+ const parts = [];
714
+ for (const child of group.children) {
715
+ const part = serializeNode(child, false);
716
+ if (part.length > 0) parts.push(part);
717
+ }
624
718
  if (parts.length === 0) return "";
625
719
  const body = parts.length === 1 ? parts[0] ?? "" : parts.join(group.combinator === "and" ? " and " : " or ");
626
720
  if (group.negated) return `!(${body})`;
@@ -712,4 +806,4 @@ const evaluateCondition = (expression, data, namedConditions, _resolved = /* @__
712
806
  return evaluateCondition$1(node, makeResolver(data, namedConditions, _resolved, _depth));
713
807
  };
714
808
  //#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 };
809
+ 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.1",
4
4
  "description": "A document-template marker and condition engine: {{...}} marker grammar, parse, compute, serialize, field values.",
5
5
  "keywords": [
6
6
  "condition",
@@ -41,23 +41,23 @@
41
41
  "build": "tsdown",
42
42
  "pack:dry-run": "bun pm pack --dry-run",
43
43
  "test": "bun test",
44
- "test:property": "bun test $(grep -rlF 'fc.assert' src --include='*.test.ts')",
45
- "typecheck": "tsgo --noEmit",
44
+ "test:property": "bun test --preload @stll/property-testing/preload $(grep -rlF 'fc.assert' src --include='*.test.ts')",
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.3.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
- "@types/bun": "1.3.14",
59
- "fast-check": "^4.8.0",
60
- "tsdown": "0.22.3"
58
+ "bun-types": "1.4.0",
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"