@spintax/core 0.8.0 → 0.9.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 +74 -0
- package/dist/index.cjs +76 -35
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +76 -35
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,80 @@
|
|
|
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.9.0 — 2026-09-17
|
|
7
|
+
|
|
8
|
+
### Changed, visibly
|
|
9
|
+
|
|
10
|
+
- **A TLD is a label in ONE case (#79).** The bare-domain and email shields took any `word.Word` for a
|
|
11
|
+
domain, so a sentence glued to the next one kept its missing space and its lower-case start:
|
|
12
|
+
`kept compact.Game categories`, `конец.Начало`. On a production host's stored content that shape was
|
|
13
|
+
a sentence start 369 times in 56 tenants and a domain never. Now a TLD is all lower case or all upper
|
|
14
|
+
case — letters without case (CJK, Arabic, Thai) fit either — so those sentences get their space and
|
|
15
|
+
their capital, while `example.com`, `ASP.NET`, `info@Example.COM`, `ПРИМЕР.РФ`, `例子.中国` and a punycode
|
|
16
|
+
TLD in any case stay whole. The accepted cost: a Title-case second half reads as a sentence too —
|
|
17
|
+
`Yandex.Money` renders `Yandex. Money`, and `info@example.Com` is no longer shielded as an email. Both
|
|
18
|
+
PHP engines take the same rule.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **A titlecase letter after a block tag stays as PHP leaves it.** The plugin writes that capitalizer
|
|
23
|
+
`/ui`, and PCRE2 does not fold a Unicode property, so its `\p{Ll}` stays lower case only. This engine
|
|
24
|
+
read `\p{Ll}` under JavaScript's `i`, which takes every cased letter, and `<p>Dživot</p>` became
|
|
25
|
+
`<p>DŽivot</p>`. Found by the post-process differential once its alphabet held a titlecase digraph; as
|
|
26
|
+
old as the capitalizer.
|
|
27
|
+
|
|
28
|
+
### Corpus
|
|
29
|
+
|
|
30
|
+
Nine new cases in `render-postprocess.json`, every expectation taken from both PHP engines with the rule
|
|
31
|
+
applied: `postprocess/title-case-tld-is-a-sentence` (`compact.Game`), `postprocess/mixed-case-tld-is-not-a-domain`
|
|
32
|
+
(`cOM` — one case means the whole label), `postprocess/title-case-email-tld-is-not-shielded` (the cost,
|
|
33
|
+
pinned so nobody files it), the negatives `postprocess/uppercase-tld-is-a-domain`,
|
|
34
|
+
`postprocess/uppercase-email-domain-is-shielded`, `postprocess/caseless-script-tld-is-a-domain`,
|
|
35
|
+
`postprocess/uppercase-punycode-tld-is-a-domain` and `postprocess/punycode-tld-folds-long-s-and-kelvin`, and
|
|
36
|
+
`postprocess/titlecase-letter-after-block-tag-is-kept`. `postprocess/glued-cyrillic-sentence-is-a-domain`
|
|
37
|
+
becomes `postprocess/glued-cyrillic-sentence-is-spaced`: it pinned the reading before #79, and its note said
|
|
38
|
+
deciding #79 would move it.
|
|
39
|
+
|
|
40
|
+
### Notes
|
|
41
|
+
|
|
42
|
+
**One JavaScript trap behind both.** Under `i`, a JavaScript `\p{Ll}` matches capitals and a `\p{Lu}`
|
|
43
|
+
matches lower case; PCRE2 leaves a property alone under `/i`. PHP can also switch `i` off for one
|
|
44
|
+
alternative with `(?-i:…)`, which JavaScript has only from ES2025 — Node 24, not the Node 18–22 this engine
|
|
45
|
+
still supports. So the domain
|
|
46
|
+
patterns carry no `i` and spell out the one part that is caseless, the punycode prefix and class — U+017F
|
|
47
|
+
and U+212A included, as PCRE2's caseless `[a-z]` takes them.
|
|
48
|
+
|
|
49
|
+
**Verified.** 24 000 generated post-process inputs — the earlier alphabet with caseless scripts, mixed-case
|
|
50
|
+
TLDs and a titlecase digraph added, plus dotted chains whose TLD candidate is lower, upper, title, mixed or
|
|
51
|
+
caseless, in prose and in emails — through both PHP engines with the rule applied: they agree with each other
|
|
52
|
+
everywhere, this engine differs on none but the recorded final `trim`, and 0.8.0 on 4 552. Control mutations:
|
|
53
|
+
`i` restored on either shield, the caseless letters dropped from one branch or both, the any-case TLD restored,
|
|
54
|
+
U+017F and U+212A dropped from the punycode class — each turns the suite red. The last one first looked
|
|
55
|
+
output-equivalent here, because a lower-case `xn--` prefix always has a one-case letter reading from the same
|
|
56
|
+
start; a mixed-case prefix has none (`site.xN--ſA`, found by the Codex gate), and
|
|
57
|
+
`postprocess/punycode-tld-folds-long-s-and-kelvin` pins it. Post-process cost is unchanged — 1 000 prose calls
|
|
58
|
+
18 ms against 19, HTML blocks 27 against 26, a 756 KB shield-heavy text 147 ms against 154.
|
|
59
|
+
|
|
60
|
+
**The PHP engines skip what they cannot shield.** Rejecting `Game` made `a.a.…a.Game` — one domain from its
|
|
61
|
+
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
|
|
62
|
+
0.7 (found by the Codex gate). Both PHP shields now take a run or a chain they cannot shield and
|
|
63
|
+
`(*SKIP)(*FAIL)` past it — the skips this engine's scanners already prove output-neutral — which also
|
|
64
|
+
removes the older retry on a chain whose last label is too short to be a TLD: 2 000 labels, 39 ms before,
|
|
65
|
+
0.1 ms now. Ordinary text costs 2–4 % more there. JavaScript has no backtracking verbs, which is why this
|
|
66
|
+
engine's shields are scanners.
|
|
67
|
+
|
|
68
|
+
**The two costs bounded by the source are gone** — 0.8.0's notes recorded them as left alone.
|
|
69
|
+
`validate()` formatted a capped circular-reference message by walking the rest of the cycle again for each
|
|
70
|
+
of its names: one cycle of 8 000 names took 6 s, now 68 ms, because the route lengths are measured once for
|
|
71
|
+
all names. `render()` ordered `#def`s by rescanning every pending name each round and handed every roll a
|
|
72
|
+
fresh copy of the map of rolled values: a 1 600-definition chain took 1.6 s and 6 400 independent
|
|
73
|
+
definitions 4.1 s, now 6 ms and 24 ms. The order is counted round by round in source order, and every roll
|
|
74
|
+
reads one map that grows by each rolled value, dropping the truthiness a conditional cached for it after
|
|
75
|
+
each write. Output is byte-identical over 3 000 generated definition-graph documents — chains, cycles with
|
|
76
|
+
tails long enough for a capped message, shadowed globals, outranked definitions, conditionals on definition
|
|
77
|
+
names — through `validate`, `analyze` and five renders, with three control mutations caught first: a round
|
|
78
|
+
left unsorted, the truthiness cache kept across a write, a route one name longer.
|
|
79
|
+
|
|
6
80
|
## 0.8.0 — 2026-09-13
|
|
7
81
|
|
|
8
82
|
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
|
-
|
|
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] =
|
|
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
|
|
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
|
-
|
|
1459
|
-
|
|
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
|
|
1463
|
-
while (
|
|
1464
|
-
const
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
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
|
-
|
|
1478
|
-
const name = queue
|
|
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)
|
|
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
|
|
1823
|
-
var
|
|
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}`, "
|
|
1828
|
-
var DOMAIN_SCAN_RE = new RegExp(`${AFTER_NON_WORD}(?:(${DOMAIN_PART}${WORD_BOUNDARY})|(?:${LABEL}\\.)*${LABEL})`, "
|
|
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");
|
|
@@ -1894,7 +1936,6 @@ function indexLeads(text) {
|
|
|
1894
1936
|
return { leadEnd };
|
|
1895
1937
|
}
|
|
1896
1938
|
var LOWER_RE = /^\p{Ll}/u;
|
|
1897
|
-
var LOWER_CASELESS_RE = /^\p{Ll}/iu;
|
|
1898
1939
|
var BLOCK_TAG_NAME_RE = /<\/?(?:p|h[1-6]|li|blockquote|div|td|th)/iuy;
|
|
1899
1940
|
var SENTENCE_END_SCAN_RE = new RegExp(`[.!?${String.fromCharCode(8230)}]`, "g");
|
|
1900
1941
|
var LEAD_WALK = 32;
|
|
@@ -2023,7 +2064,7 @@ function postProcess(input) {
|
|
|
2023
2064
|
text = text.replace(CAP_FIRST_RE, (_m, lead, ch) => lead + up(ch));
|
|
2024
2065
|
const leadContext = { leads: null };
|
|
2025
2066
|
text = capitalizeAfter(text, LOWER_RE, afterSentenceEnd, leadContext);
|
|
2026
|
-
text = capitalizeAfter(text,
|
|
2067
|
+
text = capitalizeAfter(text, LOWER_RE, afterBlockTag(), leadContext);
|
|
2027
2068
|
text = capitalizeAfter(text, LOWER_RE, afterLineBreak, leadContext);
|
|
2028
2069
|
return restore(text, input, placeholders).trim();
|
|
2029
2070
|
}
|