@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 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` collapse, post-process),
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 variable (one line) |
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 = 1;
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 SET_DIRECTIVE_RE = /^[ \t]*#set[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)[ \t]*\r?$/gmu;
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 } = extractSetDirectives(stripComments(src));
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 extractSetDirectives(text) {
35
+ function extractDirectives(text) {
36
36
  const setDefs = {};
37
- SET_DIRECTIVE_RE.lastIndex = 0;
38
- const stripped = text.replace(SET_DIRECTIVE_RE, (_full, name, value) => {
39
- setDefs[name.toLowerCase()] = value;
40
- return "";
41
- });
42
- return { body: stripped.replace(/\n{3,}/gu, "\n\n"), setDefs };
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
- checkSetDirectives(text, diagnostics);
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 checkSetDirectives(text, out) {
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
- if (!trimmed.startsWith("#set ") && !trimmed.startsWith("#set ")) return;
439
- if (!/^#set\s+%(\w+)%\s*=\s*(.+)$/u.test(trimmed)) {
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
- out.push(err("set.malformed", "Malformed #set. Expected: #set %name% = value", { line, column, endLine: line, endColumn: lineText.length + 1 }));
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 SET_LHS_RE = /^[ \t]*#set[ \t]+%\w+%[ \t]*=/gmu;
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(SET_LHS_RE, "");
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 vars = buildVars(ast.setDefs, ctx.runtimeContext, ctx.rng);
651
- const text = renderNodes(ast.nodes, { vars, rng: ctx.rng, locale: ctx.locale, depth: 0 });
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, rng) {
764
+ function buildVars(setDefs, context) {
674
765
  const vars = {};
675
766
  for (const [name, value] of Object.entries(setDefs)) {
676
- vars[name] = collapseSetValue(value, rng);
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 collapseSetValue(value, rng) {
684
- if (!value.includes("{")) return value;
685
- if (value.includes("{?") || value.includes("{plural ")) return value;
686
- return resolveEnumerationsString(value, rng);
687
- }
688
- function resolveEnumerationsString(text, rng) {
689
- let out = text;
690
- for (let guard = 0; guard < 1e3; guard += 1) {
691
- let changed = false;
692
- out = out.replace(/\{([^{}]*)\}/gu, (_m, inner) => {
693
- changed = true;
694
- const options = splitTopLevelPipes(inner);
695
- return options.length === 0 ? "" : options[randomInt(rng, 0, options.length - 1)] ?? "";
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 (!changed) break;
803
+ if (ready.length === 0) return [...ordered, ...pending];
804
+ ordered.push(...ready);
805
+ pending = pending.filter((name) => !ready.includes(name));
698
806
  }
699
- return out;
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)) return fullwidthVerbatim(countRaw, formsRaw);
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 `{plural ${countRaw}:${formsRaw}}`.replace(/\{/gu, "\uFF5B").replace(/\}/gu, "\uFF5D");
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