@spintax/core 0.10.0 → 0.12.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 +80 -0
- package/README.md +3 -3
- package/dist/index.cjs +60 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +60 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,86 @@
|
|
|
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.12.0 — 2026-10-06
|
|
7
|
+
|
|
8
|
+
A minor: rendered text moves for permutation separators in Arabic, Hebrew, Thai and Lao. No verdict
|
|
9
|
+
changes and no API change; `render()` already took the `locale` it now also reads here.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **An Arabic list can be written (#90).** The conjunction و ("and") — and ف ("and then") — is written
|
|
14
|
+
attached to the next word: `الكازينو والبث`, never `الكازينو و البث`. A letter-only separator was always
|
|
15
|
+
padded on both sides, whatever spaces the author wrote, so no shape of `lastsep` could produce it. Now,
|
|
16
|
+
under `locale: 'ar'`, a separator that is exactly و or ف keeps the space before it and none after when
|
|
17
|
+
the next element starts with an Arabic letter: `[<sep="، ";lastsep="و">…]` renders
|
|
18
|
+
`الكازينو، المراهنات الرياضية والبث المباشر`. Before anything else — a Latin brand name, a digit — both
|
|
19
|
+
spaces stay: `و Evolution`. Hebrew ו under `locale: 'he'` works the same way. The rule is keyed by
|
|
20
|
+
LANGUAGE, not script: Persian and Urdu write the same letter و as a word of its own, so under `fa` and
|
|
21
|
+
`ur` it is padded as before, and so is every word conjunction (أو, ثم, או) and و with no locale.
|
|
22
|
+
- **Thai and Lao separators join bare (#90),** as Han and kana have since 0.11.0: `[<lastsep="และ">แมว|สุนัข]`
|
|
23
|
+
renders `แมวและสุนัข`. Khmer and Myanmar take the same rule. A separator carrying a combining mark —
|
|
24
|
+
Thai `หรือ`, most Khmer and Myanmar words — is not all letters, so it was never padded and still renders
|
|
25
|
+
exactly as written, spaces included.
|
|
26
|
+
|
|
27
|
+
No template in the family's stored content uses any of these separators (measured over casino-platform,
|
|
28
|
+
content-gen, both sites and the plugin: the only hits were the probes that found the issue), so no
|
|
29
|
+
existing text changes.
|
|
30
|
+
|
|
31
|
+
### Corpus
|
|
32
|
+
|
|
33
|
+
408 cases (+22), all in `render-semantics.json`. `perm/sep-arabic-*` (و and ف attached, the author's
|
|
34
|
+
spaces, a regional locale, before a Latin word, a digit, an astral Arabic letter and U+0640, a word
|
|
35
|
+
conjunction, the issue's whole list with the post-process on), `perm/sep-persian-waw-padded`,
|
|
36
|
+
`perm/sep-urdu-waw-padded`, `perm/sep-waw-without-locale-padded`, `perm/sep-hebrew-*` (ו attached, before a Latin word, the word conjunction או), `perm/sep-thai-*`,
|
|
37
|
+
`perm/sep-lao-not-padded`, `perm/sep-khmer-not-padded`, `perm/sep-myanmar-not-padded`, and
|
|
38
|
+
`perm/sep-script-not-script-extensions`.
|
|
39
|
+
|
|
40
|
+
That last one pins a divergence 0.11.0 shipped in both PHP engines without anyone seeing it: PCRE2 10.40
|
|
41
|
+
and later read `\p{Han}` as Script_Extensions, so U+3006 〆, U+303C 〼, the kana repeat marks U+3031–3035
|
|
42
|
+
and the halfwidth sound marks U+FF9E/FF9F joined bare there and were padded here and in every port. The
|
|
43
|
+
PHP engines now exclude them, which is Script on any PCRE2 version. The same trap sat under the new Arabic
|
|
44
|
+
test (U+0640, tatweel); the next element is read by code point, not by UTF-16 unit.
|
|
45
|
+
|
|
46
|
+
## 0.11.0 — 2026-10-03
|
|
47
|
+
|
|
48
|
+
A minor: one verdict changes (a two-form `ar` plural is now an error) and rendered text moves for
|
|
49
|
+
Arabic plurals and for CJK list separators.
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
|
|
53
|
+
- **Arabic plurals (#88).** `pluralArity('ar')` is **6**, and `{plural …}` picks in CLDR order:
|
|
54
|
+
zero (0), one (1), two (2), few (n % 100 in 3..10), many (n % 100 in 11..99), other (everything
|
|
55
|
+
else — 100–102, 200–202, …). Negative counts use `abs()` as before; counts are integers, so CLDR's
|
|
56
|
+
fraction rules cannot be reached. Arabic used to get the English rule — `forms[0]` for 1,
|
|
57
|
+
`forms[1]` for everything else — which is ungrammatical for most counts, and a correct six-form
|
|
58
|
+
block was a `plural.arity` error, so there was no way to write one.
|
|
59
|
+
**The arity is strict, like every other locale's: a two-form `ar` block is now `plural.arity`**
|
|
60
|
+
and renders as the fullwidth fallback. Accepting two as a fallback would keep the silent
|
|
61
|
+
ungrammatical output. `DEFAULT_PLURAL_ARITY` (no locale) stays 2.
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- **A Chinese or Japanese list separator joins without spaces (#87).** A separator made only of
|
|
66
|
+
letters is space-padded on join (`[<lastsep="and">A|B]` → `A and B`), and that was applied to
|
|
67
|
+
scripts written without spaces between words: `[<lastsep="和">SSO|告警]` printed `SSO 和 告警`. Now a
|
|
68
|
+
separator whose every letter is Han, Hiragana or Katakana — plus the prolonged-sound marks U+30FC
|
|
69
|
+
and U+FF70, which are Script=Common but written inside Katakana words — joins bare: `SSO和告警`,
|
|
70
|
+
`SSOおよび監視`. Hangul keeps the padding (Korean spaces its words), and so does a separator that
|
|
71
|
+
mixes scripts (`and和`). Thai, Lao, Khmer and Myanmar are also written without spaces and are left
|
|
72
|
+
as they were until a template in them shows up.
|
|
73
|
+
|
|
74
|
+
### Corpus
|
|
75
|
+
|
|
76
|
+
386 cases (+34). `plural/ar-*` at 0, 1, 2, 3, 10, 11, 99, 100, 101, 102, 103, 111 and −3, a real
|
|
77
|
+
noun under `ar-EG`, and the two-form fallback; `validate/plural-ar-{six-forms-valid,two-forms-invalid,
|
|
78
|
+
three-forms-invalid}`; `perm/sep-{han,kana,katakana-prolonged-mark,halfwidth-katakana}-not-padded`,
|
|
79
|
+
`perm/sep-hangul-padded`, `perm/sep-mixed-script-padded`. Two more groups move no TS output and pin
|
|
80
|
+
what other engines got wrong: digits-only definition names (`def/digits-only-*`, `set/digits-only-name`
|
|
81
|
+
and the `%0%`/`%07%` controls — #84, PHP lost the value to integer-key coercion) and `extract`'s refs
|
|
82
|
+
(`extract/ref-inside-{set,def}-value`, `extract/def-name-not-a-phantom-ref`,
|
|
83
|
+
`extract/conditional-ref-inside-set-value`, `extract/definition-lhs-after-line-separator` — #83, the PHP runners scanned a different body). Every
|
|
84
|
+
expectation was written by hand and checked against both PHP engines with their mirrors applied.
|
|
85
|
+
|
|
6
86
|
## 0.10.0 — 2026-09-19
|
|
7
87
|
|
|
8
88
|
### Fixed
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ parse, render, validate, extract, analyze, and neutralize spintax templates.
|
|
|
19
19
|
[`spintax-core`](https://pypi.org/project/spintax-core/) (Python), an Object Pascal engine,
|
|
20
20
|
[`Spintax.Core`](https://www.nuget.org/packages/Spintax.Core/) (.NET), and
|
|
21
21
|
the [Spintax WordPress plugin](https://wordpress.org/plugins/spintax/) are all held to the same
|
|
22
|
-
behavior contract by a **shared golden corpus of
|
|
22
|
+
behavior contract by a **shared golden corpus of 408 fixtures** — deterministic verdicts, plural
|
|
23
23
|
buckets, conditionals, `#set`/`#def` semantics and post-processing agree everywhere, so a
|
|
24
24
|
template is an asset you can move between runtimes, not a lock-in.
|
|
25
25
|
- **MIT** licensed.
|
|
@@ -85,7 +85,7 @@ dedupe in the host and cap the retries: a template may simply not have N combina
|
|
|
85
85
|
| Local set | `#set %v% = value` | define a macro — re-picked at every use |
|
|
86
86
|
| Local def | `#def %v% = value` | define a value — picked once per render, held at every use |
|
|
87
87
|
| Conditional | `{?VAR?then\|else}` | `then` if `VAR` is truthy, else `else` — inside `{…}`/`[…]` it resolves first, so a `\|` in the taken branch separates options and an empty one drops its element |
|
|
88
|
-
| Plural | `{plural %n%: one\|few\|many}` | grammatical agreement by locale |
|
|
88
|
+
| Plural | `{plural %n%: one\|few\|many}` | grammatical agreement by locale — 2 forms (en), 3 (ru/uk/be, sr/hr/bs), 6 (ar: zero\|one\|two\|few\|many\|other) |
|
|
89
89
|
| Include | `#include "slug-or-id"` | embed another template (host-resolved) |
|
|
90
90
|
| Comment | `/# … #/` | stripped before rendering |
|
|
91
91
|
|
|
@@ -103,7 +103,7 @@ capitalization, URL/email shielding) is **on by default**.
|
|
|
103
103
|
render(input, {
|
|
104
104
|
context?: Record<string, string>, // variable map
|
|
105
105
|
seed?: number | string, // deterministic RNG; omit ⇒ random
|
|
106
|
-
locale?: string, // plural buckets
|
|
106
|
+
locale?: string, // plural buckets: 'ru' 3-form, 'ar' 6-form
|
|
107
107
|
includeResolver?: (ref: string) => string | null, // host-injected, synchronous
|
|
108
108
|
postProcess?: boolean, // default true; false ⇒ raw pick
|
|
109
109
|
maxDepth?: number, // #include / nesting guard (default 20)
|
package/dist/index.cjs
CHANGED
|
@@ -744,6 +744,8 @@ function normalizeBaseLang(locale) {
|
|
|
744
744
|
}
|
|
745
745
|
function pluralArity(baseLang) {
|
|
746
746
|
switch (baseLang) {
|
|
747
|
+
case "ar":
|
|
748
|
+
return 6;
|
|
747
749
|
case "ru":
|
|
748
750
|
case "uk":
|
|
749
751
|
case "be":
|
|
@@ -761,6 +763,13 @@ function pluralFor(baseLang, n, forms) {
|
|
|
761
763
|
const mod10 = abs % 10;
|
|
762
764
|
const mod100 = abs % 100;
|
|
763
765
|
switch (baseLang) {
|
|
766
|
+
case "ar":
|
|
767
|
+
if (abs === 0) return forms[0] ?? "";
|
|
768
|
+
if (abs === 1) return forms[1] ?? "";
|
|
769
|
+
if (abs === 2) return forms[2] ?? "";
|
|
770
|
+
if (mod100 >= 3 && mod100 <= 10) return forms[3] ?? "";
|
|
771
|
+
if (mod100 >= 11) return forms[4] ?? "";
|
|
772
|
+
return forms[5] ?? "";
|
|
764
773
|
case "ru":
|
|
765
774
|
case "uk":
|
|
766
775
|
case "be":
|
|
@@ -1380,6 +1389,37 @@ function trimFragment(fragment) {
|
|
|
1380
1389
|
if (fragment.lead === 0 && fragment.trail === 0) return fragment;
|
|
1381
1390
|
return new Window(fragment, fragment.lead, fragment.length - fragment.trail);
|
|
1382
1391
|
}
|
|
1392
|
+
function firstCodePointOf(fragment) {
|
|
1393
|
+
const hi = charCodeAtOf(fragment, 0);
|
|
1394
|
+
if (hi >= 55296 && hi <= 56319) {
|
|
1395
|
+
const lo = charCodeAtOf(fragment, 1);
|
|
1396
|
+
if (lo >= 56320 && lo <= 57343) return (hi - 55296) * 1024 + (lo - 56320) + 65536;
|
|
1397
|
+
}
|
|
1398
|
+
return hi;
|
|
1399
|
+
}
|
|
1400
|
+
function charCodeAtOf(fragment, index) {
|
|
1401
|
+
let piece = fragment;
|
|
1402
|
+
let at = index;
|
|
1403
|
+
for (; ; ) {
|
|
1404
|
+
if (at < 0 || at >= lengthOf(piece)) return -1;
|
|
1405
|
+
if (typeof piece === "string") return piece.charCodeAt(at);
|
|
1406
|
+
if (piece instanceof Window) {
|
|
1407
|
+
at += piece.from;
|
|
1408
|
+
piece = piece.base;
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
let offset = 0;
|
|
1412
|
+
for (const child of piece.pieces) {
|
|
1413
|
+
const length = lengthOf(child);
|
|
1414
|
+
if (at < offset + length) {
|
|
1415
|
+
piece = child;
|
|
1416
|
+
at -= offset;
|
|
1417
|
+
break;
|
|
1418
|
+
}
|
|
1419
|
+
offset += length;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1383
1423
|
function flatten(fragment) {
|
|
1384
1424
|
if (typeof fragment === "string") return fragment;
|
|
1385
1425
|
const out = [];
|
|
@@ -1773,7 +1813,12 @@ function assemblePermutation(node, rendered, opts) {
|
|
|
1773
1813
|
max = Math.max(min, Math.min(max, total));
|
|
1774
1814
|
const pick = randomInt(opts.rng, min, max);
|
|
1775
1815
|
shuffle(elements, opts.rng);
|
|
1776
|
-
return joinWithSeparators(
|
|
1816
|
+
return joinWithSeparators(
|
|
1817
|
+
elements.slice(0, pick),
|
|
1818
|
+
config.sep,
|
|
1819
|
+
config.lastsep ?? config.sep,
|
|
1820
|
+
normalizeBaseLang(opts.locale)
|
|
1821
|
+
);
|
|
1777
1822
|
}
|
|
1778
1823
|
function shuffle(arr, rng) {
|
|
1779
1824
|
for (let i = arr.length - 1; i > 0; i -= 1) {
|
|
@@ -1783,7 +1828,7 @@ function shuffle(arr, rng) {
|
|
|
1783
1828
|
arr[j] = tmp;
|
|
1784
1829
|
}
|
|
1785
1830
|
}
|
|
1786
|
-
function joinWithSeparators(elements, globalSep, globalLastsep) {
|
|
1831
|
+
function joinWithSeparators(elements, globalSep, globalLastsep, lang) {
|
|
1787
1832
|
const count = elements.length;
|
|
1788
1833
|
if (count === 0) return "";
|
|
1789
1834
|
if (count === 1) return elements[0].text;
|
|
@@ -1791,14 +1836,24 @@ function joinWithSeparators(elements, globalSep, globalLastsep) {
|
|
|
1791
1836
|
for (let i = 1; i < count; i += 1) {
|
|
1792
1837
|
const el = elements[i];
|
|
1793
1838
|
const sep = el.sep ?? (i === count - 1 ? globalLastsep : globalSep);
|
|
1794
|
-
pieces.push(padSeparator(sep), el.text);
|
|
1839
|
+
pieces.push(padSeparator(sep, lang, el.text), el.text);
|
|
1795
1840
|
}
|
|
1796
1841
|
return joinFragments(pieces);
|
|
1797
1842
|
}
|
|
1798
|
-
|
|
1843
|
+
var UNSPACED_SCRIPT_RE = /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}ーー\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]+$/u;
|
|
1844
|
+
var PROCLITICS = /* @__PURE__ */ new Map([
|
|
1845
|
+
["ar", { words: /* @__PURE__ */ new Set(["\u0648", "\u0641"]), letter: /^(?=\p{L})\p{Script=Arabic}$/u }],
|
|
1846
|
+
["he", { words: /* @__PURE__ */ new Set(["\u05D5"]), letter: /^(?=\p{L})\p{Script=Hebrew}$/u }]
|
|
1847
|
+
]);
|
|
1848
|
+
function padSeparator(sep, lang, next) {
|
|
1799
1849
|
const trimmed = phpTrim(sep);
|
|
1800
1850
|
if (trimmed === "") return sep;
|
|
1801
|
-
|
|
1851
|
+
const proclitic = PROCLITICS.get(lang);
|
|
1852
|
+
if (proclitic !== void 0 && proclitic.words.has(trimmed)) {
|
|
1853
|
+
const first = firstCodePointOf(next);
|
|
1854
|
+
return first >= 0 && proclitic.letter.test(String.fromCodePoint(first)) ? ` ${trimmed}` : ` ${trimmed} `;
|
|
1855
|
+
}
|
|
1856
|
+
if (/^\p{L}+$/u.test(trimmed) && !UNSPACED_SCRIPT_RE.test(trimmed)) return ` ${trimmed} `;
|
|
1802
1857
|
return sep;
|
|
1803
1858
|
}
|
|
1804
1859
|
|