@spintax/core 0.1.1 → 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 +36 -0
- package/README.md +9 -8
- 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,42 @@
|
|
|
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
|
+
|
|
31
|
+
## 0.1.2 — 2026-07-08
|
|
32
|
+
|
|
33
|
+
Docs. No engine or API changes.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- **Cross-engine parity is now machine-verified.** The shared golden corpus was executed against
|
|
38
|
+
the actual PHP Spintax plugin engine (via the new `packages/conformance/php` runner) — 88 cases,
|
|
39
|
+
no divergence. Upgraded the README claim from "TS side; PHP execution pending" to parity-verified
|
|
40
|
+
against both engines.
|
|
41
|
+
|
|
6
42
|
## 0.1.1 — 2026-07-08
|
|
7
43
|
|
|
8
44
|
Docs + metadata. First release published from CI with **provenance** (npm Trusted Publishing).
|
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)
|
|
@@ -15,15 +14,16 @@ parse, render, validate, extract, analyze, and neutralize spintax templates.
|
|
|
15
14
|
|
|
16
15
|
- **Zero runtime dependencies.** Runs unchanged on Cloudflare Workers, Node 18+, and in the browser.
|
|
17
16
|
- **ESM-first, dual CJS.** Ships `.d.ts` types for both.
|
|
18
|
-
- **
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
- **Parity-verified against the [Spintax WordPress plugin](https://wordpress.org/plugins/spintax/).**
|
|
18
|
+
An *independent* TypeScript implementation (not a line-by-line port) held to the plugin's behavior
|
|
19
|
+
contract by a shared golden corpus — the **same** fixtures pass against both this engine and the
|
|
20
|
+
PHP plugin (deterministic verdicts, plural buckets, conditionals, `#set` collapse, post-process),
|
|
21
|
+
with no divergence.
|
|
22
22
|
- **MIT** licensed.
|
|
23
23
|
|
|
24
|
-
> **Status:
|
|
25
|
-
> golden corpus
|
|
26
|
-
> Cloudflare Worker (`examples/worker`).
|
|
24
|
+
> **Status: released & stable.** Feature-complete — parse / render / validate / extract / analyze /
|
|
25
|
+
> neutralize. The deterministic golden corpus passes against **both** this engine and the PHP
|
|
26
|
+
> plugin, and the §9.2 API is proven by a reference Cloudflare Worker (`examples/worker`).
|
|
27
27
|
|
|
28
28
|
## Install
|
|
29
29
|
|
|
@@ -130,6 +130,7 @@ plural buckets, conditional truthiness, `#set` collapse, post-process output) is
|
|
|
130
130
|
## Links
|
|
131
131
|
|
|
132
132
|
- 📦 npm — [`@spintax/core`](https://www.npmjs.com/package/@spintax/core)
|
|
133
|
+
- 🤖 Try it — [@spintaxnetbot](https://t.me/spintaxnetbot) (Telegram: validate + preview + AI `/draft`)
|
|
133
134
|
- 🧩 Source — [investblog/spintax-js](https://github.com/investblog/spintax-js)
|
|
134
135
|
- 🌐 Product — [spintax.net](https://spintax.net)
|
|
135
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;
|