turva-llms-txt-validator 0.3.9 → 0.3.10

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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -2
  3. package/src/index.mjs +159 -49
package/README.md CHANGED
@@ -8,7 +8,7 @@ Check a website's `llms.txt` from the command line, Node or CI. Get a clear resu
8
8
 
9
9
  ## Quick start
10
10
 
11
- Requires **Node.js 18.17 or newer**. Run it without a global install:
11
+ Requires **Node.js 22 or newer**. Run it without a global install:
12
12
 
13
13
  ```sh
14
14
  npx --yes turva-llms-txt-validator example.com
@@ -110,7 +110,7 @@ for (const check of result.checks) {
110
110
 
111
111
  ## Use in CI
112
112
 
113
- Add this step to a GitHub Actions job that already has Node.js 18.17 or newer available. Replace `your-domain.com` with your domain:
113
+ Add this step to a GitHub Actions job that already has Node.js 22 or newer available. Replace `your-domain.com` with your domain:
114
114
 
115
115
  ```yaml
116
116
  - name: Validate llms.txt
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turva-llms-txt-validator",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "description": "Validate a site's llms.txt structure: eight checks as pass/warn/fail plus two llms.txt v2 discovery checks that read pass or info and never move the summary, JSON output. Same checks as the hosted validator at turva.dev/llms-txt-validator.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -19,7 +19,7 @@
19
19
  "test": "node --test"
20
20
  },
21
21
  "engines": {
22
- "node": ">=18.17"
22
+ "node": ">=22"
23
23
  },
