turva-llms-txt-validator 0.3.7 → 0.3.9

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
@@ -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": "...", "target": "..." }` and exit with code `2`. The target is the address given on the command line, with any user name, password and query value masked, and a network error adds `code`, such as `EAI_AGAIN`. An unknown option, a second domain and a missing domain are input errors too. `--help` exits with code `0`, and `--version` or `-V` prints the installed version and 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 | Content type is neither `text/plain` nor `text/markdown` |
67
- | 3 | First non-empty line is a Markdown H1 | Missing H1, including a title indented as a code block | none |
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
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, or no section contains a Markdown link list |
70
- | 6 | Markdown links have names and absolute HTTP or HTTPS targets | none | Missing links, empty names, relative targets or unsupported targets |
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
 
@@ -0,0 +1,40 @@
1
+ # Third-party notices
2
+
3
+ This package's own license is MIT, in `LICENSE`. The named character reference
4
+ table in `src/index.mjs` (`NAMED_REFERENCE_DATA`) is generated from data
5
+ published by a third-party package and carries that package's own license
6
+ below. This is a build-time data source, not an installed runtime dependency:
7
+ the table is generated once and copied into `src/index.mjs`, so no reference
8
+ to it appears in `package.json` or in the dependency tree an installer sees.
9
+
10
+ ## character-entities
11
+
12
+ - Package: `character-entities`
13
+ - Version used to generate the table: 2.0.2
14
+ - URL: <https://github.com/wooorm/character-entities>
15
+ - License: MIT
16
+
17
+ ```
18
+ (The MIT License)
19
+
20
+ Copyright (c) 2015 Titus Wormer <tituswormer@gmail.com>
21
+
22
+ Permission is hereby granted, free of charge, to any person obtaining
23
+ a copy of this software and associated documentation files (the
24
+ 'Software'), to deal in the Software without restriction, including
25
+ without limitation the rights to use, copy, modify, merge, publish,
26
+ distribute, sublicense, and/or sell copies of the Software, and to
27
+ permit persons to whom the Software is furnished to do so, subject to
28
+ the following conditions:
29
+
30
+ The above copyright notice and this permission notice shall be
31
+ included in all copies or substantial portions of the Software.
32
+
33
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
34
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
35
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
36
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
37
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
38
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
39
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
40
+ ```
package/bin/cli.mjs CHANGED
@@ -4,38 +4,88 @@
4
4
  // or bad input. The two v2 discovery checks and the input-path note carry status
