@spintax/core 0.3.2 → 0.3.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,7 +3,68 @@
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.3.2 — 2026-07-23
6
+ ## 0.3.4 — 2026-08-08
7
+
8
+ Docs-only release — no engine changes; every byte of `dist/` behavior is 0.3.3's.
9
+
10
+ ### Changed
11
+
12
+ - **README catches up with the ecosystem.** The parity paragraph now states the real contract —
13
+ one syntax surface across five independent engines (npm / Packagist / PyPI / Object Pascal /
14
+ the WordPress plugin) held by the shared 234-fixture corpus — instead of the original
15
+ two-engine wording. New "Using it without writing code" section and refreshed links:
16
+ the [`n8n-nodes-spintax`](https://www.npmjs.com/package/n8n-nodes-spintax) community node
17
+ (released 2026-08-07), the spintax.net playground, the Telegram bot, and Spintax Studio.
18
+ Internal spec-section references dropped from public copy.
19
+ - **`package.json`**: description mentions the n8n node; `n8n` added to keywords.
20
+
21
+ ## 0.3.3 — 2026-08-07
22
+
23
+ `validate()` scales. No output changes anywhere: a 464-document differential over every
24
+ definition-graph, diagnostic-position and fuzz shape is byte-identical before and after, and
25
+ three deliberate mutations (drop the prune, shift a column, freeze the line counter) each
26
+ turn it red — the harness can see what it guards.
27
+
28
+ ### Fixed
29
+
30
+ - **`validate()` is no longer super-linear on definition graphs — and no longer hangs.** Four
31
+ independent costs, each measured before and after (Node 22, same machine):
32
+
33
+ | shape | 0.3.2 | 0.3.3 |
34
+ |---|---|---|
35
+ | chain of 1600 `#set`s | 13.1 s | 18 ms |
36
+ | converging diamond, 20 levels (~1 KB) | 4.8 s | <1 ms |
37
+ | converging diamond, 26+ levels | **never returns** | <1 ms (2000 levels: 22 ms) |
38
+ | one cycle of 1600 | 55.6 s | 0.8 s |
39
+ | 6400 duplicate definitions | 9.3 s | 29 ms |
40
+ | 6400 undefined references | 2.9 s | 11 ms |
41
+
42
+ The circular-reference walk re-parsed every value's references at every visit, tested the
43
+ path with an array `includes`, copied the path at every step, and restarted from every
44
+ definition with no memory of silent subtrees — on a converging diamond that re-explored
45
+ shared subgraphs exponentially, so a one-kilobyte template could pin a CPU forever (the
46
+ validator is exposed by hosts; that is a denial-of-service shape, not just a slow one).
47
+ References are now parsed once, the path is a Set plus a shared push/pop array, the walk is
48
+ iterative (a chain as deep as the document cannot overflow the call stack), and one colour
49
+ walk computes up front which names can reach a cycle — a subtree that cannot reach one
50
+ cannot report, so skipping it is output-neutral by construction. Emission order, count and
51
+ messages are exactly the old walk's, duplicated edges and all.
52
+
53
+ The taint sweep behind `plural.count-macro` was a fixpoint that re-read every macro once per
54
+ newly tainted name; it is now a reverse-edge worklist computing the same closure. Diagnostic
55
+ positions were each computed by scanning from offset 0; a line-start index built once per
56
+ call now answers them by binary search. And `extractDirectives` counted the line of each
57
+ occurrence from the start of the text — quadratic on directive-heavy documents, and on the
58
+ **render** path too, since the renderer extracts directives the same way; a resuming counter
59
+ fixes both surfaces.
60
+
61
+ Two boundaries stay as they are, deliberately. A giant single cycle still costs seconds at
62
+ 6400 definitions because the output itself is quadratic — every diagnostic's message carries
63
+ the full cycle path, so ~300 MB of message text is the answer, not the overhead. And a
64
+ converging diamond that FEEDS a cycle still emits one diagnostic per path — exponentially
65
+ many — because that is the reference emission the corpus family just aligned on
66
+ (spintax-win, 2026-08-07); whether that emission should be bounded is a family question, not
67
+ a patch.
7
68
 
8
69
  Post-process robustness and scaling: `postProcess()` no longer emits its own U+0000 sentinel, and
9
70
  the placeholder restore is linear instead of quadratic. Output is unchanged on ordinary text; the
package/README.md CHANGED
@@ -14,16 +14,20 @@ parse, render, validate, extract, analyze, and neutralize spintax templates.
14
14
 
15
15
  - **Zero runtime dependencies.** Runs unchanged on Cloudflare Workers, Node 18+, and in the browser.
16
16
  - **ESM-first, dual CJS.** Ships `.d.ts` types for both.
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`/`#def` semantics,
21
- post-process), with no divergence.
17
+ - **One syntax contract, [five independent engines](https://spintax.net/spintax-engines/).**
18
+ This package, [`spintax/core`](https://packagist.org/packages/spintax/core) (PHP),
19
+ [`spintax-core`](https://pypi.org/project/spintax-core/) (Python), an Object Pascal engine, and
20
+ the [Spintax WordPress plugin](https://wordpress.org/plugins/spintax/) are all held to the same
21
+ behavior contract by a **shared golden corpus of 234 fixtures** — deterministic verdicts, plural
22
+ buckets, conditionals, `#set`/`#def` semantics and post-processing agree everywhere, so a
23
+ template is an asset you can move between runtimes, not a lock-in.
22
24
  - **MIT** licensed.
23
25
 
24
26
  > **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
+ > neutralize — with the public API proven by real consumers: a reference Cloudflare Worker, the
28
+ > [@spintaxnetbot](https://t.me/spintaxnetbot) Telegram bot, the
29
+ > [spintax.net playground](https://spintax.net/play/), and the
30
+ > [n8n community node](https://www.npmjs.com/package/n8n-nodes-spintax).
27
31
 
28
32
  ## Install
29
33
 
@@ -149,6 +153,19 @@ render('%bio%', { context: { bio: neutralize('Save {50|60}% today') }, postProce
149
153
  // → "Save {50|60}% today" (the braces stay literal, not a random pick)
150
154
  ```
151
155
 
156
+ ## Using it without writing code
157
+
158
+ - **n8n** — install [`n8n-nodes-spintax`](https://www.npmjs.com/package/n8n-nodes-spintax)
159
+ (Settings → Community Nodes). One node, five operations: Render, Render Many, a two-output
160
+ Validate, and the LLM authoring/repair prompt builders — a Sheets-to-outbox personalization
161
+ flow or a full AI authoring loop with no code and no credentials on the node's side.
162
+ - **Telegram** — [@spintaxnetbot](https://t.me/spintaxnetbot): validate, preview, and AI-draft
163
+ templates in chat.
164
+ - **Browser** — the [spintax.net playground](https://spintax.net/play/): live validation and
165
+ N-variant rendering, entirely client-side (it runs this very package).
166
+ - **Windows** — [Spintax Studio](https://apps.microsoft.com/detail/9mw3ch7b530p), a native
167
+ editor in the Microsoft Store (renders via the family's Object Pascal engine; same corpus).
168
+
152
169
  ## Determinism & RNG
153
170
 
154
171
  With a `seed`, `render` is reproducible within this engine. Cross-engine RNG-sequence parity
@@ -157,10 +174,15 @@ plural buckets, conditional truthiness, `#set`/`#def` semantics, post-process ou
157
174
 
158
175
  ## Links
159
176
 
160
- - 📦 npm — [`@spintax/core`](https://www.npmjs.com/package/@spintax/core)
161
- - 🤖 Try it — [@spintaxnetbot](https://t.me/spintaxnetbot) (Telegram: validate + preview + AI `/draft`)
177
+ - 📦 npm — [`@spintax/core`](https://www.npmjs.com/package/@spintax/core) ·
178
+ [`n8n-nodes-spintax`](https://www.npmjs.com/package/n8n-nodes-spintax) (the n8n node)
179
+ - 🧪 Try it — [playground](https://spintax.net/play/) ·
180
+ [@spintaxnetbot](https://t.me/spintaxnetbot) (Telegram: validate + preview + AI `/draft`)
181
+ - 🖥️ Desktop — [Spintax Studio](https://apps.microsoft.com/detail/9mw3ch7b530p) (Microsoft Store)
182
+ - 🧬 The family — [all five engines, one corpus](https://spintax.net/spintax-engines/)
183
+ (PHP · Python · Pascal · WordPress)
162
184
  - 🧩 Source — [investblog/spintax-js](https://github.com/investblog/spintax-js)
163
- - 🌐 Product — [spintax.net](https://spintax.net)
185
+ - 🌐 Docs & guides — [spintax.net](https://spintax.net)
164
186
  - 🏠 Maintainer — [301.st](https://301.st)
165
187
 
166
188
  ## License
package/dist/index.cjs CHANGED
@@ -61,6 +61,15 @@ function extractDirectives(text) {
61
61
  const setDefs = {};
62
62
  const defDefs = {};
63
63
  const occurrences = [];
64
+ let cursorOffset = 0;
65
+ let cursorLine = 1;
66
+ const lineAt = (offset) => {
67
+ for (let i = cursorOffset; i < offset; i += 1) {
68
+ if (text.charCodeAt(i) === 10) cursorLine += 1;
69
+ }
70
+ cursorOffset = offset;
71
+ return cursorLine;
72
+ };
64
73
  DIRECTIVE_RE.lastIndex = 0;
65
74
  const stripped = text.replace(
66
75
  DIRECTIVE_RE,
@@ -70,7 +79,7 @@ function extractDirectives(text) {
70
79
  kind,
71
80
  name,
72
81
  value,
73
- line: countLines(text, offset)
82
+ line: lineAt(offset)
74
83
  });
75
84
  if (kind === "def") defDefs[name] = value;
76
85
  else setDefs[name] = value;
@@ -79,11 +88,6 @@ function extractDirectives(text) {
79
88
  );
80
89
  return { body: stripped.replace(/\n{3,}/gu, "\n\n"), setDefs, defDefs, occurrences };
81
90
  }
82
- function countLines(text, offset) {
83
- let line = 1;
84
- for (let i = 0; i < offset; i += 1) if (text[i] === "\n") line += 1;
85
- return line;
86
- }
87
91
  function stripComments(text) {
88
92
  return text.replace(/\/#[\s\S]*?#\//g, "");
89
93
  }
@@ -420,13 +424,14 @@ var INCLUDE_RE = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
420
424
  function validateTemplate(src, opts = {}) {
421
425
  const diagnostics = [];
422
426
  const text = stripComments(src);
427
+ const idx = buildLineIndex(text);
423
428
  checkBrackets(text, diagnostics);
424
429
  checkDirectives(text, diagnostics);
425
- checkPermutationConfigs(text, diagnostics);
426
- checkPlurals(text, opts.locale, diagnostics);
427
- checkVariableReferences(text, opts.knownVariables, diagnostics);
430
+ checkPermutationConfigs(text, idx, diagnostics);
431
+ checkPlurals(text, idx, opts.locale, diagnostics);
432
+ checkVariableReferences(text, idx, opts.knownVariables, diagnostics);
428
433
  if (opts.knownIncludes && opts.knownIncludes.length > 0) {
429
- checkIncludeTargets(text, opts.knownIncludes, diagnostics);
434
+ checkIncludeTargets(text, idx, opts.knownIncludes, diagnostics);
430
435
  }
431
436
  return diagnostics;
432
437
  }
@@ -436,21 +441,27 @@ function err(code, message, pos) {
436
441
  function warn(code, message, pos) {
437
442
  return { severity: "warning", code, message, ...pos };
438
443
  }
439
- function offsetToLineCol(text, offset) {
440
- let line = 1;
441
- let lineStart = 0;
442
- const end = Math.min(Math.max(offset, 0), text.length);
443
- for (let i = 0; i < end; i += 1) {
444
- if (text.charAt(i) === "\n") {
445
- line += 1;
446
- lineStart = i + 1;
447
- }
444
+ function buildLineIndex(text) {
445
+ const starts = [0];
446
+ for (let i = 0; i < text.length; i += 1) {
447
+ if (text.charCodeAt(i) === 10) starts.push(i + 1);
448
+ }
449
+ return { starts, length: text.length };
450
+ }
451
+ function offsetToLineCol(idx, offset) {
452
+ const end = Math.min(Math.max(offset, 0), idx.length);
453
+ let lo = 0;
454
+ let hi = idx.starts.length - 1;
455
+ while (lo < hi) {
456
+ const mid = lo + hi + 1 >> 1;
457
+ if ((idx.starts[mid] ?? 0) <= end) lo = mid;
458
+ else hi = mid - 1;
448
459
  }
449
- return { line, column: end - lineStart + 1 };
460
+ return { line: lo + 1, column: end - (idx.starts[lo] ?? 0) + 1 };
450
461
  }
451
- function span(text, offset, length) {
452
- const s = offsetToLineCol(text, offset);
453
- const e = offsetToLineCol(text, offset + length);
462
+ function span(idx, offset, length) {
463
+ const s = offsetToLineCol(idx, offset);
464
+ const e = offsetToLineCol(idx, offset + length);
454
465
  return { line: s.line, column: s.column, endLine: e.line, endColumn: e.column };
455
466
  }
456
467
  function checkBrackets(text, out) {
@@ -518,7 +529,7 @@ function checkDirectives(text, out) {
518
529
  }
519
530
  }
520
531
  }
521
- function checkPermutationConfigs(text, out) {
532
+ function checkPermutationConfigs(text, idx, out) {
522
533
  for (const m of text.matchAll(/\[<([^>]*?)>/gu)) {
523
534
  const configStr = m[1] ?? "";
524
535
  if (!/\w+\s*=/.test(configStr)) continue;
@@ -529,7 +540,7 @@ function checkPermutationConfigs(text, out) {
529
540
  out.push(err(
530
541
  "permutation.unknown-key",
531
542
  `Unknown permutation config key: '${km[1]}'.`,
532
- { ...span(text, configBase + (km.index ?? 0), (km[1] ?? "").length), data: { key: km[1] } }
543
+ { ...span(idx, configBase + (km.index ?? 0), (km[1] ?? "").length), data: { key: km[1] } }
533
544
  ));
534
545
  }
535
546
  }
@@ -538,7 +549,7 @@ function checkPermutationConfigs(text, out) {
538
549
  out.push(err(
539
550
  "permutation.minsize-not-integer",
540
551
  `minsize must be a positive integer, got '${min[1]}'.`,
541
- { ...span(text, configBase + min.index, min[0].length), data: { value: min[1] } }
552
+ { ...span(idx, configBase + min.index, min[0].length), data: { value: min[1] } }
542
553
  ));
543
554
  }
544
555
  const max = /maxsize\s*=\s*([^;>\s]+)/i.exec(configStr);
@@ -546,17 +557,17 @@ function checkPermutationConfigs(text, out) {
546
557
  out.push(err(
547
558
  "permutation.maxsize-not-integer",
548
559
  `maxsize must be a positive integer, got '${max[1]}'.`,
549
- { ...span(text, configBase + max.index, max[0].length), data: { value: max[1] } }
560
+ { ...span(idx, configBase + max.index, max[0].length), data: { value: max[1] } }
550
561
  ));
551
562
  }
552
563
  }
553
564
  }
554
- function checkPlurals(text, locale, out) {
565
+ function checkPlurals(text, idx, locale, out) {
555
566
  const base = locale && locale !== "" ? normalizeBaseLang(locale) : "";
556
567
  const arity = base !== "" ? pluralArity(base) : 0;
557
568
  const tainted = macroTaintedNames(text);
558
569
  for (const block of findPluralBlocks(text)) {
559
- const at = span(text, block.start, block.end - block.start);
570
+ const at = span(idx, block.start, block.end - block.start);
560
571
  for (const m of block.countSlot.matchAll(/%(\w+)%/gu)) {
561
572
  const name = (m[1] ?? "").toLowerCase();
562
573
  if (!tainted.has(name)) continue;
@@ -586,25 +597,34 @@ var UNRESOLVED_AT_PLURAL_TIME = /\[|\{(?!\?)/u;
586
597
  function macroTaintedNames(text) {
587
598
  const macros = extractDirectives(text).setDefs;
588
599
  const tainted = /* @__PURE__ */ new Set();
600
+ const queue = [];
601
+ for (const [name, value] of Object.entries(macros)) {
602
+ if (UNRESOLVED_AT_PLURAL_TIME.test(value)) {
603
+ tainted.add(name);
604
+ queue.push(name);
605
+ }
606
+ }
607
+ const dependents = /* @__PURE__ */ new Map();
589
608
  for (const [name, value] of Object.entries(macros)) {
590
- if (UNRESOLVED_AT_PLURAL_TIME.test(value)) tainted.add(name);
609
+ for (const m of value.matchAll(/%(\w+)%/gu)) {
610
+ const ref = (m[1] ?? "").toLowerCase();
611
+ const list = dependents.get(ref);
612
+ if (list === void 0) dependents.set(ref, [name]);
613
+ else list.push(name);
614
+ }
591
615
  }
592
- let grew = true;
593
- while (grew) {
594
- grew = false;
595
- for (const [name, value] of Object.entries(macros)) {
596
- if (tainted.has(name)) continue;
597
- for (const m of value.matchAll(/%(\w+)%/gu)) {
598
- if (!tainted.has((m[1] ?? "").toLowerCase())) continue;
616
+ while (queue.length > 0) {
617
+ const source = queue.pop();
618
+ for (const name of dependents.get(source) ?? []) {
619
+ if (!tainted.has(name)) {
599
620
  tainted.add(name);
600
- grew = true;
601
- break;
621
+ queue.push(name);
602
622
  }
603
623
  }
604
624
  }
605
625
  return tainted;
606
626
  }
607
- function checkVariableReferences(text, known, out) {
627
+ function checkVariableReferences(text, idx, known, out) {
608
628
  const knownSet = new Set((known ?? []).map((n) => n.toLowerCase()));
609
629
  const defs = /* @__PURE__ */ new Map();
610
630
  const defPos = /* @__PURE__ */ new Map();
@@ -612,7 +632,7 @@ function checkVariableReferences(text, known, out) {
612
632
  const name = (m[1] ?? "").toLowerCase();
613
633
  defs.set(name, m[2] ?? "");
614
634
  const nameOffset = (m.index ?? 0) + m[0].indexOf("%");
615
- defPos.set(name, span(text, nameOffset, name.length + 2));
635
+ defPos.set(name, span(idx, nameOffset, name.length + 2));
616
636
  }
617
637
  const somewhere = { line: 1, column: 1 };
618
638
  for (const [name, value] of defs) {
@@ -620,8 +640,15 @@ function checkVariableReferences(text, known, out) {
620
640
  out.push(err("variable.self-reference", `Variable '${name}' references itself.`, defPos.get(name) ?? somewhere));
621
641
  }
622
642
  }
643
+ const refsOf = /* @__PURE__ */ new Map();
644
+ for (const [name, value] of defs) {
645
+ const refs = [];
646
+ for (const m of value.matchAll(/%(\w+)%/gu)) refs.push((m[1] ?? "").toLowerCase());
647
+ refsOf.set(name, refs);
648
+ }
649
+ const reachesCycle = namesThatReachACycle(defs, refsOf);
623
650
  for (const name of defs.keys()) {
624
- detectCycle(name, defs, [name], defPos.get(name) ?? somewhere, out);
651
+ detectCycle(name, defs, refsOf, reachesCycle, defPos.get(name) ?? somewhere, out);
625
652
  }
626
653
  const body = text.replace(/^[ \t]*#(?:set|def)[ \t]+%\w+%[ \t]*=[ \t]*.*?$/gmu, (m) => m.replace(/[^\n]/g, " "));
627
654
  const seen = /* @__PURE__ */ new Set();
@@ -632,7 +659,7 @@ function checkVariableReferences(text, known, out) {
632
659
  out.push(warn(
633
660
  "variable.undefined",
634
661
  `Variable '${name}' is not defined \u2014 may be a runtime variable.`,
635
- { ...span(text, offset, length), data: { name } }
662
+ { ...span(idx, offset, length), data: { name } }
636
663
  ));
637
664
  };
638
665
  for (const m of body.matchAll(/%(\w+)%/gu)) undefinedAt(m[1] ?? "", m.index ?? 0, m[0].length);
@@ -640,19 +667,70 @@ function checkVariableReferences(text, known, out) {
640
667
  undefinedAt(m[1] ?? "", (m.index ?? 0) + m[0].indexOf(m[1] ?? ""), (m[1] ?? "").length);
641
668
  }
642
669
  }
643
- function detectCycle(current, defs, visited, rootPos, out) {
644
- const value = defs.get(current) ?? "";
645
- for (const m of value.matchAll(/%(\w+)%/gu)) {
646
- const ref = (m[1] ?? "").toLowerCase();
647
- if (ref === current) continue;
648
- if (visited.includes(ref)) {
649
- out.push(err("variable.circular-reference", `Circular variable reference: ${[...visited, ref].join(" \u2192 ")}.`, rootPos));
650
- return;
670
+ function namesThatReachACycle(defs, refsOf) {
671
+ const GREY = 1;
672
+ const BLACK = 2;
673
+ const color = /* @__PURE__ */ new Map();
674
+ const reaches = /* @__PURE__ */ new Set();
675
+ for (const root of defs.keys()) {
676
+ if (color.has(root)) continue;
677
+ const stack = [{ name: root, refs: refsOf.get(root) ?? [], i: 0 }];
678
+ color.set(root, GREY);
679
+ while (stack.length > 0) {
680
+ const top = stack[stack.length - 1];
681
+ if (top.i < top.refs.length) {
682
+ const ref = top.refs[top.i];
683
+ top.i += 1;
684
+ if (ref === top.name || !defs.has(ref)) continue;
685
+ const c = color.get(ref);
686
+ if (c === GREY) reaches.add(top.name);
687
+ else if (c === BLACK) {
688
+ if (reaches.has(ref)) reaches.add(top.name);
689
+ } else {
690
+ stack.push({ name: ref, refs: refsOf.get(ref) ?? [], i: 0 });
691
+ color.set(ref, GREY);
692
+ }
693
+ } else {
694
+ stack.pop();
695
+ color.set(top.name, BLACK);
696
+ if (stack.length > 0 && reaches.has(top.name)) reaches.add(stack[stack.length - 1].name);
697
+ }
698
+ }
699
+ }
700
+ return reaches;
701
+ }
702
+ function detectCycle(root, defs, refsOf, reachesCycle, rootPos, out) {
703
+ if (!reachesCycle.has(root)) return;
704
+ const path = [root];
705
+ const onPath = /* @__PURE__ */ new Set([root]);
706
+ const stack = [{ name: root, refs: refsOf.get(root) ?? [], i: 0 }];
707
+ const leave = (frame) => {
708
+ stack.pop();
709
+ onPath.delete(frame.name);
710
+ path.pop();
711
+ };
712
+ while (stack.length > 0) {
713
+ const top = stack[stack.length - 1];
714
+ if (top.i >= top.refs.length) {
715
+ leave(top);
716
+ continue;
717
+ }
718
+ const ref = top.refs[top.i];
719
+ top.i += 1;
720
+ if (ref === top.name) continue;
721
+ if (onPath.has(ref)) {
722
+ out.push(err("variable.circular-reference", `Circular variable reference: ${[...path, ref].join(" \u2192 ")}.`, rootPos));
723
+ leave(top);
724
+ continue;
725
+ }
726
+ if (defs.has(ref) && reachesCycle.has(ref)) {
727
+ stack.push({ name: ref, refs: refsOf.get(ref) ?? [], i: 0 });
728
+ onPath.add(ref);
729
+ path.push(ref);
651
730
  }
652
- if (defs.has(ref)) detectCycle(ref, defs, [...visited, ref], rootPos, out);
653
731
  }
654
732
  }
655
- function checkIncludeTargets(text, known, out) {
733
+ function checkIncludeTargets(text, idx, known, out) {
656
734
  const set = new Set(known);
657
735
  INCLUDE_RE.lastIndex = 0;
658
736
  for (const m of text.matchAll(INCLUDE_RE)) {
@@ -662,7 +740,7 @@ function checkIncludeTargets(text, known, out) {
662
740
  out.push(err(
663
741
  "include.unknown-target",
664
742
  `#include target '${ref}' does not match any known template.`,
665
- { ...span(text, refOffset, ref.length), data: { target: ref } }
743
+ { ...span(idx, refOffset, ref.length), data: { target: ref } }
666
744
  ));
667
745
  }
668
746
  }