turva-llms-txt-validator 0.2.1 → 0.3.1

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 +5 -5
  2. package/package.json +1 -1
  3. package/src/index.mjs +112 -18
package/README.md CHANGED
@@ -28,10 +28,10 @@ The target has to be a public domain name: an IP literal, a bracketed IPv6 addre
28
28
  |---|-------|------|------|
29
29
  | 1 | File exists at /llms.txt, HTTP 200 (a same-site www/apex redirect is followed) | non-200, or an off-site or unsafe redirect | |
30
30
  | 2 | Response is plain text | body looks like HTML | content-type is not text/plain or text/markdown |
31
- | 3 | Starts with an H1 title | first non-empty line is not a markdown H1 | |
31
+ | 3 | Starts with an H1 title | first non-empty line is not a markdown H1, indentation included, since four spaces make it a code block | |
32
32
  | 4 | Blockquote summary after the title | | missing one-line summary |
33
- | 5 | H2 sections group the content | | no H2 sections |
34
- | 6 | Markdown links an agent can follow | | no links, or relative links |
33
+ | 5 | H2 sections group the content | | no H2 sections, or no section carrying a markdown file list |
34
+ | 6 | Markdown links an agent can follow | | no links, an empty link name, a scheme without a host, or relative links |
35
35
  | 7 | Small enough to be cheap to read | | over 50 KB, or read truncated at 256 KB |
36
36
  | 8 | No HTML markup in the file | | HTML tags found |
37
37
 
@@ -39,8 +39,8 @@ Two more checks read the site's home page for the link relations v2 of the forma
39
39
 
40
40
  | # | Check | pass | info |
41
41
  |---|-------|------|------|
42
- | 9 | Home page points to its llms.txt (v2) | `rel="describedby"` found in the head or the Link header | not found, or the home page could not be read |
43
- | 10 | Home page points to a markdown version (v2) | `rel="alternate" type="text/markdown"` found in the head or the Link header | not found, or the home page could not be read |
42
+ | 9 | Home page points to its llms.txt (v2) | `rel="describedby"` with a target, found in the head or the Link header | not found, no target on the relation, or the home page could not be read |
43
+ | 10 | Home page points to a markdown version (v2) | `rel="alternate"` with the media type `text/markdown` and a target, found in the head or the Link header | not found, no target on the relation, a different media type, or the home page could not be read |
44
44
 
45
45
  ## Node API
46
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turva-llms-txt-validator",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Validate a site's llms.txt structure: eight checks as pass/warn/fail plus two llms.txt v2 discovery checks as information, JSON output. Same checks as the hosted validator at turva.dev/llms-txt-validator.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
package/src/index.mjs CHANGED
@@ -103,6 +103,68 @@ export async function fetchLlmsTxt(host, opts = {}) {
103
103
  }
104
104
  }
105
105
 
