turva-llms-txt-validator 0.3.0 → 0.3.2
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 +1 -1
- package/bin/cli.mjs +6 -1
- package/package.json +2 -2
- package/src/index.mjs +88 -14
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 structure checks, each reported as pass, warn or fail with a one-line detail, and two v2 discovery checks
|
|
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 that read pass or info and never move the summary. 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
|
|
package/bin/cli.mjs
CHANGED
|
@@ -31,6 +31,11 @@ try {
|
|
|
31
31
|
if (result.summary === "valid with warnings" && flags.has("--strict")) process.exit(1);
|
|
32
32
|
process.exit(0);
|
|
33
33
|
} catch (err) {
|
|
34
|
-
|
|
34
|
+
const message = err && err.message ? err.message : String(err);
|
|
35
|
+
// The hosted validator answers every failure as JSON when JSON was asked for, so a CI
|
|
36
|
+
// step that parses --json output gets {"error": ...} here too instead of an empty stdout
|
|
37
|
+
// and a plain-text stderr (round 16 S3-3). The exit code is unchanged.
|
|
38
|
+
if (flags.has("--json")) console.log(JSON.stringify({ error: message }, null, 2));
|
|
39
|
+
else console.error("error: " + message);
|
|
35
40
|
process.exit(2);
|
|
36
41
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "turva-llms-txt-validator",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "Validate a site's llms.txt structure: eight checks as pass/warn/fail plus two llms.txt v2 discovery checks
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "Validate a site's llms.txt structure: eight checks as pass/warn/fail plus two llms.txt v2 discovery checks that read pass or info and never move the summary, 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
|
@@ -12,8 +12,20 @@ import { readFileSync } from "node:fs";
|
|
|
12
12
|
export const VERSION = JSON.parse(
|
|
13
13
|
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
14
14
|
).version;
|
|
15
|
+
// The hosted validator identifies itself without a version, because its version is the
|
|
16
|
+
// site's and moves with every release; this package carries its own. The two strings
|
|
17
|
+
// differ on purpose and nothing compares them (round 16 S3-6).
|
|
15
18
|
const UA = "turva-llms-txt-validator/" + VERSION + " (+https://turva.dev/llms-txt-validator)";
|
|
16
19
|
|
|
20
|
+
// Cut a string to at most n UTF-16 code units without leaving a lone high surrogate at the
|
|
21
|
+
// end. String.prototype.slice counts code units, so a cut that lands inside a surrogate
|
|
22
|
+
// pair leaves half of it, and that half serialises as bytes that are not valid UTF-8
|
|
23
|
+
// (round 16 S3-1 and S3-2, measured on the hosted validator). Mirrors worker.js cut().
|
|
24
|
+
export function cut(s, n) {
|
|
25
|
+
s = String(s).slice(0, n);
|
|
26
|
+
return /[\uD800-\uDBFF]$/.test(s) ? s.slice(0, -1) : s;
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
export function normalizeHostInput(raw) {
|
|
18
30
|
let s = String(raw || "").trim().toLowerCase();
|
|
19
31
|
if (!s) return null;
|
|
@@ -58,13 +70,13 @@ export async function fetchLlmsTxt(host, opts = {}) {
|
|
|
58
70
|
if (res.status >= 300 && res.status < 400) {
|
|
59
71
|
const loc = res.headers.get("location") || "";
|
|
60
72
|
if (!loc) return { redirect: true, reason: "no-location", status: res.status, location: "" };
|
|
61
|
-
if (hop >= 4) return { redirect: true, reason: "too-many", status: res.status, location: loc
|
|
73
|
+
if (hop >= 4) return { redirect: true, reason: "too-many", status: res.status, location: cut(loc, 120) };
|
|
62
74
|
let next;
|
|
63
|
-
try { next = new URL(loc, url); } catch { return { redirect: true, reason: "bad-location", status: res.status, location: loc
|
|
75
|
+
try { next = new URL(loc, url); } catch { return { redirect: true, reason: "bad-location", status: res.status, location: cut(loc, 120) }; }
|
|
64
76
|
const safeTarget = next.protocol === "https:" && !next.port && !next.username && !next.password && isValidPublicHost(next.hostname);
|
|
65
77
|
const twin = (next.hostname.startsWith("www.") ? next.hostname.slice(4) : next.hostname) === reqApex;
|
|
66
|
-
if (!safeTarget) return { redirect: true, reason: "unsafe-target", status: res.status, location: next.href
|
|
67
|
-
if (!twin) return { redirect: true, reason: "off-host", status: res.status, location: next.href
|
|
78
|
+
if (!safeTarget) return { redirect: true, reason: "unsafe-target", status: res.status, location: cut(next.href, 120) };
|
|
79
|
+
if (!twin) return { redirect: true, reason: "off-host", status: res.status, location: cut(next.href, 120) };
|
|
68
80
|
if (!redirectedFrom) redirectedFrom = url;
|
|
69
81
|
url = next.href;
|
|
70
82
|
continue;
|
|
@@ -103,6 +115,68 @@ export async function fetchLlmsTxt(host, opts = {}) {
|
|
|
103
115
|
}
|
|
104
116
|
}
|
|
105
117
|
|
|
118
|
+
// Every markdown link in the file, scanned once from left to right instead of collected with
|
|
119
|
+
// matchAll(/\[([^\][]*)\]\(([^)\s]{1,2048})\)/g). That bound meant a target longer than 2048
|
|
120
|
+
// characters was not counted as a link at all, and dropping the bound from the pattern would
|
|
121
|
+
// make it quadratic on a file that repeats "[a](" (Erik 2026-08-29). The scan carries no bound
|
|
122
|
+
// and no backtracking: every character is read once and the furthest failed target scan is
|
|
123
|
+
// remembered. matchAll resumes after a whole match, and so does this.
|
|
124
|
+
function collectLinks(text) {
|
|
125
|
+
const out = [];
|
|
126
|
+
let failEnd = -1;
|
|
127
|
+
for (let i = 0; i < text.length; i++) {
|
|
128
|
+
if (text[i] !== "[") continue;
|
|
129
|
+
let j = i + 1;
|
|
130
|
+
while (j < text.length && text[j] !== "]" && text[j] !== "[") j++;
|
|
131
|
+
if (j >= text.length) break;
|
|
132
|
+
if (text[j] === "[") { i = j - 1; continue; }
|
|
133
|
+
if (text[j + 1] !== "(") { i = j; continue; }
|
|
134
|
+
const k = j + 2;
|
|
135
|
+
if (k <= failEnd) { i = j + 1; continue; }
|
|
136
|
+
let e = k;
|
|
137
|
+
while (e < text.length && text[e] !== ")" && !/\s/.test(text[e])) e++;
|
|
138
|
+
if (e > k && text[e] === ")") { out.push({ name: text.slice(i + 1, j), target: text.slice(k, e) }); i = e; continue; }
|
|
139
|
+
if (text[e] !== ")") failEnd = e;
|
|
140
|
+
i = j + 1;
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A markdown list item that carries a link, scanned once from left to right instead of
|
|
146
|
+
// matched with /^ {0,3}[-*+] .*\[[^\][]*\]\([^)\s]+\)/. That pattern is quadratic on a line
|
|
147
|
+
// such as "- " followed by "[a](" repeated, because every candidate rescans the target to the
|
|
148
|
+
// end of the line, and the line comes from the audited site (CodeQL js/polynomial-redos,
|
|
149
|
+
// 2026-08-29). Bounding the quantifier would trade the speed bug for a silent accuracy bug,
|
|
150
|
+
// so the scan is by index: every character is read once and the furthest failed target scan
|
|
151
|
+
// is remembered.
|
|
152
|
+
function listItemHasLink(l) {
|
|
153
|
+
const m = /^ {0,3}[-*+] /.exec(l);
|
|
154
|
+
if (!m) return false;
|
|
155
|
+
const isSep = (c) => c === "\r" || c === "\n" || c === "\u2028" || c === "\u2029";
|
|
156
|
+
let failEnd = -1;
|
|
157
|
+
for (let i = m[0].length; i < l.length; i++) {
|
|
158
|
+
// A "." in the old pattern never crosses a line terminator, and split(/\r?\n/) leaves
|
|
159
|
+
// a bare CR, U+2028 and U+2029 inside a line, so a link behind one was not a match then
|
|
160
|
+
// and is not one now.
|
|
161
|
+
if (isSep(l[i])) return false;
|
|
162
|
+
if (l[i] !== "[") continue;
|
|
163
|
+
let j = i + 1, sep = false;
|
|
164
|
+
while (j < l.length && l[j] !== "]" && l[j] !== "[") { if (isSep(l[j])) sep = true; j++; }
|
|
165
|
+
if (j >= l.length) return false;
|
|
166
|
+
if (l[j] === "[") { if (sep) return false; i = j - 1; continue; }
|
|
167
|
+
if (l[j + 1] !== "(") { if (sep) return false; i = j; continue; }
|
|
168
|
+
const k = j + 2;
|
|
169
|
+
if (k <= failEnd) { if (sep) return false; i = j + 1; continue; }
|
|
170
|
+
let e = k;
|
|
171
|
+
while (e < l.length && l[e] !== ")" && !/\s/.test(l[e])) e++;
|
|
172
|
+
if (e > k && l[e] === ")") return true;
|
|
173
|
+
if (sep) return false;
|
|
174
|
+
if (l[e] !== ")") failEnd = e;
|
|
175
|
+
i = j + 1;
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
106
180
|
function redirectFailDetail(f) {
|
|
107
181
|
if (f.reason === "off-host") return "redirects to " + f.location + ", a different host; llms.txt is host-scoped, so validate that host directly";
|
|
108
182
|
if (f.reason === "unsafe-target") return "redirects to an unsupported target (" + f.location + "); only https redirects to the same site are followed";
|
|
@@ -141,13 +215,13 @@ export function validateLlmsTxt(f) {
|
|
|
141
215
|
// indented code block rather than a heading, and trimming erased that difference, so
|
|
142
216
|
// " # Site" passed as the H1 until 2026-08-29. CommonMark allows three spaces.
|
|
143
217
|
if (/^ {0,3}# \S/.test(firstRaw)) {
|
|
144
|
-
add("h1-title", "pass", "Starts with an H1 title", JSON.stringify(first
|
|
218
|
+
add("h1-title", "pass", "Starts with an H1 title", JSON.stringify(cut(first, 80)));
|
|
145
219
|
} else {
|
|
146
220
|
add("h1-title", "fail", "Starts with an H1 title", "the first non-empty line should be a markdown H1 (# Site name)");
|
|
147
221
|
}
|
|
148
222
|
const afterH1 = lines.slice(firstIdx + 1).find((l) => l.trim() !== "") || "";
|
|
149
223
|
if (afterH1.trim().startsWith("> ")) {
|
|
150
|
-
add("summary", "pass", "Blockquote summary after the title", JSON.stringify(afterH1.trim()
|
|
224
|
+
add("summary", "pass", "Blockquote summary after the title", JSON.stringify(cut(afterH1.trim(), 80)));
|
|
151
225
|
} else {
|
|
152
226
|
add("summary", "warn", "Blockquote summary after the title", "recommended by the format (> one-line summary), not required");
|
|
153
227
|
}
|
|
@@ -160,7 +234,7 @@ export function validateLlmsTxt(f) {
|
|
|
160
234
|
for (const l of lines) {
|
|
161
235
|
if (/^## /.test(l)) { inSection = true; counted = false; continue; }
|
|
162
236
|
if (/^# /.test(l)) { inSection = false; continue; }
|
|
163
|
-
if (inSection && !counted &&
|
|
237
|
+
if (inSection && !counted && listItemHasLink(l)) { sectionsWithList++; counted = true; }
|
|
164
238
|
}
|
|
165
239
|
}
|
|
166
240
|
if (h2Count > 0 && sectionsWithList > 0) {
|
|
@@ -170,12 +244,12 @@ export function validateLlmsTxt(f) {
|
|
|
170
244
|
} else {
|
|
171
245
|
add("sections", "warn", "H2 sections group the content", "no H2 sections found; sections are the convention for grouping links");
|
|
172
246
|
}
|
|
173
|
-
const links =
|
|
247
|
+
const links = collectLinks(f.text);
|
|
174
248
|
// An entry an agent can use has a name and a target with a host. An empty name and a
|
|
175
249
|
// bare "https://" both counted as valid absolute links until 2026-08-29.
|
|
176
|
-
const named = links.filter((m) => m
|
|
250
|
+
const named = links.filter((m) => m.name.trim() !== "");
|
|
177
251
|
const unnamed = links.length - named.length;
|
|
178
|
-
const absolute = named.filter((m) => /^https?:\/\/[^/\s?#]+/.test(m
|
|
252
|
+
const absolute = named.filter((m) => /^https?:\/\/[^/\s?#]+/.test(m.target)).length;
|
|
179
253
|
if (links.length === 0) {
|
|
180
254
|
add("links", "warn", "Markdown links an agent can follow", "no markdown links found");
|
|
181
255
|
} else if (unnamed > 0) {
|
|
@@ -183,7 +257,7 @@ export function validateLlmsTxt(f) {
|
|
|
183
257
|
} else if (absolute === named.length) {
|
|
184
258
|
add("links", "pass", "Markdown links an agent can follow", named.length + " link" + (named.length === 1 ? "" : "s") + ", all absolute URLs");
|
|
185
259
|
} else {
|
|
186
|
-
const relativeCount = named.filter((m) => !/^[a-z][a-z0-9+.-]*:/i.test(m
|
|
260
|
+
const relativeCount = named.filter((m) => !/^[a-z][a-z0-9+.-]*:/i.test(m.target)).length;
|
|
187
261
|
const hostless = named.length - absolute - relativeCount;
|
|
188
262
|
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");
|
|
189
263
|
}
|
|
@@ -473,9 +547,9 @@ export function validateV2Discovery(found, unreadReason) {
|
|
|
473
547
|
return checks;
|
|
474
548
|
}
|
|
475
549
|
add("v2-describedby", found.describedby ? "pass" : "info", "Home page points to its llms.txt (v2)",
|
|
476
|
-
found.describedby ? 'rel="describedby" to ' + found.describedby
|
|
550
|
+
found.describedby ? 'rel="describedby" to ' + cut(found.describedby, 120) : 'no rel="describedby" in the head or the Link header; v2 recommends it so an agent finds the file without guessing');
|
|
477
551
|
add("v2-markdown-alternate", found.markdown ? "pass" : "info", "Home page points to a markdown version (v2)",
|
|
478
|
-
found.markdown ? 'rel="alternate" type="text/markdown" to ' + found.markdown
|
|
552
|
+
found.markdown ? 'rel="alternate" type="text/markdown" to ' + cut(found.markdown, 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');
|
|
479
553
|
return checks;
|
|
480
554
|
}
|
|
481
555
|
|
|
@@ -488,7 +562,7 @@ export function summarizeChecks(checks) {
|
|
|
488
562
|
export async function validateHost(input, opts = {}) {
|
|
489
563
|
const host = normalizeHostInput(input);
|
|
490
564
|
if (!host || !isValidPublicHost(host)) {
|
|
491
|
-
throw new Error("not a public domain name: " +
|
|
565
|
+
throw new Error("not a public domain name: " + cut(input, 120));
|
|
492
566
|
}
|
|
493
567
|
// opts comes from the caller, and path and accept exist for this function's own
|
|
494
568
|
// second read. Forwarding them to the first read would let a caller point the
|