@spintax/core 0.6.1 → 0.7.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/CHANGELOG.md +102 -0
- package/README.md +4 -3
- package/dist/index.cjs +54 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +54 -18
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,108 @@
|
|
|
3
3
|
All notable changes to `@spintax/core` are documented here. This project adheres to
|
|
4
4
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
5
|
|
|
6
|
+
## 0.7.0 — 2026-09-12
|
|
7
|
+
|
|
8
|
+
**A `%variable%` written directly inside `{…}` or `[…]` is now spliced as TEXT before the construct
|
|
9
|
+
is split** — so a pipe-joined value is a list of options or elements, as it has always been in the
|
|
10
|
+
PHP engines. Reported from production: a brand preset
|
|
11
|
+
`[<minsize=5;maxsize=7;sep=", ";lastsep=" and ">%CasinoProvidersList%]` over a 57-name runtime
|
|
12
|
+
list rendered all 57 names joined with `|` — no size pick, no shuffle, no separators — in 131
|
|
13
|
+
published rows across 15 tenants. Minor rather than patch: rendered text changes for every template
|
|
14
|
+
of that shape, and `AST_VERSION` moves.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **The defect.** The tree is built before any value exists, so `[<…>%list%]` was ONE element
|
|
19
|
+
holding a variable node; `resolveVariable` handed a construct-free value back as finished text,
|
|
20
|
+
and the `|` that separates elements in every PHP engine — whose `expand_variables` runs over the
|
|
21
|
+
whole text before any bracket is read — was never seen. Same for `{%list%}`, for `[a|%list%|b]`,
|
|
22
|
+
and for a `#set` or `#def` wrapping the construct.
|
|
23
|
+
- **The rule, restated for a tree walk.** The parser keeps a construct's raw body when it holds a
|
|
24
|
+
*direct* reference — at the top level of an option, inside a conditional's branches (the plugin
|
|
25
|
+
resolves `{?…}` before it expands, Stage 6a), or in a separator string (`<sep="%S%">`,
|
|
26
|
+
`a <%S%> |`). At render time such a construct is re-read from its expanded text in the plugin's
|
|
27
|
+
own order: conditionals → expansion → conditionals → parse. Every other construct keeps the tree
|
|
28
|
+
it was parsed into, and with it the RNG order the corpus pins; a spliced value with no structural
|
|
29
|
+
characters re-reads to the same tree, so those renders are unchanged too.
|
|
30
|
+
- **The hop budget inside a bracket equals the one outside.** The textual fixpoint runs the
|
|
31
|
+
plugin's own `<= MAX_VARIABLE_DEPTH` — 51 passes, minus the hops a macro re-parse already spent —
|
|
32
|
+
and whatever it leaves is frozen for the whole subtree, so the plugin's 51 hops hold in every
|
|
33
|
+
shape: the mutual cycle leaves `%b%`, `#set %b% = x%b%y` leaves 51 pairs, a 51-deep chain into
|
|
34
|
+
`x|y` reaches the body as text and is split — inside `{…}` as at top level (#57's pins, moved
|
|
35
|
+
inside). **The plural slots now use the same arithmetic.** They ran a flat 50 passes and rendered
|
|
36
|
+
the picked form unfrozen, so a 51-deep alias chain in the count slot erased a block the plugin
|
|
37
|
+
renders, and a 52-deep chain in a form resolved to its end where the plugin leaves `%a52%`.
|
|
38
|
+
Pre-existing, found by the review gate, closed here.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- `AST_VERSION` 2 → 3: `EnumerationNode` and `PermutationNode` gained `raw`. A handle from an
|
|
43
|
+
older parser would render the old, wrong output, so the version guard turns it into
|
|
44
|
+
`AstVersionError` — the same reason `#def` bumped it. Nothing persists a handle across versions.
|
|
45
|
+
- **Every substitution now charges the expansion budget (#69), as it does in PHP.**
|
|
46
|
+
`resolveVariable` used to charge only construct-bearing values — harmless while a plain value
|
|
47
|
+
could only be a leaf, and the one door left open once a re-read construct could hand it
|
|
48
|
+
references its own fixpoint had cut off: 2^12 references to a 1 KiB value produced 4 MiB out of
|
|
49
|
+
a 1 MiB allowance, and 2^20 is an out-of-memory abort. Found by the review gate before release.
|
|
50
|
+
A template referencing a 65 KB plain list sixteen times now reaches the budget, as it already
|
|
51
|
+
did in PHP; the references past it stay literal.
|
|
52
|
+
- **The truncated shape of an expansion bomb is half what it was.** The substitution at the depth
|
|
53
|
+
cap is charged like every other now (it was free), so the 62-character bomb renders 0.57 MB where it
|
|
54
|
+
rendered 1.14 MB. Not parity-gated — the conformance README says what a truncated explosion looks
|
|
55
|
+
like is engine-specific — and the contract holds unchanged: terminates, never throws, bounded output,
|
|
56
|
+
unaffordable references left literal. The hosted MCP server's output cap now stops that bomb at
|
|
57
|
+
variant 4 rather than 2 (its test moved with it).
|
|
58
|
+
- `neutralize()` docs: the pipe is deliberately not shielded. A neutralized value the author places
|
|
59
|
+
inside `{…}`/`[…]` is split on its `|` in every engine — this one was immune only by the defect.
|
|
60
|
+
|
|
61
|
+
### Corpus
|
|
62
|
+
|
|
63
|
+
Nineteen `splice/*` fixtures in `render-semantics.json`, expected outputs taken from BOTH PHP
|
|
64
|
+
engines (docker, real code): the inline permutation, the `#set` wrapper over a runtime list,
|
|
65
|
+
literals around the variable, `{%L%}`, a conditional branch carrying the list, a `#def` wrapper
|
|
66
|
+
(rolled once, then spliced), `<sep="%S%">`, `lastsep="%S%"` and a per-element `<%S%>`, a macro
|
|
67
|
+
value with brackets AND a pipe, three hop-budget pins (a 50-alias chain into `x|y` split on the 51st
|
|
68
|
+
hop inside `{…}`, the same chain reaching its number in a plural count slot, a 51-alias chain left
|
|
69
|
+
literal in a form), the production
|
|
70
|
+
preset shape — and three negatives: a top-level reference is NOT split, an undefined name stays one
|
|
71
|
+
literal element, a nested construct splits at its own level only. No prior fixture had a variable
|
|
72
|
+
inside a construct, which is how this hid in four tree-walk engines (TS, Python, Object Pascal,
|
|
73
|
+
.NET) while the PHP engines were right all along. The siblings fail the new cases until they mirror
|
|
74
|
+
the rule.
|
|
75
|
+
|
|
76
|
+
### Notes
|
|
77
|
+
|
|
78
|
+
**What stays as it was, on purpose.** Two pre-existing tree-walk divergences in the *non-triggered*
|
|
79
|
+
path are unchanged: an element that renders to empty (a nested conditional yielding `''`) is kept
|
|
80
|
+
here and dropped by PHP, and PHP's top-level scan splits the fullwidth plural fallback on its pipes.
|
|
81
|
+
Both pathological, neither reported. A *triggered* construct drops the empty element as PHP does,
|
|
82
|
+
because the text is what PHP sees — and trims a conditional's taken branch at an element's edge,
|
|
83
|
+
as the plugin trims after Stage 6a. The fullwidth fallback is the exception in BOTH paths: the plugin
|
|
84
|
+
resolves plurals before it reads a bracket, so a fallback's ASCII pipes reach the enclosing construct
|
|
85
|
+
there, while here the re-read makes the plural a node again and it renders whole. Only a template
|
|
86
|
+
`validate()` already rejects (`plural.arity`, `plural.nested-brackets`) can reach it; recorded in the
|
|
87
|
+
conformance README under the known divergences rather than closed (review finding, kept open on
|
|
88
|
+
purpose).
|
|
89
|
+
|
|
90
|
+
**The hop budget, corrected in review.** The first cut gave the fixpoint 50 passes and rendered the
|
|
91
|
+
leftover at the depth cap, which spliced it once more as finished text: a 52nd hop when reached through
|
|
92
|
+
a macro, and one that hid a structural terminal value from the split (a 51-deep chain into `x|y`
|
|
93
|
+
inside `{…}` rendered `x|y`, where the plugin's 51st pass yields `{x|y}` and picks). Now the fixpoint
|
|
94
|
+
runs the plugin's own `<= MAX_VARIABLE_DEPTH` — 51 passes, minus the hops a macro re-parse already
|
|
95
|
+
spent — and whatever is left is frozen for the whole subtree.
|
|
96
|
+
|
|
97
|
+
**Verified.** The 0.6.1 build against this one over generated documents (seeded LCG, every
|
|
98
|
+
construct, `render` in both post-process modes, `analyze`, `extract`) — the harness first shown to
|
|
99
|
+
catch two deliberate control mutations. Documents with no direct reference anywhere: byte-identical,
|
|
100
|
+
and `analyze`/`extract` identical on every document. Documents whose constructs DO carry a direct
|
|
101
|
+
reference, with plain values and no conditional: byte-identical over 1 788 renders and three seeds —
|
|
102
|
+
that is the claim that a spliced value with no structural characters re-reads to the same tree. With
|
|
103
|
+
conditionals and construct-bearing definition values allowed, 159 of 922 triggered renders differ, in
|
|
104
|
+
exactly the three ways the PHP text has always differed from the old tree: whitespace only (a taken
|
|
105
|
+
branch trimmed at an element's edge), an element that became empty dropped before the shuffle (so
|
|
106
|
+
every later draw shifts), or a `|` carried in by a rolled `#def`.
|
|
107
|
+
|
|
6
108
|
## 0.6.1 — 2026-08-19
|
|
7
109
|
|
|
8
110
|
**The engine no longer throws on deeply nested content** (issue #68). `parse()`, `render()` and
|
package/README.md
CHANGED
|
@@ -14,11 +14,12 @@ parse, render, validate, extract, analyze, and neutralize spintax templates.
|
|
|
14
14
|
|
|
15
15
|
- **Zero runtime dependencies.** Runs unchanged on Cloudflare Workers, Node 18+, and in the browser.
|
|
16
16
|
- **ESM-first, dual CJS.** Ships `.d.ts` types for both.
|
|
17
|
-
- **One syntax contract, [
|
|
17
|
+
- **One syntax contract, [six independent engines](https://spintax.net/spintax-engines/).**
|
|
18
18
|
This package, [`spintax/core`](https://packagist.org/packages/spintax/core) (PHP),
|
|
19
|
-
[`spintax-core`](https://pypi.org/project/spintax-core/) (Python), an Object Pascal engine,
|
|
19
|
+
[`spintax-core`](https://pypi.org/project/spintax-core/) (Python), an Object Pascal engine,
|
|
20
|
+
[`Spintax.Core`](https://www.nuget.org/packages/Spintax.Core/) (.NET), and
|
|
20
21
|
the [Spintax WordPress plugin](https://wordpress.org/plugins/spintax/) are all held to the same
|
|
21
|
-
behavior contract by a **shared golden corpus of
|
|
22
|
+
behavior contract by a **shared golden corpus of 258 fixtures** — deterministic verdicts, plural
|
|
22
23
|
buckets, conditionals, `#set`/`#def` semantics and post-processing agree everywhere, so a
|
|
23
24
|
template is an asset you can move between runtimes, not a lock-in.
|
|
24
25
|
- **MIT** licensed.
|
package/dist/index.cjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// src/internal/ast.ts
|
|
4
|
-
var AST_VERSION =
|
|
4
|
+
var AST_VERSION = 3;
|
|
5
5
|
function walk(nodes, visit) {
|
|
6
6
|
const stack = [{ list: nodes, i: 0 }];
|
|
7
7
|
while (stack.length > 0) {
|
|
@@ -187,19 +187,31 @@ function planBraceConstruct(content) {
|
|
|
187
187
|
}
|
|
188
188
|
return {
|
|
189
189
|
texts: splitTopLevel(content),
|
|
190
|
-
build: (children) => ({ type: "enumeration", options: children }
|
|
190
|
+
build: (children) => hasDirectReference(children) ? { type: "enumeration", options: children, raw: content } : { type: "enumeration", options: children }
|
|
191
191
|
};
|
|
192
192
|
}
|
|
193
|
+
function hasDirectReference(lists) {
|
|
194
|
+
const stack = [...lists];
|
|
195
|
+
while (stack.length > 0) {
|
|
196
|
+
const list = stack.pop();
|
|
197
|
+
for (const node of list) {
|
|
198
|
+
if (node.type === "variable") return true;
|
|
199
|
+
if (node.type === "conditional") stack.push(node.then, node.else);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
var REFERENCE_RE = /%\w+%/u;
|
|
193
205
|
function planPermutation(rawInner) {
|
|
194
206
|
const { config, content } = extractPermutationConfig(rawInner);
|
|
195
207
|
const { texts, separators } = permutationElements(splitTopLevel(content));
|
|
208
|
+
const separatorHasRef = REFERENCE_RE.test(config.sep) || config.lastsep !== null && REFERENCE_RE.test(config.lastsep) || separators.some((sep) => sep !== null && REFERENCE_RE.test(sep));
|
|
196
209
|
return {
|
|
197
210
|
texts,
|
|
198
|
-
build: (children) =>
|
|
199
|
-
|
|
200
|
-
config,
|
|
201
|
-
|
|
202
|
-
})
|
|
211
|
+
build: (children) => {
|
|
212
|
+
const options = children.map((nodes, i) => ({ nodes, separator: separators[i] ?? null }));
|
|
213
|
+
return separatorHasRef || hasDirectReference(children) ? { type: "permutation", config, options, raw: rawInner } : { type: "permutation", config, options };
|
|
214
|
+
}
|
|
203
215
|
};
|
|
204
216
|
}
|
|
205
217
|
function recognizeConditional(text, contentStart, contentEnd) {
|
|
@@ -1088,7 +1100,7 @@ function renderNode(node, opts) {
|
|
|
1088
1100
|
case "variable":
|
|
1089
1101
|
return resolveVariable(node.name, opts);
|
|
1090
1102
|
case "enumeration":
|
|
1091
|
-
return renderEnumeration(node
|
|
1103
|
+
return renderEnumeration(node, opts);
|
|
1092
1104
|
case "permutation":
|
|
1093
1105
|
return renderPermutation(node, opts);
|
|
1094
1106
|
case "conditional":
|
|
@@ -1102,15 +1114,20 @@ function randomInt(rng, min, max) {
|
|
|
1102
1114
|
}
|
|
1103
1115
|
function resolveVariable(name, opts) {
|
|
1104
1116
|
const value = opts.vars[name.toLowerCase()];
|
|
1105
|
-
if (value === void 0) return `%${name}%`;
|
|
1106
|
-
if (opts.depth >= MAX_VARIABLE_DEPTH || !/[{[%]/u.test(value)) return value;
|
|
1117
|
+
if (value === void 0 || opts.frozen === true) return `%${name}%`;
|
|
1107
1118
|
if (opts.budget.left <= 0) return `%${name}%`;
|
|
1108
1119
|
opts.budget.left -= value.length;
|
|
1120
|
+
if (opts.depth >= MAX_VARIABLE_DEPTH || !/[{[%]/u.test(value)) return value;
|
|
1109
1121
|
return renderNodes(parseSequence(value), { ...opts, depth: opts.depth + 1 });
|
|
1110
1122
|
}
|
|
1111
|
-
function
|
|
1123
|
+
function passesLeft(opts) {
|
|
1124
|
+
return Math.max(1, MAX_VARIABLE_DEPTH - opts.depth + 1);
|
|
1125
|
+
}
|
|
1126
|
+
function expandVarsFixpoint(text, opts, passes) {
|
|
1127
|
+
if (opts.frozen === true) return { text, changed: false, converged: true };
|
|
1112
1128
|
let out = text;
|
|
1113
|
-
|
|
1129
|
+
let changedAny = false;
|
|
1130
|
+
for (let i = 0; i < passes; i += 1) {
|
|
1114
1131
|
let changed = false;
|
|
1115
1132
|
out = out.replace(/%(\w+)%/gu, (m, name) => {
|
|
1116
1133
|
const value = opts.vars[name.toLowerCase()];
|
|
@@ -1120,9 +1137,10 @@ function expandVarsOnly(text, opts) {
|
|
|
1120
1137
|
changed = true;
|
|
1121
1138
|
return value;
|
|
1122
1139
|
});
|
|
1123
|
-
if (!changed)
|
|
1140
|
+
if (!changed) return { text: out, changed: changedAny, converged: true };
|
|
1141
|
+
changedAny = true;
|
|
1124
1142
|
}
|
|
1125
|
-
return out;
|
|
1143
|
+
return { text: out, changed: changedAny, converged: false };
|
|
1126
1144
|
}
|
|
1127
1145
|
function conditionalTakesThen(name, inverted, opts) {
|
|
1128
1146
|
const value = opts.vars[name.toLowerCase()];
|
|
@@ -1133,7 +1151,7 @@ function renderConditional(node, opts) {
|
|
|
1133
1151
|
const truthy = conditionalTakesThen(node.name, node.inverted, opts);
|
|
1134
1152
|
return { lists: [truthy ? node.then : node.else], done: [], assemble: (parts) => parts[0] ?? "" };
|
|
1135
1153
|
}
|
|
1136
|
-
function
|
|
1154
|
+
function resolveConditionalsInText(text, opts) {
|
|
1137
1155
|
if (!text.includes("{?")) return text;
|
|
1138
1156
|
const close = matchBraces(text);
|
|
1139
1157
|
const out = [];
|
|
@@ -1180,8 +1198,11 @@ function matchBraces(text) {
|
|
|
1180
1198
|
return close;
|
|
1181
1199
|
}
|
|
1182
1200
|
function renderPlural(node, opts) {
|
|
1183
|
-
const
|
|
1184
|
-
const
|
|
1201
|
+
const passes = passesLeft(opts);
|
|
1202
|
+
const countPass = expandVarsFixpoint(node.countRaw, opts, passes);
|
|
1203
|
+
const formsPass = expandVarsFixpoint(node.formsRaw, opts, passes);
|
|
1204
|
+
const countRaw = resolveConditionalsInText(countPass.text, opts);
|
|
1205
|
+
const formsRaw = formsPass.text;
|
|
1185
1206
|
const base = normalizeBaseLang(opts.locale);
|
|
1186
1207
|
const report = (issue) => opts.onPluralError?.(issue);
|
|
1187
1208
|
if (/[{}[\]]/u.test(formsRaw)) {
|
|
@@ -1216,6 +1237,7 @@ function renderPlural(node, opts) {
|
|
|
1216
1237
|
return fullwidthVerbatim(countRaw, formsRaw);
|
|
1217
1238
|
}
|
|
1218
1239
|
const picked = pluralFor(base, Number.parseInt(count, 10), forms);
|
|
1240
|
+
if (!formsPass.converged) return renderNodes(parseSequence(picked), { ...opts, frozen: true });
|
|
1219
1241
|
return { lists: [parseSequence(picked)], done: [], assemble: (parts) => parts[0] ?? "" };
|
|
1220
1242
|
}
|
|
1221
1243
|
function rawConstruct(countRaw, formsRaw) {
|
|
@@ -1224,13 +1246,27 @@ function rawConstruct(countRaw, formsRaw) {
|
|
|
1224
1246
|
function fullwidthVerbatim(countRaw, formsRaw) {
|
|
1225
1247
|
return rawConstruct(countRaw, formsRaw).replace(/\{/gu, "\uFF5B").replace(/\}/gu, "\uFF5D");
|
|
1226
1248
|
}
|
|
1227
|
-
function renderEnumeration(
|
|
1249
|
+
function renderEnumeration(node, opts) {
|
|
1250
|
+
const spliced = node.raw === void 0 ? null : spliceConstruct(node.raw, "{", "}", opts);
|
|
1251
|
+
if (spliced !== null) return spliced;
|
|
1252
|
+
const { options } = node;
|
|
1228
1253
|
if (options.length === 0) return "";
|
|
1229
1254
|
const picked = options[randomInt(opts.rng, 0, options.length - 1)];
|
|
1230
1255
|
if (!picked) return "";
|
|
1231
1256
|
return { lists: [picked], done: [], assemble: (parts) => parts[0] ?? "" };
|
|
1232
1257
|
}
|
|
1258
|
+
function spliceConstruct(raw, open, close, opts) {
|
|
1259
|
+
if (opts.frozen === true) return null;
|
|
1260
|
+
const expanded = expandVarsFixpoint(resolveConditionalsInText(raw, opts), opts, passesLeft(opts));
|
|
1261
|
+
const body = resolveConditionalsInText(expanded.text, opts);
|
|
1262
|
+
if (body === raw) return null;
|
|
1263
|
+
const nodes = parseSequence(open + body + close);
|
|
1264
|
+
if (!expanded.converged) return renderNodes(nodes, { ...opts, frozen: true });
|
|
1265
|
+
return { lists: [nodes], done: [], assemble: (parts) => parts[0] ?? "" };
|
|
1266
|
+
}
|
|
1233
1267
|
function renderPermutation(node, opts) {
|
|
1268
|
+
const spliced = node.raw === void 0 ? null : spliceConstruct(node.raw, "[", "]", opts);
|
|
1269
|
+
if (spliced !== null) return spliced;
|
|
1234
1270
|
if (node.options.length === 0) return "";
|
|
1235
1271
|
return {
|
|
1236
1272
|
lists: node.options.map((o) => o.nodes),
|