@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 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 258 fixtures** — deterministic verdicts, plural
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, e.g. 'ru' (3-form)
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(elements.slice(0, pick), config.sep, config.lastsep ?? config.sep);
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
- function padSeparator(sep) {
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
- if (/^\p{L}+$/u.test(trimmed)) return ` ${trimmed} `;
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