106
+ // Every markdown link in the file, scanned once from left to right instead of collected with
107
+ // matchAll(/\[([^\][]*)\]\(([^)\s]{1,2048})\)/g). That bound meant a target longer than 2048
108
+ // characters was not counted as a link at all, and dropping the bound from the pattern would
109
+ // make it quadratic on a file that repeats "[a](" (Erik 2026-08-29). The scan carries no bound
110
+ // and no backtracking: every character is read once and the furthest failed target scan is
111
+ // remembered. matchAll resumes after a whole match, and so does this.
112
+ function collectLinks(text) {
113
+ const out = [];
114
+ let failEnd = -1;
115
+ for (let i = 0; i < text.length; i++) {
116
+ if (text[i] !== "[") continue;
117
+ let j = i + 1;
118
+ while (j < text.length && text[j] !== "]" && text[j] !== "[") j++;
119
+ if (j >= text.length) break;
120
+ if (text[j] === "[") { i = j - 1; continue; }
121
+ if (text[j + 1] !== "(") { i = j; continue; }
122
+ const k = j + 2;
123
+ if (k <= failEnd) { i = j + 1; continue; }
124
+ let e = k;
125
+ while (e < text.length && text[e] !== ")" && !/\s/.test(text[e])) e++;
126
+ if (e > k && text[e] === ")") { out.push({ name: text.slice(i + 1, j), target: text.slice(k, e) }); i = e; continue; }
127
+ if (text[e] !== ")") failEnd = e;
128
+ i = j + 1;
129
+ }
130
+ return out;
131
+ }
132
+
133
+ // A markdown list item that carries a link, scanned once from left to right instead of
134
+ // matched with /^ {0,3}[-*+] .*\[[^\][]*\]\([^)\s]+\)/. That pattern is quadratic on a line
135
+ // such as "- " followed by "[a](" repeated, because every candidate rescans the target to the
136
+ // end of the line, and the line comes from the audited site (CodeQL js/polynomial-redos,
137
+ // 2026-08-29). Bounding the quantifier would trade the speed bug for a silent accuracy bug,
138
+ // so the scan is by index: every character is read once and the furthest failed target scan
139
+ // is remembered.
140
+ function listItemHasLink(l) {
141
+ const m = /^ {0,3}[-*+] /.exec(l);
142
+ if (!m) return false;
143
+ const isSep = (c) => c === "\r" || c === "\n" || c === "\u2028" || c === "\u2029";
144
+ let failEnd = -1;
145
+ for (let i = m[0].length; i < l.length; i++) {
146
+ // A "." in the old pattern never crosses a line terminator, and split(/\r?\n/) leaves
147
+ // a bare CR, U+2028 and U+2029 inside a line, so a link behind one was not a match then
148
+ // and is not one now.
149
+ if (isSep(l[i])) return false;
150
+ if (l[i] !== "[") continue;
151
+ let j = i + 1, sep = false;
152
+ while (j < l.length && l[j] !== "]" && l[j] !== "[") { if (isSep(l[j])) sep = true; j++; }
153
+ if (j >= l.length) return false;
154
+ if (l[j] === "[") { if (sep) return false; i = j - 1; continue; }
155
+ if (l[j + 1] !== "(") { if (sep) return false; i = j; continue; }
156
+ const k = j + 2;
157
+ if (k <= failEnd) { if (sep) return false; i = j + 1; continue; }
158
+ let e = k;
159
+ while (e < l.length && l[e] !== ")" && !/\s/.test(l[e])) e++;
160
+ if (e > k && l[e] === ")") return true;
161
+ if (sep) return false;
162
+ if (l[e] !== ")") failEnd = e;
163
+ i = j + 1;
164
+ }
165
+ return false;
166
+ }
167
+
106
168
  function redirectFailDetail(f) {
107
169
  if (f.reason === "off-host") return "redirects to " + f.location + ", a different host; llms.txt is host-scoped, so validate that host directly";
108
170
  if (f.reason === "unsafe-target") return "redirects to an unsupported target (" + f.location + "); only https redirects to the same site are followed";
@@ -135,8 +197,12 @@ export function validateLlmsTxt(f) {
135
197
  }
136
198
  const lines = f.text.split(/\r?\n/);
137
199
  const firstIdx = lines.findIndex((l) => l.trim() !== "");
138
- const first = firstIdx === -1 ? "" : lines[firstIdx].trim();
139
- if (/^# \S/.test(first)) {
200
+ const firstRaw = firstIdx === -1 ? "" : lines[firstIdx];
201
+ const first = firstRaw.trim();
202
+ // The line is read as markdown and not trimmed first. Four spaces or a tab make it an
203
+ // indented code block rather than a heading, and trimming erased that difference, so
204
+ // " # Site" passed as the H1 until 2026-08-29. CommonMark allows three spaces.
205
+ if (/^ {0,3}# \S/.test(firstRaw)) {
140
206
  add("h1-title", "pass", "Starts with an H1 title", JSON.stringify(first.slice(0, 80)));
141
207
  } else {
142
208
  add("h1-title", "fail", "Starts with an H1 title", "the first non-empty line should be a markdown H1 (# Site name)");
@@ -148,19 +214,40 @@ export function validateLlmsTxt(f) {
148
214
  add("summary", "warn", "Blockquote summary after the title", "recommended by the format (> one-line summary), not required");
149
215
  }
150
216
  const h2Count = (f.text.match(/^## /gm) || []).length;
151
- if (h2Count > 0) {
152
- add("sections", "pass", "H2 sections group the content", h2Count + " section" + (h2Count === 1 ? "" : "s"));
217
+ // A section counts when it carries a file list. An H2 followed by a paragraph satisfied
218
+ // this check until 2026-08-29, and the format puts each section's links in a list.
219
+ let sectionsWithList = 0;
220
+ {
221
+ let inSection = false, counted = false;
222
+ for (const l of lines) {
223
+ if (/^## /.test(l)) { inSection = true; counted = false; continue; }
224
+ if (/^# /.test(l)) { inSection = false; continue; }
225
+ if (inSection && !counted && listItemHasLink(l)) { sectionsWithList++; counted = true; }
226
+ }
227
+ }
228
+ if (h2Count > 0 && sectionsWithList > 0) {
229
+ add("sections", "pass", "H2 sections group the content", h2Count + " section" + (h2Count === 1 ? "" : "s") + ", " + sectionsWithList + " carrying a file list");
230
+ } else if (h2Count > 0) {
231
+ add("sections", "warn", "H2 sections group the content", h2Count + " section" + (h2Count === 1 ? "" : "s") + " but no file list under any of them; the format puts a section's links in a markdown list");
153
232
  } else {
154
233
  add("sections", "warn", "H2 sections group the content", "no H2 sections found; sections are the convention for grouping links");
155
234
  }
156
- const links = [...f.text.matchAll(/\[([^\][]*)\]\(([^)\s]{1,2048})\)/g)];
157
- const absolute = links.filter((m) => /^https?:\/\//.test(m[2])).length;
235
+ const links = collectLinks(f.text);
236
+ // An entry an agent can use has a name and a target with a host. An empty name and a
237
+ // bare "https://" both counted as valid absolute links until 2026-08-29.
238
+ const named = links.filter((m) => m.name.trim() !== "");
239
+ const unnamed = links.length - named.length;
240
+ const absolute = named.filter((m) => /^https?:\/\/[^/\s?#]+/.test(m.target)).length;
158
241
  if (links.length === 0) {
159
242
  add("links", "warn", "Markdown links an agent can follow", "no markdown links found");
160
- } else if (absolute === links.length) {
161
- add("links", "pass", "Markdown links an agent can follow", links.length + " link" + (links.length === 1 ? "" : "s") + ", all absolute URLs");
243
+ } else if (unnamed > 0) {
244
+ add("links", "warn", "Markdown links an agent can follow", links.length + " link" + (links.length === 1 ? "" : "s") + ", " + unnamed + " with an empty link name; an entry needs a name an agent can show");
245
+ } else if (absolute === named.length) {
246
+ add("links", "pass", "Markdown links an agent can follow", named.length + " link" + (named.length === 1 ? "" : "s") + ", all absolute URLs");
162
247
  } else {
163
- add("links", "warn", "Markdown links an agent can follow", links.length + " links, " + (links.length - absolute) + " relative; absolute URLs travel better when the file is read out of context");
248
+ const relativeCount = named.filter((m) => !/^[a-z][a-z0-9+.-]*:/i.test(m.target)).length;
249
+ const hostless = named.length - absolute - relativeCount;
250
+ add("links", "warn", "Markdown links an agent can follow", named.length + " links, " + relativeCount + " relative" + (hostless > 0 ? " and " + hostless + " with a scheme but no host" : "") + "; absolute URLs travel better when the file is read out of context");
164
251
  }
165
252
  if (f.truncated) {
166
253
  add("size", "warn", "Small enough to be cheap to read", "over 256 KB, read truncated");
@@ -414,20 +501,27 @@ export function findLinkRelations(html, linkHeader) {
414
501
  // NAME is "link<link", not as a link element, so \b would count a relation the site
415
502
  // does not publish. Measured against parse5, 2026-08-24.
416
503
  if (!/^<link(?=[\s/>])/i.test(tag)) continue;
417
- const rel = ((tag.match(/\brel\s*=\s*["']?([^"'>]+)/i) || [])[1] || "").toLowerCase().trim().split(/\s+/);
418
- const type = ((tag.match(/\btype\s*=\s*["']?([^"'>\s]+)/i) || [])[1] || "").toLowerCase();
419
- const href = ((tag.match(/\bhref\s*=\s*"([^"]*)"|\bhref\s*=\s*'([^']*)'|\bhref\s*=\s*([^\s"'>]+)/i) || []).slice(1).find((x) => x !== undefined) || "").trim();
420
- if (!found.describedby && rel.includes("describedby")) found.describedby = href || "(link element without href)";
421
- if (!found.markdown && rel.includes("alternate") && type.startsWith("text/markdown")) found.markdown = href || "(link element without href)";
504
+ // The attribute name has to start the token. \b sits between the hyphen and the name,
505
+ // so data-rel, data-type and data-href were read as the real attributes until
506
+ // 2026-08-29, and a page could claim a relation it does not publish.
507
+ const rel = ((tag.match(/(?:^|[\s/])rel\s*=\s*["']?([^"'>]+)/i) || [])[1] || "").toLowerCase().trim().split(/\s+/);
508
+ const type = ((tag.match(/(?:^|[\s/])type\s*=\s*["']?([^"'>\s]+)/i) || [])[1] || "").toLowerCase();
509
+ const href = ((tag.match(/(?:^|[\s/])href\s*=\s*"([^"]*)"|(?:^|[\s/])href\s*=\s*'([^']*)'|(?:^|[\s/])href\s*=\s*([^\s"'>]+)/i) || []).slice(1).find((x) => x !== undefined) || "").trim();
510
+ // text/markdown, not anything that starts with it, and a relation without a target is
511
+ // not a relation: both passed until 2026-08-29.
512
+ const isMarkdown = type.split(";")[0].trim() === "text/markdown";
513
+ if (!found.describedby && href && rel.includes("describedby")) found.describedby = href;
514
+ if (!found.markdown && href && rel.includes("alternate") && isMarkdown) found.markdown = href;
422
515
  }
423
516
  for (const part of String(linkHeader || "").split(/,(?=\s*<)/)) {
424
517
  const lt = part.indexOf("<");
425
518
  const gt = lt === -1 ? -1 : part.indexOf(">", lt + 1);
426
519
  const href = (gt === -1 ? "" : part.slice(lt + 1, gt)).trim();
427
- const rel = ((part.match(/rel\s*=\s*"?([^";,]+)"?/i) || [])[1] || "").toLowerCase().trim().split(/\s+/);
428
- const type = ((part.match(/type\s*=\s*"?([^";,]+)"?/i) || [])[1] || "").toLowerCase().trim();
429
- if (!found.describedby && rel.includes("describedby")) found.describedby = href || "(Link header without a target)";
430
- if (!found.markdown && rel.includes("alternate") && type.startsWith("text/markdown")) found.markdown = href || "(Link header without a target)";
520
+ const rel = ((part.match(/(?:^|[;\s])rel\s*=\s*"?([^";,]+)"?/i) || [])[1] || "").toLowerCase().trim().split(/\s+/);
521
+ const type = ((part.match(/(?:^|[;\s])type\s*=\s*"?([^";,]+)"?/i) || [])[1] || "").toLowerCase().trim();
522
+ const isMarkdownHeader = type.split(";")[0].trim() === "text/markdown";
523
+ if (!found.describedby && href && rel.includes("describedby")) found.describedby = href;
524
+ if (!found.markdown && href && rel.includes("alternate") && isMarkdownHeader) found.markdown = href;
431
525
  }
432
526
  return found;
433
527
  }