turva-llms-txt-validator 0.3.5 → 0.3.7

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
@@ -14,7 +14,7 @@ Requires **Node.js 18.17 or newer**. Run it without a global install:
14
14
  npx --yes turva-llms-txt-validator example.com
15
15
  ```
16
16
 
17
- Replace `example.com` with the domain you want to check. A domain, HTTP URL or HTTPS URL is accepted. Validation uses the host's `/llms.txt` and home page over HTTPS, regardless of the supplied path.
17
+ Replace `example.com` with the domain you want to check. A domain, HTTP URL or HTTPS URL is accepted. Validation uses the host's `/llms.txt` and home page over HTTPS, regardless of the supplied path. If the address has a path other than `/` or `/llms.txt`, the result names that path first in an `info` line.
18
18
 
19
19
  For repeated use, install the CLI globally:
20
20
 
@@ -65,7 +65,7 @@ Completed validation returns `{ target, summary, checks }`. Each check contains
65
65
  | 1 | `/llms.txt` returns HTTP 200 | Non-200 response, rejected or excessive redirects | none |
66
66
  | 2 | Response is plain text | Body looks like an HTML page | Content type is neither `text/plain` nor `text/markdown` |
67
67
  | 3 | First non-empty line is a Markdown H1 | Missing H1, including a title indented as a code block | none |
68
- | 4 | Blockquote summary follows the title | none | Missing summary |
68
+ | 4 | Blockquote summary follows the title | none | Missing summary, or a blockquote after other text but before the first H2, named by its line |
69
69
  | 5 | H2 sections group the content | none | No H2 sections, or no section contains a Markdown link list |
70
70
  | 6 | Markdown links have names and absolute HTTP or HTTPS targets | none | Missing links, empty names, relative targets or unsupported targets |
71
71
  | 7 | File is small enough to read cheaply | none | Over 50 KB, or truncated at the 256 KB read limit |
@@ -123,8 +123,8 @@ The default validation requests two documents: `https://<host>/llms.txt` and `ht
123
123
 
124
124
  - Timeout: eight seconds per document, shared across its redirect chain.
125
125
  - Read limit: 256 KB per response.
126
- - Redirects: up to four hops, over HTTPS, to the same host or its `www`/apex equivalent. Off-site redirects, embedded credentials and unsupported ports are rejected. A rejected target is reported with its credentials and fragment removed and its query values masked.
127
- - Host checks: IP literals, bracketed IPv6 addresses, localhost and the internal-use TLDs `local`, `internal`, `home`, `lan`, `corp`, `test` and `invalid` are rejected before a request is sent. Redirect targets are checked too.
126
+ - Redirects: up to four hops, over HTTPS, to the same host or its `www`/apex equivalent. Off-site redirects, embedded credentials and unsupported ports are rejected. A redirect target is reported with its credentials and fragment removed and its query values masked, whether the redirect was followed or rejected.
127
+ - Host checks: IP literals, bracketed IPv6 addresses, localhost and the internal-use TLDs `local`, `internal`, `home`, `lan`, `corp`, `test`, `invalid`, `arpa` and `onion` are rejected before a request is sent. One trailing dot on the host you give, as in `example.com.`, is removed first. Redirect targets are checked too.
128
128
 
129
129
  **DNS resolution is not checked for private addresses.** A syntactically public domain can resolve to a private IP, so if you expose this package through a service that accepts untrusted domains, enforce private-address restrictions at the network layer. Local and CI runs use their own network policy. The hosted validator runs on the Cloudflare edge.
130
130
 
package/bin/cli.mjs CHANGED
@@ -1,8 +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. 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:
4
+ // or bad input. The two v2 discovery checks and the input-path note carry status
5
+ // "info" and move no exit code, by design. Same checks and JSON shape as the hosted validator:
6
6
  // curl -H "Accept: application/json" "https://turva.dev/llms-txt-validator?url=example.com"
7
7
  import { validateHost } from "../src/index.mjs";
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turva-llms-txt-validator",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
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",
package/src/index.mjs CHANGED
@@ -63,6 +63,14 @@ export function maskLocation(href, base) {
63
63
  return at < from ? s : s.slice(0, from) + "***@" + s.slice(at + 1);
64
64
  }
65
65
 
66
+ // Unicode bidi controls (U+202A to U+202E, U+2066 to U+2069) have no place in a check's
67
+ // detail: text quoted from the fetched file could otherwise read in a different order here
68
+ // than in the file itself, and the right-to-left override is the classic way to do that
69
+ // (round 19, V6-U1). Mirrors worker.js stripBidi().
70
+ export function stripBidi(s) {
71
+ return String(s).replace(/[\u202A-\u202E\u2066-\u2069]/g, "");
72
+ }
73
+
66
74
  export function normalizeHostInput(raw) {
67
75
  let s = String(raw || "").trim().toLowerCase();
68
76
  if (!s) return null;
@@ -72,7 +80,25 @@ export function normalizeHostInput(raw) {
72
80
  if (u.protocol !== "https:" && u.protocol !== "http:") return null;
73
81
  if (u.port && u.port !== "443" && u.port !== "80") return null;
74
82
  if (u.username || u.password) return null;
75
- return u.hostname;
83
+ // One trailing dot names the same host in absolute form, so "example.com." is read as
84
+ // example.com instead of refused (round 19, K7-P5). Mirrors worker.js normalizeHostInput().
85
+ const typedHost = u.hostname;
86
+ return typedHost.length > 1 && typedHost.endsWith(".") ? typedHost.slice(0, -1) : typedHost;
87
+ }
88
+
89
+ // The path of the address as typed, when it is neither the root nor /llms.txt. validateHost keeps its
90
+ // reads pinned to /llms.txt and the home page, because a fixed path is part of the guard against use
91
+ // as a fetch proxy, so a typed /docs/llms.txt used to be dropped without a word while the result
92
+ // described the root file (outside retest 2026-09-26). The result now names the unused path in an
93
+ // info check. The case of the path is kept, and the query and fragment are never returned.
94
+ // Mirrors worker.js enteredPath() (v3.176.0).
95
+ export function enteredPath(raw) {
96
+ let s = String(raw || "").trim();
97
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) s = "https://" + s;
98
+ let u;
99
+ try { u = new URL(s); } catch { return ""; }
100
+ const path = u.pathname;
101
+ return path === "/" || path === "" || path === "/llms.txt" ? "" : path;
76
102
  }
