turva-llms-txt-validator 0.3.9 → 0.3.11
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 +3 -3
- package/bin/cli.mjs +5 -2
- package/package.json +2 -2
- package/src/index.mjs +220 -52
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Check a website's `llms.txt` from the command line, Node or CI. Get a clear resu
|
|
|
8
8
|
|
|
9
9
|
## Quick start
|
|
10
10
|
|
|
11
|
-
Requires **Node.js
|
|
11
|
+
Requires **Node.js 22 or newer**. Run it without a global install:
|
|
12
12
|
|
|
13
13
|
```sh
|
|
14
14
|
npx --yes turva-llms-txt-validator example.com
|
|
@@ -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": "...", "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`.
|
|
57
|
+
Completed validation returns `{ target, summary, checks }`. Each check contains `{ id, status, label, detail }`. The `input-path` check, which reports a path given with the domain and then ignored, also carries `value`, that path cut to 120 characters. 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
|
|
|
@@ -110,7 +110,7 @@ for (const check of result.checks) {
|
|
|
110
110
|
|
|
111
111
|
## Use in CI
|
|
112
112
|
|
|
113
|
-
Add this step to a GitHub Actions job that already has Node.js
|
|
113
|
+
Add this step to a GitHub Actions job that already has Node.js 22 or newer available. Replace `your-domain.com` with your domain:
|
|
114
114
|
|
|
115
115
|
```yaml
|
|
116
116
|
- name: Validate llms.txt
|
package/bin/cli.mjs
CHANGED
|
@@ -60,9 +60,12 @@ try {
|
|
|
60
60
|
console.log(JSON.stringify(result, null, 2));
|
|
61
61
|
} else {
|
|
62
62
|
// Details reach this point without control characters (validateHost strips them), so a
|
|
63
|
-
// published link cannot write an escape sequence to the terminal.
|
|
63
|
+
// published link cannot write an escape sequence to the terminal. A check's reflected
|
|
64
|
+
// value, when it has one, is printed ahead of the sentence, the same position the old
|
|
65
|
+
// concatenated string put it in, so text output reads the same while the JSON field stays
|
|
66
|
+
// separate (round 3 Tek-542, mirrored from worker.js decision 19, Tek-526).
|
|
64
67
|
console.log(result.target);
|
|
65
|
-
for (const c of result.checks) console.log(" " + mark[c.status] + " " + c.label + " (" + c.detail + ")");
|
|
68
|
+
for (const c of result.checks) console.log(" " + mark[c.status] + " " + c.label + " (" + (c.value !== undefined ? c.value + " " : "") + c.detail + ")");
|
|
66
69
|
console.log("result: " + result.summary);
|
|
67
70
|
}
|
|
68
71
|
if (result.summary === "not valid") process.exit(1);
|
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.11",
|
|
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",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"test": "node --test"
|
|
20
20
|
},
|
|
21
21
|
"engines": {
|
|
22
|
-
"node": ">=
|
|
22
|
+
"node": ">=22"
|
|
23
23
|
},
|
|
24
24
|
"keywords": [
|
|
25
25
|
"llms.txt",
|
package/src/index.mjs
CHANGED
|
@@ -39,7 +39,13 @@ export function cut(s, n) {
|
|
|
39
39
|
export function maskLocation(href, base) {
|
|
40
40
|
let u = null;
|
|
41
41
|
try { u = new URL(href, base); } catch { u = null; }
|
|
42
|
-
|
|
42
|
+
// An opaque URL (no authority, e.g. "u:name@host/") parses without throwing but has no
|
|
43
|
+
// host, so clearing u.username/u.password is a silent no-op: WHATWG has nowhere to put
|
|
44
|
+
// them and the credential-shaped text stays in the opaque path untouched. Route it through
|
|
45
|
+
// the string branch below instead, the same one an unparseable target already uses
|
|
46
|
+
// (outside review 2026-09-28: a bare "u:" scheme let the text after it through as the
|
|
47
|
+
// CLI JSON "target" field, unmasked).
|
|
48
|
+
if (u && u.host) {
|
|
43
49
|
u.username = "";
|
|
44
50
|
u.password = "";
|
|
45
51
|
for (const key of Array.from(u.searchParams.keys())) u.searchParams.set(key, "***");
|
|
@@ -53,8 +59,16 @@ export function maskLocation(href, base) {
|
|
|
53
59
|
const start = scheme ? scheme[0].length : 0;
|
|
54
60
|
let from = start;
|
|
55
61
|
while (from < s.length && (s[from] === "/" || s[from] === "\\")) from++;
|
|
56
|
-
// Without a scheme and two slashes there is no authority, and an @ is part of a
|
|
57
|
-
|
|
62
|
+
// Without a scheme and two slashes there is normally no authority, and an @ is part of a
|
|
63
|
+
// path ("path@2x.png" stays unmasked below). But when an @ still appears before the first
|
|
64
|
+
// /, the text ahead of it reads as userinfo even when the parser here does not recognise it
|
|
65
|
+
// as a scheme (an underscore, a leading digit or space, a stray control character), so that
|
|
66
|
+
// shape is masked too instead of let through (VN1, round 3 Tek-542).
|
|
67
|
+
if (!scheme && from - start < 2) {
|
|
68
|
+
const firstSlash = s.indexOf("/");
|
|
69
|
+
const firstAt = s.indexOf("@");
|
|
70
|
+
if (firstAt < 0 || firstSlash < 0 || firstAt > firstSlash) return s;
|
|
71
|
+
}
|
|
58
72
|
// Nothing tells which @ of a refused target ends the user information, so everything up to the
|
|
59
73
|
// last one is shown as ***. The mask is visible on purpose: three review rounds on 2026-09-22
|
|
60
74
|
// measured every rule that guessed, and each one either returned a password that holds a slash
|
|
@@ -202,20 +216,36 @@ function isEscaped(s, i) {
|
|
|
202
216
|
return n % 2 === 1;
|
|
203
217
|
}
|
|
204
218
|
|
|
205
|
-
// Inline code spans
|
|
206
|
-
// read as the code it is (outside audit 2026-09-26, Tek-496)
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
219
|
+
// Inline code spans, marked per character across the whole document, so a link written inside
|
|
220
|
+
// backticks is read as the code it is (outside audit 2026-09-26, Tek-496), including when the
|
|
221
|
+
// span itself crosses a real line ending: a fenceless "`[a](b)\ncontinued`" used to be masked
|
|
222
|
+
// only on its own line, so the second line escaped the mask and its bracket read as a real link
|
|
223
|
+
// (outside review 2026-09-28). A span opens with a run of backticks and closes with the next run
|
|
224
|
+
// of the same length anywhere in the text; a pair that crosses a block boundary, a blank line
|
|
225
|
+
// (blank or made only of spaces and tabs), a heading line or a new list item, is not a code span
|
|
226
|
+
// in CommonMark, so it is left unmasked and its backticks stay literal (VREG2, round 3 Tek-542:
|
|
227
|
+
// backticks in two different list items used to enclose a real hyperlink between them). An
|
|
228
|
+
// escaped first backtick is literal and the rest of its run still opens, while a closing run is
|
|
229
|
+
// never escaped, because a backslash inside a code span is literal. Runs are grouped by length
|
|
230
|
+
// and every group is read with one forward pointer, so the scan stays linear on a document of
|
|
231
|
+
// unmatched runs.
|
|
232
|
+
function crossesBlockBoundary(text, start, end) {
|
|
233
|
+
const inner = text.slice(start, end);
|
|
234
|
+
if (/\n[ \t]*\n/.test(inner)) return true;
|
|
235
|
+
const lines = inner.split("\n");
|
|
236
|
+
for (let i = 1; i < lines.length; i++) {
|
|
237
|
+
if (/^ {0,3}(#{1,6}(?:[ \t]|$)|[-*+][ \t]|\d{1,9}[.)][ \t])/.test(lines[i])) return true;
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
function codeSpanMask(text) {
|
|
242
|
+
const mask = new Uint8Array(text.length);
|
|
213
243
|
const runs = [];
|
|
214
|
-
for (let i = 0; i <
|
|
215
|
-
if (
|
|
244
|
+
for (let i = 0; i < text.length; ) {
|
|
245
|
+
if (text[i] !== "`") { i++; continue; }
|
|
216
246
|
let j = i;
|
|
217
|
-
while (j <
|
|
218
|
-
runs.push({ at: i, len: j - i, escaped: isEscaped(
|
|
247
|
+
while (j < text.length && text[j] === "`") j++;
|
|
248
|
+
runs.push({ at: i, len: j - i, escaped: isEscaped(text, i) });
|
|
219
249
|
i = j;
|
|
220
250
|
}
|
|
221
251
|
const byLen = new Map();
|
|
@@ -231,29 +261,77 @@ function codeSpanMask(line) {
|
|
|
231
261
|
ptr.set(len, p);
|
|
232
262
|
if (p === list.length) continue;
|
|
233
263
|
const close = runs[list[p]];
|
|
234
|
-
|
|
264
|
+
const start = r.escaped ? r.at + 1 : r.at;
|
|
265
|
+
const end = close.at + close.len;
|
|
266
|
+
if (!crossesBlockBoundary(text, start, end)) mask.fill(1, start, end);
|
|
235
267
|
k = list[p];
|
|
236
268
|
}
|
|
237
269
|
return mask;
|
|
238
270
|
}
|
|
239
271
|
|
|
272
|
+
// A CommonMark link reference label is matched without regard to case or the exact run of
|
|
273
|
+
// whitespace inside it, so "[Guide]" and "[ guide ]" name the same definition.
|
|
274
|
+
function normalizeLinkLabel(s) {
|
|
275
|
+
return s.trim().replace(/\s+/g, " ").toLowerCase();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Link reference definitions, "[label]: target", read once per document so a reference link
|
|
279
|
+
// such as "[text][label]" can resolve against one written anywhere else in the same file,
|
|
280
|
+
// before or after its use, the way CommonMark allows (outside review 2026-09-28). A definition
|
|
281
|
+
// only opens where a new block can start: at the top of the document, right after a blank
|
|
282
|
+
// line, right after a heading or list marker line, or right after another definition line; a
|
|
283
|
+
// line that continues open paragraph text is read as that paragraph, not as a fresh
|
|
284
|
+
// definition, and a "definition" written inside an inline code span is the span's own literal
|
|
285
|
+
// text (VREG1, round 3 Tek-542). A title after the target counts only in quotes or
|
|
286
|
+
// parentheses on the same line; anything else trailing the target, such as a bare word, makes
|
|
287
|
+
// the whole line not a definition, matching CommonMark instead of reading everything up to the
|
|
288
|
+
// next space as the title's own value. Only the first definition of a given label counts, per
|
|
289
|
+
// spec. A destination wrapped in angle brackets has them stripped.
|
|
290
|
+
function collectLinkDefinitions(text) {
|
|
291
|
+
const defs = new Map();
|
|
292
|
+
const lines = text.split("\n");
|
|
293
|
+
const code = codeSpanMask(text);
|
|
294
|
+
const starts = [];
|
|
295
|
+
{ let offset = 0; for (const l of lines) { starts.push(offset); offset += l.length + 1; } }
|
|
296
|
+
const DEF_START = /^ {0,3}\[[^\]]+\]:/;
|
|
297
|
+
const BLOCK_START = /^ {0,3}(#{1,6}(?:[ \t]|$)|[-*+][ \t]|\d{1,9}[.)][ \t])/;
|
|
298
|
+
for (let i = 0; i < lines.length; i++) {
|
|
299
|
+
const line = lines[i];
|
|
300
|
+
const prev = i > 0 ? lines[i - 1] : "";
|
|
301
|
+
if (prev.trim() !== "" && !DEF_START.test(prev) && !BLOCK_START.test(prev)) continue;
|
|
302
|
+
const m = /^ {0,3}\[([^\]]+)\]:[ \t]*(<[^<>\n]*>|\S+)([ \t]+(?:"[^"\n]*"|'[^'\n]*'|\([^()\n]*\)))?[ \t]*$/.exec(line);
|
|
303
|
+
if (!m) continue;
|
|
304
|
+
if (code[starts[i] + line.indexOf("[")]) continue;
|
|
305
|
+
const label = normalizeLinkLabel(m[1]);
|
|
306
|
+
if (!label || defs.has(label)) continue;
|
|
307
|
+
let dest = m[2];
|
|
308
|
+
if (dest[0] === "<" && dest[dest.length - 1] === ">") dest = dest.slice(1, -1);
|
|
309
|
+
defs.set(label, dest);
|
|
310
|
+
}
|
|
311
|
+
return defs;
|
|
312
|
+
}
|
|
313
|
+
|
|
240
314
|
// Every markdown link in the prose, scanned once from left to right. A "[" inside a code span,
|
|
241
315
|
// an escaped "\[" and the "](y)", is link content and is
|
|
319
|
+
// skipped whole rather than ending the scan, so the outer hyperlink is still found; any other
|
|
320
|
+
// nested "[" still ends the scan the way it always has, because CommonMark does not allow a
|
|
321
|
+
// link inside a link (outside review 2026-09-28: the previous scan gave up on the whole outer
|
|
322
|
+
// link the moment any nested "[" appeared, image or not). A full or collapsed reference link,
|
|
323
|
+
// "[text][label]" or "[text][]", resolves against collectLinkDefinitions; an unresolved label
|
|
324
|
+
// is read as plain brackets, the way it always was. A resolved target is decoded for character
|
|
325
|
+
// references (":", "&", ...) the same way an HTML attribute value is, so a destination
|
|
326
|
+
// written as "https://host:99999/" is checked as what it resolves to and not as its literal
|
|
327
|
+
// source spelling (outside review 2026-09-28). The scan carries no bound and no backtracking:
|
|
328
|
+
// the furthest failed plain target is remembered as before, and the closing ">", quote and line
|
|
329
|
+
// end are looked up through a cache that only moves forward, so a file that repeats "[a](" or
|
|
330
|
+
// "[a](x \"" stays linear.
|
|
331
|
+
function collectLinks(text, defs) {
|
|
248
332
|
const out = [];
|
|
249
|
-
const code =
|
|
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
|
-
}
|
|
333
|
+
const code = codeSpanMask(text);
|
|
334
|
+
if (!defs) defs = collectLinkDefinitions(text);
|
|
257
335
|
const cache = Object.create(null);
|
|
258
336
|
const nextIdx = (c, from) => {
|
|
259
337
|
const h = cache[c];
|
|
@@ -304,17 +382,66 @@ function collectLinks(text) {
|
|
|
304
382
|
if (!found) failEnd = e;
|
|
305
383
|
return found;
|
|
306
384
|
};
|
|
385
|
+
// An image nested in the link text is skipped whole: k is the index of its own "[", right
|
|
386
|
+
// after an unescaped "!". Its alt text is not itself scanned for nested links or images, the
|
|
387
|
+
// first unescaped "]" closes it, and it is only skipped when a target follows; a malformed
|
|
388
|
+
// image (no closing "]", no "(target)") is left for the caller to treat as an ordinary
|
|
389
|
+
// nested bracket, the way one always was.
|
|
390
|
+
const skipImage = (k) => {
|
|
391
|
+
let m = k + 1;
|
|
392
|
+
while (m < text.length && text[m] !== "]" && text[m] !== "[") m += text[m] === "\\" ? 2 : 1;
|
|
393
|
+
if (m >= text.length || text[m] !== "]" || text[m + 1] !== "(") return null;
|
|
394
|
+
const t = readTarget(m + 2);
|
|
395
|
+
return t ? t.end + 1 : null;
|
|
396
|
+
};
|
|
307
397
|
for (let i = 0; i < text.length; i++) {
|
|
308
398
|
if (text[i] !== "[" || code[i] || isEscaped(text, i)) continue;
|
|
309
399
|
if (i > 0 && text[i - 1] === "!" && !isEscaped(text, i - 1)) continue;
|
|
310
400
|
let j = i + 1;
|
|
311
|
-
|
|
401
|
+
for (;;) {
|
|
402
|
+
while (j < text.length && text[j] !== "]" && text[j] !== "[") j += text[j] === "\\" ? 2 : 1;
|
|
403
|
+
if (j >= text.length || text[j] !== "[") break;
|
|
404
|
+
if (code[j] || text[j - 1] !== "!" || isEscaped(text, j - 1)) break;
|
|
405
|
+
const after = skipImage(j);
|
|
406
|
+
if (after === null) break;
|
|
407
|
+
j = after;
|
|
408
|
+
}
|
|
312
409
|
if (j >= text.length) break;
|
|
313
410
|
if (text[j] === "[") { i = j - 1; continue; }
|
|
314
|
-
if (text[j + 1]
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
411
|
+
if (text[j + 1] === "(") {
|
|
412
|
+
const t = readTarget(j + 2);
|
|
413
|
+
if (t) { out.push({ name: text.slice(i + 1, j), target: decodeAttributeValue(t.target), at: i }); i = t.end; continue; }
|
|
414
|
+
i = j + 1;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (text[j + 1] === "[") {
|
|
418
|
+
let m = j + 2;
|
|
419
|
+
while (m < text.length && text[m] !== "]" && text[m] !== "\n") m += text[m] === "\\" ? 2 : 1;
|
|
420
|
+
if (m < text.length && text[m] === "]") {
|
|
421
|
+
const rawLabel = text.slice(j + 2, m);
|
|
422
|
+
const label = normalizeLinkLabel(rawLabel === "" ? text.slice(i + 1, j) : rawLabel);
|
|
423
|
+
const dest = label && defs.get(label);
|
|
424
|
+
if (dest !== undefined) {
|
|
425
|
+
out.push({ name: text.slice(i + 1, j), target: decodeAttributeValue(dest), at: i });
|
|
426
|
+
i = m;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
} else {
|
|
431
|
+
// A shortcut reference, "[text]" alone with neither "(" nor "[" right after it, resolves
|
|
432
|
+
// against a definition using the link text itself as the label (VN2, round 3 Tek-542).
|
|
433
|
+
// Checked last, and only here, so it never overrides the full or collapsed reference
|
|
434
|
+
// forms above. Not tried when a ":" follows: that is the definition's own "[label]:"
|
|
435
|
+
// syntax, not a use of the label, and a definition line must not resolve against itself.
|
|
436
|
+
const label = text[j + 1] === ":" ? "" : normalizeLinkLabel(text.slice(i + 1, j));
|
|
437
|
+
const dest = label ? defs.get(label) : undefined;
|
|
438
|
+
if (dest !== undefined) {
|
|
439
|
+
out.push({ name: text.slice(i + 1, j), target: decodeAttributeValue(dest), at: i });
|
|
440
|
+
i = j;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
i = j;
|
|
318
445
|
}
|
|
319
446
|
return out;
|
|
320
447
|
}
|
|
@@ -356,16 +483,12 @@ function fenceMask(lines) {
|
|
|
356
483
|
}
|
|
357
484
|
// A markdown list item that carries a link. A bullet or an ordered marker (1. or 1)) followed by
|
|
358
485
|
// 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).
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
function listItemHasLink(l) {
|
|
364
|
-
|
|
365
|
-
if (!m) 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;
|
|
486
|
+
// list items until 2026-09-27 (Tek-496). Whether the line carries a link is decided by the
|
|
487
|
+
// caller against the whole document's own collectLinks result (hasLink), not by re-scanning
|
|
488
|
+
// this one line in isolation: a code span or a reference link target can resolve only across
|
|
489
|
+
// more than one line, and a per-line rescan cannot see that (outside review 2026-09-28, V2).
|
|
490
|
+
function listItemHasLink(l, hasLink) {
|
|
491
|
+
return !!hasLink && /^ {0,3}(?:[-*+]|\d{1,9}[.)])[ \t]/.test(l);
|
|
369
492
|
}
|
|
370
493
|
|
|
371
494
|
// The text of an ATX H1, or null when the line is not one. One to three leading spaces, a
|
|
@@ -423,8 +546,14 @@ export function validateLlmsTxt(f) {
|
|
|
423
546
|
// v3.164.0): a query value or a fragment in a same-host Location used to reach this
|
|
424
547
|
// detail as sent.
|
|
425
548
|
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");
|
|
549
|
+
// A leading byte-order mark is stripped the same way the fetch path already strips it: the
|
|
550
|
+
// TextDecoder used there removes a BOM by default, so the direct API took a different
|
|
551
|
+
// document than the fetched one for the exact same bytes until this was measured (outside
|
|
552
|
+
// review 2026-09-28); the h1-title check saw the mark as part of the first line and failed a
|
|
553
|
+
// document that would pass when fetched.
|
|
554
|
+
const text = f.text.charCodeAt(0) === 0xFEFF ? f.text.slice(1) : f.text;
|
|
426
555
|
const ct = (f.contentType || "").toLowerCase();
|
|
427
|
-
const looksHtml = /^\s*(<!doctype|<html|<head|<body)/i.test(
|
|
556
|
+
const looksHtml = /^\s*(<!doctype|<html|<head|<body)/i.test(text);
|
|
428
557
|
if (looksHtml) {
|
|
429
558
|
add("content-type", "fail", "Response is plain text", "the body looks like an HTML page, not an llms.txt file");
|
|
430
559
|
return checks;
|
|
@@ -437,7 +566,7 @@ export function validateLlmsTxt(f) {
|
|
|
437
566
|
} else {
|
|
438
567
|
add("content-type", "warn", "Response is plain text", "content-type is " + (ct.split(";")[0] || "missing") + ", text/plain or text/markdown is the convention");
|
|
439
568
|
}
|
|
440
|
-
const lines =
|
|
569
|
+
const lines = text.split(/\r?\n/);
|
|
441
570
|
const firstIdx = lines.findIndex((l) => l.trim() !== "");
|
|
442
571
|
const firstRaw = firstIdx === -1 ? "" : lines[firstIdx];
|
|
443
572
|
const first = firstRaw.trim();
|
|
@@ -458,7 +587,12 @@ export function validateLlmsTxt(f) {
|
|
|
458
587
|
// leading spaces, per CommonMark. A trim-first prefix test used to erase the difference, so
|
|
459
588
|
// a four-space or tab-indented code block right after the title passed as the summary until
|
|
460
589
|
// 2026-09-27 (Tek-496, mirrored from worker.js).
|
|
461
|
-
|
|
590
|
+
// A blockquote marker is 0-3 leading spaces plus ">", optionally followed by one space that
|
|
591
|
+
// is then part of the marker and not the content; CommonMark reads ">Summary" and a tab the
|
|
592
|
+
// same way, and four or more leading spaces stay indented code, not a blockquote (outside
|
|
593
|
+
// review 2026-09-28: a literal space was required, so a tab or no space at all warned as
|
|
594
|
+
// missing).
|
|
595
|
+
if (/^ {0,3}>/.test(afterH1)) {
|
|
462
596
|
add("summary", "pass", "Blockquote summary after the title", JSON.stringify(cut(afterH1.trim(), 80)));
|
|
463
597
|
} else {
|
|
464
598
|
// A blockquote further down, before the first H2, is a summary in the wrong place and not a
|
|
@@ -470,7 +604,7 @@ export function validateLlmsTxt(f) {
|
|
|
470
604
|
for (let i = firstIdx + 1; i < lines.length; i++) {
|
|
471
605
|
if (fence[i]) continue;
|
|
472
606
|
if (/^ {0,3}## /.test(lines[i])) break;
|
|
473
|
-
if (/^ {0,3}
|
|
607
|
+
if (/^ {0,3}>/.test(lines[i])) { late = i; break; }
|
|
474
608
|
}
|
|
475
609
|
add("summary", "warn", "Blockquote summary after the title", late === -1
|
|
476
610
|
? "recommended by the format (> one-line summary), not required"
|
|
@@ -481,6 +615,35 @@ export function validateLlmsTxt(f) {
|
|
|
481
615
|
// demanded column zero, so a correct file indented by one to three spaces was reported as
|
|
482
616
|
// having no sections at all while its list under the same indentation counted fine.
|
|
483
617
|
const fenced = fenceMask(lines);
|
|
618
|
+
// Computed once for both the section-list scan below and the links check further down, so a
|
|
619
|
+
// reference link definition written anywhere in the prose resolves the same way for each
|
|
620
|
+
// (outside review 2026-09-28, V2): "[text][label]" used to be recognized in the whole-document
|
|
621
|
+
// links check but not in the per-line section scan, which only ever saw one list item line.
|
|
622
|
+
// Fenced lines are blanked rather than removed (VREG1, round 3 Tek-542): deleting them and
|
|
623
|
+
// joining the survivors let a link's own target span straight across a fenced code block, as
|
|
624
|
+
// if the fence had never separated the two halves.
|
|
625
|
+
const proseText = lines.map((l, i) => (fenced[i] ? "" : l)).join("\n");
|
|
626
|
+
const linkDefs = collectLinkDefinitions(proseText);
|
|
627
|
+
const proseLinks = collectLinks(proseText, linkDefs);
|
|
628
|
+
// Which original line each recognized link starts on, so the section scan below can ask "does
|
|
629
|
+
// this line carry a link" against the same whole-document result the links check itself uses,
|
|
630
|
+
// instead of re-finding links one isolated line at a time (outside review 2026-09-28, V2).
|
|
631
|
+
// proseLinks is already ordered by position, so the line cursor only moves forward across the
|
|
632
|
+
// whole list instead of restarting the scan from line zero for every link, which used to make
|
|
633
|
+
// a many-link document's mapping step quadratic (VREG3/VN3, round 3 Tek-542). Each proseText
|
|
634
|
+
// line now sits at the same index as its source line, because fenced lines are blanked, not
|
|
635
|
+
// removed, so the index read off the start-offset table names the original line directly.
|
|
636
|
+
const linkedLineIdx = new Set();
|
|
637
|
+
{
|
|
638
|
+
const starts = [];
|
|
639
|
+
let offset = 0;
|
|
640
|
+
for (const pl of proseText.split("\n")) { starts.push(offset); offset += pl.length + 1; }
|
|
641
|
+
let idx = 0;
|
|
642
|
+
for (const link of proseLinks) {
|
|
643
|
+
while (idx + 1 < starts.length && starts[idx + 1] <= link.at) idx++;
|
|
644
|
+
linkedLineIdx.add(idx);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
484
647
|
const h2Count = lines.filter((l, i) => !fenced[i] && /^ {0,3}## /.test(l)).length;
|
|
485
648
|
// A section counts when it carries a file list. An H2 followed by a paragraph satisfied
|
|
486
649
|
// this check until 2026-08-29, and the format puts each section's links in a list.
|
|
@@ -497,7 +660,7 @@ export function validateLlmsTxt(f) {
|
|
|
497
660
|
const h1 = /^ {0,3}#(?:[ \t]|$)/.test(l);
|
|
498
661
|
if (misplaced === -1 && i > firstIdx && (h1 || (!seenH2 && /^ {0,3}#{3,6}(?:[ \t]|$)/.test(l)))) misplaced = i;
|
|
499
662
|
if (h1) { inSection = false; continue; }
|
|
500
|
-
if (inSection && !counted && listItemHasLink(l)) { sectionsWithList++; counted = true; }
|
|
663
|
+
if (inSection && !counted && listItemHasLink(l, linkedLineIdx.has(i))) { sectionsWithList++; counted = true; }
|
|
501
664
|
}
|
|
502
665
|
}
|
|
503
666
|
if (h2Count > 0 && sectionsWithList > 0) {
|
|
@@ -513,8 +676,9 @@ export function validateLlmsTxt(f) {
|
|
|
513
676
|
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
677
|
}
|
|
515
678
|
// Links are collected from the prose only, for the same reason the headings are: a link
|
|
516
|
-
// shown inside a code fence is an example of a link, not one an agent can follow.
|
|
517
|
-
|
|
679
|
+
// shown inside a code fence is an example of a link, not one an agent can follow. Computed
|
|
680
|
+
// once above as proseLinks, reused here under its usual name.
|
|
681
|
+
const links = proseLinks;
|
|
518
682
|
// An entry an agent can use has a name and a target with a host. An empty name and a
|
|
519
683
|
// bare "https://" both counted as valid absolute links until 2026-08-29.
|
|
520
684
|
const named = links.filter((m) => m.name.trim() !== "");
|
|
@@ -538,7 +702,7 @@ export function validateLlmsTxt(f) {
|
|
|
538
702
|
} else {
|
|
539
703
|
add("size", "warn", "Small enough to be cheap to read", f.bytes + " bytes; consider moving detail to llms-full.txt");
|
|
540
704
|
}
|
|
541
|
-
if (/<[a-z][a-z0-9-]*[\s>]/i.test(
|
|
705
|
+
if (/<[a-z][a-z0-9-]*[\s>]/i.test(text)) {
|
|
542
706
|
add("no-html", "warn", "No HTML markup in the file", "HTML tags found; llms.txt should be plain markdown");
|
|
543
707
|
} else {
|
|
544
708
|
add("no-html", "pass", "No HTML markup in the file", "plain markdown");
|
|
@@ -1458,7 +1622,11 @@ export async function validateHost(input, opts = {}) {
|
|
|
1458
1622
|
// A path in the typed address is not used, and the result says so first (mirrored from worker.js
|
|
1459
1623
|
// v3.176.0). Information moves no summary and no exit code.
|
|
1460
1624
|
const unusedPath = enteredPath(input);
|
|
1461
|
-
|
|
1625
|
+
// The typed path goes on its own field with the same 120 character cut it always had (VREG,
|
|
1626
|
+
// round 3 Tek-542, mirrored from worker.js decision 19, Tek-526), instead of being built into
|
|
1627
|
+
// the sentence, so a JSON caller reads the raw value from one field instead of parsing it
|
|
1628
|
+
// back out of a message meant for people.
|
|
1629
|
+
if (unusedPath) checks.unshift({ id: "input-path", status: "info", label: "Path in the address you entered", value: cut(unusedPath, 120), detail: "This path is not used, because the validator always reads /llms.txt at the root of the host." });
|
|
1462
1630
|
// Text quoted from the fetched file leaves without bidirectional controls (round 19,
|
|
1463
1631
|
// V6-U1, mirrored from worker.js v3.164.0): a site's own H1 or summary could otherwise
|
|
1464
1632
|
// read in another order here than in the file itself.
|