turva-llms-txt-validator 0.3.6 → 0.3.8
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 +11 -9
- package/bin/cli.mjs +41 -15
- package/package.json +1 -1
- package/src/index.mjs +243 -61
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
|
|
|
@@ -54,7 +54,7 @@ Use JSON for automation and strict mode when warnings should fail a CI step:
|
|
|
54
54
|
npx --yes turva-llms-txt-validator example.com --json --strict
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
Completed validation returns `{ target, summary, checks }`. Each check contains `{ id, status, label, detail }`. With `--json`, input and fetch errors return `{ "error": "..." }` and exit with code `2`.
|
|
57
|
+
Completed validation returns `{ target, summary, checks }`. Each check contains `{ id, status, label, detail }`. With `--json`, input and fetch errors return `{ "error": "..." }` and exit with code `2`. An unknown option, a second domain and a missing domain are input errors too, and `--help` exits with code `0`.
|
|
58
58
|
|
|
59
59
|
## What it checks
|
|
60
60
|
|
|
@@ -63,14 +63,16 @@ Completed validation returns `{ target, summary, checks }`. Each check contains
|
|
|
63
63
|
| # | Check | Failure | Warning |
|
|
64
64
|
| --- | --- | --- | --- |
|
|
65
65
|
| 1 | `/llms.txt` returns HTTP 200 | Non-200 response, rejected or excessive redirects | none |
|
|
66
|
-
| 2 | Response is plain text | Body looks like an HTML page |
|
|
67
|
-
| 3 | First non-empty line is a Markdown H1 | Missing H1,
|
|
68
|
-
| 4 | Blockquote summary follows the title | none | Missing summary |
|
|
69
|
-
| 5 | H2 sections group the content | none | No H2 sections,
|
|
70
|
-
| 6 | Markdown links have names and absolute HTTP or HTTPS targets | none | Missing links, empty names, relative targets or
|
|
66
|
+
| 2 | Response is plain text | Body looks like an HTML page | The media type, read without its parameters, is neither `text/plain` nor `text/markdown` |
|
|
67
|
+
| 3 | First non-empty line is a Markdown H1 | Missing H1, a title indented as a code block, or an H1 with no text | none |
|
|
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
|
+
| 5 | H2 sections group the content | none | No H2 sections, no section with a Markdown link list, a heading between the title and the first H2, or a second H1 |
|
|
70
|
+
| 6 | Markdown links have names and absolute HTTP or HTTPS targets | none | Missing links, empty names, relative targets or targets that do not parse as HTTP or HTTPS URLs |
|
|
71
71
|
| 7 | File is small enough to read cheaply | none | Over 50 KB, or truncated at the 256 KB read limit |
|
|
72
72
|
| 8 | File contains no HTML markup | none | HTML tags found |
|
|
73
73
|
|
|
74
|
+
Links are read the way CommonMark reads them. A link inside inline code, after an escaped bracket or in an image does not count, and a link in an ordered list, a target in angle brackets and a target with a title do.
|
|
75
|
+
|
|
74
76
|
Some failures stop the file checks early. For example, an HTML response is reported as a failed plain-text check rather than parsed as Markdown.
|
|
75
77
|
|
|
76
78
|
### Home-page discovery
|
|
@@ -82,7 +84,7 @@ These checks are labelled **v2** in the output. They look for link relations in
|
|
|
82
84
|
| 9 | Home page points to its llms.txt | `rel="describedby"` has a non-empty target | `info` |
|
|
83
85
|
| 10 | Home page points to a Markdown version | `rel="alternate"` has media type `text/markdown` and a non-empty target | `info` |
|
|
84
86
|
|
|
85
|
-
The validator detects these declarations without fetching their targets. It also does not follow the links inside `llms.txt`, crawl the site or test whether an AI agent can complete a task there.
|
|
87
|
+
The validator detects these declarations without fetching their targets. It reads the first 65,536 characters of the head, and when a relation is missing from a longer head the result says that only that part was read. A target is shown with its credentials and fragment removed and its query values masked. It also does not follow the links inside `llms.txt`, crawl the site or test whether an AI agent can complete a task there.
|
|
86
88
|
|
|
87
89
|
## Use from Node
|
|
88
90
|
|
|
@@ -124,7 +126,7 @@ The default validation requests two documents: `https://<host>/llms.txt` and `ht
|
|
|
124
126
|
- Timeout: eight seconds per document, shared across its redirect chain.
|
|
125
127
|
- Read limit: 256 KB per response.
|
|
126
128
|
- 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.
|
|
129
|
+
- Host checks: IP literals, bracketed IPv6 addresses, localhost and the internal-use TLDs `localdomain`, `local`, `internal`, `home`, `lan`, `corp`, `test`, `invalid`, `arpa` and `onion` are rejected before a request is sent. An address that holds `@`, `?` or `#` is not repeated in the error message, because it can carry a user name, a password or a query value. One trailing dot on the host you give, as in `example.com.`, is removed first. Redirect targets are checked too.
|
|
128
130
|
|
|
129
131
|
**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
132
|
|
package/bin/cli.mjs
CHANGED
|
@@ -1,41 +1,67 @@
|
|
|
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
|
|
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
|
-
import { validateHost } from "../src/index.mjs";
|
|
7
|
+
import { validateHost, stripControls } from "../src/index.mjs";
|
|
8
8
|
|
|
9
|
+
const USAGE = [
|
|
10
|
+
"usage: llms-txt-validate <domain-or-url> [--json] [--strict]",
|
|
11
|
+
" --json print the result as JSON (same shape as the hosted validator)",
|
|
12
|
+
" --strict exit 1 on warnings too, for CI gates",
|
|
13
|
+
" --help print this help"
|
|
14
|
+
];
|
|
15
|
+
const KNOWN = new Set(["--json", "--strict", "--help", "-h"]);
|
|
16
|
+
|
|
17
|
+
// The arguments are read strictly since 0.3.8 (Tek-496). An unknown flag such as a misspelled
|
|
18
|
+
// --strcit used to be ignored, so a CI step ran without the gate it asked for, and a second
|
|
19
|
+
// target was dropped without a word. Both now stop with exit 2, and every input error is JSON
|
|
20
|
+
// when --json was given, the missing target included.
|
|
9
21
|
const args = process.argv.slice(2);
|
|
10
|
-
const
|
|
11
|
-
const
|
|
22
|
+
const json = args.includes("--json");
|
|
23
|
+
const flags = args.filter((a) => a.startsWith("-") && a !== "-");
|
|
24
|
+
const targets = args.filter((a) => !(a.startsWith("-") && a !== "-"));
|
|
25
|
+
|
|
26
|
+
function fail(message) {
|
|
27
|
+
if (json) console.log(JSON.stringify({ error: message }, null, 2));
|
|
28
|
+
else {
|
|
29
|
+
console.error("error: " + message);
|
|
30
|
+
console.error(USAGE[0]);
|
|
31
|
+
}
|
|
32
|
+
process.exit(2);
|
|
33
|
+
}
|
|
12
34
|
|
|
13
|
-
if (
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
console.log(" --strict exit 1 on warnings too, for CI gates");
|
|
17
|
-
process.exit(target ? 0 : 2);
|
|
35
|
+
if (flags.includes("--help") || flags.includes("-h")) {
|
|
36
|
+
for (const line of USAGE) console.log(line);
|
|
37
|
+
process.exit(0);
|
|
18
38
|
}
|
|
39
|
+
const unknown = flags.find((f) => !KNOWN.has(f));
|
|
40
|
+
if (unknown) fail("unknown option " + JSON.stringify(stripControls(unknown).slice(0, 40)));
|
|
41
|
+
if (targets.length === 0 || targets[0].trim() === "") fail("no domain or URL given");
|
|
42
|
+
if (targets.length > 1) fail("one domain or URL at a time, got " + targets.length);
|
|
19
43
|
|
|
20
44
|
const mark = { pass: "ok ", warn: "warn", fail: "FAIL", info: "info" };
|
|
21
45
|
try {
|
|
22
|
-
const result = await validateHost(
|
|
23
|
-
if (
|
|
46
|
+
const result = await validateHost(targets[0]);
|
|
47
|
+
if (json) {
|
|
24
48
|
console.log(JSON.stringify(result, null, 2));
|
|
25
49
|
} else {
|
|
50
|
+
// Details reach this point without control characters (validateHost strips them), so a
|
|
51
|
+
// published link cannot write an escape sequence to the terminal.
|
|
26
52
|
console.log(result.target);
|
|
27
53
|
for (const c of result.checks) console.log(" " + mark[c.status] + " " + c.label + " (" + c.detail + ")");
|
|
28
54
|
console.log("result: " + result.summary);
|
|
29
55
|
}
|
|
30
56
|
if (result.summary === "not valid") process.exit(1);
|
|
31
|
-
if (result.summary === "valid with warnings" && flags.
|
|
57
|
+
if (result.summary === "valid with warnings" && flags.includes("--strict")) process.exit(1);
|
|
32
58
|
process.exit(0);
|
|
33
59
|
} catch (err) {
|
|
34
|
-
const message = err && err.message ? err.message : String(err);
|
|
60
|
+
const message = stripControls(err && err.message ? err.message : String(err));
|
|
35
61
|
// The hosted validator answers every failure as JSON when JSON was asked for, so a CI
|
|
36
62
|
// step that parses --json output gets {"error": ...} here too instead of an empty stdout
|
|
37
63
|
// and a plain-text stderr (round 16 S3-3). The exit code is unchanged.
|
|
38
|
-
if (
|
|
64
|
+
if (json) console.log(JSON.stringify({ error: message }, null, 2));
|
|
39
65
|
else console.error("error: " + message);
|
|
40
66
|
process.exit(2);
|
|
41
67
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "turva-llms-txt-validator",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
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
|
@@ -71,6 +71,14 @@ export function stripBidi(s) {
|
|
|
71
71
|
return String(s).replace(/[\u202A-\u202E\u2066-\u2069]/g, "");
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// C0 and C1 control characters leave a check's detail as well. JSON escapes them, but a program
|
|
75
|
+
// that prints a detail to a terminal passes ESC through, so a published link could carry a
|
|
76
|
+
// sequence that clears the screen (Tek-496).
|
|
77
|
+
// Mirrors worker.js stripControls().
|
|
78
|
+
export function stripControls(s) {
|
|
79
|
+
return String(s).replace(/[\u0000-\u001F\u007F-\u009F]/g, "");
|
|
80
|
+
}
|
|
81
|
+
|
|
74
82
|
export function normalizeHostInput(raw) {
|
|
75
83
|
let s = String(raw || "").trim().toLowerCase();
|
|
76
84
|
if (!s) return null;
|
|
@@ -86,6 +94,21 @@ export function normalizeHostInput(raw) {
|
|
|
86
94
|
return typedHost.length > 1 && typedHost.endsWith(".") ? typedHost.slice(0, -1) : typedHost;
|
|
87
95
|
}
|
|
88
96
|
|
|
97
|
+
// The path of the address as typed, when it is neither the root nor /llms.txt. validateHost keeps its
|
|
98
|
+
// reads pinned to /llms.txt and the home page, because a fixed path is part of the guard against use
|
|
99
|
+
// as a fetch proxy, so a typed /docs/llms.txt used to be dropped without a word while the result
|
|
100
|
+
// described the root file (outside retest 2026-09-26). The result now names the unused path in an
|
|
101
|
+
// info check. The case of the path is kept, and the query and fragment are never returned.
|
|
102
|
+
// Mirrors worker.js enteredPath() (v3.176.0).
|
|
103
|
+
export function enteredPath(raw) {
|
|
104
|
+
let s = String(raw || "").trim();
|
|
105
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) s = "https://" + s;
|
|
106
|
+
let u;
|
|
107
|
+
try { u = new URL(s); } catch { return ""; }
|
|
108
|
+
const path = u.pathname;
|
|
109
|
+
return path === "/" || path === "" || path === "/llms.txt" ? "" : path;
|
|
110
|
+
}
|
|
111
|
+
|
|
89
112
|
export function isValidPublicHost(host) {
|
|
90
113
|
if (!host || host.length > 253) return false;
|
|
91
114
|
if (host.startsWith("[") || /^\d+\.\d+\.\d+\.\d+$/.test(host)) return false;
|
|
@@ -93,7 +116,9 @@ export function isValidPublicHost(host) {
|
|
|
93
116
|
const tld = host.split(".").pop();
|
|
94
117
|
// arpa and onion since round 19 (P3): home.arpa is the home-network name of RFC 8375,
|
|
95
118
|
// in-addr.arpa and ip6.arpa name addresses, and an onion name never resolves on the public web.
|
|
96
|
-
|
|
119
|
+
// localdomain since 2026-09-27 (Tek-496): localhost.localdomain is the loopback alias that many
|
|
120
|
+
// hosts files carry, and it passed this list although the name never resolves on the public web.
|
|
121
|
+
if (["localhost", "localdomain", "local", "internal", "home", "lan", "corp", "test", "invalid", "arpa", "onion"].includes(tld)) return false;
|
|
97
122
|
return true;
|
|
98
123
|
}
|
|
99
124
|
|
|
@@ -170,28 +195,125 @@ export async function fetchLlmsTxt(host, opts = {}) {
|
|
|
170
195
|
}
|
|
171
196
|
}
|
|
172
197
|
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
198
|
+
// A backslash escapes the character after it when an odd number of backslashes precede it.
|
|
199
|
+
function isEscaped(s, i) {
|
|
200
|
+
let n = 0;
|
|
201
|
+
for (let b = i - 1; b >= 0 && s[b] === "\\"; b--) n++;
|
|
202
|
+
return n % 2 === 1;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Inline code spans of one line, marked per character, so a link written inside backticks is
|
|
206
|
+
// read as the code it is (outside audit 2026-09-26, Tek-496). A span opens with a run of
|
|
207
|
+
// backticks and closes with the next run of the same length on the same line. An escaped first
|
|
208
|
+
// backtick is literal and the rest of its run still opens, while a closing run is never escaped,
|
|
209
|
+
// because a backslash inside a code span is literal. Runs are grouped by length and every group
|
|
210
|
+
// is read with one forward pointer, so the scan stays linear on a line of unmatched runs.
|
|
211
|
+
function codeSpanMask(line) {
|
|
212
|
+
const mask = new Uint8Array(line.length);
|
|
213
|
+
const runs = [];
|
|
214
|
+
for (let i = 0; i < line.length; ) {
|
|
215
|
+
if (line[i] !== "`") { i++; continue; }
|
|
216
|
+
let j = i;
|
|
217
|
+
while (j < line.length && line[j] === "`") j++;
|
|
218
|
+
runs.push({ at: i, len: j - i, escaped: isEscaped(line, i) });
|
|
219
|
+
i = j;
|
|
220
|
+
}
|
|
221
|
+
const byLen = new Map();
|
|
222
|
+
runs.forEach((r, k) => { if (!byLen.has(r.len)) byLen.set(r.len, []); byLen.get(r.len).push(k); });
|
|
223
|
+
const ptr = new Map();
|
|
224
|
+
for (let k = 0; k < runs.length; k++) {
|
|
225
|
+
const r = runs[k];
|
|
226
|
+
const len = r.escaped ? r.len - 1 : r.len;
|
|
227
|
+
const list = len > 0 ? byLen.get(len) : undefined;
|
|
228
|
+
if (!list) continue;
|
|
229
|
+
let p = ptr.get(len) || 0;
|
|
230
|
+
while (p < list.length && list[p] <= k) p++;
|
|
231
|
+
ptr.set(len, p);
|
|
232
|
+
if (p === list.length) continue;
|
|
233
|
+
const close = runs[list[p]];
|
|
234
|
+
mask.fill(1, r.escaped ? r.at + 1 : r.at, close.at + close.len);
|
|
235
|
+
k = list[p];
|
|
236
|
+
}
|
|
237
|
+
return mask;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Every markdown link in the prose, scanned once from left to right. A "[" inside a code span,
|
|
241
|
+
// an escaped "\[" and the " {
|
|
180
248
|
const out = [];
|
|
249
|
+
const code = new Uint8Array(text.length);
|
|
250
|
+
{
|
|
251
|
+
let at = 0;
|
|
252
|
+
for (const line of text.split("\n")) {
|
|
253
|
+
code.set(codeSpanMask(line), at);
|
|
254
|
+
at += line.length + 1;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const cache = Object.create(null);
|
|
258
|
+
const nextIdx = (c, from) => {
|
|
259
|
+
const h = cache[c];
|
|
260
|
+
if (h && h.from <= from && (h.at === -1 || h.at >= from)) return h.at;
|
|
261
|
+
const at = text.indexOf(c, from);
|
|
262
|
+
cache[c] = { from, at };
|
|
263
|
+
return at;
|
|
264
|
+
};
|
|
265
|
+
const isBlank = (c) => c === " " || c === "\t";
|
|
266
|
+
// Spaces, tabs and at most one line ending may stand around the destination and the title, as
|
|
267
|
+
// CommonMark allows; "[a]( b )" and a ")" on the next line were missed until the review of
|
|
268
|
+
// 2026-09-27. A blank line still ends the link.
|
|
269
|
+
const skipSpace = (p) => {
|
|
270
|
+
while (p < text.length && isBlank(text[p])) p++;
|
|
271
|
+
if (text[p] === "\n") { p++; while (p < text.length && isBlank(text[p])) p++; }
|
|
272
|
+
return p;
|
|
273
|
+
};
|
|
274
|
+
const afterTarget = (target, p) => {
|
|
275
|
+
const q = skipSpace(p);
|
|
276
|
+
if (text[q] === ")") return { target, end: q };
|
|
277
|
+
const quote = text[q];
|
|
278
|
+
if (q > p && (quote === '"' || quote === "'")) {
|
|
279
|
+
const close = nextIdx(quote, q + 1);
|
|
280
|
+
const nl = nextIdx("\n", q + 1);
|
|
281
|
+
if (close === -1 || (nl !== -1 && nl < close)) return null;
|
|
282
|
+
const r = skipSpace(close + 1);
|
|
283
|
+
if (text[r] === ")") return { target, end: r };
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
};
|
|
181
287
|
let failEnd = -1;
|
|
288
|
+
const readTarget = (k) => {
|
|
289
|
+
k = skipSpace(k);
|
|
290
|
+
if (text[k] === "<") {
|
|
291
|
+
const gt = nextIdx(">", k + 1);
|
|
292
|
+
if (gt === -1 || gt === k + 1) return null;
|
|
293
|
+
const lt = nextIdx("<", k + 1);
|
|
294
|
+
const nl = nextIdx("\n", k + 1);
|
|
295
|
+
if ((lt !== -1 && lt < gt) || (nl !== -1 && nl < gt)) return null;
|
|
296
|
+
return afterTarget(text.slice(k + 1, gt), gt + 1);
|
|
297
|
+
}
|
|
298
|
+
if (k <= failEnd) return null;
|
|
299
|
+
let e = k;
|
|
300
|
+
while (e < text.length && text[e] !== ")" && !/\s/.test(text[e])) e++;
|
|
301
|
+
if (e === k) return null;
|
|
302
|
+
if (text[e] === ")") return { target: text.slice(k, e), end: e };
|
|
303
|
+
const found = e < text.length ? afterTarget(text.slice(k, e), e) : null;
|
|
304
|
+
if (!found) failEnd = e;
|
|
305
|
+
return found;
|
|
306
|
+
};
|
|
182
307
|
for (let i = 0; i < text.length; i++) {
|
|
183
|
-
if (text[i] !== "[") continue;
|
|
308
|
+
if (text[i] !== "[" || code[i] || isEscaped(text, i)) continue;
|
|
309
|
+
if (i > 0 && text[i - 1] === "!" && !isEscaped(text, i - 1)) continue;
|
|
184
310
|
let j = i + 1;
|
|
185
|
-
while (j < text.length && text[j] !== "]" && text[j] !== "[") j
|
|
311
|
+
while (j < text.length && text[j] !== "]" && text[j] !== "[") j += text[j] === "\\" ? 2 : 1;
|
|
186
312
|
if (j >= text.length) break;
|
|
187
313
|
if (text[j] === "[") { i = j - 1; continue; }
|
|
188
314
|
if (text[j + 1] !== "(") { i = j; continue; }
|
|
189
|
-
const
|
|
190
|
-
if (
|
|
191
|
-
let e = k;
|
|
192
|
-
while (e < text.length && text[e] !== ")" && !/\s/.test(text[e])) e++;
|
|
193
|
-
if (e > k && text[e] === ")") { out.push({ name: text.slice(i + 1, j), target: text.slice(k, e) }); i = e; continue; }
|
|
194
|
-
if (text[e] !== ")") failEnd = e;
|
|
315
|
+
const t = readTarget(j + 2);
|
|
316
|
+
if (t) { out.push({ name: text.slice(i + 1, j), target: t.target }); i = t.end; continue; }
|
|
195
317
|
i = j + 1;
|
|
196
318
|
}
|
|
197
319
|
return out;
|
|
@@ -232,39 +354,51 @@ function fenceMask(lines) {
|
|
|
232
354
|
}
|
|
233
355
|
return mask;
|
|
234
356
|
}
|
|
235
|
-
// A markdown list item that carries a link
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
// is remembered.
|
|
357
|
+
// A markdown list item that carries a link. A bullet or an ordered marker (1. or 1)) followed by
|
|
358
|
+
// a space or a tab opens the item; an ordered list and a tab after the marker were not read as
|
|
359
|
+
// list items until 2026-09-27 (Tek-496). The link is found by collectLinks, so code, escapes
|
|
360
|
+
// and images count here exactly as they do in the links check. A bare CR, U+2028 and U+2029 stay
|
|
361
|
+
// inside a line after split(/\r?\n/), and a link behind one has never counted, so the item is read
|
|
362
|
+
// up to the first of them.
|
|
242
363
|
function listItemHasLink(l) {
|
|
243
|
-
const m = /^ {0,3}[-*+] /.exec(l);
|
|
364
|
+
const m = /^ {0,3}(?:[-*+]|\d{1,9}[.)])[ \t]/.exec(l);
|
|
244
365
|
if (!m) return false;
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
366
|
+
const sep = l.slice(m[0].length).search(/[\r\u2028\u2029]/);
|
|
367
|
+
const body = sep === -1 ? l.slice(m[0].length) : l.slice(m[0].length, m[0].length + sep);
|
|
368
|
+
return collectLinks(body).length > 0;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// The text of an ATX H1, or null when the line is not one. One to three leading spaces, a
|
|
372
|
+
// single "#", then a space, a tab or the end of the line. A closing run of "#" preceded by a
|
|
373
|
+
// space or a tab is not part of the title, and the title is what remains once both ends are
|
|
374
|
+
// trimmed. Until 2026-09-27 the check demanded exactly one space and a visible character after
|
|
375
|
+
// it, so "# Example" and "#\tExample" failed and "# ###", a heading with no text, passed
|
|
376
|
+
// (Tek-496). The closing run is read by index, not by a pattern, so a long line of
|
|
377
|
+
// spaces and "#" stays linear.
|
|
378
|
+
function h1Title(line) {
|
|
379
|
+
const m = /^ {0,3}#(?=[ \t]|$)/.exec(line);
|
|
380
|
+
if (!m) return null;
|
|
381
|
+
const s = line.slice(m[0].length);
|
|
382
|
+
let end = s.length;
|
|
383
|
+
while (end > 0 && (s[end - 1] === " " || s[end - 1] === "\t")) end--;
|
|
384
|
+
let h = end;
|
|
385
|
+
while (h > 0 && s[h - 1] === "#") h--;
|
|
386
|
+
if (h < end && (h === 0 || s[h - 1] === " " || s[h - 1] === "\t")) end = h;
|
|
387
|
+
let start = 0;
|
|
388
|
+
while (start < end && (s[start] === " " || s[start] === "\t")) start++;
|
|
389
|
+
while (end > start && (s[end - 1] === " " || s[end - 1] === "\t")) end--;
|
|
390
|
+
return s.slice(start, end);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// An absolute link target an agent can follow: it starts with http:// or https:// and the URL
|
|
394
|
+
// parser reads it with a host. Until 2026-09-27 a pattern accepted anything after the scheme,
|
|
395
|
+
// so https://% and https://example.com:99999/ passed as absolute URLs although no client can
|
|
396
|
+
// open them (Tek-496).
|
|
397
|
+
function isAbsoluteHttpUrl(t) {
|
|
398
|
+
if (!/^https?:\/\//i.test(t)) return false;
|
|
399
|
+
let u;
|
|
400
|
+
try { u = new URL(t); } catch { return false; }
|
|
401
|
+
return (u.protocol === "http:" || u.protocol === "https:") && u.hostname !== "";
|
|
268
402
|
}
|
|
269
403
|
|
|
270
404
|
function redirectFailDetail(f) {
|
|
@@ -295,8 +429,11 @@ export function validateLlmsTxt(f) {
|
|
|
295
429
|
add("content-type", "fail", "Response is plain text", "the body looks like an HTML page, not an llms.txt file");
|
|
296
430
|
return checks;
|
|
297
431
|
}
|
|
298
|
-
|
|
299
|
-
|
|
432
|
+
// The media type is compared whole, without its parameters. A substring test passed
|
|
433
|
+
// application/x-text/plain and text/markdownish until 2026-09-27 (Tek-496).
|
|
434
|
+
const mediaType = ct.split(";")[0].trim();
|
|
435
|
+
if (mediaType === "text/plain" || mediaType === "text/markdown") {
|
|
436
|
+
add("content-type", "pass", "Response is plain text", mediaType);
|
|
300
437
|
} else {
|
|
301
438
|
add("content-type", "warn", "Response is plain text", "content-type is " + (ct.split(";")[0] || "missing") + ", text/plain or text/markdown is the convention");
|
|
302
439
|
}
|
|
@@ -307,8 +444,12 @@ export function validateLlmsTxt(f) {
|
|
|
307
444
|
// The line is read as markdown and not trimmed first. Four spaces or a tab make it an
|
|
308
445
|
// indented code block rather than a heading, and trimming erased that difference, so
|
|
309
446
|
// " # Site" passed as the H1 until 2026-08-29. CommonMark allows three spaces.
|
|
310
|
-
|
|
447
|
+
// h1Title reads the heading the way CommonMark does, its closing run included (Tek-496).
|
|
448
|
+
const title = h1Title(firstRaw);
|
|
449
|
+
if (title) {
|
|
311
450
|
add("h1-title", "pass", "Starts with an H1 title", JSON.stringify(cut(first, 80)));
|
|
451
|
+
} else if (title === "") {
|
|
452
|
+
add("h1-title", "fail", "Starts with an H1 title", "the H1 has no text; the format requires the project name there");
|
|
312
453
|
} else {
|
|
313
454
|
add("h1-title", "fail", "Starts with an H1 title", "the first non-empty line should be a markdown H1 (# Site name)");
|
|
314
455
|
}
|
|
@@ -316,7 +457,20 @@ export function validateLlmsTxt(f) {
|
|
|
316
457
|
if (afterH1.trim().startsWith("> ")) {
|
|
317
458
|
add("summary", "pass", "Blockquote summary after the title", JSON.stringify(cut(afterH1.trim(), 80)));
|
|
318
459
|
} else {
|
|
319
|
-
|
|
460
|
+
// A blockquote further down, before the first H2, is a summary in the wrong place and not a
|
|
461
|
+
// missing one, and the detail says which (outside retest 2026-09-26, mirrored from worker.js
|
|
462
|
+
// v3.176.0). The status is warn either way, because the format places the summary directly
|
|
463
|
+
// after the title.
|
|
464
|
+
const fence = fenceMask(lines);
|
|
465
|
+
let late = -1;
|
|
466
|
+
for (let i = firstIdx + 1; i < lines.length; i++) {
|
|
467
|
+
if (fence[i]) continue;
|
|
468
|
+
if (/^ {0,3}## /.test(lines[i])) break;
|
|
469
|
+
if (/^ {0,3}> /.test(lines[i])) { late = i; break; }
|
|
470
|
+
}
|
|
471
|
+
add("summary", "warn", "Blockquote summary after the title", late === -1
|
|
472
|
+
? "recommended by the format (> one-line summary), not required"
|
|
473
|
+
: "the blockquote at line " + (late + 1) + " comes after other text, and the format places the summary directly after the title");
|
|
320
474
|
}
|
|
321
475
|
// Headings are read outside fences only, and with the same indentation the H1 check and
|
|
322
476
|
// listItemHasLink have allowed since 2026-08-29. Until 2026-09-10 this one line still
|
|
@@ -326,14 +480,19 @@ export function validateLlmsTxt(f) {
|
|
|
326
480
|
const h2Count = lines.filter((l, i) => !fenced[i] && /^ {0,3}## /.test(l)).length;
|
|
327
481
|
// A section counts when it carries a file list. An H2 followed by a paragraph satisfied
|
|
328
482
|
// this check until 2026-08-29, and the format puts each section's links in a list.
|
|
329
|
-
|
|
483
|
+
// A heading between the title and the first H2, or a second H1 anywhere, is out of place,
|
|
484
|
+
// because the format has one H1 and allows only paragraphs and lists before the file lists.
|
|
485
|
+
// Both passed until 2026-09-27 (Tek-496). The first one is named in the detail.
|
|
486
|
+
let sectionsWithList = 0, misplaced = -1;
|
|
330
487
|
{
|
|
331
|
-
let inSection = false, counted = false;
|
|
488
|
+
let inSection = false, counted = false, seenH2 = false;
|
|
332
489
|
for (let i = 0; i < lines.length; i++) {
|
|
333
490
|
const l = lines[i];
|
|
334
491
|
if (fenced[i]) continue;
|
|
335
|
-
if (/^ {0,3}## /.test(l)) { inSection = true; counted = false; continue; }
|
|
336
|
-
|
|
492
|
+
if (/^ {0,3}## /.test(l)) { inSection = true; counted = false; seenH2 = true; continue; }
|
|
493
|
+
const h1 = /^ {0,3}#(?:[ \t]|$)/.test(l);
|
|
494
|
+
if (misplaced === -1 && i > firstIdx && (h1 || (!seenH2 && /^ {0,3}#{3,6}(?:[ \t]|$)/.test(l)))) misplaced = i;
|
|
495
|
+
if (h1) { inSection = false; continue; }
|
|
337
496
|
if (inSection && !counted && listItemHasLink(l)) { sectionsWithList++; counted = true; }
|
|
338
497
|
}
|
|
339
498
|
}
|
|
@@ -344,6 +503,11 @@ export function validateLlmsTxt(f) {
|
|
|
344
503
|
} else {
|
|
345
504
|
add("sections", "warn", "H2 sections group the content", "no H2 sections found; sections are the convention for grouping links");
|
|
346
505
|
}
|
|
506
|
+
if (misplaced !== -1) {
|
|
507
|
+
const s = checks[checks.length - 1];
|
|
508
|
+
s.status = "warn";
|
|
509
|
+
s.detail += "; the heading at line " + (misplaced + 1) + " is out of place, because the format has one H1 and no other heading before the first H2";
|
|
510
|
+
}
|
|
347
511
|
// Links are collected from the prose only, for the same reason the headings are: a link
|
|
348
512
|
// shown inside a code fence is an example of a link, not one an agent can follow.
|
|
349
513
|
const links = collectLinks(lines.filter((l, i) => !fenced[i]).join("\n"));
|
|
@@ -351,7 +515,7 @@ export function validateLlmsTxt(f) {
|
|
|
351
515
|
// bare "https://" both counted as valid absolute links until 2026-08-29.
|
|
352
516
|
const named = links.filter((m) => m.name.trim() !== "");
|
|
353
517
|
const unnamed = links.length - named.length;
|
|
354
|
-
const absolute = named.filter((m) =>
|
|
518
|
+
const absolute = named.filter((m) => isAbsoluteHttpUrl(m.target)).length;
|
|
355
519
|
if (links.length === 0) {
|
|
356
520
|
add("links", "warn", "Markdown links an agent can follow", "no markdown links found");
|
|
357
521
|
} else if (unnamed > 0) {
|
|
@@ -361,7 +525,7 @@ export function validateLlmsTxt(f) {
|
|
|
361
525
|
} else {
|
|
362
526
|
const relativeCount = named.filter((m) => !/^[a-z][a-z0-9+.-]*:/i.test(m.target)).length;
|
|
363
527
|
const hostless = named.length - absolute - relativeCount;
|
|
364
|
-
add("links", "warn", "Markdown links an agent can follow", named.length + " links, " + relativeCount + " relative" + (hostless > 0 ? " and " + hostless + "
|
|
528
|
+
add("links", "warn", "Markdown links an agent can follow", named.length + " links, " + relativeCount + " relative" + (hostless > 0 ? " and " + hostless + " that are not valid http or https URLs" : "") + "; absolute URLs travel better when the file is read out of context");
|
|
365
529
|
}
|
|
366
530
|
if (f.truncated) {
|
|
367
531
|
add("size", "warn", "Small enough to be cheap to read", "over 256 KB, read truncated");
|
|
@@ -1195,7 +1359,8 @@ export function findLinkRelations(html, linkHeader) {
|
|
|
1195
1359
|
// The cap applies after the head is collected, so it bounds this tag scan and not the head
|
|
1196
1360
|
// parse, and a relation declared past it is not found here. The Link response header is
|
|
1197
1361
|
// read separately below.
|
|
1198
|
-
const
|
|
1362
|
+
const whole = headOfDocument(html);
|
|
1363
|
+
const head = whole.slice(0, 65536);
|
|
1199
1364
|
for (const tag of htmlTags(head)) {
|
|
1200
1365
|
// The name has to END at "link": a real parser reads "<link<link" as ONE tag whose
|
|
1201
1366
|
// NAME is "link<link", not as a link element, so \b would count a relation the site
|
|
@@ -1225,6 +1390,9 @@ export function findLinkRelations(html, linkHeader) {
|
|
|
1225
1390
|
if (!found.describedby && href && rel.includes("describedby")) found.describedby = href;
|
|
1226
1391
|
if (!found.markdown && href && rel.includes("alternate") && isMarkdownHeader) found.markdown = href;
|
|
1227
1392
|
}
|
|
1393
|
+
// A head longer than the cap was read in part, so a relation missing from that part is
|
|
1394
|
+
// reported as missing from the part read, not from the page (Tek-496).
|
|
1395
|
+
if (whole.length > 65536) found.headCut = true;
|
|
1228
1396
|
return found;
|
|
1229
1397
|
}
|
|
1230
1398
|
|
|
@@ -1236,10 +1404,14 @@ export function validateV2Discovery(found, unreadReason) {
|
|
|
1236
1404
|
add("v2-markdown-alternate", "info", "Home page points to a markdown version (v2)", unreadReason);
|
|
1237
1405
|
return checks;
|
|
1238
1406
|
}
|
|
1407
|
+
// A target is shown through maskLocation, as a redirect target is, so a user name, a password,
|
|
1408
|
+
// a query value or a fragment in a published link stays out of the report; until 2026-09-27 it
|
|
1409
|
+
// was only cut (Tek-496). A relative target stays relative.
|
|
1410
|
+
const where = found.headCut ? "in the first 65,536 characters of the head, the part that was read, or in the Link header" : "in the head or the Link header";
|
|
1239
1411
|
add("v2-describedby", found.describedby ? "pass" : "info", "Home page points to its llms.txt (v2)",
|
|
1240
|
-
found.describedby ? 'rel="describedby" to ' + cut(found.describedby, 120) : 'no rel="describedby"
|
|
1412
|
+
found.describedby ? 'rel="describedby" to ' + cut(maskLocation(found.describedby), 120) : 'no rel="describedby" ' + where + '; v2 recommends it so an agent finds the file without guessing');
|
|
1241
1413
|
add("v2-markdown-alternate", found.markdown ? "pass" : "info", "Home page points to a markdown version (v2)",
|
|
1242
|
-
found.markdown ? 'rel="alternate" type="text/markdown" to ' + cut(found.markdown, 120) : 'no rel="alternate" type="text/markdown"
|
|
1414
|
+
found.markdown ? 'rel="alternate" type="text/markdown" to ' + cut(maskLocation(found.markdown), 120) : 'no rel="alternate" type="text/markdown" ' + where + '; v2 recommends it so an agent finds the markdown form without guessing');
|
|
1243
1415
|
return checks;
|
|
1244
1416
|
}
|
|
1245
1417
|
|
|
@@ -1252,7 +1424,13 @@ export function summarizeChecks(checks) {
|
|
|
1252
1424
|
export async function validateHost(input, opts = {}) {
|
|
1253
1425
|
const host = normalizeHostInput(input);
|
|
1254
1426
|
if (!host || !isValidPublicHost(host)) {
|
|
1255
|
-
|
|
1427
|
+
// The input is repeated only when it cannot carry a secret. An entry with an @, a ? or a #
|
|
1428
|
+
// can hold a user name, a password or a query value, and until 0.3.8 the message copied
|
|
1429
|
+
// its first 120 characters into stderr and the --json error (Tek-496). Such an entry is
|
|
1430
|
+
// named by the host the parser reads from it, or not at all, as the hosted form does.
|
|
1431
|
+
const raw = String(input ?? "");
|
|
1432
|
+
const shown = /[@?#]/.test(raw) ? normalizeHostInput(raw) || "" : stripControls(cut(raw, 120));
|
|
1433
|
+
throw new Error(shown ? "not a public domain name: " + shown : "not a public domain name");
|
|
1256
1434
|
}
|
|
1257
1435
|
// opts comes from the caller, and path and accept exist for this function's own
|
|
1258
1436
|
// second read. Forwarding them to the first read would let a caller point the
|
|
@@ -1270,9 +1448,13 @@ export async function validateHost(input, opts = {}) {
|
|
|
1270
1448
|
discovery = validateV2Discovery(null, "the home page could not be read, so this was not measured");
|
|
1271
1449
|
}
|
|
1272
1450
|
const checks = validateLlmsTxt(fetched).concat(discovery);
|
|
1451
|
+
// A path in the typed address is not used, and the result says so first (mirrored from worker.js
|
|
1452
|
+
// v3.176.0). Information moves no summary and no exit code.
|
|
1453
|
+
const unusedPath = enteredPath(input);
|
|
1454
|
+
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" });
|
|
1273
1455
|
// Text quoted from the fetched file leaves without bidirectional controls (round 19,
|
|
1274
1456
|
// V6-U1, mirrored from worker.js v3.164.0): a site's own H1 or summary could otherwise
|
|
1275
1457
|
// read in another order here than in the file itself.
|
|
1276
|
-
const stripped = checks.map((c) => (typeof c.detail === "string" ? { ...c, detail: stripBidi(c.detail) } : c));
|
|
1458
|
+
const stripped = checks.map((c) => (typeof c.detail === "string" ? { ...c, detail: stripControls(stripBidi(c.detail)) } : c));
|
|
1277
1459
|
return { target: "https://" + host + "/llms.txt", summary: summarizeChecks(stripped), checks: stripped };
|
|
1278
1460
|
}
|