@spintax/core 0.6.0 → 0.6.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/CHANGELOG.md +44 -0
- package/dist/index.cjs +147 -80
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +147 -80
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,50 @@
|
|
|
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.6.1 — 2026-08-19
|
|
7
|
+
|
|
8
|
+
**The engine no longer throws on deeply nested content** (issue #68). `parse()`, `render()` and
|
|
9
|
+
`analyze()` raised `RangeError: Maximum call stack size exceeded` at about 2000 levels of nesting —
|
|
10
|
+
a **3.9 KB** template — while §9.2 promises a bad construct comes back as text with fullwidth
|
|
11
|
+
braces, never as an exception. No other engine in the family threw.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
Three walks recursed once per level of nesting, and each is a frame stack now:
|
|
16
|
+
|
|
17
|
+
- `parseSequence` — a construct returns a *plan* (its child texts, and how to assemble the node)
|
|
18
|
+
and a stack drives it. The shape mirrors the Python port's `_plan_*` functions, written that way
|
|
19
|
+
from the start for this reason.
|
|
20
|
+
- `renderNodes` — the same. **The RNG order is contract and did not move**: an enumeration picks
|
|
21
|
+
before descending, so an unpicked branch never consumes RNG; a permutation renders every element
|
|
22
|
+
first and consumes its own picks after. Seeded renders are reproducible within the engine (§3.2)
|
|
23
|
+
and corpus fixtures pin exact picks with `rng: { sequence }`.
|
|
24
|
+
- `walk` (`ast.ts`) — pre-order, children in source order, because `analyze`'s `constructs` counts
|
|
25
|
+
and `refs` order come out of it.
|
|
26
|
+
|
|
27
|
+
Fixing only the parser would have moved the wall rather than removed it: with parsing iterative,
|
|
28
|
+
`render` and `analyze` still threw at ~5000. All three now return at 2 000, 9 000 and 50 000 levels.
|
|
29
|
+
|
|
30
|
+
`validate()` and `extract()` never threw, which made this worse than it looked — a caller could not
|
|
31
|
+
pre-screen for it by validating first.
|
|
32
|
+
|
|
33
|
+
### Notes
|
|
34
|
+
|
|
35
|
+
**Output is byte-identical.** 400 generated documents covering every construct, dumped across
|
|
36
|
+
parse / extract / validate×2 / analyze / render×2 / neutralize before and after — no difference.
|
|
37
|
+
The harness itself was corrected first: of three deliberate control mutations it caught two and
|
|
38
|
+
missed one, because the generator never emitted the per-element `[a < or > | b]` separator — which
|
|
39
|
+
is precisely the code this release moved. A green that cannot see the changed line is not evidence.
|
|
40
|
+
|
|
41
|
+
**Deep nesting is still super-linear**, in this engine and in the family: 9 000 levels cost seconds
|
|
42
|
+
here where the PHP engine costs 382 ms, because `findMatchingClose`, the inner slice and
|
|
43
|
+
`splitTopLevel` each rescan a construct's content once per level. Removing that means threading
|
|
44
|
+
spans through five helpers whose separator rules deliberately differ from one another, so it is
|
|
45
|
+
recorded rather than guessed at — the same call as #71. Bounding input remains a host job (§9.3);
|
|
46
|
+
the reference Worker caps a source at 8192 characters.
|
|
47
|
+
|
|
48
|
+
`AST_VERSION` is unchanged — the node shape did not move.
|
|
49
|
+
|
|
6
50
|
## 0.6.0 — 2026-08-18
|
|
7
51
|
|
|
8
52
|
**`validate()` now emits ONE `variable.circular-reference` per NAME that takes part in, or leads
|
package/dist/index.cjs
CHANGED
|
@@ -3,19 +3,32 @@
|
|
|
3
3
|
// src/internal/ast.ts
|
|
4
4
|
var AST_VERSION = 2;
|
|
5
5
|
function walk(nodes, visit) {
|
|
6
|
-
|
|
6
|
+
const stack = [{ list: nodes, i: 0 }];
|
|
7
|
+
while (stack.length > 0) {
|
|
8
|
+
const top = stack[stack.length - 1];
|
|
9
|
+
if (top.i >= top.list.length) {
|
|
10
|
+
stack.pop();
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const n = top.list[top.i];
|
|
14
|
+
top.i += 1;
|
|
7
15
|
visit(n);
|
|
16
|
+
let children;
|
|
8
17
|
switch (n.type) {
|
|
9
18
|
case "enumeration":
|
|
10
|
-
|
|
19
|
+
children = n.options;
|
|
11
20
|
break;
|
|
12
21
|
case "conditional":
|
|
13
|
-
|
|
14
|
-
walk(n.else, visit);
|
|
22
|
+
children = [n.then, n.else];
|
|
15
23
|
break;
|
|
16
24
|
case "permutation":
|
|
17
|
-
|
|
25
|
+
children = n.options.map((opt) => opt.nodes);
|
|
18
26
|
break;
|
|
27
|
+
default:
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
for (let k = children.length - 1; k >= 0; k -= 1) {
|
|
31
|
+
stack.push({ list: children[k], i: 0 });
|
|
19
32
|
}
|
|
20
33
|
}
|
|
21
34
|
}
|
|
@@ -92,65 +105,102 @@ function stripComments(text) {
|
|
|
92
105
|
return text.replace(/\/#[\s\S]*?#\//g, "");
|
|
93
106
|
}
|
|
94
107
|
function parseSequence(text) {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
};
|
|
104
|
-
while (i < text.length) {
|
|
105
|
-
const ch = text.charAt(i);
|
|
106
|
-
if (ch === "{") {
|
|
107
|
-
const end = findMatchingClose(text, i, "{", "}");
|
|
108
|
-
if (end === -1) {
|
|
109
|
-
literal += ch;
|
|
110
|
-
i += 1;
|
|
108
|
+
const frame = (t) => ({ text: t, i: 0, literal: "", nodes: [], plan: null, parts: [] });
|
|
109
|
+
const stack = [frame(text)];
|
|
110
|
+
while (stack.length > 0) {
|
|
111
|
+
const f = stack[stack.length - 1];
|
|
112
|
+
if (f.plan !== null) {
|
|
113
|
+
if (f.parts.length < f.plan.texts.length) {
|
|
114
|
+
stack.push(frame(f.plan.texts[f.parts.length]));
|
|
111
115
|
continue;
|
|
112
116
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
i = end + 1;
|
|
117
|
+
f.nodes.push(f.plan.build(f.parts));
|
|
118
|
+
f.plan = null;
|
|
119
|
+
f.parts = [];
|
|
117
120
|
continue;
|
|
118
121
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
literal
|
|
123
|
-
i += 1;
|
|
124
|
-
continue;
|
|
122
|
+
const flushLiteral = () => {
|
|
123
|
+
if (f.literal !== "") {
|
|
124
|
+
f.nodes.push({ type: "literal", value: f.literal });
|
|
125
|
+
f.literal = "";
|
|
125
126
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
127
|
+
};
|
|
128
|
+
let planned = null;
|
|
129
|
+
while (f.i < f.text.length && planned === null) {
|
|
130
|
+
const ch = f.text.charAt(f.i);
|
|
131
|
+
if (ch === "{" || ch === "[") {
|
|
132
|
+
const close = ch === "{" ? "}" : "]";
|
|
133
|
+
const end = findMatchingClose(f.text, f.i, ch, close);
|
|
134
|
+
if (end === -1) {
|
|
135
|
+
f.literal += ch;
|
|
136
|
+
f.i += 1;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const inner = f.text.slice(f.i + 1, end);
|
|
134
140
|
flushLiteral();
|
|
135
|
-
|
|
136
|
-
i
|
|
141
|
+
planned = ch === "{" ? planBraceConstruct(inner) : planPermutation(inner);
|
|
142
|
+
f.i = end + 1;
|
|
137
143
|
continue;
|
|
138
144
|
}
|
|
145
|
+
if (ch === "%") {
|
|
146
|
+
const name = VARIABLE_RE.exec(f.text.slice(f.i))?.[1];
|
|
147
|
+
if (name !== void 0) {
|
|
148
|
+
flushLiteral();
|
|
149
|
+
f.nodes.push({ type: "variable", name });
|
|
150
|
+
f.i += name.length + 2;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
f.literal += ch;
|
|
155
|
+
f.i += 1;
|
|
139
156
|
}
|
|
140
|
-
|
|
141
|
-
|
|
157
|
+
if (planned !== null) {
|
|
158
|
+
if ("node" in planned) f.nodes.push(planned.node);
|
|
159
|
+
else f.plan = planned;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
flushLiteral();
|
|
163
|
+
stack.pop();
|
|
164
|
+
const parent = stack[stack.length - 1];
|
|
165
|
+
if (parent !== void 0) parent.parts.push(f.nodes);
|
|
166
|
+
else return f.nodes;
|
|
142
167
|
}
|
|
143
|
-
|
|
144
|
-
return nodes;
|
|
168
|
+
return [];
|
|
145
169
|
}
|
|
146
|
-
function
|
|
170
|
+
function planBraceConstruct(content) {
|
|
147
171
|
if (content.charAt(0) === "?") {
|
|
148
|
-
const
|
|
149
|
-
if (
|
|
172
|
+
const parts = splitConditional(content);
|
|
173
|
+
if (parts !== null) {
|
|
174
|
+
return {
|
|
175
|
+
texts: [parts.thenRaw, parts.elseRaw],
|
|
176
|
+
build: (children) => ({
|
|
177
|
+
type: "conditional",
|
|
178
|
+
name: parts.name,
|
|
179
|
+
inverted: parts.inverted,
|
|
180
|
+
then: children[0] ?? [],
|
|
181
|
+
else: children[1] ?? []
|
|
182
|
+
})
|
|
183
|
+
};
|
|
184
|
+
}
|
|
150
185
|
} else if (content.startsWith(PLURAL_PREFIX) && content.slice(PLURAL_PREFIX.length).includes(":")) {
|
|
151
|
-
return parsePlural(content.slice(PLURAL_PREFIX.length));
|
|
186
|
+
return { node: parsePlural(content.slice(PLURAL_PREFIX.length)) };
|
|
152
187
|
}
|
|
153
|
-
return {
|
|
188
|
+
return {
|
|
189
|
+
texts: splitTopLevel(content),
|
|
190
|
+
build: (children) => ({ type: "enumeration", options: children })
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function planPermutation(rawInner) {
|
|
194
|
+
const { config, content } = extractPermutationConfig(rawInner);
|
|
195
|
+
const { texts, separators } = permutationElements(splitTopLevel(content));
|
|
196
|
+
return {
|
|
197
|
+
texts,
|
|
198
|
+
build: (children) => ({
|
|
199
|
+
type: "permutation",
|
|
200
|
+
config,
|
|
201
|
+
options: children.map((nodes, i) => ({ nodes, separator: separators[i] ?? null }))
|
|
202
|
+
})
|
|
203
|
+
};
|
|
154
204
|
}
|
|
155
205
|
function recognizeConditional(text, contentStart, contentEnd) {
|
|
156
206
|
let p = contentStart + 1;
|
|
@@ -179,17 +229,6 @@ function splitConditional(content) {
|
|
|
179
229
|
elseRaw: sep < 0 ? "" : body.slice(sep + 1)
|
|
180
230
|
};
|
|
181
231
|
}
|
|
182
|
-
function tryParseConditional(content) {
|
|
183
|
-
const parts = splitConditional(content);
|
|
184
|
-
if (parts === null) return null;
|
|
185
|
-
return {
|
|
186
|
-
type: "conditional",
|
|
187
|
-
name: parts.name,
|
|
188
|
-
inverted: parts.inverted,
|
|
189
|
-
then: parseSequence(parts.thenRaw),
|
|
190
|
-
else: parseSequence(parts.elseRaw)
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
232
|
function parsePlural(afterPrefix) {
|
|
194
233
|
const colon = afterPrefix.indexOf(":");
|
|
195
234
|
return { type: "plural", countRaw: afterPrefix.slice(0, colon), formsRaw: afterPrefix.slice(colon + 1) };
|
|
@@ -204,11 +243,6 @@ var PER_ELEM_HTML_RE = /^[a-zA-Z][a-zA-Z0-9]*\s/;
|
|
|
204
243
|
function defaultPermConfig() {
|
|
205
244
|
return { minsize: null, maxsize: null, sep: " ", lastsep: null };
|
|
206
245
|
}
|
|
207
|
-
function parsePermutation(rawInner) {
|
|
208
|
-
const { config, content } = extractPermutationConfig(rawInner);
|
|
209
|
-
const options = extractPerElementSeparators(splitTopLevel(content));
|
|
210
|
-
return { type: "permutation", config, options };
|
|
211
|
-
}
|
|
212
246
|
function extractPermutationConfig(content) {
|
|
213
247
|
const trimmed = phpLtrim(content);
|
|
214
248
|
if (trimmed === "" || trimmed.charAt(0) !== "<") {
|
|
@@ -252,8 +286,9 @@ function looksLikeHtmlStartTag(tagText, remaining) {
|
|
|
252
286
|
const tagName = (m[1] ?? "").toLowerCase();
|
|
253
287
|
return new RegExp(`</${escapeRegExp(tagName)}\\s*>`, "iu").test(remaining);
|
|
254
288
|
}
|
|
255
|
-
function
|
|
256
|
-
const
|
|
289
|
+
function permutationElements(rawParts) {
|
|
290
|
+
const texts = [];
|
|
291
|
+
const separators = [];
|
|
257
292
|
let pendingSep = null;
|
|
258
293
|
rawParts.forEach((part, i) => {
|
|
259
294
|
let text = part;
|
|
@@ -267,11 +302,12 @@ function extractPerElementSeparators(rawParts) {
|
|
|
267
302
|
}
|
|
268
303
|
const trimmed = phpTrim(text);
|
|
269
304
|
if (trimmed !== "") {
|
|
270
|
-
|
|
305
|
+
texts.push(trimmed);
|
|
306
|
+
separators.push(pendingSep);
|
|
271
307
|
}
|
|
272
308
|
pendingSep = trailingSep;
|
|
273
309
|
});
|
|
274
|
-
return
|
|
310
|
+
return { texts, separators };
|
|
275
311
|
}
|
|
276
312
|
function extractTrailingSep(part) {
|
|
277
313
|
const trimmed = phpRtrim(part);
|
|
@@ -1016,9 +1052,34 @@ function directReferences(text) {
|
|
|
1016
1052
|
return [...text.matchAll(/%(\w+)%/gu)].map((match) => (match[1] ?? "").toLowerCase());
|
|
1017
1053
|
}
|
|
1018
1054
|
function renderNodes(nodes, opts) {
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1055
|
+
const frame = (list) => ({ nodes: list, i: 0, out: [], pending: null });
|
|
1056
|
+
const stack = [frame(nodes)];
|
|
1057
|
+
while (stack.length > 0) {
|
|
1058
|
+
const f = stack[stack.length - 1];
|
|
1059
|
+
if (f.pending !== null) {
|
|
1060
|
+
if (f.pending.done.length < f.pending.lists.length) {
|
|
1061
|
+
stack.push(frame(f.pending.lists[f.pending.done.length]));
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
f.out.push(f.pending.assemble(f.pending.done));
|
|
1065
|
+
f.pending = null;
|
|
1066
|
+
continue;
|
|
1067
|
+
}
|
|
1068
|
+
if (f.i >= f.nodes.length) {
|
|
1069
|
+
const text = f.out.join("");
|
|
1070
|
+
stack.pop();
|
|
1071
|
+
const parent = stack[stack.length - 1];
|
|
1072
|
+
if (parent === void 0) return text;
|
|
1073
|
+
parent.pending.done.push(text);
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
const node = f.nodes[f.i];
|
|
1077
|
+
f.i += 1;
|
|
1078
|
+
const step = renderNode(node, opts);
|
|
1079
|
+
if (typeof step === "string") f.out.push(step);
|
|
1080
|
+
else f.pending = step;
|
|
1081
|
+
}
|
|
1082
|
+
return "";
|
|
1022
1083
|
}
|
|
1023
1084
|
function renderNode(node, opts) {
|
|
1024
1085
|
switch (node.type) {
|
|
@@ -1070,7 +1131,7 @@ function conditionalTakesThen(name, inverted, opts) {
|
|
|
1070
1131
|
}
|
|
1071
1132
|
function renderConditional(node, opts) {
|
|
1072
1133
|
const truthy = conditionalTakesThen(node.name, node.inverted, opts);
|
|
1073
|
-
return
|
|
1134
|
+
return { lists: [truthy ? node.then : node.else], done: [], assemble: (parts) => parts[0] ?? "" };
|
|
1074
1135
|
}
|
|
1075
1136
|
function resolveCountConditionals(text, opts) {
|
|
1076
1137
|
if (!text.includes("{?")) return text;
|
|
@@ -1155,7 +1216,7 @@ function renderPlural(node, opts) {
|
|
|
1155
1216
|
return fullwidthVerbatim(countRaw, formsRaw);
|
|
1156
1217
|
}
|
|
1157
1218
|
const picked = pluralFor(base, Number.parseInt(count, 10), forms);
|
|
1158
|
-
return
|
|
1219
|
+
return { lists: [parseSequence(picked)], done: [], assemble: (parts) => parts[0] ?? "" };
|
|
1159
1220
|
}
|
|
1160
1221
|
function rawConstruct(countRaw, formsRaw) {
|
|
1161
1222
|
return `{plural ${countRaw}:${formsRaw}}`;
|
|
@@ -1166,13 +1227,19 @@ function fullwidthVerbatim(countRaw, formsRaw) {
|
|
|
1166
1227
|
function renderEnumeration(options, opts) {
|
|
1167
1228
|
if (options.length === 0) return "";
|
|
1168
1229
|
const picked = options[randomInt(opts.rng, 0, options.length - 1)];
|
|
1169
|
-
|
|
1230
|
+
if (!picked) return "";
|
|
1231
|
+
return { lists: [picked], done: [], assemble: (parts) => parts[0] ?? "" };
|
|
1170
1232
|
}
|
|
1171
1233
|
function renderPermutation(node, opts) {
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1234
|
+
if (node.options.length === 0) return "";
|
|
1235
|
+
return {
|
|
1236
|
+
lists: node.options.map((o) => o.nodes),
|
|
1237
|
+
done: [],
|
|
1238
|
+
assemble: (parts) => assemblePermutation(node, parts, opts)
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
function assemblePermutation(node, rendered, opts) {
|
|
1242
|
+
const elements = node.options.map((o, i) => ({ text: rendered[i] ?? "", sep: o.separator }));
|
|
1176
1243
|
const total = elements.length;
|
|
1177
1244
|
if (total === 0) return "";
|
|
1178
1245
|
const { config } = node;
|