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