@spintax/core 0.3.0 → 0.3.1

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,34 @@
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.1 — 2026-07-21
7
+
8
+ Author markup is sanitised in one place, so a handle renders exactly like its source.
9
+
10
+ ### Fixed
11
+
12
+ - **`render(parse(src))` no longer diverges from `render(src)`** when the template contains an
13
+ author-typed engine sentinel (the reserved range U+E000–U+E005). The strip that keeps stray
14
+ sentinels out of a tree lived at the render entry points, so `parse()` and `analyze(str)` — two
15
+ of the three doors into the parser — skipped it, and the mandatory safety-restore then rewrote
16
+ the author's character into a structural glyph they never wrote:
17
+
18
+ ```js
19
+ const src = `a${String.fromCharCode(0xe000)}b`;
20
+ render(src, { postProcess: false }); // "ab"
21
+ render(parse(src), { postProcess: false }); // "a{b" ← before this release
22
+ ```
23
+
24
+ The strip now lives in `parseTemplate`, the single door from author source into an AST, so all
25
+ three entry points agree. `parseSequence` is deliberately **not** sanitised — it re-parses a
26
+ variable's *value*, where sentinels a host `neutralize()`d are legitimate and must survive to the
27
+ restore — and `ParsedAst.source` still keeps the original bytes so diagnostics point at what was
28
+ typed. ([#51](https://github.com/investblog/spintax-js/issues/51))
29
+
30
+ Templates that contain no reserved-range characters are unaffected: the strip is a no-op on them.
31
+ The Python port fixed the same defect the same way; the PHP engines never had it (no `parse()`
32
+ handle, no PUA sentinels).
33
+
6
34
  ## 0.3.0 — 2026-07-19
7
35
 
8
36
  `#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` collapse, post-process output) is parity-gated.
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(stripSentinels(included)), {
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 first.",
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
  });
@@ -1113,7 +1113,7 @@ function renderWith(input, rng, opts = {}) {
1113
1113
  includeStack: [],
1114
1114
  onPluralError: opts.onPluralError
1115
1115
  };
1116
- const ast = resolveAst(typeof input === "string" ? stripSentinels(input) : input);
1116
+ const ast = resolveAst(input);
1117
1117
  let out = renderAst(ast, ctx);
1118
1118
  if (opts.postProcess !== false) out = postProcess(out);
1119
1119
  return safetyRestore(out);