@spintax/core 0.3.4 → 0.5.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,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.5.0 — 2026-08-18
7
+
8
+ Two plural fixes, both cross-engine, both found by measuring one engine against another rather
9
+ than by a failing test. Minor rather than patch: validation output and render output both change.
10
+
11
+ ### Fixed
12
+
13
+ - **`validate` counted plural forms before `%variable%` expansion; `render` counts them after**
14
+ (issue #66, found by the Object Pascal port and confirmed in all five engines).
15
+
16
+ `#def %tail% = few|many` with `{plural 2: one|%tail%}` under `ru` renders correctly as three
17
+ forms, and every validator reported `plural.arity` for the two it could see in the source. The
18
+ count now substitutes definition values first — every reference per pass, as the renderer does —
19
+ and splits the result.
20
+
21
+ Only where the count is **provably invariant**. A value carrying any bracket — all four, and
22
+ conditionals too — suppresses the count-based verdicts rather than guessing: `{a|b}` really does
23
+ always freeze to one form, but `{?flag?a|b|c}` freezes as `a` or as `b|c` and the two cannot be
24
+ told apart without evaluating the construct. Predicting the roll was tried first and produced a
25
+ fresh crop of false errors. Silence on an unknowable input is the trade.
26
+
27
+ One case is not a prediction: a `#set` named **directly** in the form slot is substituted
28
+ verbatim and is still spintax when the plural is decided, so its brackets keep earning
29
+ `plural.nested-brackets` — through a `#def` it is rolled first and earns nothing.
30
+
31
+ - **A conditional in the plural COUNT slot deleted the block, silently.**
32
+
33
+ ```
34
+ #set %flag% =
35
+ #set %n% = {?flag?1|2}
36
+ start {plural %n%: one|two} end
37
+ ```
38
+
39
+ rendered `start end` — no fallback braces, no diagnostic, `validate` returning `[]`. Both PHP
40
+ engines have always rendered `start two end`: they run the conditional stage over the whole text
41
+ before plurals, so a plain number reaches the slot. This engine expanded variables into the raw
42
+ slot and left constructs literal, so the conditional failed the numeric test and the block was
43
+ erased. `plural.count-macro` exempts conditionals *because* they resolve before plurals — the
44
+ validator was written to a renderer behaviour that was not implemented here.
45
+
46
+ The branch is substituted, never rendered: enumerations resolve AFTER plurals, so a branch
47
+ yielding `{a|b}` still reaches the numeric test intact and still erases the block, exactly as the
48
+ plugin does. Rendering it would invent a count no engine has.
49
+
50
+ The **form** slot is deliberately untouched. There the engines genuinely disagree — both PHP
51
+ renderers resolve a conditional that expansion introduces inside a form list, this engine and the
52
+ Python one do not — and picking a side is issue #67, not a bug fix.
53
+
54
+ ### Performance
55
+
56
+ - The count-slot pass is linear and iterative. Written recursively it made `render()` throw
57
+ `RangeError` at ~9000 levels of nesting, breaking the §9.2 promise that render never throws on
58
+ content; matching braces per `{?` made it quadratic, and an unbalanced count slot is legal input
59
+ (only the whole `{plural …}` block has to balance, and the slot is cut at the first `:`), so a
60
+ 78 KB template cost 3 seconds. Both were reachable from template text through a public endpoint.
61
+ 78 KB now costs 42 ms and 1 MB costs 372 ms.
62
+
63
+ Deep *balanced* nesting is still super-linear, in every engine of the family — the reference
64
+ implementation scans the body per level too. Bounding input size is a host job (§9.3); the
65
+ reference Worker now refuses a template over 8192 characters with 413, the same cap the hosted
66
+ MCP server has always applied.
67
+
68
+ ### Notes
69
+
70
+ Mirrored where each engine needed it: the counting fix into `spintax/core`, the WordPress plugin
71
+ and `spintax-core` (Python); the count-slot fix into the Python engine only, the PHP engines
72
+ having been right all along. Pinned by 20 new corpus fixtures — 12 for the counting rule, 8 for the
73
+ count slot, of which 4 are negative controls — taking the corpus from 235 to 255, green in
74
+ TypeScript, Python and both PHP engines.
75
+
76
+ ## 0.4.0 — 2026-08-17
77
+
78
+ ### Added
79
+
80
+ - **`plural.locale-missing` — a warning where `validate` used to be silent** (issue #65, reported
81
+ from a pipeline rendering ~1000 articles per campaign).
82
+
83
+ `validate` files no arity *verdict* without a locale, and that stays: the template may well be
84
+ correct for the locale the host will render with, so failing it here would fail a good template
85
+ for a fact the caller never claimed. But `render` has no such luxury — it resolves against the
86
+ 2-form default — so a `{plural …}` block with any other form count reached finished text as the
87
+ fullwidth-brace fallback `{plural …}`, with nothing said upfront. Downstream checks that scan
88
+ for `{`/`}` do not see those braces.
89
+
90
+ The warning fires only where the risk is: **no locale normalized, and the form count is not the
91
+ render default**. A 2-form block stays silent, because the default resolves it. `valid` is
92
+ unchanged in every case (`valid ⇔ no error-severity diagnostic`), and supplying any locale
93
+ replaces the warning with the real verdict — nothing for `ru`, `plural.arity` for `en`.
94
+
95
+ Carries `data: { got, defaultArity }`. The default arity is derived from the same table `render`
96
+ uses rather than written as a literal, so the two cannot come to disagree.
97
+
98
+ Mirrored into `spintax/core`, the WordPress plugin and `spintax-core` (Python) in the same
99
+ change, and pinned by a corpus fixture. Note the gate's shape: the shared corpus asserts
100
+ diagnostics as a SUBSET, so it can pin that the warning IS emitted but not that it is absent on
101
+ a 2-form block — that negative control lives in each engine's own suite.
102
+
6
103
  ## 0.3.4 — 2026-08-08
7
104
 
8
105
  Docs-only release — no engine changes; every byte of `dist/` behavior is 0.3.3's.
package/dist/index.cjs CHANGED
@@ -51,7 +51,7 @@ function stripSentinels(text) {
51
51
  // src/internal/parser.ts
52
52
  var VARIABLE_RE = /^%(\w+)%/;
53
53
  var DIRECTIVE_RE = /^[ \t]*#(set|def)[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)[ \t]*\r?$/gmu;
54
- var CONDITIONAL_NAME_RE = /^[A-Za-z_]\w*/;
54
+ var CONDITIONAL_NAME_RE = /[A-Za-z_]\w*/y;
55
55
  var PLURAL_PREFIX = "plural ";
56
56
  function parseTemplate(src) {
57
57
  const { body, setDefs, defDefs } = extractDirectives(stripComments(stripSentinels(src)));
@@ -152,28 +152,42 @@ function parseBraceConstruct(content) {
152
152
  }
153
153
  return { type: "enumeration", options: splitTopLevel(content).map((o) => parseSequence(o)) };
154
154
  }
155
- function tryParseConditional(content) {
156
- let p = 1;
155
+ function recognizeConditional(text, contentStart, contentEnd) {
156
+ let p = contentStart + 1;
157
157
  let inverted = false;
158
- if (content.charAt(p) === "!") {
158
+ if (text.charAt(p) === "!") {
159
159
  inverted = true;
160
160
  p += 1;
161
161
  }
162
- const name = CONDITIONAL_NAME_RE.exec(content.slice(p))?.[0];
163
- if (name === void 0) return null;
162
+ CONDITIONAL_NAME_RE.lastIndex = p;
163
+ const name = CONDITIONAL_NAME_RE.exec(text)?.[0];
164
+ if (name === void 0 || p + name.length > contentEnd) return null;
164
165
  p += name.length;
165
- if (content.charAt(p) !== "?") return null;
166
+ if (text.charAt(p) !== "?") return null;
166
167
  p += 1;
167
- const body = content.slice(p);
168
- const sep = firstTopLevelPipe(body);
169
- const thenRaw = sep < 0 ? body : body.slice(0, sep);
170
- const elseRaw = sep < 0 ? "" : body.slice(sep + 1);
168
+ return { name, inverted, bodyStart: p, sepIndex: firstTopLevelPipe(text, p, contentEnd) };
169
+ }
170
+ function splitConditional(content) {
171
+ const head = recognizeConditional(content, 0, content.length);
172
+ if (head === null) return null;
173
+ const body = content.slice(head.bodyStart);
174
+ const sep = head.sepIndex < 0 ? -1 : head.sepIndex - head.bodyStart;
175
+ return {
176
+ name: head.name,
177
+ inverted: head.inverted,
178
+ thenRaw: sep < 0 ? body : body.slice(0, sep),
179
+ elseRaw: sep < 0 ? "" : body.slice(sep + 1)
180
+ };
181
+ }
182
+ function tryParseConditional(content) {
183
+ const parts = splitConditional(content);
184
+ if (parts === null) return null;
171
185
  return {
172
186
  type: "conditional",
173
- name,
174
- inverted,
175
- then: parseSequence(thenRaw),
176
- else: parseSequence(elseRaw)
187
+ name: parts.name,
188
+ inverted: parts.inverted,
189
+ then: parseSequence(parts.thenRaw),
190
+ else: parseSequence(parts.elseRaw)
177
191
  };
178
192
  }
179
193
  function parsePlural(afterPrefix) {
@@ -333,9 +347,9 @@ function splitTopLevel(inner) {
333
347
  parts.push(cur);
334
348
  return parts;
335
349
  }
336
- function firstTopLevelPipe(body) {
350
+ function firstTopLevelPipe(body, from = 0, to = body.length) {
337
351
  let depth = 0;
338
- for (let j = 0; j < body.length; j += 1) {
352
+ for (let j = from; j < to; j += 1) {
339
353
  const ch = body.charAt(j);
340
354
  if (ch === "{" || ch === "[") {
341
355
  depth += 1;
@@ -399,6 +413,7 @@ function pluralArity(baseLang) {
399
413
  return 2;
400
414
  }
401
415
  }
416
+ var DEFAULT_PLURAL_ARITY = pluralArity("");
402
417
  function pluralFor(baseLang, n, forms) {
403
418
  const abs = Math.abs(n);
404
419
  const mod10 = abs % 10;
@@ -428,7 +443,7 @@ function validateTemplate(src, opts = {}) {
428
443
  checkBrackets(text, diagnostics);
429
444
  checkDirectives(text, diagnostics);
430
445
  checkPermutationConfigs(text, idx, diagnostics);
431
- checkPlurals(text, idx, opts.locale, diagnostics);
446
+ checkPlurals(text, idx, opts.locale, opts.knownVariables, diagnostics);
432
447
  checkVariableReferences(text, idx, opts.knownVariables, diagnostics);
433
448
  if (opts.knownIncludes && opts.knownIncludes.length > 0) {
434
449
  checkIncludeTargets(text, idx, opts.knownIncludes, diagnostics);
@@ -562,10 +577,12 @@ function checkPermutationConfigs(text, idx, out) {
562
577
  }
563
578
  }
564
579
  }
565
- function checkPlurals(text, idx, locale, out) {
580
+ function checkPlurals(text, idx, locale, knownVariables, out) {
566
581
  const base = locale && locale !== "" ? normalizeBaseLang(locale) : "";
567
582
  const arity = base !== "" ? pluralArity(base) : 0;
568
583
  const tainted = macroTaintedNames(text);
584
+ const { setDefs: macros, defDefs: defs } = extractDirectives(text);
585
+ const hostNames = new Set((knownVariables ?? []).map((n) => n.toLowerCase()));
569
586
  for (const block of findPluralBlocks(text)) {
570
587
  const at = span(idx, block.start, block.end - block.start);
571
588
  for (const m of block.countSlot.matchAll(/%(\w+)%/gu)) {
@@ -581,8 +598,14 @@ function checkPlurals(text, idx, locale, out) {
581
598
  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));
582
599
  continue;
583
600
  }
601
+ const expanded = expandFormsForCounting(block.formsRaw, defs, macros, hostNames);
602
+ if (expanded.directMacroSpintax) {
603
+ 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));
604
+ continue;
605
+ }
606
+ if (expanded.unresolved) continue;
607
+ const count = expanded.forms;
584
608
  if (arity > 0) {
585
- const count = block.formsRaw.split("|").length;
586
609
  if (count !== arity) {
587
610
  out.push(err(
588
611
  "plural.arity",
@@ -590,10 +613,79 @@ function checkPlurals(text, idx, locale, out) {
590
613
  { ...at, data: { expected: arity, got: count } }
591
614
  ));
592
615
  }
616
+ } else if (count !== DEFAULT_PLURAL_ARITY) {
617
+ const unusable = locale !== void 0 && locale !== "";
618
+ out.push(warn(
619
+ "plural.locale-missing",
620
+ unusable ? `{plural ...}: ${count} forms, and the locale '${locale}' does not normalize to a language the engine knows, so render falls back to ${DEFAULT_PLURAL_ARITY} forms and leaves this block unresolved \u2014 pass a base tag such as 'en' or 'ru'.` : `{plural ...}: ${count} forms, but no locale was supplied. render defaults to ${DEFAULT_PLURAL_ARITY} forms and leaves this block unresolved \u2014 pass the locale you will render with.`,
621
+ {
622
+ ...at,
623
+ data: {
624
+ got: count,
625
+ defaultArity: DEFAULT_PLURAL_ARITY,
626
+ ...unusable ? { locale } : {}
627
+ }
628
+ }
629
+ ));
593
630
  }
594
631
  }
595
632
  }
596
633
  var UNRESOLVED_AT_PLURAL_TIME = /\[|\{(?!\?)/u;
634
+ var ANY_BRACKET = /[[\]{}]/u;
635
+ var FORM_EXPANSION_PASSES = 51;
636
+ function expandFormsForCounting(formsRaw, defs, macros, hostNames) {
637
+ const unknown = (directMacroSpintax = false) => ({
638
+ forms: 0,
639
+ unresolved: true,
640
+ directMacroSpintax
641
+ });
642
+ const seenMacro = /* @__PURE__ */ new Set();
643
+ const walkMacros = (source) => {
644
+ for (const m of source.matchAll(/%(\w+)%/gu)) {
645
+ const name = (m[1] ?? "").toLowerCase();
646
+ if (defs[name] !== void 0 || hostNames.has(name)) continue;
647
+ const macro = macros[name];
648
+ if (macro === void 0 || seenMacro.has(name)) continue;
649
+ seenMacro.add(name);
650
+ if (/\{\?/u.test(macro)) return "opaque";
651
+ if (ANY_BRACKET.test(macro)) return "brackets";
652
+ const deeper = walkMacros(macro);
653
+ if (deeper !== "clean") return deeper;
654
+ }
655
+ return "clean";
656
+ };
657
+ const verbatim = walkMacros(formsRaw);
658
+ if (verbatim === "brackets") return unknown(true);
659
+ if (verbatim === "opaque") return unknown();
660
+ let text = formsRaw;
661
+ for (let pass = 0; pass < FORM_EXPANSION_PASSES; pass++) {
662
+ let sawReference = false;
663
+ let bailed = false;
664
+ text = text.replace(/%(\w+)%/gu, (whole, raw) => {
665
+ sawReference = true;
666
+ const name = raw.toLowerCase();
667
+ if (hostNames.has(name)) {
668
+ bailed = true;
669
+ return whole;
670
+ }
671
+ const value = defs[name] ?? macros[name];
672
+ if (value === void 0) {
673
+ bailed = true;
674
+ return whole;
675
+ }
676
+ if (ANY_BRACKET.test(value)) {
677
+ bailed = true;
678
+ return whole;
679
+ }
680
+ return value;
681
+ });
682
+ if (bailed) return unknown();
683
+ if (!sawReference) {
684
+ return { forms: text.split("|").length, unresolved: false, directMacroSpintax: false };
685
+ }
686
+ }
687
+ return unknown();
688
+ }
597
689
  function macroTaintedNames(text) {
598
690
  const macros = extractDirectives(text).setDefs;
599
691
  const tainted = /* @__PURE__ */ new Set();
@@ -943,14 +1035,63 @@ function expandVarsOnly(text, opts) {
943
1035
  }
944
1036
  return out;
945
1037
  }
946
- function renderConditional(node, opts) {
947
- const value = opts.vars[node.name.toLowerCase()];
1038
+ function conditionalTakesThen(name, inverted, opts) {
1039
+ const value = opts.vars[name.toLowerCase()];
948
1040
  const baseTruthy = value !== void 0 && /\S/u.test(value);
949
- const truthy = node.inverted ? !baseTruthy : baseTruthy;
1041
+ return inverted ? !baseTruthy : baseTruthy;
1042
+ }
1043
+ function renderConditional(node, opts) {
1044
+ const truthy = conditionalTakesThen(node.name, node.inverted, opts);
950
1045
  return renderNodes(truthy ? node.then : node.else, opts);
951
1046
  }
1047
+ function resolveCountConditionals(text, opts) {
1048
+ if (!text.includes("{?")) return text;
1049
+ const close = matchBraces(text);
1050
+ const out = [];
1051
+ const pending = [[0, text.length]];
1052
+ while (pending.length > 0) {
1053
+ const segment = pending.pop();
1054
+ const segEnd = segment[1];
1055
+ let i = segment[0];
1056
+ while (i < segEnd) {
1057
+ const open = text.indexOf("{?", i);
1058
+ if (open === -1 || open + 1 >= segEnd) {
1059
+ out.push(text.slice(i, segEnd));
1060
+ break;
1061
+ }
1062
+ const shut = close[open] ?? -1;
1063
+ const head = shut === -1 || shut >= segEnd ? null : recognizeConditional(text, open + 1, shut);
1064
+ if (head === null) {
1065
+ out.push(text.slice(i, Math.min(open + 2, segEnd)));
1066
+ i = open + 2;
1067
+ continue;
1068
+ }
1069
+ out.push(text.slice(i, open));
1070
+ const branchEnd = head.sepIndex < 0 ? shut : head.sepIndex;
1071
+ const [from, to] = conditionalTakesThen(head.name, head.inverted, opts) ? [head.bodyStart, branchEnd] : [head.sepIndex < 0 ? shut : head.sepIndex + 1, shut];
1072
+ pending.push([shut + 1, segEnd]);
1073
+ pending.push([from, to]);
1074
+ break;
1075
+ }
1076
+ }
1077
+ return out.join("");
1078
+ }
1079
+ function matchBraces(text) {
1080
+ const close = new Int32Array(text.length).fill(-1);
1081
+ const opens = [];
1082
+ for (let i = 0; i < text.length; i += 1) {
1083
+ const ch = text.charAt(i);
1084
+ if (ch === "{") {
1085
+ opens.push(i);
1086
+ } else if (ch === "}") {
1087
+ const open = opens.pop();
1088
+ if (open !== void 0) close[open] = i;
1089
+ }
1090
+ }
1091
+ return close;
1092
+ }
952
1093
  function renderPlural(node, opts) {
953
- const countRaw = expandVarsOnly(node.countRaw, opts);
1094
+ const countRaw = resolveCountConditionals(expandVarsOnly(node.countRaw, opts), opts);
954
1095
  const formsRaw = expandVarsOnly(node.formsRaw, opts);
955
1096
  const base = normalizeBaseLang(opts.locale);
956
1097
  const report = (issue) => opts.onPluralError?.(issue);