@spintax/core 0.13.0 → 0.14.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 CHANGED
@@ -3,6 +3,35 @@
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.14.0 — 2026-10-19
7
+
8
+ **Breaking (syntax surface): a verbatim span, `/! … !/` (#92).** A minor on 0.x, as every syntax change
9
+ here. No stored output changes: the delimiters occur 0 times in 3 560 stored documents of the family's
10
+ products, and a render, validate and extract differential over them shows 0 changes.
11
+
12
+ ### Added
13
+
14
+ - **`/! … !/` keeps its content exactly as written** — no spintax inside, no post-process
15
+ (`docs/decisions/0001-verbatim-span.md`). Markdown links (`/![Docs](https://spintax.net)!/`), foreign
16
+ macros (`/!#file[D:\kw.txt,1,S]!/`), BBCode and prose about spintax itself (`/!{Hello|Hi}!/`) now
17
+ survive a render. One scanner with `/# … #/` comments: whichever opener comes first wins; the first `!/`
18
+ closes a span; `/!!/` is removed; an unclosed `/!` is plain text. A span works in a `#set`/`#def` value
19
+ (across lines, too), in an `#include` body and in an `#include` ref (the resolver gets it decoded).
20
+ Around a span the post-process works as usual; the span itself is neither capitalized nor trimmed.
21
+ - **`verbatim.unclosed` warning** for a `/!` with no closer — its text then parses as spintax. A warning:
22
+ the verdict does not change.
23
+ - `analyze().constructs.verbatim` counts spans.
24
+
25
+ ### Changed
26
+
27
+ - **`AST_VERSION` 5.** An `Ast` from an earlier version is refused with `AstVersionError`; it would spin a
28
+ span instead of emitting it.
29
+ - **Reserved range.** U+F0000–U+F1FFF (Supplementary Private Use Area-A, where spans are encoded during a
30
+ render) is stripped from template text outside spans and from every runtime `context` value. The
31
+ neutralize sentinels U+E000–U+E005 inside a span are content and come back unchanged.
32
+ - Validator positions after a comment or a span point at the author's source (they were shifted by the
33
+ removed comment text). Positions are not parity-gated.
34
+
6
35
  ## 0.13.0 — 2026-10-19
7
36
 
8
37
  A minor: the post-process moves rendered text for a token that starts with a dot, for a bare `//`
