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 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 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.
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
- console.error("error: " + (err && err.message ? err.message : String(err)));
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.1",
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.",
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.slice(0, 120) };
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.slice(0, 120) }; }
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.slice(0, 120) };
67
- if (!twin) return { redirect: true, reason: "off-host", status: res.status, location: next.href.slice(0, 120) };
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.slice(0, 80)));
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().slice(0, 80)));
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.slice(0, 120) : 'no rel="describedby" in the head or the Link header; v2 recommends it so an agent finds the file without guessing');
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.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');
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: " + String(input).slice(0, 120));
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