@spintax/core 0.5.3 → 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 +86 -0
- package/dist/index.cjs +191 -114
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +191 -114
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,92 @@
|
|
|
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
|
+
|
|
50
|
+
## 0.6.0 — 2026-08-18
|
|
51
|
+
|
|
52
|
+
**`validate()` now emits ONE `variable.circular-reference` per NAME that takes part in, or leads
|
|
53
|
+
to, a cycle** — it used to emit one per PATH (issue #59). Minor rather than patch: diagnostic
|
|
54
|
+
output visibly changes, though no verdict moves and no API does.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
Per-path emission is exponential on a converging diamond, because the number of routes into a
|
|
59
|
+
cycle is exponential in the diamond's depth:
|
|
60
|
+
|
|
61
|
+
| shape | template | before | after |
|
|
62
|
+
|---|---|---|---|
|
|
63
|
+
| 20 definitions, each referencing the previous one twice, feeding a 2-cycle | **507 B** | **2 097 152 diagnostics, 5.9 s** | **22 diagnostics, 6 ms** |
|
|
64
|
+
| the same at depth 200 | 5 KB | (never finished) | 202 diagnostics, ms |
|
|
65
|
+
| live `POST /validate-template` | **547 B** | **HTTP 503** | 200, with a verdict |
|
|
66
|
+
|
|
67
|
+
The second half of the same issue: one cycle of N names printed an N-name route in each of N
|
|
68
|
+
messages. A 43 KB template of one giant cycle carried **29 MB of message text**; the route is now
|
|
69
|
+
capped at 8 names and becomes a count — `n0 → n1 → … → n7 → … (1992 more)` — which brings that to
|
|
70
|
+
200 KB. A real cycle is two to five names and reads exactly as it did.
|
|
71
|
+
|
|
72
|
+
**Messages are unchanged for every shape a human writes.** The colour walk that already existed as
|
|
73
|
+
a prune now also records one witness edge per name, and following those edges reproduces the route
|
|
74
|
+
the per-path walk used to print: `a → b → a`, `d0 → c1 → c2 → c1`. Only the duplicates are gone.
|
|
75
|
+
`Diagnostic.data` carries `{ name }` so a consumer can group without parsing the message.
|
|
76
|
+
|
|
77
|
+
### Notes
|
|
78
|
+
|
|
79
|
+
This reverses a deliberate decision, which is why it is recorded rather than quietly shipped. The
|
|
80
|
+
per-path shape was pinned on purpose in three engines' own suites — "an implementation that
|
|
81
|
+
deduplicates repeated edges, memoizes per root, or reorders roots goes red here, not silently
|
|
82
|
+
green" — and `spintax-win` aligned to it on 2026-08-07. It could not be kept and bounded: re-walking
|
|
83
|
+
every route *is* the emission. `spintax-core` (Python) already emitted per name and was the one
|
|
84
|
+
engine immune; the rest followed it, and its counts are now the reference — verified by diffing 400
|
|
85
|
+
generated definition graphs against it, and against `spintax/core`, with zero mismatches.
|
|
86
|
+
|
|
87
|
+
Mirrored into `spintax/core` 0.8.0 and the WordPress plugin. The corpus gains a fixture for the
|
|
88
|
+
diamond whose real assertion is that the engine *answers*; it cannot gate the count, because
|
|
89
|
+
diagnostics are asserted as a subset — and `packages/conformance/README.md` now says that
|
|
90
|
+
multiplicity is deliberately not part of the contract.
|
|
91
|
+
|
|
6
92
|
## 0.5.3 — 2026-08-18
|
|
7
93
|
|
|
8
94
|
The expansion budget added in 0.5.2 was created per rendered AST, and an `#include` renders one
|
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;
|
|
156
|
+
}
|
|
157
|
+
if (planned !== null) {
|
|
158
|
+
if ("node" in planned) f.nodes.push(planned.node);
|
|
159
|
+
else f.plan = planned;
|
|
160
|
+
continue;
|
|
139
161
|
}
|
|
140
|
-
|
|
141
|
-
|
|
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);
|
|
@@ -750,9 +786,14 @@ function checkVariableReferences(text, idx, known, out) {
|
|
|
750
786
|
for (const m of value.matchAll(/%(\w+)%/gu)) refs.push((m[1] ?? "").toLowerCase());
|
|
751
787
|
refsOf.set(name, refs);
|
|
752
788
|
}
|
|
753
|
-
const reachesCycle = namesThatReachACycle(defs, refsOf);
|
|
789
|
+
const { reaches: reachesCycle, via } = namesThatReachACycle(defs, refsOf);
|
|
754
790
|
for (const name of defs.keys()) {
|
|
755
|
-
|
|
791
|
+
if (!reachesCycle.has(name)) continue;
|
|
792
|
+
out.push(err(
|
|
793
|
+
"variable.circular-reference",
|
|
794
|
+
`Circular variable reference: ${cyclePath(name, via)}.`,
|
|
795
|
+
{ ...defPos.get(name) ?? somewhere, data: { name } }
|
|
796
|
+
));
|
|
756
797
|
}
|
|
757
798
|
const body = text.replace(/^[ \t]*#(?:set|def)[ \t]+%\w+%[ \t]*=[ \t]*.*?$/gmu, (m) => m.replace(/[^\n]/g, " "));
|
|
758
799
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -776,6 +817,12 @@ function namesThatReachACycle(defs, refsOf) {
|
|
|
776
817
|
const BLACK = 2;
|
|
777
818
|
const color = /* @__PURE__ */ new Map();
|
|
778
819
|
const reaches = /* @__PURE__ */ new Set();
|
|
820
|
+
const via = /* @__PURE__ */ new Map();
|
|
821
|
+
const mark = (name, witness) => {
|
|
822
|
+
if (reaches.has(name)) return;
|
|
823
|
+
reaches.add(name);
|
|
824
|
+
via.set(name, witness);
|
|
825
|
+
};
|
|
779
826
|
for (const root of defs.keys()) {
|
|
780
827
|
if (color.has(root)) continue;
|
|
781
828
|
const stack = [{ name: root, refs: refsOf.get(root) ?? [], i: 0 }];
|
|
@@ -787,9 +834,9 @@ function namesThatReachACycle(defs, refsOf) {
|
|
|
787
834
|
top.i += 1;
|
|
788
835
|
if (ref === top.name || !defs.has(ref)) continue;
|
|
789
836
|
const c = color.get(ref);
|
|
790
|
-
if (c === GREY)
|
|
837
|
+
if (c === GREY) mark(top.name, ref);
|
|
791
838
|
else if (c === BLACK) {
|
|
792
|
-
if (reaches.has(ref))
|
|
839
|
+
if (reaches.has(ref)) mark(top.name, ref);
|
|
793
840
|
} else {
|
|
794
841
|
stack.push({ name: ref, refs: refsOf.get(ref) ?? [], i: 0 });
|
|
795
842
|
color.set(ref, GREY);
|
|
@@ -797,42 +844,41 @@ function namesThatReachACycle(defs, refsOf) {
|
|
|
797
844
|
} else {
|
|
798
845
|
stack.pop();
|
|
799
846
|
color.set(top.name, BLACK);
|
|
800
|
-
if (stack.length > 0 && reaches.has(top.name))
|
|
847
|
+
if (stack.length > 0 && reaches.has(top.name)) mark(stack[stack.length - 1].name, top.name);
|
|
801
848
|
}
|
|
802
849
|
}
|
|
803
850
|
}
|
|
804
|
-
return reaches;
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
const
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
const top = stack[stack.length - 1];
|
|
818
|
-
if (top.i >= top.refs.length) {
|
|
819
|
-
leave(top);
|
|
820
|
-
continue;
|
|
851
|
+
return { reaches, via };
|
|
852
|
+
}
|
|
853
|
+
var CYCLE_PATH_LIMIT = 8;
|
|
854
|
+
function cyclePath(name, via) {
|
|
855
|
+
const seen = /* @__PURE__ */ new Set([name]);
|
|
856
|
+
const shown = [name];
|
|
857
|
+
let current = name;
|
|
858
|
+
for (; ; ) {
|
|
859
|
+
const next = via.get(current);
|
|
860
|
+
if (next === void 0) break;
|
|
861
|
+
if (seen.has(next)) {
|
|
862
|
+
shown.push(next);
|
|
863
|
+
return shown.join(" \u2192 ");
|
|
821
864
|
}
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
path.push(ref);
|
|
865
|
+
if (shown.length >= CYCLE_PATH_LIMIT) {
|
|
866
|
+
let more = 0;
|
|
867
|
+
let walk2 = next;
|
|
868
|
+
while (!seen.has(walk2)) {
|
|
869
|
+
seen.add(walk2);
|
|
870
|
+
more += 1;
|
|
871
|
+
const step = via.get(walk2);
|
|
872
|
+
if (step === void 0) break;
|
|
873
|
+
walk2 = step;
|
|
874
|
+
}
|
|
875
|
+
return `${shown.join(" \u2192 ")} \u2192 \u2026 (${more} more)`;
|
|
834
876
|
}
|
|
877
|
+
seen.add(next);
|
|
878
|
+
shown.push(next);
|
|
879
|
+
current = next;
|
|
835
880
|
}
|
|
881
|
+
return shown.join(" \u2192 ");
|
|
836
882
|
}
|
|
837
883
|
function checkIncludeTargets(text, idx, known, out) {
|
|
838
884
|
const set = new Set(known);
|
|
@@ -1006,9 +1052,34 @@ function directReferences(text) {
|
|
|
1006
1052
|
return [...text.matchAll(/%(\w+)%/gu)].map((match) => (match[1] ?? "").toLowerCase());
|
|
1007
1053
|
}
|
|
1008
1054
|
function renderNodes(nodes, opts) {
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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 "";
|
|
1012
1083
|
}
|
|
1013
1084
|
function renderNode(node, opts) {
|
|
1014
1085
|
switch (node.type) {
|
|
@@ -1060,7 +1131,7 @@ function conditionalTakesThen(name, inverted, opts) {
|
|
|
1060
1131
|
}
|
|
1061
1132
|
function renderConditional(node, opts) {
|
|
1062
1133
|
const truthy = conditionalTakesThen(node.name, node.inverted, opts);
|
|
1063
|
-
return
|
|
1134
|
+
return { lists: [truthy ? node.then : node.else], done: [], assemble: (parts) => parts[0] ?? "" };
|
|
1064
1135
|
}
|
|
1065
1136
|
function resolveCountConditionals(text, opts) {
|
|
1066
1137
|
if (!text.includes("{?")) return text;
|
|
@@ -1145,7 +1216,7 @@ function renderPlural(node, opts) {
|
|
|
1145
1216
|
return fullwidthVerbatim(countRaw, formsRaw);
|
|
1146
1217
|
}
|
|
1147
1218
|
const picked = pluralFor(base, Number.parseInt(count, 10), forms);
|
|
1148
|
-
return
|
|
1219
|
+
return { lists: [parseSequence(picked)], done: [], assemble: (parts) => parts[0] ?? "" };
|
|
1149
1220
|
}
|
|
1150
1221
|
function rawConstruct(countRaw, formsRaw) {
|
|
1151
1222
|
return `{plural ${countRaw}:${formsRaw}}`;
|
|
@@ -1156,13 +1227,19 @@ function fullwidthVerbatim(countRaw, formsRaw) {
|
|
|
1156
1227
|
function renderEnumeration(options, opts) {
|
|
1157
1228
|
if (options.length === 0) return "";
|
|
1158
1229
|
const picked = options[randomInt(opts.rng, 0, options.length - 1)];
|
|
1159
|
-
|
|
1230
|
+
if (!picked) return "";
|
|
1231
|
+
return { lists: [picked], done: [], assemble: (parts) => parts[0] ?? "" };
|
|
1160
1232
|
}
|
|
1161
1233
|
function renderPermutation(node, opts) {
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
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 }));
|
|
1166
1243
|
const total = elements.length;
|
|
1167
1244
|
if (total === 0) return "";
|
|
1168
1245
|
const { config } = node;
|