@spintax/core 0.9.0 → 0.11.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,97 @@
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.11.0 — 2026-10-03
7
+
8
+ A minor: one verdict changes (a two-form `ar` plural is now an error) and rendered text moves for
9
+ Arabic plurals and for CJK list separators.
10
+
11
+ ### Added
12
+
13
+ - **Arabic plurals (#88).** `pluralArity('ar')` is **6**, and `{plural …}` picks in CLDR order:
14
+ zero (0), one (1), two (2), few (n % 100 in 3..10), many (n % 100 in 11..99), other (everything
15
+ else — 100–102, 200–202, …). Negative counts use `abs()` as before; counts are integers, so CLDR's
16
+ fraction rules cannot be reached. Arabic used to get the English rule — `forms[0]` for 1,
17
+ `forms[1]` for everything else — which is ungrammatical for most counts, and a correct six-form
18
+ block was a `plural.arity` error, so there was no way to write one.
19
+ **The arity is strict, like every other locale's: a two-form `ar` block is now `plural.arity`**
20
+ and renders as the fullwidth fallback. Accepting two as a fallback would keep the silent
21
+ ungrammatical output. `DEFAULT_PLURAL_ARITY` (no locale) stays 2.
22
+
23
+ ### Fixed
24
+
25
+ - **A Chinese or Japanese list separator joins without spaces (#87).** A separator made only of
26
+ letters is space-padded on join (`[<lastsep="and">A|B]` → `A and B`), and that was applied to
27
+ scripts written without spaces between words: `[<lastsep="和">SSO|告警]` printed `SSO 和 告警`. Now a
28
+ separator whose every letter is Han, Hiragana or Katakana — plus the prolonged-sound marks U+30FC
29
+ and U+FF70, which are Script=Common but written inside Katakana words — joins bare: `SSO和告警`,
30
+ `SSOおよび監視`. Hangul keeps the padding (Korean spaces its words), and so does a separator that
31
+ mixes scripts (`and和`). Thai, Lao, Khmer and Myanmar are also written without spaces and are left
32
+ as they were until a template in them shows up.
33
+
34
+ ### Corpus
35
+
36
+ 386 cases (+34). `plural/ar-*` at 0, 1, 2, 3, 10, 11, 99, 100, 101, 102, 103, 111 and −3, a real
37
+ noun under `ar-EG`, and the two-form fallback; `validate/plural-ar-{six-forms-valid,two-forms-invalid,
38
+ three-forms-invalid}`; `perm/sep-{han,kana,katakana-prolonged-mark,halfwidth-katakana}-not-padded`,
39
+ `perm/sep-hangul-padded`, `perm/sep-mixed-script-padded`. Two more groups move no TS output and pin
40
+ what other engines got wrong: digits-only definition names (`def/digits-only-*`, `set/digits-only-name`
41
+ and the `%0%`/`%07%` controls — #84, PHP lost the value to integer-key coercion) and `extract`'s refs
42
+ (`extract/ref-inside-{set,def}-value`, `extract/def-name-not-a-phantom-ref`,
43
+ `extract/conditional-ref-inside-set-value`, `extract/definition-lhs-after-line-separator` — #83, the PHP runners scanned a different body). Every
44
+ expectation was written by hand and checked against both PHP engines with their mirrors applied.
45
+
46
+ ## 0.10.0 — 2026-09-19
47
+
48
+ ### Fixed
49
+
50
+ - **No space between a punctuation mark and the quote or bracket that closes it.** The spacing passes
51
+ put their space after `.!?` and `,;:` whenever the next character was not a space, a digit, a tag or
52
+ the end — so a closing quote or bracket got one in front of it: `"Is it audited? ", the figure`,
53
+ `He called it "the best. "`, `She said "yes, " and left`, `«Как дела? », и ушёл`, `(really? )`,
54
+ `(apples, pears, etc. )`. Found in a landing lead that quotes a question-shaped keyword; every engine
55
+ in the family did the same. Now no space goes between the mark and a closer, and what follows the
56
+ closer is left as written. `)` and `]` always close (a stray `]` stays literal). A quote is read by
57
+ what follows its run, because its shape says nothing — `“` opens English and closes German, `"` and `'`
58
+ do both: whitespace, the end, a tag, the end of a tag (`title="Really?">`), `.,;:!?…`, `)`, `]` or a
59
+ dash make it a closer. Anything else keeps the space it always got — a word or a number
60
+ (`Is it?"Next"` → `Is it? "Next"`), `(`, `$`, a shielded value. The accepted cost: an opening quote
61
+ glued to the mark whose text starts with a follower (`is it?"—no"`, `is it?"<em>yes</em>"`) reads as a
62
+ closer and loses that space. Both PHP engines take the same rule.
63
+
64
+ ### Corpus
65
+
66
+ Nineteen new cases in `render-postprocess.json`, every expectation taken from both PHP engines with the
67
+ rule applied: the reported shapes (`postprocess/closing-quote-after-question`, `closing-quote-before-space`,
68
+ `closing-typographic-quote-after-exclamation`, `closing-single-quote`), the rest of the class
69
+ (`closing-quote-after-period-at-end-of-text`, `closing-quote-after-comma`, `closing-guillemet-ru`,
70
+ `closing-quote-ends-attribute`, `closing-bracket`, `closing-square-bracket`, `closing-quote-before-tag`,
71
+ `closing-run-of-nested-quotes`, `bracket-always-closes`), `closing-quote-followers` and
72
+ `closing-reversed-quotes-and-more-followers` (between them every quote as a closer and every follower),
73
+ the cost `glued-opener-before-a-follower-reads-as-closer`, and three guards —
74
+ `opening-quote-after-sentence-keeps-space` (a quote or a run of quotes before a letter or a digit still
75
+ opens), `quote-before-anything-else-keeps-its-space` (`(`, `$`) and `quote-before-shielded-value-opens`.
76
+
77
+ ### Notes
78
+
79
+ **The list names what follows a CLOSING quote.** The first version of the rule excluded instead — any
80
+ quote run not followed by a letter, a digit or a placeholder closed. That also took the space from an
81
+ opening quote glued to the mark before `(`, `$` or an emoji (`is it?"(really)"`), which the old rule had
82
+ repaired (found by the Codex gate, as were the attribute case and the stray `]`). A list changes output
83
+ only where the quote is known to close, and an unnamed shape keeps the old output. Left as it was: a
84
+ bracket that arrives through `neutralize()` is a sentinel during this stage and is restored after it, so a
85
+ neutralized `[sic.]` still renders `[sic. ]`.
86
+
87
+ **Verified.** 143 968 generated post-process inputs — random soup over letters, digits, whitespace,
88
+ `.,;:!?…`, every quote and bracket, tags and shieldable values, plus an exhaustive grid of mark × quote run
89
+ × follower — through this engine and both patched PHP engines: identical everywhere. The three agreed
90
+ before the change as well, and every one of the 66 988 outputs that moved differs from the old output only
91
+ by a deleted space between a mark and a closer. Thirty control mutations, one per character of the rule —
92
+ each quote dropped, each follower dropped (including whitespace, the end, a tag, `/>` and `>`), `)` or `]`
93
+ no longer closing, the comma pass left alone, and the first version's letter-or-digit rule — each turn the
94
+ differential red (363 to 25 905 inputs) and fail at least one fixture. Cost unchanged here and in PHP:
95
+ 200 000 marks before 200 000 quotes take single-digit milliseconds in all three.
96
+
6
97
  ## 0.9.0 — 2026-09-17
7
98
 
8
99
  ### Changed, visibly
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 386 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":
@@ -1795,10 +1804,11 @@ function joinWithSeparators(elements, globalSep, globalLastsep) {
1795
1804
  }
1796
1805
  return joinFragments(pieces);
1797
1806
  }
1807
+ var UNSPACED_SCRIPT_RE = /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}ーー]+$/u;
1798
1808
  function padSeparator(sep) {
1799
1809
  const trimmed = phpTrim(sep);
1800
1810
  if (trimmed === "") return sep;
1801
- if (/^\p{L}+$/u.test(trimmed)) return ` ${trimmed} `;
1811
+ if (/^\p{L}+$/u.test(trimmed) && !UNSPACED_SCRIPT_RE.test(trimmed)) return ` ${trimmed} `;
1802
1812
  return sep;
1803
1813
  }
1804
1814
 
@@ -1911,9 +1921,11 @@ function trailingPunctuationStart(value) {
1911
1921
  }
1912
1922
  var SENTENCE_OPENERS = "\xBF\xA1";
1913
1923
  var LEAD = `(?:<[^>]+>|[${SENTENCE_OPENERS}]|${S})*`;
1924
+ var QUOTES = `"'\xAB\xBB\u2039\u203A\u201C\u201D\u2018\u2019`;
1925
+ var CLOSER = `[)\\]]|[${QUOTES}]+(?=${S}|$|<|\\/?>|[.,;:!?\u2026)\\]\u2014\u2013])`;
1914
1926
  var SPACE_BEFORE_PUNCT_RE = new RegExp(`(?<!${S})${S}+([,;:!?.])`, "gu");
1915
- var SPACE_AFTER_COMMA_RE = new RegExp(`([,;:])(?!\\p{Nd})(?!${S}|$|<)`, "gu");
1916
- var SPACE_AFTER_SENTENCE_RE = new RegExp(`(?<![.!?])([.!?]+)(?![.!?])(?!\\p{Nd})(?!${S}|$|<)`, "gu");
1927
+ var SPACE_AFTER_COMMA_RE = new RegExp(`([,;:])(?!\\p{Nd})(?!${S}|$|<|${CLOSER})`, "gu");
1928
+ var SPACE_AFTER_SENTENCE_RE = new RegExp(`(?<![.!?])([.!?]+)(?![.!?])(?!\\p{Nd})(?!${S}|$|<|${CLOSER})`, "gu");
1917
1929
  var SPACE_AFTER_OPENER_RE = new RegExp(`([${SENTENCE_OPENERS}])${S}+`, "gu");
1918
1930
  var CAP_FIRST_RE = new RegExp(`^(${LEAD})(\\p{Ll})`, "u");
1919
1931
  var up = (ch) => ch.toUpperCase();