@spintax/core 0.3.0 → 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 +92 -0
- package/README.md +1 -1
- package/dist/index.cjs +49 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +49 -37
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,98 @@
|
|
|
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
|
+
|
|
70
|
+
## 0.3.1 — 2026-07-21
|
|
71
|
+
|
|
72
|
+
Author markup is sanitised in one place, so a handle renders exactly like its source.
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
|
|
76
|
+
- **`render(parse(src))` no longer diverges from `render(src)`** when the template contains an
|
|
77
|
+
author-typed engine sentinel (the reserved range U+E000–U+E005). The strip that keeps stray
|
|
78
|
+
sentinels out of a tree lived at the render entry points, so `parse()` and `analyze(str)` — two
|
|
79
|
+
of the three doors into the parser — skipped it, and the mandatory safety-restore then rewrote
|
|
80
|
+
the author's character into a structural glyph they never wrote:
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
const src = `a${String.fromCharCode(0xe000)}b`;
|
|
84
|
+
render(src, { postProcess: false }); // "ab"
|
|
85
|
+
render(parse(src), { postProcess: false }); // "a{b" ← before this release
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The strip now lives in `parseTemplate`, the single door from author source into an AST, so all
|
|
89
|
+
three entry points agree. `parseSequence` is deliberately **not** sanitised — it re-parses a
|
|
90
|
+
variable's *value*, where sentinels a host `neutralize()`d are legitimate and must survive to the
|
|
91
|
+
restore — and `ParsedAst.source` still keeps the original bytes so diagnostics point at what was
|
|
92
|
+
typed. ([#51](https://github.com/investblog/spintax-js/issues/51))
|
|
93
|
+
|
|
94
|
+
Templates that contain no reserved-range characters are unaffected: the strip is a no-op on them.
|
|
95
|
+
The Python port fixed the same defect the same way; the PHP engines never had it (no `parse()`
|
|
96
|
+
handle, no PUA sentinels).
|
|
97
|
+
|
|
6
98
|
## 0.3.0 — 2026-07-19
|
|
7
99
|
|
|
8
100
|
`#set` goes back to being a macro and a new `#def` carries roll-once. Breaking: it changes what
|
package/README.md
CHANGED
|
@@ -153,7 +153,7 @@ render('%bio%', { context: { bio: neutralize('Save {50|60}% today') }, postProce
|
|
|
153
153
|
|
|
154
154
|
With a `seed`, `render` is reproducible within this engine. Cross-engine RNG-sequence parity
|
|
155
155
|
with the PHP plugin is a **non-goal** — only the *deterministic* behavior (validation verdicts,
|
|
156
|
-
plural buckets, conditional truthiness, `#set`
|
|
156
|
+
plural buckets, conditional truthiness, `#set`/`#def` semantics, post-process output) is parity-gated.
|
|
157
157
|
|
|
158
158
|
## Links
|
|
159
159
|
|
package/dist/index.cjs
CHANGED
|
@@ -23,13 +23,38 @@ function isParsedAst(value) {
|
|
|
23
23
|
return typeof value === "object" && value !== null && value.astVersion === AST_VERSION && typeof value.source === "string" && Array.isArray(value.nodes);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// src/internal/neutralize.ts
|
|
27
|
+
var STRUCTURAL = ["{", "}", "[", "]", "%", "#"];
|
|
28
|
+
var SENTINEL_BASE = 57344;
|
|
29
|
+
var SHIELD = /* @__PURE__ */ new Map();
|
|
30
|
+
var RESTORE = /* @__PURE__ */ new Map();
|
|
31
|
+
STRUCTURAL.forEach((ch, i) => {
|
|
32
|
+
const sentinel = String.fromCharCode(SENTINEL_BASE + i);
|
|
33
|
+
SHIELD.set(ch, sentinel);
|
|
34
|
+
RESTORE.set(sentinel, ch);
|
|
35
|
+
});
|
|
36
|
+
var SHIELD_RE = /[{}[\]%#]/gu;
|
|
37
|
+
var RESTORE_RE = new RegExp(
|
|
38
|
+
`[${String.fromCharCode(SENTINEL_BASE)}-${String.fromCharCode(SENTINEL_BASE + STRUCTURAL.length - 1)}]`,
|
|
39
|
+
"gu"
|
|
40
|
+
);
|
|
41
|
+
function neutralize(value) {
|
|
42
|
+
return value.replace(SHIELD_RE, (ch) => SHIELD.get(ch) ?? ch);
|
|
43
|
+
}
|
|
44
|
+
function safetyRestore(text) {
|
|
45
|
+
return text.replace(RESTORE_RE, (ch) => RESTORE.get(ch) ?? ch);
|
|
46
|
+
}
|
|
47
|
+
function stripSentinels(text) {
|
|
48
|
+
return text.replace(RESTORE_RE, "");
|
|
49
|
+
}
|
|
50
|
+
|
|
26
51
|
// src/internal/parser.ts
|
|
27
52
|
var VARIABLE_RE = /^%(\w+)%/;
|
|
28
53
|
var DIRECTIVE_RE = /^[ \t]*#(set|def)[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)[ \t]*\r?$/gmu;
|
|
29
54
|
var CONDITIONAL_NAME_RE = /^[A-Za-z_]\w*/;
|
|
30
55
|
var PLURAL_PREFIX = "plural ";
|
|
31
56
|
function parseTemplate(src) {
|
|
32
|
-
const { body, setDefs, defDefs } = extractDirectives(stripComments(src));
|
|
57
|
+
const { body, setDefs, defDefs } = extractDirectives(stripComments(stripSentinels(src)));
|
|
33
58
|
return { astVersion: AST_VERSION, source: src, setDefs, defDefs, nodes: parseSequence(body) };
|
|
34
59
|
}
|
|
35
60
|
function extractDirectives(text) {
|
|
@@ -702,31 +727,6 @@ var NotImplementedError = class extends SpintaxError {
|
|
|
702
727
|
}
|
|
703
728
|
};
|
|
704
729
|
|
|
705
|
-
// src/internal/neutralize.ts
|
|
706
|
-
var STRUCTURAL = ["{", "}", "[", "]", "%", "#"];
|
|
707
|
-
var SENTINEL_BASE = 57344;
|
|
708
|
-
var SHIELD = /* @__PURE__ */ new Map();
|
|
709
|
-
var RESTORE = /* @__PURE__ */ new Map();
|
|
710
|
-
STRUCTURAL.forEach((ch, i) => {
|
|
711
|
-
const sentinel = String.fromCharCode(SENTINEL_BASE + i);
|
|
712
|
-
SHIELD.set(ch, sentinel);
|
|
713
|
-
RESTORE.set(sentinel, ch);
|
|
714
|
-
});
|
|
715
|
-
var SHIELD_RE = /[{}[\]%#]/gu;
|
|
716
|
-
var RESTORE_RE = new RegExp(
|
|
717
|
-
`[${String.fromCharCode(SENTINEL_BASE)}-${String.fromCharCode(SENTINEL_BASE + STRUCTURAL.length - 1)}]`,
|
|
718
|
-
"gu"
|
|
719
|
-
);
|
|
720
|
-
function neutralize(value) {
|
|
721
|
-
return value.replace(SHIELD_RE, (ch) => SHIELD.get(ch) ?? ch);
|
|
722
|
-
}
|
|
723
|
-
function safetyRestore(text) {
|
|
724
|
-
return text.replace(RESTORE_RE, (ch) => RESTORE.get(ch) ?? ch);
|
|
725
|
-
}
|
|
726
|
-
function stripSentinels(text) {
|
|
727
|
-
return text.replace(RESTORE_RE, "");
|
|
728
|
-
}
|
|
729
|
-
|
|
730
730
|
// src/internal/render.ts
|
|
731
731
|
var MAX_VARIABLE_DEPTH = 50;
|
|
732
732
|
var INCLUDE_LINE_RE = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
|
|
@@ -753,7 +753,7 @@ function resolveIncludes(text, ctx) {
|
|
|
753
753
|
throw new IncludeResolverError(`includeResolver threw for "${ref}"`, { cause });
|
|
754
754
|
}
|
|
755
755
|
if (included === null) return "";
|
|
756
|
-
return renderAst(parseTemplate(
|
|
756
|
+
return renderAst(parseTemplate(included), {
|
|
757
757
|
...ctx,
|
|
758
758
|
runtimeContext: ctx.runtimeContext,
|
|
759
759
|
// child inherits runtime, not parent #set
|
|
@@ -879,7 +879,7 @@ function renderPlural(node, opts) {
|
|
|
879
879
|
if (/[{}[\]]/u.test(formsRaw)) {
|
|
880
880
|
report({
|
|
881
881
|
code: "plural.nested-brackets",
|
|
882
|
-
message: "Plural form slot contains nested spintax brackets; extract via #set
|
|
882
|
+
message: "Plural form slot contains nested spintax brackets; extract via #def first \u2014 a #set is substituted verbatim and would put the brackets straight back.",
|
|
883
883
|
construct: rawConstruct(countRaw, formsRaw),
|
|
884
884
|
locale: base
|
|
885
885
|
});
|
|
@@ -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
|
|
1042
|
-
var
|
|
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(
|
|
1083
|
-
|
|
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
|
-
|
|
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
|
|
@@ -1113,7 +1125,7 @@ function renderWith(input, rng, opts = {}) {
|
|
|
1113
1125
|
includeStack: [],
|
|
1114
1126
|
onPluralError: opts.onPluralError
|
|
1115
1127
|
};
|
|
1116
|
-
const ast = resolveAst(
|
|
1128
|
+
const ast = resolveAst(input);
|
|
1117
1129
|
let out = renderAst(ast, ctx);
|
|
1118
1130
|
if (opts.postProcess !== false) out = postProcess(out);
|
|
1119
1131
|
return safetyRestore(out);
|