turva-llms-txt-validator 0.3.1 → 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 +21 -9
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;
|
|
@@ -203,13 +215,13 @@ export function validateLlmsTxt(f) {
|
|
|
203
215
|
// indented code block rather than a heading, and trimming erased that difference, so
|
|
204
216
|
// " # Site" passed as the H1 until 2026-08-29. CommonMark allows three spaces.
|
|
205
217
|
if (/^ {0,3}# \S/.test(firstRaw)) {
|
|
206
|
-
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)));
|
|
207
219
|
} else {
|
|
208
220
|
add("h1-title", "fail", "Starts with an H1 title", "the first non-empty line should be a markdown H1 (# Site name)");
|
|
209
221
|
}
|
|
210
222
|
const afterH1 = lines.slice(firstIdx + 1).find((l) => l.trim() !== "") || "";
|
|
211
223
|
if (afterH1.trim().startsWith("> ")) {
|
|
212
|
-
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)));
|
|
213
225
|
} else {
|
|
214
226
|
add("summary", "warn", "Blockquote summary after the title", "recommended by the format (> one-line summary), not required");
|
|
215
227
|
}
|
|
@@ -535,9 +547,9 @@ export function validateV2Discovery(found, unreadReason) {
|
|
|
535
547
|
return checks;
|
|
536
548
|
}
|
|
537
549
|
add("v2-describedby", found.describedby ? "pass" : "info", "Home page points to its llms.txt (v2)",
|
|
538
|
-
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');
|
|
539
551
|
add("v2-markdown-alternate", found.markdown ? "pass" : "info", "Home page points to a markdown version (v2)",
|
|
540
|
-
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');
|
|
541
553
|
return checks;
|
|
542
554
|
}
|
|
543
555
|
|
|
@@ -550,7 +562,7 @@ export function summarizeChecks(checks) {
|
|
|
550
562
|
export async function validateHost(input, opts = {}) {
|
|
551
563
|
const host = normalizeHostInput(input);
|
|
552
564
|
if (!host || !isValidPublicHost(host)) {
|
|
553
|
-
throw new Error("not a public domain name: " +
|
|
565
|
+
throw new Error("not a public domain name: " + cut(input, 120));
|
|
554
566
|
}
|
|
555
567
|
// opts comes from the caller, and path and accept exist for this function's own
|
|
556
568
|
// second read. Forwarding them to the first read would let a caller point the
|