package/README.md CHANGED
@@ -19,7 +19,7 @@ parse, render, validate, extract, analyze, and neutralize spintax templates.
19
19
  [`spintax-core`](https://pypi.org/project/spintax-core/) (Python), an Object Pascal engine,
20
20
  [`Spintax.Core`](https://www.nuget.org/packages/Spintax.Core/) (.NET), and
21
21
  the [Spintax WordPress plugin](https://wordpress.org/plugins/spintax/) are all held to the same
22
- behavior contract by a **shared golden corpus of 424 fixtures** — deterministic verdicts, plural
22
+ behavior contract by a **shared golden corpus of 486 fixtures** — deterministic verdicts, plural
23
23
  buckets, conditionals, `#set`/`#def` semantics and post-processing agree everywhere, so a
24
24
  template is an asset you can move between runtimes, not a lock-in.
25
25
  - **MIT** licensed.
@@ -88,6 +88,7 @@ dedupe in the host and cap the retries: a template may simply not have N combina
88
88
  | Plural | `{plural %n%: one\|few\|many}` | grammatical agreement by locale — 2 forms (en), 3 (ru/uk/be, sr/hr/bs), 6 (ar: zero\|one\|two\|few\|many\|other) |
89
89
  | Include | `#include "slug-or-id"` | embed another template (host-resolved) |
90
90
  | Comment | `/# … #/` | stripped before rendering |
91
+ | Verbatim | `/! … !/` | emitted exactly as written — no spintax inside, no post-process: `/![Docs](https://spintax.net)!/`, `/!#file[D:\kw.txt,1,S]!/`. The first `!/` closes it; an unclosed `/!` is plain text and a `verbatim.unclosed` warning |
91
92
 
92
93
  ## API
93
94
 
package/dist/index.cjs CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  // src/internal/ast.ts
4
- var AST_VERSION = 4;
4
+ var AST_VERSION = 5;
5
5
  function walk(nodes, visit) {
6
6
  const stack = [{ list: nodes, i: 0 }];
7
7
  while (stack.length > 0) {
@@ -45,31 +45,6 @@ function nameMap(...sources) {
45
45
  return map;
46
46
  }
47
47
 
48
- // src/internal/neutralize.ts
49
- var STRUCTURAL = ["{", "}", "[", "]", "%", "#"];
50
- var SENTINEL_BASE = 57344;
51
- var SHIELD = /* @__PURE__ */ new Map();
52
- var RESTORE = /* @__PURE__ */ new Map();
53
- STRUCTURAL.forEach((ch, i) => {
54
- const sentinel = String.fromCharCode(SENTINEL_BASE + i);
55
- SHIELD.set(ch, sentinel);
56
- RESTORE.set(sentinel, ch);
57
- });
58
- var SHIELD_RE = /[{}[\]%#]/gu;
59
- var RESTORE_RE = new RegExp(
60
- `[${String.fromCharCode(SENTINEL_BASE)}-${String.fromCharCode(SENTINEL_BASE + STRUCTURAL.length - 1)}]`,
61
- "gu"
62
- );
63
- function neutralize(value) {
64
- return value.replace(SHIELD_RE, (ch) => SHIELD.get(ch) ?? ch);
65
- }
66
- function safetyRestore(text) {
67
- return text.replace(RESTORE_RE, (ch) => RESTORE.get(ch) ?? ch);
68
- }
69
- function stripSentinels(text) {
70
- return text.replace(RESTORE_RE, "");
71
- }
72
-
73
48
  // src/internal/pairs.ts
74
49
  var BRACE_OPEN = 123;
75
50
  var BRACE_CLOSE = 125;
@@ -347,6 +322,126 @@ var TextIndex = class {
347
322
  }
348
323
  };
349
324
 
325
+ // src/internal/verbatim.ts
326
+ var OPEN = 983040;
327
+ var CLOSE = 983041;
328
+ var DIGIT = 987136;
329
+ var RESERVED_RE = /[-\u{F0000}-\u{F1FFF}]/gu;
330
+ var SPAN_RANGE_RE = /[\u{F0000}-\u{F1FFF}]/gu;
331
+ var TOKEN_RE = /\u{F0000}((?:[\u{F1000}-\u{F1FFF}]{2})*)\u{F0001}/gu;
332
+ var SPAN_HIGH_RE = /[\uDB80-\uDB87]/;
333
+ function encodeVerbatim(content) {
334
+ let out = String.fromCodePoint(OPEN);
335
+ for (const ch of content) {
336
+ const c = ch.codePointAt(0);
337
+ out += String.fromCodePoint(DIGIT + (c >> 12), DIGIT + (c & 4095));
338
+ }
339
+ return out + String.fromCodePoint(CLOSE);
340
+ }
341
+ function decodeVerbatim(text) {
342
+ if (!SPAN_HIGH_RE.test(text)) return text;
343
+ TOKEN_RE.lastIndex = 0;
344
+ return text.replace(TOKEN_RE, (_m, digits) => {
345
+ let out = "";
346
+ const units = [...digits];
347
+ for (let i = 0; i + 1 < units.length; i += 2) {
348
+ out += String.fromCodePoint(units[i].codePointAt(0) - DIGIT << 12 | units[i + 1].codePointAt(0) - DIGIT);
349
+ }
350
+ return out;
351
+ });
352
+ }
353
+ function budgetLength(value) {
354
+ return SPAN_HIGH_RE.test(value) ? decodeVerbatim(value).length : value.length;
355
+ }
356
+ function stripSpanRange(value) {
357
+ return SPAN_HIGH_RE.test(value) ? value.replace(SPAN_RANGE_RE, "") : value;
358
+ }
359
+ function projectSource(src) {
360
+ const outStarts = [];
361
+ const srcStarts = [];
362
+ const srcEnds = [];
363
+ const tokens = [];
364
+ let out = "";
365
+ let spans = 0;
366
+ let unclosed = -1;
367
+ const copy = (start, end) => {
368
+ if (start >= end) return;
369
+ const chunk = src.slice(start, end);
370
+ let from2 = 0;
371
+ for (const m of chunk.matchAll(RESERVED_RE)) {
372
+ if (m.index > from2) push(start + from2, start + m.index, chunk.slice(from2, m.index), false);
373
+ from2 = m.index + m[0].length;
374
+ }
375
+ if (from2 < chunk.length) push(start + from2, end, chunk.slice(from2), false);
376
+ };
377
+ const push = (srcStart, srcEnd, piece, token) => {
378
+ outStarts.push(out.length);
379
+ srcStarts.push(srcStart);
380
+ srcEnds.push(srcEnd);
381
+ tokens.push(token);
382
+ out += piece;
383
+ };
384
+ let from = 0;
385
+ let at = 0;
386
+ let nextComment = -2;
387
+ let nextSpan = -2;
388
+ let commentCloserGone = false;
389
+ let spanCloserGone = false;
390
+ for (; ; ) {
391
+ if (nextComment !== -1 && nextComment < at) nextComment = src.indexOf("/#", at);
392
+ if (nextSpan !== -1 && nextSpan < at) nextSpan = src.indexOf("/!", at);
393
+ if (nextComment === -1 && nextSpan === -1) break;
394
+ const isSpan = nextComment === -1 || nextSpan !== -1 && nextSpan < nextComment;
395
+ const open = isSpan ? nextSpan : nextComment;
396
+ const gone = isSpan ? spanCloserGone : commentCloserGone;
397
+ const close = gone ? -1 : src.indexOf(isSpan ? "!/" : "#/", open + 2);
398
+ if (close === -1) {
399
+ if (isSpan) {
400
+ spanCloserGone = true;
401
+ if (unclosed === -1) unclosed = open;
402
+ } else {
403
+ commentCloserGone = true;
404
+ }
405
+ at = open + 2;
406
+ continue;
407
+ }
408
+ copy(from, open);
409
+ if (isSpan) {
410
+ spans += 1;
411
+ if (close > open + 2) push(open, close + 2, encodeVerbatim(src.slice(open + 2, close)), true);
412
+ }
413
+ from = at = close + 2;
414
+ }
415
+ copy(from, src.length);
416
+ const segmentAt = (offset) => {
417
+ let lo = 0;
418
+ let hi = outStarts.length - 1;
419
+ if (hi < 0 || outStarts[0] > offset) return -1;
420
+ while (lo < hi) {
421
+ const mid = lo + hi + 1 >> 1;
422
+ if (outStarts[mid] <= offset) lo = mid;
423
+ else hi = mid - 1;
424
+ }
425
+ return lo;
426
+ };
427
+ const toSource = (offset) => {
428
+ const at2 = segmentAt(offset);
429
+ if (at2 === -1) return 0;
430
+ const delta = offset - outStarts[at2];
431
+ if (delta <= 0) return srcStarts[at2];
432
+ if (tokens[at2]) return srcEnds[at2];
433
+ return Math.min(srcStarts[at2] + delta, srcEnds[at2]);
434
+ };
435
+ const toSourceEnd = (offset) => {
436
+ if (offset <= 0) return toSource(0);
437
+ const at2 = segmentAt(offset - 1);
438
+ if (at2 === -1) return 0;
439
+ if (tokens[at2]) return srcEnds[at2];
440
+ return Math.min(srcStarts[at2] + (offset - outStarts[at2]), srcEnds[at2]);
441
+ };
442
+ return { text: out, spans, unclosed, toSource, toSourceEnd };
443
+ }
444
+
350
445
  // src/internal/parser.ts
351
446
  var VARIABLE_RE = /%(\w+)%/y;
352
447
  var PERCENT = 37;
@@ -358,7 +453,7 @@ var DIRECTIVE_RE = /^[ \t]*#(set|def)[ \t]+%(\w+)%[ \t]*=[ \t]*((?:.*[^ \t\n\r\u
358
453
  var CONDITIONAL_NAME_RE2 = /[A-Za-z_]\w*/y;
359
454
  var PLURAL_PREFIX = "plural ";
360
455
  function parseTemplate(src) {
361
- const { body, setDefs, defDefs } = extractDirectives(stripComments(stripSentinels(src)));
456
+ const { body, setDefs, defDefs } = extractDirectives(projectSource(src).text);
362
457
  return { astVersion: AST_VERSION, source: src, setDefs, defDefs, nodes: parseSequence(body) };
363
458
  }
364
459
  function extractDirectives(text) {
@@ -392,19 +487,6 @@ function extractDirectives(text) {
392
487
  );
393
488
  return { body: stripped.replace(/\n{3,}/gu, "\n\n"), setDefs, defDefs, occurrences };
394
489
  }
395
- function stripComments(text) {
396
- let out = "";
397
- let from = 0;
398
- for (; ; ) {
399
- const open = text.indexOf("/#", from);
400
- if (open === -1) break;
401
- const close = text.indexOf("#/", open + 2);
402
- if (close === -1) break;
403
- out += text.slice(from, open);
404
- from = close + 2;
405
- }
406
- return from === 0 ? text : out + text.slice(from);
407
- }
408
490
  function parseSequence(text) {
409
491
  const index = new TextIndex(text);
410
492
  const frame = (start, end) => ({
@@ -789,7 +871,8 @@ var KNOWN_CONFIG_KEYS = /* @__PURE__ */ new Set(["minsize", "maxsize", "sep", "l
789
871
  var INCLUDE_RE = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
790
872
  function validateTemplate(src, opts = {}) {
791
873
  const diagnostics = [];
792
- const text = stripComments(src);
874
+ const projection = projectSource(src);
875
+ const text = projection.text;
793
876
  const idx = buildLineIndex(text);
794
877
  checkBrackets(text, diagnostics);
795
878
  checkDirectives(text, diagnostics);
@@ -799,7 +882,62 @@ function validateTemplate(src, opts = {}) {
799
882
  if (opts.knownIncludes && opts.knownIncludes.length > 0) {
800
883
  checkIncludeTargets(text, idx, opts.knownIncludes, diagnostics);
801
884
  }
802
- return diagnostics;
885
+ const srcIdx = text === src ? idx : buildLineIndex(src);
886
+ const remap = { text, idx, src, srcIdx, toSource: projection.toSource, toSourceEnd: projection.toSourceEnd };
887
+ const placed = text === src ? diagnostics : diagnostics.map((d) => toSourcePositions(d, remap));
888
+ if (projection.unclosed !== -1) {
889
+ placed.push(warn("verbatim.unclosed", "Unclosed '/!' \u2014 the text after it is parsed as spintax.", span(srcIdx, projection.unclosed, 2)));
890
+ }
891
+ return placed;
892
+ }
893
+ function toSourcePositions(d, r) {
894
+ const codePoints = d.code.startsWith("bracket.");
895
+ const move = (line, column) => {
896
+ const offset = r.toSource(lineColToOffset(r, line, column, codePoints));
897
+ return lineColAt(r, offset, codePoints);
898
+ };
899
+ const start = move(d.line, d.column);
900
+ const data = d.data === void 0 ? void 0 : Object.fromEntries(
901
+ Object.entries(d.data).map(([key, value]) => [key, typeof value === "string" ? decodeVerbatim(value) : value])
902
+ );
903
+ const moved = { ...d, message: decodeVerbatim(d.message), line: start.line, column: start.column, ...data === void 0 ? {} : { data } };
904
+ if (d.endLine !== void 0 && d.endColumn !== void 0) {
905
+ const end = lineColAt(r, r.toSourceEnd(lineColToOffset(r, d.endLine, d.endColumn, codePoints)), codePoints);
906
+ return { ...moved, endLine: end.line, endColumn: end.column };
907
+ }
908
+ return moved;
909
+ }
910
+ function astralPrefix(text) {
911
+ const prefix = new Uint32Array(text.length + 1);
912
+ for (let i = 0; i < text.length; i += 1) {
913
+ const c = text.charCodeAt(i);
914
+ const pair = c >= 55296 && c <= 56319 && (text.charCodeAt(i + 1) & 64512) === 56320;
915
+ prefix[i + 1] = prefix[i] + (pair ? 1 : 0);
916
+ }
917
+ return prefix;
918
+ }
919
+ function codePointsBetween(prefix, from, to) {
920
+ return to - from - (prefix[to] - prefix[from]);
921
+ }
922
+ function lineColToOffset(r, line, column, codePoints) {
923
+ const start = r.idx.starts[Math.min(Math.max(line, 1), r.idx.starts.length) - 1] ?? 0;
924
+ if (!codePoints) return Math.min(start + column - 1, r.text.length);
925
+ const prefix = r.textAstral ??= astralPrefix(r.text);
926
+ let lo = start;
927
+ let hi = Math.min(start + 2 * (column - 1), r.text.length);
928
+ while (lo < hi) {
929
+ const mid = lo + hi >> 1;
930
+ if (codePointsBetween(prefix, start, mid) < column - 1) lo = mid + 1;
931
+ else hi = mid;
932
+ }
933
+ return lo;
934
+ }
935
+ function lineColAt(r, offset, codePoints) {
936
+ const pos = offsetToLineCol(r.srcIdx, offset);
937
+ if (!codePoints) return pos;
938
+ const start = r.srcIdx.starts[pos.line - 1] ?? 0;
939
+ const prefix = r.srcAstral ??= astralPrefix(r.src);
940
+ return { line: pos.line, column: codePointsBetween(prefix, start, Math.min(offset, r.src.length)) + 1 };
803
941
  }
804
942
  function err(code, message, pos) {
805
943
  return { severity: "error", code, message, ...pos };
@@ -1225,13 +1363,14 @@ function checkIncludeTargets(text, idx, known, out) {
1225
1363
  const set = new Set(known);
1226
1364
  INCLUDE_RE.lastIndex = 0;
1227
1365
  for (const m of text.matchAll(INCLUDE_RE)) {
1228
- const ref = m[1] ?? "";
1366
+ const raw = m[1] ?? "";
1367
+ const ref = decodeVerbatim(raw);
1229
1368
  if (!set.has(ref)) {
1230
1369
  const refOffset = (m.index ?? 0) + m[0].indexOf('"') + 1;
1231
1370
  out.push(err(
1232
1371
  "include.unknown-target",
1233
1372
  `#include target '${ref}' does not match any known template.`,
1234
- { ...span(idx, refOffset, ref.length), data: { target: ref } }
1373
+ { ...span(idx, refOffset, raw.length), data: { target: ref } }
1235
1374
  ));
1236
1375
  }
1237
1376
  }
@@ -1245,10 +1384,10 @@ var INCLUDE_RE2 = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu
1245
1384
  var VARIABLE_RE2 = /%(\w+)%/gu;
1246
1385
  var CONDITIONAL_REF_RE = /\{\?!?([A-Za-z_]\w*)\?/gu;
1247
1386
  function extractFromSource(src) {
1248
- const text = stripComments(src);
1387
+ const text = projectSource(src).text;
1249
1388
  const sets = collect(text, SET_DEF_RE, true);
1250
1389
  const defs = collect(text, DEF_DEF_RE, true);
1251
- const includes = collect(text, INCLUDE_RE2, false);
1390
+ const includes = [...new Set(collect(text, INCLUDE_RE2, false).map(decodeVerbatim))];
1252
1391
  const body = text.replace(DEFINITION_LHS_RE, "");
1253
1392
  const refs = /* @__PURE__ */ new Set();
1254
1393
  addAll(refs, body, VARIABLE_RE2, true);
@@ -1467,7 +1606,8 @@ function renderAst(ast, ctx) {
1467
1606
  }
1468
1607
  function resolveIncludes(text, ctx) {
1469
1608
  INCLUDE_LINE_RE.lastIndex = 0;
1470
- return text.replace(INCLUDE_LINE_RE, (_m, ref) => {
1609
+ return text.replace(INCLUDE_LINE_RE, (_m, raw) => {
1610
+ const ref = decodeVerbatim(raw);
1471
1611
  if (ctx.includeStack.includes(ref) || ctx.includeStack.length >= ctx.maxDepth) return "";
1472
1612
  let included;
1473
1613
  try {
@@ -1621,7 +1761,7 @@ function resolveVariable(name, opts) {
1621
1761
  const value = opts.vars[name.toLowerCase()];
1622
1762
  if (value === void 0 || opts.frozen === true) return `%${name}%`;
1623
1763
  if (opts.budget.left <= 0) return `%${name}%`;
1624
- opts.budget.left -= value.length;
1764
+ opts.budget.left -= budgetLength(value);
1625
1765
  if (opts.depth >= MAX_VARIABLE_DEPTH || !/[{[%]/u.test(value)) return value;
1626
1766
  return renderFragment(parseSequence(value), { ...opts, depth: opts.depth + 1 });
1627
1767
  }
@@ -1638,7 +1778,7 @@ function expandVarsFixpoint(text, opts, passes) {
1638
1778
  const value = opts.vars[name.toLowerCase()];
1639
1779
  if (value === void 0) return m;
1640
1780
  if (opts.budget.left <= 0) return m;
1641
- opts.budget.left -= value.length;
1781
+ opts.budget.left -= budgetLength(value);
1642
1782
  changed = true;
1643
1783
  return value;
1644
1784
  });
@@ -2058,10 +2198,10 @@ var afterLineBreak = (text, from) => {
2058
2198
  return at === -1 ? null : { leadStart: at + 1, resume: at + 1 };
2059
2199
  };
2060
2200
  var SHIELD_PREFIXES = ["URL", "URI", "EMAIL", "DOTTOK", "DOM", "NUM", "ABBR"];
2061
- var RESTORE_RE2 = new RegExp(`\\x00(?:${SHIELD_PREFIXES.join("|")})_\\d+\\x00`, "gu");
2201
+ var RESTORE_RE = new RegExp(`\\x00(?:${SHIELD_PREFIXES.join("|")})_\\d+\\x00`, "gu");
2062
2202
  function restore(text, input, placeholders) {
2063
2203
  if (!input.includes("\0")) {
2064
- return text.replace(RESTORE_RE2, (key) => placeholders.get(key) ?? key);
2204
+ return text.replace(RESTORE_RE, (key) => placeholders.get(key) ?? key);
2065
2205
  }
2066
2206
  return restoreAsTheLoopDoes(text, placeholders);
2067
2207
  }
@@ -2131,11 +2271,33 @@ function postProcess(input, locale = "") {
2131
2271
  return restore(text, input, placeholders).trim();
2132
2272
  }
2133
2273
 
2274
+ // src/internal/neutralize.ts
2275
+ var STRUCTURAL = ["{", "}", "[", "]", "%", "#"];
2276
+ var SENTINEL_BASE = 57344;
2277
+ var SHIELD = /* @__PURE__ */ new Map();
2278
+ var RESTORE = /* @__PURE__ */ new Map();
2279
+ STRUCTURAL.forEach((ch, i) => {
2280
+ const sentinel = String.fromCharCode(SENTINEL_BASE + i);
2281
+ SHIELD.set(ch, sentinel);
2282
+ RESTORE.set(sentinel, ch);
2283
+ });
2284
+ var SHIELD_RE = /[{}[\]%#]/gu;
2285
+ var RESTORE_RE2 = new RegExp(
2286
+ `[${String.fromCharCode(SENTINEL_BASE)}-${String.fromCharCode(SENTINEL_BASE + STRUCTURAL.length - 1)}]`,
2287
+ "gu"
2288
+ );
2289
+ function neutralize(value) {
2290
+ return value.replace(SHIELD_RE, (ch) => SHIELD.get(ch) ?? ch);
2291
+ }
2292
+ function safetyRestore(text) {
2293
+ return text.replace(RESTORE_RE2, (ch) => RESTORE.get(ch) ?? ch);
2294
+ }
2295
+
2134
2296
  // src/internal/pipeline.ts
2135
2297
  var DEFAULT_MAX_DEPTH = 20;
2136
2298
  function renderWith(input, rng, opts = {}) {
2137
2299
  const ctx = {
2138
- runtimeContext: opts.context ?? {},
2300
+ runtimeContext: runtimeValues(opts.context),
2139
2301
  rng,
2140
2302
  locale: opts.locale ?? "",
2141
2303
  resolver: opts.includeResolver,
@@ -2149,7 +2311,13 @@ function renderWith(input, rng, opts = {}) {
2149
2311
  const ast = resolveAst(input);
2150
2312
  let out = renderAst(ast, ctx);
2151
2313
  if (opts.postProcess !== false) out = postProcess(out, ctx.locale);
2152
- return safetyRestore(out);
2314
+ return decodeVerbatim(safetyRestore(out));
2315
+ }
2316
+ function runtimeValues(context) {
2317
+ if (context === void 0) return {};
2318
+ const entries = Object.entries(context);
2319
+ if (entries.every(([, value]) => stripSpanRange(value) === value)) return context;
2320
+ return Object.fromEntries(entries.map(([key, value]) => [key, stripSpanRange(value)]));
2153
2321
  }
2154
2322
  function resolveAst(input) {
2155
2323
  if (typeof input === "string") return parseTemplate(input);
@@ -2231,6 +2399,7 @@ function countConstructs(ast, includeCount) {
2231
2399
  });
2232
2400
  counts.set = Object.keys(ast.setDefs).length;
2233
2401
  counts.include = includeCount;
2402
+ counts.verbatim = projectSource(ast.source).spans;
2234
2403
  return counts;
2235
2404
  }
2236
2405
  function neutralize2(value) {