turva-llms-txt-validator 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.
Files changed (3) hide show
  1. package/README.md +3 -3
  2. package/package.json +2 -2
  3. package/src/index.mjs +61 -37
package/README.md CHANGED
@@ -18,13 +18,13 @@ 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 file is fetched over https, redirects are not followed (a 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. 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.
22
22
 
23
23
  ## The seven checks
24
24
 
25
25
  | # | Check | fail | warn |
26
26
  |---|-------|------|------|
27
- | 1 | File exists at /llms.txt, HTTP 200 | non-200 or a redirect | |
27
+ | 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 | |
28
28
  | 2 | Response is plain text | body looks like HTML | content-type is not text/plain or text/markdown |
29
29
  | 3 | Starts with a single H1 title | first non-empty line is not a markdown H1 | |
30
30
  | 4 | Blockquote summary after the title | | missing one-line summary |
@@ -61,4 +61,4 @@ The llms.txt format is a plain text map of a site for AI agents: an H1 title, a
61
61
 
62
62
  ## License
63
63
 
64
- MIT. Source at [codeberg.org/erekola/llms-txt-validator](https://codeberg.org/erekola/llms-txt-validator), mirrored at [github.com/erekola/llms-txt-validator](https://github.com/erekola/llms-txt-validator).
64
+ MIT. Source at [codeberg.org/erekola/llms-txt-validator](https://codeberg.org/erekola/llms-txt-validator), mirrored at [github.com/erekola/llms-txt-validator](https://github.com/erekola/llms-txt-validator). Published to npm from GitHub Actions with provenance, a signed attestation of where and how the package was built, verifiable on the npm package page.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turva-llms-txt-validator",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Validate a site's llms.txt structure: seven checks, pass/warn/fail, JSON output. Same checks as the hosted validator at turva.dev/llms-txt-validator.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -32,7 +32,7 @@
32
32
  "license": "MIT",
33
33
  "repository": {
34
34
  "type": "git",
35
- "url": "https://codeberg.org/erekola/llms-txt-validator"
35
+ "url": "git+https://github.com/erekola/llms-txt-validator.git"
36
36
  },
37
37
  "homepage": "https://turva.dev/llms-txt-validator",
38
38
  "bugs": {
package/src/index.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  // canonical: if the two ever disagree, the hosted one wins and this package
6
6
  // gets the fix.
7
7
 
8
- export const VERSION = "0.1.0";
8
+ export const VERSION = "0.1.2";
9
9
  const UA = "turva-llms-txt-validator/" + VERSION + " (+https://turva.dev/llms-txt-validator)";
10
10
 
11
11
  export function normalizeHostInput(raw) {
@@ -32,57 +32,81 @@ export function isValidPublicHost(host) {
32
32
  export async function fetchLlmsTxt(host, opts = {}) {
33
33
  const timeoutMs = opts.timeoutMs ?? 8000;
34
34
  const cap = opts.cap ?? 262144;
35
- const target = "https://" + host + "/llms.txt";
36
- const res = await fetch(target, {
37
- redirect: "manual",
38
- signal: AbortSignal.timeout(timeoutMs),
39
- headers: { "user-agent": opts.userAgent ?? UA, "accept": "text/plain, text/markdown;q=0.9, */*;q=0.1" }
40
- });
41
- if (res.status >= 300 && res.status < 400) {
42
- return { redirect: true, status: res.status, location: res.headers.get("location") || "" };
43
- }
44
- let bytes = 0, truncated = false;
45
- const chunks = [];
46
- if (res.body) {
47
- const reader = res.body.getReader();
48
- for (;;) {
49
- const { done, value } = await reader.read();
50
- if (done) break;
51
- bytes += value.length;
52
- if (bytes > cap) {
53
- truncated = true;
54
- chunks.push(value.slice(0, value.length - (bytes - cap)));
55
- bytes = cap;
56
- await reader.cancel();
57
- break;
35
+ const reqApex = host.startsWith("www.") ? host.slice(4) : host;
36
+ let url = "https://" + host + "/llms.txt";
37
+ let redirectedFrom = null;
38
+ for (let hop = 0; ; hop++) {
39
+ const res = await fetch(url, {
40
+ redirect: "manual",
41
+ signal: AbortSignal.timeout(timeoutMs),
42
+ headers: { "user-agent": opts.userAgent ?? UA, "accept": "text/plain, text/markdown;q=0.9, */*;q=0.1" }
43
+ });
44
+ if (res.status >= 300 && res.status < 400) {
45
+ const loc = res.headers.get("location") || "";
46
+ if (!loc) return { redirect: true, reason: "no-location", status: res.status, location: "" };
47
+ if (hop >= 4) return { redirect: true, reason: "too-many", status: res.status, location: loc.slice(0, 120) };
48
+ let next;
49
+ try { next = new URL(loc, url); } catch { return { redirect: true, reason: "bad-location", status: res.status, location: loc.slice(0, 120) }; }
50
+ const safeTarget = next.protocol === "https:" && !next.port && !next.username && !next.password && isValidPublicHost(next.hostname);
51
+ const twin = (next.hostname.startsWith("www.") ? next.hostname.slice(4) : next.hostname) === reqApex;
52
+ if (!safeTarget) return { redirect: true, reason: "unsafe-target", status: res.status, location: next.href.slice(0, 120) };
53
+ if (!twin) return { redirect: true, reason: "off-host", status: res.status, location: next.href.slice(0, 120) };
54
+ if (!redirectedFrom) redirectedFrom = url;
55
+ url = next.href;
56
+ continue;
57
+ }
58
+ let bytes = 0, truncated = false;
59
+ const chunks = [];
60
+ if (res.body) {
61
+ const reader = res.body.getReader();
62
+ for (;;) {
63
+ const { done, value } = await reader.read();
64
+ if (done) break;
65
+ bytes += value.length;
66
+ if (bytes > cap) {
67
+ truncated = true;
68
+ chunks.push(value.slice(0, value.length - (bytes - cap)));
69
+ bytes = cap;
70
+ await reader.cancel();
71
+ break;
72
+ }
73
+ chunks.push(value);
58
74
  }
59
- chunks.push(value);
60
75
  }
76
+ const buf = new Uint8Array(bytes);
77
+ let o = 0;
78
+ for (const c of chunks) { buf.set(c, o); o += c.length; }
79
+ return {
80
+ status: res.status,
81
+ contentType: res.headers.get("content-type") || "",
82
+ text: new TextDecoder("utf-8").decode(buf),
83
+ bytes,
84
+ truncated,
85
+ redirectedFrom,
86
+ finalUrl: url
87
+ };
61
88
  }
62
- const buf = new Uint8Array(bytes);
63
- let o = 0;
64
- for (const c of chunks) { buf.set(c, o); o += c.length; }
65
- return {
66
- status: res.status,
67
- contentType: res.headers.get("content-type") || "",
68
- text: new TextDecoder("utf-8").decode(buf),
69
- bytes,
70
- truncated
71
- };
89
+ }
90
+
91
+ function redirectFailDetail(f) {
92
+ if (f.reason === "off-host") return "redirects to " + f.location + ", a different host; llms.txt is host-scoped, so validate that host directly";
93
+ if (f.reason === "unsafe-target") return "redirects to an unsupported target (" + f.location + "); only https redirects to the same site are followed";
94
+ if (f.reason === "too-many") return "too many redirects; the llms.txt is not served at a stable URL";
95
+ return "got a " + f.status + " redirect without a usable Location header";
72
96
  }
73
97
 
74
98
  export function validateLlmsTxt(f) {
75
99
  const checks = [];
76
100
  const add = (id, status, label, detail) => checks.push({ id, status, label, detail });
77
101
  if (f.redirect) {
78
- add("http-status", "fail", "File exists at /llms.txt", "expected HTTP 200 at /llms.txt, got a " + f.status + " redirect" + (f.location ? " to " + f.location.slice(0, 120) : "") + "; llms.txt should be served directly");
102
+ add("http-status", "fail", "File exists at /llms.txt", redirectFailDetail(f));
79
103
  return checks;
80
104
  }
81
105
  if (f.status !== 200) {
82
106
  add("http-status", "fail", "File exists at /llms.txt", "expected HTTP 200, got " + f.status);
83
107
  return checks;
84
108
  }
85
- add("http-status", "pass", "File exists at /llms.txt", "HTTP 200");
109
+ add("http-status", "pass", "File exists at /llms.txt", f.redirectedFrom ? "HTTP 200, followed a redirect from " + f.redirectedFrom + " to " + f.finalUrl : "HTTP 200");
86
110
  const ct = (f.contentType || "").toLowerCase();
87
111
  const looksHtml = /^\s*(<!doctype|<html|<head|<body)/i.test(f.text);
88
112
  if (looksHtml) {