@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 +146 -0
- package/README.md +4 -3
- package/dist/index.cjs +195 -92
- 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 +195 -92
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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, [
|
|
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,21 +1,34 @@
|
|
|
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
|
+
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,114 @@ 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) => 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
|
|
256
|
-
const
|
|
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
|
-
|
|
317
|
+
texts.push(trimmed);
|
|
318
|
+
separators.push(pendingSep);
|
|
271
319
|
}
|
|
272
320
|
pendingSep = trailingSep;
|
|
273
321
|
});
|
|
274
|
-
return
|
|
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
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
|
1152
|
+
return { lists: [truthy ? node.then : node.else], done: [], assemble: (parts) => parts[0] ?? "" };
|
|
1074
1153
|
}
|
|
1075
|
-
function
|
|
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
|
|
1123
|
-
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;
|
|
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(
|
|
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
|
-
|
|
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
|
|
1173
|
-
|
|
1174
|
-
|
|
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;
|