turva-llms-txt-validator 0.1.8 → 0.2.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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # turva-llms-txt-validator
2
2
 
3
- Validate a site's llms.txt structure from the command line, from Node or in CI. Eight checks, each reported as pass, warn or fail, with a one-line detail. No score on purpose: a structure check can say what is there and what is missing, and a number on top of eight checks would look like an agent-readiness score without measuring one.
3
+ Validate a site's llms.txt structure from the command line, from Node or in CI. Eight structure checks, each reported as pass, warn or fail with a one-line detail, and two v2 discovery checks reported as information. No score on purpose: a structure check can say what is there and what is missing, and a number on top of ten checks would look like an agent-readiness score without measuring one.
4
4
 
5
5
  This is the open-source form of the hosted validator at [turva.dev/llms-txt-validator](https://turva.dev/llms-txt-validator), which runs the same logic inside the open [turva.dev Cloudflare Worker](https://github.com/erekola/turva-worker). The hosted validator stays canonical: if the two ever disagree, the hosted one wins and this package gets the fix.
6
6
 
@@ -18,11 +18,11 @@ Or run it without installing:
18
18
  llms-txt-validate example.com --json
19
19
  llms-txt-validate example.com --strict
20
20
 
21
- Exit codes: 0 valid (warnings allowed), 1 not valid (with --strict, warnings also exit 1), 2 fetch or input error. Only the target site's /llms.txt is fetched over https, following a redirect only to the same host or its www/apex twin (an off-site or unsafe redirect fails the first check), the fetch times out after 8 seconds and the read is capped at 256 KB. Nothing is stored.
21
+ Exit codes: 0 valid (warnings allowed), 1 not valid (with --strict, warnings also exit 1), 2 fetch or input error. The two v2 checks are reported as information and move no exit code. Two documents are fetched from the target site over https, its /llms.txt and its home page, each following a redirect only to the same host or its www/apex twin (an off-site or unsafe redirect fails the first check), each fetch times out after 8 seconds and each read is capped at 256 KB. Nothing else is requested, the site is never crawled and nothing is stored.
22
22
 
23
23
  The target has to be a public domain name: an IP literal, a bracketed IPv6 address, localhost and the internal-use TLDs local, internal, home, lan, corp, test and invalid are all refused before any request goes out, and every redirect hop is checked by the same rule. Names are not resolved here, so a public name that points at a private address is stopped by the network the fetch runs on rather than by this code. On the hosted validator that network is the Cloudflare edge, which does not route to private address space.
24
24
 
25
- ## The eight checks
25
+ ## The checks
26
26
 
27
27
  | # | Check | fail | warn |
28
28
  |---|-------|------|------|
@@ -35,6 +35,13 @@ The target has to be a public domain name: an IP literal, a bracketed IPv6 addre
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
 
38
+ Two more checks read the site's home page for the link relations v2 of the format recommends. They describe the site rather than the file, so they carry the status `pass` when the relation is there and `info` when it is not, never `warn` and never `fail`, and they leave both the summary line and the `--strict` exit code alone. v2 is two weeks old at the time of writing, so warning about these would turn valid files into files with warnings for following the version of the format they were written against.
39
+
40
+ | # | Check | pass | info |
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 |
44
+
38
45
  ## Node API
39
46
 
40
47
  import { validateHost } from "turva-llms-txt-validator";
@@ -43,7 +50,7 @@ The target has to be a public domain name: an IP literal, a bracketed IPv6 addre
43
50
  console.log(result.summary);
44
51
  for (const c of result.checks) console.log(c.status, c.label, c.detail);
45
52
 
46
- The summary is one of valid, valid with warnings, or not valid. The result object is { target, summary, checks }, the same shape the hosted validator returns as JSON:
53
+ The summary is one of valid, valid with warnings, or not valid, and the two v2 checks never change it. The result object is { target, summary, checks }, the same shape the hosted validator returns as JSON:
47
54
 
48
55
  curl -H "Accept: application/json" "https://turva.dev/llms-txt-validator?url=example.com"
49
56
 
@@ -58,7 +65,7 @@ Woodpecker or any other runner: the same npx line works anywhere Node 18.17 or n
58
65
 
59
66
  ## Why these checks
60
67
 
61
- The llms.txt format is a plain text map of a site for AI agents: an H1 title, a one-line blockquote summary and H2 sections of markdown links. The checks test exactly that structure and nothing else. What the format is and why it matters is written out at [llms.txt explained](https://turva.dev/guides/llms-txt), and the free tools this package belongs to are collected at [turva.dev/tools](https://turva.dev/tools).
68
+ The llms.txt format is a plain text map of a site for AI agents: an H1 title, a one-line blockquote summary and H2 sections of markdown links. v2 of the proposal, published in August 2026, left that format alone and added two link relations so an agent can find a page's markdown version and its llms.txt without guessing. The first eight checks test the structure and nothing else, and the last two report those two relations. What the format is and why it matters is written out at [llms.txt explained](https://turva.dev/guides/llms-txt), and the free tools this package belongs to are collected at [turva.dev/tools](https://turva.dev/tools).
62
69
 
63
70
  ## License
64
71
 
package/bin/cli.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // CLI for turva-llms-txt-validator. Exit codes: 0 = valid (or valid with
3
3
  // warnings), 1 = not valid (or warnings with --strict), 2 = could not fetch
4
- // or bad input. Same checks and JSON shape as the hosted validator:
4
+ // or bad input. The two v2 discovery checks carry status "info" and move no
5
+ // exit code, by design. Same checks and JSON shape as the hosted validator:
5
6
  // curl -H "Accept: application/json" "https://turva.dev/llms-txt-validator?url=example.com"
6
7
  import { validateHost } from "../src/index.mjs";
7
8
 
@@ -16,7 +17,7 @@ if (!target || flags.has("--help")) {
16
17
  process.exit(target ? 0 : 2);
17
18
  }
18
19
 
19
- const mark = { pass: "ok ", warn: "warn", fail: "FAIL" };
20
+ const mark = { pass: "ok ", warn: "warn", fail: "FAIL", info: "info" };
20
21
  try {
21
22
  const result = await validateHost(target);
22
23
  if (flags.has("--json")) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "turva-llms-txt-validator",
3
- "version": "0.1.8",
4
- "description": "Validate a site's llms.txt structure: eight checks, pass/warn/fail, JSON output. Same checks as the hosted validator at turva.dev/llms-txt-validator.",
3
+ "version": "0.2.1",
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",
7
7
  "exports": "./src/index.mjs",
package/src/index.mjs CHANGED
@@ -35,11 +35,15 @@ export function isValidPublicHost(host) {
35
35
  return true;
36
36
  }
37
37
 
38
+ // opts.path and opts.accept were added for the v2 discovery checks, which need the
39
+ // site's home page as well as its llms.txt. Nothing else moved: the redirect budget,
40
+ // the same-host rule, the credential and port rejections and the 256 KB cap are the
41
+ // guards this function was measured against.
38
42
  export async function fetchLlmsTxt(host, opts = {}) {
39
43
  const timeoutMs = opts.timeoutMs ?? 8000;
40
44
  const cap = opts.cap ?? 262144;
41
45
  const reqApex = host.startsWith("www.") ? host.slice(4) : host;
42
- let url = "https://" + host + "/llms.txt";
46
+ let url = "https://" + host + (opts.path ?? "/llms.txt");
43
47
  let redirectedFrom = null;
44
48
  // One budget for the whole redirect chain, not one per hop. Mirrors the hosted validator
45
49
  // (turva.dev/llms-txt-validator), which is canonical: with five hops a per-hop signal made
@@ -49,7 +53,7 @@ export async function fetchLlmsTxt(host, opts = {}) {
49
53
  const res = await fetch(url, {
50
54
  redirect: "manual",
51
55
  signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())),
52
- headers: { "user-agent": opts.userAgent ?? UA, "accept": "text/plain, text/markdown;q=0.9, */*;q=0.1" }
56
+ headers: { "user-agent": opts.userAgent ?? UA, "accept": opts.accept ?? "text/plain, text/markdown;q=0.9, */*;q=0.1" }
53
57
  });
54
58
  if (res.status >= 300 && res.status < 400) {
55
59
  const loc = res.headers.get("location") || "";
@@ -89,6 +93,7 @@ export async function fetchLlmsTxt(host, opts = {}) {
89
93
  return {
90
94
  status: res.status,
91
95
  contentType: res.headers.get("content-type") || "",
96
+ linkHeader: res.headers.get("link") || "",
92
97
  text: new TextDecoder("utf-8").decode(buf),
93
98
  bytes,
94
99
  truncated,
@@ -172,6 +177,276 @@ export function validateLlmsTxt(f) {
172
177
  return checks;
173
178
  }
174
179
 
180
+ // A "<" starts a tag only when a letter, "!", "/" or "?" follows it. Anything else is
181
+ // text, and the NEXT "<" can still start a tag. Measured, not assumed: without this rule
182
+ // "<<style><link rel=describedby ...>" read the link as published while a real parser
183
+ // treats the first "<" as text, opens style, and publishes nothing.
184
+ function startsTag(c) {
185
+ return c !== undefined && (c === "!" || c === "/" || c === "?" || (c >= "a" && c <= "z"));
186
+ }
187
+
188
+ // The end of the tag that starts at lt: the first ">" that is NOT inside a quoted
189
+ // attribute value. A quote opens a value only right after "=", which is what the
190
+ // tokenizer does. indexOf(">") is wrong here: <link data-x="a>b" rel="describedby"> is
191
+ // one tag for a parser and two for indexOf, and the relation was lost.
192
+ function tagEnd(text, lt) {
193
+ let quote = "", afterEq = false;
194
+ for (let j = lt + 1; j < text.length; j++) {
195
+ const c = text[j];
196
+ if (quote) { if (c === quote) quote = ""; continue; }
197
+ if (c === ">") return j;
198
+ if (c === "=") { afterEq = true; continue; }
199
+ if (c === " " || c === "\t" || c === "\n" || c === "\r" || c === "\f") continue;
200
+ if (afterEq && (c === '"' || c === "'")) { quote = c; afterEq = false; continue; }
201
+ afterEq = false;
202
+ }
203
+ return -1;
204
+ }
205
+
206
+ // The head is where a real parser says it ends, not where the text "</head>" happens to
207
+ // appear. Erik's decision 2026-08-24: the two v2 checks describe what the page's HEAD
208
+ // points at, so a link element the parser moves into the body is not one of them. That is
209
+ // the strict reading, and it is what the HTML parsing spec's "in head" insertion mode
210
+ // does: whitespace, comments, doctype and the head-only elements keep the head open;
211
+ // the first text node, the first body-level element and </head>, </body>, </html> or
212
+ // </br> close it; any other end tag in the head is a parse error and is ignored.
213
+ var HEAD_ELEMENTS = ["base", "basefont", "bgsound", "link", "meta", "noframes", "script", "style", "template", "title", "noscript"];
214
+ // Once </head> has been seen the parser is in "after head", and that list is the one above
215
+ // WITHOUT noscript: a noscript there opens the body instead of staying in the head.
216
+ var AFTER_HEAD_ELEMENTS = ["base", "basefont", "bgsound", "link", "meta", "noframes", "script", "style", "template", "title"];
217
+ // Their content is not markup: script, style, title, noscript (a parser with scripting on
218
+ // reads it as raw text) and noframes are raw text or RCDATA, and template content is inert.
219
+ var HEAD_SKIPPED_CONTENT = ["script", "style", "title", "noscript", "noframes", "template"];
220
+
221
+ // The end of a raw text or RCDATA element: the first </name that is followed by optional
222
+ // whitespace, an optional "/" (a parser closes on </script/> too) and a ">". Returns the
223
+ // index after it, or -1 when the element never closes, which means the rest of the
224
+ // document is inside it.
225
+ function skipSpace(lower, j) {
226
+ while (j < lower.length && (lower[j] === " " || lower[j] === "\t" || lower[j] === "\n" || lower[j] === "\r" || lower[j] === "\f")) j++;
227
+ return j;
228
+ }
229
+
230
+ // True when the character ends a tag name: whitespace, "/", ">" or the end of the input.
231
+ function isTagBoundary(c) {
232
+ return c === undefined || c === ">" || c === "/" || c === " " || c === "\t" || c === "\n" || c === "\r" || c === "\f";
233
+ }
234
+
235
+ // The index just after "</name ... >", or -1 when that is not an end tag there.
236
+ function endTagAt(lower, name, at) {
237
+ if (!lower.startsWith("</" + name, at)) return -1;
238
+ let j = skipSpace(lower, at + name.length + 2);
239
+ if (lower[j] === "/") j = skipSpace(lower, j + 1);
240
+ return lower[j] === ">" ? j + 1 : -1;
241
+ }
242
+
243
+ function rawTextEnd(lower, name, from) {
244
+ for (let at = lower.indexOf("</" + name, from); at !== -1; at = lower.indexOf("</" + name, at + name.length + 2)) {
245
+ const end = endTagAt(lower, name, at);
246
+ if (end !== -1) return end;
247
+ }
248
+ return -1;
249
+ }
250
+
251
+ // script is not plain raw text: <!-- puts the tokenizer in the escaped state, a nested
252
+ // <script there puts it in the double escaped state, and in THAT state </script only ends
253
+ // the escape, not the element. Without this the legacy shape
254
+ // <script><!-- ... <script>...</script> ... --></script> ended early and the rest of the
255
+ // script was read as markup, which is the wrong direction. 4 of 240 000 fuzz inputs.
256
+ function scriptEnd(lower, from) {
257
+ let i = from, escaped = false, doubleEscaped = false;
258
+ while (i < lower.length) {
259
+ if (lower.startsWith("<!--", i)) { escaped = true; i += 4; continue; }
260
+ if (escaped && lower.startsWith("-->", i)) { escaped = false; doubleEscaped = false; i += 3; continue; }
261
+ if (escaped && !doubleEscaped && lower.startsWith("<script", i) && isTagBoundary(lower[i + 7])) { doubleEscaped = true; i += 7; continue; }
262
+ if (lower.startsWith("</script", i)) {
263
+ if (doubleEscaped) { doubleEscaped = false; i += 8; continue; }
264
+ const end = endTagAt(lower, "script", i);
265
+ if (end !== -1) return end;
266
+ i += 8;
267
+ continue;
268
+ }
269
+ const next = lower.indexOf("<", i + 1);
270
+ i = next === -1 ? lower.length : next;
271
+ }
272
+ return -1;
273
+ }
274
+
275
+ // The end of a nested template: templates count, so an inner </template> does not close
276
+ // an outer one. Returns the index after the closing tag, or -1 when it never closes.
277
+ function templateEnd(lower, from) {
278
+ let depth = 1;
279
+ // Both searches resume from their own previous hit. Restarting either one from a shared
280
+ // cursor is quadratic: "</templateX" repeated made every round scan to the end of the
281
+ // input again, and 1 MB of it measured 15 686 ms.
282
+ let open = lower.indexOf("<template", from);
283
+ let close = lower.indexOf("</template", from);
284
+ for (;;) {
285
+ if (close === -1) return -1;
286
+ if (open !== -1 && open < close) {
287
+ if (isTagBoundary(lower[open + 9])) depth++;
288
+ open = lower.indexOf("<template", open + 9);
289
+ continue;
290
+ }
291
+ let j = close + 10;
292
+ j = skipSpace(lower, j);
293
+ if (lower[j] === "/") j = skipSpace(lower, j + 1);
294
+ if (lower[j] !== ">") {
295
+ // Not an end tag: for the tokenizer the rest of the name runs to the next ">", and a
296
+ // "<" inside it is part of the name rather than a new tag.
297
+ const bogus = lower.indexOf(">", close + 10);
298
+ if (bogus === -1) return -1;
299
+ close = lower.indexOf("</template", bogus + 1);
300
+ if (open !== -1 && open < bogus) open = lower.indexOf("<template", bogus + 1);
301
+ continue;
302
+ }
303
+ depth--;
304
+ if (depth === 0) return j + 1;
305
+ close = lower.indexOf("</template", j + 1);
306
+ }
307
+ }
308
+
309
+ // One left to right scan by index that returns the head, with comments and the content of
310
+ // the raw text and template elements already removed. Why a scan and not a regex: a
311
+ // character class that reads a tag's attributes is quadratic on input the TARGET site
312
+ // controls (CodeQL js/polynomial-redos, alerts #4 and #5, 2026-08-24), and bounding the
313
+ // class trades that speed bug for a worse correctness bug, because an open tag longer than
314
+ // the bound stops being recognised and the element's own text is then read as markup. A
315
+ // scan has no bound and no backtracking, and it visits every character once.
316
+ //
317
+ // The shapes, all measured against a real HTML parser (parse5) rather than reasoned about.
318
+ // An unterminated <!-- comments out the rest of the document. <!--> and <!---> are EMPTY
319
+ // comments, not unterminated ones, and --!> ends a comment as well. A "<" that no letter,
320
+ // "!", "/" or "?" follows is text. A ">" inside a quoted attribute value does not end a
321
+ // tag. </script/> closes a raw text element as well as </script> does.
322
+ function headOfDocument(html) {
323
+ const text = String(html || "");
324
+ const lower = text.toLowerCase();
325
+ const out = [];
326
+ let i = 0, afterHead = false;
327
+ for (;;) {
328
+ const lt = lower.indexOf("<", i);
329
+ const gap = lt === -1 ? text.slice(i) : text.slice(i, lt);
330
+ if (gap.trim() !== "") break;
331
+ out.push(gap);
332
+ if (lt === -1) break;
333
+ if (lower.startsWith("<!--", lt)) {
334
+ if (lower.startsWith("<!-->", lt)) { i = lt + 5; continue; }
335
+ if (lower.startsWith("<!--->", lt)) { i = lt + 6; continue; }
336
+ const dashes = lower.indexOf("-->", lt + 4);
337
+ const bang = lower.indexOf("--!>", lt + 4);
338
+ if (dashes === -1 && bang === -1) break;
339
+ i = (bang === -1 || (dashes !== -1 && dashes <= bang)) ? dashes + 3 : bang + 4;
340
+ continue;
341
+ }
342
+ if (!startsTag(lower[lt + 1])) break;
343
+ const gt = tagEnd(text, lt);
344
+ if (gt === -1) break;
345
+ if (lower[lt + 1] === "!" || lower[lt + 1] === "?") { i = gt + 1; continue; }
346
+ const head14 = lower.slice(lt, Math.min(lt + 14, gt + 1));
347
+ const endTag = (/^<\/([a-z]+)/.exec(head14) || [])[1];
348
+ if (endTag) {
349
+ // </body>, </html> and </br> start the body. </head> does NOT end the search: after
350
+ // it a parser still puts base, link, meta, script, style, title and template into the
351
+ // HEAD element until real body content starts, and every other end tag in the head is
352
+ // a parse error that is ignored. Measured against parse5: without this, 2 660 of
353
+ // 200 000 fuzz inputs lost a relation the head really carries.
354
+ if (endTag === "body" || endTag === "html" || endTag === "br") break;
355
+ if (endTag === "head") afterHead = true;
356
+ i = gt + 1;
357
+ continue;
358
+ }
359
+ const name = (/^<([a-z]+)(?=[\s/>]|$)/.exec(head14) || [])[1];
360
+ if (!name) break;
361
+ if (name === "html" || name === "head") { i = gt + 1; continue; }
362
+ if (!(afterHead ? AFTER_HEAD_ELEMENTS : HEAD_ELEMENTS).includes(name)) break; // <body> and the first body level element
363
+ if (HEAD_SKIPPED_CONTENT.includes(name)) {
364
+ const end = name === "template" ? templateEnd(lower, gt + 1)
365
+ : name === "script" ? scriptEnd(lower, gt + 1)
366
+ : rawTextEnd(lower, name, gt + 1);
367
+ if (end === -1) break;
368
+ i = end;
369
+ continue;
370
+ }
371
+ out.push(text.slice(lt, gt + 1));
372
+ i = gt + 1;
373
+ }
374
+ return out.join("");
375
+ }
376
+
377
+ // Tags are found by index and not by /<link\b[^>]*>/g, on purpose. That regex is
378
+ // quadratic on input the TARGET site controls: every "<link" with no ">" after it makes
379
+ // the character class scan to the end of the document, and "<link" repeated 16 000 times
380
+ // measured 196 ms where this loop measures under 1 ms. CodeQL reports the same shape as
381
+ // js/polynomial-redos (alerts #4 and #5 on the package repo, 2026-08-24). This scan
382
+ // visits every character once: from each "<" it reads to the next ">" and then continues
383
+ // after it, which is what the regex meant to say.
384
+ function* htmlTags(text) {
385
+ const lower = text.toLowerCase();
386
+ let i = 0;
387
+ for (;;) {
388
+ const open = lower.indexOf("<", i);
389
+ if (open === -1) return;
390
+ if (!startsTag(lower[open + 1])) { i = open + 1; continue; }
391
+ const close = tagEnd(text, open);
392
+ if (close === -1) return;
393
+ yield text.slice(open, close + 1);
394
+ i = close + 1;
395
+ }
396
+ }
397
+
398
+ // v2 of the llms.txt proposal (August 2026) left the file format alone and added one
399
+ // thing: a page should say where its markdown version and its llms.txt are, using
400
+ // rel="alternate" type="text/markdown" and rel="describedby", as HTML link elements or
401
+ // as a Link response header. That is a property of the site, not of the file, so these
402
+ // two carry their own status, "info". They are never a warn and never a fail, the
403
+ // summary line is unchanged, and --strict keeps exiting on warnings only.
404
+ export function findLinkRelations(html, linkHeader) {
405
+ const found = { describedby: null, markdown: null };
406
+ // Only the head, and only what a parser would put there: a commented-out link element,
407
+ // one inside a script or a template, and one the parser moves into the body are all not
408
+ // what these two checks are about, and counting them would report a relation the site
409
+ // does not serve. See headOfDocument above for the rules shape by shape. The 64 KB bound
410
+ // is a cap on work, not a rule: a head longer than that is not a head.
411
+ const head = headOfDocument(html).slice(0, 65536);
412
+ for (const tag of htmlTags(head)) {
413
+ // The name has to END at "link": a real parser reads "<link<link" as ONE tag whose
414
+ // NAME is "link<link", not as a link element, so \b would count a relation the site
415
+ // does not publish. Measured against parse5, 2026-08-24.
416
+ 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)";
422
+ }
423
+ for (const part of String(linkHeader || "").split(/,(?=\s*<)/)) {
424
+ const lt = part.indexOf("<");
425
+ const gt = lt === -1 ? -1 : part.indexOf(">", lt + 1);
426
+ 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)";
431
+ }
432
+ return found;
433
+ }
434
+
435
+ export function validateV2Discovery(found, unreadReason) {
436
+ const checks = [];
437
+ const add = (id, status, label, detail) => checks.push({ id, status, label, detail });
438
+ if (!found) {
439
+ add("v2-describedby", "info", "Home page points to its llms.txt (v2)", unreadReason);
440
+ add("v2-markdown-alternate", "info", "Home page points to a markdown version (v2)", unreadReason);
441
+ return checks;
442
+ }
443
+ add("v2-describedby", found.describedby ? "pass" : "info", "Home page points to its llms.txt (v2)",
444
+ found.describedby ? 'rel="describedby" to ' + found.describedby.slice(0, 120) : 'no rel="describedby" in the head or the Link header; v2 recommends it so an agent finds the file without guessing');
445
+ add("v2-markdown-alternate", found.markdown ? "pass" : "info", "Home page points to a markdown version (v2)",
446
+ found.markdown ? 'rel="alternate" type="text/markdown" to ' + found.markdown.slice(0, 120) : 'no rel="alternate" type="text/markdown" in the head or the Link header; v2 recommends it so an agent finds the markdown form without guessing');
447
+ return checks;
448
+ }
449
+
175
450
  export function summarizeChecks(checks) {
176
451
  if (checks.some((c) => c.status === "fail")) return "not valid";
177
452
  if (checks.some((c) => c.status === "warn")) return "valid with warnings";
@@ -183,7 +458,21 @@ export async function validateHost(input, opts = {}) {
183
458
  if (!host || !isValidPublicHost(host)) {
184
459
  throw new Error("not a public domain name: " + String(input).slice(0, 120));
185
460
  }
186
- const fetched = await fetchLlmsTxt(host, opts);
187
- const checks = validateLlmsTxt(fetched);
461
+ // opts comes from the caller, and path and accept exist for this function's own
462
+ // second read. Forwarding them to the first read would let a caller point the
463
+ // "llms.txt" result at any path on the host, so both are pinned here.
464
+ const fetched = await fetchLlmsTxt(host, { ...opts, path: "/llms.txt", accept: undefined });
465
+ let discovery;
466
+ try {
467
+ const home = await fetchLlmsTxt(host, { ...opts, path: "/", accept: "text/html, */*;q=0.1" });
468
+ discovery = home.redirect
469
+ ? validateV2Discovery(null, "the home page redirects away from this host, so this was not measured")
470
+ : home.status !== 200
471
+ ? validateV2Discovery(null, "the home page returned HTTP " + home.status + ", so this was not measured")
472
+ : validateV2Discovery(findLinkRelations(home.text, home.linkHeader));
473
+ } catch {
474
+ discovery = validateV2Discovery(null, "the home page could not be read, so this was not measured");
475
+ }
476
+ const checks = validateLlmsTxt(fetched).concat(discovery);
188
477
  return { target: "https://" + host + "/llms.txt", summary: summarizeChecks(checks), checks };
189
478
  }