@spintax/core 0.1.6 → 0.2.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,85 @@
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.2.0 — 2026-07-18
7
+
8
+ Serbian, Croatian and Bosnian join the 3-form plural family; the plural error model and the
9
+ locale helpers that go with it land in the same release. Minor, not patch: the BCS change
10
+ below alters a **validation verdict**, which §0.1 classes as breaking.
11
+
12
+ Nothing here shipped separately — 0.2.0 was never published, so these additions fold into it
13
+ rather than minting a version between them. That matters for `@spintax/authoring-prompt`,
14
+ whose peer range `>=0.2.0` would otherwise be satisfiable by a 0.2.0 without the exports it
15
+ now imports at runtime.
16
+
17
+ ### Added
18
+
19
+ - **`pluralArity(locale?)` and `normalizeBaseLang(locale?)` are public.** Exported so a
20
+ consumer that must AGREE with the engine about plurals can ask it instead of keeping a copy
21
+ of the table. The copy is not hypothetical: this repo's own authoring prompt kept one and it
22
+ drifted twice — once in content (a locale added here and not there) and once in shape
23
+ (`locale.slice(0, 2)`, which disagrees with this normalization on any 3-letter tag).
24
+
25
+ `pluralArity` takes a RAW locale and normalizes internally, unlike the internal helper of the
26
+ same name — a public function answering 2 for `sr-Latn` would be a trap. Both accept an absent
27
+ locale, matching `RenderOptions.locale?` / `ValidateOptions.locale?`, so the same optional
28
+ value can be threaded straight through.
29
+
30
+ Note one asymmetry: an absent locale makes `validate()` skip the arity check entirely, while
31
+ `pluralArity` answers the 2-form default `render()` would apply. It tells you what render will
32
+ do, not what validate will enforce.
33
+
34
+ `findPluralBlocks` is deliberately NOT exported alongside them — it returns byte offsets into
35
+ the source, and publishing it would freeze a parser internal and invite consumers onto the
36
+ parse layer, the same reason `Ast` is opaque.
37
+
38
+ - **`RenderOptions.onPluralError` — an observer for unresolvable `{plural …}` blocks.**
39
+ The port of the plugin's `on_error` callable, and the missing half of its error model:
40
+ this engine already behaved exactly like the plugin's *lenient* mode (arity mismatch and
41
+ nested-bracket errors degrade to fullwidth-brace verbatim, an unresolved count erases the
42
+ block), but there was no way to learn that it had happened.
43
+
44
+ Observation only. Output is byte-identical with and without the callback, and `render()`
45
+ still never throws on template content (§9.3) — the host decides whether a report is
46
+ fatal. A report carries the diagnostic `code`, the construct **as the renderer saw it**
47
+ (after variable expansion), the normalized `locale`, and `expected`/`got` for arity.
48
+
49
+ The `plural.count` code has no `validate()` counterpart on purpose: an unresolved count is
50
+ a runtime-value fact that static analysis cannot see. It is also the case that most needs
51
+ the seam — erasing leaves no trace, so a host persisting a render otherwise cannot tell an
52
+ unsubstituted `%Var%` from copy that was meant to be empty.
53
+
54
+ - **BCS plural buckets — `sr`, `hr`, `bs`.** On integers, BCS shares the East-Slavic rule
55
+ character for character (`mod10===1 && mod100!==11` → one; `mod10∈[2,4] && mod100∉[12,14]`
56
+ → few; else → many), so it reuses that branch rather than getting its own. CLDR names the
57
+ third bucket `other` rather than `many` — positionally the same slot. The genuine
58
+ BCS/East-Slavic divergence is fractional-only and unreachable here, since a non-numeric
59
+ count slot is erased to `''` before the bucket math (§3.1).
60
+
61
+ **Script and region subtags carry no plural grammar.** `sr-Latn`, `sr-Cyrl` and `sr_RS` all
62
+ normalise to `sr` and pick identical buckets; the script lives only in the author's form text.
63
+ Corpus fixtures pin each form, and the arity fixtures pin that a script subtag does not rescue
64
+ a 2-form template. The ladder runs past two digits, where `mod100` has to beat `mod10`: 101 is
65
+ `one`, 111 is not.
66
+
67
+ Render fixtures probe with `sat` (1 sat / 2 sata / 5 sati) because all three buckets differ
68
+ there. The tempting `bonus|bonusa|bonusa` collapses few and other, so it would pass against a
69
+ broken rule.
70
+
71
+ Landed in all three engines at once — `@spintax/core`, the WordPress plugin 2.5.0, and
72
+ `spintax/core` 0.2.0 — because plural buckets are a parity-required item and the golden
73
+ corpus gates every engine.
74
+
75
+ ### Changed
76
+
77
+ - **BREAKING (verdict): `{plural 2: one|many}` under `sr`/`hr`/`bs` is now `plural.arity`.**
78
+ Previously these locales fell through to the EN-style 2-form default, so a 2-form BCS
79
+ template validated clean and rendered from the wrong bucket set. Any existing BCS template
80
+ must grow a third form. No other locale changes behaviour.
81
+ - The LLM authoring prompt (`@spintax/authoring-prompt`) now emits a BCS grammar block —
82
+ agreement rules, the 3-bucket warning, and a "do not mix Latin and Cyrillic inside one
83
+ template" rule. Without it a model writes 2-form BCS that the validator then rejects.
84
+
6
85
  ## 0.1.6 — 2026-07-13
7
86
 
8
87
  Two post-process fixes. **Supersedes 0.1.5** — upgrade straight past it. No API change.
