@spintax/core 0.3.1 → 0.3.2

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,70 @@
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.3.2 — 2026-07-23
7
+
8
+ Post-process robustness and scaling: `postProcess()` no longer emits its own U+0000 sentinel, and
9
+ the placeholder restore is linear instead of quadratic. Output is unchanged on ordinary text; the
10
+ two behaviour changes are on shapes that previously produced invalid output (a raw U+0000).
11
+
12
+ ### Fixed
13
+
14
+ - **`postProcess()` no longer emits a raw U+0000** into the returned text — on input carrying
15
+ none. A URI body runs to the first delimiter, so the URL rule and the `mailto:`/`tel:` rule
16
+ overlap whenever one URI carries the other's scheme, and shielding them in two passes let the
17
+ second run into a placeholder the first had minted:
18
+
19
+ ```js
20
+ render('mailto:sales@example.com?body=see%20https://shop.example.com/cart');
21
+ // 'mailto:sales@example.com?body=see%20\x00URL_0\x00' ← before this release
22
+ ```
23
+
24
+ The swallowed key never restored, so the engine returned its own placeholder delimiter: illegal
25
+ in an XML document, replaced with U+FFFD by an HTML parser, rejected by PostgreSQL in
26
+ `text`/`varchar` — and a live key again as soon as an edit detaches it from the `mailto:` prefix
27
+ that was shielding it, at which point the next render substitutes an unrelated URL into the
28
+ contact link. It also silently disabled the linear restore below, whose guard is
29
+ `input.includes('\x00')`.
30
+
31
+ The two rules are now **one alternation**, so the leftmost match takes the whole token whichever
32
+ scheme it is. Reordering the passes was the other candidate and is not equivalent: it moves the
33
+ damage onto a URL whose path carries a `mailto:`, where the leading half then loses its trailing
34
+ dot to the punctuation pass. Both directions are fixtures now, the second one negative. U+0000 is
35
+ also excluded from the URI body class, for a caller-supplied one.
36
+
37
+ Mirrored into the WordPress plugin engine, and gated by three golden-corpus fixtures — the
38
+ cross-engine contract, so `spintax-php`, `spintax-py` and `spintax-win` inherit the gate.
39
+ ([#53](https://github.com/investblog/spintax-js/issues/53))
40
+
41
+ ### Performance
42
+
43
+ - **Post-process no longer goes quadratic on shield-heavy text.** The restore step replaced each
44
+ shielded placeholder across the whole text one key at a time — O(text × placeholders) — and
45
+ since URLs, `mailto:`/`tel:` URIs, emails, domains, decimals and abbreviations are all shielded,
46
+ the placeholder count grows with the input. The stage came to dominate the render:
47
+
48
+ | input | `postProcess: false` | before | after |
49
+ |--------|----------------------|------------|---------|
50
+ | 47 KB | 0.007 s | 0.18 s | 0.014 s |
51
+ | 189 KB | 0.006 s | 3.06 s | 0.057 s |
52
+ | 756 KB | 0.081 s | **43.9 s** | 0.25 s |
53
+
54
+ A single left-to-right pass replaces the loop, behind a guard on the input. The two are not the
55
+ same function: the loop is a repeated *substring* substitution, so it rewrites every occurrence
56
+ of a key rather than the one the shield placed. Most of the disagreement needs a literal `\x00`
57
+ from the caller, and the guard sends that input to the original loop.
58
+
59
+ **One shape survives the guard**, and on it the behaviour changed deliberately: two adjacent
60
+ placeholders can sandwich caller text that spells a key, so one token's closing delimiter, that
61
+ text, and the next token's opening delimiter form a third occurrence of a real key. Rendering
62
+ `https://a.io e.g. URL_0mailto:x@y.io` — no `\x00` anywhere in it — the loop substituted the
63
+ forgery, destroyed two real tokens and returned raw `\x00` bytes; the single pass returns the
64
+ text intact. Measured over 456 976 probes, 12 `\x00`-free inputs distinguish the two restores and
65
+ the loop emits a raw `\x00` on all 12. A corpus fixture now pins the surviving answer, since the
66
+ engines disagreed on it. `npm run bench:postprocess` records the scaling.
67
+ ([#52](https://github.com/investblog/spintax-js/issues/52),
68
+ [#54](https://github.com/investblog/spintax-js/issues/54))
69
+
6
70
  ## 0.3.1 — 2026-07-21
7
71
 
8
72
  Author markup is sanitised in one place, so a handle renders exactly like its source.
package/dist/index.cjs CHANGED
@@ -1038,8 +1038,9 @@ var SINGLE_ABBREVS = [
1038
1038
  var WS = " \\t\\r\\n\\f\\x0B";
1039
1039
  var S = `[${WS}]`;
1040
1040
  var DOMAIN_PART = "(?:(?:(?:xn--)?[\\p{L}\\p{N}]+(?:-[\\p{L}\\p{N}]+)*)\\.)+(?:xn--[a-z0-9\\-]{2,59}|[\\p{L}][\\p{L}\\p{N}-]{1,62})";
1041
- var URL_RE = new RegExp(`(?:https?|ftp):\\/\\/[^${WS}<>"')\\]]+`, "giu");
1042
- var MAILTEL_RE = new RegExp(`(?:mailto|tel):[^${WS}<>"')\\]]+`, "giu");
1041
+ var URI_BODY = `[^\\x00${WS}<>"')\\]]`;
1042
+ var URI_RE = new RegExp(`(?:(?:https?|ftp):\\/\\/|(?:mailto|tel):)${URI_BODY}+`, "giu");
1043
+ var MAILTEL_PREFIX_RE = /^(?:mailto|tel):/iu;
1043
1044
  var EMAIL_RE = new RegExp(`[a-z0-9._%+\\-]+@${DOMAIN_PART}\\b`, "giu");
1044
1045
  var DOMAIN_RE = new RegExp(`\\b${DOMAIN_PART}\\b`, "giu");
1045
1046
  var DECIMAL_RE = /\b\d+\.\d+\b/gu;
@@ -1060,6 +1061,18 @@ var CAP_AFTER_BLOCK_RE = new RegExp(
1060
1061
  );
1061
1062
  var CAP_AFTER_BREAK_RE = new RegExp(`(\\n${LEAD})(\\p{Ll})`, "gu");
1062
1063
  var up = (ch) => ch.toUpperCase();
1064
+ var SHIELD_PREFIXES = ["URL", "URI", "EMAIL", "DOM", "NUM", "ABBR"];
1065
+ var RESTORE_RE2 = new RegExp(`\\x00(?:${SHIELD_PREFIXES.join("|")})_\\d+\\x00`, "gu");
1066
+ function restore(text, input, placeholders) {
1067
+ if (!input.includes("\0")) {
1068
+ return text.replace(RESTORE_RE2, (key) => placeholders.get(key) ?? key);
1069
+ }
1070
+ let out = text;
1071
+ for (const [key, value] of placeholders) {
1072
+ out = out.split(key).join(value);
1073
+ }
1074
+ return out;
1075
+ }
1063
1076
  function postProcess(input) {
1064
1077
  const placeholders = /* @__PURE__ */ new Map();
1065
1078
  let counter = 0;
@@ -1079,8 +1092,10 @@ function postProcess(input) {
1079
1092
  return store(value, prefix);
1080
1093
  };
1081
1094
  let text = input;
1082
- text = text.replace(URL_RE, (m) => storeWithTrailingPunct(m, "URL"));
1083
- text = text.replace(MAILTEL_RE, (m) => storeWithTrailingPunct(m, "URI"));
1095
+ text = text.replace(
1096
+ URI_RE,
1097
+ (m) => storeWithTrailingPunct(m, MAILTEL_PREFIX_RE.test(m) ? "URI" : "URL")
1098
+ );
1084
1099
  text = text.replace(EMAIL_RE, (m) => store(m, "EMAIL"));
1085
1100
  text = text.replace(DOMAIN_RE, (m) => store(m, "DOM"));
1086
1101
  text = text.replace(DECIMAL_RE, (m) => store(m, "NUM"));
@@ -1095,10 +1110,7 @@ function postProcess(input) {
1095
1110
  text = text.replace(CAP_AFTER_SENTENCE_RE, (_m, p, gap, ch) => p + gap + up(ch));
1096
1111
  text = text.replace(CAP_AFTER_BLOCK_RE, (_m, tag, ch) => tag + up(ch));
1097
1112
  text = text.replace(CAP_AFTER_BREAK_RE, (_m, br, ch) => br + up(ch));
1098
- for (const [key, value] of placeholders) {
1099
- text = text.split(key).join(value);
1100
- }
1101
- return text.trim();
1113
+ return restore(text, input, placeholders).trim();
1102
1114
  }
1103
1115
 
1104
1116
  // src/internal/pipeline.ts