@stll/template-conditions 0.5.4 → 0.6.2
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 +15 -3
- package/dist/index.js +64 -15
- package/package.json +4 -4
package/dist/index.d.ts
CHANGED
|
@@ -181,9 +181,14 @@ type MarkerMeta = {
|
|
|
181
181
|
} | {
|
|
182
182
|
kind: "if";
|
|
183
183
|
expr: string;
|
|
184
|
+
/** Filters on a tag whose whole body is one field path configure THAT
|
|
185
|
+
* boolean: a question the document only asks has no value marker to
|
|
186
|
+
* carry a chain. A tag holding a real expression carries none. */
|
|
187
|
+
filters: readonly FilterCall[];
|
|
184
188
|
} | {
|
|
185
189
|
kind: "elif";
|
|
186
190
|
expr: string;
|
|
191
|
+
filters: readonly FilterCall[];
|
|
187
192
|
} | {
|
|
188
193
|
kind: "else";
|
|
189
194
|
} | {
|
|
@@ -525,17 +530,24 @@ type ConditionTagOptions = {
|
|
|
525
530
|
kind: "if" | "elif";
|
|
526
531
|
/** The `{% if %}` expression, already serialized. */
|
|
527
532
|
expression: string;
|
|
533
|
+
/** The chain the boolean this tag names carries. Only a tag whose whole
|
|
534
|
+
* expression is one field path reads one back, so an expression tag writes
|
|
535
|
+
* none. */
|
|
536
|
+
filters: readonly FilterCall[];
|
|
528
537
|
/** The placement the tag already carried. */
|
|
529
538
|
prefix: MarkerPrefix;
|
|
530
539
|
};
|
|
531
540
|
/**
|
|
532
|
-
* One `{% if %}` / `{% elif %}` opener carrying its expression
|
|
541
|
+
* One `{% if %}` / `{% elif %}` opener carrying its expression, and the field
|
|
542
|
+
* configuration of the boolean it asks for.
|
|
533
543
|
*
|
|
534
544
|
* A rule that decides whether a block shows belongs in the tag that shows it:
|
|
535
545
|
* the tag is the only place a document has for it, so a builder's rule is
|
|
536
|
-
* serialized here rather than stored beside the document under a name.
|
|
546
|
+
* serialized here rather than stored beside the document under a name. The
|
|
547
|
+
* same is true of what to call the question and how to decide it: a condition
|
|
548
|
+
* the document never prints has no value marker to hold that chain either.
|
|
537
549
|
*/
|
|
538
|
-
export declare const renderConditionTag: ({ expression, kind, prefix }: ConditionTagOptions) => string;
|
|
550
|
+
export declare const renderConditionTag: ({ expression, filters, kind, prefix }: ConditionTagOptions) => string;
|
|
539
551
|
type ForOpenerOptions = {
|
|
540
552
|
alias: string;
|
|
541
553
|
path: string;
|
package/dist/index.js
CHANGED
|
@@ -15,14 +15,14 @@ import { Temporal } from "temporal-polyfill/full";
|
|
|
15
15
|
* (`parties.1.name`), and rejecting arrays would silently resolve those to
|
|
16
16
|
* `undefined`.
|
|
17
17
|
*/
|
|
18
|
-
const
|
|
18
|
+
const isNonNullObject = (v) => typeof v === "object" && v !== null;
|
|
19
19
|
/** Resolve a dotted path like `company.name` against data. */
|
|
20
20
|
const resolvePath = (path, data) => {
|
|
21
21
|
if (Object.hasOwn(data, path)) return data[path];
|
|
22
22
|
const parts = path.split(".");
|
|
23
23
|
let current = data;
|
|
24
24
|
for (const part of parts) {
|
|
25
|
-
if (!
|
|
25
|
+
if (!isNonNullObject(current)) return;
|
|
26
26
|
current = current[part];
|
|
27
27
|
}
|
|
28
28
|
return current;
|
|
@@ -55,6 +55,14 @@ const NUMERIC_FUNCTION_NAMES = Object.freeze([
|
|
|
55
55
|
"ceil"
|
|
56
56
|
]);
|
|
57
57
|
const isNumericFunctionName = (name) => NUMERIC_FUNCTION_NAMES.some((candidate) => candidate === name);
|
|
58
|
+
const OPERATORS = [
|
|
59
|
+
"+",
|
|
60
|
+
"-",
|
|
61
|
+
"*",
|
|
62
|
+
"/",
|
|
63
|
+
"%"
|
|
64
|
+
];
|
|
65
|
+
const isOperator = (value) => OPERATORS.some((candidate) => candidate === value);
|
|
58
66
|
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;
|
|
59
67
|
const PRECEDENCE = {
|
|
60
68
|
"+": 1,
|
|
@@ -79,7 +87,7 @@ const tokenize$1 = (expression) => {
|
|
|
79
87
|
else if (punct === "(") tokens.push({ t: "lp" });
|
|
80
88
|
else if (punct === ")") tokens.push({ t: "rp" });
|
|
81
89
|
else if (punct === ",") tokens.push({ t: "comma" });
|
|
82
|
-
else if (punct !== void 0) tokens.push({
|
|
90
|
+
else if (punct !== void 0 && isOperator(punct)) tokens.push({
|
|
83
91
|
t: "op",
|
|
84
92
|
v: punct
|
|
85
93
|
});
|
|
@@ -208,7 +216,7 @@ const RESERVED_WORDS = new Set(CONDITION_RESERVED_WORDS);
|
|
|
208
216
|
const isConditionKeyword = (raw) => raw !== "true" && raw !== "false" && RESERVED_WORDS.has(raw);
|
|
209
217
|
/** The keyword alternation of the tokenizer, derived from the same list. */
|
|
210
218
|
const KEYWORD_PATTERN = CONDITION_RESERVED_WORDS.filter(isConditionKeyword).map((word) => String.raw`${word}\b`).join("|");
|
|
211
|
-
const COMPARE_SYMBOL_PATTERN = Object.keys(COMPARE_SYMBOL_TO_OP).
|
|
219
|
+
const COMPARE_SYMBOL_PATTERN = Object.keys(COMPARE_SYMBOL_TO_OP).toSorted((left, right) => right.length - left.length).map(escapeRegexLiteral).join("|");
|
|
212
220
|
const NON_STRING_TOKEN_RE = new RegExp(String.raw`(?<token>${COMPARE_SYMBOL_PATTERN}|${KEYWORD_PATTERN}|[()]|-?\d[\p{N}_.]*|[\p{L}\p{N}_.]+(?:-[\p{L}\p{N}_.]+)*)`, "uy");
|
|
213
221
|
const STARTS_WITH_DIGIT_RE = /^-?\d/u;
|
|
214
222
|
const isCompareSymbol = (raw) => Object.hasOwn(COMPARE_SYMBOL_TO_OP, raw);
|
|
@@ -945,6 +953,32 @@ const readPrefix = (token) => token === "p" || token === "tr" ? PREFIX_BY_TOKEN[
|
|
|
945
953
|
const TAG_RE = /^(?<tag>[\p{L}_][\p{L}\p{N}_]*)\b(?<rest>[\s\S]*)$/u;
|
|
946
954
|
const FOR_RE = /^(?<alias>[\p{L}_][\p{L}\p{N}_-]*)\s+in\s+(?<path>[\p{L}\p{N}_.-]+)\s*(?<filters>\|[\s\S]*)?$/u;
|
|
947
955
|
const CALL_RE = /^(?<name>[\p{L}_][\p{L}\p{N}_]*)\s*\(/u;
|
|
956
|
+
const CONDITION_RE = /^(?<path>[\p{L}\p{N}_.-]+)\s*(?<filters>\|[\s\S]*)$/u;
|
|
957
|
+
/**
|
|
958
|
+
* Read an `{% if %}` / `{% elif %}` body: one field path plus the chain that
|
|
959
|
+
* configures the boolean it names, or an expression the author wrote.
|
|
960
|
+
*
|
|
961
|
+
* A tail the filter catalogue does not read is part of that expression
|
|
962
|
+
* (`items|length > 0`), never a configuration, so the body stays verbatim
|
|
963
|
+
* rather than being rejected: a tag the grammar refuses would take its whole
|
|
964
|
+
* block with it.
|
|
965
|
+
*/
|
|
966
|
+
const readConditionBody = (rest) => {
|
|
967
|
+
const match = CONDITION_RE.exec(rest);
|
|
968
|
+
if (!match) return {
|
|
969
|
+
expr: rest,
|
|
970
|
+
filters: []
|
|
971
|
+
};
|
|
972
|
+
const chain = scanFilterChain(match.groups?.["filters"] ?? "");
|
|
973
|
+
if (chain === null || "unknownFilter" in chain || chain.filters.length === 0) return {
|
|
974
|
+
expr: rest,
|
|
975
|
+
filters: []
|
|
976
|
+
};
|
|
977
|
+
return {
|
|
978
|
+
expr: match.groups?.["path"] ?? rest,
|
|
979
|
+
filters: chain.filters
|
|
980
|
+
};
|
|
981
|
+
};
|
|
948
982
|
/** Classify a `{% ... %}` tag. */
|
|
949
983
|
const classifyStatement = (inner) => {
|
|
950
984
|
const match = TAG_RE.exec(inner);
|
|
@@ -952,14 +986,22 @@ const classifyStatement = (inner) => {
|
|
|
952
986
|
const tag = match.groups?.["tag"] ?? "";
|
|
953
987
|
const rest = (match.groups?.["rest"] ?? "").trim();
|
|
954
988
|
switch (tag) {
|
|
955
|
-
case "if":
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
}
|
|
989
|
+
case "if": {
|
|
990
|
+
const { expr, filters } = readConditionBody(rest);
|
|
991
|
+
return {
|
|
992
|
+
kind: "if",
|
|
993
|
+
expr,
|
|
994
|
+
filters
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
case "elif": {
|
|
998
|
+
const { expr, filters } = readConditionBody(rest);
|
|
999
|
+
return {
|
|
1000
|
+
kind: "elif",
|
|
1001
|
+
expr,
|
|
1002
|
+
filters
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
963
1005
|
case "else": return rest === "" ? { kind: "else" } : null;
|
|
964
1006
|
case "endif": return rest === "" ? { kind: "endif" } : null;
|
|
965
1007
|
case "endfor": return rest === "" ? { kind: "endfor" } : null;
|
|
@@ -1466,13 +1508,20 @@ const PREFIX_TOKEN = {
|
|
|
1466
1508
|
row: "tr"
|
|
1467
1509
|
};
|
|
1468
1510
|
/**
|
|
1469
|
-
* One `{% if %}` / `{% elif %}` opener carrying its expression
|
|
1511
|
+
* One `{% if %}` / `{% elif %}` opener carrying its expression, and the field
|
|
1512
|
+
* configuration of the boolean it asks for.
|
|
1470
1513
|
*
|
|
1471
1514
|
* A rule that decides whether a block shows belongs in the tag that shows it:
|
|
1472
1515
|
* the tag is the only place a document has for it, so a builder's rule is
|
|
1473
|
-
* serialized here rather than stored beside the document under a name.
|
|
1516
|
+
* serialized here rather than stored beside the document under a name. The
|
|
1517
|
+
* same is true of what to call the question and how to decide it: a condition
|
|
1518
|
+
* the document never prints has no value marker to hold that chain either.
|
|
1474
1519
|
*/
|
|
1475
|
-
const renderConditionTag = ({ expression, kind, prefix }) =>
|
|
1520
|
+
const renderConditionTag = ({ expression, filters, kind, prefix }) => {
|
|
1521
|
+
const chain = renderFilterChain(filters);
|
|
1522
|
+
const head = `{%${PREFIX_TOKEN[prefix]} ${kind} ${expression}`;
|
|
1523
|
+
return chain === "" ? `${head} %}` : `${head} | ${chain} %}`;
|
|
1524
|
+
};
|
|
1476
1525
|
/** One `{% for %}` opener, keeping its placement prefix. */
|
|
1477
1526
|
const renderForOpener = ({ alias, filters, path, prefix }) => {
|
|
1478
1527
|
const chain = renderFilterChain(filters);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stll/template-conditions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "A document-template marker and condition engine: {{...}} marker grammar, parse, compute, serialize, field values.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"condition",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"test": "bun test",
|
|
44
44
|
"test:property": "bun test --preload @stll/property-testing/preload $(grep -rlF 'fc.assert' src --include='*.test.ts')",
|
|
45
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 --
|
|
46
|
+
"lint": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --report-unused-disable-directives-severity=error --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": "bun ../../scripts/run-oxfmt.ts .",
|
|
49
49
|
"prepack": "bun run build"
|
|
@@ -51,13 +51,13 @@
|
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@stll/conditions": "^0.4.0",
|
|
53
53
|
"better-result": "3.0.1",
|
|
54
|
-
"temporal-polyfill": "1.0.
|
|
54
|
+
"temporal-polyfill": "1.0.5"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@stll/property-testing": "0.0.0",
|
|
58
58
|
"@stll/typescript-config": "0.0.0",
|
|
59
59
|
"bun-types": "1.4.2",
|
|
60
|
-
"fast-check": "^4.
|
|
60
|
+
"fast-check": "^4.10.1",
|
|
61
61
|
"tsdown": "0.23.0"
|
|
62
62
|
},
|
|
63
63
|
"main": "./dist/index.js",
|