24
24
  "keywords": [
25
25
  "llms.txt",
package/src/index.mjs CHANGED
@@ -39,7 +39,13 @@ export function cut(s, n) {
39
39
  export function maskLocation(href, base) {
40
40
  let u = null;
41
41
  try { u = new URL(href, base); } catch { u = null; }
42
- if (u) {
42
+ // An opaque URL (no authority, e.g. "u:name@host/") parses without throwing but has no
43
+ // host, so clearing u.username/u.password is a silent no-op: WHATWG has nowhere to put
44
+ // them and the credential-shaped text stays in the opaque path untouched. Route it through
45
+ // the string branch below instead, the same one an unparseable target already uses
46
+ // (outside review 2026-09-28: a bare "u:" scheme let the text after it through as the
47
+ // CLI JSON "target" field, unmasked).
48
+ if (u && u.host) {
43
49
  u.username = "";
44
50
  u.password = "";
45
51
  for (const key of Array.from(u.searchParams.keys())) u.searchParams.set(key, "***");
@@ -202,20 +208,25 @@ function isEscaped(s, i) {
202
208
  return n % 2 === 1;
203
209
  }
204
210
 
205
- // Inline code spans of one line, marked per character, so a link written inside backticks is
206
- // read as the code it is (outside audit 2026-09-26, Tek-496). A span opens with a run of
207
- // backticks and closes with the next run of the same length on the same line. An escaped first
208
- // backtick is literal and the rest of its run still opens, while a closing run is never escaped,
209
- // because a backslash inside a code span is literal. Runs are grouped by length and every group
210
- // is read with one forward pointer, so the scan stays linear on a line of unmatched runs.
211
- function codeSpanMask(line) {
212
- const mask = new Uint8Array(line.length);
211
+ // Inline code spans, marked per character across the whole document, so a link written inside
212
+ // backticks is read as the code it is (outside audit 2026-09-26, Tek-496), including when the
213
+ // span itself crosses a real line ending: a fenceless "`[a](b)\ncontinued`" used to be masked
214
+ // only on its own line, so the second line escaped the mask and its bracket read as a real link
215
+ // (outside review 2026-09-28). A span opens with a run of backticks and closes with the next run
216
+ // of the same length anywhere in the text; a pair whose interior holds a blank line (two line
217
+ // endings in a row) is not a code span in CommonMark, so it is left unmasked and its backticks
218
+ // stay literal. An escaped first backtick is literal and the rest of its run still opens, while a
219
+ // closing run is never escaped, because a backslash inside a code span is literal. Runs are
220
+ // grouped by length and every group is read with one forward pointer, so the scan stays linear on
221
+ // a document of unmatched runs.
222
+ function codeSpanMask(text) {
223
+ const mask = new Uint8Array(text.length);
213
224
  const runs = [];
214
- for (let i = 0; i < line.length; ) {
215
- if (line[i] !== "`") { i++; continue; }
225
+ for (let i = 0; i < text.length; ) {
226
+ if (text[i] !== "`") { i++; continue; }
216
227
  let j = i;
217
- while (j < line.length && line[j] === "`") j++;
218
- runs.push({ at: i, len: j - i, escaped: isEscaped(line, i) });
228
+ while (j < text.length && text[j] === "`") j++;
229
+ runs.push({ at: i, len: j - i, escaped: isEscaped(text, i) });
219
230
  i = j;
220
231
  }
221
232
  const byLen = new Map();
@@ -231,29 +242,61 @@ function codeSpanMask(line) {
231
242
  ptr.set(len, p);
232
243
  if (p === list.length) continue;
233
244
  const close = runs[list[p]];
234
- mask.fill(1, r.escaped ? r.at + 1 : r.at, close.at + close.len);
245
+ const start = r.escaped ? r.at + 1 : r.at;
246
+ const end = close.at + close.len;
247
+ if (text.slice(start, end).indexOf("\n\n") === -1) mask.fill(1, start, end);
235
248
  k = list[p];
236
249
  }
237
250
  return mask;
238
251
  }
239
252
 
253
+ // A CommonMark link reference label is matched without regard to case or the exact run of
254
+ // whitespace inside it, so "[Guide]" and "[ guide ]" name the same definition.
255
+ function normalizeLinkLabel(s) {
256
+ return s.trim().replace(/\s+/g, " ").toLowerCase();
257
+ }
258
+
259
+ // Link reference definitions, "[label]: target", read once per document so a reference link
260
+ // such as "[text][label]" can resolve against one written anywhere else in the same file,
261
+ // before or after its use, the way CommonMark allows (outside review 2026-09-28). A title
262
+ // after the target is not read, because nothing here compares it, and only the first
263
+ // definition of a given label counts, per spec. A destination wrapped in angle brackets has
264
+ // them stripped.
265
+ function collectLinkDefinitions(text) {
266
+ const defs = new Map();
267
+ for (const line of text.split("\n")) {
268
+ const m = /^ {0,3}\[([^\]]+)\]:[ \t]*(<[^<>\n]*>|\S+)/.exec(line);
269
+ if (!m) continue;
270
+ const label = normalizeLinkLabel(m[1]);
271
+ if (!label || defs.has(label)) continue;
272
+ let dest = m[2];
273
+ if (dest[0] === "<" && dest[dest.length - 1] === ">") dest = dest.slice(1, -1);
274
+ defs.set(label, dest);
275
+ }
276
+ return defs;
277
+ }
278
+
240
279
  // Every markdown link in the prose, scanned once from left to right. A "[" inside a code span,
241
280
  // an escaped "\[" and the "![" of an image open no link, because none of them is a hyperlink
242
281
  // (all three counted until the outside audit of 2026-09-26, Tek-496). A target may be written in angle
243
282
  // brackets and may carry a title in quotes, as CommonMark allows; both were missed or misread
244
- // before. The scan carries no bound and no backtracking: the furthest failed plain target is
245
- // remembered as before, and the closing ">", quote and line end are looked up through a cache
246
- // that only moves forward, so a file that repeats "[a](" or "[a](x \"" stays linear.
247
- function collectLinks(text) {
283
+ // before. An image nested in the link text, such as "[a ![i](x)](y)", is link content and is
284
+ // skipped whole rather than ending the scan, so the outer hyperlink is still found; any other
285
+ // nested "[" still ends the scan the way it always has, because CommonMark does not allow a
286
+ // link inside a link (outside review 2026-09-28: the previous scan gave up on the whole outer
287
+ // link the moment any nested "[" appeared, image or not). A full or collapsed reference link,
288
+ // "[text][label]" or "[text][]", resolves against collectLinkDefinitions; an unresolved label
289
+ // is read as plain brackets, the way it always was. A resolved target is decoded for character
290
+ // references ("&#58;", "&amp;", ...) the same way an HTML attribute value is, so a destination
291
+ // written as "https://host&#58;99999/" is checked as what it resolves to and not as its literal
292
+ // source spelling (outside review 2026-09-28). The scan carries no bound and no backtracking:
293
+ // the furthest failed plain target is remembered as before, and the closing ">", quote and line
294
+ // end are looked up through a cache that only moves forward, so a file that repeats "[a](" or
295
+ // "[a](x \"" stays linear.
296
+ function collectLinks(text, defs) {
248
297
  const out = [];
249
- const code = new Uint8Array(text.length);
250
- {
251
- let at = 0;
252
- for (const line of text.split("\n")) {
253
- code.set(codeSpanMask(line), at);
254
- at += line.length + 1;
255
- }
256
- }
298
+ const code = codeSpanMask(text);
299
+ if (!defs) defs = collectLinkDefinitions(text);
257
300
  const cache = Object.create(null);
258
301
  const nextIdx = (c, from) => {
259
302
  const h = cache[c];
@@ -304,17 +347,53 @@ function collectLinks(text) {
304
347
  if (!found) failEnd = e;
305
348
  return found;
306
349
  };
350
+ // An image nested in the link text is skipped whole: k is the index of its own "[", right
351
+ // after an unescaped "!". Its alt text is not itself scanned for nested links or images, the
352
+ // first unescaped "]" closes it, and it is only skipped when a target follows; a malformed
353
+ // image (no closing "]", no "(target)") is left for the caller to treat as an ordinary
354
+ // nested bracket, the way one always was.
355
+ const skipImage = (k) => {
356
+ let m = k + 1;
357
+ while (m < text.length && text[m] !== "]" && text[m] !== "[") m += text[m] === "\\" ? 2 : 1;
358
+ if (m >= text.length || text[m] !== "]" || text[m + 1] !== "(") return null;
359
+ const t = readTarget(m + 2);
360
+ return t ? t.end + 1 : null;
361
+ };
307
362
  for (let i = 0; i < text.length; i++) {
308
363
  if (text[i] !== "[" || code[i] || isEscaped(text, i)) continue;
309
364
  if (i > 0 && text[i - 1] === "!" && !isEscaped(text, i - 1)) continue;
310
365
  let j = i + 1;
311
- while (j < text.length && text[j] !== "]" && text[j] !== "[") j += text[j] === "\\" ? 2 : 1;
366
+ for (;;) {
367
+ while (j < text.length && text[j] !== "]" && text[j] !== "[") j += text[j] === "\\" ? 2 : 1;
368
+ if (j >= text.length || text[j] !== "[") break;
369
+ if (code[j] || text[j - 1] !== "!" || isEscaped(text, j - 1)) break;
370
+ const after = skipImage(j);
371
+ if (after === null) break;
372
+ j = after;
373
+ }
312
374
  if (j >= text.length) break;
313
375
  if (text[j] === "[") { i = j - 1; continue; }
314
- if (text[j + 1] !== "(") { i = j; continue; }
315
- const t = readTarget(j + 2);
316
- if (t) { out.push({ name: text.slice(i + 1, j), target: t.target }); i = t.end; continue; }
317
- i = j + 1;
376
+ if (text[j + 1] === "(") {
377
+ const t = readTarget(j + 2);
378
+ if (t) { out.push({ name: text.slice(i + 1, j), target: decodeAttributeValue(t.target), at: i }); i = t.end; continue; }
379
+ i = j + 1;
380
+ continue;
381
+ }
382
+ if (text[j + 1] === "[") {
383
+ let m = j + 2;
384
+ while (m < text.length && text[m] !== "]" && text[m] !== "\n") m += text[m] === "\\" ? 2 : 1;
385
+ if (m < text.length && text[m] === "]") {
386
+ const rawLabel = text.slice(j + 2, m);
387
+ const label = normalizeLinkLabel(rawLabel === "" ? text.slice(i + 1, j) : rawLabel);
388
+ const dest = label && defs.get(label);
389
+ if (dest !== undefined) {
390
+ out.push({ name: text.slice(i + 1, j), target: decodeAttributeValue(dest), at: i });
391
+ i = m;
392
+ continue;
393
+ }
394
+ }
395
+ }
396
+ i = j;
318
397
  }
319
398
  return out;
320
399
  }
@@ -356,16 +435,12 @@ function fenceMask(lines) {
356
435
  }
357
436
  // A markdown list item that carries a link. A bullet or an ordered marker (1. or 1)) followed by
358
437
  // a space or a tab opens the item; an ordered list and a tab after the marker were not read as
359
- // list items until 2026-09-27 (Tek-496). The link is found by collectLinks, so code, escapes
360
- // and images count here exactly as they do in the links check. A bare CR, U+2028 and U+2029 stay
361
- // inside a line after split(/\r?\n/), and a link behind one has never counted, so the item is read
362
- // up to the first of them.
363
- function listItemHasLink(l) {
364
- const m = /^ {0,3}(?:[-*+]|\d{1,9}[.)])[ \t]/.exec(l);
365
- if (!m) return false;
366
- const sep = l.slice(m[0].length).search(/[\r\u2028\u2029]/);
367
- const body = sep === -1 ? l.slice(m[0].length) : l.slice(m[0].length, m[0].length + sep);
368
- return collectLinks(body).length > 0;
438
+ // list items until 2026-09-27 (Tek-496). Whether the line carries a link is decided by the
439
+ // caller against the whole document's own collectLinks result (hasLink), not by re-scanning
440
+ // this one line in isolation: a code span or a reference link target can resolve only across
441
+ // more than one line, and a per-line rescan cannot see that (outside review 2026-09-28, V2).
442
+ function listItemHasLink(l, hasLink) {
443
+ return !!hasLink && /^ {0,3}(?:[-*+]|\d{1,9}[.)])[ \t]/.test(l);
369
444
  }
370
445
 
371
446
  // The text of an ATX H1, or null when the line is not one. One to three leading spaces, a
@@ -423,8 +498,14 @@ export function validateLlmsTxt(f) {
423
498
  // v3.164.0): a query value or a fragment in a same-host Location used to reach this
424
499
  // detail as sent.
425
500
  add("http-status", "pass", "File exists at /llms.txt", f.redirectedFrom ? "HTTP 200, followed a redirect from " + maskLocation(f.redirectedFrom) + " to " + maskLocation(f.finalUrl) : "HTTP 200");
501
+ // A leading byte-order mark is stripped the same way the fetch path already strips it: the
502
+ // TextDecoder used there removes a BOM by default, so the direct API took a different
503
+ // document than the fetched one for the exact same bytes until this was measured (outside
504
+ // review 2026-09-28); the h1-title check saw the mark as part of the first line and failed a
505
+ // document that would pass when fetched.
506
+ const text = f.text.charCodeAt(0) === 0xFEFF ? f.text.slice(1) : f.text;
426
507
  const ct = (f.contentType || "").toLowerCase();
427
- const looksHtml = /^\s*(<!doctype|<html|<head|<body)/i.test(f.text);
508
+ const looksHtml = /^\s*(<!doctype|<html|<head|<body)/i.test(text);
428
509
  if (looksHtml) {
429
510
  add("content-type", "fail", "Response is plain text", "the body looks like an HTML page, not an llms.txt file");
430
511
  return checks;
@@ -437,7 +518,7 @@ export function validateLlmsTxt(f) {
437
518
  } else {
438
519
  add("content-type", "warn", "Response is plain text", "content-type is " + (ct.split(";")[0] || "missing") + ", text/plain or text/markdown is the convention");
439
520
  }
440
- const lines = f.text.split(/\r?\n/);
521
+ const lines = text.split(/\r?\n/);
441
522
  const firstIdx = lines.findIndex((l) => l.trim() !== "");
442
523
  const firstRaw = firstIdx === -1 ? "" : lines[firstIdx];
443
524
  const first = firstRaw.trim();
@@ -458,7 +539,12 @@ export function validateLlmsTxt(f) {
458
539
  // leading spaces, per CommonMark. A trim-first prefix test used to erase the difference, so
459
540
  // a four-space or tab-indented code block right after the title passed as the summary until
460
541
  // 2026-09-27 (Tek-496, mirrored from worker.js).
461
- if (/^ {0,3}> /.test(afterH1)) {
542
+ // A blockquote marker is 0-3 leading spaces plus ">", optionally followed by one space that
543
+ // is then part of the marker and not the content; CommonMark reads ">Summary" and a tab the
544
+ // same way, and four or more leading spaces stay indented code, not a blockquote (outside
545
+ // review 2026-09-28: a literal space was required, so a tab or no space at all warned as
546
+ // missing).
547
+ if (/^ {0,3}>/.test(afterH1)) {
462
548
  add("summary", "pass", "Blockquote summary after the title", JSON.stringify(cut(afterH1.trim(), 80)));
463
549
  } else {
464
550
  // A blockquote further down, before the first H2, is a summary in the wrong place and not a
@@ -470,7 +556,7 @@ export function validateLlmsTxt(f) {
470
556
  for (let i = firstIdx + 1; i < lines.length; i++) {
471
557
  if (fence[i]) continue;
472
558
  if (/^ {0,3}## /.test(lines[i])) break;
473
- if (/^ {0,3}> /.test(lines[i])) { late = i; break; }
559
+ if (/^ {0,3}>/.test(lines[i])) { late = i; break; }
474
560
  }
475
561
  add("summary", "warn", "Blockquote summary after the title", late === -1
476
562
  ? "recommended by the format (> one-line summary), not required"
@@ -481,6 +567,29 @@ export function validateLlmsTxt(f) {
481
567
  // demanded column zero, so a correct file indented by one to three spaces was reported as
482
568
  // having no sections at all while its list under the same indentation counted fine.
483
569
  const fenced = fenceMask(lines);
570
+ // Computed once for both the section-list scan below and the links check further down, so a
571
+ // reference link definition written anywhere in the prose resolves the same way for each
572
+ // (outside review 2026-09-28, V2): "[text][label]" used to be recognized in the whole-document
573
+ // links check but not in the per-line section scan, which only ever saw one list item line.
574
+ const proseText = lines.filter((l, i) => !fenced[i]).join("\n");
575
+ const linkDefs = collectLinkDefinitions(proseText);
576
+ const proseLinks = collectLinks(proseText, linkDefs);
577
+ // Which original line each recognized link starts on, so the section scan below can ask "does
578
+ // this line carry a link" against the same whole-document result the links check itself uses,
579
+ // instead of re-finding links one isolated line at a time (outside review 2026-09-28, V2).
580
+ const linkedLineIdx = new Set();
581
+ {
582
+ const owners = [];
583
+ for (let i = 0; i < lines.length; i++) if (!fenced[i]) owners.push(i);
584
+ const starts = [];
585
+ let offset = 0;
586
+ for (const pl of proseText.split("\n")) { starts.push(offset); offset += pl.length + 1; }
587
+ for (const link of proseLinks) {
588
+ let idx = 0;
589
+ for (let k = 0; k < starts.length; k++) { if (starts[k] <= link.at) idx = k; else break; }
590
+ linkedLineIdx.add(owners[idx]);
591
+ }
592
+ }
484
593
  const h2Count = lines.filter((l, i) => !fenced[i] && /^ {0,3}## /.test(l)).length;
485
594
  // A section counts when it carries a file list. An H2 followed by a paragraph satisfied
486
595
  // this check until 2026-08-29, and the format puts each section's links in a list.
@@ -497,7 +606,7 @@ export function validateLlmsTxt(f) {
497
606
  const h1 = /^ {0,3}#(?:[ \t]|$)/.test(l);
498
607
  if (misplaced === -1 && i > firstIdx && (h1 || (!seenH2 && /^ {0,3}#{3,6}(?:[ \t]|$)/.test(l)))) misplaced = i;
499
608
  if (h1) { inSection = false; continue; }
500
- if (inSection && !counted && listItemHasLink(l)) { sectionsWithList++; counted = true; }
609
+ if (inSection && !counted && listItemHasLink(l, linkedLineIdx.has(i))) { sectionsWithList++; counted = true; }
501
610
  }
502
611
  }
503
612
  if (h2Count > 0 && sectionsWithList > 0) {
@@ -513,8 +622,9 @@ export function validateLlmsTxt(f) {
513
622
  s.detail += "; the heading at line " + (misplaced + 1) + " is out of place, because the format has one H1 and no other heading before the first H2";
514
623
  }
515
624
  // Links are collected from the prose only, for the same reason the headings are: a link
516
- // shown inside a code fence is an example of a link, not one an agent can follow.
517
- const links = collectLinks(lines.filter((l, i) => !fenced[i]).join("\n"));
625
+ // shown inside a code fence is an example of a link, not one an agent can follow. Computed
626
+ // once above as proseLinks, reused here under its usual name.
627
+ const links = proseLinks;
518
628
  // An entry an agent can use has a name and a target with a host. An empty name and a
519
629
  // bare "https://" both counted as valid absolute links until 2026-08-29.
520
630
  const named = links.filter((m) => m.name.trim() !== "");
@@ -538,7 +648,7 @@ export function validateLlmsTxt(f) {
538
648
  } else {
539
649
  add("size", "warn", "Small enough to be cheap to read", f.bytes + " bytes; consider moving detail to llms-full.txt");
540
650
  }
541
- if (/<[a-z][a-z0-9-]*[\s>]/i.test(f.text)) {
651
+ if (/<[a-z][a-z0-9-]*[\s>]/i.test(text)) {
542
652
  add("no-html", "warn", "No HTML markup in the file", "HTML tags found; llms.txt should be plain markdown");
543
653
  } else {
544
654
  add("no-html", "pass", "No HTML markup in the file", "plain markdown");