turva-llms-txt-validator 0.1.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/LICENSE +21 -0
- package/README.md +64 -0
- package/bin/cli.mjs +35 -0
- package/package.json +41 -0
- package/src/index.mjs +155 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Erik Rekola
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# turva-llms-txt-validator
|
|
2
|
+
|
|
3
|
+
Validate a site's llms.txt structure from the command line, from Node, or in CI. Seven 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 seven checks would look like an agent-readiness score without measuring one.
|
|
4
|
+
|
|
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://codeberg.org/erekola/turva-worker). The hosted validator stays canonical: if the two ever disagree, the hosted one wins and this package gets the fix.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
npm install -g turva-llms-txt-validator
|
|
10
|
+
|
|
11
|
+
Or run it without installing:
|
|
12
|
+
|
|
13
|
+
npx turva-llms-txt-validator example.com
|
|
14
|
+
|
|
15
|
+
## CLI
|
|
16
|
+
|
|
17
|
+
llms-txt-validate example.com
|
|
18
|
+
llms-txt-validate example.com --json
|
|
19
|
+
llms-txt-validate example.com --strict
|
|
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.
|
|
22
|
+
|
|
23
|
+
## The seven checks
|
|
24
|
+
|
|
25
|
+
| # | Check | fail | warn |
|
|
26
|
+
|---|-------|------|------|
|
|
27
|
+
| 1 | File exists at /llms.txt, HTTP 200 | non-200 or a redirect | |
|
|
28
|
+
| 2 | Response is plain text | body looks like HTML | content-type is not text/plain or text/markdown |
|
|
29
|
+
| 3 | Starts with a single H1 title | first non-empty line is not a markdown H1 | |
|
|
30
|
+
| 4 | Blockquote summary after the title | | missing one-line summary |
|
|
31
|
+
| 5 | H2 sections group the content | | no H2 sections |
|
|
32
|
+
| 6 | Markdown links an agent can follow | | no links, or relative links |
|
|
33
|
+
| 7 | Small enough to be cheap to read | | over 50 KB, or read truncated at 256 KB |
|
|
34
|
+
|
|
35
|
+
An eighth signal, inline HTML in the file, is reported in the same list as a warning.
|
|
36
|
+
|
|
37
|
+
## Node API
|
|
38
|
+
|
|
39
|
+
import { validateHost } from "turva-llms-txt-validator";
|
|
40
|
+
|
|
41
|
+
const result = await validateHost("example.com");
|
|
42
|
+
console.log(result.summary);
|
|
43
|
+
for (const c of result.checks) console.log(c.status, c.label, c.detail);
|
|
44
|
+
|
|
45
|
+
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:
|
|
46
|
+
|
|
47
|
+
curl -H "Accept: application/json" "https://turva.dev/llms-txt-validator?url=example.com"
|
|
48
|
+
|
|
49
|
+
## CI
|
|
50
|
+
|
|
51
|
+
GitHub Actions:
|
|
52
|
+
|
|
53
|
+
- name: Validate llms.txt
|
|
54
|
+
run: npx turva-llms-txt-validator your-domain.com --strict
|
|
55
|
+
|
|
56
|
+
Woodpecker or any other runner: the same npx line works anywhere Node 18.17 or newer is present.
|
|
57
|
+
|
|
58
|
+
## Why these checks
|
|
59
|
+
|
|
60
|
+
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).
|
|
61
|
+
|
|
62
|
+
## License
|
|
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).
|
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI for turva-llms-txt-validator. Exit codes: 0 = valid (or valid with
|
|
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:
|
|
5
|
+
// curl -H "Accept: application/json" "https://turva.dev/llms-txt-validator?url=example.com"
|
|
6
|
+
import { validateHost } from "../src/index.mjs";
|
|
7
|
+
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
const flags = new Set(args.filter((a) => a.startsWith("--")));
|
|
10
|
+
const target = args.find((a) => !a.startsWith("--"));
|
|
11
|
+
|
|
12
|
+
if (!target || flags.has("--help")) {
|
|
13
|
+
console.log("usage: llms-txt-validate <domain-or-url> [--json] [--strict]");
|
|
14
|
+
console.log(" --json print the result as JSON (same shape as the hosted validator)");
|
|
15
|
+
console.log(" --strict exit 1 on warnings too, for CI gates");
|
|
16
|
+
process.exit(target ? 0 : 2);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const mark = { pass: "ok ", warn: "warn", fail: "FAIL" };
|
|
20
|
+
try {
|
|
21
|
+
const result = await validateHost(target);
|
|
22
|
+
if (flags.has("--json")) {
|
|
23
|
+
console.log(JSON.stringify(result, null, 2));
|
|
24
|
+
} else {
|
|
25
|
+
console.log(result.target);
|
|
26
|
+
for (const c of result.checks) console.log(" " + mark[c.status] + " " + c.label + " (" + c.detail + ")");
|
|
27
|
+
console.log("result: " + result.summary);
|
|
28
|
+
}
|
|
29
|
+
if (result.summary === "not valid") process.exit(1);
|
|
30
|
+
if (result.summary === "valid with warnings" && flags.has("--strict")) process.exit(1);
|
|
31
|
+
process.exit(0);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error("error: " + (err && err.message ? err.message : String(err)));
|
|
34
|
+
process.exit(2);
|
|
35
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "turva-llms-txt-validator",
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.mjs",
|
|
7
|
+
"exports": "./src/index.mjs",
|
|
8
|
+
"bin": {
|
|
9
|
+
"llms-txt-validate": "bin/cli.mjs"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"bin",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18.17"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"llms.txt",
|
|
25
|
+
"llms-txt",
|
|
26
|
+
"validator",
|
|
27
|
+
"ai-agents",
|
|
28
|
+
"agent-readiness",
|
|
29
|
+
"cli"
|
|
30
|
+
],
|
|
31
|
+
"author": "Erik Rekola (https://turva.dev)",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://codeberg.org/erekola/llms-txt-validator"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://turva.dev/llms-txt-validator",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://codeberg.org/erekola/llms-txt-validator/issues"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// turva-llms-txt-validator: validate a site's llms.txt structure.
|
|
2
|
+
// The checks mirror the hosted validator at https://turva.dev/llms-txt-validator,
|
|
3
|
+
// which runs this same logic inside the turva.dev Cloudflare Worker
|
|
4
|
+
// (https://codeberg.org/erekola/turva-worker). The hosted validator stays
|
|
5
|
+
// canonical: if the two ever disagree, the hosted one wins and this package
|
|
6
|
+
// gets the fix.
|
|
7
|
+
|
|
8
|
+
export const VERSION = "0.1.0";
|
|
9
|
+
const UA = "turva-llms-txt-validator/" + VERSION + " (+https://turva.dev/llms-txt-validator)";
|
|
10
|
+
|
|
11
|
+
export function normalizeHostInput(raw) {
|
|
12
|
+
let s = String(raw || "").trim().toLowerCase();
|
|
13
|
+
if (!s) return null;
|
|
14
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//.test(s)) s = "https://" + s;
|
|
15
|
+
let u;
|
|
16
|
+
try { u = new URL(s); } catch { return null; }
|
|
17
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") return null;
|
|
18
|
+
if (u.port && u.port !== "443" && u.port !== "80") return null;
|
|
19
|
+
if (u.username || u.password) return null;
|
|
20
|
+
return u.hostname;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isValidPublicHost(host) {
|
|
24
|
+
if (!host || host.length > 253) return false;
|
|
25
|
+
if (host.startsWith("[") || /^\d+\.\d+\.\d+\.\d+$/.test(host)) return false;
|
|
26
|
+
if (!/^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{1,62}$/.test(host)) return false;
|
|
27
|
+
const tld = host.split(".").pop();
|
|
28
|
+
if (["localhost", "local", "internal", "home", "lan", "corp", "test", "invalid"].includes(tld)) return false;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function fetchLlmsTxt(host, opts = {}) {
|
|
33
|
+
const timeoutMs = opts.timeoutMs ?? 8000;
|
|
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;
|
|
58
|
+
}
|
|
59
|
+
chunks.push(value);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
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
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function validateLlmsTxt(f) {
|
|
75
|
+
const checks = [];
|
|
76
|
+
const add = (id, status, label, detail) => checks.push({ id, status, label, detail });
|
|
77
|
+
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");
|
|
79
|
+
return checks;
|
|
80
|
+
}
|
|
81
|
+
if (f.status !== 200) {
|
|
82
|
+
add("http-status", "fail", "File exists at /llms.txt", "expected HTTP 200, got " + f.status);
|
|
83
|
+
return checks;
|
|
84
|
+
}
|
|
85
|
+
add("http-status", "pass", "File exists at /llms.txt", "HTTP 200");
|
|
86
|
+
const ct = (f.contentType || "").toLowerCase();
|
|
87
|
+
const looksHtml = /^\s*(<!doctype|<html|<head|<body)/i.test(f.text);
|
|
88
|
+
if (looksHtml) {
|
|
89
|
+
add("content-type", "fail", "Response is plain text", "the body looks like an HTML page, not an llms.txt file");
|
|
90
|
+
return checks;
|
|
91
|
+
}
|
|
92
|
+
if (ct.includes("text/plain") || ct.includes("text/markdown")) {
|
|
93
|
+
add("content-type", "pass", "Response is plain text", ct.split(";")[0]);
|
|
94
|
+
} else {
|
|
95
|
+
add("content-type", "warn", "Response is plain text", "content-type is " + (ct.split(";")[0] || "missing") + ", text/plain or text/markdown is the convention");
|
|
96
|
+
}
|
|
97
|
+
const lines = f.text.split(/\r?\n/);
|
|
98
|
+
const firstIdx = lines.findIndex((l) => l.trim() !== "");
|
|
99
|
+
const first = firstIdx === -1 ? "" : lines[firstIdx].trim();
|
|
100
|
+
if (/^# \S/.test(first)) {
|
|
101
|
+
add("h1-title", "pass", "Starts with an H1 title", JSON.stringify(first.slice(0, 80)));
|
|
102
|
+
} else {
|
|
103
|
+
add("h1-title", "fail", "Starts with an H1 title", "the first non-empty line should be a markdown H1 (# Site name)");
|
|
104
|
+
}
|
|
105
|
+
const afterH1 = lines.slice(firstIdx + 1).find((l) => l.trim() !== "") || "";
|
|
106
|
+
if (afterH1.trim().startsWith("> ")) {
|
|
107
|
+
add("summary", "pass", "Blockquote summary after the title", JSON.stringify(afterH1.trim().slice(0, 80)));
|
|
108
|
+
} else {
|
|
109
|
+
add("summary", "warn", "Blockquote summary after the title", "recommended by the format (> one-line summary), not required");
|
|
110
|
+
}
|
|
111
|
+
const h2Count = (f.text.match(/^## /gm) || []).length;
|
|
112
|
+
if (h2Count > 0) {
|
|
113
|
+
add("sections", "pass", "H2 sections group the content", h2Count + " section" + (h2Count === 1 ? "" : "s"));
|
|
114
|
+
} else {
|
|
115
|
+
add("sections", "warn", "H2 sections group the content", "no H2 sections found; sections are the convention for grouping links");
|
|
116
|
+
}
|
|
117
|
+
const links = [...f.text.matchAll(/\[([^\]]*)\]\(([^)\s]+)\)/g)];
|
|
118
|
+
const absolute = links.filter((m) => /^https?:\/\//.test(m[2])).length;
|
|
119
|
+
if (links.length === 0) {
|
|
120
|
+
add("links", "warn", "Markdown links an agent can follow", "no markdown links found");
|
|
121
|
+
} else if (absolute === links.length) {
|
|
122
|
+
add("links", "pass", "Markdown links an agent can follow", links.length + " link" + (links.length === 1 ? "" : "s") + ", all absolute URLs");
|
|
123
|
+
} else {
|
|
124
|
+
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");
|
|
125
|
+
}
|
|
126
|
+
if (f.truncated) {
|
|
127
|
+
add("size", "warn", "Small enough to be cheap to read", "over 256 KB, read truncated");
|
|
128
|
+
} else if (f.bytes <= 51200) {
|
|
129
|
+
add("size", "pass", "Small enough to be cheap to read", f.bytes + " bytes");
|
|
130
|
+
} else {
|
|
131
|
+
add("size", "warn", "Small enough to be cheap to read", f.bytes + " bytes; consider moving detail to llms-full.txt");
|
|
132
|
+
}
|
|
133
|
+
if (/<[a-z][a-z0-9-]*[\s>]/i.test(f.text)) {
|
|
134
|
+
add("no-html", "warn", "No HTML markup in the file", "HTML tags found; llms.txt should be plain markdown");
|
|
135
|
+
} else {
|
|
136
|
+
add("no-html", "pass", "No HTML markup in the file", "plain markdown");
|
|
137
|
+
}
|
|
138
|
+
return checks;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function summarizeChecks(checks) {
|
|
142
|
+
if (checks.some((c) => c.status === "fail")) return "not valid";
|
|
143
|
+
if (checks.some((c) => c.status === "warn")) return "valid with warnings";
|
|
144
|
+
return "valid";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function validateHost(input, opts = {}) {
|
|
148
|
+
const host = normalizeHostInput(input);
|
|
149
|
+
if (!host || !isValidPublicHost(host)) {
|
|
150
|
+
throw new Error("not a public domain name: " + String(input).slice(0, 120));
|
|
151
|
+
}
|
|
152
|
+
const fetched = await fetchLlmsTxt(host, opts);
|
|
153
|
+
const checks = validateLlmsTxt(fetched);
|
|
154
|
+
return { target: "https://" + host + "/llms.txt", summary: summarizeChecks(checks), checks };
|
|
155
|
+
}
|