@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 +69 -20
- package/dist/index.js +148 -54
- package/package.json +8 -8
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,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
|
-
|
|
181
|
-
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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]
|
|
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
|
|
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
|
|
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
|
|
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")
|
|
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 =
|
|
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
|
|
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": "
|
|
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.
|
|
53
|
-
"better-result": "
|
|
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
|
-
"
|
|
59
|
-
"fast-check": "^4.
|
|
60
|
-
"tsdown": "0.22.
|
|
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"
|