77
103
 
78
104
  export function isValidPublicHost(host) {
@@ -80,7 +106,9 @@ export function isValidPublicHost(host) {
80
106
  if (host.startsWith("[") || /^\d+\.\d+\.\d+\.\d+$/.test(host)) return false;
81
107
  if (!/^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{1,62}$/.test(host)) return false;
82
108
  const tld = host.split(".").pop();
83
- if (["localhost", "local", "internal", "home", "lan", "corp", "test", "invalid"].includes(tld)) return false;
109
+ // arpa and onion since round 19 (P3): home.arpa is the home-network name of RFC 8375,
110
+ // in-addr.arpa and ip6.arpa name addresses, and an onion name never resolves on the public web.
111
+ if (["localhost", "local", "internal", "home", "lan", "corp", "test", "invalid", "arpa", "onion"].includes(tld)) return false;
84
112
  return true;
85
113
  }
86
114
 
@@ -272,7 +300,10 @@ export function validateLlmsTxt(f) {
272
300
  add("http-status", "fail", "File exists at /llms.txt", "expected HTTP 200, got " + f.status);
273
301
  return checks;
274
302
  }
275
- add("http-status", "pass", "File exists at /llms.txt", f.redirectedFrom ? "HTTP 200, followed a redirect from " + f.redirectedFrom + " to " + f.finalUrl : "HTTP 200");
303
+ // An accepted redirect is shown masked as well (round 19, K7-2, mirrored from worker.js
304
+ // v3.164.0): a query value or a fragment in a same-host Location used to reach this
305
+ // detail as sent.
306
+ add("http-status", "pass", "File exists at /llms.txt", f.redirectedFrom ? "HTTP 200, followed a redirect from " + maskLocation(f.redirectedFrom) + " to " + maskLocation(f.finalUrl) : "HTTP 200");
276
307
  const ct = (f.contentType || "").toLowerCase();
277
308
  const looksHtml = /^\s*(<!doctype|<html|<head|<body)/i.test(f.text);
278
309
  if (looksHtml) {
@@ -300,7 +331,20 @@ export function validateLlmsTxt(f) {
300
331
  if (afterH1.trim().startsWith("> ")) {
301
332
  add("summary", "pass", "Blockquote summary after the title", JSON.stringify(cut(afterH1.trim(), 80)));
302
333
  } else {
303
- add("summary", "warn", "Blockquote summary after the title", "recommended by the format (> one-line summary), not required");
334
+ // A blockquote further down, before the first H2, is a summary in the wrong place and not a
335
+ // missing one, and the detail says which (outside retest 2026-09-26, mirrored from worker.js
336
+ // v3.176.0). The status is warn either way, because the format places the summary directly
337
+ // after the title.
338
+ const fence = fenceMask(lines);
339
+ let late = -1;
340
+ for (let i = firstIdx + 1; i < lines.length; i++) {
341
+ if (fence[i]) continue;
342
+ if (/^ {0,3}## /.test(lines[i])) break;
343
+ if (/^ {0,3}> /.test(lines[i])) { late = i; break; }
344
+ }
345
+ add("summary", "warn", "Blockquote summary after the title", late === -1
346
+ ? "recommended by the format (> one-line summary), not required"
347
+ : "the blockquote at line " + (late + 1) + " comes after other text, and the format places the summary directly after the title");
304
348
  }
305
349
  // Headings are read outside fences only, and with the same indentation the H1 check and
306
350
  // listItemHasLink have allowed since 2026-08-29. Until 2026-09-10 this one line still
@@ -1254,5 +1298,13 @@ export async function validateHost(input, opts = {}) {
1254
1298
  discovery = validateV2Discovery(null, "the home page could not be read, so this was not measured");
1255
1299
  }
1256
1300
  const checks = validateLlmsTxt(fetched).concat(discovery);
1257
- return { target: "https://" + host + "/llms.txt", summary: summarizeChecks(checks), checks };
1301
+ // A path in the typed address is not used, and the result says so first (mirrored from worker.js
1302
+ // v3.176.0). Information moves no summary and no exit code.
1303
+ const unusedPath = enteredPath(input);
1304
+ if (unusedPath) checks.unshift({ id: "input-path", status: "info", label: "Path in the address you entered", detail: cut(unusedPath, 120) + " is not used, because the validator always reads /llms.txt at the root of the host" });
1305
+ // Text quoted from the fetched file leaves without bidirectional controls (round 19,
1306
+ // V6-U1, mirrored from worker.js v3.164.0): a site's own H1 or summary could otherwise
1307
+ // read in another order here than in the file itself.
1308
+ const stripped = checks.map((c) => (typeof c.detail === "string" ? { ...c, detail: stripBidi(c.detail) } : c));
1309
+ return { target: "https://" + host + "/llms.txt", summary: summarizeChecks(stripped), checks: stripped };
1258
1310
  }