@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 +25 -0
- package/README.md +1 -1
- package/dist/index.cjs +83 -40
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +83 -40
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
[](https://www.npmjs.com/package/@spintax/core)
|
|
4
|
-
[](https://www.npmjs.com/package/@spintax/core)
|
|
5
4
|
[](https://github.com/investblog/spintax-js/actions/workflows/ci.yml)
|
|
6
5
|
[](https://www.npmjs.com/package/@spintax/core)
|
|
7
6
|
[](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,
|
|
385
|
-
return { severity: "error", code, message,
|
|
384
|
+
function err(code, message, pos) {
|
|
385
|
+
return { severity: "error", code, message, ...pos };
|
|
386
386
|
}
|
|
387
|
-
function warn(code, message,
|
|
388
|
-
return { severity: "warning", code, message,
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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.",
|
|
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(
|
|
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
|
-
|
|
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
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
508
|
-
|
|
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;
|