package/dist/index.cjs CHANGED
@@ -344,6 +344,9 @@ function pluralArity(baseLang) {
344
344
  case "ru":
345
345
  case "uk":
346
346
  case "be":
347
+ case "sr":
348
+ case "hr":
349
+ case "bs":
347
350
  return 3;
348
351
  default:
349
352
  return 2;
@@ -357,6 +360,9 @@ function pluralFor(baseLang, n, forms) {
357
360
  case "ru":
358
361
  case "uk":
359
362
  case "be":
363
+ case "sr":
364
+ case "hr":
365
+ case "bs":
360
366
  if (mod10 === 1 && mod100 !== 11) return forms[0] ?? "";
361
367
  if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return forms[1] ?? "";
362
368
  return forms[2] ?? "";
@@ -648,7 +654,13 @@ var MAX_VARIABLE_DEPTH = 50;
648
654
  var INCLUDE_LINE_RE = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
649
655
  function renderAst(ast, ctx) {
650
656
  const vars = buildVars(ast.setDefs, ctx.runtimeContext, ctx.rng);
651
- const text = renderNodes(ast.nodes, { vars, rng: ctx.rng, locale: ctx.locale, depth: 0 });
657
+ const text = renderNodes(ast.nodes, {
658
+ vars,
659
+ rng: ctx.rng,
660
+ locale: ctx.locale,
661
+ depth: 0,
662
+ onPluralError: ctx.onPluralError
663
+ });
652
664
  return ctx.resolver ? resolveIncludes(text, ctx) : text;
653
665
  }
654
666
  function resolveIncludes(text, ctx) {
@@ -751,17 +763,47 @@ function renderConditional(node, opts) {
751
763
  function renderPlural(node, opts) {
752
764
  const countRaw = expandVarsOnly(node.countRaw, opts);
753
765
  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
766
  const base = normalizeBaseLang(opts.locale);
767
+ const report = (issue) => opts.onPluralError?.(issue);
768
+ if (/[{}[\]]/u.test(formsRaw)) {
769
+ report({
770
+ code: "plural.nested-brackets",
771
+ message: "Plural form slot contains nested spintax brackets; extract via #set first.",
772
+ construct: rawConstruct(countRaw, formsRaw),
773
+ locale: base
774
+ });
775
+ return fullwidthVerbatim(countRaw, formsRaw);
776
+ }
777
+ const count = phpTrim2(countRaw);
778
+ if (!/^-?\d+$/u.test(count)) {
779
+ report({
780
+ code: "plural.count",
781
+ message: `Plural count slot is empty or non-numeric (${JSON.stringify(count)}); block erased.`,
782
+ construct: rawConstruct(countRaw, formsRaw),
783
+ locale: base
784
+ });
785
+ return "";
786
+ }
758
787
  const forms = formsRaw.split("|").map((f) => phpTrim2(f));
759
- if (forms.length !== pluralArity(base)) return fullwidthVerbatim(countRaw, formsRaw);
788
+ if (forms.length !== pluralArity(base)) {
789
+ report({
790
+ code: "plural.arity",
791
+ message: `Plural has ${forms.length} form(s); locale "${base}" takes ${pluralArity(base)}.`,
792
+ construct: rawConstruct(countRaw, formsRaw),
793
+ locale: base,
794
+ expected: pluralArity(base),
795
+ got: forms.length
796
+ });
797
+ return fullwidthVerbatim(countRaw, formsRaw);
798
+ }
760
799
  const picked = pluralFor(base, Number.parseInt(count, 10), forms);
761
800
  return renderNodes(parseSequence(picked), opts);
762
801
  }
802
+ function rawConstruct(countRaw, formsRaw) {
803
+ return `{plural ${countRaw}:${formsRaw}}`;
804
+ }
763
805
  function fullwidthVerbatim(countRaw, formsRaw) {
764
- return `{plural ${countRaw}:${formsRaw}}`.replace(/\{/gu, "\uFF5B").replace(/\}/gu, "\uFF5D");
806
+ return rawConstruct(countRaw, formsRaw).replace(/\{/gu, "\uFF5B").replace(/\}/gu, "\uFF5D");
765
807
  }
766
808
  function renderEnumeration(options, opts) {
767
809
  if (options.length === 0) return "";
@@ -977,7 +1019,8 @@ function renderWith(input, rng, opts = {}) {
977
1019
  locale: opts.locale ?? "",
978
1020
  resolver: opts.includeResolver,
979
1021
  maxDepth: opts.maxDepth ?? DEFAULT_MAX_DEPTH,
980
- includeStack: []
1022
+ includeStack: [],
1023
+ onPluralError: opts.onPluralError
981
1024
  };
982
1025
  const ast = resolveAst(typeof input === "string" ? stripSentinels(input) : input);
983
1026
  let out = renderAst(ast, ctx);
@@ -1014,6 +1057,12 @@ function hashString(s) {
1014
1057
  }
1015
1058
 
1016
1059
  // src/index.ts
1060
+ function normalizeBaseLang2(locale) {
1061
+ return normalizeBaseLang(locale ?? "");
1062
+ }
1063
+ function pluralArity2(locale) {
1064
+ return pluralArity(normalizeBaseLang2(locale));
1065
+ }
1017
1066
  function parse(src) {
1018
1067
  return parseTemplate(src);
1019
1068
  }
@@ -1072,7 +1121,9 @@ exports.SpintaxError = SpintaxError;
1072
1121
  exports.analyze = analyze;
1073
1122
  exports.extract = extract;
1074
1123
  exports.neutralize = neutralize2;
1124
+ exports.normalizeBaseLang = normalizeBaseLang2;
1075
1125
  exports.parse = parse;
1126
+ exports.pluralArity = pluralArity2;
1076
1127
  exports.render = render;
1077
1128
  exports.validate = validate;
1078
1129
  //# sourceMappingURL=index.cjs.map