5
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 { readFileSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+ import { validateHost, stripControls, maskLocation } from "../src/index.mjs";
8
10
 
11
+ const USAGE = [
12
+ "usage: llms-txt-validate <domain-or-url> [--json] [--strict]",
13
+ " --json print the result as JSON (same shape as the hosted validator)",
14
+ " --strict exit 1 on warnings too, for CI gates",
15
+ " --help print this help",
16
+ " --version print the installed version and exit"
17
+ ];
18
+ const KNOWN = new Set(["--json", "--strict", "--help", "-h", "--version", "-V"]);
19
+
20
+ // The arguments are read strictly since 0.3.8 (Tek-496). An unknown flag such as a misspelled
21
+ // --strcit used to be ignored, so a CI step ran without the gate it asked for, and a second
22
+ // target was dropped without a word. Both now stop with exit 2, and every input error is JSON
23
+ // when --json was given, the missing target included.
9
24
  const args = process.argv.slice(2);
10
- const flags = new Set(args.filter((a) => a.startsWith("--")));
11
- const target = args.find((a) => !a.startsWith("--"));
25
+ const json = args.includes("--json");
26
+ const flags = args.filter((a) => a.startsWith("-") && a !== "-");
27
+ const targets = args.filter((a) => !(a.startsWith("-") && a !== "-"));
28
+
29
+ function fail(message) {
30
+ if (json) console.log(JSON.stringify({ error: message }, null, 2));
31
+ else {
32
+ console.error("error: " + message);
33
+ console.error(USAGE[0]);
34
+ }
35
+ process.exit(2);
36
+ }
12
37
 
13
- if (!target || flags.has("--help")) {
14
- console.log("usage: llms-txt-validate <domain-or-url> [--json] [--strict]");
15
- console.log(" --json print the result as JSON (same shape as the hosted validator)");
16
- console.log(" --strict exit 1 on warnings too, for CI gates");
17
- process.exit(target ? 0 : 2);
38
+ if (flags.includes("--help") || flags.includes("-h")) {
39
+ for (const line of USAGE) console.log(line);
40
+ process.exit(0);
41
+ }
42
+ if (flags.includes("--version") || flags.includes("-V")) {
43
+ // Read package.json relative to this file, not the caller's cwd, so this works the same
44
+ // whether the CLI runs installed, linked or through npx (V-04, outside audit 2026-09-26).
45
+ // No network read: the version is the one actually installed, not the npm registry's latest.
46
+ const pkgUrl = new URL("../package.json", import.meta.url);
47
+ const pkg = JSON.parse(readFileSync(fileURLToPath(pkgUrl), "utf8"));
48
+ console.log(pkg.version);
49
+ process.exit(0);
18
50
  }
51
+ const unknown = flags.find((f) => !KNOWN.has(f));
52
+ if (unknown) fail("unknown option " + JSON.stringify(stripControls(unknown).slice(0, 40)));
53
+ if (targets.length === 0 || targets[0].trim() === "") fail("no domain or URL given");
54
+ if (targets.length > 1) fail("one domain or URL at a time, got " + targets.length);
19
55
 
20
56
  const mark = { pass: "ok ", warn: "warn", fail: "FAIL", info: "info" };
21
57
  try {
22
- const result = await validateHost(target);
23
- if (flags.has("--json")) {
58
+ const result = await validateHost(targets[0]);
59
+ if (json) {
24
60
  console.log(JSON.stringify(result, null, 2));
25
61
  } else {
62
+ // Details reach this point without control characters (validateHost strips them), so a
63
+ // published link cannot write an escape sequence to the terminal.
26
64
  console.log(result.target);
27
65
  for (const c of result.checks) console.log(" " + mark[c.status] + " " + c.label + " (" + c.detail + ")");
28
66
  console.log("result: " + result.summary);
29
67
  }
30
68
  if (result.summary === "not valid") process.exit(1);
31
- if (result.summary === "valid with warnings" && flags.has("--strict")) process.exit(1);
69
+ if (result.summary === "valid with warnings" && flags.includes("--strict")) process.exit(1);
32
70
  process.exit(0);
33
71
  } catch (err) {
34
- const message = err && err.message ? err.message : String(err);
72
+ const message = stripControls(err && err.message ? err.message : String(err));
35
73
  // The hosted validator answers every failure as JSON when JSON was asked for, so a CI
36
74
  // step that parses --json output gets {"error": ...} here too instead of an empty stdout
37
75
  // 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);
76
+ //
77
+ // target and code are added as separate fields (V-05, outside audit 2026-09-26): a bare
78
+ // {"error": message} left a CI step unable to tell which target failed or why without
79
+ // parsing free text. code reads a network error's cause first (undici wraps the real
80
+ // errno code, such as EAI_AGAIN or ECONNREFUSED, in err.cause.code) and falls back to
81
+ // err.code for an error that sets it directly; it is omitted, not null, when neither is set.
82
+ // target goes through maskLocation like every other address the tool prints, so a user name,
83
+ // a password or a query value typed into the command line is never echoed back.
84
+ const code = (err && err.cause && err.cause.code) || (err && err.code);
85
+ if (json) {
86
+ const report = { error: message, target: stripControls(maskLocation(targets[0])) };
87
+ if (code) report.code = stripControls(String(code));
88
+ console.log(JSON.stringify(report, null, 2));
89
+ } else console.error("error: " + message);
40
90
  process.exit(2);
41
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turva-llms-txt-validator",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
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",
@@ -12,7 +12,8 @@
12
12
  "src",
13
13
  "bin",
14
14
  "README.md",
15
- "LICENSE"
15
+ "LICENSE",
16
+ "THIRD-PARTY-NOTICES.md"
16
17
  ],
17
18
  "scripts": {
18
19
  "test": "node --test"
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;
@@ -108,7 +116,9 @@ export function isValidPublicHost(host) {
108
116
  const tld = host.split(".").pop();
109
117
  // arpa and onion since round 19 (P3): home.arpa is the home-network name of RFC 8375,
110
118
  // 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;
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;
112
122
  return true;
113
123
  }
114
124
 
@@ -185,28 +195,125 @@ export async function fetchLlmsTxt(host, opts = {}) {
185
195
  }
186
196
  }
187
197
 
188
- // Every markdown link in the file, scanned once from left to right instead of collected with
189
- // matchAll(/\[([^\][]*)\]\(([^)\s]{1,2048})\)/g). That bound meant a target longer than 2048
190
- // characters was not counted as a link at all, and dropping the bound from the pattern would
191
- // make it quadratic on a file that repeats "[a](" (Erik 2026-08-29). The scan carries no bound
192
- // and no backtracking: every character is read once and the furthest failed target scan is
193
- // remembered. matchAll resumes after a whole match, and so does this.
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 "![" of an image open no link, because none of them is a hyperlink
242
+ // (all three counted until the outside audit of 2026-09-26, Tek-496). A target may be written in angle
243
+ // brackets and may carry a title in quotes, as CommonMark allows; both were missed or misread
244
+ // before. The scan carries no bound and no backtracking: the furthest failed plain target is
245
+ // remembered as before, and the closing ">", quote and line end are looked up through a cache
246
+ // that only moves forward, so a file that repeats "[a](" or "[a](x \"" stays linear.
194
247
  function collectLinks(text) {
195
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
+ };
196
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
+ };
197
307
  for (let i = 0; i < text.length; i++) {
198
- 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;
199
310
  let j = i + 1;
200
- while (j < text.length && text[j] !== "]" && text[j] !== "[") j++;
311
+ while (j < text.length && text[j] !== "]" && text[j] !== "[") j += text[j] === "\\" ? 2 : 1;
201
312
  if (j >= text.length) break;
202
313
  if (text[j] === "[") { i = j - 1; continue; }
203
314
  if (text[j + 1] !== "(") { i = j; continue; }
204
- const k = j + 2;
205
- if (k <= failEnd) { i = j + 1; continue; }
206
- let e = k;
207
- while (e < text.length && text[e] !== ")" && !/\s/.test(text[e])) e++;
208
- if (e > k && text[e] === ")") { out.push({ name: text.slice(i + 1, j), target: text.slice(k, e) }); i = e; continue; }
209
- 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; }
210
317
  i = j + 1;
211
318
  }
212
319
  return out;
@@ -247,39 +354,51 @@ function fenceMask(lines) {
247
354
  }
248
355
  return mask;
249
356
  }
