@spintax/core 0.12.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,44 @@
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
+
6
44
  ## 0.12.0 — 2026-10-06
7
45
 
8
46
  A minor: rendered text moves for permutation separators in Arabic, Hebrew, Thai and Lao. No verdict
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 408 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
@@ -1919,7 +1919,7 @@ var TLD_UPPER = "\\p{Lu}\\p{Lt}\\p{Lm}\\p{Lo}";
1919
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})`;
1920
1920
  var DOMAIN_PART = `(?:${LABEL}\\.)+${TLD}`;
1921
1921
  var URI_BODY = `[^\\x00${WS}<>"')\\]]`;
1922
- 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");
1923
1923
  var MAILTEL_PREFIX_RE = /^(?:mailto|tel):/iu;
1924
1924
  var DOMAIN_AT_RE = new RegExp(`${DOMAIN_PART}${WORD_BOUNDARY}`, "uy");
1925
1925
  var DOMAIN_SCAN_RE = new RegExp(`${AFTER_NON_WORD}(?:(${DOMAIN_PART}${WORD_BOUNDARY})|(?:${LABEL}\\.)*${LABEL})`, "gu");
@@ -1968,12 +1968,15 @@ var SENTENCE_OPENERS = "\xBF\xA1";
1968
1968
  var LEAD = `(?:<[^>]+>|[${SENTENCE_OPENERS}]|${S})*`;
1969
1969
  var QUOTES = `"'\xAB\xBB\u2039\u203A\u201C\u201D\u2018\u2019`;
1970
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");
1971
1972
  var SPACE_BEFORE_PUNCT_RE = new RegExp(`(?<!${S})${S}+([,;:!?.])`, "gu");
1972
1973
  var SPACE_AFTER_COMMA_RE = new RegExp(`([,;:])(?!\\p{Nd})(?!${S}|$|<|${CLOSER})`, "gu");
1973
1974
  var SPACE_AFTER_SENTENCE_RE = new RegExp(`(?<![.!?])([.!?]+)(?![.!?])(?!\\p{Nd})(?!${S}|$|<|${CLOSER})`, "gu");
1974
1975
  var SPACE_AFTER_OPENER_RE = new RegExp(`([${SENTENCE_OPENERS}])${S}+`, "gu");
1975
1976
  var CAP_FIRST_RE = new RegExp(`^(${LEAD})(\\p{Ll})`, "u");
1976
1977
  var up = (ch) => ch.toUpperCase();
1978
+ var upTurkic = (ch) => ch === "i" ? "\u0130" : ch.toUpperCase();
1979
+ var TURKIC_LANGS = /* @__PURE__ */ new Set(["tr", "az"]);
1977
1980
  function indexLeads(text) {
1978
1981
  const n = text.length;
1979
1982
  const leadEnd = new Int32Array(n + 1);
@@ -2006,7 +2009,7 @@ function leadEndFrom(text, i, leads) {
2006
2009
  }
2007
2010
  return j < text.length ? leads().leadEnd[j] : j;
2008
2011
  }
2009
- function capitalizeAfter(text, lower, next, context) {
2012
+ function capitalizeAfter(text, lower, next, context, upper) {
2010
2013
  const leads = () => {
2011
2014
  if (context.leads === null || context.leads.leadEnd.length !== text.length + 1) context.leads = indexLeads(text);
2012
2015
  return context.leads;
@@ -2021,7 +2024,7 @@ function capitalizeAfter(text, lower, next, context) {
2021
2024
  if (cp === void 0) continue;
2022
2025
  const ch = String.fromCodePoint(cp);
2023
2026
  if (!lower.test(ch)) continue;
2024
- out += text.slice(emitted, at) + up(ch);
2027
+ out += text.slice(emitted, at) + upper(ch);
2025
2028
  emitted = at + ch.length;
2026
2029
  from = emitted;
2027
2030
  }
@@ -2054,7 +2057,7 @@ var afterLineBreak = (text, from) => {
2054
2057
  const at = text.indexOf("\n", from);
2055
2058
  return at === -1 ? null : { leadStart: at + 1, resume: at + 1 };
2056
2059
  };
2057
- var SHIELD_PREFIXES = ["URL", "URI", "EMAIL", "DOM", "NUM", "ABBR"];
2060
+ var SHIELD_PREFIXES = ["URL", "URI", "EMAIL", "DOTTOK", "DOM", "NUM", "ABBR"];
2058
2061
  var RESTORE_RE2 = new RegExp(`\\x00(?:${SHIELD_PREFIXES.join("|")})_\\d+\\x00`, "gu");
2059
2062
  function restore(text, input, placeholders) {
2060
2063
  if (!input.includes("\0")) {
@@ -2089,7 +2092,8 @@ function restoreAsTheLoopDoes(text, placeholders) {
2089
2092
  }
2090
2093
  return out;
2091
2094
  }
2092
- function postProcess(input) {
2095
+ function postProcess(input, locale = "") {
2096
+ const upper = TURKIC_LANGS.has(normalizeBaseLang(locale)) ? upTurkic : up;
2093
2097
  const placeholders = /* @__PURE__ */ new Map();
2094
2098
  let counter = 0;
2095
2099
  const store = (value, prefix) => {
@@ -2109,6 +2113,7 @@ function postProcess(input) {
2109
2113
  (m) => storeWithTrailingPunct(m, MAILTEL_PREFIX_RE.test(m) ? "URI" : "URL")
2110
2114
  );
2111
2115
  text = shieldEmails(text, (m) => store(m, "EMAIL"));
2116
+ text = text.replace(DOT_TOKEN_RE, (m) => storeWithTrailingPunct(m, "DOTTOK"));
2112
2117
  text = shieldDomains(text, (m) => store(m, "DOM"));
2113
2118
  text = text.replace(DECIMAL_RE, (m) => store(m, "NUM"));
2114
2119
  text = text.replace(MULTI_ABBR_RE, (m) => store(m, "ABBR"));
@@ -2118,11 +2123,11 @@ function postProcess(input) {
2118
2123
  text = text.replace(SPACE_AFTER_COMMA_RE, "$1 ");
2119
2124
  text = text.replace(SPACE_AFTER_SENTENCE_RE, "$1 ");
2120
2125
  text = text.replace(SPACE_AFTER_OPENER_RE, "$1");
2121
- 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));
2122
2127
  const leadContext = { leads: null };
2123
- text = capitalizeAfter(text, LOWER_RE, afterSentenceEnd, leadContext);
2124
- text = capitalizeAfter(text, LOWER_RE, afterBlockTag(), leadContext);
2125
- 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);
2126
2131
  return restore(text, input, placeholders).trim();
2127
2132
  }
2128
2133
 
@@ -2143,7 +2148,7 @@ function renderWith(input, rng, opts = {}) {
2143
2148
  };
2144
2149
  const ast = resolveAst(input);
2145
2150
  let out = renderAst(ast, ctx);
2146
- if (opts.postProcess !== false) out = postProcess(out);
2151
+ if (opts.postProcess !== false) out = postProcess(out, ctx.locale);
2147
2152
  return safetyRestore(out);
2148
2153
  }
2149
2154
  function resolveAst(input) {