turva-llms-txt-validator 0.3.10 → 0.3.12
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 +2 -2
- package/bin/cli.mjs +5 -2
- package/package.json +1 -1
- package/src/index.mjs +80 -22
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": "...", "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
|
|
|
@@ -71,7 +71,7 @@ Completed validation returns `{ target, summary, checks }`. Each check contains
|
|
|
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.
|
|
74
|
+
Links are read the way CommonMark reads them for the forms listed here. 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. A link reference definition or label that spans several lines, and an image used as a reference, are not handled.
|
|
75
75
|
|
|
76
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.
|
|
77
77
|
|
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.12",
|
|
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
|
@@ -59,8 +59,16 @@ export function maskLocation(href, base) {
|
|
|
59
59
|
const start = scheme ? scheme[0].length : 0;
|
|
60
60
|
let from = start;
|
|
61
61
|
while (from < s.length && (s[from] === "/" || s[from] === "\\")) from++;
|
|
62
|
-
// Without a scheme and two slashes there is no authority, and an @ is part of a
|
|
63
|
-
|
|
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
|
+
}
|
|
64
72
|
// Nothing tells which @ of a refused target ends the user information, so everything up to the
|
|
65
73
|
// last one is shown as ***. The mask is visible on purpose: three review rounds on 2026-09-22
|
|
66
74
|
// measured every rule that guessed, and each one either returned a password that holds a slash
|
|
@@ -213,12 +221,23 @@ function isEscaped(s, i) {
|
|
|
213
221
|
// span itself crosses a real line ending: a fenceless "`[a](b)\ncontinued`" used to be masked
|
|
214
222
|
// only on its own line, so the second line escaped the mask and its bracket read as a real link
|
|
215
223
|
// (outside review 2026-09-28). A span opens with a run of backticks and closes with the next run
|
|
216
|
-
// of the same length anywhere in the text; a pair
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
// a
|
|
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
|
+
}
|
|
222
241
|
function codeSpanMask(text) {
|
|
223
242
|
const mask = new Uint8Array(text.length);
|
|
224
243
|
const runs = [];
|
|
@@ -244,7 +263,7 @@ function codeSpanMask(text) {
|
|
|
244
263
|
const close = runs[list[p]];
|
|
245
264
|
const start = r.escaped ? r.at + 1 : r.at;
|
|
246
265
|
const end = close.at + close.len;
|
|
247
|
-
if (text
|
|
266
|
+
if (!crossesBlockBoundary(text, start, end)) mask.fill(1, start, end);
|
|
248
267
|
k = list[p];
|
|
249
268
|
}
|
|
250
269
|
return mask;
|
|
@@ -258,15 +277,31 @@ function normalizeLinkLabel(s) {
|
|
|
258
277
|
|
|
259
278
|
// Link reference definitions, "[label]: target", read once per document so a reference link
|
|
260
279
|
// such as "[text][label]" can resolve against one written anywhere else in the same file,
|
|
261
|
-
// before or after its use, the way CommonMark allows (outside review 2026-09-28). A
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
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.
|
|
265
290
|
function collectLinkDefinitions(text) {
|
|
266
291
|
const defs = new Map();
|
|
267
|
-
|
|
268
|
-
|
|
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);
|
|
269
303
|
if (!m) continue;
|
|
304
|
+
if (code[starts[i] + line.indexOf("[")]) continue;
|
|
270
305
|
const label = normalizeLinkLabel(m[1]);
|
|
271
306
|
if (!label || defs.has(label)) continue;
|
|
272
307
|
let dest = m[2];
|
|
@@ -392,6 +427,19 @@ function collectLinks(text, defs) {
|
|
|
392
427
|
continue;
|
|
393
428
|
}
|
|
394
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
|
+
}
|
|
395
443
|
}
|
|
396
444
|
i = j;
|
|
397
445
|
}
|
|
@@ -571,23 +619,29 @@ export function validateLlmsTxt(f) {
|
|
|
571
619
|
// reference link definition written anywhere in the prose resolves the same way for each
|
|
572
620
|
// (outside review 2026-09-28, V2): "[text][label]" used to be recognized in the whole-document
|
|
573
621
|
// links check but not in the per-line section scan, which only ever saw one list item line.
|
|
574
|
-
|
|
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");
|
|
575
626
|
const linkDefs = collectLinkDefinitions(proseText);
|
|
576
627
|
const proseLinks = collectLinks(proseText, linkDefs);
|
|
577
628
|
// Which original line each recognized link starts on, so the section scan below can ask "does
|
|
578
629
|
// this line carry a link" against the same whole-document result the links check itself uses,
|
|
579
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.
|
|
580
636
|
const linkedLineIdx = new Set();
|
|
581
637
|
{
|
|
582
|
-
const owners = [];
|
|
583
|
-
for (let i = 0; i < lines.length; i++) if (!fenced[i]) owners.push(i);
|
|
584
638
|
const starts = [];
|
|
585
639
|
let offset = 0;
|
|
586
640
|
for (const pl of proseText.split("\n")) { starts.push(offset); offset += pl.length + 1; }
|
|
641
|
+
let idx = 0;
|
|
587
642
|
for (const link of proseLinks) {
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
linkedLineIdx.add(owners[idx]);
|
|
643
|
+
while (idx + 1 < starts.length && starts[idx + 1] <= link.at) idx++;
|
|
644
|
+
linkedLineIdx.add(idx);
|
|
591
645
|
}
|
|
592
646
|
}
|
|
593
647
|
const h2Count = lines.filter((l, i) => !fenced[i] && /^ {0,3}## /.test(l)).length;
|
|
@@ -1568,7 +1622,11 @@ export async function validateHost(input, opts = {}) {
|
|
|
1568
1622
|
// A path in the typed address is not used, and the result says so first (mirrored from worker.js
|
|
1569
1623
|
// v3.176.0). Information moves no summary and no exit code.
|
|
1570
1624
|
const unusedPath = enteredPath(input);
|
|
1571
|
-
|
|
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." });
|
|
1572
1630
|
// Text quoted from the fetched file leaves without bidirectional controls (round 19,
|
|
1573
1631
|
// V6-U1, mirrored from worker.js v3.164.0): a site's own H1 or summary could otherwise
|
|
1574
1632
|
// read in another order here than in the file itself.
|