@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 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` 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) |
@@ -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` collapse, post-process output) is parity-gated.
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 = 1;
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 SET_DIRECTIVE_RE = /^[ \t]*#set[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)[ \t]*\r?$/gmu;
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 } = extractSetDirectives(stripComments(src));
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 extractSetDirectives(text) {
60
+ function extractDirectives(text) {
36
61
  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 };
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
- checkSetDirectives(text, diagnostics);
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 checkSetDirectives(text, out) {
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
- if (!trimmed.startsWith("#set ") && !trimmed.startsWith("#set ")) return;
445
- if (!/^#set\s+%(\w+)%\s*=\s*(.+)$/u.test(trimmed)) {
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
- out.push(err("set.malformed", "Malformed #set. Expected: #set %name% = value", { line, column, endLine: line, endColumn: lineText.length + 1 }));
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 SET_LHS_RE = /^[ \t]*#set[ \t]+%\w+%[ \t]*=/gmu;
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(SET_LHS_RE, "");
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 vars = buildVars(ast.setDefs, ctx.runtimeContext, ctx.rng);
657
- const text = renderNodes(ast.nodes, {
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(stripSentinels(included)), {
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, rng) {
764
+ function buildVars(setDefs, context) {
686
765
  const vars = {};
687
766
  for (const [name, value] of Object.entries(setDefs)) {
688
- vars[name] = collapseSetValue(value, rng);
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 collapseSetValue(value, rng) {
696
- if (!value.includes("{")) return value;
697
- if (value.includes("{?") || value.includes("{plural ")) return value;
698
- return resolveEnumerationsString(value, rng);
699
- }
700
- function resolveEnumerationsString(text, rng) {
701
- let out = text;
702
- for (let guard = 0; guard < 1e3; guard += 1) {
703
- let changed = false;
704
- out = out.replace(/\{([^{}]*)\}/gu, (_m, inner) => {
705
- changed = true;
706
- const options = splitTopLevelPipes(inner);
707
- 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));
708
802
  });
709
- if (!changed) break;
803
+ if (ready.length === 0) return [...ordered, ...pending];
804
+ ordered.push(...ready);
805
+ pending = pending.filter((name) => !ready.includes(name));
710
806
  }
711
- 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());
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 first.",
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(typeof input === "string" ? stripSentinels(input) : input);
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);