@spintax/core 0.11.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,46 @@
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
+
6
46
  ## 0.11.0 — 2026-10-03
7
47
 
8
48
  A minor: one verdict changes (a two-form `ar` plural is now an error) and rendered text moves for
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 386 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.
package/dist/index.cjs CHANGED
@@ -1389,6 +1389,37 @@ function trimFragment(fragment) {
1389
1389
  if (fragment.lead === 0 && fragment.trail === 0) return fragment;
1390
1390
  return new Window(fragment, fragment.lead, fragment.length - fragment.trail);
1391
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
+ }
1392
1423
  function flatten(fragment) {
1393
1424
  if (typeof fragment === "string") return fragment;
1394
1425
  const out = [];
@@ -1782,7 +1813,12 @@ function assemblePermutation(node, rendered, opts) {
1782
1813
  max = Math.max(min, Math.min(max, total));
1783
1814
  const pick = randomInt(opts.rng, min, max);
1784
1815
  shuffle(elements, opts.rng);
1785
- 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
+ );
1786
1822
  }
1787
1823
  function shuffle(arr, rng) {
1788
1824
  for (let i = arr.length - 1; i > 0; i -= 1) {
@@ -1792,7 +1828,7 @@ function shuffle(arr, rng) {
1792
1828
  arr[j] = tmp;
1793
1829
  }
1794
1830
  }
1795
- function joinWithSeparators(elements, globalSep, globalLastsep) {
1831
+ function joinWithSeparators(elements, globalSep, globalLastsep, lang) {
1796
1832
  const count = elements.length;
1797
1833
  if (count === 0) return "";
1798
1834
  if (count === 1) return elements[0].text;
@@ -1800,14 +1836,23 @@ function joinWithSeparators(elements, globalSep, globalLastsep) {
1800
1836
  for (let i = 1; i < count; i += 1) {
1801
1837
  const el = elements[i];
1802
1838
  const sep = el.sep ?? (i === count - 1 ? globalLastsep : globalSep);
1803
- pieces.push(padSeparator(sep), el.text);
1839
+ pieces.push(padSeparator(sep, lang, el.text), el.text);
1804
1840
  }
1805
1841
  return joinFragments(pieces);
1806
1842
  }
1807
- var UNSPACED_SCRIPT_RE = /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}ーー]+$/u;
1808
- 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) {
1809
1849
  const trimmed = phpTrim(sep);
1810
1850
  if (trimmed === "") return sep;
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
+ }
1811
1856
  if (/^\p{L}+$/u.test(trimmed) && !UNSPACED_SCRIPT_RE.test(trimmed)) return ` ${trimmed} `;
1812
1857
  return sep;
1813
1858
  }