@spintax/core 0.12.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,73 @@
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
+
35
+ ## 0.13.0 — 2026-10-19
36
+
37
+ A minor: the post-process moves rendered text for a token that starts with a dot, for a bare `//`
38
+ scheme, and for a sentence opening on `i` under Turkish or Azerbaijani. No verdict changes and no API
39
+ change; `render()` already took the `locale` the post-process now also reads.
40
+
41
+ ### Fixed
42
+
43
+ - **A dot that starts a token is not a sentence end (#91).** `.NET`, a dotfile, a file extension were
44
+ taken for sentence punctuation: the space before the dot was removed, a space was inserted after it and
45
+ the token was capitalized — `and .NET` rendered `and. NET`, `the .apk file` rendered `the. Apk file`.
46
+ A token that begins with a dot — `.NET`, `.gitignore`, `.env.local`, `.tar.gz`, `.xlsx`, `.5` — is now
47
+ shielded like a domain or a URL when it follows the text start, whitespace, an opening bracket, a quote,
48
+ a tag or a path separator (`src/.env`, `dir\.env`); trailing punctuation stays outside it (`.NET.`).
49
+ Unchanged: `word .` → `Word.`, `end . Next` → `End. Next`, `end.Next`, `ASP.NET`, `3.5`, and an ellipsis
50
+ glued to a word (`see...and` → `See... And`). The cost: `end .Next` — a sentence typed with the space on
51
+ the wrong side of the dot — is no longer repaired.
52
+ - **Turkish and Azerbaijani capital İ (#94).** Under `locale: 'tr'` or `'az'` (any region, `-` or `_`) a
53
+ sentence that opens on dotted `i` is capitalized to `İ`, not `I` — `istanbul` → `İstanbul`; dotless `ı`
54
+ still becomes `I`. An explicit mapping, not `toLocaleUpperCase`, so the result does not depend on the
55
+ host's ICU data. Other locales are unchanged.
56
+ - **A bare `//` scheme is a URL.** Prose that names the protocol — `(https://)` — got a space after its
57
+ colon: `(https: //)`. A `//` scheme with no body is now shielded like a URL, in any case (`HTTPS://`).
58
+ `mailto:` and `tel:` still need a body, so `Tel: 555` opening a sentence is capitalized and spaced as
59
+ before.
60
+
61
+ Measured before the change over the family's stored content (2 581 production templates in four
62
+ languages, 163 more in ten): every changed document is a fix — 21 renders of a published article that
63
+ read `the. Apk file`, 11 that read `(https: //)` — and nothing else moves.
64
+
65
+ ### Corpus
66
+
67
+ 424 cases (+16), all in `render-postprocess.json`: `postprocess/turkish-*`, `postprocess/azerbaijani-*`,
68
+ `postprocess/turkic-locale-case-and-underscore`, `postprocess/dotted-capital-only-under-turkic-locales`,
69
+ `postprocess/dot-token-*` (openers, punctuation, text and line start, a digit, UCP whitespace, both path
70
+ separators, and the negatives with the accepted cost), `postprocess/bare-scheme-*` and
71
+ `postprocess/bare-mailto-and-tel-are-not-uris`.
72
+
6
73
  ## 0.12.0 — 2026-10-06
7
74
 
8
75
  A minor: rendered text moves for permutation separators in Arabic, Hebrew, Thai and Lao. No verdict
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 408 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
  });
@@ -1919,7 +2059,7 @@ var TLD_UPPER = "\\p{Lu}\\p{Lt}\\p{Lm}\\p{Lo}";
1919
2059
  var TLD = `(?:${XN}[a-zA-Z0-9\\-\\u017F\\u212A]{2,59}|[${TLD_LOWER}][${TLD_LOWER}\\p{N}-]{1,62}|[${TLD_UPPER}][${TLD_UPPER}\\p{N}-]{1,62})`;
1920
2060
  var DOMAIN_PART = `(?:${LABEL}\\.)+${TLD}`;
1921
2061
  var URI_BODY = `[^\\x00${WS}<>"')\\]]`;
1922
- var URI_RE = new RegExp(`(?:(?:https?|ftp):\\/\\/|(?:mailto|tel):)${URI_BODY}+`, "giu");
2062
+ var URI_RE = new RegExp(`(?:(?:https?|ftp):\\/\\/${URI_BODY}*|(?:mailto|tel):${URI_BODY}+)`, "giu");
1923
2063
  var MAILTEL_PREFIX_RE = /^(?:mailto|tel):/iu;
