@spintax/core 0.11.0 → 0.13.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,84 @@
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.13.0 — 2026-10-19
7
+
8
+ A minor: the post-process moves rendered text for a token that starts with a dot, for a bare `//`
9
+ scheme, and for a sentence opening on `i` under Turkish or Azerbaijani. No verdict changes and no API
10
+ change; `render()` already took the `locale` the post-process now also reads.
11
+
12
+ ### Fixed
13
+
14
+ - **A dot that starts a token is not a sentence end (#91).** `.NET`, a dotfile, a file extension were
15
+ taken for sentence punctuation: the space before the dot was removed, a space was inserted after it and
16
+ the token was capitalized — `and .NET` rendered `and. NET`, `the .apk file` rendered `the. Apk file`.
17
+ A token that begins with a dot — `.NET`, `.gitignore`, `.env.local`, `.tar.gz`, `.xlsx`, `.5` — is now
18
+ shielded like a domain or a URL when it follows the text start, whitespace, an opening bracket, a quote,
19
+ a tag or a path separator (`src/.env`, `dir\.env`); trailing punctuation stays outside it (`.NET.`).
20
+ Unchanged: `word .` → `Word.`, `end . Next` → `End. Next`, `end.Next`, `ASP.NET`, `3.5`, and an ellipsis
21
+ glued to a word (`see...and` → `See... And`). The cost: `end .Next` — a sentence typed with the space on
22
+ the wrong side of the dot — is no longer repaired.
23
+ - **Turkish and Azerbaijani capital İ (#94).** Under `locale: 'tr'` or `'az'` (any region, `-` or `_`) a
24
+ sentence that opens on dotted `i` is capitalized to `İ`, not `I` — `istanbul` → `İstanbul`; dotless `ı`
25
+ still becomes `I`. An explicit mapping, not `toLocaleUpperCase`, so the result does not depend on the
26
+ host's ICU data. Other locales are unchanged.
27
+ - **A bare `//` scheme is a URL.** Prose that names the protocol — `(https://)` — got a space after its
28
+ colon: `(https: //)`. A `//` scheme with no body is now shielded like a URL, in any case (`HTTPS://`).
29
+ `mailto:` and `tel:` still need a body, so `Tel: 555` opening a sentence is capitalized and spaced as
30
+ before.
31
+
32
+ Measured before the change over the family's stored content (2 581 production templates in four
33
+ languages, 163 more in ten): every changed document is a fix — 21 renders of a published article that
34
+ read `the. Apk file`, 11 that read `(https: //)` — and nothing else moves.
35
+
36
+ ### Corpus
37
+
38
+ 424 cases (+16), all in `render-postprocess.json`: `postprocess/turkish-*`, `postprocess/azerbaijani-*`,
39
+ `postprocess/turkic-locale-case-and-underscore`, `postprocess/dotted-capital-only-under-turkic-locales`,
40
+ `postprocess/dot-token-*` (openers, punctuation, text and line start, a digit, UCP whitespace, both path
41
+ separators, and the negatives with the accepted cost), `postprocess/bare-scheme-*` and
42
+ `postprocess/bare-mailto-and-tel-are-not-uris`.
43
+
44
+ ## 0.12.0 — 2026-10-06
45
+
46
+ A minor: rendered text moves for permutation separators in Arabic, Hebrew, Thai and Lao. No verdict
47
+ changes and no API change; `render()` already took the `locale` it now also reads here.
48
+
49
+ ### Fixed
50
+
51
+ - **An Arabic list can be written (#90).** The conjunction و ("and") — and ف ("and then") — is written
52
+ attached to the next word: `الكازينو والبث`, never `الكازينو و البث`. A letter-only separator was always
53
+ padded on both sides, whatever spaces the author wrote, so no shape of `lastsep` could produce it. Now,
54
+ under `locale: 'ar'`, a separator that is exactly و or ف keeps the space before it and none after when
55
+ the next element starts with an Arabic letter: `[<sep="، ";lastsep="و">…]` renders
56
+ `الكازينو، المراهنات الرياضية والبث المباشر`. Before anything else — a Latin brand name, a digit — both
57
+ spaces stay: `و Evolution`. Hebrew ו under `locale: 'he'` works the same way. The rule is keyed by
58
+ LANGUAGE, not script: Persian and Urdu write the same letter و as a word of its own, so under `fa` and
59
+ `ur` it is padded as before, and so is every word conjunction (أو, ثم, או) and و with no locale.
60
+ - **Thai and Lao separators join bare (#90),** as Han and kana have since 0.11.0: `[<lastsep="และ">แมว|สุนัข]`
61
+ renders `แมวและสุนัข`. Khmer and Myanmar take the same rule. A separator carrying a combining mark —
62
+ Thai `หรือ`, most Khmer and Myanmar words — is not all letters, so it was never padded and still renders
63
+ exactly as written, spaces included.
64
+
65
+ No template in the family's stored content uses any of these separators (measured over casino-platform,
66
+ content-gen, both sites and the plugin: the only hits were the probes that found the issue), so no
67
+ existing text changes.
68
+
69
+ ### Corpus
70
+
71
+ 408 cases (+22), all in `render-semantics.json`. `perm/sep-arabic-*` (و and ف attached, the author's
72
+ spaces, a regional locale, before a Latin word, a digit, an astral Arabic letter and U+0640, a word
73
+ conjunction, the issue's whole list with the post-process on), `perm/sep-persian-waw-padded`,
74
+ `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-*`,
75
+ `perm/sep-lao-not-padded`, `perm/sep-khmer-not-padded`, `perm/sep-myanmar-not-padded`, and
76
+ `perm/sep-script-not-script-extensions`.
77
+
78
+ That last one pins a divergence 0.11.0 shipped in both PHP engines without anyone seeing it: PCRE2 10.40
79
+ and later read `\p{Han}` as Script_Extensions, so U+3006 〆, U+303C 〼, the kana repeat marks U+3031–3035
80
+ and the halfwidth sound marks U+FF9E/FF9F joined bare there and were padded here and in every port. The
81
+ PHP engines now exclude them, which is Script on any PCRE2 version. The same trap sat under the new Arabic
82
+ test (U+0640, tatweel); the next element is read by code point, not by UTF-16 unit.
83
+
6
84
  ## 0.11.0 — 2026-10-03
7
85
 
8
86
  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 424 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
  }
@@ -1874,7 +1919,7 @@ var TLD_UPPER = "\\p{Lu}\\p{Lt}\\p{Lm}\\p{Lo}";
1874
1919
  var TLD = `(?:${XN}[a-zA-Z0-9\\-\\u017F\\u212A]{2,59}|[${TLD_LOWER}][${TLD_LOWER}\\p{N}-]{1,62}|[${TLD_UPPER}][${TLD_UPPER}\\p{N}-]{1,62})`;
1875
1920
  var DOMAIN_PART = `(?:${LABEL}\\.)+${TLD}`;
1876
1921
  var URI_BODY = `[^\\x00${WS}<>"')\\]]`;
1877
- var URI_RE = new RegExp(`(?:(?:https?|ftp):\\/\\/|(?:mailto|tel):)${URI_BODY}+`, "giu");
1922
+ var URI_RE = new RegExp(`(?:(?:https?|ftp):\\/\\/${URI_BODY}*|(?:mailto|tel):${URI_BODY}+)`, "giu");
1878
1923
  var MAILTEL_PREFIX_RE = /^(?:mailto|tel):/iu;
1879
1924
  var DOMAIN_AT_RE = new RegExp(`${DOMAIN_PART}${WORD_BOUNDARY}`, "uy");
1880
1925
  var DOMAIN_SCAN_RE = new RegExp(`${AFTER_NON_WORD}(?:(${DOMAIN_PART}${WORD_BOUNDARY})|(?:${LABEL}\\.)*${LABEL})`, "gu");
@@ -1923,12 +1968,15 @@ var SENTENCE_OPENERS = "\xBF\xA1";
1923
1968
  var LEAD = `(?:<[^>]+>|[${SENTENCE_OPENERS}]|${S})*`;
1924
1969
  var QUOTES = `"'\xAB\xBB\u2039\u203A\u201C\u201D\u2018\u2019`;
1925
1970
  var CLOSER = `[)\\]]|[${QUOTES}]+(?=${S}|$|<|\\/?>|[.,;:!?\u2026)\\]\u2014\u2013])`;
1971
+ var DOT_TOKEN_RE = new RegExp(`(?<=^|${S}|[(\\[{>/\\\\${QUOTES}])\\.[\\p{L}\\p{N}][\\p{L}\\p{N}_.\\-]*`, "gu");
1926
1972
  var SPACE_BEFORE_PUNCT_RE = new RegExp(`(?<!${S})${S}+([,;:!?.])`, "gu");
1927
1973
  var SPACE_AFTER_COMMA_RE = new RegExp(`([,;:])(?!\\p{Nd})(?!${S}|$|<|${CLOSER})`, "gu");
1928
1974
  var SPACE_AFTER_SENTENCE_RE = new RegExp(`(?<![.!?])([.!?]+)(?![.!?])(?!\\p{Nd})(?!${S}|$|<|${CLOSER})`, "gu");
1929
1975
  var SPACE_AFTER_OPENER_RE = new RegExp(`([${SENTENCE_OPENERS}])${S}+`, "gu");
1930
1976
  var CAP_FIRST_RE = new RegExp(`^(${LEAD})(\\p{Ll})`, "u");
1931
1977
  var up = (ch) => ch.toUpperCase();
1978
+ var upTurkic = (ch) => ch === "i" ? "\u0130" : ch.toUpperCase();
1979
+ var TURKIC_LANGS = /* @__PURE__ */ new Set(["tr", "az"]);
1932
1980
  function indexLeads(text) {
1933
1981
  const n = text.length;
1934
1982
  const leadEnd = new Int32Array(n + 1);
@@ -1961,7 +2009,7 @@ function leadEndFrom(text, i, leads) {
1961
2009
  }
1962
2010
  return j < text.length ? leads().leadEnd[j] : j;
1963
2011
  }
1964
- function capitalizeAfter(text, lower, next, context) {
2012
+ function capitalizeAfter(text, lower, next, context, upper) {
1965
2013
  const leads = () => {
1966
2014
  if (context.leads === null || context.leads.leadEnd.length !== text.length + 1) context.leads = indexLeads(text);
1967
2015
  return context.leads;
@@ -1976,7 +2024,7 @@ function capitalizeAfter(text, lower, next, context) {
1976
2024
  if (cp === void 0) continue;
1977
2025
  const ch = String.fromCodePoint(cp);
1978
2026
  if (!lower.test(ch)) continue;
1979
- out += text.slice(emitted, at) + up(ch);
2027
+ out += text.slice(emitted, at) + upper(ch);
1980
2028
  emitted = at + ch.length;
1981
2029
  from = emitted;
1982
2030
  }
@@ -2009,7 +2057,7 @@ var afterLineBreak = (text, from) => {
2009
2057
  const at = text.indexOf("\n", from);
2010
2058
  return at === -1 ? null : { leadStart: at + 1, resume: at + 1 };
2011
2059
  };
2012
- var SHIELD_PREFIXES = ["URL", "URI", "EMAIL", "DOM", "NUM", "ABBR"];
2060
+ var SHIELD_PREFIXES = ["URL", "URI", "EMAIL", "DOTTOK", "DOM", "NUM", "ABBR"];
2013
2061
  var RESTORE_RE2 = new RegExp(`\\x00(?:${SHIELD_PREFIXES.join("|")})_\\d+\\x00`, "gu");
2014
2062
  function restore(text, input, placeholders) {
2015
2063
  if (!input.includes("\0")) {
@@ -2044,7 +2092,8 @@ function restoreAsTheLoopDoes(text, placeholders) {
2044
2092
  }
2045
2093
  return out;
2046
2094
  }
2047
- function postProcess(input) {
2095
+ function postProcess(input, locale = "") {
2096
+ const upper = TURKIC_LANGS.has(normalizeBaseLang(locale)) ? upTurkic : up;
2048
2097
  const placeholders = /* @__PURE__ */ new Map();
2049
2098
  let counter = 0;
2050
2099
  const store = (value, prefix) => {
@@ -2064,6 +2113,7 @@ function postProcess(input) {
2064
2113
  (m) => storeWithTrailingPunct(m, MAILTEL_PREFIX_RE.test(m) ? "URI" : "URL")
2065
2114
  );
2066
2115
  text = shieldEmails(text, (m) => store(m, "EMAIL"));
2116
+ text = text.replace(DOT_TOKEN_RE, (m) => storeWithTrailingPunct(m, "DOTTOK"));
2067
2117
  text = shieldDomains(text, (m) => store(m, "DOM"));
2068
2118
  text = text.replace(DECIMAL_RE, (m) => store(m, "NUM"));
2069
2119
  text = text.replace(MULTI_ABBR_RE, (m) => store(m, "ABBR"));
@@ -2073,11 +2123,11 @@ function postProcess(input) {
2073
2123
  text = text.replace(SPACE_AFTER_COMMA_RE, "$1 ");
2074
2124
  text = text.replace(SPACE_AFTER_SENTENCE_RE, "$1 ");
2075
2125
  text = text.replace(SPACE_AFTER_OPENER_RE, "$1");
2076
- text = text.replace(CAP_FIRST_RE, (_m, lead, ch) => lead + up(ch));
2126
+ text = text.replace(CAP_FIRST_RE, (_m, lead, ch) => lead + upper(ch));
2077
2127
  const leadContext = { leads: null };
2078
- text = capitalizeAfter(text, LOWER_RE, afterSentenceEnd, leadContext);
2079
- text = capitalizeAfter(text, LOWER_RE, afterBlockTag(), leadContext);
2080
- text = capitalizeAfter(text, LOWER_RE, afterLineBreak, leadContext);
2128
+ text = capitalizeAfter(text, LOWER_RE, afterSentenceEnd, leadContext, upper);
2129
+ text = capitalizeAfter(text, LOWER_RE, afterBlockTag(), leadContext, upper);
2130
+ text = capitalizeAfter(text, LOWER_RE, afterLineBreak, leadContext, upper);
2081
2131
  return restore(text, input, placeholders).trim();
2082
2132
  }
2083
2133
 
@@ -2098,7 +2148,7 @@ function renderWith(input, rng, opts = {}) {
2098
2148
  };
2099
2149
  const ast = resolveAst(input);
2100
2150
  let out = renderAst(ast, ctx);
2101
- if (opts.postProcess !== false) out = postProcess(out);
2151
+ if (opts.postProcess !== false) out = postProcess(out, ctx.locale);
2102
2152
  return safetyRestore(out);
2103
2153
  }
2104
2154
  function resolveAst(input) {