turva-llms-txt-validator 0.1.8 → 0.2.0
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 +12 -5
- package/bin/cli.mjs +3 -2
- package/package.json +2 -2
- package/src/index.mjs +70 -4
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
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
4
|
-
"description": "Validate a site's llms.txt structure: eight checks
|
|
3
|
+
"version": "0.2.0",
|
|
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,53 @@ export function validateLlmsTxt(f) {
|
|
|
172
177
|
return checks;
|
|
173
178
|
}
|
|
174
179
|
|
|
180
|
+
// v2 of the llms.txt proposal (August 2026) left the file format alone and added one
|
|
181
|
+
// thing: a page should say where its markdown version and its llms.txt are, using
|
|
182
|
+
// rel="alternate" type="text/markdown" and rel="describedby", as HTML link elements or
|
|
183
|
+
// as a Link response header. That is a property of the site, not of the file, so these
|
|
184
|
+
// two carry their own status, "info". They are never a warn and never a fail, the
|
|
185
|
+
// summary line is unchanged, and --strict keeps exiting on warnings only.
|
|
186
|
+
export function findLinkRelations(html, linkHeader) {
|
|
187
|
+
const found = { describedby: null, markdown: null };
|
|
188
|
+
// Comments are stripped first: a commented-out link element is not published,
|
|
189
|
+
// and counting one would report a relation the site does not serve. With no
|
|
190
|
+
// </head> the first 64 KB are scanned, body included, so a malformed document
|
|
191
|
+
// does not silently report nothing found.
|
|
192
|
+
const text = String(html || "").replace(/<!--[\s\S]*?-->/g, "");
|
|
193
|
+
const cut = text.toLowerCase().indexOf("</head>");
|
|
194
|
+
const head = cut === -1 ? text.slice(0, 65536) : text.slice(0, cut);
|
|
195
|
+
for (const tag of head.match(/<link\b[^>]*>/gi) || []) {
|
|
196
|
+
const rel = ((tag.match(/\brel\s*=\s*["']?([^"'>]+)/i) || [])[1] || "").toLowerCase().trim().split(/\s+/);
|
|
197
|
+
const type = ((tag.match(/\btype\s*=\s*["']?([^"'>\s]+)/i) || [])[1] || "").toLowerCase();
|
|
198
|
+
const href = ((tag.match(/\bhref\s*=\s*"([^"]*)"|\bhref\s*=\s*'([^']*)'|\bhref\s*=\s*([^\s"'>]+)/i) || []).slice(1).find((x) => x !== undefined) || "").trim();
|
|
199
|
+
if (!found.describedby && rel.includes("describedby")) found.describedby = href || "(link element without href)";
|
|
200
|
+
if (!found.markdown && rel.includes("alternate") && type.startsWith("text/markdown")) found.markdown = href || "(link element without href)";
|
|
201
|
+
}
|
|
202
|
+
for (const part of String(linkHeader || "").split(/,(?=\s*<)/)) {
|
|
203
|
+
const href = ((part.match(/<([^>]*)>/) || [])[1] || "").trim();
|
|
204
|
+
const rel = ((part.match(/rel\s*=\s*"?([^";,]+)"?/i) || [])[1] || "").toLowerCase().trim().split(/\s+/);
|
|
205
|
+
const type = ((part.match(/type\s*=\s*"?([^";,]+)"?/i) || [])[1] || "").toLowerCase().trim();
|
|
206
|
+
if (!found.describedby && rel.includes("describedby")) found.describedby = href || "(Link header without a target)";
|
|
207
|
+
if (!found.markdown && rel.includes("alternate") && type.startsWith("text/markdown")) found.markdown = href || "(Link header without a target)";
|
|
208
|
+
}
|
|
209
|
+
return found;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function validateV2Discovery(found, unreadReason) {
|
|
213
|
+
const checks = [];
|
|
214
|
+
const add = (id, status, label, detail) => checks.push({ id, status, label, detail });
|
|
215
|
+
if (!found) {
|
|
216
|
+
add("v2-describedby", "info", "Home page points to its llms.txt (v2)", unreadReason);
|
|
217
|
+
add("v2-markdown-alternate", "info", "Home page points to a markdown version (v2)", unreadReason);
|
|
218
|
+
return checks;
|
|
219
|
+
}
|
|
220
|
+
add("v2-describedby", found.describedby ? "pass" : "info", "Home page points to its llms.txt (v2)",
|
|
221
|
+
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');
|
|
222
|
+
add("v2-markdown-alternate", found.markdown ? "pass" : "info", "Home page points to a markdown version (v2)",
|
|
223
|
+
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');
|
|
224
|
+
return checks;
|
|
225
|
+
}
|
|
226
|
+
|
|
175
227
|
export function summarizeChecks(checks) {
|
|
176
228
|
if (checks.some((c) => c.status === "fail")) return "not valid";
|
|
177
229
|
if (checks.some((c) => c.status === "warn")) return "valid with warnings";
|
|
@@ -183,7 +235,21 @@ export async function validateHost(input, opts = {}) {
|
|
|
183
235
|
if (!host || !isValidPublicHost(host)) {
|
|
184
236
|
throw new Error("not a public domain name: " + String(input).slice(0, 120));
|
|
185
237
|
}
|
|
186
|
-
|
|
187
|
-
|
|
238
|
+
// opts comes from the caller, and path and accept exist for this function's own
|
|
239
|
+
// second read. Forwarding them to the first read would let a caller point the
|
|
240
|
+
// "llms.txt" result at any path on the host, so both are pinned here.
|
|
241
|
+
const fetched = await fetchLlmsTxt(host, { ...opts, path: "/llms.txt", accept: undefined });
|
|
242
|
+
let discovery;
|
|
243
|
+
try {
|
|
244
|
+
const home = await fetchLlmsTxt(host, { ...opts, path: "/", accept: "text/html, */*;q=0.1" });
|
|
245
|
+
discovery = home.redirect
|
|
246
|
+
? validateV2Discovery(null, "the home page redirects away from this host, so this was not measured")
|
|
247
|
+
: home.status !== 200
|
|
248
|
+
? validateV2Discovery(null, "the home page returned HTTP " + home.status + ", so this was not measured")
|
|
249
|
+
: validateV2Discovery(findLinkRelations(home.text, home.linkHeader));
|
|
250
|
+
} catch {
|
|
251
|
+
discovery = validateV2Discovery(null, "the home page could not be read, so this was not measured");
|
|
252
|
+
}
|
|
253
|
+
const checks = validateLlmsTxt(fetched).concat(discovery);
|
|
188
254
|
return { target: "https://" + host + "/llms.txt", summary: summarizeChecks(checks), checks };
|
|
189
255
|
}
|