1924
2064
  var DOMAIN_AT_RE = new RegExp(`${DOMAIN_PART}${WORD_BOUNDARY}`, "uy");
1925
2065
  var DOMAIN_SCAN_RE = new RegExp(`${AFTER_NON_WORD}(?:(${DOMAIN_PART}${WORD_BOUNDARY})|(?:${LABEL}\\.)*${LABEL})`, "gu");
@@ -1968,12 +2108,15 @@ var SENTENCE_OPENERS = "\xBF\xA1";
1968
2108
  var LEAD = `(?:<[^>]+>|[${SENTENCE_OPENERS}]|${S})*`;
1969
2109
  var QUOTES = `"'\xAB\xBB\u2039\u203A\u201C\u201D\u2018\u2019`;
1970
2110
  var CLOSER = `[)\\]]|[${QUOTES}]+(?=${S}|$|<|\\/?>|[.,;:!?\u2026)\\]\u2014\u2013])`;
2111
+ var DOT_TOKEN_RE = new RegExp(`(?<=^|${S}|[(\\[{>/\\\\${QUOTES}])\\.[\\p{L}\\p{N}][\\p{L}\\p{N}_.\\-]*`, "gu");
1971
2112
  var SPACE_BEFORE_PUNCT_RE = new RegExp(`(?<!${S})${S}+([,;:!?.])`, "gu");
1972
2113
  var SPACE_AFTER_COMMA_RE = new RegExp(`([,;:])(?!\\p{Nd})(?!${S}|$|<|${CLOSER})`, "gu");
1973
2114
  var SPACE_AFTER_SENTENCE_RE = new RegExp(`(?<![.!?])([.!?]+)(?![.!?])(?!\\p{Nd})(?!${S}|$|<|${CLOSER})`, "gu");
1974
2115
  var SPACE_AFTER_OPENER_RE = new RegExp(`([${SENTENCE_OPENERS}])${S}+`, "gu");
1975
2116
  var CAP_FIRST_RE = new RegExp(`^(${LEAD})(\\p{Ll})`, "u");
1976
2117
  var up = (ch) => ch.toUpperCase();
2118
+ var upTurkic = (ch) => ch === "i" ? "\u0130" : ch.toUpperCase();
2119
+ var TURKIC_LANGS = /* @__PURE__ */ new Set(["tr", "az"]);
1977
2120
  function indexLeads(text) {
1978
2121
  const n = text.length;
1979
2122
  const leadEnd = new Int32Array(n + 1);
@@ -2006,7 +2149,7 @@ function leadEndFrom(text, i, leads) {
2006
2149
  }
2007
2150
  return j < text.length ? leads().leadEnd[j] : j;
2008
2151
  }
