turva-llms-txt-validator 0.3.15 → 0.3.17
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 +16 -6
- package/package.json +1 -1
- package/src/index.mjs +169 -44
package/README.md
CHANGED
|
@@ -64,14 +64,14 @@ Completed validation returns `{ target, summary, checks }`. Each check contains
|
|
|
64
64
|
| --- | --- | --- | --- |
|
|
65
65
|
| 1 | `/llms.txt` returns HTTP 200 | Non-200 response, rejected or excessive redirects | none |
|
|
66
66
|
| 2 | Response is plain text | Body looks like an HTML page | 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 |
|
|
67
|
+
| 3 | First non-empty line is a Markdown H1, written `# Title` or as one line of text underlined with `=` | 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, a blockquote block in which no line has text after the `>`, or a blockquote after other text but before the first H2, named by its line |
|
|
69
69
|
| 5 | H2 sections group the content | none | No H2 sections, no section with a Markdown link list, a heading between the title and the first H2, or a second H1 |
|
|
70
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
|
-
| 8 | File contains no HTML markup | none | An opening tag
|
|
72
|
+
| 8 | File contains no HTML markup | none | An opening tag with its closing `>`, a closing tag or an HTML comment is found anywhere in the file, including inside code spans and fenced code. An attribute name is any run of characters other than white space, quotes, `<`, `>`, `/` and `=` that holds a Unicode letter, a Unicode digit or the character `_`, so `<div @click="x">` warns. Autolinks such as `<https://example.com>` and prose such as `a <b + c` are not tags. A tag still open at the end of the file, such as `<div class="x"`, has no closing `>` and does not warn |
|
|
73
73
|
|
|
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 in double or single quotes do. A title in parentheses is not read, so `[Guide](https://example.org/guide (Overview))` is not collected. An unbracketed target ends at its first closing parenthesis, so `https://example.org/docs_(v2)` is read only up to `https://example.org/docs_(v2
|
|
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 in double or single quotes do. A title in parentheses is not read, so `[Guide](https://example.org/guide (Overview))` is not collected. An unbracketed target ends at its first closing parenthesis, so `https://example.org/docs_(v2)` is read only up to `https://example.org/docs_(v2`. Write a target that contains parentheses in angle brackets. A link reference definition or label that spans several lines, and an image used as a reference, are not handled. A quote escaped with a backslash inside a link title, as in `[A](https://example.org/a "Ti\"tle\"")`, is not recognised, so that link is dropped. A title that spans a line break is dropped the same way. Nested brackets in a link label, as in `[A [x] B](https://example.org/a)`, drop the link. A heading is an H2 only when `##` is followed by a space, so `##` followed by a tab is not read as one. A link in a four-space indented code block counts as a link, but such a block does not count as a file list under an H2. A backslash escape in a target is resolved before the URL test, so `https\://example.org/guide` is read as `https://example.org/guide`. A link label cannot span a blank line. A link on a line that continues a list item, such as the line after a bare `-`, counts as that item's link. After a bare marker the next line counts only when it is indented to the item's content column, which is 2 for `-` and 3 for `1.`, and a blank line right after a bare marker ends the item. After a blank line inside an item, a line counts only when it is indented to the content column, where the item's text starts, so `- item` needs 4 columns. A code fence indented less than that column ends the item. A fence indented to it keeps the item open, and the line after the fence then counts only when it is indented to that column too. CR and CRLF line endings are read as LF. A setext title, which is a text line underlined with `=`, is read only when it is a single line.
|
|
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
|
|
|
@@ -130,7 +130,17 @@ The default validation requests two documents: `https://<host>/llms.txt` and `ht
|
|
|
130
130
|
|
|
131
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.
|
|
132
132
|
|
|
133
|
-
For vulnerability reporting and supported versions, see [SECURITY.md](SECURITY.md).
|
|
133
|
+
For vulnerability reporting and supported versions, see [SECURITY.md](https://github.com/erekola/llms-txt-validator/blob/main/SECURITY.md).
|
|
134
|
+
|
|
135
|
+
## Verify a release
|
|
136
|
+
|
|
137
|
+
GitHub Actions publishes every version from 0.1.3 on with npm trusted publishing, and each one carries a provenance attestation. To check one, install it in an empty directory and ask npm to verify the signatures. Replace the version with the one you want to check.
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
mkdir verify-llms && cd verify-llms && npm init -y && npm install turva-llms-txt-validator@0.3.17 --ignore-scripts && npm audit signatures
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`npm audit signatures` checks the registry signature and the provenance attestation of each installed package that has one. The attestation of this package names three things to compare with what you expect: the repository `github.com/erekola/llms-txt-validator`, the workflow file `.github/workflows/publish.yml` and the commit that produced the tarball. The npm version page shows them under Provenance. Tag `v0.3.17` must point at that same commit, and `gh api repos/erekola/llms-txt-validator/commits/v0.3.17 --jq .sha` prints it. A mismatch is a reason not to use that version. Provenance proves where a release was built and from which commit. It does not prove that the code is safe, so reading the source and the dependencies stays your job.
|
|
134
144
|
|
|
135
145
|
## Hosted version and source
|
|
136
146
|
|
|
@@ -146,7 +156,7 @@ curl -H "Accept: application/json" "https://turva.dev/llms-txt-validator?url=exa
|
|
|
146
156
|
|
|
147
157
|
In Windows PowerShell, use `curl.exe` if `curl` resolves to `Invoke-WebRequest`.
|
|
148
158
|
|
|
149
|
-
The package has no runtime dependencies. The repository's release workflow uses npm trusted publishing with provenance. See [SECURITY.md](SECURITY.md) for details and [CHANGELOG.md](CHANGELOG.md) for releases.
|
|
159
|
+
The package has no runtime dependencies. The repository's release workflow uses npm trusted publishing with provenance. See [SECURITY.md](https://github.com/erekola/llms-txt-validator/blob/main/SECURITY.md) for details and [CHANGELOG.md](https://github.com/erekola/llms-txt-validator/blob/main/CHANGELOG.md) for releases.
|
|
150
160
|
|
|
151
161
|
## Check HTML and Markdown content
|
|
152
162
|
|
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.17",
|
|
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
|
@@ -48,7 +48,14 @@ export function maskLocation(href, base) {
|
|
|
48
48
|
if (u && u.host) {
|
|
49
49
|
u.username = "";
|
|
50
50
|
u.password = "";
|
|
51
|
-
|
|
51
|
+
// One pass (Tek-560): a Set keeps the first-seen key order and collapses duplicates, and the
|
|
52
|
+
// masked query is built once. Setting each key on u.searchParams re-serializes the whole
|
|
53
|
+
// query every time, which is quadratic in the number of keys. An empty query is left alone,
|
|
54
|
+
// so "?" stays "?" as before.
|
|
55
|
+
const maskedQuery = new URLSearchParams();
|
|
56
|
+
// append, not set: the keys are already unique, and set searches the whole list on every call.
|
|
57
|
+
for (const key of new Set(u.searchParams.keys())) maskedQuery.append(key, "***");
|
|
58
|
+
if (maskedQuery.toString() !== "") u.search = maskedQuery.toString();
|
|
52
59
|
u.hash = "";
|
|
53
60
|
return u.href;
|
|
54
61
|
}
|
|
@@ -350,6 +357,14 @@ function collectLinks(text, defs) {
|
|
|
350
357
|
return at;
|
|
351
358
|
};
|
|
352
359
|
const isBlank = (c) => c === " " || c === "\t";
|
|
360
|
+
// A blank line ends a paragraph, and a link label cannot reach across one (Tek-560). True when
|
|
361
|
+
// the line break at p is followed by a line that holds only spaces and tabs, or by the end.
|
|
362
|
+
const blankAt = (p) => {
|
|
363
|
+
if (text[p] !== "\n") return false;
|
|
364
|
+
let q = p + 1;
|
|
365
|
+
while (q < text.length && isBlank(text[q])) q++;
|
|
366
|
+
return q >= text.length || text[q] === "\n";
|
|
367
|
+
};
|
|
353
368
|
// Spaces, tabs and at most one line ending may stand around the destination and the title, as
|
|
354
369
|
// CommonMark allows; "[a]( b )" and a ")" on the next line were missed until the review of
|
|
355
370
|
// 2026-09-27. A blank line still ends the link.
|
|
@@ -398,7 +413,7 @@ function collectLinks(text, defs) {
|
|
|
398
413
|
// nested bracket, the way one always was.
|
|
399
414
|
const skipImage = (k) => {
|
|
400
415
|
let m = k + 1;
|
|
401
|
-
while (m < text.length && text[m] !== "]" && text[m] !== "[") m += text[m] === "\\" ? 2 : 1;
|
|
416
|
+
while (m < text.length && text[m] !== "]" && text[m] !== "[" && !blankAt(m)) m += text[m] === "\\" ? 2 : 1;
|
|
402
417
|
if (m >= text.length || text[m] !== "]" || text[m + 1] !== "(") return null;
|
|
403
418
|
const t = readTarget(m + 2);
|
|
404
419
|
return t ? t.end + 1 : null;
|
|
@@ -408,7 +423,7 @@ function collectLinks(text, defs) {
|
|
|
408
423
|
if (i > 0 && text[i - 1] === "!" && !isEscaped(text, i - 1)) continue;
|
|
409
424
|
let j = i + 1;
|
|
410
425
|
for (;;) {
|
|
411
|
-
while (j < text.length && text[j] !== "]" && text[j] !== "[") j += text[j] === "\\" ? 2 : 1;
|
|
426
|
+
while (j < text.length && text[j] !== "]" && text[j] !== "[" && !blankAt(j)) j += text[j] === "\\" ? 2 : 1;
|
|
412
427
|
if (j >= text.length || text[j] !== "[") break;
|
|
413
428
|
if (code[j] || text[j - 1] !== "!" || isEscaped(text, j - 1)) break;
|
|
414
429
|
const after = skipImage(j);
|
|
@@ -416,10 +431,11 @@ function collectLinks(text, defs) {
|
|
|
416
431
|
j = after;
|
|
417
432
|
}
|
|
418
433
|
if (j >= text.length) break;
|
|
434
|
+
if (text[j] === "\n") { i = j; continue; }
|
|
419
435
|
if (text[j] === "[") { i = j - 1; continue; }
|
|
420
436
|
if (text[j + 1] === "(") {
|
|
421
437
|
const t = readTarget(j + 2);
|
|
422
|
-
if (t) { out.push({ name: text.slice(i + 1, j), target:
|
|
438
|
+
if (t) { out.push({ name: text.slice(i + 1, j), target: decodeDestination(t.target), at: i }); i = t.end; continue; }
|
|
423
439
|
i = j + 1;
|
|
424
440
|
continue;
|
|
425
441
|
}
|
|
@@ -431,7 +447,7 @@ function collectLinks(text, defs) {
|
|
|
431
447
|
const label = normalizeLinkLabel(rawLabel === "" ? text.slice(i + 1, j) : rawLabel);
|
|
432
448
|
const dest = label && defs.get(label);
|
|
433
449
|
if (dest !== undefined) {
|
|
434
|
-
out.push({ name: text.slice(i + 1, j), target:
|
|
450
|
+
out.push({ name: text.slice(i + 1, j), target: decodeDestination(dest), at: i });
|
|
435
451
|
i = m;
|
|
436
452
|
continue;
|
|
437
453
|
}
|
|
@@ -445,7 +461,7 @@ function collectLinks(text, defs) {
|
|
|
445
461
|
const label = text[j + 1] === ":" ? "" : normalizeLinkLabel(text.slice(i + 1, j));
|
|
446
462
|
const dest = label ? defs.get(label) : undefined;
|
|
447
463
|
if (dest !== undefined) {
|
|
448
|
-
out.push({ name: text.slice(i + 1, j), target:
|
|
464
|
+
out.push({ name: text.slice(i + 1, j), target: decodeDestination(dest), at: i });
|
|
449
465
|
i = j;
|
|
450
466
|
continue;
|
|
451
467
|
}
|
|
@@ -500,6 +516,26 @@ function listItemHasLink(l, hasLink) {
|
|
|
500
516
|
return !!hasLink && /^ {0,3}(?:[-*+]|\d{1,9}[.)])[ \t]/.test(l);
|
|
501
517
|
}
|
|
502
518
|
|
|
519
|
+
// The destination of a link as the URL test should see it. CommonMark lets a backslash escape any
|
|
520
|
+
// ASCII punctuation character, so "https\://example.com" is the destination "https://example.com".
|
|
521
|
+
// Backslash escapes and character references are read in one pass over the source, so an escaped
|
|
522
|
+
// ampersand stays literal text and is not decoded a second time (Tek-560).
|
|
523
|
+
function decodeDestination(s) {
|
|
524
|
+
const escapeAt = /(\\[!-\/:-@\[-`{-~])/;
|
|
525
|
+
return String(s).split(escapeAt).map((part) => (part.length === 2 && part[0] === "\\" && /[!-\/:-@\[-`{-~]/.test(part[1]) ? part[1] : decodeAttributeValue(part))).join("");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// A setext H1: one text line indented at most three spaces whose next line is a run of "=" (also
|
|
529
|
+
// indented at most three spaces, trailing spaces allowed). Returns true for the pair. A line that
|
|
530
|
+
// starts a block of its own (heading, blockquote, list item, fence) is not paragraph text, so it
|
|
531
|
+
// never becomes a setext title. Only a one-line title is read; a title over several lines is not
|
|
532
|
+
// (Tek-560).
|
|
533
|
+
function isSetextH1(line, next) {
|
|
534
|
+
if (typeof next !== "string" || !/^ {0,3}=+[ \t]*$/.test(next)) return false;
|
|
535
|
+
if (!/^ {0,3}\S/.test(line)) return false;
|
|
536
|
+
return !/^ {0,3}(?:#|>|[-*+](?:[ \t]|$)|\d{1,9}[.)](?:[ \t]|$)|`{3,}|~{3,})/.test(line);
|
|
537
|
+
}
|
|
538
|
+
|
|
503
539
|
// The text of an ATX H1, or null when the line is not one. One to three leading spaces, a
|
|
504
540
|
// single "#", then a space, a tab or the end of the line. A closing run of "#" preceded by a
|
|
505
541
|
// space or a tab is not part of the title, and the title is what remains once both ends are
|
|
@@ -560,7 +596,9 @@ export function validateLlmsTxt(f) {
|
|
|
560
596
|
// document than the fetched one for the exact same bytes until this was measured (outside
|
|
561
597
|
// review 2026-09-28); the h1-title check saw the mark as part of the first line and failed a
|
|
562
598
|
// document that would pass when fetched.
|
|
563
|
-
|
|
599
|
+
// CR-only and CRLF line endings become LF here (Tek-560): a file written with bare CR was one
|
|
600
|
+
// line for the old split and read as a title with the whole document in it.
|
|
601
|
+
const text = (f.text.charCodeAt(0) === 0xFEFF ? f.text.slice(1) : f.text).replace(/\r\n?/g, "\n");
|
|
564
602
|
const ct = (f.contentType || "").toLowerCase();
|
|
565
603
|
const looksHtml = /^\s*(<!doctype|<html|<head|<body)/i.test(text);
|
|
566
604
|
if (looksHtml) {
|
|
@@ -575,7 +613,7 @@ export function validateLlmsTxt(f) {
|
|
|
575
613
|
} else {
|
|
576
614
|
add("content-type", "warn", "Response is plain text", "content-type is " + (ct.split(";")[0] || "missing") + ", text/plain or text/markdown is the convention");
|
|
577
615
|
}
|
|
578
|
-
const lines = text.split(
|
|
616
|
+
const lines = text.split("\n");
|
|
579
617
|
const firstIdx = lines.findIndex((l) => l.trim() !== "");
|
|
580
618
|
const firstRaw = firstIdx === -1 ? "" : lines[firstIdx];
|
|
581
619
|
const first = firstRaw.trim();
|
|
@@ -584,14 +622,19 @@ export function validateLlmsTxt(f) {
|
|
|
584
622
|
// " # Site" passed as the H1 until 2026-08-29. CommonMark allows three spaces.
|
|
585
623
|
// h1Title reads the heading the way CommonMark does, its closing run included (Tek-496).
|
|
586
624
|
const title = h1Title(firstRaw);
|
|
587
|
-
|
|
625
|
+
// A setext H1 (a text line, then a line of "=") is an H1 as well (Tek-560). The summary and the
|
|
626
|
+
// later scans start after its underline.
|
|
627
|
+
const setext = title === null && firstIdx !== -1 && isSetextH1(firstRaw, lines[firstIdx + 1]);
|
|
628
|
+
const h1End = setext ? firstIdx + 1 : firstIdx;
|
|
629
|
+
if (title || setext) {
|
|
588
630
|
add("h1-title", "pass", "Starts with an H1 title", JSON.stringify(cut(first, 80)));
|
|
589
631
|
} else if (title === "") {
|
|
590
632
|
add("h1-title", "fail", "Starts with an H1 title", "the H1 has no text; the format requires the project name there");
|
|
591
633
|
} else {
|
|
592
|
-
add("h1-title", "fail", "Starts with an H1 title", "the first non-empty line should be a markdown H1 (# Site name)");
|
|
634
|
+
add("h1-title", "fail", "Starts with an H1 title", "the first non-empty line should be a markdown H1 (# Site name, or one line of text underlined with =)");
|
|
593
635
|
}
|
|
594
|
-
const
|
|
636
|
+
const afterIdx = lines.findIndex((l, i) => i > h1End && l.trim() !== "");
|
|
637
|
+
const afterH1 = afterIdx === -1 ? "" : lines[afterIdx];
|
|
595
638
|
// Read the same way as the H1 check and the "late" blockquote search below: at most three
|
|
596
639
|
// leading spaces, per CommonMark. A trim-first prefix test used to erase the difference, so
|
|
597
640
|
// a four-space or tab-indented code block right after the title passed as the summary until
|
|
@@ -601,8 +644,18 @@ export function validateLlmsTxt(f) {
|
|
|
601
644
|
// same way, and four or more leading spaces stay indented code, not a blockquote (outside
|
|
602
645
|
// review 2026-09-28: a literal space was required, so a tab or no space at all warned as
|
|
603
646
|
// missing).
|
|
647
|
+
// The blockquote directly under the title is one block of consecutive ">" lines, and it is the
|
|
648
|
+
// summary when any line of it has text (Tek-560). ">" followed by "> Real summary" passes, and a
|
|
649
|
+
// block whose every line is empty (">" alone, or ">" on several lines) is an empty blockquote and
|
|
650
|
+
// warns. A nested ">>" or "> >" line counts as text, as it did in 0.3.15.
|
|
651
|
+
let summaryLine = "";
|
|
604
652
|
if (/^ {0,3}>/.test(afterH1)) {
|
|
605
|
-
|
|
653
|
+
for (let i = afterIdx; i < lines.length && /^ {0,3}>/.test(lines[i]); i++) {
|
|
654
|
+
if (/^ {0,3}>[ \t]*\S/.test(lines[i])) { summaryLine = lines[i]; break; }
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (summaryLine !== "") {
|
|
658
|
+
add("summary", "pass", "Blockquote summary after the title", JSON.stringify(cut(summaryLine.trim(), 80)));
|
|
606
659
|
} else {
|
|
607
660
|
// A blockquote further down, before the first H2, is a summary in the wrong place and not a
|
|
608
661
|
// missing one, and the detail says which (outside retest 2026-09-26, mirrored from worker.js
|
|
@@ -610,10 +663,10 @@ export function validateLlmsTxt(f) {
|
|
|
610
663
|
// after the title.
|
|
611
664
|
const fence = fenceMask(lines);
|
|
612
665
|
let late = -1;
|
|
613
|
-
for (let i =
|
|
666
|
+
for (let i = h1End + 1; i < lines.length; i++) {
|
|
614
667
|
if (fence[i]) continue;
|
|
615
668
|
if (/^ {0,3}## /.test(lines[i])) break;
|
|
616
|
-
if (/^ {0,3}
|
|
669
|
+
if (/^ {0,3}>[ \t]*\S/.test(lines[i])) { late = i; break; }
|
|
617
670
|
}
|
|
618
671
|
add("summary", "warn", "Blockquote summary after the title", late === -1
|
|
619
672
|
? "recommended by the format (> one-line summary), not required"
|
|
@@ -661,15 +714,60 @@ export function validateLlmsTxt(f) {
|
|
|
661
714
|
// Both passed until 2026-09-27 (Tek-496). The first one is named in the detail.
|
|
662
715
|
let sectionsWithList = 0, misplaced = -1;
|
|
663
716
|
{
|
|
664
|
-
let inSection = false, counted = false, seenH2 = false;
|
|
717
|
+
let inSection = false, counted = false, seenH2 = false, itemOpen = false, itemGap = false, itemEmpty = false, itemCol = 0, afterFence = false;
|
|
718
|
+
// Width of the leading white space of a line in columns, a tab running to the next multiple of 4.
|
|
719
|
+
const indentWidth = (s) => { let c = 0; for (const ch of s) { if (ch === " ") c++; else if (ch === "\t") c += 4 - (c % 4); else break; } return c; };
|
|
665
720
|
for (let i = 0; i < lines.length; i++) {
|
|
666
721
|
const l = lines[i];
|
|
667
|
-
|
|
668
|
-
|
|
722
|
+
// A code fence indented less than the item's content column closes the open item, so a link
|
|
723
|
+
// after it is not that item's link. The content column is where the item's text starts, 2 for
|
|
724
|
+
// "- item" and 4 for "- item". A fence indented to that column is inside the item and keeps
|
|
725
|
+
// it open, as in CommonMark, but the line after it then counts only when it is indented to
|
|
726
|
+
// that column too, because a lazy continuation cannot follow a fence.
|
|
727
|
+
if (fenced[i]) {
|
|
728
|
+
if (l.trim() !== "" && indentWidth(l) < itemCol) itemOpen = false;
|
|
729
|
+
if (itemOpen) afterFence = true;
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
if (/^ {0,3}## /.test(l)) { inSection = true; counted = false; seenH2 = true; itemOpen = false; continue; }
|
|
669
733
|
const h1 = /^ {0,3}#(?:[ \t]|$)/.test(l);
|
|
670
734
|
if (misplaced === -1 && i > firstIdx && (h1 || (!seenH2 && /^ {0,3}#{3,6}(?:[ \t]|$)/.test(l)))) misplaced = i;
|
|
671
|
-
if (h1) { inSection = false; continue; }
|
|
672
|
-
|
|
735
|
+
if (h1) { inSection = false; itemOpen = false; continue; }
|
|
736
|
+
// A link on a line that continues an open list item counts as that item's link (Tek-560):
|
|
737
|
+
// a marker alone on its line, a link on the next line, or a lazy continuation line. A blank
|
|
738
|
+
// line keeps the item open only for a line indented to the item's content column after it. A
|
|
739
|
+
// heading, a blockquote or a thematic break ends the item. An empty marker takes no lazy
|
|
740
|
+
// continuation (CommonMark): the next line counts only when indented to the content column,
|
|
741
|
+
// and a blank line right after the empty marker closes the item.
|
|
742
|
+
if (l.trim() === "") { if (itemEmpty) itemOpen = false; itemGap = itemOpen; continue; }
|
|
743
|
+
const fromFence = afterFence;
|
|
744
|
+
afterFence = false;
|
|
745
|
+
if (/^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)/.test(l)) {
|
|
746
|
+
itemOpen = true;
|
|
747
|
+
itemGap = false;
|
|
748
|
+
itemEmpty = /^ {0,3}(?:[-*+]|\d{1,9}[.)])[ \t]*$/.test(l);
|
|
749
|
+
{
|
|
750
|
+
// Content column: where the text after the marker starts when one to four spaces
|
|
751
|
+
// separate them, and the column after the marker when the line is empty or five or
|
|
752
|
+
// more spaces follow, which makes the rest indented code.
|
|
753
|
+
const mk = /^( {0,3})([-*+]|\d{1,9}[.)])([ \t]*)/.exec(l);
|
|
754
|
+
const markerEnd = mk[1].length + mk[2].length;
|
|
755
|
+
let c = markerEnd;
|
|
756
|
+
for (const ch of mk[3]) c += ch === "\t" ? 4 - (c % 4) : 1;
|
|
757
|
+
itemCol = (mk[0].length === l.length || c - markerEnd > 4) ? markerEnd + 1 : c;
|
|
758
|
+
}
|
|
759
|
+
if (inSection && !counted && listItemHasLink(l, linkedLineIdx.has(i))) { sectionsWithList++; counted = true; }
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
if (/^ {0,3}(?:#{1,6}(?:[ \t]|$)|>|([-*_])(?: *\1){2,} *$)/.test(l)) { itemOpen = false; continue; }
|
|
763
|
+
const indented = indentWidth(l) >= itemCol;
|
|
764
|
+
if (itemOpen && (!itemGap || indented) && (!itemEmpty || indented) && (!fromFence || indented)) {
|
|
765
|
+
if (inSection && !counted && linkedLineIdx.has(i)) { sectionsWithList++; counted = true; }
|
|
766
|
+
itemGap = false;
|
|
767
|
+
itemEmpty = false;
|
|
768
|
+
} else {
|
|
769
|
+
itemOpen = false;
|
|
770
|
+
}
|
|
673
771
|
}
|
|
674
772
|
}
|
|
675
773
|
if (h2Count > 0 && sectionsWithList > 0) {
|
|
@@ -711,7 +809,15 @@ export function validateLlmsTxt(f) {
|
|
|
711
809
|
} else {
|
|
712
810
|
add("size", "warn", "Small enough to be cheap to read", f.bytes + " bytes; consider moving detail to llms-full.txt");
|
|
713
811
|
}
|
|
714
|
-
|
|
812
|
+
// A tag needs its closing ">" (Tek-560): an opening tag with well formed attributes, a closing tag
|
|
813
|
+
// or a comment start. "a <b + c" is prose and no longer warns. An attribute name is any run of
|
|
814
|
+
// characters other than white space, quotes, "<", ">", "/" and "=" that holds a Unicode letter,
|
|
815
|
+
// a Unicode digit or an underscore, so "@click", "*ngIf", ":class" and "ä" count, while a bare
|
|
816
|
+
// operator such as the "+" in "a <b + c > d" does not. The tag name itself is ASCII. An unquoted
|
|
817
|
+
// value may not start with a quote, which keeps the quoted and the unquoted form from matching
|
|
818
|
+
// the same text, so the scan stays linear. A tag still open at the end of the file, as in
|
|
819
|
+
// '<div class="x"', has no closing ">" and does not warn.
|
|
820
|
+
if (/<[a-zA-Z][a-zA-Z0-9-]*(?:\s+[^\s"'<>\/=\p{L}\p{N}_]*[\p{L}\p{N}_][^\s"'<>\/=]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|(?!["'])[^\s<>]+))?)*\s*\/?>|<\/[a-zA-Z][a-zA-Z0-9-]*\s*>|<!--/u.test(text)) {
|
|
715
821
|
add("no-html", "warn", "No HTML markup in the file", "HTML tags found; llms.txt should be plain markdown");
|
|
716
822
|
} else {
|
|
717
823
|
add("no-html", "pass", "No HTML markup in the file", "plain markdown");
|
|
@@ -1297,36 +1403,55 @@ function scriptEnd(lower, from) {
|
|
|
1297
1403
|
}
|
|
1298
1404
|
|
|
1299
1405
|
// The end of a nested template: templates count, so an inner </template> does not close
|
|
1300
|
-
// an outer one. Returns the index after the closing tag, or -1 when it never closes.
|
|
1406
|
+
// an outer one. Returns the index after the closing tag, or -1 when it never closes. A comment
|
|
1407
|
+
// or a raw text element inside the template is skipped whole before its markers are counted.
|
|
1408
|
+
// Raw text and RCDATA elements whose content is text for the tokenizer, so a "<template" or a
|
|
1409
|
+
// "</template" inside one is not a tag. script is read by scriptEnd, the others by rawTextEnd.
|
|
1410
|
+
const TEMPLATE_RAW_TEXT = ["script", "style", "title", "noscript", "noframes", "textarea", "xmp", "iframe", "noembed"];
|
|
1301
1411
|
function templateEnd(lower, from) {
|
|
1302
|
-
let depth = 1;
|
|
1303
|
-
//
|
|
1304
|
-
//
|
|
1305
|
-
//
|
|
1306
|
-
|
|
1307
|
-
let close = lower.indexOf("</template", from);
|
|
1412
|
+
let depth = 1, i = from;
|
|
1413
|
+
// One forward scan: every "<" is looked at once and the cursor never moves back, so the cost
|
|
1414
|
+
// stays linear (restarting a search each round measured 15 686 ms on 1 MB of "</templateX").
|
|
1415
|
+
// Comments and raw text elements are stepped over whole, because the markers inside them are
|
|
1416
|
+
// text for a parser and counted before as if they were tags (Tek-560).
|
|
1308
1417
|
for (;;) {
|
|
1309
|
-
|
|
1310
|
-
if (
|
|
1311
|
-
|
|
1312
|
-
|
|
1418
|
+
const lt = lower.indexOf("<", i);
|
|
1419
|
+
if (lt === -1) return -1;
|
|
1420
|
+
if (lower.startsWith("<!--", lt)) {
|
|
1421
|
+
if (lower.startsWith("<!-->", lt)) { i = lt + 5; continue; }
|
|
1422
|
+
if (lower.startsWith("<!--->", lt)) { i = lt + 6; continue; }
|
|
1423
|
+
const cend = commentEnd(lower, lt + 4);
|
|
1424
|
+
if (cend === -1) return -1;
|
|
1425
|
+
i = cend;
|
|
1426
|
+
continue;
|
|
1427
|
+
}
|
|
1428
|
+
if (lower.startsWith("</template", lt)) {
|
|
1429
|
+
let j = skipSpace(lower, lt + 10);
|
|
1430
|
+
if (lower[j] === "/") j = skipSpace(lower, j + 1);
|
|
1431
|
+
if (lower[j] !== ">") {
|
|
1432
|
+
// Not an end tag: for the tokenizer the rest of the name runs to the next ">", and a
|
|
1433
|
+
// "<" inside it is part of the name rather than a new tag.
|
|
1434
|
+
const bogus = lower.indexOf(">", lt + 10);
|
|
1435
|
+
if (bogus === -1) return -1;
|
|
1436
|
+
i = bogus + 1;
|
|
1437
|
+
continue;
|
|
1438
|
+
}
|
|
1439
|
+
depth--;
|
|
1440
|
+
if (depth === 0) return j + 1;
|
|
1441
|
+
i = j + 1;
|
|
1313
1442
|
continue;
|
|
1314
1443
|
}
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
if (
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
close = lower.indexOf("</template", bogus + 1);
|
|
1324
|
-
if (open !== -1 && open < bogus) open = lower.indexOf("<template", bogus + 1);
|
|
1444
|
+
if (lower.startsWith("<template", lt) && isTagBoundary(lower[lt + 9])) { depth++; i = lt + 9; continue; }
|
|
1445
|
+
const rawName = TEMPLATE_RAW_TEXT.find((n) => lower.startsWith(n, lt + 1) && isTagBoundary(lower[lt + 1 + n.length]));
|
|
1446
|
+
if (rawName) {
|
|
1447
|
+
const gt = tagEnd(lower, lt);
|
|
1448
|
+
if (gt === -1) return -1;
|
|
1449
|
+
const rend = rawName === "script" ? scriptEnd(lower, gt + 1) : rawTextEnd(lower, rawName, gt + 1);
|
|
1450
|
+
if (rend === -1) return -1;
|
|
1451
|
+
i = rend;
|
|
1325
1452
|
continue;
|
|
1326
1453
|
}
|
|
1327
|
-
|
|
1328
|
-
if (depth === 0) return j + 1;
|
|
1329
|
-
close = lower.indexOf("</template", j + 1);
|
|
1454
|
+
i = lt + 1;
|
|
1330
1455
|
}
|
|
1331
1456
|
}
|
|
1332
1457
|
|