turva-llms-txt-validator 0.3.14 → 0.3.16

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.
Files changed (3) hide show
  1. package/README.md +7 -7
  2. package/package.json +1 -1
  3. 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 | HTML tags found |
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`; 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.
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
 
@@ -84,7 +84,7 @@ These checks are labelled **v2** in the output. They look for link relations in
84
84
  | 9 | Home page points to its llms.txt | `rel="describedby"` has a non-empty target | `info` |
85
85
  | 10 | Home page points to a Markdown version | `rel="alternate"` has media type `text/markdown` and a non-empty target | `info` |
86
86
 
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.
87
+ The validator detects these declarations without fetching their targets. It reads the first 65,536 UTF-16 code units of the head after comments and the contents of script, style, title and template elements have been set aside, 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.
88
88
 
89
89
  ## Use from Node
90
90
 
@@ -130,7 +130,7 @@ 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
134
 
135
135
  ## Hosted version and source
136
136
 
@@ -146,7 +146,7 @@ curl -H "Accept: application/json" "https://turva.dev/llms-txt-validator?url=exa
146
146
 
147
147
  In Windows PowerShell, use `curl.exe` if `curl` resolves to `Invoke-WebRequest`.
148
148
 
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.
149
+ 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
150
 
151
151
  ## Check HTML and Markdown content
152
152
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turva-llms-txt-validator",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
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
- for (const key of Array.from(u.searchParams.keys())) u.searchParams.set(key, "***");
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: decodeAttributeValue(t.target), at: i }); i = t.end; continue; }
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: decodeAttributeValue(dest), at: i });
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: decodeAttributeValue(dest), at: i });
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
- const text = f.text.charCodeAt(0) === 0xFEFF ? f.text.slice(1) : f.text;
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(/\r?\n/);
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
- if (title) {
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 afterH1 = lines.slice(firstIdx + 1).find((l) => l.trim() !== "") || "";
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
- add("summary", "pass", "Blockquote summary after the title", JSON.stringify(cut(afterH1.trim(), 80)));
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 = firstIdx + 1; i < lines.length; 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}>/.test(lines[i])) { late = i; break; }
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
- if (fenced[i]) continue;
668
- if (/^ {0,3}## /.test(l)) { inSection = true; counted = false; seenH2 = true; continue; }
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
- if (inSection && !counted && listItemHasLink(l, linkedLineIdx.has(i))) { sectionsWithList++; counted = true; }
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
- if (/<[a-z][a-z0-9-]*[\s>]/i.test(text)) {
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
- // Both searches resume from their own previous hit. Restarting either one from a shared
1304
- // cursor is quadratic: "</templateX" repeated made every round scan to the end of the
1305
- // input again, and 1 MB of it measured 15 686 ms.
1306
- let open = lower.indexOf("<template", from);
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
- if (close === -1) return -1;
1310
- if (open !== -1 && open < close) {
1311
- if (isTagBoundary(lower[open + 9])) depth++;
1312
- open = lower.indexOf("<template", open + 9);
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
- let j = close + 10;
1316
- j = skipSpace(lower, j);
1317
- if (lower[j] === "/") j = skipSpace(lower, j + 1);
1318
- if (lower[j] !== ">") {
1319
- // Not an end tag: for the tokenizer the rest of the name runs to the next ">", and a
1320
- // "<" inside it is part of the name rather than a new tag.
1321
- const bogus = lower.indexOf(">", close + 10);
1322
- if (bogus === -1) return -1;
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
- depth--;
1328
- if (depth === 0) return j + 1;
1329
- close = lower.indexOf("</template", j + 1);
1454
+ i = lt + 1;
1330
1455
  }
1331
1456
  }
1332
1457