@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 +38 -0
- package/README.md +1 -1
- package/dist/index.cjs +15 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +15 -10
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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)
|
|
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) +
|
|
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 +
|
|
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) {
|