@spintax/core 0.1.2 → 0.1.4

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,54 @@
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.1.4 — 2026-07-13
7
+
8
+ A post-process bug fix (hit in production) plus docs. No API change.
9
+
10
+ ### Fixed
11
+
12
+ - **`postProcess` no longer mangles `mailto:` / `tel:` URIs.** They carry no `//` authority,
13
+ so the URL shield missed them; the email shield then carved the address out from under the
14
+ prefix, and the "space after a colon" rule split the leftover into a malformed
15
+ `mailto: contact@example.com` href. They are now shielded as whole tokens (with the same
16
+ trailing-punctuation handling as URLs) before the email/domain passes. Mirrored into the PHP
17
+ engine and covered by 4 golden-corpus fixtures, so the post-process parity contract holds
18
+ in both engines. Reported in [#41](https://github.com/investblog/spintax-js/issues/41).
19
+
20
+ ### Changed
21
+
22
+ - Docs: a **Use Cases** section (cold email, notifications, chatbots, A/B copy, programmatic
23
+ SEO, spinning LLM-drafted templates locally) and the N-variants recipe — call `render` N
24
+ times with different seeds — with the caveat that distinct seeds are *independent draws, not
25
+ distinct results*, so a low-cardinality template will repeat. Batching stays a host concern.
26
+ - npm keywords broadened (`text-spinner`, `email-template`, `placeholders`, `variables`,
27
+ `conditionals`) to match how people actually search for this.
28
+
29
+ ## 0.1.3 — 2026-07-08
30
+
31
+ Precise diagnostic positions. Backward-compatible — `validate()` verdicts (pass/fail),
32
+ codes, and severities are unchanged (still not parity-gated per §3.1); only the
33
+ best-effort position fields are improved. No render/parse behavior change.
34
+
35
+ ### Added
36
+
37
+ - **`validate()` diagnostics now carry accurate `line`/`column` for every code**, not just
38
+ brackets. Previously `plural.*`, `permutation.*`, `set.malformed`, and `include.*` reported
39
+ a line with `column: 1`, and `variable.*` had no position at all (defaulted to `1:1`). All
40
+ now point at the offending token.
41
+ - **`endLine`/`endColumn`** are populated so a consumer can underline the exact span (e.g. the
42
+ whole `%name%` reference or `{plural …}` block).
43
+ - **Structured `data`** on diagnostics: `variable.undefined` → `{ name }`; `plural.arity` →
44
+ `{ expected, got }`; `permutation.*` → `{ key }` / `{ value }`; `bracket.*` → `{ bracket }` /
45
+ `{ open, close }`; `include.unknown-target` → `{ target }`. Lets a bot/editor build UI without
46
+ parsing the (non-parity-gated) `message`.
47
+ - `PluralBlock.end` (internal) — exclusive end offset, so validation can span the full block.
48
+
49
+ ### Notes
50
+
51
+ - `variable.undefined` still reports **once per unique name**, now anchored at its first
52
+ occurrence. Consumers that want to highlight every occurrence can expand via `data.name`.
53
+
6
54
  ## 0.1.2 — 2026-07-08
7
55
 
8
56
  Docs. No engine or API changes.
package/README.md CHANGED
@@ -1,7 +1,6 @@
1
1
  # @spintax/core
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@spintax/core.svg)](https://www.npmjs.com/package/@spintax/core)
4
- [![npm downloads](https://img.shields.io/npm/dm/@spintax/core.svg)](https://www.npmjs.com/package/@spintax/core)
5
4
  [![CI](https://github.com/investblog/spintax-js/actions/workflows/ci.yml/badge.svg)](https://github.com/investblog/spintax-js/actions/workflows/ci.yml)
6
5
  [![types](https://img.shields.io/npm/types/@spintax/core.svg)](https://www.npmjs.com/package/@spintax/core)
7
6
  [![license](https://img.shields.io/npm/l/@spintax/core.svg)](https://github.com/investblog/spintax-js/blob/main/LICENSE)
@@ -44,6 +43,33 @@ validate('{a|b'); // → [{ severity: 'error', code: 'bracket.unclosed'
44
43
  extract('%title% {?promo?Sale}'); // → { refs: ['title', 'promo'], sets: [], includes: [] }
45
44
  ```
46
45
 
46
+ ## Use cases
47
+
48
+ Anywhere one message has to go out many times without reading like a form letter:
49
+
50
+ - **Cold email & outreach** — one template, a distinct body per recipient, personalized through `context`.
51
+ - **SMS / push / notifications** — deterministic output in a few kilobytes, no API call per send.
52
+ - **Chatbots & agents** — vary canned replies so a bot doesn't repeat itself verbatim.
53
+ - **A/B and multivariate testing** — enumerate copy variants in the template instead of in application code.
54
+ - **Programmatic SEO / content generation** — thousands of pages from one authored source.
55
+ - **LLM output at scale** — have a model draft the template once, then spin it locally, forever, for free.
56
+
57
+ The core renders a **single** string per call — batching is a host concern. To emit N variants, call
58
+ `render` N times with different seeds; a seeded call is reproducible, so any variant can be
59
+ regenerated later from its seed alone:
60
+
61
+ ```ts
62
+ const template = '{Hi|Hello|Hey} %name%, {quick|short|small} question about {pricing|billing}';
63
+ const variants = [1, 2, 3].map((seed) => render(template, { context: { name: 'Ada' }, seed }));
64
+ // → [ 'Hello Ada, quick question about billing',
65
+ // 'Hey Ada, quick question about pricing',
66
+ // 'Hey Ada, quick question about pricing' ] ← same as seed 2; see the caveat below
67
+ ```
68
+
69
+ Distinct seeds are **independent draws, not distinct results** — like any sampling they can repeat,
70
+ and the fewer combinations a template has, the more often they will. If you need N *unique* variants,
71
+ dedupe in the host and cap the retries: a template may simply not have N combinations to give.
72
+
47
73
  ## Spintax syntax
48
74
 
49
75
  | Construct | Example | Meaning |
@@ -131,6 +157,7 @@ plural buckets, conditional truthiness, `#set` collapse, post-process output) is
131
157
  ## Links
132
158
 
133
159
  - 📦 npm — [`@spintax/core`](https://www.npmjs.com/package/@spintax/core)
160
+ - 🤖 Try it — [@spintaxnetbot](https://t.me/spintaxnetbot) (Telegram: validate + preview + AI `/draft`)
134
161
  - 🧩 Source — [investblog/spintax-js](https://github.com/investblog/spintax-js)
135
162
  - 🌐 Product — [spintax.net](https://spintax.net)
136
163
  - 🏠 Maintainer — [301.st](https://301.st)
package/dist/index.cjs CHANGED
@@ -330,7 +330,7 @@ function findPluralBlocks(text) {
330
330
  i = j + 1;
331
331
  continue;
332
332
  }
333
- blocks.push({ start, countSlot: inner.slice(0, colon), formsRaw: inner.slice(colon + 1) });
333
+ blocks.push({ start, end: j + 1, countSlot: inner.slice(0, colon), formsRaw: inner.slice(colon + 1) });
334
334
  i = j + 1;
335
335
  }
336
336
  return blocks;
@@ -381,11 +381,28 @@ function validateTemplate(src, opts = {}) {
381
381
  }
382
382
  return diagnostics;
383
383
  }
384
- function err(code, message, line = 1, column = 1) {
385
- return { severity: "error", code, message, line, column };
384
+ function err(code, message, pos) {
385
+ return { severity: "error", code, message, ...pos };
386
386
  }
387
- function warn(code, message, line = 1, column = 1) {
388
- return { severity: "warning", code, message, line, column };
387
+ function warn(code, message, pos) {
388
+ return { severity: "warning", code, message, ...pos };
389
+ }
390
+ function offsetToLineCol(text, offset) {
391
+ let line = 1;
392
+ let lineStart = 0;
393
+ const end = Math.min(Math.max(offset, 0), text.length);
394
+ for (let i = 0; i < end; i += 1) {
395
+ if (text.charAt(i) === "\n") {
396
+ line += 1;
397
+ lineStart = i + 1;
398
+ }
399
+ }
400
+ return { line, column: end - lineStart + 1 };
401
+ }
402
+ function span(text, offset, length) {
403
+ const s = offsetToLineCol(text, offset);
404
+ const e = offsetToLineCol(text, offset + length);
405
+ return { line: s.line, column: s.column, endLine: e.line, endColumn: e.column };
389
406
  }
390
407
  function checkBrackets(text, out) {
391
408
  const close = { "{": "}", "[": "]" };
@@ -403,15 +420,15 @@ function checkBrackets(text, out) {
403
420
  } else if (ch === "}" || ch === "]") {
404
421
  const top = stack.pop();
405
422
  if (top === void 0) {
406
- out.push(err("bracket.unexpected-closing", `Unexpected closing '${ch}'.`, line, column));
423
+ out.push(err("bracket.unexpected-closing", `Unexpected closing '${ch}'.`, { line, column, endLine: line, endColumn: column + 1, data: { bracket: ch } }));
407
424
  } else if (top.expect !== ch) {
408
- out.push(err("bracket.mismatched", `'${top.char}' closed by '${ch}'.`, line, column));
425
+ out.push(err("bracket.mismatched", `'${top.char}' closed by '${ch}'.`, { line, column, endLine: line, endColumn: column + 1, data: { open: top.char, close: ch } }));
409
426
  }
410
427
  }
411
428
  column += 1;
412
429
  }
413
430
  for (const unclosed of stack) {
414
- out.push(err("bracket.unclosed", `Unclosed '${unclosed.char}'.`, unclosed.line, unclosed.column));
431
+ out.push(err("bracket.unclosed", `Unclosed '${unclosed.char}'.`, { line: unclosed.line, column: unclosed.column, endLine: unclosed.line, endColumn: unclosed.column + 1, data: { bracket: unclosed.char } }));
415
432
  }
416
433
  }
417
434
  function checkSetDirectives(text, out) {
@@ -420,7 +437,9 @@ function checkSetDirectives(text, out) {
420
437
  const trimmed = lineText.replace(/^[ \t]+/, "");
421
438
  if (!trimmed.startsWith("#set ") && !trimmed.startsWith("#set ")) return;
422
439
  if (!/^#set\s+%(\w+)%\s*=\s*(.+)$/u.test(trimmed)) {
423
- out.push(err("set.malformed", "Malformed #set. Expected: #set %name% = value", idx + 1));
440
+ const column = lineText.length - trimmed.length + 1;
441
+ const line = idx + 1;
442
+ out.push(err("set.malformed", "Malformed #set. Expected: #set %name% = value", { line, column, endLine: line, endColumn: lineText.length + 1 }));
424
443
  }
425
444
  });
426
445
  }
@@ -428,20 +447,32 @@ function checkPermutationConfigs(text, out) {
428
447
  for (const m of text.matchAll(/\[<([^>]*?)>/gu)) {
429
448
  const configStr = m[1] ?? "";
430
449
  if (!/\w+\s*=/.test(configStr)) continue;
431
- const line = countLines(text, m.index ?? 0);
450
+ const configBase = (m.index ?? 0) + 2;
432
451
  for (const km of configStr.matchAll(/(\w+)\s*=/gu)) {
433
452
  const key = (km[1] ?? "").toLowerCase();
434
453
  if (!KNOWN_CONFIG_KEYS.has(key)) {
435
- out.push(err("permutation.unknown-key", `Unknown permutation config key: '${km[1]}'.`, line));
454
+ out.push(err(
455
+ "permutation.unknown-key",
456
+ `Unknown permutation config key: '${km[1]}'.`,
457
+ { ...span(text, configBase + (km.index ?? 0), (km[1] ?? "").length), data: { key: km[1] } }
458
+ ));
436
459
  }
437
460
  }
438
461
  const min = /minsize\s*=\s*([^;>\s]+)/i.exec(configStr);
439
462
  if (min && !/^\d+$/.test(min[1] ?? "")) {
440
- out.push(err("permutation.minsize-not-integer", `minsize must be a positive integer, got '${min[1]}'.`, line));
463
+ out.push(err(
464
+ "permutation.minsize-not-integer",
465
+ `minsize must be a positive integer, got '${min[1]}'.`,
466
+ { ...span(text, configBase + min.index, min[0].length), data: { value: min[1] } }
467
+ ));
441
468
  }
442
469
  const max = /maxsize\s*=\s*([^;>\s]+)/i.exec(configStr);
443
470
  if (max && !/^\d+$/.test(max[1] ?? "")) {
444
- out.push(err("permutation.maxsize-not-integer", `maxsize must be a positive integer, got '${max[1]}'.`, line));
471
+ out.push(err(
472
+ "permutation.maxsize-not-integer",
473
+ `maxsize must be a positive integer, got '${max[1]}'.`,
474
+ { ...span(text, configBase + max.index, max[0].length), data: { value: max[1] } }
475
+ ));
445
476
  }
446
477
  }
447
478
  }
@@ -449,15 +480,19 @@ function checkPlurals(text, locale, out) {
449
480
  const base = locale && locale !== "" ? normalizeBaseLang(locale) : "";
450
481
  const arity = base !== "" ? pluralArity(base) : 0;
451
482
  for (const block of findPluralBlocks(text)) {
452
- const line = countLines(text, block.start);
483
+ const at = span(text, block.start, block.end - block.start);
453
484
  if (/[{}[\]]/.test(block.formsRaw)) {
454
- out.push(err("plural.nested-brackets", "{plural ...}: forms must not contain nested spintax brackets.", line));
485
+ out.push(err("plural.nested-brackets", "{plural ...}: forms must not contain nested spintax brackets.", at));
455
486
  continue;
456
487
  }
457
488
  if (arity > 0) {
458
489
  const count = block.formsRaw.split("|").length;
459
490
  if (count !== arity) {
460
- out.push(err("plural.arity", `{plural ...}: expected ${arity} forms, got ${count}.`, line));
491
+ out.push(err(
492
+ "plural.arity",
493
+ `{plural ...}: expected ${arity} forms, got ${count}.`,
494
+ { ...at, data: { expected: arity, got: count } }
495
+ ));
461
496
  }
462
497
  }
463
498
  }
@@ -465,37 +500,49 @@ function checkPlurals(text, locale, out) {
465
500
  function checkVariableReferences(text, known, out) {
466
501
  const knownSet = new Set((known ?? []).map((n) => n.toLowerCase()));
467
502
  const defs = /* @__PURE__ */ new Map();
503
+ const defPos = /* @__PURE__ */ new Map();
468
504
  for (const m of text.matchAll(/^[ \t]*#set[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)$/gmu)) {
469
- defs.set((m[1] ?? "").toLowerCase(), m[2] ?? "");
505
+ const name = (m[1] ?? "").toLowerCase();
506
+ defs.set(name, m[2] ?? "");
507
+ const nameOffset = (m.index ?? 0) + m[0].indexOf("%");
508
+ defPos.set(name, span(text, nameOffset, name.length + 2));
470
509
  }
510
+ const somewhere = { line: 1, column: 1 };
471
511
  for (const [name, value] of defs) {
472
512
  if (value.toLowerCase().includes(`%${name}%`)) {
473
- out.push(err("variable.self-reference", `Variable '${name}' references itself.`));
513
+ out.push(err("variable.self-reference", `Variable '${name}' references itself.`, defPos.get(name) ?? somewhere));
474
514
  }
475
515
  }
476
516
  for (const name of defs.keys()) {
477
- detectCycle(name, defs, [name], out);
517
+ detectCycle(name, defs, [name], defPos.get(name) ?? somewhere, out);
478
518
  }
479
- const body = text.replace(/^[ \t]*#set[ \t]+%\w+%[ \t]*=[ \t]*.*?$/gmu, "");
480
- const refs = /* @__PURE__ */ new Set();
481
- for (const m of body.matchAll(/%(\w+)%/gu)) refs.add((m[1] ?? "").toLowerCase());
482
- for (const m of body.matchAll(/\{\?!?([A-Za-z_]\w*)\?/gu)) refs.add((m[1] ?? "").toLowerCase());
483
- for (const ref of refs) {
484
- if (!defs.has(ref) && !knownSet.has(ref)) {
485
- out.push(warn("variable.undefined", `Variable '${ref}' is not defined \u2014 may be a runtime variable.`));
486
- }
519
+ const body = text.replace(/^[ \t]*#set[ \t]+%\w+%[ \t]*=[ \t]*.*?$/gmu, (m) => m.replace(/[^\n]/g, " "));
520
+ const seen = /* @__PURE__ */ new Set();
521
+ const undefinedAt = (name, offset, length) => {
522
+ const key = name.toLowerCase();
523
+ if (defs.has(key) || knownSet.has(key) || seen.has(key)) return;
524
+ seen.add(key);
525
+ out.push(warn(
526
+ "variable.undefined",
527
+ `Variable '${name}' is not defined \u2014 may be a runtime variable.`,
528
+ { ...span(text, offset, length), data: { name } }
529
+ ));
530
+ };
531
+ for (const m of body.matchAll(/%(\w+)%/gu)) undefinedAt(m[1] ?? "", m.index ?? 0, m[0].length);
532
+ for (const m of body.matchAll(/\{\?!?([A-Za-z_]\w*)\?/gu)) {
533
+ undefinedAt(m[1] ?? "", (m.index ?? 0) + m[0].indexOf(m[1] ?? ""), (m[1] ?? "").length);
487
534
  }
488
535
  }
489
- function detectCycle(current, defs, visited, out) {
536
+ function detectCycle(current, defs, visited, rootPos, out) {
490
537
  const value = defs.get(current) ?? "";
491
538
  for (const m of value.matchAll(/%(\w+)%/gu)) {
492
539
  const ref = (m[1] ?? "").toLowerCase();
493
540
  if (ref === current) continue;
494
541
  if (visited.includes(ref)) {
495
- out.push(err("variable.circular-reference", `Circular variable reference: ${[...visited, ref].join(" \u2192 ")}.`));
542
+ out.push(err("variable.circular-reference", `Circular variable reference: ${[...visited, ref].join(" \u2192 ")}.`, rootPos));
496
543
  return;
497
544
  }
498
- if (defs.has(ref)) detectCycle(ref, defs, [...visited, ref], out);
545
+ if (defs.has(ref)) detectCycle(ref, defs, [...visited, ref], rootPos, out);
499
546
  }
500
547
  }
501
548
  function checkIncludeTargets(text, known, out) {
@@ -504,19 +551,15 @@ function checkIncludeTargets(text, known, out) {
504
551
  for (const m of text.matchAll(INCLUDE_RE)) {
505
552
  const ref = m[1] ?? "";
506
553
  if (!set.has(ref)) {
507
- out.push(
508
- err("include.unknown-target", `#include target '${ref}' does not match any known template.`, countLines(text, m.index ?? 0))
509
- );
554
+ const refOffset = (m.index ?? 0) + m[0].indexOf('"') + 1;
555
+ out.push(err(
556
+ "include.unknown-target",
557
+ `#include target '${ref}' does not match any known template.`,
558
+ { ...span(text, refOffset, ref.length), data: { target: ref } }
559
+ ));
510
560
  }
511
561
  }
512
562
  }
513
- function countLines(text, offset) {
514
- let n = 1;
515
- for (let i = 0; i < offset && i < text.length; i += 1) {
516
- if (text.charAt(i) === "\n") n += 1;
517
- }
518
- return n;
519
- }
520
563
 
521
564
  // src/internal/extract.ts
522
565
  var SET_DEF_RE = /^[ \t]*#set[ \t]+%(\w+)%[ \t]*=/gmu;
@@ -863,6 +906,7 @@ var WS = " \\t\\r\\n\\f\\x0B";
863
906
  var S = `[${WS}]`;
864
907
  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})";
865
908
  var URL_RE = new RegExp(`(?:https?|ftp):\\/\\/[^${WS}<>"')\\]]+`, "giu");
909
+ var MAILTEL_RE = new RegExp(`(?:mailto|tel):[^${WS}<>"')\\]]+`, "giu");
866
910
  var EMAIL_RE = new RegExp(`[a-z0-9._%+\\-]+@${DOMAIN_PART}\\b`, "giu");
867
911
  var DOMAIN_RE = new RegExp(`\\b${DOMAIN_PART}\\b`, "giu");
868
912
  var DECIMAL_RE = /\b\d+\.\d+\b/gu;
@@ -897,6 +941,7 @@ function postProcess(input) {
897
941
  };
898
942
  let text = input;
899
943
  text = text.replace(URL_RE, (m) => storeWithTrailingPunct(m, "URL"));
944
+ text = text.replace(MAILTEL_RE, (m) => storeWithTrailingPunct(m, "URI"));
900
945
  text = text.replace(EMAIL_RE, (m) => store(m, "EMAIL"));
901
946
  text = text.replace(DOMAIN_RE, (m) => store(m, "DOM"));
902
947
  text = text.replace(DECIMAL_RE, (m) => store(m, "NUM"));