@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 +117 -0
- package/dist/index.cjs +64 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +94 -1
- package/dist/index.d.ts +94 -1
- package/dist/index.js +63 -14
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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, {
|
|
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))
|
|
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
|
|
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
|
|
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(`^(${
|
|
923
|
-
var CAP_AFTER_SENTENCE_RE = new RegExp(`([.!?\u2026])(${
|
|
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)[^>]*>${
|
|
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${
|
|
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
|