@spintax/core 0.1.2 → 0.1.3

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,31 @@
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.3 — 2026-07-08
7
+
8
+ Precise diagnostic positions. Backward-compatible — `validate()` verdicts (pass/fail),
9
+ codes, and severities are unchanged (still not parity-gated per §3.1); only the
10
+ best-effort position fields are improved. No render/parse behavior change.
11
+
12
+ ### Added
13
+
14
+ - **`validate()` diagnostics now carry accurate `line`/`column` for every code**, not just
15
+ brackets. Previously `plural.*`, `permutation.*`, `set.malformed`, and `include.*` reported
16
+ a line with `column: 1`, and `variable.*` had no position at all (defaulted to `1:1`). All
17
+ now point at the offending token.
18
+ - **`endLine`/`endColumn`** are populated so a consumer can underline the exact span (e.g. the
19
+ whole `%name%` reference or `{plural …}` block).
20
+ - **Structured `data`** on diagnostics: `variable.undefined` → `{ name }`; `plural.arity` →
21
+ `{ expected, got }`; `permutation.*` → `{ key }` / `{ value }`; `bracket.*` → `{ bracket }` /
22
+ `{ open, close }`; `include.unknown-target` → `{ target }`. Lets a bot/editor build UI without
23
+ parsing the (non-parity-gated) `message`.
24
+ - `PluralBlock.end` (internal) — exclusive end offset, so validation can span the full block.
25
+
26
+ ### Notes
27
+
28
+ - `variable.undefined` still reports **once per unique name**, now anchored at its first
29
+ occurrence. Consumers that want to highlight every occurrence can expand via `data.name`.
30
+
6
31
  ## 0.1.2 — 2026-07-08
7
32
 
8
33
  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)
@@ -131,6 +130,7 @@ plural buckets, conditional truthiness, `#set` collapse, post-process output) is
131
130
  ## Links
132
131
 
133
132
  - 📦 npm — [`@spintax/core`](https://www.npmjs.com/package/@spintax/core)
133
+ - 🤖 Try it — [@spintaxnetbot](https://t.me/spintaxnetbot) (Telegram: validate + preview + AI `/draft`)
134
134
  - 🧩 Source — [investblog/spintax-js](https://github.com/investblog/spintax-js)
135
135
  - 🌐 Product — [spintax.net](https://spintax.net)
136
136
  - 🏠 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;