250
- // A markdown list item that carries a link, scanned once from left to right instead of
251
- // matched with /^ {0,3}[-*+] .*\[[^\][]*\]\([^)\s]+\)/. That pattern is quadratic on a line
252
- // such as "- " followed by "[a](" repeated, because every candidate rescans the target to the
253
- // end of the line, and the line comes from the audited site (CodeQL js/polynomial-redos,
254
- // 2026-08-29). Bounding the quantifier would trade the speed bug for a silent accuracy bug,
255
- // so the scan is by index: every character is read once and the furthest failed target scan
256
- // 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.
257
363
  function listItemHasLink(l) {
258
- const m = /^ {0,3}[-*+] /.exec(l);
364
+ const m = /^ {0,3}(?:[-*+]|\d{1,9}[.)])[ \t]/.exec(l);
259
365
  if (!m) return false;
260
- const isSep = (c) => c === "\r" || c === "\n" || c === "\u2028" || c === "\u2029";
261
- let failEnd = -1;
262
- for (let i = m[0].length; i < l.length; i++) {
263
- // A "." in the old pattern never crosses a line terminator, and split(/\r?\n/) leaves
264
- // a bare CR, U+2028 and U+2029 inside a line, so a link behind one was not a match then
265
- // and is not one now.
266
- if (isSep(l[i])) return false;
267
- if (l[i] !== "[") continue;
268
- let j = i + 1, sep = false;
269
- while (j < l.length && l[j] !== "]" && l[j] !== "[") { if (isSep(l[j])) sep = true; j++; }
270
- if (j >= l.length) return false;
271
- if (l[j] === "[") { if (sep) return false; i = j - 1; continue; }
272
- if (l[j + 1] !== "(") { if (sep) return false; i = j; continue; }
273
- const k = j + 2;
274
- if (k <= failEnd) { if (sep) return false; i = j + 1; continue; }
275
- let e = k;
276
- while (e < l.length && l[e] !== ")" && !/\s/.test(l[e])) e++;
277
- if (e > k && l[e] === ")") return true;
278
- if (sep) return false;
279
- if (l[e] !== ")") failEnd = e;
280
- i = j + 1;
281
- }
282
- return false;
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 !== "";
283
402
  }
