@spintax/core 0.3.1 → 0.3.3

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,116 @@
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.3 — 2026-08-07
7
+
8
+ `validate()` scales. No output changes anywhere: a 464-document differential over every
9
+ definition-graph, diagnostic-position and fuzz shape is byte-identical before and after, and
10
+ three deliberate mutations (drop the prune, shift a column, freeze the line counter) each
11
+ turn it red — the harness can see what it guards.
12
+
13
+ ### Fixed
14
+
15
+ - **`validate()` is no longer super-linear on definition graphs — and no longer hangs.** Four
16
+ independent costs, each measured before and after (Node 22, same machine):
17
+
18
+ | shape | 0.3.2 | 0.3.3 |
19
+ |---|---|---|
20
+ | chain of 1600 `#set`s | 13.1 s | 18 ms |
21
+ | converging diamond, 20 levels (~1 KB) | 4.8 s | <1 ms |
22
+ | converging diamond, 26+ levels | **never returns** | <1 ms (2000 levels: 22 ms) |
23
+ | one cycle of 1600 | 55.6 s | 0.8 s |
24
+ | 6400 duplicate definitions | 9.3 s | 29 ms |
25
+ | 6400 undefined references | 2.9 s | 11 ms |
26
+
27
+ The circular-reference walk re-parsed every value's references at every visit, tested the
28
+ path with an array `includes`, copied the path at every step, and restarted from every
29
+ definition with no memory of silent subtrees — on a converging diamond that re-explored
30
+ shared subgraphs exponentially, so a one-kilobyte template could pin a CPU forever (the
31
+ validator is exposed by hosts; that is a denial-of-service shape, not just a slow one).
32
+ References are now parsed once, the path is a Set plus a shared push/pop array, the walk is
33
+ iterative (a chain as deep as the document cannot overflow the call stack), and one colour
34
+ walk computes up front which names can reach a cycle — a subtree that cannot reach one
35
+ cannot report, so skipping it is output-neutral by construction. Emission order, count and
36
+ messages are exactly the old walk's, duplicated edges and all.
37
+
38
+ The taint sweep behind `plural.count-macro` was a fixpoint that re-read every macro once per
39
+ newly tainted name; it is now a reverse-edge worklist computing the same closure. Diagnostic
40
+ positions were each computed by scanning from offset 0; a line-start index built once per
41
+ call now answers them by binary search. And `extractDirectives` counted the line of each
42
+ occurrence from the start of the text — quadratic on directive-heavy documents, and on the
43
+ **render** path too, since the renderer extracts directives the same way; a resuming counter
44
+ fixes both surfaces.
45
+
46
+ Two boundaries stay as they are, deliberately. A giant single cycle still costs seconds at
47
+ 6400 definitions because the output itself is quadratic — every diagnostic's message carries
48
+ the full cycle path, so ~300 MB of message text is the answer, not the overhead. And a
49
+ converging diamond that FEEDS a cycle still emits one diagnostic per path — exponentially
50
+ many — because that is the reference emission the corpus family just aligned on
51
+ (spintax-win, 2026-08-07); whether that emission should be bounded is a family question, not
52
+ a patch.
53
+
54
+ Post-process robustness and scaling: `postProcess()` no longer emits its own U+0000 sentinel, and
55
+ the placeholder restore is linear instead of quadratic. Output is unchanged on ordinary text; the
56
+ two behaviour changes are on shapes that previously produced invalid output (a raw U+0000).
57
+
58
+ ### Fixed
59
+
60
+ - **`postProcess()` no longer emits a raw U+0000** into the returned text — on input carrying
61
+ none. A URI body runs to the first delimiter, so the URL rule and the `mailto:`/`tel:` rule
62
+ overlap whenever one URI carries the other's scheme, and shielding them in two passes let the
63
+ second run into a placeholder the first had minted:
64
+
65
+ ```js
66
+ render('mailto:sales@example.com?body=see%20https://shop.example.com/cart');
67
+ // 'mailto:sales@example.com?body=see%20\x00URL_0\x00' ← before this release
68
+ ```
69
+
70
+ The swallowed key never restored, so the engine returned its own placeholder delimiter: illegal
71
+ in an XML document, replaced with U+FFFD by an HTML parser, rejected by PostgreSQL in
72
+ `text`/`varchar` — and a live key again as soon as an edit detaches it from the `mailto:` prefix
73
+ that was shielding it, at which point the next render substitutes an unrelated URL into the
74
+ contact link. It also silently disabled the linear restore below, whose guard is
75
+ `input.includes('\x00')`.
76
+
77
+ The two rules are now **one alternation**, so the leftmost match takes the whole token whichever
78
+ scheme it is. Reordering the passes was the other candidate and is not equivalent: it moves the
79
+ damage onto a URL whose path carries a `mailto:`, where the leading half then loses its trailing
80
+ dot to the punctuation pass. Both directions are fixtures now, the second one negative. U+0000 is
81
+ also excluded from the URI body class, for a caller-supplied one.
82
+
83
+ Mirrored into the WordPress plugin engine, and gated by three golden-corpus fixtures — the
84
+ cross-engine contract, so `spintax-php`, `spintax-py` and `spintax-win` inherit the gate.
85
+ ([#53](https://github.com/investblog/spintax-js/issues/53))
86
+
87
+ ### Performance
88
+
89
+ - **Post-process no longer goes quadratic on shield-heavy text.** The restore step replaced each
90
+ shielded placeholder across the whole text one key at a time — O(text × placeholders) — and
91
+ since URLs, `mailto:`/`tel:` URIs, emails, domains, decimals and abbreviations are all shielded,
92
+ the placeholder count grows with the input. The stage came to dominate the render:
93
+
94
+ | input | `postProcess: false` | before | after |
95
+ |--------|----------------------|------------|---------|
96
+ | 47 KB | 0.007 s | 0.18 s | 0.014 s |
97
+ | 189 KB | 0.006 s | 3.06 s | 0.057 s |
98
+ | 756 KB | 0.081 s | **43.9 s** | 0.25 s |
99
+
100
+ A single left-to-right pass replaces the loop, behind a guard on the input. The two are not the
101
+ same function: the loop is a repeated *substring* substitution, so it rewrites every occurrence
102
+ of a key rather than the one the shield placed. Most of the disagreement needs a literal `\x00`
103
+ from the caller, and the guard sends that input to the original loop.
104
+
105
+ **One shape survives the guard**, and on it the behaviour changed deliberately: two adjacent
106
+ placeholders can sandwich caller text that spells a key, so one token's closing delimiter, that
107
+ text, and the next token's opening delimiter form a third occurrence of a real key. Rendering
108
+ `https://a.io e.g. URL_0mailto:x@y.io` — no `\x00` anywhere in it — the loop substituted the
109
+ forgery, destroyed two real tokens and returned raw `\x00` bytes; the single pass returns the
110
+ text intact. Measured over 456 976 probes, 12 `\x00`-free inputs distinguish the two restores and
111
+ the loop emits a raw `\x00` on all 12. A corpus fixture now pins the surviving answer, since the
112
+ engines disagreed on it. `npm run bench:postprocess` records the scaling.
113
+ ([#52](https://github.com/investblog/spintax-js/issues/52),
114
+ [#54](https://github.com/investblog/spintax-js/issues/54))
115
+
6
116
  ## 0.3.1 — 2026-07-21
7
117
 
8
118
  Author markup is sanitised in one place, so a handle renders exactly like its source.
package/dist/index.cjs CHANGED
@@ -61,6 +61,15 @@ function extractDirectives(text) {
61
61
  const setDefs = {};
62
62
  const defDefs = {};
63
63
  const occurrences = [];
64
+ let cursorOffset = 0;
65
+ let cursorLine = 1;
66
+ const lineAt = (offset) => {
67
+ for (let i = cursorOffset; i < offset; i += 1) {
68
+ if (text.charCodeAt(i) === 10) cursorLine += 1;
69
+ }
70
+ cursorOffset = offset;
71
+ return cursorLine;
72
+ };
64
73
  DIRECTIVE_RE.lastIndex = 0;
65
74
  const stripped = text.replace(
66
75
  DIRECTIVE_RE,
@@ -70,7 +79,7 @@ function extractDirectives(text) {
70
79
  kind,
71
80
  name,
72
81
  value,
73
- line: countLines(text, offset)
82
+ line: lineAt(offset)
74
83
  });
75
84
  if (kind === "def") defDefs[name] = value;
76
85
  else setDefs[name] = value;
@@ -79,11 +88,6 @@ function extractDirectives(text) {
79
88
  );
80
89
  return { body: stripped.replace(/\n{3,}/gu, "\n\n"), setDefs, defDefs, occurrences };
81
90
  }
82
- function countLines(text, offset) {
83
- let line = 1;
84
- for (let i = 0; i < offset; i += 1) if (text[i] === "\n") line += 1;
85
- return line;
86
- }
87
91
  function stripComments(text) {
88
92
  return text.replace(/\/#[\s\S]*?#\//g, "");
89
93
  }
@@ -420,13 +424,14 @@ var INCLUDE_RE = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
420
424
  function validateTemplate(src, opts = {}) {
421
425
  const diagnostics = [];
422
426
  const text = stripComments(src);
427
+ const idx = buildLineIndex(text);
423
428
  checkBrackets(text, diagnostics);
424
429
  checkDirectives(text, diagnostics);
425
- checkPermutationConfigs(text, diagnostics);
426
- checkPlurals(text, opts.locale, diagnostics);
427
- checkVariableReferences(text, opts.knownVariables, diagnostics);
430
+ checkPermutationConfigs(text, idx, diagnostics);
431
+ checkPlurals(text, idx, opts.locale, diagnostics);
432
+ checkVariableReferences(text, idx, opts.knownVariables, diagnostics);
428
433
  if (opts.knownIncludes && opts.knownIncludes.length > 0) {
429
- checkIncludeTargets(text, opts.knownIncludes, diagnostics);
434
+ checkIncludeTargets(text, idx, opts.knownIncludes, diagnostics);
430
435
  }
431
436
  return diagnostics;
432
437
  }
@@ -436,21 +441,27 @@ function err(code, message, pos) {
436
441
  function warn(code, message, pos) {
437
442
  return { severity: "warning", code, message, ...pos };
438
443
  }
439
- function offsetToLineCol(text, offset) {
440
- let line = 1;
441
- let lineStart = 0;
442
- const end = Math.min(Math.max(offset, 0), text.length);
443
- for (let i = 0; i < end; i += 1) {
444
- if (text.charAt(i) === "\n") {
445
- line += 1;
446
- lineStart = i + 1;
447
- }
444
+ function buildLineIndex(text) {
445
+ const starts = [0];
446
+ for (let i = 0; i < text.length; i += 1) {
447
+ if (text.charCodeAt(i) === 10) starts.push(i + 1);
448
448
  }
449
- return { line, column: end - lineStart + 1 };
449
+ return { starts, length: text.length };
450
+ }
451
+ function offsetToLineCol(idx, offset) {
452
+ const end = Math.min(Math.max(offset, 0), idx.length);
453
+ let lo = 0;
454
+ let hi = idx.starts.length - 1;
455
+ while (lo < hi) {
456
+ const mid = lo + hi + 1 >> 1;
457
+ if ((idx.starts[mid] ?? 0) <= end) lo = mid;
458
+ else hi = mid - 1;
459
+ }
460
+ return { line: lo + 1, column: end - (idx.starts[lo] ?? 0) + 1 };
450
461
  }
451
- function span(text, offset, length) {
452
- const s = offsetToLineCol(text, offset);
453
- const e = offsetToLineCol(text, offset + length);
462
+ function span(idx, offset, length) {
463
+ const s = offsetToLineCol(idx, offset);
464
+ const e = offsetToLineCol(idx, offset + length);
454
465
  return { line: s.line, column: s.column, endLine: e.line, endColumn: e.column };
455
466
  }
456
467
  function checkBrackets(text, out) {
@@ -518,7 +529,7 @@ function checkDirectives(text, out) {
518
529
  }
519
530
  }
520
531
  }
521
- function checkPermutationConfigs(text, out) {
532
+ function checkPermutationConfigs(text, idx, out) {
522
533
  for (const m of text.matchAll(/\[<([^>]*?)>/gu)) {
523
534
  const configStr = m[1] ?? "";
524
535
  if (!/\w+\s*=/.test(configStr)) continue;
@@ -529,7 +540,7 @@ function checkPermutationConfigs(text, out) {
529
540
  out.push(err(
530
541
  "permutation.unknown-key",
531
542
  `Unknown permutation config key: '${km[1]}'.`,
532
- { ...span(text, configBase + (km.index ?? 0), (km[1] ?? "").length), data: { key: km[1] } }
543
+ { ...span(idx, configBase + (km.index ?? 0), (km[1] ?? "").length), data: { key: km[1] } }
533
544
  ));
534
545
  }
535
546
  }
@@ -538,7 +549,7 @@ function checkPermutationConfigs(text, out) {
538
549
  out.push(err(
539
550
  "permutation.minsize-not-integer",
540
551
  `minsize must be a positive integer, got '${min[1]}'.`,
541
- { ...span(text, configBase + min.index, min[0].length), data: { value: min[1] } }
552
+ { ...span(idx, configBase + min.index, min[0].length), data: { value: min[1] } }
542
553
  ));
543
554
  }
544
555
  const max = /maxsize\s*=\s*([^;>\s]+)/i.exec(configStr);
@@ -546,17 +557,17 @@ function checkPermutationConfigs(text, out) {
546
557
  out.push(err(
547
558
  "permutation.maxsize-not-integer",
548
559
  `maxsize must be a positive integer, got '${max[1]}'.`,
549
- { ...span(text, configBase + max.index, max[0].length), data: { value: max[1] } }
560
+ { ...span(idx, configBase + max.index, max[0].length), data: { value: max[1] } }
550
561
  ));
551
562
  }
552
563
  }
553
564
  }
554
- function checkPlurals(text, locale, out) {
565
+ function checkPlurals(text, idx, locale, out) {
555
566
  const base = locale && locale !== "" ? normalizeBaseLang(locale) : "";
556
567
  const arity = base !== "" ? pluralArity(base) : 0;
557
568
  const tainted = macroTaintedNames(text);
558
569
  for (const block of findPluralBlocks(text)) {
559
- const at = span(text, block.start, block.end - block.start);
570
+ const at = span(idx, block.start, block.end - block.start);
560
571
  for (const m of block.countSlot.matchAll(/%(\w+)%/gu)) {
561
572
  const name = (m[1] ?? "").toLowerCase();
562
573
  if (!tainted.has(name)) continue;
@@ -586,25 +597,34 @@ var UNRESOLVED_AT_PLURAL_TIME = /\[|\{(?!\?)/u;
586
597
  function macroTaintedNames(text) {
587
598
  const macros = extractDirectives(text).setDefs;
588
599
  const tainted = /* @__PURE__ */ new Set();
600
+ const queue = [];
589
601
  for (const [name, value] of Object.entries(macros)) {
590
- if (UNRESOLVED_AT_PLURAL_TIME.test(value)) tainted.add(name);
602
+ if (UNRESOLVED_AT_PLURAL_TIME.test(value)) {
603
+ tainted.add(name);
604
+ queue.push(name);
605
+ }
591
606
  }
592
- let grew = true;
593
- while (grew) {
594
- grew = false;
595
- for (const [name, value] of Object.entries(macros)) {
596
- if (tainted.has(name)) continue;
597
- for (const m of value.matchAll(/%(\w+)%/gu)) {
598
- if (!tainted.has((m[1] ?? "").toLowerCase())) continue;
607
+ const dependents = /* @__PURE__ */ new Map();
608
+ for (const [name, value] of Object.entries(macros)) {
609
+ for (const m of value.matchAll(/%(\w+)%/gu)) {
610
+ const ref = (m[1] ?? "").toLowerCase();
611
+ const list = dependents.get(ref);
612
+ if (list === void 0) dependents.set(ref, [name]);
613
+ else list.push(name);
614
+ }
615
+ }
616
+ while (queue.length > 0) {
617
+ const source = queue.pop();
618
+ for (const name of dependents.get(source) ?? []) {
619
+ if (!tainted.has(name)) {
599
620
  tainted.add(name);
600
- grew = true;
601
- break;
621
+ queue.push(name);
602
622
  }
603
623
  }
604
624
  }
605
625
  return tainted;
606
626
  }
607
- function checkVariableReferences(text, known, out) {
627
+ function checkVariableReferences(text, idx, known, out) {
608
628
  const knownSet = new Set((known ?? []).map((n) => n.toLowerCase()));
609
629
  const defs = /* @__PURE__ */ new Map();
610
630
  const defPos = /* @__PURE__ */ new Map();
@@ -612,7 +632,7 @@ function checkVariableReferences(text, known, out) {
612
632
  const name = (m[1] ?? "").toLowerCase();
613
633
  defs.set(name, m[2] ?? "");
614
634
  const nameOffset = (m.index ?? 0) + m[0].indexOf("%");
615
- defPos.set(name, span(text, nameOffset, name.length + 2));
635
+ defPos.set(name, span(idx, nameOffset, name.length + 2));
616
636
  }
617
637
  const somewhere = { line: 1, column: 1 };
618
638
  for (const [name, value] of defs) {
@@ -620,8 +640,15 @@ function checkVariableReferences(text, known, out) {
620
640
  out.push(err("variable.self-reference", `Variable '${name}' references itself.`, defPos.get(name) ?? somewhere));
621
641
  }
622
642
  }
643
+ const refsOf = /* @__PURE__ */ new Map();
644
+ for (const [name, value] of defs) {
645
+ const refs = [];
646
+ for (const m of value.matchAll(/%(\w+)%/gu)) refs.push((m[1] ?? "").toLowerCase());
647
+ refsOf.set(name, refs);
648
+ }
649
+ const reachesCycle = namesThatReachACycle(defs, refsOf);
623
650
  for (const name of defs.keys()) {
624
- detectCycle(name, defs, [name], defPos.get(name) ?? somewhere, out);
651
+ detectCycle(name, defs, refsOf, reachesCycle, defPos.get(name) ?? somewhere, out);
625
652
  }
626
653
  const body = text.replace(/^[ \t]*#(?:set|def)[ \t]+%\w+%[ \t]*=[ \t]*.*?$/gmu, (m) => m.replace(/[^\n]/g, " "));
627
654
  const seen = /* @__PURE__ */ new Set();
@@ -632,7 +659,7 @@ function checkVariableReferences(text, known, out) {
632
659
  out.push(warn(
633
660
  "variable.undefined",
634
661
  `Variable '${name}' is not defined \u2014 may be a runtime variable.`,
635
- { ...span(text, offset, length), data: { name } }
662
+ { ...span(idx, offset, length), data: { name } }
636
663
  ));
637
664
  };
638
665
  for (const m of body.matchAll(/%(\w+)%/gu)) undefinedAt(m[1] ?? "", m.index ?? 0, m[0].length);
@@ -640,19 +667,70 @@ function checkVariableReferences(text, known, out) {
640
667
  undefinedAt(m[1] ?? "", (m.index ?? 0) + m[0].indexOf(m[1] ?? ""), (m[1] ?? "").length);
641
668
  }
642
669
  }
643
- function detectCycle(current, defs, visited, rootPos, out) {
644
- const value = defs.get(current) ?? "";
645
- for (const m of value.matchAll(/%(\w+)%/gu)) {
646
- const ref = (m[1] ?? "").toLowerCase();
647
- if (ref === current) continue;
648
- if (visited.includes(ref)) {
649
- out.push(err("variable.circular-reference", `Circular variable reference: ${[...visited, ref].join(" \u2192 ")}.`, rootPos));
650
- return;
670
+ function namesThatReachACycle(defs, refsOf) {
671
+ const GREY = 1;
672
+ const BLACK = 2;
673
+ const color = /* @__PURE__ */ new Map();
674
+ const reaches = /* @__PURE__ */ new Set();
675
+ for (const root of defs.keys()) {
676
+ if (color.has(root)) continue;
677
+ const stack = [{ name: root, refs: refsOf.get(root) ?? [], i: 0 }];
678
+ color.set(root, GREY);
679
+ while (stack.length > 0) {
680
+ const top = stack[stack.length - 1];
681
+ if (top.i < top.refs.length) {
682
+ const ref = top.refs[top.i];
683
+ top.i += 1;
684
+ if (ref === top.name || !defs.has(ref)) continue;
685
+ const c = color.get(ref);
686
+ if (c === GREY) reaches.add(top.name);
687
+ else if (c === BLACK) {
688
+ if (reaches.has(ref)) reaches.add(top.name);
689
+ } else {
690
+ stack.push({ name: ref, refs: refsOf.get(ref) ?? [], i: 0 });
691
+ color.set(ref, GREY);
692
+ }
693
+ } else {
694
+ stack.pop();
695
+ color.set(top.name, BLACK);
696
+ if (stack.length > 0 && reaches.has(top.name)) reaches.add(stack[stack.length - 1].name);
697
+ }
698
+ }
699
+ }
700
+ return reaches;
701
+ }
702
+ function detectCycle(root, defs, refsOf, reachesCycle, rootPos, out) {
703
+ if (!reachesCycle.has(root)) return;
704
+ const path = [root];
705
+ const onPath = /* @__PURE__ */ new Set([root]);
706
+ const stack = [{ name: root, refs: refsOf.get(root) ?? [], i: 0 }];
707
+ const leave = (frame) => {
708
+ stack.pop();
709
+ onPath.delete(frame.name);
710
+ path.pop();
711
+ };
712
+ while (stack.length > 0) {
713
+ const top = stack[stack.length - 1];
714
+ if (top.i >= top.refs.length) {
715
+ leave(top);
716
+ continue;
717
+ }
718
+ const ref = top.refs[top.i];
719
+ top.i += 1;
720
+ if (ref === top.name) continue;
721
+ if (onPath.has(ref)) {
722
+ out.push(err("variable.circular-reference", `Circular variable reference: ${[...path, ref].join(" \u2192 ")}.`, rootPos));
723
+ leave(top);
724
+ continue;
725
+ }
726
+ if (defs.has(ref) && reachesCycle.has(ref)) {
727
+ stack.push({ name: ref, refs: refsOf.get(ref) ?? [], i: 0 });
728
+ onPath.add(ref);
729
+ path.push(ref);
651
730
  }
652
- if (defs.has(ref)) detectCycle(ref, defs, [...visited, ref], rootPos, out);
653
731
  }
654
732
  }
655
- function checkIncludeTargets(text, known, out) {
733
+ function checkIncludeTargets(text, idx, known, out) {
656
734
  const set = new Set(known);
657
735
  INCLUDE_RE.lastIndex = 0;
658
736
  for (const m of text.matchAll(INCLUDE_RE)) {
@@ -662,7 +740,7 @@ function checkIncludeTargets(text, known, out) {
662
740
  out.push(err(
663
741
  "include.unknown-target",
664
742
  `#include target '${ref}' does not match any known template.`,
665
- { ...span(text, refOffset, ref.length), data: { target: ref } }
743
+ { ...span(idx, refOffset, ref.length), data: { target: ref } }
666
744
  ));
667
745
  }
668
746
  }
@@ -1038,8 +1116,9 @@ var SINGLE_ABBREVS = [
1038
1116
  var WS = " \\t\\r\\n\\f\\x0B";
1039
1117
  var S = `[${WS}]`;
1040
1118
  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");
1119
+ var URI_BODY = `[^\\x00${WS}<>"')\\]]`;
1120
+ var URI_RE = new RegExp(`(?:(?:https?|ftp):\\/\\/|(?:mailto|tel):)${URI_BODY}+`, "giu");
1121
+ var MAILTEL_PREFIX_RE = /^(?:mailto|tel):/iu;
1043
1122
  var EMAIL_RE = new RegExp(`[a-z0-9._%+\\-]+@${DOMAIN_PART}\\b`, "giu");
1044
1123
  var DOMAIN_RE = new RegExp(`\\b${DOMAIN_PART}\\b`, "giu");
1045
1124
  var DECIMAL_RE = /\b\d+\.\d+\b/gu;
@@ -1060,6 +1139,18 @@ var CAP_AFTER_BLOCK_RE = new RegExp(
1060
1139
  );
1061
1140
  var CAP_AFTER_BREAK_RE = new RegExp(`(\\n${LEAD})(\\p{Ll})`, "gu");
1062
1141
  var up = (ch) => ch.toUpperCase();
1142
+ var SHIELD_PREFIXES = ["URL", "URI", "EMAIL", "DOM", "NUM", "ABBR"];
1143
+ var RESTORE_RE2 = new RegExp(`\\x00(?:${SHIELD_PREFIXES.join("|")})_\\d+\\x00`, "gu");
1144
+ function restore(text, input, placeholders) {
1145
+ if (!input.includes("\0")) {
1146
+ return text.replace(RESTORE_RE2, (key) => placeholders.get(key) ?? key);
1147
+ }
1148
+ let out = text;
1149
+ for (const [key, value] of placeholders) {
1150
+ out = out.split(key).join(value);
1151
+ }
1152
+ return out;
1153
+ }
1063
1154
  function postProcess(input) {
1064
1155
  const placeholders = /* @__PURE__ */ new Map();
1065
1156
  let counter = 0;
@@ -1079,8 +1170,10 @@ function postProcess(input) {
1079
1170
  return store(value, prefix);
1080
1171
  };
1081
1172
  let text = input;
1082
- text = text.replace(URL_RE, (m) => storeWithTrailingPunct(m, "URL"));
1083
- text = text.replace(MAILTEL_RE, (m) => storeWithTrailingPunct(m, "URI"));
1173
+ text = text.replace(
1174
+ URI_RE,
1175
+ (m) => storeWithTrailingPunct(m, MAILTEL_PREFIX_RE.test(m) ? "URI" : "URL")
1176
+ );
1084
1177
  text = text.replace(EMAIL_RE, (m) => store(m, "EMAIL"));
1085
1178
  text = text.replace(DOMAIN_RE, (m) => store(m, "DOM"));
1086
1179
  text = text.replace(DECIMAL_RE, (m) => store(m, "NUM"));
@@ -1095,10 +1188,7 @@ function postProcess(input) {
1095
1188
  text = text.replace(CAP_AFTER_SENTENCE_RE, (_m, p, gap, ch) => p + gap + up(ch));
1096
1189
  text = text.replace(CAP_AFTER_BLOCK_RE, (_m, tag, ch) => tag + up(ch));
1097
1190
  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();
1191
+ return restore(text, input, placeholders).trim();
1102
1192
  }
1103
1193
 
1104
1194
  // src/internal/pipeline.ts