2009
- function capitalizeAfter(text, lower, next, context) {
2152
+ function capitalizeAfter(text, lower, next, context, upper) {
2010
2153
  const leads = () => {
2011
2154
  if (context.leads === null || context.leads.leadEnd.length !== text.length + 1) context.leads = indexLeads(text);
2012
2155
  return context.leads;
@@ -2021,7 +2164,7 @@ function capitalizeAfter(text, lower, next, context) {
2021
2164
  if (cp === void 0) continue;
2022
2165
  const ch = String.fromCodePoint(cp);
2023
2166
  if (!lower.test(ch)) continue;
2024
- out += text.slice(emitted, at) + up(ch);
2167
+ out += text.slice(emitted, at) + upper(ch);
2025
2168
  emitted = at + ch.length;
2026
2169
  from = emitted;
2027
2170
  }
@@ -2054,11 +2197,11 @@ var afterLineBreak = (text, from) => {
2054
2197
  const at = text.indexOf("\n", from);
2055
2198
  return at === -1 ? null : { leadStart: at + 1, resume: at + 1 };
2056
2199
  };
2057
- var SHIELD_PREFIXES = ["URL", "URI", "EMAIL", "DOM", "NUM", "ABBR"];
2058
- var RESTORE_RE2 = new RegExp(`\\x00(?:${SHIELD_PREFIXES.join("|")})_\\d+\\x00`, "gu");
2200
+ var SHIELD_PREFIXES = ["URL", "URI", "EMAIL", "DOTTOK", "DOM", "NUM", "ABBR"];
2201
+ var RESTORE_RE = new RegExp(`\\x00(?:${SHIELD_PREFIXES.join("|")})_\\d+\\x00`, "gu");
2059
2202
  function restore(text, input, placeholders) {
2060
2203
  if (!input.includes("\0")) {
2061
- return text.replace(RESTORE_RE2, (key) => placeholders.get(key) ?? key);
2204
+ return text.replace(RESTORE_RE, (key) => placeholders.get(key) ?? key);
2062
2205
  }
2063
2206
  return restoreAsTheLoopDoes(text, placeholders);
2064
2207
  }
@@ -2089,7 +2232,8 @@ function restoreAsTheLoopDoes(text, placeholders) {
2089
2232
  }
2090
2233
  return out;
2091
2234
  }
2092
- function postProcess(input) {
2235
+ function postProcess(input, locale = "") {
2236
+ const upper = TURKIC_LANGS.has(normalizeBaseLang(locale)) ? upTurkic : up;
2093
2237
  const placeholders = /* @__PURE__ */ new Map();
2094
2238
  let counter = 0;
2095
2239
  const store = (value, prefix) => {
@@ -2109,6 +2253,7 @@ function postProcess(input) {
2109
2253
  (m) => storeWithTrailingPunct(m, MAILTEL_PREFIX_RE.test(m) ? "URI" : "URL")
2110
2254
  );
2111
2255
  text = shieldEmails(text, (m) => store(m, "EMAIL"));
2256
+ text = text.replace(DOT_TOKEN_RE, (m) => storeWithTrailingPunct(m, "DOTTOK"));
2112
2257
  text = shieldDomains(text, (m) => store(m, "DOM"));
2113
2258
  text = text.replace(DECIMAL_RE, (m) => store(m, "NUM"));
2114
2259
  text = text.replace(MULTI_ABBR_RE, (m) => store(m, "ABBR"));
@@ -2118,19 +2263,41 @@ function postProcess(input) {
2118
2263
  text = text.replace(SPACE_AFTER_COMMA_RE, "$1 ");
2119
2264
  text = text.replace(SPACE_AFTER_SENTENCE_RE, "$1 ");
2120
2265
  text = text.replace(SPACE_AFTER_OPENER_RE, "$1");
2121
- text = text.replace(CAP_FIRST_RE, (_m, lead, ch) => lead + up(ch));
2266
+ text = text.replace(CAP_FIRST_RE, (_m, lead, ch) => lead + upper(ch));
2122
2267
  const leadContext = { leads: null };
2123
- text = capitalizeAfter(text, LOWER_RE, afterSentenceEnd, leadContext);
2124
- text = capitalizeAfter(text, LOWER_RE, afterBlockTag(), leadContext);
2125
- text = capitalizeAfter(text, LOWER_RE, afterLineBreak, leadContext);
2268
+ text = capitalizeAfter(text, LOWER_RE, afterSentenceEnd, leadContext, upper);
2269
+ text = capitalizeAfter(text, LOWER_RE, afterBlockTag(), leadContext, upper);
2270
+ text = capitalizeAfter(text, LOWER_RE, afterLineBreak, leadContext, upper);
2126
2271
  return restore(text, input, placeholders).trim();
2127
2272
  }
2128
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
+
2129
2296
  // src/internal/pipeline.ts
2130
2297
  var DEFAULT_MAX_DEPTH = 20;
2131
2298
  function renderWith(input, rng, opts = {}) {
2132
2299
  const ctx = {
2133
- runtimeContext: opts.context ?? {},
2300
+ runtimeContext: runtimeValues(opts.context),
2134
2301
  rng,
2135
2302
  locale: opts.locale ?? "",
2136
2303
  resolver: opts.includeResolver,
@@ -2143,8 +2310,14 @@ function renderWith(input, rng, opts = {}) {
2143
2310
  };
2144
2311
  const ast = resolveAst(input);
2145
2312
  let out = renderAst(ast, ctx);
2146
- if (opts.postProcess !== false) out = postProcess(out);
2147
- return safetyRestore(out);
2313
+ if (opts.postProcess !== false) out = postProcess(out, ctx.locale);
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)]));
2148
2321
  }
2149
2322
  function resolveAst(input) {
2150
2323
  if (typeof input === "string") return parseTemplate(input);
@@ -2226,6 +2399,7 @@ function countConstructs(ast, includeCount) {
2226
2399
  });
2227
2400
  counts.set = Object.keys(ast.setDefs).length;
2228
2401
  counts.include = includeCount;
2402
+ counts.verbatim = projectSource(ast.source).spans;
2229
2403
  return counts;
2230
2404
  }
2231
2405
  function neutralize2(value) {