@spintax/core 0.6.0 → 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 CHANGED
@@ -3,6 +3,152 @@
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
+
108
+ ## 0.6.1 — 2026-08-19
109
+
110
+ **The engine no longer throws on deeply nested content** (issue #68). `parse()`, `render()` and
111
+ `analyze()` raised `RangeError: Maximum call stack size exceeded` at about 2000 levels of nesting —
112
+ a **3.9 KB** template — while §9.2 promises a bad construct comes back as text with fullwidth
113
+ braces, never as an exception. No other engine in the family threw.
114
+
115
+ ### Fixed
116
+
117
+ Three walks recursed once per level of nesting, and each is a frame stack now:
118
+
119
+ - `parseSequence` — a construct returns a *plan* (its child texts, and how to assemble the node)
120
+ and a stack drives it. The shape mirrors the Python port's `_plan_*` functions, written that way
121
+ from the start for this reason.
122
+ - `renderNodes` — the same. **The RNG order is contract and did not move**: an enumeration picks
123
+ before descending, so an unpicked branch never consumes RNG; a permutation renders every element
124
+ first and consumes its own picks after. Seeded renders are reproducible within the engine (§3.2)
125
+ and corpus fixtures pin exact picks with `rng: { sequence }`.
126
+ - `walk` (`ast.ts`) — pre-order, children in source order, because `analyze`'s `constructs` counts
127
+ and `refs` order come out of it.
128
+
129
+ Fixing only the parser would have moved the wall rather than removed it: with parsing iterative,
130
+ `render` and `analyze` still threw at ~5000. All three now return at 2 000, 9 000 and 50 000 levels.
131
+
132
+ `validate()` and `extract()` never threw, which made this worse than it looked — a caller could not
133
+ pre-screen for it by validating first.
134
+
135
+ ### Notes
136
+
137
+ **Output is byte-identical.** 400 generated documents covering every construct, dumped across
138
+ parse / extract / validate×2 / analyze / render×2 / neutralize before and after — no difference.
139
+ The harness itself was corrected first: of three deliberate control mutations it caught two and
140
+ missed one, because the generator never emitted the per-element `[a < or > | b]` separator — which
141
+ is precisely the code this release moved. A green that cannot see the changed line is not evidence.
142
+
143
+ **Deep nesting is still super-linear**, in this engine and in the family: 9 000 levels cost seconds
144
+ here where the PHP engine costs 382 ms, because `findMatchingClose`, the inner slice and
145
+ `splitTopLevel` each rescan a construct's content once per level. Removing that means threading
146
+ spans through five helpers whose separator rules deliberately differ from one another, so it is
147
+ recorded rather than guessed at — the same call as #71. Bounding input remains a host job (§9.3);
148
+ the reference Worker caps a source at 8192 characters.
149
+
150
+ `AST_VERSION` is unchanged — the node shape did not move.
151
+
6
152
  ## 0.6.0 — 2026-08-18
7
153
 
8
154
  **`validate()` now emits ONE `variable.circular-reference` per NAME that takes part in, or leads
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, [five independent engines](https://spintax.net/spintax-engines/).**
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, and
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 234 fixtures** — deterministic verdicts, plural
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,21 +1,34 @@
1
1
  'use strict';
2
2
 
3
3
  // src/internal/ast.ts
4
- var AST_VERSION = 2;
4
+ var AST_VERSION = 3;
5
5
  function walk(nodes, visit) {
6
- for (const n of nodes) {
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
- for (const opt of n.options) walk(opt, visit);
19
+ children = n.options;
11
20
  break;
12
21
  case "conditional":
13
- walk(n.then, visit);
14
- walk(n.else, visit);
22
+ children = [n.then, n.else];
15
23
  break;
16
24
  case "permutation":
17
- for (const opt of n.options) walk(opt.nodes, visit);
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,114 @@ function stripComments(text) {
92
105
  return text.replace(/\/#[\s\S]*?#\//g, "");
93
106
  }
94
107
  function parseSequence(text) {
95
- const nodes = [];
96
- let literal = "";
97
- let i = 0;
98
- const flushLiteral = () => {
99
- if (literal !== "") {
100
- nodes.push({ type: "literal", value: literal });
101
- literal = "";
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
- const content = text.slice(i + 1, end);
114
- flushLiteral();
115
- nodes.push(parseBraceConstruct(content));
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
- if (ch === "[") {
120
- const end = findMatchingClose(text, i, "[", "]");
121
- if (end === -1) {
122
- literal += ch;
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
- flushLiteral();
127
- nodes.push(parsePermutation(text.slice(i + 1, end)));
128
- i = end + 1;
129
- continue;
130
- }
131
- if (ch === "%") {
132
- const name = VARIABLE_RE.exec(text.slice(i))?.[1];
133
- if (name !== void 0) {
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
- nodes.push({ type: "variable", name });
136
- i += name.length + 2;
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
- literal += ch;
141
- i += 1;
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
- flushLiteral();
144
- return nodes;
168
+ return [];
145
169
  }
146
- function parseBraceConstruct(content) {
170
+ function planBraceConstruct(content) {
147
171
  if (content.charAt(0) === "?") {
148
- const cond = tryParseConditional(content);
149
- if (cond) return cond;
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 { type: "enumeration", options: splitTopLevel(content).map((o) => parseSequence(o)) };
188
+ return {
189
+ texts: splitTopLevel(content),
190
+ build: (children) => hasDirectReference(children) ? { type: "enumeration", options: children, raw: content } : { type: "enumeration", options: children }
191
+ };
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;
205
+ function planPermutation(rawInner) {
206
+ const { config, content } = extractPermutationConfig(rawInner);
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));
209
+ return {
210
+ texts,
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
+ }
215
+ };
154
216
  }
155
217
  function recognizeConditional(text, contentStart, contentEnd) {
156
218
  let p = contentStart + 1;
@@ -179,17 +241,6 @@ function splitConditional(content) {
179
241
  elseRaw: sep < 0 ? "" : body.slice(sep + 1)
180
242
  };
181
243
  }
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
244
  function parsePlural(afterPrefix) {
194
245
  const colon = afterPrefix.indexOf(":");
195
246
  return { type: "plural", countRaw: afterPrefix.slice(0, colon), formsRaw: afterPrefix.slice(colon + 1) };
@@ -204,11 +255,6 @@ var PER_ELEM_HTML_RE = /^[a-zA-Z][a-zA-Z0-9]*\s/;
204
255
  function defaultPermConfig() {
205
256
  return { minsize: null, maxsize: null, sep: " ", lastsep: null };
206
257
  }
207
- function parsePermutation(rawInner) {
208
- const { config, content } = extractPermutationConfig(rawInner);
209
- const options = extractPerElementSeparators(splitTopLevel(content));
210
- return { type: "permutation", config, options };
211
- }
212
258
  function extractPermutationConfig(content) {
213
259
  const trimmed = phpLtrim(content);
214
260
  if (trimmed === "" || trimmed.charAt(0) !== "<") {
@@ -252,8 +298,9 @@ function looksLikeHtmlStartTag(tagText, remaining) {
252
298
  const tagName = (m[1] ?? "").toLowerCase();
253
299
  return new RegExp(`</${escapeRegExp(tagName)}\\s*>`, "iu").test(remaining);
254
300
  }
255
- function extractPerElementSeparators(rawParts) {
256
- const options = [];
301
+ function permutationElements(rawParts) {
302
+ const texts = [];
303
+ const separators = [];
257
304
  let pendingSep = null;
258
305
  rawParts.forEach((part, i) => {
259
306
  let text = part;
@@ -267,11 +314,12 @@ function extractPerElementSeparators(rawParts) {
267
314
  }
268
315
  const trimmed = phpTrim(text);
269
316
  if (trimmed !== "") {
270
- options.push({ nodes: parseSequence(trimmed), separator: pendingSep });
317
+ texts.push(trimmed);
318
+ separators.push(pendingSep);
271
319
  }
272
320
  pendingSep = trailingSep;
273
321
  });
274
- return options;
322
+ return { texts, separators };
275
323
  }
276
324
  function extractTrailingSep(part) {
277
325
  const trimmed = phpRtrim(part);
@@ -1016,9 +1064,34 @@ function directReferences(text) {
1016
1064
  return [...text.matchAll(/%(\w+)%/gu)].map((match) => (match[1] ?? "").toLowerCase());
1017
1065
  }
1018
1066
  function renderNodes(nodes, opts) {
1019
- let out = "";
1020
- for (const node of nodes) out += renderNode(node, opts);
1021
- return out;
1067
+ const frame = (list) => ({ nodes: list, i: 0, out: [], pending: null });
1068
+ const stack = [frame(nodes)];
1069
+ while (stack.length > 0) {
1070
+ const f = stack[stack.length - 1];
1071
+ if (f.pending !== null) {
1072
+ if (f.pending.done.length < f.pending.lists.length) {
1073
+ stack.push(frame(f.pending.lists[f.pending.done.length]));
1074
+ continue;
1075
+ }
1076
+ f.out.push(f.pending.assemble(f.pending.done));
1077
+ f.pending = null;
1078
+ continue;
1079
+ }
1080
+ if (f.i >= f.nodes.length) {
1081
+ const text = f.out.join("");
1082
+ stack.pop();
1083
+ const parent = stack[stack.length - 1];
1084
+ if (parent === void 0) return text;
1085
+ parent.pending.done.push(text);
1086
+ continue;
1087
+ }
1088
+ const node = f.nodes[f.i];
1089
+ f.i += 1;
1090
+ const step = renderNode(node, opts);
1091
+ if (typeof step === "string") f.out.push(step);
1092
+ else f.pending = step;
1093
+ }
1094
+ return "";
1022
1095
  }
1023
1096
  function renderNode(node, opts) {
1024
1097
  switch (node.type) {
@@ -1027,7 +1100,7 @@ function renderNode(node, opts) {
1027
1100
  case "variable":
1028
1101
  return resolveVariable(node.name, opts);
1029
1102
  case "enumeration":
1030
- return renderEnumeration(node.options, opts);
1103
+ return renderEnumeration(node, opts);
1031
1104
  case "permutation":
1032
1105
  return renderPermutation(node, opts);
1033
1106
  case "conditional":
@@ -1041,15 +1114,20 @@ function randomInt(rng, min, max) {
1041
1114
  }
1042
1115
  function resolveVariable(name, opts) {
1043
1116
  const value = opts.vars[name.toLowerCase()];
1044
- if (value === void 0) return `%${name}%`;
1045
- if (opts.depth >= MAX_VARIABLE_DEPTH || !/[{[%]/u.test(value)) return value;
1117
+ if (value === void 0 || opts.frozen === true) return `%${name}%`;
1046
1118
  if (opts.budget.left <= 0) return `%${name}%`;
1047
1119
  opts.budget.left -= value.length;
1120
+ if (opts.depth >= MAX_VARIABLE_DEPTH || !/[{[%]/u.test(value)) return value;
1048
1121
  return renderNodes(parseSequence(value), { ...opts, depth: opts.depth + 1 });
1049
1122
  }
1050
- function expandVarsOnly(text, opts) {
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 };
1051
1128
  let out = text;
1052
- for (let i = 0; i < MAX_VARIABLE_DEPTH; i += 1) {
1129
+ let changedAny = false;
1130
+ for (let i = 0; i < passes; i += 1) {
1053
1131
  let changed = false;
1054
1132
  out = out.replace(/%(\w+)%/gu, (m, name) => {
1055
1133
  const value = opts.vars[name.toLowerCase()];
@@ -1059,9 +1137,10 @@ function expandVarsOnly(text, opts) {
1059
1137
  changed = true;
1060
1138
  return value;
1061
1139
  });
1062
- if (!changed) break;
1140
+ if (!changed) return { text: out, changed: changedAny, converged: true };
1141
+ changedAny = true;
1063
1142
  }
1064
- return out;
1143
+ return { text: out, changed: changedAny, converged: false };
1065
1144
  }
1066
1145
  function conditionalTakesThen(name, inverted, opts) {
1067
1146
  const value = opts.vars[name.toLowerCase()];
@@ -1070,9 +1149,9 @@ function conditionalTakesThen(name, inverted, opts) {
1070
1149
  }
1071
1150
  function renderConditional(node, opts) {
1072
1151
  const truthy = conditionalTakesThen(node.name, node.inverted, opts);
1073
- return renderNodes(truthy ? node.then : node.else, opts);
1152
+ return { lists: [truthy ? node.then : node.else], done: [], assemble: (parts) => parts[0] ?? "" };
1074
1153
  }
1075
- function resolveCountConditionals(text, opts) {
1154
+ function resolveConditionalsInText(text, opts) {
1076
1155
  if (!text.includes("{?")) return text;
1077
1156
  const close = matchBraces(text);
1078
1157
  const out = [];
@@ -1119,8 +1198,11 @@ function matchBraces(text) {
1119
1198
  return close;
1120
1199
  }
1121
1200
  function renderPlural(node, opts) {
1122
- const countRaw = resolveCountConditionals(expandVarsOnly(node.countRaw, opts), opts);
1123
- const formsRaw = expandVarsOnly(node.formsRaw, opts);
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;
1124
1206
  const base = normalizeBaseLang(opts.locale);
1125
1207
  const report = (issue) => opts.onPluralError?.(issue);
1126
1208
  if (/[{}[\]]/u.test(formsRaw)) {
@@ -1155,7 +1237,8 @@ function renderPlural(node, opts) {
1155
1237
  return fullwidthVerbatim(countRaw, formsRaw);
1156
1238
  }
1157
1239
  const picked = pluralFor(base, Number.parseInt(count, 10), forms);
1158
- return renderNodes(parseSequence(picked), opts);
1240
+ if (!formsPass.converged) return renderNodes(parseSequence(picked), { ...opts, frozen: true });
1241
+ return { lists: [parseSequence(picked)], done: [], assemble: (parts) => parts[0] ?? "" };
1159
1242
  }
1160
1243
  function rawConstruct(countRaw, formsRaw) {
1161
1244
  return `{plural ${countRaw}:${formsRaw}}`;
@@ -1163,16 +1246,36 @@ function rawConstruct(countRaw, formsRaw) {
1163
1246
  function fullwidthVerbatim(countRaw, formsRaw) {
1164
1247
  return rawConstruct(countRaw, formsRaw).replace(/\{/gu, "\uFF5B").replace(/\}/gu, "\uFF5D");
1165
1248
  }
1166
- function renderEnumeration(options, opts) {
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;
1167
1253
  if (options.length === 0) return "";
1168
1254
  const picked = options[randomInt(opts.rng, 0, options.length - 1)];
1169
- return picked ? renderNodes(picked, opts) : "";
1255
+ if (!picked) return "";
1256
+ return { lists: [picked], done: [], assemble: (parts) => parts[0] ?? "" };
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] ?? "" };
1170
1266
  }
1171
1267
  function renderPermutation(node, opts) {
1172
- const elements = node.options.map((o) => ({
1173
- text: renderNodes(o.nodes, opts),
1174
- sep: o.separator
1175
- }));
1268
+ const spliced = node.raw === void 0 ? null : spliceConstruct(node.raw, "[", "]", opts);
1269
+ if (spliced !== null) return spliced;
1270
+ if (node.options.length === 0) return "";
1271
+ return {
1272
+ lists: node.options.map((o) => o.nodes),
1273
+ done: [],
1274
+ assemble: (parts) => assemblePermutation(node, parts, opts)
1275
+ };
1276
+ }
1277
+ function assemblePermutation(node, rendered, opts) {
1278
+ const elements = node.options.map((o, i) => ({ text: rendered[i] ?? "", sep: o.separator }));
1176
1279
  const total = elements.length;
1177
1280
  if (total === 0) return "";
1178
1281
  const { config } = node;