@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 +69 -20
- package/dist/index.js +143 -52
- package/package.json +6 -6
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". */
|
|
54
|
-
|
|
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
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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. */
|
|
203
|
-
|
|
204
|
-
|
|
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;
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
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
|
|
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
|
|
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")
|
|
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 =
|
|
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.
|
|
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": "
|
|
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.
|
|
53
|
-
"better-result": "
|
|
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.
|
|
60
|
-
"tsdown": "0.22.
|
|
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"
|