284
403
 
285
404
  function redirectFailDetail(f) {
@@ -310,8 +429,11 @@ export function validateLlmsTxt(f) {
310
429
  add("content-type", "fail", "Response is plain text", "the body looks like an HTML page, not an llms.txt file");
311
430
  return checks;
312
431
  }
313
- if (ct.includes("text/plain") || ct.includes("text/markdown")) {
314
- add("content-type", "pass", "Response is plain text", ct.split(";")[0]);
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);
315
437
  } else {
316
438
  add("content-type", "warn", "Response is plain text", "content-type is " + (ct.split(";")[0] || "missing") + ", text/plain or text/markdown is the convention");
317
439
  }
@@ -322,13 +444,21 @@ export function validateLlmsTxt(f) {
322
444
  // The line is read as markdown and not trimmed first. Four spaces or a tab make it an
323
445
  // indented code block rather than a heading, and trimming erased that difference, so
324
446
  // " # Site" passed as the H1 until 2026-08-29. CommonMark allows three spaces.
325
- if (/^ {0,3}# \S/.test(firstRaw)) {
447
+ // h1Title reads the heading the way CommonMark does, its closing run included (Tek-496).
448
+ const title = h1Title(firstRaw);
449
+ if (title) {
326
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");
327
453
  } else {
328
454
  add("h1-title", "fail", "Starts with an H1 title", "the first non-empty line should be a markdown H1 (# Site name)");
329
455
  }
330
456
  const afterH1 = lines.slice(firstIdx + 1).find((l) => l.trim() !== "") || "";
331
- if (afterH1.trim().startsWith("> ")) {
457
+ // Read the same way as the H1 check and the "late" blockquote search below: at most three
458
+ // leading spaces, per CommonMark. A trim-first prefix test used to erase the difference, so
459
+ // a four-space or tab-indented code block right after the title passed as the summary until
460
+ // 2026-09-27 (Tek-496, mirrored from worker.js).
461
+ if (/^ {0,3}> /.test(afterH1)) {
332
462
  add("summary", "pass", "Blockquote summary after the title", JSON.stringify(cut(afterH1.trim(), 80)));
333
463
  } else {
334
464
  // A blockquote further down, before the first H2, is a summary in the wrong place and not a
@@ -354,14 +484,19 @@ export function validateLlmsTxt(f) {
354
484
  const h2Count = lines.filter((l, i) => !fenced[i] && /^ {0,3}## /.test(l)).length;
355
485
  // A section counts when it carries a file list. An H2 followed by a paragraph satisfied
356
486
  // this check until 2026-08-29, and the format puts each section's links in a list.
357
- let sectionsWithList = 0;
487
+ // A heading between the title and the first H2, or a second H1 anywhere, is out of place,
488
+ // because the format has one H1 and allows only paragraphs and lists before the file lists.
489
+ // Both passed until 2026-09-27 (Tek-496). The first one is named in the detail.
490
+ let sectionsWithList = 0, misplaced = -1;
358
491
  {
359
- let inSection = false, counted = false;
492
+ let inSection = false, counted = false, seenH2 = false;
360
493
  for (let i = 0; i < lines.length; i++) {
361
494
  const l = lines[i];
362
495
  if (fenced[i]) continue;
363
- if (/^ {0,3}## /.test(l)) { inSection = true; counted = false; continue; }
364
- if (/^ {0,3}# /.test(l)) { inSection = false; continue; }
496
+ if (/^ {0,3}## /.test(l)) { inSection = true; counted = false; seenH2 = true; continue; }
497
+ const h1 = /^ {0,3}#(?:[ \t]|$)/.test(l);
498
+ if (misplaced === -1 && i > firstIdx && (h1 || (!seenH2 && /^ {0,3}#{3,6}(?:[ \t]|$)/.test(l)))) misplaced = i;
499
+ if (h1) { inSection = false; continue; }
365
500
  if (inSection && !counted && listItemHasLink(l)) { sectionsWithList++; counted = true; }
366
501
  }
367
502
  }
@@ -372,6 +507,11 @@ export function validateLlmsTxt(f) {
372
507
  } else {
373
508
  add("sections", "warn", "H2 sections group the content", "no H2 sections found; sections are the convention for grouping links");
374
509
  }
510
+ if (misplaced !== -1) {
511
+ const s = checks[checks.length - 1];
512
+ s.status = "warn";
513
+ 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";
514
+ }
375
515
  // Links are collected from the prose only, for the same reason the headings are: a link
376
516
  // shown inside a code fence is an example of a link, not one an agent can follow.
377
517
  const links = collectLinks(lines.filter((l, i) => !fenced[i]).join("\n"));
@@ -379,7 +519,7 @@ export function validateLlmsTxt(f) {
379
519
  // bare "https://" both counted as valid absolute links until 2026-08-29.
380
520
  const named = links.filter((m) => m.name.trim() !== "");
381
521
  const unnamed = links.length - named.length;
382
- const absolute = named.filter((m) => /^https?:\/\/[^/\s?#]+/.test(m.target)).length;
522
+ const absolute = named.filter((m) => isAbsoluteHttpUrl(m.target)).length;
383
523
  if (links.length === 0) {
384
524
  add("links", "warn", "Markdown links an agent can follow", "no markdown links found");
385
525
  } else if (unnamed > 0) {
@@ -389,7 +529,7 @@ export function validateLlmsTxt(f) {
389
529
  } else {
390
530
  const relativeCount = named.filter((m) => !/^[a-z][a-z0-9+.-]*:/i.test(m.target)).length;
391
531
  const hostless = named.length - absolute - relativeCount;
392
- 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");
532
+ 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");
393
533
  }
394
534
  if (f.truncated) {
395
535
  add("size", "warn", "Small enough to be cheap to read", "over 256 KB, read truncated");
@@ -543,8 +683,11 @@ var C1_REPLACEMENTS = {
543
683
 
544
684
  // The full WHATWG list of named character references, 2 231 names: "name!hex" is a name that
545
685
  // also decodes without its ";", "name=hex" one that needs it, and several code points are
546
- // separated by ",". Generated 2026-09-12 from the character-entities package, with every value
547
- // and every legacy name checked against the decoder of the entities package.
686
+ // separated by ",". Generated 2026-09-12 from character-entities 2.0.2
687
+ // (https://github.com/wooorm/character-entities), (c) 2015 Titus Wormer, MIT, with every value
688
+ // and every legacy name checked against the decoder of the entities package. The full license
689
+ // text is in THIRD-PARTY-NOTICES.md (E01, outside audit 2026-09-26); this is a build-time data
690
+ // source copied in at generation time, not an installed runtime dependency.
548
691
  var NAMED_REFERENCE_DATA =
549
692
  "AElig!c6 AMP!26 Aacute!c1 Abreve=102 Acirc!c2 Acy=410 Afr=1d504 Agrave!c0 Alpha=391 Amacr=100 " +
550
693
  "And=2a53 Aogon=104 Aopf=1d538 ApplyFunction=2061 Aring!c5 Ascr=1d49c Assign=2254 Atilde!c3 " +
@@ -1223,7 +1366,8 @@ export function findLinkRelations(html, linkHeader) {
1223
1366
  // The cap applies after the head is collected, so it bounds this tag scan and not the head
1224
1367
  // parse, and a relation declared past it is not found here. The Link response header is
1225
1368
  // read separately below.
1226
- const head = headOfDocument(html).slice(0, 65536);
1369
+ const whole = headOfDocument(html);
1370
+ const head = whole.slice(0, 65536);
1227
1371
  for (const tag of htmlTags(head)) {
1228
1372
  // The name has to END at "link": a real parser reads "<link<link" as ONE tag whose
1229
1373
  // NAME is "link<link", not as a link element, so \b would count a relation the site
@@ -1253,6 +1397,9 @@ export function findLinkRelations(html, linkHeader) {
1253
1397
  if (!found.describedby && href && rel.includes("describedby")) found.describedby = href;
1254
1398
  if (!found.markdown && href && rel.includes("alternate") && isMarkdownHeader) found.markdown = href;
1255
1399
  }
1400
+ // A head longer than the cap was read in part, so a relation missing from that part is
1401
+ // reported as missing from the part read, not from the page (Tek-496).
1402
+ if (whole.length > 65536) found.headCut = true;
1256
1403
  return found;
1257
1404
  }
1258
1405
 
@@ -1264,10 +1411,14 @@ export function validateV2Discovery(found, unreadReason) {
1264
1411
  add("v2-markdown-alternate", "info", "Home page points to a markdown version (v2)", unreadReason);
1265
1412
  return checks;
1266
1413
  }
1414
+ // A target is shown through maskLocation, as a redirect target is, so a user name, a password,
1415
+ // a query value or a fragment in a published link stays out of the report; until 2026-09-27 it
1416
+ // was only cut (Tek-496). A relative target stays relative.
1417
+ 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";
1267
1418
  add("v2-describedby", found.describedby ? "pass" : "info", "Home page points to its llms.txt (v2)",
1268
- 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');
1419
+ 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');
1269
1420
  add("v2-markdown-alternate", found.markdown ? "pass" : "info", "Home page points to a markdown version (v2)",
1270
- 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');
1421
+ 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');
1271
1422
  return checks;
1272
1423
  }
1273
1424
 
@@ -1280,7 +1431,13 @@ export function summarizeChecks(checks) {
1280
1431
  export async function validateHost(input, opts = {}) {
1281
1432
  const host = normalizeHostInput(input);
1282
1433
  if (!host || !isValidPublicHost(host)) {
1283
- throw new Error("not a public domain name: " + cut(input, 120));
1434
+ // The input is repeated only when it cannot carry a secret. An entry with an @, a ? or a #
1435
+ // can hold a user name, a password or a query value, and until 0.3.8 the message copied
1436
+ // its first 120 characters into stderr and the --json error (Tek-496). Such an entry is
1437
+ // named by the host the parser reads from it, or not at all, as the hosted form does.
1438
+ const raw = String(input ?? "");
1439
+ const shown = /[@?#]/.test(raw) ? normalizeHostInput(raw) || "" : stripControls(cut(raw, 120));
1440
+ throw new Error(shown ? "not a public domain name: " + shown : "not a public domain name");
1284
1441
  }
1285
1442
  // opts comes from the caller, and path and accept exist for this function's own
1286
1443
  // second read. Forwarding them to the first read would let a caller point the
@@ -1305,6 +1462,6 @@ export async function validateHost(input, opts = {}) {
1305
1462
  // Text quoted from the fetched file leaves without bidirectional controls (round 19,
1306
1463
  // V6-U1, mirrored from worker.js v3.164.0): a site's own H1 or summary could otherwise
1307
1464
  // 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));
1465
+ const stripped = checks.map((c) => (typeof c.detail === "string" ? { ...c, detail: stripControls(stripBidi(c.detail)) } : c));
1309
1466
  return { target: "https://" + host + "/llms.txt", summary: summarizeChecks(stripped), checks: stripped };
1310
1467
  }