@spintax/core 0.6.1 → 0.8.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 +463 -0
- package/README.md +11 -5
- package/dist/index.cjs +883 -282
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +14 -1
- package/dist/index.d.ts +14 -1
- package/dist/index.js +883 -282
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,469 @@
|
|
|
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.8.0 — 2026-09-13
|
|
7
|
+
|
|
8
|
+
Two defects found by the ports while they mirrored 0.7.0, both places where this engine read a
|
|
9
|
+
template differently from the PHP engines — and both measured on those engines before anything
|
|
10
|
+
changed: the character classes of the post-process, and the key that decides which constructs are
|
|
11
|
+
re-read as text (#80).
|
|
12
|
+
|
|
13
|
+
**The post-process reads characters the way PHP does.** PHP compiles every pattern of the cosmetic
|
|
14
|
+
stage with `/u`, and `/u` turns on PCRE2_UCP: `\s`, `\d`, `\w` and `\b` are Unicode classes there.
|
|
15
|
+
This engine took them for ASCII — the belief was written into its own comments ("no PCRE_UCP") — and
|
|
16
|
+
the three ports that copied the classes did the same. Measured on both PHP engines:
|
|
17
|
+
|
|
18
|
+
| input | 0.7.0 | both PHP engines, and now this one |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| `и т.д. и т.п. всё` | `И т. Д. И т. П. Всё` | `И т.д. и т.п. всё` |
|
|
21
|
+
| `открой пример.рф сегодня` | `Открой пример. Рф сегодня` | `Открой пример.рф сегодня` |
|
|
22
|
+
| `пишите на info@сайт.рф` | `Пишите на info@сайт. Рф` | `Пишите на info@сайт.рф` |
|
|
23
|
+
| `end.<NBSP>next` | `End. <NBSP>next` | `End.<NBSP>Next` |
|
|
24
|
+
| `в 5<NBSP>тыс.<NBSP>руб.` | `В 5<NBSP>тыс. <NBSP>руб.` | `В 5<NBSP>тыс.<NBSP>руб.` |
|
|
25
|
+
|
|
26
|
+
**A conditional or a config reference inside `{…}`/`[…]` is text before the split, too (#80).** 0.7.0
|
|
27
|
+
re-read a construct as text when a `%var%` sat directly in it, and decided that from a key narrower
|
|
28
|
+
than its own rule:
|
|
29
|
+
|
|
30
|
+
| template | 0.7.0 | both PHP engines, and now this one |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `[<minsize=%n%;maxsize=%n%>a\|b\|c]`, `n=1` | all three elements | one element |
|
|
33
|
+
| `[<sep=%S%>a\|b]`, `S=", "` | `b a` | `b, a` |
|
|
34
|
+
| `[{?f?a\|b\|x}\|c]` | `c b\|x` — a raw pipe | three elements |
|
|
35
|
+
| `[<sep=", ";lastsep=" and ">{?f?live casino}\|slots\|poker]` | `slots, poker and ` | `poker and slots` |
|
|
36
|
+
| `[<sep=", ">slots\|{live casino\|}\|poker]`, the empty option picked | `slots, , poker` | `slots, poker` |
|
|
37
|
+
|
|
38
|
+
Minor rather than patch: rendered text changes for every template of these shapes, and
|
|
39
|
+
`AST_VERSION` moves to 4.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- **The post-process classes are PHP's UCP classes** (`internal/charclass.ts`). Whitespace is PCRE2's
|
|
44
|
+
`\s` — NBSP, the thin spaces, NEL — and not JavaScript's, which adds U+FEFF and misses U+0085 and
|
|
45
|
+
U+180E. `\b` counts a letter of any script as a word character. The digit in the two spacing
|
|
46
|
+
lookaheads is any `\p{Nd}`. The decimal shield stays ASCII: PHP writes that one pattern without `/u`.
|
|
47
|
+
- **Conditional truthiness uses the same whitespace.** A value of U+FEFF alone is truthy (it was blank
|
|
48
|
+
here); a value of U+0085 alone is blank (it was truthy).
|
|
49
|
+
- **The permutation-config patterns go the other way.** PHP writes them without `/u`, so their `\s` is
|
|
50
|
+
ASCII, while JavaScript's `\s` is Unicode on any flags. A no-break space around `=` is no longer
|
|
51
|
+
config whitespace — `[<minsize<NBSP>=<NBSP>1>a|b|c]` is one separator, as in PHP — and the validator
|
|
52
|
+
agrees: `[<foo<NBSP>=1>a|b]` is valid, `[<minsize=2<NBSP>>a|b]` is `permutation.minsize-not-integer`.
|
|
53
|
+
- **A reference anywhere in a permutation's `<config>` marks it for the re-read** (#80). 0.7.0 tested
|
|
54
|
+
the PARSED `sep` and `lastsep`, where a size reference never arrives (a size that is not digits
|
|
55
|
+
parses to nothing) and neither does an unquoted separator (it parses to the default). The raw
|
|
56
|
+
header is tested now, so `minsize=%n%`, `maxsize=%n%` and `sep=%S%` take their values from the
|
|
57
|
+
context, as they always have in PHP. So does a whole `{?…}` there or in a per-element separator —
|
|
58
|
+
`[<lastsep="{?en? and | и }">a|b|c]` printed the raw conditional unless a neighbour happened to
|
|
59
|
+
trigger the re-read (found in review). A bare `{?` does not mark: the first cut of that check made
|
|
60
|
+
every level of `[<{?}>a|[<{?}>a|…]]` rescan its body for a conditional that was not there, twice
|
|
61
|
+
0.7.0's time on deep nesting (found by the Codex gate).
|
|
62
|
+
- **A `{?…}` conditional directly in `{…}`/`[…]` marks the construct** (#80), whatever its branches
|
|
63
|
+
hold — 0.7.0 marked it only when a `%var%` sat in a branch. The plugin resolves conditionals at
|
|
64
|
+
Stage 6a, before any bracket is read, so a taken branch's `|` separates options, an empty branch
|
|
65
|
+
leaves an empty permutation element that is dropped, and whitespace at a branch's edge is the
|
|
66
|
+
element's edge. 0.7.0's notes kept the empty element on purpose as a pathological divergence; a
|
|
67
|
+
list item gated by a flag is an ordinary template, and in one it rendered `Есть покер, слоты и.`.
|
|
68
|
+
- **A permutation element is its rendered text, trimmed, and one that renders empty is dropped** (#80).
|
|
69
|
+
The plugin resolves every nested enumeration before it splits a permutation, so an optional item
|
|
70
|
+
written `{live casino|}` that picks its empty option leaves `slots||poker` there — two elements —
|
|
71
|
+
while this engine kept a blank third and printed `slots, , poker`; `[<minsize=3;maxsize=3>a|{b|}|c]`
|
|
72
|
+
counted three and printed `a c`. The conditional fix above did not reach it: the re-read drops a
|
|
73
|
+
part that is empty as TEXT, and `{b|}` is not empty until it is rendered. Found by the differential
|
|
74
|
+
below after the conditional half was in; the size pick and the shuffle count the elements that
|
|
75
|
+
remain, and an element that is neither empty nor padded draws as before.
|
|
76
|
+
- **`AST_VERSION` 3 → 4.** The node shape is 3's, but an `Ast` cached by 0.7.0 lacks `raw` exactly
|
|
77
|
+
where the wider key puts it and would render the old output; the guard turns that into
|
|
78
|
+
`AstVersionError`. Nothing persists a handle across versions.
|
|
79
|
+
- **Three patterns no longer scan a long run once per character** (found in review). Trimming each
|
|
80
|
+
assembled element with `/[…]+$/` retried from every position of a whitespace run inside the text —
|
|
81
|
+
the first cut of the element fix made `[a%v%y|z]` over 51 000 spaces take 3 s, and 13 s from a
|
|
82
|
+
362-byte macro template; the PHP trims are loops now, in the parser as well, where the same regex
|
|
83
|
+
already cost seconds on a re-read. And two post-process patterns that were quadratic IN 0.7.0 got
|
|
84
|
+
new triggers from the wider whitespace class: removing whitespace before punctuation rescanned a run
|
|
85
|
+
with no punctuation after it (100 000 form feeds: 10 s in 0.7.0; a macro of NBSP or U+3000: 10–31 s
|
|
86
|
+
on the first cut), and capitalizing after a line break rescanned a run of breaks and spaces with no
|
|
87
|
+
letter after it (20 000 of `\n` plus a space: 4 s in 0.7.0). Each now starts a match only where the
|
|
88
|
+
run starts — the same matches, checked over 500 000 generated strings — and all of these take
|
|
89
|
+
milliseconds.
|
|
90
|
+
- **`parse()` and `render()` no longer throw on a long tag-shaped permutation config.** Whether
|
|
91
|
+
`[<name>…]` opens with an HTML tag was decided by compiling `</name\s*>` from the name itself, and V8
|
|
92
|
+
will not compile that past about 7.8 KB of name: `SyntaxError: Invalid regular expression … Stack
|
|
93
|
+
overflow`, straight out of `parse()`. A 7.8 KB source was enough — under the reference Worker's 8 KB
|
|
94
|
+
cap — and so were 333 bytes of `#set` doubling a letter inside a re-read config, which spell the name
|
|
95
|
+
at render time. §9.2 says the engine never throws on content; 0.7.0 threw on both.
|
|
96
|
+
The closing tag is found by a scan now, matching exactly what the pattern matched: any case, and the
|
|
97
|
+
two non-ASCII letters `iu` folds into ASCII (U+017F, U+212A). Found by the scaling probe behind the
|
|
98
|
+
note on template scans below.
|
|
99
|
+
- **`constructor` and `__proto__` are ordinary variable names.** The variable and definition maps were
|
|
100
|
+
plain JavaScript objects, so both names found `Object`'s own members, and assigning `__proto__` stored
|
|
101
|
+
nothing. `render('a %constructor% b')` threw `TypeError` — §9.2 lets `render()` throw only on a resolver
|
|
102
|
+
or a foreign `Ast` — while `validate()` called the name merely undefined, so nothing could screen it out.
|
|
103
|
+
`%__proto__%` rendered as nothing, `{?constructor?…}` and `{?__proto__?…}` took the then-branch, a
|
|
104
|
+
`#set`, a `#def` or a context value under `__proto__` was lost, a runtime list under it spliced
|
|
105
|
+
`[object Object]`, and the validator withheld `plural.arity`, `plural.count-macro` and
|
|
106
|
+
`plural.nested-brackets` for a `#set` of either name — valid where both PHP validators say invalid.
|
|
107
|
+
Both PHP engines read variables from arrays and always treated them as ordinary names. The maps have
|
|
108
|
+
no prototype now (`internal/name-map.ts`). Found by the Codex gate on the nesting rewrite, where
|
|
109
|
+
`%__proto__%` had left the expansion budget `NaN`.
|
|
110
|
+
|
|
111
|
+
### Changed, visibly
|
|
112
|
+
|
|
113
|
+
- A no-break space behaves like a space around punctuation: removed before `,.;:!?`, no second space
|
|
114
|
+
inserted after one, and the next sentence capitalized through it. French typography with U+202F
|
|
115
|
+
before `!` or `?` loses it — as a plain `bonjour !` always has, in every engine.
|
|
116
|
+
- A glued Cyrillic sentence stays glued: `конец.Начало` matches the bare-domain shield in PHP and now
|
|
117
|
+
here, exactly as `compact.Game` always has in ASCII. Whether the shield should take a capitalized
|
|
118
|
+
TLD at all is #79.
|
|
119
|
+
|
|
120
|
+
### Corpus
|
|
121
|
+
|
|
122
|
+
Twenty fixtures, every expectation taken from BOTH PHP engines (docker, real code): fifteen in
|
|
123
|
+
`render-postprocess.json` — Cyrillic multi-dot abbreviations, an IDN domain, email and TLD, NBSP before
|
|
124
|
+
and after punctuation and after a whitelisted abbreviation, a URL that ends at NBSP, an Arabic-Indic
|
|
125
|
+
digit, NEL as whitespace and U+FEFF as not, a glued Cyrillic sentence, `_` before an abbreviation — plus
|
|
126
|
+
`conditional/bom-only-is-truthy`, `conditional/nel-only-is-falsy`,
|
|
127
|
+
`perm/config-nbsp-is-not-config-whitespace` and its two `validate/*` twins. Nineteen fail on 0.7.0; the
|
|
128
|
+
twentieth is a negative guard. No earlier fixture put a non-ASCII character next to a boundary or a
|
|
129
|
+
whitespace class — every domain, email and multi-dot case was Latin — which is how a premise stated in
|
|
130
|
+
a comment stayed green for two months. `postprocess.test.ts` had pinned the mangled `т.д.` "in both
|
|
131
|
+
engines", with an assertion that passed on the capital letter alone.
|
|
132
|
+
|
|
133
|
+
Seven more `splice/*` fixtures for #80, again from both PHP engines: a size and an unquoted separator
|
|
134
|
+
taken from a variable, a taken branch carrying a pipe in `[…]` and in `{…}`, a flag-gated list item,
|
|
135
|
+
a branch trimmed at an element's edge — six fail on 0.7.0 — and the negative
|
|
136
|
+
`splice/conditional-without-pipes-keeps-draws`, which pins that a conditional changing nothing
|
|
137
|
+
structural draws exactly where 0.7.0 drew. Three `perm/*` fixtures for an element that renders empty or
|
|
138
|
+
padded: `perm/nested-empty-option-drops-element`, `perm/nested-option-edge-whitespace-trimmed` and
|
|
139
|
+
`perm/dropped-element-narrows-the-size-range`, all failing on 0.7.0. Four from the review, all failing on
|
|
140
|
+
0.7.0: `splice/conditional-in-per-element-separator`, `splice/conditional-in-config-lastsep`, and the
|
|
141
|
+
separator of a dropped element — `perm/dropped-element-takes-its-separator` (`[a<1>|{x|}|b]` is `a b`)
|
|
142
|
+
and `perm/dropped-element-passes-on-its-trailing-separator` (`[a<1>|{x|}<2>|b]` is `a2b`).
|
|
143
|
+
|
|
144
|
+
**The corpus can pin how many** (#74). `validate` cases take an optional `diagnosticCount` — the exact
|
|
145
|
+
number of diagnostics per code — where the subset match used to be the only assertion, and that
|
|
146
|
+
subset match is what passed two million circular-reference diagnostics for eleven days (#59). Two
|
|
147
|
+
cases carry it: `validate/cycle-diamond-terminates` (22, one per name) and the new
|
|
148
|
+
`validate/plural-count-macro-per-reference`, which records #73's decision: `plural.count-macro` once
|
|
149
|
+
per tainted reference in the count slot, as this engine, `spintax-core` and both PHP validators already
|
|
150
|
+
emit it. No engine output changed for either.
|
|
151
|
+
|
|
152
|
+
Twelve fixtures for the prototype names, every expectation taken from both PHP engines: eight in
|
|
153
|
+
`render-semantics.json` — undefined references, conditionals, a `#set` of each name, context keys, a
|
|
154
|
+
`#def` rolled once, a `#def` naming them undefined, a runtime list spliced into a permutation, a form
|
|
155
|
+
slot — and four in `validate.json`: the three withheld plural verdicts and the negative
|
|
156
|
+
`validate/prototype-names-defined-and-undefined`. Eleven fail on 0.7.0. A JSON-decoded context carries
|
|
157
|
+
`__proto__` as an ordinary key in every engine's harness; a JavaScript object literal would not.
|
|
158
|
+
|
|
159
|
+
### Notes
|
|
160
|
+
|
|
161
|
+
**Cost.** Against 0.7.0, the post-process of this whole batch is faster on prose and on HTML blocks and
|
|
162
|
+
about a third slower on shield-heavy text — V8 runs a Unicode-class lookbehind more slowly than its ASCII
|
|
163
|
+
`\b`, and every shield pattern carries one now. Per 1 000 calls, the four builds side by side:
|
|
164
|
+
|
|
165
|
+
| input | 0.7.0 | the classes alone (regexes) | first linear scanners | now |
|
|
166
|
+
|---|---|---|---|---|
|
|
167
|
+
| prose, 857 characters | 34 ms | 32 ms | 108 ms | 19 ms |
|
|
168
|
+
| HTML blocks, about 700 characters | 33 ms | 31 ms | 86 ms | 27 ms |
|
|
169
|
+
| a URL, email, domain, decimal and abbreviation line, 237 characters | 36 ms | 46 ms | 70 ms | 49 ms |
|
|
170
|
+
| the same line ×3 200, 756 KB, once | 113 ms | 151 ms | 223 ms | 158 ms |
|
|
171
|
+
|
|
172
|
+
**The post-process is linear — and was a live denial of service in every release before this.** Eight
|
|
173
|
+
of its passes were global regex replaces that a long run made retry from every start inside it: the
|
|
174
|
+
email shield (one long word), the bare-domain shield (a dotted chain in any script), the
|
|
175
|
+
trailing-punctuation cut of a URL, the space after a run of sentence marks, and the three capitalizers
|
|
176
|
+
after a sentence end, a block tag and a line break (an unclosed `<`, a run of `<p>`). None of those
|
|
177
|
+
runs needs a long template: 336 bytes of `#set` doubling one letter expand to 131 000 characters, whose
|
|
178
|
+
post-process took 25 s, and the dotted, mark and tag units ran longer — so any host rendering untrusted
|
|
179
|
+
templates with post-process on, the reference Worker and the hosted MCP server among them, could be held
|
|
180
|
+
for as long as its CPU limit allowed. The expansion budget bounded the size of that text, never the time
|
|
181
|
+
to post-process it. One more stage was quadratic on its own terms (found by the Codex gate): the restore
|
|
182
|
+
of a text that carries U+0000, where #54 keeps the reference loop's reading — one `split`/`join` per
|
|
183
|
+
placeholder — so a few hundred bytes doubling a NUL and a decimal took 2.1 s at 16 000 decimals, four
|
|
184
|
+
times that per doubling.
|
|
185
|
+
|
|
186
|
+
The shields and the capitalizers are scanners now. They run the plugin's own patterns at the same starts,
|
|
187
|
+
in the same order, and skip only starts that provably fail. Every start inside one run of email-local
|
|
188
|
+
characters meets the same `@` or none, so the email shield works back from each `@` — a text without one
|
|
189
|
+
costs nothing. An attempt that fails at the start of a chain of labels fails at every later start in that
|
|
190
|
+
chain, because the chain's own labels prefix any match further in, so the domain shield is one regex that
|
|
191
|
+
matches at the starts it tries and consumes the chain when there is no domain — and is skipped when no dot
|
|
192
|
+
in the text is followed by a label's first character. A lead has exactly one tokenization — a tag ends at
|
|
193
|
+
the first `>` — so the capitalizers find their boundaries natively, walk a short lead, and build an index of
|
|
194
|
+
where each lead ends, from the right, only when a lead holds a tag or runs past 32 characters; one index
|
|
195
|
+
serves all three passes unless a capital grew the text (`ß` → `SS`). The URL cut is a loop, and the
|
|
196
|
+
sentence-mark space starts a match only where its run starts. Output is byte-identical: 7.9 million strings
|
|
197
|
+
against the regex implementation — exhaustive over small alphabets aimed at each pass, plus random soup — and
|
|
198
|
+
5.8 million more aimed at the final form of each scanner, with every real control mutation caught, and the
|
|
199
|
+
20 000-input post-process differential against both PHP engines unchanged at zero. Those macro templates now
|
|
200
|
+
render half a megabyte to a megabyte in 0.1–0.2 s.
|
|
201
|
+
|
|
202
|
+
The first cut of these scanners paid for linearity on every call: a regex call per word, a JavaScript step
|
|
203
|
+
per character, three times — 3.4 times the regex passes on a short paragraph (the table above). The scaling
|
|
204
|
+
bench, one large text, could not show that, and this note first said the bench was no slower on prose. The
|
|
205
|
+
shapes above are what came after measuring a thousand short renders instead.
|
|
206
|
+
|
|
207
|
+
The NUL-path restore computes the loop's result in one pass. An occurrence of a key is always a stretch
|
|
208
|
+
of the original text between two `\x00`s — no stored value holds a `\x00`, and no whole stored value
|
|
209
|
+
fits inside a key name — so the only way one replacement touches another is by taking a delimiter they
|
|
210
|
+
share, and visiting the candidates in the loop's key order, each only while both its delimiters survive,
|
|
211
|
+
is the loop. 1.9 million NUL-carrying strings come out identical, three control mutations were caught,
|
|
212
|
+
and the 16 000-decimal case takes 130 ms.
|
|
213
|
+
|
|
214
|
+
**The template scans are linear too — and part of that was live the same way.** They had the
|
|
215
|
+
post-process's flaw. The parser matched each `{` and `[` by counting forward to its closer, which
|
|
216
|
+
for an opener that never closes is the end of the text, from every such opener; the plural scan did the
|
|
217
|
+
same for `{plural …}`, and the validator ran `/\[<([^>]*?)>/` from every `[<`. Lazy or overlapping
|
|
218
|
+
patterns restarted from every character of a run in four more places: a `/# … #/` comment that never
|
|
219
|
+
closes, a `#set`/`#def` value with a long whitespace run inside, a tag-shaped config holding a quoted
|
|
220
|
+
`>`, and the validator's config-key test on a long word. The re-read of a construct hands the parser
|
|
221
|
+
whatever its macros spell, so the parser's half was reachable from a few hundred bytes:
|
|
222
|
+
|
|
223
|
+
| input (Node 22) | 0.7.0 | now |
|
|
224
|
+
|---|---|---|
|
|
225
|
+
| 333–341 bytes of `#set` spelling `[<`, `{?a?`, `[<li>`, `[<sep="` or `{plural 1:` inside `{…}` — render | 1.3–6.2 s, ×4 per doubling | 63–117 ms |
|
|
226
|
+
| 387 bytes spelling form feeds into a tag-shaped config that holds a quoted `>` — render | 8.6 s, ×4 per doubling | 117 ms |
|
|
227
|
+
| `[<` repeated, 32 KB — render / validate | 1.4 s / 0.27 s | 29 ms / 23 ms |
|
|
228
|
+
| `{plural 1:` repeated, 32 KB — render / validate | 0.32 s / 0.20 s | 41 ms / 17 ms |
|
|
229
|
+
| `/#a` repeated, 32 KB — parse / validate | 0.14 s / 0.14 s | 10 ms / 12 ms |
|
|
230
|
+
| a `#set` value holding 32 KB of spaces — parse / validate | 2.8 s / 11.8 s | 4 ms / 14 ms |
|
|
231
|
+
| `[<a`, 32 KB of spaces, a quoted `>` — parse | 2.0 s | 10 ms |
|
|
232
|
+
| `[<` and a 32 KB word `>` — validate | 0.9 s | 10 ms |
|
|
233
|
+
|
|
234
|
+
Brackets are paired once for the whole text, and the plural scan and the renderer's conditional pass use
|
|
235
|
+
the same tables — the deep-nesting note below says how a construct is read now. The comment strip and the
|
|
236
|
+
config scan are `indexOf` loops. The directive value ends at its last non-blank character instead of
|
|
237
|
+
growing lazily, the tag pattern takes one whitespace character where two runs used to trade them, and a
|
|
238
|
+
config key starts only where a word does — each matching exactly what it matched before. Output is
|
|
239
|
+
byte-identical to the code before: 4.3 million generated strings through `parse`, `render` (three rng
|
|
240
|
+
strategies, with and without post-process), `validate`, `analyze`, `extract` and the plural scan —
|
|
241
|
+
exhaustive over alphabets aimed at each rewritten scan, plus random soup — with all twelve control
|
|
242
|
+
mutations caught first.
|
|
243
|
+
|
|
244
|
+
**Deep nesting no longer costs its depth again at every level — and the re-read had made that a live denial
|
|
245
|
+
of service.** #68 kept nesting super-linear on the ground that depth costs source: at the hosted 8 KB cap,
|
|
246
|
+
4 000 levels answered in about a second. 0.7.0's re-read took that ground away, because a construct re-read
|
|
247
|
+
as text hands the parser whatever its macros spell (found by the Codex gate):
|
|
248
|
+
|
|
249
|
+
| input (Node 22, the two measured side by side) | 0.7.0 | now |
|
|
250
|
+
|---|---|---|
|
|
251
|
+
| 705 bytes: `{%o15%x%c15%\|y}` over fifteen `#set` doublings of `{` and of `}` — render | 40 s | 0.14 s |
|
|
252
|
+
| a `#def` rolling those 32 768 levels, referenced once — render | 40 s | 0.28 s |
|
|
253
|
+
| `{a…x…b}` 16 384 levels deep, every level adding text — render | 19 s | 0.14 s |
|
|
254
|
+
| `[a\|…x…]` 16 384 levels deep — render | 20 s | 0.24 s |
|
|
255
|
+
| `{a%u%…x…}` 8 192 levels, each marked for the re-read by an undefined reference — render | 20 s | 0.10 s |
|
|
256
|
+
| 20 000 nested conditionals naming a `#def` of 2^17 form feeds — render | 13 s | 0.30 s |
|
|
257
|
+
| `{` 100 000 levels deep — parse | > 90 s | 0.21 s |
|
|
258
|
+
|
|
259
|
+
Four costs were paid once per level, and each is gone:
|
|
260
|
+
|
|
261
|
+
- **The parser read every construct's content in full** — to find its closer, to split it on top-level
|
|
262
|
+
pipes, to find a conditional's pipe, a config's end, a closing tag, a trailing separator — and handed its
|
|
263
|
+
children copies. A construct's children are spans of one text now, and every one of those questions is
|
|
264
|
+
answered for a span from tables built once over the text (`internal/text-index.ts`): bracket pairs; the
|
|
265
|
+
top-level pipes grouped by the brace and bracket totals in front of them, since `split_top_level`'s two
|
|
266
|
+
signed counters split exactly where both totals equal the ones at the span's start; a conditional's pipe
|
|
267
|
+
by jumping each opener to where one stack of both bracket kinds closes it, which is what the clamped
|
|
268
|
+
counter counts; the quote parity of every `>`; every closing tag by name; every `<` and `>`. Most of those
|
|
269
|
+
answers are a binary search in a sorted list of positions, so a template of n characters parses in
|
|
270
|
+
O(n log n) at worst rather than O(n) — the logarithm shows on many small siblings: 200 000 `{a|b}` parse in
|
|
271
|
+
150 ms against 0.7.0's 137, 100 000 `[<sep=",">a|b]` in 208 against 161 (found by the Codex gate).
|
|
272
|
+
- **The walk joined each frame's output into a new string**, copying a construct's text once for every
|
|
273
|
+
level above it. Frames hand fragments up instead — joins, and windows for a trimmed permutation element,
|
|
274
|
+
every join knowing how many PHP trim characters sit at its ends — and the text is made once.
|
|
275
|
+
- **A construct marked for the re-read ran both conditional passes and the fixpoint over its body to find
|
|
276
|
+
nothing to change**, once per marked level. The index answers first: the body can change only if it holds
|
|
277
|
+
a conditional the pass would resolve — a well-formed head whose brace closes inside it — or a reference
|
|
278
|
+
expansion would substitute, a name the variable map defines while the budget lasts. When neither, the
|
|
279
|
+
splice returns what it always returned. The conditional pass finds its heads once, too, instead of
|
|
280
|
+
searching the text again from the start of every span.
|
|
281
|
+
- **Truthiness scanned a value's leading whitespace at every conditional**; it is computed once per
|
|
282
|
+
variable map.
|
|
283
|
+
|
|
284
|
+
Output is byte-identical to the code before this change: the 4.3 million alphabet strings above again, and
|
|
285
|
+
20 000 generated documents — long outputs, padded and empty elements, constructs marked for the re-read
|
|
286
|
+
with defined, undefined and budget-starved references, conditionals in bodies, count slots and `#def`
|
|
287
|
+
rolls, nests up to 300 deep — through eight probes each (`parse`, `validate`, `analyze`, four rng
|
|
288
|
+
strategies with and without post-process, and a pre-parsed `Ast`), with the control mutations caught first:
|
|
289
|
+
a join that stops counting trim characters after its first piece, one that never counts them at its end, a
|
|
290
|
+
trim window a character late, a re-read check that forgets conditionals or inverted heads or compares names
|
|
291
|
+
case-sensitively, one truthiness cache for every map, a conditional pass that skips a head at a span's start,
|
|
292
|
+
top-level pipes grouped by one depth, a flipped quote parity, a closing tag on the span's end counted
|
|
293
|
+
outside it. The PHP engines are no linear reference here: both resolve innermost-first with a
|
|
294
|
+
full-text pass per level and stop at 10 000 levels with an exception — in `spintax/core`, 8 192 nested
|
|
295
|
+
permutations took 5.9 s and 32 768 nested enumerations threw. Real templates did not pay for any of this —
|
|
296
|
+
the render cost note below has the numbers. An `Ast` copied out of the process (it is not a serialization
|
|
297
|
+
format) loses the side table the index lives in, and renders the same text by reading bodies as before.
|
|
298
|
+
|
|
299
|
+
**Two costs bounded by the source, recorded rather than changed.** The same gate found `validate()`
|
|
300
|
+
formatting a large definition cycle in quadratic time — each name walks the cycle to count what its
|
|
301
|
+
printed route leaves out: 8 000 names 6.5 s, the 320 that fit in 8 KB 23 ms — and `render()` ordering
|
|
302
|
+
`#def`s in quadratic-to-cubic time: 1 600 in a chain 2 s, 400 in 90 ms. A macro cannot spell either,
|
|
303
|
+
both were so in 0.7.0, and the hosted surfaces cap a source at 8 KB.
|
|
304
|
+
|
|
305
|
+
**Two PHP builds differ at the margin.** PCRE2 10.43 made non-spacing marks and connector punctuation
|
|
306
|
+
word characters, so PHP 8.3 sees a boundary between `x` and U+0301 that PHP 8.4 does not. The corpus
|
|
307
|
+
runs PHP 8.4 and this engine follows it; recorded in the conformance README.
|
|
308
|
+
|
|
309
|
+
**What the wider re-read does not move.** A construct re-read because it holds a conditional draws
|
|
310
|
+
exactly as the tree did whenever the taken branch changes nothing structural — measured on 0.7.0
|
|
311
|
+
before the change, 500 seeds over five shapes (a permutation with separators, one with a size range,
|
|
312
|
+
an enumeration, a nested permutation in a branch, an else branch): identical renders. What changes is
|
|
313
|
+
the three kinds of text the plugin always produced here — a pipe in a branch, an empty element, a
|
|
314
|
+
trimmed edge — and only in the renders where one occurs: a permutation whose elements all render
|
|
315
|
+
non-empty and unpadded picks and shuffles exactly as before. `validate()` still reports `minsize=%n%`
|
|
316
|
+
as `permutation.minsize-not-integer`, as both PHP validators do: a verdict about the template as
|
|
317
|
+
written, unchanged.
|
|
318
|
+
|
|
319
|
+
**Verified against PHP, not only against 0.7.0.** A generator of construct-heavy templates —
|
|
320
|
+
conditionals with empty, piped and padded branches, references in configs and elements, macros
|
|
321
|
+
carrying conditionals, per-element separators — 3 000 renders with `rng: first` and `last` through
|
|
322
|
+
both PHP engines: 0.7.0 differed on 416, this engine on 0. The first cut of #80 still differed on
|
|
323
|
+
147, every one an element that rendered empty through a nested construct; that is how the trim-and-drop
|
|
324
|
+
above was found. And 20 000 generated post-process inputs (Latin, Cyrillic, marks, every space the two
|
|
325
|
+
dialects disagree on, shield triggers): 0.7.0 differed on 6 229, this engine on none but the recorded
|
|
326
|
+
final `trim`, which accounts for all 3 689 of its edge-only differences. A zero holds for what those
|
|
327
|
+
generators can build, and is stated here as that.
|
|
328
|
+
|
|
329
|
+
**On a production host's templates.** 2 522 template-and-flag combinations taken from a live
|
|
330
|
+
deployment's migrations, 15 132 renders against 0.7.0: 30 change, and `validate`/`analyze`/`extract`
|
|
331
|
+
change on none. They are the two defects, in real copy: Russian brand articles stop printing
|
|
332
|
+
`Т. Е. Каждый перевод…` and `…х5, х7 и т. Д.`, and a payment FAQ whose list gates one item behind
|
|
333
|
+
`{?CasinoHasCrypto?…|}` stops printing `…are offered.. Traditional methods…` for a casino without
|
|
334
|
+
crypto — the blank element and its `. ` separator, which post-process had turned into a double stop.
|
|
335
|
+
The same generated documents without any of the changed shapes — 600 of them, 3 600 renders plus
|
|
336
|
+
`validate`/`analyze`/`extract` — are byte-identical, and the harness caught all three deliberate control
|
|
337
|
+
mutations first.
|
|
338
|
+
|
|
339
|
+
**Render cost.** A construct re-read because it holds a conditional is parsed again on every render, so
|
|
340
|
+
the payment FAQ whose list gates one item behind a flag is the one real template that got slower: 1 000
|
|
341
|
+
renders take 120 ms against 0.7.0's 86. Everything else measured got faster once the index answered most
|
|
342
|
+
re-reads without reading, the walk stopped copying and the post-process lost its per-call overhead: the FAQ
|
|
343
|
+
with a providers gate four times over, 513 ms against 1 322; plain prose, 145 against 198; all 1 262
|
|
344
|
+
construct-bearing literals of the production host's migrations, rendered once each, 178 ms against 262.
|
|
345
|
+
|
|
346
|
+
**The prototype names, verified against a renamed oracle.** 0.7.0's output under those names was the
|
|
347
|
+
defect, so the fixed build is compared with the build before it on each document with `__proto__` and
|
|
348
|
+
`constructor` renamed to ordinary names of the same length, renamed back in the output: 20 000 generated
|
|
349
|
+
documents, 19 875 carrying one of the names in a reference, a conditional, a `#set`, a `#def`, a plural
|
|
350
|
+
slot or a context key, through `parse`, `validate`, `analyze` and five renders — no difference, with three
|
|
351
|
+
control mutations caught first (an unrelated truthiness change, and the alias map or the `#def` map left a
|
|
352
|
+
plain object). The 449 009 alphabet strings, which name neither, are unchanged. The maps are merged by a
|
|
353
|
+
loop: `Object.assign` into a map with no prototype took five times the spread it replaced, once per
|
|
354
|
+
`#def` — 1 000 renders of a template with 60 context variables and 20 definitions went from 920 ms to
|
|
355
|
+
1 336 — and the loop brings that to 968. The production templates, the FAQ and prose measure as before.
|
|
356
|
+
|
|
357
|
+
**Recorded, not closed** — in the conformance README, under the known divergences. A value carrying an
|
|
358
|
+
unbalanced bracket (`[a|{%L%}]` with `L = "x}|y"`) re-cuts the enclosing construct in PHP and only its
|
|
359
|
+
own here. Four shapes of the same family, where a nested pick leaves markup the permutation's reader
|
|
360
|
+
sees in PHP and a tree parsed before the pick: a leading element that renders empty exposes its
|
|
361
|
+
`<…>` as the permutation's config, a pick ending in `<…>` becomes a per-element separator, a
|
|
362
|
+
construct inside the config is resolved first, and a nested permutation joined by `|` re-splits the
|
|
363
|
+
outer one (`[[<|>a|b]|c]`). All four were already so in 0.7.0. A generated differential aimed at exactly these meets
|
|
364
|
+
the first twice in 3 000 renders. And the Unicode tables: Node 22's are 17.0, PCRE2 10.44's are 15.0,
|
|
365
|
+
so a character assigned since then can sit on the other side of a boundary.
|
|
366
|
+
|
|
367
|
+
## 0.7.0 — 2026-09-12
|
|
368
|
+
|
|
369
|
+
**A `%variable%` written directly inside `{…}` or `[…]` is now spliced as TEXT before the construct
|
|
370
|
+
is split** — so a pipe-joined value is a list of options or elements, as it has always been in the
|
|
371
|
+
PHP engines. Reported from production: a brand preset
|
|
372
|
+
`[<minsize=5;maxsize=7;sep=", ";lastsep=" and ">%CasinoProvidersList%]` over a 57-name runtime
|
|
373
|
+
list rendered all 57 names joined with `|` — no size pick, no shuffle, no separators — in 131
|
|
374
|
+
published rows across 15 tenants. Minor rather than patch: rendered text changes for every template
|
|
375
|
+
of that shape, and `AST_VERSION` moves.
|
|
376
|
+
|
|
377
|
+
### Fixed
|
|
378
|
+
|
|
379
|
+
- **The defect.** The tree is built before any value exists, so `[<…>%list%]` was ONE element
|
|
380
|
+
holding a variable node; `resolveVariable` handed a construct-free value back as finished text,
|
|
381
|
+
and the `|` that separates elements in every PHP engine — whose `expand_variables` runs over the
|
|
382
|
+
whole text before any bracket is read — was never seen. Same for `{%list%}`, for `[a|%list%|b]`,
|
|
383
|
+
and for a `#set` or `#def` wrapping the construct.
|
|
384
|
+
- **The rule, restated for a tree walk.** The parser keeps a construct's raw body when it holds a
|
|
385
|
+
*direct* reference — at the top level of an option, inside a conditional's branches (the plugin
|
|
386
|
+
resolves `{?…}` before it expands, Stage 6a), or in a separator string (`<sep="%S%">`,
|
|
387
|
+
`a <%S%> |`). At render time such a construct is re-read from its expanded text in the plugin's
|
|
388
|
+
own order: conditionals → expansion → conditionals → parse. Every other construct keeps the tree
|
|
389
|
+
it was parsed into, and with it the RNG order the corpus pins; a spliced value with no structural
|
|
390
|
+
characters re-reads to the same tree, so those renders are unchanged too.
|
|
391
|
+
- **The hop budget inside a bracket equals the one outside.** The textual fixpoint runs the
|
|
392
|
+
plugin's own `<= MAX_VARIABLE_DEPTH` — 51 passes, minus the hops a macro re-parse already spent —
|
|
393
|
+
and whatever it leaves is frozen for the whole subtree, so the plugin's 51 hops hold in every
|
|
394
|
+
shape: the mutual cycle leaves `%b%`, `#set %b% = x%b%y` leaves 51 pairs, a 51-deep chain into
|
|
395
|
+
`x|y` reaches the body as text and is split — inside `{…}` as at top level (#57's pins, moved
|
|
396
|
+
inside). **The plural slots now use the same arithmetic.** They ran a flat 50 passes and rendered
|
|
397
|
+
the picked form unfrozen, so a 51-deep alias chain in the count slot erased a block the plugin
|
|
398
|
+
renders, and a 52-deep chain in a form resolved to its end where the plugin leaves `%a52%`.
|
|
399
|
+
Pre-existing, found by the review gate, closed here.
|
|
400
|
+
|
|
401
|
+
### Changed
|
|
402
|
+
|
|
403
|
+
- `AST_VERSION` 2 → 3: `EnumerationNode` and `PermutationNode` gained `raw`. A handle from an
|
|
404
|
+
older parser would render the old, wrong output, so the version guard turns it into
|
|
405
|
+
`AstVersionError` — the same reason `#def` bumped it. Nothing persists a handle across versions.
|
|
406
|
+
- **Every substitution now charges the expansion budget (#69), as it does in PHP.**
|
|
407
|
+
`resolveVariable` used to charge only construct-bearing values — harmless while a plain value
|
|
408
|
+
could only be a leaf, and the one door left open once a re-read construct could hand it
|
|
409
|
+
references its own fixpoint had cut off: 2^12 references to a 1 KiB value produced 4 MiB out of
|
|
410
|
+
a 1 MiB allowance, and 2^20 is an out-of-memory abort. Found by the review gate before release.
|
|
411
|
+
A template referencing a 65 KB plain list sixteen times now reaches the budget, as it already
|
|
412
|
+
did in PHP; the references past it stay literal.
|
|
413
|
+
- **The truncated shape of an expansion bomb is half what it was.** The substitution at the depth
|
|
414
|
+
cap is charged like every other now (it was free), so the 62-character bomb renders 0.57 MB where it
|
|
415
|
+
rendered 1.14 MB. Not parity-gated — the conformance README says what a truncated explosion looks
|
|
416
|
+
like is engine-specific — and the contract holds unchanged: terminates, never throws, bounded output,
|
|
417
|
+
unaffordable references left literal. The hosted MCP server's output cap now stops that bomb at
|
|
418
|
+
variant 4 rather than 2 (its test moved with it).
|
|
419
|
+
- `neutralize()` docs: the pipe is deliberately not shielded. A neutralized value the author places
|
|
420
|
+
inside `{…}`/`[…]` is split on its `|` in every engine — this one was immune only by the defect.
|
|
421
|
+
|
|
422
|
+
### Corpus
|
|
423
|
+
|
|
424
|
+
Nineteen `splice/*` fixtures in `render-semantics.json`, expected outputs taken from BOTH PHP
|
|
425
|
+
engines (docker, real code): the inline permutation, the `#set` wrapper over a runtime list,
|
|
426
|
+
literals around the variable, `{%L%}`, a conditional branch carrying the list, a `#def` wrapper
|
|
427
|
+
(rolled once, then spliced), `<sep="%S%">`, `lastsep="%S%"` and a per-element `<%S%>`, a macro
|
|
428
|
+
value with brackets AND a pipe, three hop-budget pins (a 50-alias chain into `x|y` split on the 51st
|
|
429
|
+
hop inside `{…}`, the same chain reaching its number in a plural count slot, a 51-alias chain left
|
|
430
|
+
literal in a form), the production
|
|
431
|
+
preset shape — and three negatives: a top-level reference is NOT split, an undefined name stays one
|
|
432
|
+
literal element, a nested construct splits at its own level only. No prior fixture had a variable
|
|
433
|
+
inside a construct, which is how this hid in four tree-walk engines (TS, Python, Object Pascal,
|
|
434
|
+
.NET) while the PHP engines were right all along. The siblings fail the new cases until they mirror
|
|
435
|
+
the rule.
|
|
436
|
+
|
|
437
|
+
### Notes
|
|
438
|
+
|
|
439
|
+
**What stays as it was, on purpose.** Two pre-existing tree-walk divergences in the *non-triggered*
|
|
440
|
+
path are unchanged: an element that renders to empty (a nested conditional yielding `''`) is kept
|
|
441
|
+
here and dropped by PHP, and PHP's top-level scan splits the fullwidth plural fallback on its pipes.
|
|
442
|
+
Both pathological, neither reported. A *triggered* construct drops the empty element as PHP does,
|
|
443
|
+
because the text is what PHP sees — and trims a conditional's taken branch at an element's edge,
|
|
444
|
+
as the plugin trims after Stage 6a. The fullwidth fallback is the exception in BOTH paths: the plugin
|
|
445
|
+
resolves plurals before it reads a bracket, so a fallback's ASCII pipes reach the enclosing construct
|
|
446
|
+
there, while here the re-read makes the plural a node again and it renders whole. Only a template
|
|
447
|
+
`validate()` already rejects (`plural.arity`, `plural.nested-brackets`) can reach it; recorded in the
|
|
448
|
+
conformance README under the known divergences rather than closed (review finding, kept open on
|
|
449
|
+
purpose).
|
|
450
|
+
|
|
451
|
+
**The hop budget, corrected in review.** The first cut gave the fixpoint 50 passes and rendered the
|
|
452
|
+
leftover at the depth cap, which spliced it once more as finished text: a 52nd hop when reached through
|
|
453
|
+
a macro, and one that hid a structural terminal value from the split (a 51-deep chain into `x|y`
|
|
454
|
+
inside `{…}` rendered `x|y`, where the plugin's 51st pass yields `{x|y}` and picks). Now the fixpoint
|
|
455
|
+
runs the plugin's own `<= MAX_VARIABLE_DEPTH` — 51 passes, minus the hops a macro re-parse already
|
|
456
|
+
spent — and whatever is left is frozen for the whole subtree.
|
|
457
|
+
|
|
458
|
+
**Verified.** The 0.6.1 build against this one over generated documents (seeded LCG, every
|
|
459
|
+
construct, `render` in both post-process modes, `analyze`, `extract`) — the harness first shown to
|
|
460
|
+
catch two deliberate control mutations. Documents with no direct reference anywhere: byte-identical,
|
|
461
|
+
and `analyze`/`extract` identical on every document. Documents whose constructs DO carry a direct
|
|
462
|
+
reference, with plain values and no conditional: byte-identical over 1 788 renders and three seeds —
|
|
463
|
+
that is the claim that a spliced value with no structural characters re-reads to the same tree. With
|
|
464
|
+
conditionals and construct-bearing definition values allowed, 159 of 922 triggered renders differ, in
|
|
465
|
+
exactly the three ways the PHP text has always differed from the old tree: whitespace only (a taken
|
|
466
|
+
branch trimmed at an element's edge), an element that became empty dropped before the shuffle (so
|
|
467
|
+
every later draw shifts), or a `|` carried in by a rolled `#def`.
|
|
468
|
+
|
|
6
469
|
## 0.6.1 — 2026-08-19
|
|
7
470
|
|
|
8
471
|
**The engine no longer throws on deeply nested content** (issue #68). `parse()`, `render()` and
|
package/README.md
CHANGED
|
@@ -14,11 +14,12 @@ parse, render, validate, extract, analyze, and neutralize spintax templates.
|
|
|
14
14
|
|
|
15
15
|
- **Zero runtime dependencies.** Runs unchanged on Cloudflare Workers, Node 18+, and in the browser.
|
|
16
16
|
- **ESM-first, dual CJS.** Ships `.d.ts` types for both.
|
|
17
|
-
- **One syntax contract, [
|
|
17
|
+
- **One syntax contract, [six independent engines](https://spintax.net/spintax-engines/).**
|
|
18
18
|
This package, [`spintax/core`](https://packagist.org/packages/spintax/core) (PHP),
|
|
19
|
-
[`spintax-core`](https://pypi.org/project/spintax-core/) (Python), an Object Pascal engine,
|
|
19
|
+
[`spintax-core`](https://pypi.org/project/spintax-core/) (Python), an Object Pascal engine,
|
|
20
|
+
[`Spintax.Core`](https://www.nuget.org/packages/Spintax.Core/) (.NET), and
|
|
20
21
|
the [Spintax WordPress plugin](https://wordpress.org/plugins/spintax/) are all held to the same
|
|
21
|
-
behavior contract by a **shared golden corpus of
|
|
22
|
+
behavior contract by a **shared golden corpus of 258 fixtures** — deterministic verdicts, plural
|
|
22
23
|
buckets, conditionals, `#set`/`#def` semantics and post-processing agree everywhere, so a
|
|
23
24
|
template is an asset you can move between runtimes, not a lock-in.
|
|
24
25
|
- **MIT** licensed.
|
|
@@ -80,10 +81,10 @@ dedupe in the host and cap the retries: a template may simply not have N combina
|
|
|
80
81
|
| --- | --- | --- |
|
|
81
82
|
| Enumeration | `{a\|b\|c}` | pick one (nestable: `{a\|{b\|c}}`) |
|
|
82
83
|
| Permutation | `[a\|b\|c]` | pick N, shuffle, join — `[<minsize=1;maxsize=2;sep=", ">a\|b\|c]` |
|
|
83
|
-
| Variable | `%var%` | substitute a context value |
|
|
84
|
+
| Variable | `%var%` | substitute a context value — inside `{…}`/`[…]` the value is spliced as text first, so a `|` it carries separates options |
|
|
84
85
|
| Local set | `#set %v% = value` | define a macro — re-picked at every use |
|
|
85
86
|
| Local def | `#def %v% = value` | define a value — picked once per render, held at every use |
|
|
86
|
-
| Conditional | `{?VAR?then\|else}` | `then` if `VAR` is truthy, else `else` |
|
|
87
|
+
| Conditional | `{?VAR?then\|else}` | `then` if `VAR` is truthy, else `else` — inside `{…}`/`[…]` it resolves first, so a `\|` in the taken branch separates options and an empty one drops its element |
|
|
87
88
|
| Plural | `{plural %n%: one\|few\|many}` | grammatical agreement by locale |
|
|
88
89
|
| Include | `#include "slug-or-id"` | embed another template (host-resolved) |
|
|
89
90
|
| Comment | `/# … #/` | stripped before rendering |
|
|
@@ -153,6 +154,11 @@ render('%bio%', { context: { bio: neutralize('Save {50|60}% today') }, postProce
|
|
|
153
154
|
// → "Save {50|60}% today" (the braces stay literal, not a random pick)
|
|
154
155
|
```
|
|
155
156
|
|
|
157
|
+
The pipe is deliberately **not** shielded: it means nothing outside a construct, and inside one it
|
|
158
|
+
is the separator. A neutralized value an author places in `{…}`/`[…]` is still split on its `|`, in
|
|
159
|
+
this engine as in every other in the family. Keep such a value out of a construct if it must stay
|
|
160
|
+
one option.
|
|
161
|
+
|
|
156
162
|
## Using it without writing code
|
|
157
163
|
|
|
158
164
|
- **n8n** — install [`n8n-nodes-spintax`](https://www.npmjs.com/package/n8n-nodes-spintax)
|