@spintax/core 0.1.6 → 0.3.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 +148 -0
- package/README.md +4 -3
- package/dist/index.cjs +209 -67
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +105 -3
- package/dist/index.d.ts +105 -3
- package/dist/index.js +208 -68
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,154 @@
|
|
|
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.0 — 2026-07-19
|
|
7
|
+
|
|
8
|
+
`#set` goes back to being a macro and a new `#def` carries roll-once. Breaking: it changes what
|
|
9
|
+
existing templates mean. Ships in lockstep with the WordPress plugin 3.0.0, `spintax/core` and the
|
|
10
|
+
OpenCart port; the corpus landed last, after both PHP engines.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **`#set` is a macro.** The value is substituted at every `%var%` reference and whatever brackets
|
|
15
|
+
it holds resolve independently each time. Until now an enumeration-valued `#set` collapsed once at
|
|
16
|
+
set-time; that behaviour moved to `#def`.
|
|
17
|
+
|
|
18
|
+
Worth recording *why* the revert, because the reasoning is not recoverable from the diff:
|
|
19
|
+
collapse-once was the newcomer. It shipped in the plugin on 2026-07-04, was announced in one
|
|
20
|
+
changelog line, and contradicted the project's published documentation from the day it landed.
|
|
21
|
+
Macro expansion is what the engine did before that and what consumers written against those docs
|
|
22
|
+
assume.
|
|
23
|
+
|
|
24
|
+
- **`AST_VERSION` 1 → 2.** `ParsedAst` gained `defDefs`. An `Ast` cached by an older version carries
|
|
25
|
+
no definition map, so rendering it would silently drop every `#def`; the version guard turns that
|
|
26
|
+
into an `AstVersionError` instead.
|
|
27
|
+
|
|
28
|
+
- **`extract()` reports `defs`** alongside `sets`. Additive for readers, but a consumer building a
|
|
29
|
+
variable list from `sets` alone will now miss `#def` names.
|
|
30
|
+
|
|
31
|
+
- **`plural.nested-brackets` advice.** "Extract via `#set` first" was correct under collapse-once and
|
|
32
|
+
is wrong under a macro — the value is substituted verbatim and puts the brackets straight back into
|
|
33
|
+
the form slot, raising the very error it was meant to avoid. It now says `#def`.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **`#def %var% = value` — roll-once.** The value is rendered once per render, as if it were a
|
|
38
|
+
miniature body, and the result is held for every reference. It covers enumerations *and*
|
|
39
|
+
permutations, resolves **after** the merged context exists (so it can read globals and runtime
|
|
40
|
+
variables; a runtime variable of the same name outranks it), and resolves in dependency order —
|
|
41
|
+
an order that follows aliases **through** macro values, since a `#def` can reach another `#def` by
|
|
42
|
+
way of a `#set` expanded only at reference time.
|
|
43
|
+
|
|
44
|
+
This is where a plural counter now lives: `#def %n% = {1|4|9}` then `{plural %n%: …}` prints and
|
|
45
|
+
agrees the same number. Under `#set` the two disagree and the block is dropped.
|
|
46
|
+
|
|
47
|
+
- **Four diagnostics**: `def.malformed`, `definition.duplicate-name` (a name belongs to one
|
|
48
|
+
directive, once — this also closes silently-last-wins duplicate `#set`s), `def.include-in-value`
|
|
49
|
+
(includes resolve after a value is frozen; legal inside a `#set`), and `plural.count-macro`.
|
|
50
|
+
|
|
51
|
+
`plural.count-macro` is decided by **stage order, not bracket type**: conditionals resolve before
|
|
52
|
+
plurals and are exempt, enumerations and permutations resolve after and are not, and a nested
|
|
53
|
+
`{plural …}` resolves in the *same* pass so it is not exempt either. Taint propagates through
|
|
54
|
+
`#set` → `#set` references to a fixpoint.
|
|
55
|
+
|
|
56
|
+
### Corpus
|
|
57
|
+
|
|
58
|
+
The difference between the directives is a difference in how many RNG draws a render consumes, so
|
|
59
|
+
seeded-sequence fixtures pin it exactly and cross-engine — `set/macro-re-rolls-at-every-reference`
|
|
60
|
+
and `def/rolled-once-and-held` share a template and a sequence and differ only in whether the second
|
|
61
|
+
draw is reached. The corpus grew 138 → 160 cases, green against this engine, the WordPress plugin
|
|
62
|
+
and `spintax/core`.
|
|
63
|
+
|
|
64
|
+
The previous rule here said collapse could only be pinned with RNG-free values, and the consequence
|
|
65
|
+
was that **nothing pinned it at all**: the semantics flipped in the plugin without a single fixture
|
|
66
|
+
noticing. All-identical alternatives are not sufficient either — they render the same under both
|
|
67
|
+
semantics and would pass against an engine implementing `#def` as an alias of `#set`.
|
|
68
|
+
|
|
69
|
+
### Migration
|
|
70
|
+
|
|
71
|
+
A `#set` whose value is an enumeration or permutation *and* which is referenced more than once for
|
|
72
|
+
consistency — a plural counter, a brand name that must not vary mid-sentence — becomes `#def`. One
|
|
73
|
+
line per definition; references are untouched.
|
|
74
|
+
|
|
75
|
+
## 0.2.0 — 2026-07-18
|
|
76
|
+
|
|
77
|
+
Serbian, Croatian and Bosnian join the 3-form plural family; the plural error model and the
|
|
78
|
+
locale helpers that go with it land in the same release. Minor, not patch: the BCS change
|
|
79
|
+
below alters a **validation verdict**, which §0.1 classes as breaking.
|
|
80
|
+
|
|
81
|
+
Nothing here shipped separately — 0.2.0 was never published, so these additions fold into it
|
|
82
|
+
rather than minting a version between them. That matters for `@spintax/authoring-prompt`,
|
|
83
|
+
whose peer range `>=0.2.0` would otherwise be satisfiable by a 0.2.0 without the exports it
|
|
84
|
+
now imports at runtime.
|
|
85
|
+
|
|
86
|
+
### Added
|
|
87
|
+
|
|
88
|
+
- **`pluralArity(locale?)` and `normalizeBaseLang(locale?)` are public.** Exported so a
|
|
89
|
+
consumer that must AGREE with the engine about plurals can ask it instead of keeping a copy
|
|
90
|
+
of the table. The copy is not hypothetical: this repo's own authoring prompt kept one and it
|
|
91
|
+
drifted twice — once in content (a locale added here and not there) and once in shape
|
|
92
|
+
(`locale.slice(0, 2)`, which disagrees with this normalization on any 3-letter tag).
|
|
93
|
+
|
|
94
|
+
`pluralArity` takes a RAW locale and normalizes internally, unlike the internal helper of the
|
|
95
|
+
same name — a public function answering 2 for `sr-Latn` would be a trap. Both accept an absent
|
|
96
|
+
locale, matching `RenderOptions.locale?` / `ValidateOptions.locale?`, so the same optional
|
|
97
|
+
value can be threaded straight through.
|
|
98
|
+
|
|
99
|
+
Note one asymmetry: an absent locale makes `validate()` skip the arity check entirely, while
|
|
100
|
+
`pluralArity` answers the 2-form default `render()` would apply. It tells you what render will
|
|
101
|
+
do, not what validate will enforce.
|
|
102
|
+
|
|
103
|
+
`findPluralBlocks` is deliberately NOT exported alongside them — it returns byte offsets into
|
|
104
|
+
the source, and publishing it would freeze a parser internal and invite consumers onto the
|
|
105
|
+
parse layer, the same reason `Ast` is opaque.
|
|
106
|
+
|
|
107
|
+
- **`RenderOptions.onPluralError` — an observer for unresolvable `{plural …}` blocks.**
|
|
108
|
+
The port of the plugin's `on_error` callable, and the missing half of its error model:
|
|
109
|
+
this engine already behaved exactly like the plugin's *lenient* mode (arity mismatch and
|
|
110
|
+
nested-bracket errors degrade to fullwidth-brace verbatim, an unresolved count erases the
|
|
111
|
+
block), but there was no way to learn that it had happened.
|
|
112
|
+
|
|
113
|
+
Observation only. Output is byte-identical with and without the callback, and `render()`
|
|
114
|
+
still never throws on template content (§9.3) — the host decides whether a report is
|
|
115
|
+
fatal. A report carries the diagnostic `code`, the construct **as the renderer saw it**
|
|
116
|
+
(after variable expansion), the normalized `locale`, and `expected`/`got` for arity.
|
|
117
|
+
|
|
118
|
+
The `plural.count` code has no `validate()` counterpart on purpose: an unresolved count is
|
|
119
|
+
a runtime-value fact that static analysis cannot see. It is also the case that most needs
|
|
120
|
+
the seam — erasing leaves no trace, so a host persisting a render otherwise cannot tell an
|
|
121
|
+
unsubstituted `%Var%` from copy that was meant to be empty.
|
|
122
|
+
|
|
123
|
+
- **BCS plural buckets — `sr`, `hr`, `bs`.** On integers, BCS shares the East-Slavic rule
|
|
124
|
+
character for character (`mod10===1 && mod100!==11` → one; `mod10∈[2,4] && mod100∉[12,14]`
|
|
125
|
+
→ few; else → many), so it reuses that branch rather than getting its own. CLDR names the
|
|
126
|
+
third bucket `other` rather than `many` — positionally the same slot. The genuine
|
|
127
|
+
BCS/East-Slavic divergence is fractional-only and unreachable here, since a non-numeric
|
|
128
|
+
count slot is erased to `''` before the bucket math (§3.1).
|
|
129
|
+
|
|
130
|
+
**Script and region subtags carry no plural grammar.** `sr-Latn`, `sr-Cyrl` and `sr_RS` all
|
|
131
|
+
normalise to `sr` and pick identical buckets; the script lives only in the author's form text.
|
|
132
|
+
Corpus fixtures pin each form, and the arity fixtures pin that a script subtag does not rescue
|
|
133
|
+
a 2-form template. The ladder runs past two digits, where `mod100` has to beat `mod10`: 101 is
|
|
134
|
+
`one`, 111 is not.
|
|
135
|
+
|
|
136
|
+
Render fixtures probe with `sat` (1 sat / 2 sata / 5 sati) because all three buckets differ
|
|
137
|
+
there. The tempting `bonus|bonusa|bonusa` collapses few and other, so it would pass against a
|
|
138
|
+
broken rule.
|
|
139
|
+
|
|
140
|
+
Landed in all three engines at once — `@spintax/core`, the WordPress plugin 2.5.0, and
|
|
141
|
+
`spintax/core` 0.2.0 — because plural buckets are a parity-required item and the golden
|
|
142
|
+
corpus gates every engine.
|
|
143
|
+
|
|
144
|
+
### Changed
|
|
145
|
+
|
|
146
|
+
- **BREAKING (verdict): `{plural 2: one|many}` under `sr`/`hr`/`bs` is now `plural.arity`.**
|
|
147
|
+
Previously these locales fell through to the EN-style 2-form default, so a 2-form BCS
|
|
148
|
+
template validated clean and rendered from the wrong bucket set. Any existing BCS template
|
|
149
|
+
must grow a third form. No other locale changes behaviour.
|
|
150
|
+
- The LLM authoring prompt (`@spintax/authoring-prompt`) now emits a BCS grammar block —
|
|
151
|
+
agreement rules, the 3-bucket warning, and a "do not mix Latin and Cyrillic inside one
|
|
152
|
+
template" rule. Without it a model writes 2-form BCS that the validator then rejects.
|
|
153
|
+
|
|
6
154
|
## 0.1.6 — 2026-07-13
|
|
7
155
|
|
|
8
156
|
Two post-process fixes. **Supersedes 0.1.5** — upgrade straight past it. No API change.
|
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) |
|
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);
|
|
@@ -25,21 +25,39 @@ function isParsedAst(value) {
|
|
|
25
25
|
|
|
26
26
|
// src/internal/parser.ts
|
|
27
27
|
var VARIABLE_RE = /^%(\w+)%/;
|
|
28
|
-
var
|
|
28
|
+
var DIRECTIVE_RE = /^[ \t]*#(set|def)[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)[ \t]*\r?$/gmu;
|
|
29
29
|
var CONDITIONAL_NAME_RE = /^[A-Za-z_]\w*/;
|
|
30
30
|
var PLURAL_PREFIX = "plural ";
|
|
31
31
|
function parseTemplate(src) {
|
|
32
|
-
const { body, setDefs } =
|
|
33
|
-
return { astVersion: AST_VERSION, source: src, setDefs, nodes: parseSequence(body) };
|
|
32
|
+
const { body, setDefs, defDefs } = extractDirectives(stripComments(src));
|
|
33
|
+
return { astVersion: AST_VERSION, source: src, setDefs, defDefs, nodes: parseSequence(body) };
|
|
34
34
|
}
|
|
35
|
-
function
|
|
35
|
+
function extractDirectives(text) {
|
|
36
36
|
const setDefs = {};
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
37
|
+
const defDefs = {};
|
|
38
|
+
const occurrences = [];
|
|
39
|
+
DIRECTIVE_RE.lastIndex = 0;
|
|
40
|
+
const stripped = text.replace(
|
|
41
|
+
DIRECTIVE_RE,
|
|
42
|
+
(full, kind, rawName, value, offset) => {
|
|
43
|
+
const name = rawName.toLowerCase();
|
|
44
|
+
occurrences.push({
|
|
45
|
+
kind,
|
|
46
|
+
name,
|
|
47
|
+
value,
|
|
48
|
+
line: countLines(text, offset)
|
|
49
|
+
});
|
|
50
|
+
if (kind === "def") defDefs[name] = value;
|
|
51
|
+
else setDefs[name] = value;
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
return { body: stripped.replace(/\n{3,}/gu, "\n\n"), setDefs, defDefs, occurrences };
|
|
56
|
+
}
|
|
57
|
+
function countLines(text, offset) {
|
|
58
|
+
let line = 1;
|
|
59
|
+
for (let i = 0; i < offset; i += 1) if (text[i] === "\n") line += 1;
|
|
60
|
+
return line;
|
|
43
61
|
}
|
|
44
62
|
function stripComments(text) {
|
|
45
63
|
return text.replace(/\/#[\s\S]*?#\//g, "");
|
|
@@ -344,6 +362,9 @@ function pluralArity(baseLang) {
|
|
|
344
362
|
case "ru":
|
|
345
363
|
case "uk":
|
|
346
364
|
case "be":
|
|
365
|
+
case "sr":
|
|
366
|
+
case "hr":
|
|
367
|
+
case "bs":
|
|
347
368
|
return 3;
|
|
348
369
|
default:
|
|
349
370
|
return 2;
|
|
@@ -357,6 +378,9 @@ function pluralFor(baseLang, n, forms) {
|
|
|
357
378
|
case "ru":
|
|
358
379
|
case "uk":
|
|
359
380
|
case "be":
|
|
381
|
+
case "sr":
|
|
382
|
+
case "hr":
|
|
383
|
+
case "bs":
|
|
360
384
|
if (mod10 === 1 && mod100 !== 11) return forms[0] ?? "";
|
|
361
385
|
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return forms[1] ?? "";
|
|
362
386
|
return forms[2] ?? "";
|
|
@@ -372,7 +396,7 @@ function validateTemplate(src, opts = {}) {
|
|
|
372
396
|
const diagnostics = [];
|
|
373
397
|
const text = stripComments(src);
|
|
374
398
|
checkBrackets(text, diagnostics);
|
|
375
|
-
|
|
399
|
+
checkDirectives(text, diagnostics);
|
|
376
400
|
checkPermutationConfigs(text, diagnostics);
|
|
377
401
|
checkPlurals(text, opts.locale, diagnostics);
|
|
378
402
|
checkVariableReferences(text, opts.knownVariables, diagnostics);
|
|
@@ -431,17 +455,43 @@ function checkBrackets(text, out) {
|
|
|
431
455
|
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 } }));
|
|
432
456
|
}
|
|
433
457
|
}
|
|
434
|
-
function
|
|
458
|
+
function checkDirectives(text, out) {
|
|
435
459
|
const lines = text.split("\n");
|
|
436
460
|
lines.forEach((lineText, idx) => {
|
|
437
461
|
const trimmed = lineText.replace(/^[ \t]+/, "");
|
|
438
|
-
|
|
439
|
-
|
|
462
|
+
const kind = ["#set", "#def"].find(
|
|
463
|
+
(candidate) => trimmed.startsWith(`${candidate} `) || trimmed.startsWith(`${candidate} `)
|
|
464
|
+
);
|
|
465
|
+
if (!kind) return;
|
|
466
|
+
DIRECTIVE_RE.lastIndex = 0;
|
|
467
|
+
if (!DIRECTIVE_RE.test(trimmed)) {
|
|
440
468
|
const column = lineText.length - trimmed.length + 1;
|
|
441
469
|
const line = idx + 1;
|
|
442
|
-
|
|
470
|
+
const code = kind === "#def" ? "def.malformed" : "set.malformed";
|
|
471
|
+
out.push(err(code, `Malformed ${kind}. Expected: ${kind} %name% = value`, { line, column, endLine: line, endColumn: lineText.length + 1 }));
|
|
443
472
|
}
|
|
444
473
|
});
|
|
474
|
+
const { occurrences } = extractDirectives(text);
|
|
475
|
+
const seen = /* @__PURE__ */ new Map();
|
|
476
|
+
for (const occurrence of occurrences) {
|
|
477
|
+
const first = seen.get(occurrence.name);
|
|
478
|
+
if (first !== void 0) {
|
|
479
|
+
out.push(err(
|
|
480
|
+
"definition.duplicate-name",
|
|
481
|
+
`Variable '${occurrence.name}' is defined more than once (first on line ${first}). A name belongs to one directive, once.`,
|
|
482
|
+
{ line: occurrence.line, column: 1 }
|
|
483
|
+
));
|
|
484
|
+
} else {
|
|
485
|
+
seen.set(occurrence.name, occurrence.line);
|
|
486
|
+
}
|
|
487
|
+
if (occurrence.kind === "def" && /#include\b/u.test(occurrence.value)) {
|
|
488
|
+
out.push(err(
|
|
489
|
+
"def.include-in-value",
|
|
490
|
+
`#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.`,
|
|
491
|
+
{ line: occurrence.line, column: 1 }
|
|
492
|
+
));
|
|
493
|
+
}
|
|
494
|
+
}
|
|
445
495
|
}
|
|
446
496
|
function checkPermutationConfigs(text, out) {
|
|
447
497
|
for (const m of text.matchAll(/\[<([^>]*?)>/gu)) {
|
|
@@ -479,10 +529,20 @@ function checkPermutationConfigs(text, out) {
|
|
|
479
529
|
function checkPlurals(text, locale, out) {
|
|
480
530
|
const base = locale && locale !== "" ? normalizeBaseLang(locale) : "";
|
|
481
531
|
const arity = base !== "" ? pluralArity(base) : 0;
|
|
532
|
+
const tainted = macroTaintedNames(text);
|
|
482
533
|
for (const block of findPluralBlocks(text)) {
|
|
483
534
|
const at = span(text, block.start, block.end - block.start);
|
|
535
|
+
for (const m of block.countSlot.matchAll(/%(\w+)%/gu)) {
|
|
536
|
+
const name = (m[1] ?? "").toLowerCase();
|
|
537
|
+
if (!tainted.has(name)) continue;
|
|
538
|
+
out.push(err(
|
|
539
|
+
"plural.count-macro",
|
|
540
|
+
`{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.`,
|
|
541
|
+
at
|
|
542
|
+
));
|
|
543
|
+
}
|
|
484
544
|
if (/[{}[\]]/.test(block.formsRaw)) {
|
|
485
|
-
out.push(err("plural.nested-brackets", "{plural ...}: forms must not contain nested spintax brackets.", at));
|
|
545
|
+
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));
|
|
486
546
|
continue;
|
|
487
547
|
}
|
|
488
548
|
if (arity > 0) {
|
|
@@ -497,11 +557,33 @@ function checkPlurals(text, locale, out) {
|
|
|
497
557
|
}
|
|
498
558
|
}
|
|
499
559
|
}
|
|
560
|
+
var UNRESOLVED_AT_PLURAL_TIME = /\[|\{(?!\?)/u;
|
|
561
|
+
function macroTaintedNames(text) {
|
|
562
|
+
const macros = extractDirectives(text).setDefs;
|
|
563
|
+
const tainted = /* @__PURE__ */ new Set();
|
|
564
|
+
for (const [name, value] of Object.entries(macros)) {
|
|
565
|
+
if (UNRESOLVED_AT_PLURAL_TIME.test(value)) tainted.add(name);
|
|
566
|
+
}
|
|
567
|
+
let grew = true;
|
|
568
|
+
while (grew) {
|
|
569
|
+
grew = false;
|
|
570
|
+
for (const [name, value] of Object.entries(macros)) {
|
|
571
|
+
if (tainted.has(name)) continue;
|
|
572
|
+
for (const m of value.matchAll(/%(\w+)%/gu)) {
|
|
573
|
+
if (!tainted.has((m[1] ?? "").toLowerCase())) continue;
|
|
574
|
+
tainted.add(name);
|
|
575
|
+
grew = true;
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return tainted;
|
|
581
|
+
}
|
|
500
582
|
function checkVariableReferences(text, known, out) {
|
|
501
583
|
const knownSet = new Set((known ?? []).map((n) => n.toLowerCase()));
|
|
502
584
|
const defs = /* @__PURE__ */ new Map();
|
|
503
585
|
const defPos = /* @__PURE__ */ new Map();
|
|
504
|
-
for (const m of text.matchAll(/^[ \t]*#set[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)$/gmu)) {
|
|
586
|
+
for (const m of text.matchAll(/^[ \t]*#(?:set|def)[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)$/gmu)) {
|
|
505
587
|
const name = (m[1] ?? "").toLowerCase();
|
|
506
588
|
defs.set(name, m[2] ?? "");
|
|
507
589
|
const nameOffset = (m.index ?? 0) + m[0].indexOf("%");
|
|
@@ -516,7 +598,7 @@ function checkVariableReferences(text, known, out) {
|
|
|
516
598
|
for (const name of defs.keys()) {
|
|
517
599
|
detectCycle(name, defs, [name], defPos.get(name) ?? somewhere, out);
|
|
518
600
|
}
|
|
519
|
-
const body = text.replace(/^[ \t]*#set[ \t]+%\w+%[ \t]*=[ \t]*.*?$/gmu, (m) => m.replace(/[^\n]/g, " "));
|
|
601
|
+
const body = text.replace(/^[ \t]*#(?:set|def)[ \t]+%\w+%[ \t]*=[ \t]*.*?$/gmu, (m) => m.replace(/[^\n]/g, " "));
|
|
520
602
|
const seen = /* @__PURE__ */ new Set();
|
|
521
603
|
const undefinedAt = (name, offset, length) => {
|
|
522
604
|
const key = name.toLowerCase();
|
|
@@ -563,19 +645,21 @@ function checkIncludeTargets(text, known, out) {
|
|
|
563
645
|
|
|
564
646
|
// src/internal/extract.ts
|
|
565
647
|
var SET_DEF_RE = /^[ \t]*#set[ \t]+%(\w+)%[ \t]*=/gmu;
|
|
566
|
-
var
|
|
648
|
+
var DEF_DEF_RE = /^[ \t]*#def[ \t]+%(\w+)%[ \t]*=/gmu;
|
|
649
|
+
var DEFINITION_LHS_RE = /^[ \t]*#(?:set|def)[ \t]+%\w+%[ \t]*=/gmu;
|
|
567
650
|
var INCLUDE_RE2 = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
|
|
568
651
|
var VARIABLE_RE2 = /%(\w+)%/gu;
|
|
569
652
|
var CONDITIONAL_REF_RE = /\{\?!?([A-Za-z_]\w*)\?/gu;
|
|
570
653
|
function extractFromSource(src) {
|
|
571
654
|
const text = stripComments(src);
|
|
572
655
|
const sets = collect(text, SET_DEF_RE, true);
|
|
656
|
+
const defs = collect(text, DEF_DEF_RE, true);
|
|
573
657
|
const includes = collect(text, INCLUDE_RE2, false);
|
|
574
|
-
const body = text.replace(
|
|
658
|
+
const body = text.replace(DEFINITION_LHS_RE, "");
|
|
575
659
|
const refs = /* @__PURE__ */ new Set();
|
|
576
660
|
addAll(refs, body, VARIABLE_RE2, true);
|
|
577
661
|
addAll(refs, body, CONDITIONAL_REF_RE, true);
|
|
578
|
-
return { refs: [...refs], sets, includes };
|
|
662
|
+
return { refs: [...refs], sets, defs, includes };
|
|
579
663
|
}
|
|
580
664
|
function collect(text, re, fold) {
|
|
581
665
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -647,8 +731,15 @@ function stripSentinels(text) {
|
|
|
647
731
|
var MAX_VARIABLE_DEPTH = 50;
|
|
648
732
|
var INCLUDE_LINE_RE = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
|
|
649
733
|
function renderAst(ast, ctx) {
|
|
650
|
-
const
|
|
651
|
-
const
|
|
734
|
+
const base = buildVars(ast.setDefs, ctx.runtimeContext);
|
|
735
|
+
const walkOpts = {
|
|
736
|
+
rng: ctx.rng,
|
|
737
|
+
locale: ctx.locale,
|
|
738
|
+
depth: 0,
|
|
739
|
+
onPluralError: ctx.onPluralError
|
|
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 });
|
|
652
743
|
return ctx.resolver ? resolveIncludes(text, ctx) : text;
|
|
653
744
|
}
|
|
654
745
|
function resolveIncludes(text, ctx) {
|
|
@@ -670,33 +761,65 @@ function resolveIncludes(text, ctx) {
|
|
|
670
761
|
});
|
|
671
762
|
});
|
|
672
763
|
}
|
|
673
|
-
function buildVars(setDefs, context
|
|
764
|
+
function buildVars(setDefs, context) {
|
|
674
765
|
const vars = {};
|
|
675
766
|
for (const [name, value] of Object.entries(setDefs)) {
|
|
676
|
-
vars[name] =
|
|
767
|
+
vars[name] = value;
|
|
677
768
|
}
|
|
678
769
|
for (const [name, value] of Object.entries(context)) {
|
|
679
770
|
vars[name.toLowerCase()] = value;
|
|
680
771
|
}
|
|
681
772
|
return vars;
|
|
682
773
|
}
|
|
683
|
-
function
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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));
|
|
696
802
|
});
|
|
697
|
-
if (
|
|
803
|
+
if (ready.length === 0) return [...ordered, ...pending];
|
|
804
|
+
ordered.push(...ready);
|
|
805
|
+
pending = pending.filter((name) => !ready.includes(name));
|
|
698
806
|
}
|
|
699
|
-
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());
|
|
700
823
|
}
|
|
701
824
|
function renderNodes(nodes, opts) {
|
|
702
825
|
let out = "";
|
|
@@ -751,17 +874,47 @@ function renderConditional(node, opts) {
|
|
|
751
874
|
function renderPlural(node, opts) {
|
|
752
875
|
const countRaw = expandVarsOnly(node.countRaw, opts);
|
|
753
876
|
const formsRaw = expandVarsOnly(node.formsRaw, opts);
|
|
754
|
-
if (/[{}[\]]/u.test(formsRaw)) return fullwidthVerbatim(countRaw, formsRaw);
|
|
755
|
-
const count = phpTrim2(countRaw);
|
|
756
|
-
if (!/^-?\d+$/u.test(count)) return "";
|
|
757
877
|
const base = normalizeBaseLang(opts.locale);
|
|
878
|
+
const report = (issue) => opts.onPluralError?.(issue);
|
|
879
|
+
if (/[{}[\]]/u.test(formsRaw)) {
|
|
880
|
+
report({
|
|
881
|
+
code: "plural.nested-brackets",
|
|
882
|
+
message: "Plural form slot contains nested spintax brackets; extract via #set first.",
|
|
883
|
+
construct: rawConstruct(countRaw, formsRaw),
|
|
884
|
+
locale: base
|
|
885
|
+
});
|
|
886
|
+
return fullwidthVerbatim(countRaw, formsRaw);
|
|
887
|
+
}
|
|
888
|
+
const count = phpTrim2(countRaw);
|
|
889
|
+
if (!/^-?\d+$/u.test(count)) {
|
|
890
|
+
report({
|
|
891
|
+
code: "plural.count",
|
|
892
|
+
message: `Plural count slot is empty or non-numeric (${JSON.stringify(count)}); block erased.`,
|
|
893
|
+
construct: rawConstruct(countRaw, formsRaw),
|
|
894
|
+
locale: base
|
|
895
|
+
});
|
|
896
|
+
return "";
|
|
897
|
+
}
|
|
758
898
|
const forms = formsRaw.split("|").map((f) => phpTrim2(f));
|
|
759
|
-
if (forms.length !== pluralArity(base))
|
|
899
|
+
if (forms.length !== pluralArity(base)) {
|
|
900
|
+
report({
|
|
901
|
+
code: "plural.arity",
|
|
902
|
+
message: `Plural has ${forms.length} form(s); locale "${base}" takes ${pluralArity(base)}.`,
|
|
903
|
+
construct: rawConstruct(countRaw, formsRaw),
|
|
904
|
+
locale: base,
|
|
905
|
+
expected: pluralArity(base),
|
|
906
|
+
got: forms.length
|
|
907
|
+
});
|
|
908
|
+
return fullwidthVerbatim(countRaw, formsRaw);
|
|
909
|
+
}
|
|
760
910
|
const picked = pluralFor(base, Number.parseInt(count, 10), forms);
|
|
761
911
|
return renderNodes(parseSequence(picked), opts);
|
|
762
912
|
}
|
|
913
|
+
function rawConstruct(countRaw, formsRaw) {
|
|
914
|
+
return `{plural ${countRaw}:${formsRaw}}`;
|
|
915
|
+
}
|
|
763
916
|
function fullwidthVerbatim(countRaw, formsRaw) {
|
|
764
|
-
return
|
|
917
|
+
return rawConstruct(countRaw, formsRaw).replace(/\{/gu, "\uFF5B").replace(/\}/gu, "\uFF5D");
|
|
765
918
|
}
|
|
766
919
|
function renderEnumeration(options, opts) {
|
|
767
920
|
if (options.length === 0) return "";
|
|
@@ -825,26 +978,6 @@ function padSeparator(sep) {
|
|
|
825
978
|
if (/^\p{L}+$/u.test(trimmed)) return ` ${trimmed} `;
|
|
826
979
|
return sep;
|
|
827
980
|
}
|
|
828
|
-
function splitTopLevelPipes(inner) {
|
|
829
|
-
const parts = [];
|
|
830
|
-
let brace = 0;
|
|
831
|
-
let bracket = 0;
|
|
832
|
-
let cur = "";
|
|
833
|
-
for (const ch of inner) {
|
|
834
|
-
if (ch === "{") brace += 1;
|
|
835
|
-
else if (ch === "}") brace -= 1;
|
|
836
|
-
else if (ch === "[") bracket += 1;
|
|
837
|
-
else if (ch === "]") bracket -= 1;
|
|
838
|
-
if (ch === "|" && brace === 0 && bracket === 0) {
|
|
839
|
-
parts.push(cur);
|
|
840
|
-
cur = "";
|
|
841
|
-
} else {
|
|
842
|
-
cur += ch;
|
|
843
|
-
}
|
|
844
|
-
}
|
|
845
|
-
parts.push(cur);
|
|
846
|
-
return parts;
|
|
847
|
-
}
|
|
848
981
|
var PHP_LTRIM_RE2 = /^[ \t\n\r\0\x0B]+/u;
|
|
849
982
|
var PHP_RTRIM_RE2 = /[ \t\n\r\0\x0B]+$/u;
|
|
850
983
|
function phpTrim2(s) {
|
|
@@ -977,7 +1110,8 @@ function renderWith(input, rng, opts = {}) {
|
|
|
977
1110
|
locale: opts.locale ?? "",
|
|
978
1111
|
resolver: opts.includeResolver,
|
|
979
1112
|
maxDepth: opts.maxDepth ?? DEFAULT_MAX_DEPTH,
|
|
980
|
-
includeStack: []
|
|
1113
|
+
includeStack: [],
|
|
1114
|
+
onPluralError: opts.onPluralError
|
|
981
1115
|
};
|
|
982
1116
|
const ast = resolveAst(typeof input === "string" ? stripSentinels(input) : input);
|
|
983
1117
|
let out = renderAst(ast, ctx);
|
|
@@ -1014,6 +1148,12 @@ function hashString(s) {
|
|
|
1014
1148
|
}
|
|
1015
1149
|
|
|
1016
1150
|
// src/index.ts
|
|
1151
|
+
function normalizeBaseLang2(locale) {
|
|
1152
|
+
return normalizeBaseLang(locale ?? "");
|
|
1153
|
+
}
|
|
1154
|
+
function pluralArity2(locale) {
|
|
1155
|
+
return pluralArity(normalizeBaseLang2(locale));
|
|
1156
|
+
}
|
|
1017
1157
|
function parse(src) {
|
|
1018
1158
|
return parseTemplate(src);
|
|
1019
1159
|
}
|
|
@@ -1072,7 +1212,9 @@ exports.SpintaxError = SpintaxError;
|
|
|
1072
1212
|
exports.analyze = analyze;
|
|
1073
1213
|
exports.extract = extract;
|
|
1074
1214
|
exports.neutralize = neutralize2;
|
|
1215
|
+
exports.normalizeBaseLang = normalizeBaseLang2;
|
|
1075
1216
|
exports.parse = parse;
|
|
1217
|
+
exports.pluralArity = pluralArity2;
|
|
1076
1218
|
exports.render = render;
|
|
1077
1219
|
exports.validate = validate;
|
|
1078
1220
|
//# sourceMappingURL=index.cjs.map
|