@spintax/core 0.8.0 → 0.10.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,131 @@
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.10.0 — 2026-09-19
7
+
8
+ ### Fixed
9
+
10
+ - **No space between a punctuation mark and the quote or bracket that closes it.** The spacing passes
11
+ put their space after `.!?` and `,;:` whenever the next character was not a space, a digit, a tag or
12
+ the end — so a closing quote or bracket got one in front of it: `"Is it audited? ", the figure`,
13
+ `He called it "the best. "`, `She said "yes, " and left`, `«Как дела? », и ушёл`, `(really? )`,
14
+ `(apples, pears, etc. )`. Found in a landing lead that quotes a question-shaped keyword; every engine
15
+ in the family did the same. Now no space goes between the mark and a closer, and what follows the
16
+ closer is left as written. `)` and `]` always close (a stray `]` stays literal). A quote is read by
17
+ what follows its run, because its shape says nothing — `“` opens English and closes German, `"` and `'`
18
+ do both: whitespace, the end, a tag, the end of a tag (`title="Really?">`), `.,;:!?…`, `)`, `]` or a
19
+ dash make it a closer. Anything else keeps the space it always got — a word or a number
20
+ (`Is it?"Next"` → `Is it? "Next"`), `(`, `$`, a shielded value. The accepted cost: an opening quote
21
+ glued to the mark whose text starts with a follower (`is it?"—no"`, `is it?"<em>yes</em>"`) reads as a
22
+ closer and loses that space. Both PHP engines take the same rule.
23
+
24
+ ### Corpus
25
+
26
+ Nineteen new cases in `render-postprocess.json`, every expectation taken from both PHP engines with the
27
+ rule applied: the reported shapes (`postprocess/closing-quote-after-question`, `closing-quote-before-space`,
28
+ `closing-typographic-quote-after-exclamation`, `closing-single-quote`), the rest of the class
29
+ (`closing-quote-after-period-at-end-of-text`, `closing-quote-after-comma`, `closing-guillemet-ru`,
30
+ `closing-quote-ends-attribute`, `closing-bracket`, `closing-square-bracket`, `closing-quote-before-tag`,
31
+ `closing-run-of-nested-quotes`, `bracket-always-closes`), `closing-quote-followers` and
32
+ `closing-reversed-quotes-and-more-followers` (between them every quote as a closer and every follower),
33
+ the cost `glued-opener-before-a-follower-reads-as-closer`, and three guards —
34
+ `opening-quote-after-sentence-keeps-space` (a quote or a run of quotes before a letter or a digit still
35
+ opens), `quote-before-anything-else-keeps-its-space` (`(`, `$`) and `quote-before-shielded-value-opens`.
36
+
37
+ ### Notes
38
+
39
+ **The list names what follows a CLOSING quote.** The first version of the rule excluded instead — any
40
+ quote run not followed by a letter, a digit or a placeholder closed. That also took the space from an
41
+ opening quote glued to the mark before `(`, `$` or an emoji (`is it?"(really)"`), which the old rule had
42
+ repaired (found by the Codex gate, as were the attribute case and the stray `]`). A list changes output
43
+ only where the quote is known to close, and an unnamed shape keeps the old output. Left as it was: a
44
+ bracket that arrives through `neutralize()` is a sentinel during this stage and is restored after it, so a
45
+ neutralized `[sic.]` still renders `[sic. ]`.
46
+
47
+ **Verified.** 143 968 generated post-process inputs — random soup over letters, digits, whitespace,
48
+ `.,;:!?…`, every quote and bracket, tags and shieldable values, plus an exhaustive grid of mark × quote run
49
+ × follower — through this engine and both patched PHP engines: identical everywhere. The three agreed
50
+ before the change as well, and every one of the 66 988 outputs that moved differs from the old output only
51
+ by a deleted space between a mark and a closer. Thirty control mutations, one per character of the rule —
52
+ each quote dropped, each follower dropped (including whitespace, the end, a tag, `/>` and `>`), `)` or `]`
53
+ no longer closing, the comma pass left alone, and the first version's letter-or-digit rule — each turn the
54
+ differential red (363 to 25 905 inputs) and fail at least one fixture. Cost unchanged here and in PHP:
55
+ 200 000 marks before 200 000 quotes take single-digit milliseconds in all three.
56
+
57
+ ## 0.9.0 — 2026-09-17
58
+
59
+ ### Changed, visibly
60
+
61
+ - **A TLD is a label in ONE case (#79).** The bare-domain and email shields took any `word.Word` for a
62
+ domain, so a sentence glued to the next one kept its missing space and its lower-case start:
63
+ `kept compact.Game categories`, `конец.Начало`. On a production host's stored content that shape was
64
+ a sentence start 369 times in 56 tenants and a domain never. Now a TLD is all lower case or all upper
65
+ case — letters without case (CJK, Arabic, Thai) fit either — so those sentences get their space and
66
+ their capital, while `example.com`, `ASP.NET`, `info@Example.COM`, `ПРИМЕР.РФ`, `例子.中国` and a punycode
67
+ TLD in any case stay whole. The accepted cost: a Title-case second half reads as a sentence too —
68
+ `Yandex.Money` renders `Yandex. Money`, and `info@example.Com` is no longer shielded as an email. Both
69
+ PHP engines take the same rule.
70
+
71
+ ### Fixed
72
+
73
+ - **A titlecase letter after a block tag stays as PHP leaves it.** The plugin writes that capitalizer
74
+ `/ui`, and PCRE2 does not fold a Unicode property, so its `\p{Ll}` stays lower case only. This engine
75
+ read `\p{Ll}` under JavaScript's `i`, which takes every cased letter, and `<p>Dživot</p>` became
76
+ `<p>DŽivot</p>`. Found by the post-process differential once its alphabet held a titlecase digraph; as
77
+ old as the capitalizer.
78
+
79
+ ### Corpus
80
+
81
+ Nine new cases in `render-postprocess.json`, every expectation taken from both PHP engines with the rule
82
+ applied: `postprocess/title-case-tld-is-a-sentence` (`compact.Game`), `postprocess/mixed-case-tld-is-not-a-domain`
83
+ (`cOM` — one case means the whole label), `postprocess/title-case-email-tld-is-not-shielded` (the cost,
84
+ pinned so nobody files it), the negatives `postprocess/uppercase-tld-is-a-domain`,
85
+ `postprocess/uppercase-email-domain-is-shielded`, `postprocess/caseless-script-tld-is-a-domain`,
86
+ `postprocess/uppercase-punycode-tld-is-a-domain` and `postprocess/punycode-tld-folds-long-s-and-kelvin`, and
87
+ `postprocess/titlecase-letter-after-block-tag-is-kept`. `postprocess/glued-cyrillic-sentence-is-a-domain`
88
+ becomes `postprocess/glued-cyrillic-sentence-is-spaced`: it pinned the reading before #79, and its note said
89
+ deciding #79 would move it.
90
+
91
+ ### Notes
92
+
93
+ **One JavaScript trap behind both.** Under `i`, a JavaScript `\p{Ll}` matches capitals and a `\p{Lu}`
94
+ matches lower case; PCRE2 leaves a property alone under `/i`. PHP can also switch `i` off for one
95
+ alternative with `(?-i:…)`, which JavaScript has only from ES2025 — Node 24, not the Node 18–22 this engine
96
+ still supports. So the domain
97
+ patterns carry no `i` and spell out the one part that is caseless, the punycode prefix and class — U+017F
98
+ and U+212A included, as PCRE2's caseless `[a-z]` takes them.
99
+
100
+ **Verified.** 24 000 generated post-process inputs — the earlier alphabet with caseless scripts, mixed-case
101
+ TLDs and a titlecase digraph added, plus dotted chains whose TLD candidate is lower, upper, title, mixed or
102
+ caseless, in prose and in emails — through both PHP engines with the rule applied: they agree with each other
103
+ everywhere, this engine differs on none but the recorded final `trim`, and 0.8.0 on 4 552. Control mutations:
104
+ `i` restored on either shield, the caseless letters dropped from one branch or both, the any-case TLD restored,
105
+ U+017F and U+212A dropped from the punycode class — each turns the suite red. The last one first looked
106
+ output-equivalent here, because a lower-case `xn--` prefix always has a one-case letter reading from the same
107
+ start; a mixed-case prefix has none (`site.xN--ſA`, found by the Codex gate), and
108
+ `postprocess/punycode-tld-folds-long-s-and-kelvin` pins it. Post-process cost is unchanged — 1 000 prose calls
109
+ 18 ms against 19, HTML blocks 27 against 26, a 756 KB shield-heavy text 147 ms against 154.
110
+
111
+ **The PHP engines skip what they cannot shield.** Rejecting `Game` made `a.a.…a.Game` — one domain from its
112
+ first start before — a chain PCRE retried from every label: 2 000 one-character labels (about 4 KB) took 48 ms where they had taken
113
+ 0.7 (found by the Codex gate). Both PHP shields now take a run or a chain they cannot shield and
114
+ `(*SKIP)(*FAIL)` past it — the skips this engine's scanners already prove output-neutral — which also
115
+ removes the older retry on a chain whose last label is too short to be a TLD: 2 000 labels, 39 ms before,
116
+ 0.1 ms now. Ordinary text costs 2–4 % more there. JavaScript has no backtracking verbs, which is why this
117
+ engine's shields are scanners.
118
+
119
+ **The two costs bounded by the source are gone** — 0.8.0's notes recorded them as left alone.
120
+ `validate()` formatted a capped circular-reference message by walking the rest of the cycle again for each
121
+ of its names: one cycle of 8 000 names took 6 s, now 68 ms, because the route lengths are measured once for
122
+ all names. `render()` ordered `#def`s by rescanning every pending name each round and handed every roll a
123
+ fresh copy of the map of rolled values: a 1 600-definition chain took 1.6 s and 6 400 independent
124
+ definitions 4.1 s, now 6 ms and 24 ms. The order is counted round by round in source order, and every roll
125
+ reads one map that grows by each rolled value, dropping the truthiness a conditional cached for it after
126
+ each write. Output is byte-identical over 3 000 generated definition-graph documents — chains, cycles with
127
+ tails long enough for a capped message, shadowed globals, outranked definitions, conditionals on definition
128
+ names — through `validate`, `analyze` and five renders, with three control mutations caught first: a round
129
+ left unsorted, the truthiness cache kept across a write, a route one name longer.
130
+
6
131
  ## 0.8.0 — 2026-09-13
7
132
 
8
133
  Two defects found by the ports while they mirrored 0.7.0, both places where this engine read a
package/dist/index.cjs CHANGED
@@ -1100,11 +1100,12 @@ function checkVariableReferences(text, idx, known, out) {
1100
1100
  refsOf.set(name, refs);
1101
1101
  }
1102
1102
  const { reaches: reachesCycle, via } = namesThatReachACycle(defs, refsOf);
1103
+ const lengths = routeLengths(via);
1103
1104
  for (const name of defs.keys()) {
1104
1105
  if (!reachesCycle.has(name)) continue;
1105
1106
  out.push(err(
1106
1107
  "variable.circular-reference",
1107
- `Circular variable reference: ${cyclePath(name, via)}.`,
1108
+ `Circular variable reference: ${cyclePath(name, via, lengths)}.`,
1108
1109
  { ...defPos.get(name) ?? somewhere, data: { name } }
1109
1110
  ));
1110
1111
  }
@@ -1164,7 +1165,7 @@ function namesThatReachACycle(defs, refsOf) {
1164
1165
  return { reaches, via };
1165
1166
  }
1166
1167
  var CYCLE_PATH_LIMIT = 8;
1167
- function cyclePath(name, via) {
1168
+ function cyclePath(name, via, lengths) {
1168
1169
  const seen = /* @__PURE__ */ new Set([name]);
1169
1170
  const shown = [name];
1170
1171
  let current = name;
@@ -1176,16 +1177,7 @@ function cyclePath(name, via) {
1176
1177
  return shown.join(" \u2192 ");
1177
1178
  }
1178
1179
  if (shown.length >= CYCLE_PATH_LIMIT) {
1179
- let more = 0;
1180
- let walk2 = next;
1181
- while (!seen.has(walk2)) {
1182
- seen.add(walk2);
1183
- more += 1;
1184
- const step = via.get(walk2);
1185
- if (step === void 0) break;
1186
- walk2 = step;
1187
- }
1188
- return `${shown.join(" \u2192 ")} \u2192 \u2026 (${more} more)`;
1180
+ return `${shown.join(" \u2192 ")} \u2192 \u2026 (${lengths.get(name) - shown.length} more)`;
1189
1181
  }
1190
1182
  seen.add(next);
1191
1183
  shown.push(next);
@@ -1193,6 +1185,33 @@ function cyclePath(name, via) {
1193
1185
  }
1194
1186
  return shown.join(" \u2192 ");
1195
1187
  }
1188
+ function routeLengths(via) {
1189
+ const lengths = /* @__PURE__ */ new Map();
1190
+ for (const start of via.keys()) {
1191
+ if (lengths.has(start)) continue;
1192
+ const walk2 = [];
1193
+ const onWalk = /* @__PURE__ */ new Map();
1194
+ let node = start;
1195
+ while (node !== void 0 && !lengths.has(node) && !onWalk.has(node)) {
1196
+ onWalk.set(node, walk2.length);
1197
+ walk2.push(node);
1198
+ node = via.get(node);
1199
+ }
1200
+ let tailEnd = walk2.length;
1201
+ let length = node === void 0 ? 0 : lengths.get(node) ?? 0;
1202
+ if (node !== void 0 && onWalk.has(node)) {
1203
+ const from = onWalk.get(node);
1204
+ length = walk2.length - from;
1205
+ for (let k = from; k < walk2.length; k += 1) lengths.set(walk2[k], length);
1206
+ tailEnd = from;
1207
+ }
1208
+ for (let k = tailEnd - 1; k >= 0; k -= 1) {
1209
+ length += 1;
1210
+ lengths.set(walk2[k], length);
1211
+ }
1212
+ }
1213
+ return lengths;
1214
+ }
1196
1215
  function checkIncludeTargets(text, idx, known, out) {
1197
1216
  const set = new Set(known);
1198
1217
  INCLUDE_RE.lastIndex = 0;
@@ -1444,42 +1463,61 @@ function rollDefinitions(defDefs, vars, context, opts) {
1444
1463
  if (name in definitions && !outranked.has(name)) continue;
1445
1464
  aliases[name] = value;
1446
1465
  }
1466
+ const visible = nameMap(vars);
1447
1467
  for (const name of orderDefinitions(definitions, aliases)) {
1448
1468
  if (outranked.has(name)) continue;
1449
- const value = definitions[name] ?? "";
1450
- rolled[name] = renderNodes(parseSequence(value), { ...opts, vars: nameMap(vars, rolled) });
1469
+ const value = renderNodes(parseSequence(definitions[name] ?? ""), { ...opts, vars: visible });
1470
+ rolled[name] = value;
1471
+ visible[name] = value;
1472
+ truthinessByVars.delete(visible);
1451
1473
  }
1452
1474
  return rolled;
1453
1475
  }
1454
1476
  function orderDefinitions(defDefs, aliases) {
1455
1477
  const names = Object.keys(defDefs);
1456
- const blocked = /* @__PURE__ */ new Map();
1478
+ const position = new Map(names.map((name, i) => [name, i]));
1479
+ const unplaced = /* @__PURE__ */ new Map();
1480
+ const waitingOn = /* @__PURE__ */ new Map();
1457
1481
  for (const name of names) {
1458
- const reached = referencedNames(defDefs[name] ?? "", aliases);
1459
- blocked.set(name, new Set(names.filter((candidate) => reached.has(candidate))));
1482
+ let count = 0;
1483
+ for (const dep of referencedNames(defDefs[name] ?? "", aliases)) {
1484
+ if (dep === name || !position.has(dep)) continue;
1485
+ count += 1;
1486
+ const waiting = waitingOn.get(dep);
1487
+ if (waiting === void 0) waitingOn.set(dep, [name]);
1488
+ else waiting.push(name);
1489
+ }
1490
+ unplaced.set(name, count);
1460
1491
  }
1461
1492
  const ordered = [];
1462
- let pending = names;
1463
- while (pending.length > 0) {
1464
- const ready = pending.filter((name) => {
1465
- const deps = blocked.get(name);
1466
- return !deps || ![...deps].some((dep) => dep !== name && pending.includes(dep));
1467
- });
1468
- if (ready.length === 0) return [...ordered, ...pending];
1469
- ordered.push(...ready);
1470
- pending = pending.filter((name) => !ready.includes(name));
1493
+ let round = names.filter((name) => unplaced.get(name) === 0);
1494
+ while (round.length > 0) {
1495
+ const next = [];
1496
+ for (const name of round) {
1497
+ ordered.push(name);
1498
+ for (const waiting of waitingOn.get(name) ?? []) {
1499
+ const left = unplaced.get(waiting) - 1;
1500
+ unplaced.set(waiting, left);
1501
+ if (left === 0) next.push(waiting);
1502
+ }
1503
+ }
1504
+ round = next.sort((a, b) => position.get(a) - position.get(b));
1505
+ }
1506
+ if (ordered.length < names.length) {
1507
+ const placed = new Set(ordered);
1508
+ for (const name of names) if (!placed.has(name)) ordered.push(name);
1471
1509
  }
1472
1510
  return ordered;
1473
1511
  }
1474
1512
  function referencedNames(value, aliases) {
1475
1513
  const seen = /* @__PURE__ */ new Set();
1476
1514
  const queue = directReferences(value);
1477
- while (queue.length > 0) {
1478
- const name = queue.shift();
1515
+ for (let i = 0; i < queue.length; i += 1) {
1516
+ const name = queue[i];
1479
1517
  if (seen.has(name)) continue;
1480
1518
  seen.add(name);
1481
1519
  const alias = aliases[name];
1482
- if (alias !== void 0) queue.push(...directReferences(alias));
1520
+ if (alias !== void 0) for (const ref of directReferences(alias)) queue.push(ref);
1483
1521
  }
1484
1522
  return seen;
1485
1523
  }
@@ -1819,13 +1857,17 @@ var WS = UCP_SPACE;
1819
1857
  var S = `[${WS}]`;
1820
1858
  var AFTER_NON_WORD = `(?<![${UCP_WORD}])`;
1821
1859
  var WORD_BOUNDARY = `(?:(?<=[${UCP_WORD}])(?![${UCP_WORD}])|(?<![${UCP_WORD}])(?=[${UCP_WORD}]))`;
1822
- var LABEL = "(?:xn--)?[\\p{L}\\p{N}]+(?:-[\\p{L}\\p{N}]+)*";
1823
- var DOMAIN_PART = `(?:${LABEL}\\.)+(?:xn--[a-z0-9\\-]{2,59}|[\\p{L}][\\p{L}\\p{N}-]{1,62})`;
1860
+ var XN = "[xX][nN]--";
1861
+ var LABEL = `(?:${XN})?[\\p{L}\\p{N}]+(?:-[\\p{L}\\p{N}]+)*`;
1862
+ var TLD_LOWER = "\\p{Ll}\\p{Lm}\\p{Lo}";
1863
+ var TLD_UPPER = "\\p{Lu}\\p{Lt}\\p{Lm}\\p{Lo}";
1864
+ 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})`;
1865
+ var DOMAIN_PART = `(?:${LABEL}\\.)+${TLD}`;
1824
1866
  var URI_BODY = `[^\\x00${WS}<>"')\\]]`;
1825
1867
  var URI_RE = new RegExp(`(?:(?:https?|ftp):\\/\\/|(?:mailto|tel):)${URI_BODY}+`, "giu");
1826
1868
  var MAILTEL_PREFIX_RE = /^(?:mailto|tel):/iu;
1827
- var DOMAIN_AT_RE = new RegExp(`${DOMAIN_PART}${WORD_BOUNDARY}`, "iuy");
1828
- var DOMAIN_SCAN_RE = new RegExp(`${AFTER_NON_WORD}(?:(${DOMAIN_PART}${WORD_BOUNDARY})|(?:${LABEL}\\.)*${LABEL})`, "giu");
1869
+ var DOMAIN_AT_RE = new RegExp(`${DOMAIN_PART}${WORD_BOUNDARY}`, "uy");
1870
+ var DOMAIN_SCAN_RE = new RegExp(`${AFTER_NON_WORD}(?:(${DOMAIN_PART}${WORD_BOUNDARY})|(?:${LABEL}\\.)*${LABEL})`, "gu");
1829
1871
  var DECIMAL_RE = /\b\d+\.\d+\b/gu;
1830
1872
  var MULTI_ABBR_RE = new RegExp(`${AFTER_NON_WORD}(?:\\p{L}{1,2}\\.${S}*){2,}`, "gu");
1831
1873
  var SINGLE_ABBR_RE = new RegExp(`(?<![\\p{L}\\p{N}])(?:${SINGLE_ABBREVS.join("|")})\\.(?=${S}|$|<)`, "giu");
@@ -1869,9 +1911,11 @@ function trailingPunctuationStart(value) {
1869
1911
  }
1870
1912
  var SENTENCE_OPENERS = "\xBF\xA1";
1871
1913
  var LEAD = `(?:<[^>]+>|[${SENTENCE_OPENERS}]|${S})*`;
1914
+ var QUOTES = `"'\xAB\xBB\u2039\u203A\u201C\u201D\u2018\u2019`;
1915
+ var CLOSER = `[)\\]]|[${QUOTES}]+(?=${S}|$|<|\\/?>|[.,;:!?\u2026)\\]\u2014\u2013])`;
1872
1916
  var SPACE_BEFORE_PUNCT_RE = new RegExp(`(?<!${S})${S}+([,;:!?.])`, "gu");
1873
- var SPACE_AFTER_COMMA_RE = new RegExp(`([,;:])(?!\\p{Nd})(?!${S}|$|<)`, "gu");
1874
- var SPACE_AFTER_SENTENCE_RE = new RegExp(`(?<![.!?])([.!?]+)(?![.!?])(?!\\p{Nd})(?!${S}|$|<)`, "gu");
1917
+ var SPACE_AFTER_COMMA_RE = new RegExp(`([,;:])(?!\\p{Nd})(?!${S}|$|<|${CLOSER})`, "gu");
1918
+ var SPACE_AFTER_SENTENCE_RE = new RegExp(`(?<![.!?])([.!?]+)(?![.!?])(?!\\p{Nd})(?!${S}|$|<|${CLOSER})`, "gu");
1875
1919
  var SPACE_AFTER_OPENER_RE = new RegExp(`([${SENTENCE_OPENERS}])${S}+`, "gu");
1876
1920
  var CAP_FIRST_RE = new RegExp(`^(${LEAD})(\\p{Ll})`, "u");
1877
1921
  var up = (ch) => ch.toUpperCase();
@@ -1894,7 +1938,6 @@ function indexLeads(text) {
1894
1938
  return { leadEnd };
1895
1939
  }
1896
1940
  var LOWER_RE = /^\p{Ll}/u;
1897
- var LOWER_CASELESS_RE = /^\p{Ll}/iu;
1898
1941
  var BLOCK_TAG_NAME_RE = /<\/?(?:p|h[1-6]|li|blockquote|div|td|th)/iuy;
1899
1942
  var SENTENCE_END_SCAN_RE = new RegExp(`[.!?${String.fromCharCode(8230)}]`, "g");
1900
1943
  var LEAD_WALK = 32;
@@ -2023,7 +2066,7 @@ function postProcess(input) {
2023
2066
  text = text.replace(CAP_FIRST_RE, (_m, lead, ch) => lead + up(ch));
2024
2067
  const leadContext = { leads: null };
2025
2068
  text = capitalizeAfter(text, LOWER_RE, afterSentenceEnd, leadContext);
2026
- text = capitalizeAfter(text, LOWER_CASELESS_RE, afterBlockTag(), leadContext);
2069
+ text = capitalizeAfter(text, LOWER_RE, afterBlockTag(), leadContext);
2027
2070
  text = capitalizeAfter(text, LOWER_RE, afterLineBreak, leadContext);
2028
2071
  return restore(text, input, placeholders).trim();
2029
2072
  }