@spintax/core 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +97 -0
- package/README.md +5 -4
- package/dist/index.cjs +182 -91
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +12 -3
- package/dist/index.d.ts +12 -3
- package/dist/index.js +182 -91
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,103 @@
|
|
|
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.3.1 — 2026-07-21
|
|
7
|
+
|
|
8
|
+
Author markup is sanitised in one place, so a handle renders exactly like its source.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **`render(parse(src))` no longer diverges from `render(src)`** when the template contains an
|
|
13
|
+
author-typed engine sentinel (the reserved range U+E000–U+E005). The strip that keeps stray
|
|
14
|
+
sentinels out of a tree lived at the render entry points, so `parse()` and `analyze(str)` — two
|
|
15
|
+
of the three doors into the parser — skipped it, and the mandatory safety-restore then rewrote
|
|
16
|
+
the author's character into a structural glyph they never wrote:
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
const src = `a${String.fromCharCode(0xe000)}b`;
|
|
20
|
+
render(src, { postProcess: false }); // "ab"
|
|
21
|
+
render(parse(src), { postProcess: false }); // "a{b" ← before this release
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The strip now lives in `parseTemplate`, the single door from author source into an AST, so all
|
|
25
|
+
three entry points agree. `parseSequence` is deliberately **not** sanitised — it re-parses a
|
|
26
|
+
variable's *value*, where sentinels a host `neutralize()`d are legitimate and must survive to the
|
|
27
|
+
restore — and `ParsedAst.source` still keeps the original bytes so diagnostics point at what was
|
|
28
|
+
typed. ([#51](https://github.com/investblog/spintax-js/issues/51))
|
|
29
|
+
|
|
30
|
+
Templates that contain no reserved-range characters are unaffected: the strip is a no-op on them.
|
|
31
|
+
The Python port fixed the same defect the same way; the PHP engines never had it (no `parse()`
|
|
32
|
+
handle, no PUA sentinels).
|
|
33
|
+
|
|
34
|
+
## 0.3.0 — 2026-07-19
|
|
35
|
+
|
|
36
|
+
`#set` goes back to being a macro and a new `#def` carries roll-once. Breaking: it changes what
|
|
37
|
+
existing templates mean. Ships in lockstep with the WordPress plugin 3.0.0, `spintax/core` and the
|
|
38
|
+
OpenCart port; the corpus landed last, after both PHP engines.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- **`#set` is a macro.** The value is substituted at every `%var%` reference and whatever brackets
|
|
43
|
+
it holds resolve independently each time. Until now an enumeration-valued `#set` collapsed once at
|
|
44
|
+
set-time; that behaviour moved to `#def`.
|
|
45
|
+
|
|
46
|
+
Worth recording *why* the revert, because the reasoning is not recoverable from the diff:
|
|
47
|
+
collapse-once was the newcomer. It shipped in the plugin on 2026-07-04, was announced in one
|
|
48
|
+
changelog line, and contradicted the project's published documentation from the day it landed.
|
|
49
|
+
Macro expansion is what the engine did before that and what consumers written against those docs
|
|
50
|
+
assume.
|
|
51
|
+
|
|
52
|
+
- **`AST_VERSION` 1 → 2.** `ParsedAst` gained `defDefs`. An `Ast` cached by an older version carries
|
|
53
|
+
no definition map, so rendering it would silently drop every `#def`; the version guard turns that
|
|
54
|
+
into an `AstVersionError` instead.
|
|
55
|
+
|
|
56
|
+
- **`extract()` reports `defs`** alongside `sets`. Additive for readers, but a consumer building a
|
|
57
|
+
variable list from `sets` alone will now miss `#def` names.
|
|
58
|
+
|
|
59
|
+
- **`plural.nested-brackets` advice.** "Extract via `#set` first" was correct under collapse-once and
|
|
60
|
+
is wrong under a macro — the value is substituted verbatim and puts the brackets straight back into
|
|
61
|
+
the form slot, raising the very error it was meant to avoid. It now says `#def`.
|
|
62
|
+
|
|
63
|
+
### Added
|
|
64
|
+
|
|
65
|
+
- **`#def %var% = value` — roll-once.** The value is rendered once per render, as if it were a
|
|
66
|
+
miniature body, and the result is held for every reference. It covers enumerations *and*
|
|
67
|
+
permutations, resolves **after** the merged context exists (so it can read globals and runtime
|
|
68
|
+
variables; a runtime variable of the same name outranks it), and resolves in dependency order —
|
|
69
|
+
an order that follows aliases **through** macro values, since a `#def` can reach another `#def` by
|
|
70
|
+
way of a `#set` expanded only at reference time.
|
|
71
|
+
|
|
72
|
+
This is where a plural counter now lives: `#def %n% = {1|4|9}` then `{plural %n%: …}` prints and
|
|
73
|
+
agrees the same number. Under `#set` the two disagree and the block is dropped.
|
|
74
|
+
|
|
75
|
+
- **Four diagnostics**: `def.malformed`, `definition.duplicate-name` (a name belongs to one
|
|
76
|
+
directive, once — this also closes silently-last-wins duplicate `#set`s), `def.include-in-value`
|
|
77
|
+
(includes resolve after a value is frozen; legal inside a `#set`), and `plural.count-macro`.
|
|
78
|
+
|
|
79
|
+
`plural.count-macro` is decided by **stage order, not bracket type**: conditionals resolve before
|
|
80
|
+
plurals and are exempt, enumerations and permutations resolve after and are not, and a nested
|
|
81
|
+
`{plural …}` resolves in the *same* pass so it is not exempt either. Taint propagates through
|
|
82
|
+
`#set` → `#set` references to a fixpoint.
|
|
83
|
+
|
|
84
|
+
### Corpus
|
|
85
|
+
|
|
86
|
+
The difference between the directives is a difference in how many RNG draws a render consumes, so
|
|
87
|
+
seeded-sequence fixtures pin it exactly and cross-engine — `set/macro-re-rolls-at-every-reference`
|
|
88
|
+
and `def/rolled-once-and-held` share a template and a sequence and differ only in whether the second
|
|
89
|
+
draw is reached. The corpus grew 138 → 160 cases, green against this engine, the WordPress plugin
|
|
90
|
+
and `spintax/core`.
|
|
91
|
+
|
|
92
|
+
The previous rule here said collapse could only be pinned with RNG-free values, and the consequence
|
|
93
|
+
was that **nothing pinned it at all**: the semantics flipped in the plugin without a single fixture
|
|
94
|
+
noticing. All-identical alternatives are not sufficient either — they render the same under both
|
|
95
|
+
semantics and would pass against an engine implementing `#def` as an alias of `#set`.
|
|
96
|
+
|
|
97
|
+
### Migration
|
|
98
|
+
|
|
99
|
+
A `#set` whose value is an enumeration or permutation *and* which is referenced more than once for
|
|
100
|
+
consistency — a plural counter, a brand name that must not vary mid-sentence — becomes `#def`. One
|
|
101
|
+
line per definition; references are untouched.
|
|
102
|
+
|
|
6
103
|
## 0.2.0 — 2026-07-18
|
|
7
104
|
|
|
8
105
|
Serbian, Croatian and Bosnian join the 3-form plural family; the plural error model and the
|
package/README.md
CHANGED
|
@@ -17,8 +17,8 @@ parse, render, validate, extract, analyze, and neutralize spintax templates.
|
|
|
17
17
|
- **Parity-verified against the [Spintax WordPress plugin](https://wordpress.org/plugins/spintax/).**
|
|
18
18
|
An *independent* TypeScript implementation (not a line-by-line port) held to the plugin's behavior
|
|
19
19
|
contract by a shared golden corpus — the **same** fixtures pass against both this engine and the
|
|
20
|
-
PHP plugin (deterministic verdicts, plural buckets, conditionals, `#set`
|
|
21
|
-
with no divergence.
|
|
20
|
+
PHP plugin (deterministic verdicts, plural buckets, conditionals, `#set`/`#def` semantics,
|
|
21
|
+
post-process), with no divergence.
|
|
22
22
|
- **MIT** licensed.
|
|
23
23
|
|
|
24
24
|
> **Status: released & stable.** Feature-complete — parse / render / validate / extract / analyze /
|
|
@@ -77,7 +77,8 @@ dedupe in the host and cap the retries: a template may simply not have N combina
|
|
|
77
77
|
| Enumeration | `{a\|b\|c}` | pick one (nestable: `{a\|{b\|c}}`) |
|
|
78
78
|
| Permutation | `[a\|b\|c]` | pick N, shuffle, join — `[<minsize=1;maxsize=2;sep=", ">a\|b\|c]` |
|
|
79
79
|
| Variable | `%var%` | substitute a context value |
|
|
80
|
-
| Local set | `#set %v% = value` | define a
|
|
80
|
+
| Local set | `#set %v% = value` | define a macro — re-picked at every use |
|
|
81
|
+
| Local def | `#def %v% = value` | define a value — picked once per render, held at every use |
|
|
81
82
|
| Conditional | `{?VAR?then\|else}` | `then` if `VAR` is truthy, else `else` |
|
|
82
83
|
| Plural | `{plural %n%: one\|few\|many}` | grammatical agreement by locale |
|
|
83
84
|
| Include | `#include "slug-or-id"` | embed another template (host-resolved) |
|
|
@@ -152,7 +153,7 @@ render('%bio%', { context: { bio: neutralize('Save {50|60}% today') }, postProce
|
|
|
152
153
|
|
|
153
154
|
With a `seed`, `render` is reproducible within this engine. Cross-engine RNG-sequence parity
|
|
154
155
|
with the PHP plugin is a **non-goal** — only the *deterministic* behavior (validation verdicts,
|
|
155
|
-
plural buckets, conditional truthiness, `#set`
|
|
156
|
+
plural buckets, conditional truthiness, `#set`/`#def` semantics, post-process output) is parity-gated.
|
|
156
157
|
|
|
157
158
|
## Links
|
|
158
159
|
|
package/dist/index.cjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// src/internal/ast.ts
|
|
4
|
-
var AST_VERSION =
|
|
4
|
+
var AST_VERSION = 2;
|
|
5
5
|
function walk(nodes, visit) {
|
|
6
6
|
for (const n of nodes) {
|
|
7
7
|
visit(n);
|
|
@@ -23,23 +23,66 @@ function isParsedAst(value) {
|
|
|
23
23
|
return typeof value === "object" && value !== null && value.astVersion === AST_VERSION && typeof value.source === "string" && Array.isArray(value.nodes);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// src/internal/neutralize.ts
|
|
27
|
+
var STRUCTURAL = ["{", "}", "[", "]", "%", "#"];
|
|
28
|
+
var SENTINEL_BASE = 57344;
|
|
29
|
+
var SHIELD = /* @__PURE__ */ new Map();
|
|
30
|
+
var RESTORE = /* @__PURE__ */ new Map();
|
|
31
|
+
STRUCTURAL.forEach((ch, i) => {
|
|
32
|
+
const sentinel = String.fromCharCode(SENTINEL_BASE + i);
|
|
33
|
+
SHIELD.set(ch, sentinel);
|
|
34
|
+
RESTORE.set(sentinel, ch);
|
|
35
|
+
});
|
|
36
|
+
var SHIELD_RE = /[{}[\]%#]/gu;
|
|
37
|
+
var RESTORE_RE = new RegExp(
|
|
38
|
+
`[${String.fromCharCode(SENTINEL_BASE)}-${String.fromCharCode(SENTINEL_BASE + STRUCTURAL.length - 1)}]`,
|
|
39
|
+
"gu"
|
|
40
|
+
);
|
|
41
|
+
function neutralize(value) {
|
|
42
|
+
return value.replace(SHIELD_RE, (ch) => SHIELD.get(ch) ?? ch);
|
|
43
|
+
}
|
|
44
|
+
function safetyRestore(text) {
|
|
45
|
+
return text.replace(RESTORE_RE, (ch) => RESTORE.get(ch) ?? ch);
|
|
46
|
+
}
|
|
47
|
+
function stripSentinels(text) {
|
|
48
|
+
return text.replace(RESTORE_RE, "");
|
|
49
|
+
}
|
|
50
|
+
|
|
26
51
|
// src/internal/parser.ts
|
|
27
52
|
var VARIABLE_RE = /^%(\w+)%/;
|
|
28
|
-
var
|
|
53
|
+
var DIRECTIVE_RE = /^[ \t]*#(set|def)[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)[ \t]*\r?$/gmu;
|
|
29
54
|
var CONDITIONAL_NAME_RE = /^[A-Za-z_]\w*/;
|
|
30
55
|
var PLURAL_PREFIX = "plural ";
|
|
31
56
|
function parseTemplate(src) {
|
|
32
|
-
const { body, setDefs } =
|
|
33
|
-
return { astVersion: AST_VERSION, source: src, setDefs, nodes: parseSequence(body) };
|
|
57
|
+
const { body, setDefs, defDefs } = extractDirectives(stripComments(stripSentinels(src)));
|
|
58
|
+
return { astVersion: AST_VERSION, source: src, setDefs, defDefs, nodes: parseSequence(body) };
|
|
34
59
|
}
|
|
35
|
-
function
|
|
60
|
+
function extractDirectives(text) {
|
|
36
61
|
const setDefs = {};
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
62
|
+
const defDefs = {};
|
|
63
|
+
const occurrences = [];
|
|
64
|
+
DIRECTIVE_RE.lastIndex = 0;
|
|
65
|
+
const stripped = text.replace(
|
|
66
|
+
DIRECTIVE_RE,
|
|
67
|
+
(full, kind, rawName, value, offset) => {
|
|
68
|
+
const name = rawName.toLowerCase();
|
|
69
|
+
occurrences.push({
|
|
70
|
+
kind,
|
|
71
|
+
name,
|
|
72
|
+
value,
|
|
73
|
+
line: countLines(text, offset)
|
|
74
|
+
});
|
|
75
|
+
if (kind === "def") defDefs[name] = value;
|
|
76
|
+
else setDefs[name] = value;
|
|
77
|
+
return "";
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
return { body: stripped.replace(/\n{3,}/gu, "\n\n"), setDefs, defDefs, occurrences };
|
|
81
|
+
}
|
|
82
|
+
function countLines(text, offset) {
|
|
83
|
+
let line = 1;
|
|
84
|
+
for (let i = 0; i < offset; i += 1) if (text[i] === "\n") line += 1;
|
|
85
|
+
return line;
|
|
43
86
|
}
|
|
44
87
|
function stripComments(text) {
|
|
45
88
|
return text.replace(/\/#[\s\S]*?#\//g, "");
|
|
@@ -378,7 +421,7 @@ function validateTemplate(src, opts = {}) {
|
|
|
378
421
|
const diagnostics = [];
|
|
379
422
|
const text = stripComments(src);
|
|
380
423
|
checkBrackets(text, diagnostics);
|
|
381
|
-
|
|
424
|
+
checkDirectives(text, diagnostics);
|
|
382
425
|
checkPermutationConfigs(text, diagnostics);
|
|
383
426
|
checkPlurals(text, opts.locale, diagnostics);
|
|
384
427
|
checkVariableReferences(text, opts.knownVariables, diagnostics);
|
|
@@ -437,17 +480,43 @@ function checkBrackets(text, out) {
|
|
|
437
480
|
out.push(err("bracket.unclosed", `Unclosed '${unclosed.char}'.`, { line: unclosed.line, column: unclosed.column, endLine: unclosed.line, endColumn: unclosed.column + 1, data: { bracket: unclosed.char } }));
|
|
438
481
|
}
|
|
439
482
|
}
|
|
440
|
-
function
|
|
483
|
+
function checkDirectives(text, out) {
|
|
441
484
|
const lines = text.split("\n");
|
|
442
485
|
lines.forEach((lineText, idx) => {
|
|
443
486
|
const trimmed = lineText.replace(/^[ \t]+/, "");
|
|
444
|
-
|
|
445
|
-
|
|
487
|
+
const kind = ["#set", "#def"].find(
|
|
488
|
+
(candidate) => trimmed.startsWith(`${candidate} `) || trimmed.startsWith(`${candidate} `)
|
|
489
|
+
);
|
|
490
|
+
if (!kind) return;
|
|
491
|
+
DIRECTIVE_RE.lastIndex = 0;
|
|
492
|
+
if (!DIRECTIVE_RE.test(trimmed)) {
|
|
446
493
|
const column = lineText.length - trimmed.length + 1;
|
|
447
494
|
const line = idx + 1;
|
|
448
|
-
|
|
495
|
+
const code = kind === "#def" ? "def.malformed" : "set.malformed";
|
|
496
|
+
out.push(err(code, `Malformed ${kind}. Expected: ${kind} %name% = value`, { line, column, endLine: line, endColumn: lineText.length + 1 }));
|
|
449
497
|
}
|
|
450
498
|
});
|
|
499
|
+
const { occurrences } = extractDirectives(text);
|
|
500
|
+
const seen = /* @__PURE__ */ new Map();
|
|
501
|
+
for (const occurrence of occurrences) {
|
|
502
|
+
const first = seen.get(occurrence.name);
|
|
503
|
+
if (first !== void 0) {
|
|
504
|
+
out.push(err(
|
|
505
|
+
"definition.duplicate-name",
|
|
506
|
+
`Variable '${occurrence.name}' is defined more than once (first on line ${first}). A name belongs to one directive, once.`,
|
|
507
|
+
{ line: occurrence.line, column: 1 }
|
|
508
|
+
));
|
|
509
|
+
} else {
|
|
510
|
+
seen.set(occurrence.name, occurrence.line);
|
|
511
|
+
}
|
|
512
|
+
if (occurrence.kind === "def" && /#include\b/u.test(occurrence.value)) {
|
|
513
|
+
out.push(err(
|
|
514
|
+
"def.include-in-value",
|
|
515
|
+
`#include cannot appear in a #def value ('${occurrence.name}'): includes resolve after the value is frozen. Use #set, or put the #include in the body.`,
|
|
516
|
+
{ line: occurrence.line, column: 1 }
|
|
517
|
+
));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
451
520
|
}
|
|
452
521
|
function checkPermutationConfigs(text, out) {
|
|
453
522
|
for (const m of text.matchAll(/\[<([^>]*?)>/gu)) {
|
|
@@ -485,10 +554,20 @@ function checkPermutationConfigs(text, out) {
|
|
|
485
554
|
function checkPlurals(text, locale, out) {
|
|
486
555
|
const base = locale && locale !== "" ? normalizeBaseLang(locale) : "";
|
|
487
556
|
const arity = base !== "" ? pluralArity(base) : 0;
|
|
557
|
+
const tainted = macroTaintedNames(text);
|
|
488
558
|
for (const block of findPluralBlocks(text)) {
|
|
489
559
|
const at = span(text, block.start, block.end - block.start);
|
|
560
|
+
for (const m of block.countSlot.matchAll(/%(\w+)%/gu)) {
|
|
561
|
+
const name = (m[1] ?? "").toLowerCase();
|
|
562
|
+
if (!tainted.has(name)) continue;
|
|
563
|
+
out.push(err(
|
|
564
|
+
"plural.count-macro",
|
|
565
|
+
`{plural ...}: the count '${m[1]}' is a #set macro, so it is still unresolved spintax when the plural is decided and the block renders empty. Define it with #def instead.`,
|
|
566
|
+
at
|
|
567
|
+
));
|
|
568
|
+
}
|
|
490
569
|
if (/[{}[\]]/.test(block.formsRaw)) {
|
|
491
|
-
out.push(err("plural.nested-brackets", "{plural ...}: forms must not contain nested spintax brackets.", at));
|
|
570
|
+
out.push(err("plural.nested-brackets", "{plural ...}: forms must not contain nested spintax brackets. Extract via #def first \u2014 a #set is substituted verbatim and would put the brackets straight back.", at));
|
|
492
571
|
continue;
|
|
493
572
|
}
|
|
494
573
|
if (arity > 0) {
|
|
@@ -503,11 +582,33 @@ function checkPlurals(text, locale, out) {
|
|
|
503
582
|
}
|
|
504
583
|
}
|
|
505
584
|
}
|
|
585
|
+
var UNRESOLVED_AT_PLURAL_TIME = /\[|\{(?!\?)/u;
|
|
586
|
+
function macroTaintedNames(text) {
|
|
587
|
+
const macros = extractDirectives(text).setDefs;
|
|
588
|
+
const tainted = /* @__PURE__ */ new Set();
|
|
589
|
+
for (const [name, value] of Object.entries(macros)) {
|
|
590
|
+
if (UNRESOLVED_AT_PLURAL_TIME.test(value)) tainted.add(name);
|
|
591
|
+
}
|
|
592
|
+
let grew = true;
|
|
593
|
+
while (grew) {
|
|
594
|
+
grew = false;
|
|
595
|
+
for (const [name, value] of Object.entries(macros)) {
|
|
596
|
+
if (tainted.has(name)) continue;
|
|
597
|
+
for (const m of value.matchAll(/%(\w+)%/gu)) {
|
|
598
|
+
if (!tainted.has((m[1] ?? "").toLowerCase())) continue;
|
|
599
|
+
tainted.add(name);
|
|
600
|
+
grew = true;
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return tainted;
|
|
606
|
+
}
|
|
506
607
|
function checkVariableReferences(text, known, out) {
|
|
507
608
|
const knownSet = new Set((known ?? []).map((n) => n.toLowerCase()));
|
|
508
609
|
const defs = /* @__PURE__ */ new Map();
|
|
509
610
|
const defPos = /* @__PURE__ */ new Map();
|
|
510
|
-
for (const m of text.matchAll(/^[ \t]*#set[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)$/gmu)) {
|
|
611
|
+
for (const m of text.matchAll(/^[ \t]*#(?:set|def)[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)$/gmu)) {
|
|
511
612
|
const name = (m[1] ?? "").toLowerCase();
|
|
512
613
|
defs.set(name, m[2] ?? "");
|
|
513
614
|
const nameOffset = (m.index ?? 0) + m[0].indexOf("%");
|
|
@@ -522,7 +623,7 @@ function checkVariableReferences(text, known, out) {
|
|
|
522
623
|
for (const name of defs.keys()) {
|
|
523
624
|
detectCycle(name, defs, [name], defPos.get(name) ?? somewhere, out);
|
|
524
625
|
}
|
|
525
|
-
const body = text.replace(/^[ \t]*#set[ \t]+%\w+%[ \t]*=[ \t]*.*?$/gmu, (m) => m.replace(/[^\n]/g, " "));
|
|
626
|
+
const body = text.replace(/^[ \t]*#(?:set|def)[ \t]+%\w+%[ \t]*=[ \t]*.*?$/gmu, (m) => m.replace(/[^\n]/g, " "));
|
|
526
627
|
const seen = /* @__PURE__ */ new Set();
|
|
527
628
|
const undefinedAt = (name, offset, length) => {
|
|
528
629
|
const key = name.toLowerCase();
|
|
@@ -569,19 +670,21 @@ function checkIncludeTargets(text, known, out) {
|
|
|
569
670
|
|
|
570
671
|
// src/internal/extract.ts
|
|
571
672
|
var SET_DEF_RE = /^[ \t]*#set[ \t]+%(\w+)%[ \t]*=/gmu;
|
|
572
|
-
var
|
|
673
|
+
var DEF_DEF_RE = /^[ \t]*#def[ \t]+%(\w+)%[ \t]*=/gmu;
|
|
674
|
+
var DEFINITION_LHS_RE = /^[ \t]*#(?:set|def)[ \t]+%\w+%[ \t]*=/gmu;
|
|
573
675
|
var INCLUDE_RE2 = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
|
|
574
676
|
var VARIABLE_RE2 = /%(\w+)%/gu;
|
|
575
677
|
var CONDITIONAL_REF_RE = /\{\?!?([A-Za-z_]\w*)\?/gu;
|
|
576
678
|
function extractFromSource(src) {
|
|
577
679
|
const text = stripComments(src);
|
|
578
680
|
const sets = collect(text, SET_DEF_RE, true);
|
|
681
|
+
const defs = collect(text, DEF_DEF_RE, true);
|
|
579
682
|
const includes = collect(text, INCLUDE_RE2, false);
|
|
580
|
-
const body = text.replace(
|
|
683
|
+
const body = text.replace(DEFINITION_LHS_RE, "");
|
|
581
684
|
const refs = /* @__PURE__ */ new Set();
|
|
582
685
|
addAll(refs, body, VARIABLE_RE2, true);
|
|
583
686
|
addAll(refs, body, CONDITIONAL_REF_RE, true);
|
|
584
|
-
return { refs: [...refs], sets, includes };
|
|
687
|
+
return { refs: [...refs], sets, defs, includes };
|
|
585
688
|
}
|
|
586
689
|
function collect(text, re, fold) {
|
|
587
690
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -624,43 +727,19 @@ var NotImplementedError = class extends SpintaxError {
|
|
|
624
727
|
}
|
|
625
728
|
};
|
|
626
729
|
|
|
627
|
-
// src/internal/neutralize.ts
|
|
628
|
-
var STRUCTURAL = ["{", "}", "[", "]", "%", "#"];
|
|
629
|
-
var SENTINEL_BASE = 57344;
|
|
630
|
-
var SHIELD = /* @__PURE__ */ new Map();
|
|
631
|
-
var RESTORE = /* @__PURE__ */ new Map();
|
|
632
|
-
STRUCTURAL.forEach((ch, i) => {
|
|
633
|
-
const sentinel = String.fromCharCode(SENTINEL_BASE + i);
|
|
634
|
-
SHIELD.set(ch, sentinel);
|
|
635
|
-
RESTORE.set(sentinel, ch);
|
|
636
|
-
});
|
|
637
|
-
var SHIELD_RE = /[{}[\]%#]/gu;
|
|
638
|
-
var RESTORE_RE = new RegExp(
|
|
639
|
-
`[${String.fromCharCode(SENTINEL_BASE)}-${String.fromCharCode(SENTINEL_BASE + STRUCTURAL.length - 1)}]`,
|
|
640
|
-
"gu"
|
|
641
|
-
);
|
|
642
|
-
function neutralize(value) {
|
|
643
|
-
return value.replace(SHIELD_RE, (ch) => SHIELD.get(ch) ?? ch);
|
|
644
|
-
}
|
|
645
|
-
function safetyRestore(text) {
|
|
646
|
-
return text.replace(RESTORE_RE, (ch) => RESTORE.get(ch) ?? ch);
|
|
647
|
-
}
|
|
648
|
-
function stripSentinels(text) {
|
|
649
|
-
return text.replace(RESTORE_RE, "");
|
|
650
|
-
}
|
|
651
|
-
|
|
652
730
|
// src/internal/render.ts
|
|
653
731
|
var MAX_VARIABLE_DEPTH = 50;
|
|
654
732
|
var INCLUDE_LINE_RE = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
|
|
655
733
|
function renderAst(ast, ctx) {
|
|
656
|
-
const
|
|
657
|
-
const
|
|
658
|
-
vars,
|
|
734
|
+
const base = buildVars(ast.setDefs, ctx.runtimeContext);
|
|
735
|
+
const walkOpts = {
|
|
659
736
|
rng: ctx.rng,
|
|
660
737
|
locale: ctx.locale,
|
|
661
738
|
depth: 0,
|
|
662
739
|
onPluralError: ctx.onPluralError
|
|
663
|
-
}
|
|
740
|
+
};
|
|
741
|
+
const vars = Object.keys(ast.defDefs).length > 0 ? { ...base, ...rollDefinitions(ast.defDefs, base, ctx.runtimeContext, walkOpts) } : base;
|
|
742
|
+
const text = renderNodes(ast.nodes, { ...walkOpts, vars });
|
|
664
743
|
return ctx.resolver ? resolveIncludes(text, ctx) : text;
|
|
665
744
|
}
|
|
666
745
|
function resolveIncludes(text, ctx) {
|
|
@@ -674,7 +753,7 @@ function resolveIncludes(text, ctx) {
|
|
|
674
753
|
throw new IncludeResolverError(`includeResolver threw for "${ref}"`, { cause });
|
|
675
754
|
}
|
|
676
755
|
if (included === null) return "";
|
|
677
|
-
return renderAst(parseTemplate(
|
|
756
|
+
return renderAst(parseTemplate(included), {
|
|
678
757
|
...ctx,
|
|
679
758
|
runtimeContext: ctx.runtimeContext,
|
|
680
759
|
// child inherits runtime, not parent #set
|
|
@@ -682,33 +761,65 @@ function resolveIncludes(text, ctx) {
|
|
|
682
761
|
});
|
|
683
762
|
});
|
|
684
763
|
}
|
|
685
|
-
function buildVars(setDefs, context
|
|
764
|
+
function buildVars(setDefs, context) {
|
|
686
765
|
const vars = {};
|
|
687
766
|
for (const [name, value] of Object.entries(setDefs)) {
|
|
688
|
-
vars[name] =
|
|
767
|
+
vars[name] = value;
|
|
689
768
|
}
|
|
690
769
|
for (const [name, value] of Object.entries(context)) {
|
|
691
770
|
vars[name.toLowerCase()] = value;
|
|
692
771
|
}
|
|
693
772
|
return vars;
|
|
694
773
|
}
|
|
695
|
-
function
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
774
|
+
function rollDefinitions(defDefs, vars, context, opts) {
|
|
775
|
+
const outranked = new Set(Object.keys(context).map((key) => key.toLowerCase()));
|
|
776
|
+
const rolled = {};
|
|
777
|
+
const aliases = {};
|
|
778
|
+
for (const [name, value] of Object.entries(vars)) {
|
|
779
|
+
if (name in defDefs && !outranked.has(name)) continue;
|
|
780
|
+
aliases[name] = value;
|
|
781
|
+
}
|
|
782
|
+
for (const name of orderDefinitions(defDefs, aliases)) {
|
|
783
|
+
if (outranked.has(name)) continue;
|
|
784
|
+
const value = defDefs[name] ?? "";
|
|
785
|
+
rolled[name] = renderNodes(parseSequence(value), { ...opts, vars: { ...vars, ...rolled } });
|
|
786
|
+
}
|
|
787
|
+
return rolled;
|
|
788
|
+
}
|
|
789
|
+
function orderDefinitions(defDefs, aliases) {
|
|
790
|
+
const names = Object.keys(defDefs);
|
|
791
|
+
const blocked = /* @__PURE__ */ new Map();
|
|
792
|
+
for (const name of names) {
|
|
793
|
+
const reached = referencedNames(defDefs[name] ?? "", aliases);
|
|
794
|
+
blocked.set(name, new Set(names.filter((candidate) => reached.has(candidate))));
|
|
795
|
+
}
|
|
796
|
+
const ordered = [];
|
|
797
|
+
let pending = names;
|
|
798
|
+
while (pending.length > 0) {
|
|
799
|
+
const ready = pending.filter((name) => {
|
|
800
|
+
const deps = blocked.get(name);
|
|
801
|
+
return !deps || ![...deps].some((dep) => dep !== name && pending.includes(dep));
|
|
708
802
|
});
|
|
709
|
-
if (
|
|
803
|
+
if (ready.length === 0) return [...ordered, ...pending];
|
|
804
|
+
ordered.push(...ready);
|
|
805
|
+
pending = pending.filter((name) => !ready.includes(name));
|
|
710
806
|
}
|
|
711
|
-
return
|
|
807
|
+
return ordered;
|
|
808
|
+
}
|
|
809
|
+
function referencedNames(value, aliases) {
|
|
810
|
+
const seen = /* @__PURE__ */ new Set();
|
|
811
|
+
const queue = directReferences(value);
|
|
812
|
+
while (queue.length > 0) {
|
|
813
|
+
const name = queue.shift();
|
|
814
|
+
if (seen.has(name)) continue;
|
|
815
|
+
seen.add(name);
|
|
816
|
+
const alias = aliases[name];
|
|
817
|
+
if (alias !== void 0) queue.push(...directReferences(alias));
|
|
818
|
+
}
|
|
819
|
+
return seen;
|
|
820
|
+
}
|
|
821
|
+
function directReferences(text) {
|
|
822
|
+
return [...text.matchAll(/%(\w+)%/gu)].map((match) => (match[1] ?? "").toLowerCase());
|
|
712
823
|
}
|
|
713
824
|
function renderNodes(nodes, opts) {
|
|
714
825
|
let out = "";
|
|
@@ -768,7 +879,7 @@ function renderPlural(node, opts) {
|
|
|
768
879
|
if (/[{}[\]]/u.test(formsRaw)) {
|
|
769
880
|
report({
|
|
770
881
|
code: "plural.nested-brackets",
|
|
771
|
-
message: "Plural form slot contains nested spintax brackets; extract via #set
|
|
882
|
+
message: "Plural form slot contains nested spintax brackets; extract via #def first \u2014 a #set is substituted verbatim and would put the brackets straight back.",
|
|
772
883
|
construct: rawConstruct(countRaw, formsRaw),
|
|
773
884
|
locale: base
|
|
774
885
|
});
|
|
@@ -867,26 +978,6 @@ function padSeparator(sep) {
|
|
|
867
978
|
if (/^\p{L}+$/u.test(trimmed)) return ` ${trimmed} `;
|
|
868
979
|
return sep;
|
|
869
980
|
}
|
|
870
|
-
function splitTopLevelPipes(inner) {
|
|
871
|
-
const parts = [];
|
|
872
|
-
let brace = 0;
|
|
873
|
-
let bracket = 0;
|
|
874
|
-
let cur = "";
|
|
875
|
-
for (const ch of inner) {
|
|
876
|
-
if (ch === "{") brace += 1;
|
|
877
|
-
else if (ch === "}") brace -= 1;
|
|
878
|
-
else if (ch === "[") bracket += 1;
|
|
879
|
-
else if (ch === "]") bracket -= 1;
|
|
880
|
-
if (ch === "|" && brace === 0 && bracket === 0) {
|
|
881
|
-
parts.push(cur);
|
|
882
|
-
cur = "";
|
|
883
|
-
} else {
|
|
884
|
-
cur += ch;
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
parts.push(cur);
|
|
888
|
-
return parts;
|
|
889
|
-
}
|
|
890
981
|
var PHP_LTRIM_RE2 = /^[ \t\n\r\0\x0B]+/u;
|
|
891
982
|
var PHP_RTRIM_RE2 = /[ \t\n\r\0\x0B]+$/u;
|
|
892
983
|
function phpTrim2(s) {
|
|
@@ -1022,7 +1113,7 @@ function renderWith(input, rng, opts = {}) {
|
|
|
1022
1113
|
includeStack: [],
|
|
1023
1114
|
onPluralError: opts.onPluralError
|
|
1024
1115
|
};
|
|
1025
|
-
const ast = resolveAst(
|
|
1116
|
+
const ast = resolveAst(input);
|
|
1026
1117
|
let out = renderAst(ast, ctx);
|
|
1027
1118
|
if (opts.postProcess !== false) out = postProcess(out);
|
|
1028
1119
|
return safetyRestore(out);
|