@spintax/core 0.1.5 → 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,123 @@
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
+
85
+ ## 0.1.6 — 2026-07-13
86
+
87
+ Two post-process fixes. **Supersedes 0.1.5** — upgrade straight past it. No API change.
88
+
89
+ ### Fixed
90
+
91
+ - **A run of sentence punctuation is no longer split from the inside.** This is not a Spanish issue
92
+ and it predates the 0.1.5 work: the "space after `.!?`" rule looked exactly one character ahead
93
+ and never at the rest of the run, so it fired *between* the marks.
94
+
95
+ ```
96
+ wait... what? → Wait. . . What? (0.1.5 and earlier)
97
+ wow!!! → Wow! ! !
98
+ really?! → Really? !
99
+ ```
100
+
101
+ The ASCII ellipsis is the common casualty — and the Unicode `…` is *not* in the `[.!?]` class,
102
+ which is exactly why the corpus's existing ellipsis fixture never caught it. A run is now matched
103
+ whole and required to be complete: `([.!?]+)(?![.!?])`. The guard is load-bearing — a greedy `+`
104
+ alone gives ground back *into* the run to satisfy the lookaheads and yields `Wow!! !`.
105
+
106
+ - **`¡¿Qué haces?!` keeps its capital.** The 0.1.5 opener rule allowed exactly *one* opener, so the
107
+ RAE form for a sentence that is both a question and an exclamation — the most Spanish sentence
108
+ there is — still lost its capital. An opener followed by markup (`¿<strong>cómo</strong> estás?`,
109
+ `<p>¿<a href="/ayuda">necesitas ayuda</a>?</p>`) failed for the same reason: the old lead only
110
+ allowed `tags → opener → letter`, never `opener → tags → letter`.
111
+
112
+ The lead is now any run of tags, sentence openers and whitespace, in any order. The opener set
113
+ stays deliberately **narrow** — quotes and brackets are still not openers, and the fixtures added
114
+ in 0.1.5 keep guarding that.
115
+
116
+ ### Notes
117
+
118
+ - Mirrored into the PHP plugin engine (released there as 2.3.3). Verified against the shared golden
119
+ corpus in both engines: TS 243 tests; the conformance runner against the real plugin engine 107
120
+ tests / 120 assertions. No performance regression — the pathological-input cost is unchanged from
121
+ 0.1.5 and comes from the URL/domain shields, not from these rules.
122
+
6
123
  ## 0.1.5 — 2026-07-13
7
124
 
8
125
  Spanish post-process fix. 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 "";
@@ -914,18 +956,18 @@ var MULTI_ABBR_RE = new RegExp(`\\b(?:\\p{L}{1,2}\\.${S}*){2,}`, "gu");
914
956
  var SINGLE_ABBR_RE = new RegExp(`(?<![\\p{L}\\p{N}])(?:${SINGLE_ABBREVS.join("|")})\\.(?=${S}|$|<)`, "giu");
915
957
  var TRAILING_PUNCT_RE = /([.,;:!]+)$/u;
916
958
  var SENTENCE_OPENERS = "\xBF\xA1";
917
- var OPEN = `[${SENTENCE_OPENERS}]?${S}*`;
959
+ var LEAD = `(?:<[^>]+>|[${SENTENCE_OPENERS}]|${S})*`;
918
960
  var SPACE_BEFORE_PUNCT_RE = new RegExp(`${S}+([,;:!?.])`, "gu");
919
961
  var SPACE_AFTER_COMMA_RE = new RegExp(`([,;:])(?!\\d)(?!${S}|$|<)`, "gu");
920
- var SPACE_AFTER_SENTENCE_RE = new RegExp(`([.!?])(?!\\d)(?!${S}|$|<)`, "gu");
962
+ var SPACE_AFTER_SENTENCE_RE = new RegExp(`([.!?]+)(?![.!?])(?!\\d)(?!${S}|$|<)`, "gu");
921
963
  var SPACE_AFTER_OPENER_RE = new RegExp(`([${SENTENCE_OPENERS}])${S}+`, "gu");
922
- var CAP_FIRST_RE = new RegExp(`^(${S}*(?:<[^>]+>${S}*)*${OPEN})(\\p{Ll})`, "u");
923
- var CAP_AFTER_SENTENCE_RE = new RegExp(`([.!?\u2026])(${S}*(?:<\\/?[^>]+>${S}*)*${OPEN})(\\p{Ll})`, "gu");
964
+ var CAP_FIRST_RE = new RegExp(`^(${LEAD})(\\p{Ll})`, "u");
965
+ var CAP_AFTER_SENTENCE_RE = new RegExp(`([.!?\u2026])(${LEAD})(\\p{Ll})`, "gu");
924
966
  var CAP_AFTER_BLOCK_RE = new RegExp(
925
- `(<\\/?(?:p|h[1-6]|li|blockquote|div|td|th)[^>]*>${S}*${OPEN})(\\p{Ll})`,
967
+ `(<\\/?(?:p|h[1-6]|li|blockquote|div|td|th)[^>]*>${LEAD})(\\p{Ll})`,
926
968
  "giu"
927
969
  );
928
- var CAP_AFTER_BREAK_RE = new RegExp(`(\\n${S}*${OPEN})(\\p{Ll})`, "gu");
970
+ var CAP_AFTER_BREAK_RE = new RegExp(`(\\n${LEAD})(\\p{Ll})`, "gu");
929
971
  var up = (ch) => ch.toUpperCase();
930
972
  function postProcess(input) {
931
973
  const placeholders = /* @__PURE__ */ new Map();
@@ -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