@spintax/core 0.2.0 → 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,75 @@
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
+
6
75
  ## 0.2.0 — 2026-07-18
7
76
 
8
77
  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) |
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, "");
@@ -378,7 +396,7 @@ function validateTemplate(src, opts = {}) {
378
396
  const diagnostics = [];
379
397
  const text = stripComments(src);
380
398
  checkBrackets(text, diagnostics);
381
- checkSetDirectives(text, diagnostics);
399
+ checkDirectives(text, diagnostics);
382
400
  checkPermutationConfigs(text, diagnostics);
383
401
  checkPlurals(text, opts.locale, diagnostics);
384
402
  checkVariableReferences(text, opts.knownVariables, diagnostics);
@@ -437,17 +455,43 @@ function checkBrackets(text, out) {
437
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 } }));
438
456
  }
439
457
  }
440
- function checkSetDirectives(text, out) {
458
+ function checkDirectives(text, out) {
441
459
  const lines = text.split("\n");
442
460
  lines.forEach((lineText, idx) => {
443
461
  const trimmed = lineText.replace(/^[ \t]+/, "");
444
- if (!trimmed.startsWith("#set ") && !trimmed.startsWith("#set ")) return;
445
- 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)) {
446
468
  const column = lineText.length - trimmed.length + 1;
447
469
  const line = idx + 1;
448
- 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 }));
449
472
  }
450
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
+ }
451
495
  }
452
496
  function checkPermutationConfigs(text, out) {
453
497
  for (const m of text.matchAll(/\[<([^>]*?)>/gu)) {
@@ -485,10 +529,20 @@ function checkPermutationConfigs(text, out) {
485
529
  function checkPlurals(text, locale, out) {
486
530
  const base = locale && locale !== "" ? normalizeBaseLang(locale) : "";
487
531
  const arity = base !== "" ? pluralArity(base) : 0;
532
+ const tainted = macroTaintedNames(text);
488
533
  for (const block of findPluralBlocks(text)) {
489
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
+ }
490
544
  if (/[{}[\]]/.test(block.formsRaw)) {
491
- 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));
492
546
  continue;
493
547
  }
494
548
  if (arity > 0) {
@@ -503,11 +557,33 @@ function checkPlurals(text, locale, out) {
503
557
  }
504
558
  }
505
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
+ }
506
582
  function checkVariableReferences(text, known, out) {
507
583
  const knownSet = new Set((known ?? []).map((n) => n.toLowerCase()));
508
584
  const defs = /* @__PURE__ */ new Map();
509
585
  const defPos = /* @__PURE__ */ new Map();
510
- 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)) {
511
587
  const name = (m[1] ?? "").toLowerCase();
512
588
  defs.set(name, m[2] ?? "");
513
589
  const nameOffset = (m.index ?? 0) + m[0].indexOf("%");
@@ -522,7 +598,7 @@ function checkVariableReferences(text, known, out) {
522
598
  for (const name of defs.keys()) {
523
599
  detectCycle(name, defs, [name], defPos.get(name) ?? somewhere, out);
524
600
  }
525
- 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, " "));
526
602
  const seen = /* @__PURE__ */ new Set();
527
603
  const undefinedAt = (name, offset, length) => {
528
604
  const key = name.toLowerCase();
@@ -569,19 +645,21 @@ function checkIncludeTargets(text, known, out) {
569
645
 
570
646
  // src/internal/extract.ts
571
647
  var SET_DEF_RE = /^[ \t]*#set[ \t]+%(\w+)%[ \t]*=/gmu;
572
- 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;
573
650
  var INCLUDE_RE2 = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
574
651
  var VARIABLE_RE2 = /%(\w+)%/gu;
575
652
  var CONDITIONAL_REF_RE = /\{\?!?([A-Za-z_]\w*)\?/gu;
576
653
  function extractFromSource(src) {
577
654
  const text = stripComments(src);
578
655
  const sets = collect(text, SET_DEF_RE, true);
656
+ const defs = collect(text, DEF_DEF_RE, true);
579
657
  const includes = collect(text, INCLUDE_RE2, false);
580
- const body = text.replace(SET_LHS_RE, "");
658
+ const body = text.replace(DEFINITION_LHS_RE, "");
581
659
  const refs = /* @__PURE__ */ new Set();
582
660
  addAll(refs, body, VARIABLE_RE2, true);
583
661
  addAll(refs, body, CONDITIONAL_REF_RE, true);
584
- return { refs: [...refs], sets, includes };
662
+ return { refs: [...refs], sets, defs, includes };
585
663
  }
586
664
  function collect(text, re, fold) {
587
665
  const seen = /* @__PURE__ */ new Set();
@@ -653,14 +731,15 @@ function stripSentinels(text) {
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) {
@@ -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 = "";
@@ -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) {