turva-llms-txt-validator 0.2.0 → 0.3.0
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 +5 -5
- package/package.json +1 -1
- package/src/index.mjs +281 -26
package/README.md
CHANGED
|
@@ -28,10 +28,10 @@ The target has to be a public domain name: an IP literal, a bracketed IPv6 addre
|
|
|
28
28
|
|---|-------|------|------|
|
|
29
29
|
| 1 | File exists at /llms.txt, HTTP 200 (a same-site www/apex redirect is followed) | non-200, or an off-site or unsafe redirect | |
|
|
30
30
|
| 2 | Response is plain text | body looks like HTML | content-type is not text/plain or text/markdown |
|
|
31
|
-
| 3 | Starts with an H1 title | first non-empty line is not a markdown H1 | |
|
|
31
|
+
| 3 | Starts with an H1 title | first non-empty line is not a markdown H1, indentation included, since four spaces make it a code block | |
|
|
32
32
|
| 4 | Blockquote summary after the title | | missing one-line summary |
|
|
33
|
-
| 5 | H2 sections group the content | | no H2 sections |
|
|
34
|
-
| 6 | Markdown links an agent can follow | | no links, or relative links |
|
|
33
|
+
| 5 | H2 sections group the content | | no H2 sections, or no section carrying a markdown file list |
|
|
34
|
+
| 6 | Markdown links an agent can follow | | no links, an empty link name, a scheme without a host, or relative links |
|
|
35
35
|
| 7 | Small enough to be cheap to read | | over 50 KB, or read truncated at 256 KB |
|
|
36
36
|
| 8 | No HTML markup in the file | | HTML tags found |
|
|
37
37
|
|
|
@@ -39,8 +39,8 @@ Two more checks read the site's home page for the link relations v2 of the forma
|
|
|
39
39
|
|
|
40
40
|
| # | Check | pass | info |
|
|
41
41
|
|---|-------|------|------|
|
|
42
|
-
| 9 | Home page points to its llms.txt (v2) | `rel="describedby"` found in the head or the Link header | not found, or the home page could not be read |
|
|
43
|
-
| 10 | Home page points to a markdown version (v2) | `rel="alternate" type
|
|
42
|
+
| 9 | Home page points to its llms.txt (v2) | `rel="describedby"` with a target, found in the head or the Link header | not found, no target on the relation, or the home page could not be read |
|
|
43
|
+
| 10 | Home page points to a markdown version (v2) | `rel="alternate"` with the media type `text/markdown` and a target, found in the head or the Link header | not found, no target on the relation, a different media type, or the home page could not be read |
|
|
44
44
|
|
|
45
45
|
## Node API
|
|
46
46
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "turva-llms-txt-validator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Validate a site's llms.txt structure: eight checks as pass/warn/fail plus two llms.txt v2 discovery checks as information, 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
|
@@ -135,8 +135,12 @@ export function validateLlmsTxt(f) {
|
|
|
135
135
|
}
|
|
136
136
|
const lines = f.text.split(/\r?\n/);
|
|
137
137
|
const firstIdx = lines.findIndex((l) => l.trim() !== "");
|
|
138
|
-
const
|
|
139
|
-
|
|
138
|
+
const firstRaw = firstIdx === -1 ? "" : lines[firstIdx];
|
|
139
|
+
const first = firstRaw.trim();
|
|
140
|
+
// The line is read as markdown and not trimmed first. Four spaces or a tab make it an
|
|
141
|
+
// indented code block rather than a heading, and trimming erased that difference, so
|
|
142
|
+
// " # Site" passed as the H1 until 2026-08-29. CommonMark allows three spaces.
|
|
143
|
+
if (/^ {0,3}# \S/.test(firstRaw)) {
|
|
140
144
|
add("h1-title", "pass", "Starts with an H1 title", JSON.stringify(first.slice(0, 80)));
|
|
141
145
|
} else {
|
|
142
146
|
add("h1-title", "fail", "Starts with an H1 title", "the first non-empty line should be a markdown H1 (# Site name)");
|
|
@@ -148,19 +152,40 @@ export function validateLlmsTxt(f) {
|
|
|
148
152
|
add("summary", "warn", "Blockquote summary after the title", "recommended by the format (> one-line summary), not required");
|
|
149
153
|
}
|
|
150
154
|
const h2Count = (f.text.match(/^## /gm) || []).length;
|
|
151
|
-
|
|
152
|
-
|
|
155
|
+
// A section counts when it carries a file list. An H2 followed by a paragraph satisfied
|
|
156
|
+
// this check until 2026-08-29, and the format puts each section's links in a list.
|
|
157
|
+
let sectionsWithList = 0;
|
|
158
|
+
{
|
|
159
|
+
let inSection = false, counted = false;
|
|
160
|
+
for (const l of lines) {
|
|
161
|
+
if (/^## /.test(l)) { inSection = true; counted = false; continue; }
|
|
162
|
+
if (/^# /.test(l)) { inSection = false; continue; }
|
|
163
|
+
if (inSection && !counted && /^ {0,3}[-*+] .*\[[^\][]*\]\([^)\s]+\)/.test(l)) { sectionsWithList++; counted = true; }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (h2Count > 0 && sectionsWithList > 0) {
|
|
167
|
+
add("sections", "pass", "H2 sections group the content", h2Count + " section" + (h2Count === 1 ? "" : "s") + ", " + sectionsWithList + " carrying a file list");
|
|
168
|
+
} else if (h2Count > 0) {
|
|
169
|
+
add("sections", "warn", "H2 sections group the content", h2Count + " section" + (h2Count === 1 ? "" : "s") + " but no file list under any of them; the format puts a section's links in a markdown list");
|
|
153
170
|
} else {
|
|
154
171
|
add("sections", "warn", "H2 sections group the content", "no H2 sections found; sections are the convention for grouping links");
|
|
155
172
|
}
|
|
156
173
|
const links = [...f.text.matchAll(/\[([^\][]*)\]\(([^)\s]{1,2048})\)/g)];
|
|
157
|
-
|
|
174
|
+
// An entry an agent can use has a name and a target with a host. An empty name and a
|
|
175
|
+
// bare "https://" both counted as valid absolute links until 2026-08-29.
|
|
176
|
+
const named = links.filter((m) => m[1].trim() !== "");
|
|
177
|
+
const unnamed = links.length - named.length;
|
|
178
|
+
const absolute = named.filter((m) => /^https?:\/\/[^/\s?#]+/.test(m[2])).length;
|
|
158
179
|
if (links.length === 0) {
|
|
159
180
|
add("links", "warn", "Markdown links an agent can follow", "no markdown links found");
|
|
160
|
-
} else if (
|
|
161
|
-
add("links", "
|
|
181
|
+
} else if (unnamed > 0) {
|
|
182
|
+
add("links", "warn", "Markdown links an agent can follow", links.length + " link" + (links.length === 1 ? "" : "s") + ", " + unnamed + " with an empty link name; an entry needs a name an agent can show");
|
|
183
|
+
} else if (absolute === named.length) {
|
|
184
|
+
add("links", "pass", "Markdown links an agent can follow", named.length + " link" + (named.length === 1 ? "" : "s") + ", all absolute URLs");
|
|
162
185
|
} else {
|
|
163
|
-
|
|
186
|
+
const relativeCount = named.filter((m) => !/^[a-z][a-z0-9+.-]*:/i.test(m[2])).length;
|
|
187
|
+
const hostless = named.length - absolute - relativeCount;
|
|
188
|
+
add("links", "warn", "Markdown links an agent can follow", named.length + " links, " + relativeCount + " relative" + (hostless > 0 ? " and " + hostless + " with a scheme but no host" : "") + "; absolute URLs travel better when the file is read out of context");
|
|
164
189
|
}
|
|
165
190
|
if (f.truncated) {
|
|
166
191
|
add("size", "warn", "Small enough to be cheap to read", "over 256 KB, read truncated");
|
|
@@ -177,6 +202,224 @@ export function validateLlmsTxt(f) {
|
|
|
177
202
|
return checks;
|
|
178
203
|
}
|
|
179
204
|
|
|
205
|
+
// A "<" starts a tag only when a letter, "!", "/" or "?" follows it. Anything else is
|
|
206
|
+
// text, and the NEXT "<" can still start a tag. Measured, not assumed: without this rule
|
|
207
|
+
// "<<style><link rel=describedby ...>" read the link as published while a real parser
|
|
208
|
+
// treats the first "<" as text, opens style, and publishes nothing.
|
|
209
|
+
function startsTag(c) {
|
|
210
|
+
return c !== undefined && (c === "!" || c === "/" || c === "?" || (c >= "a" && c <= "z"));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The end of the tag that starts at lt: the first ">" that is NOT inside a quoted
|
|
214
|
+
// attribute value. A quote opens a value only right after "=", which is what the
|
|
215
|
+
// tokenizer does. indexOf(">") is wrong here: <link data-x="a>b" rel="describedby"> is
|
|
216
|
+
// one tag for a parser and two for indexOf, and the relation was lost.
|
|
217
|
+
function tagEnd(text, lt) {
|
|
218
|
+
let quote = "", afterEq = false;
|
|
219
|
+
for (let j = lt + 1; j < text.length; j++) {
|
|
220
|
+
const c = text[j];
|
|
221
|
+
if (quote) { if (c === quote) quote = ""; continue; }
|
|
222
|
+
if (c === ">") return j;
|
|
223
|
+
if (c === "=") { afterEq = true; continue; }
|
|
224
|
+
if (c === " " || c === "\t" || c === "\n" || c === "\r" || c === "\f") continue;
|
|
225
|
+
if (afterEq && (c === '"' || c === "'")) { quote = c; afterEq = false; continue; }
|
|
226
|
+
afterEq = false;
|
|
227
|
+
}
|
|
228
|
+
return -1;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// The head is where a real parser says it ends, not where the text "</head>" happens to
|
|
232
|
+
// appear. Erik's decision 2026-08-24: the two v2 checks describe what the page's HEAD
|
|
233
|
+
// points at, so a link element the parser moves into the body is not one of them. That is
|
|
234
|
+
// the strict reading, and it is what the HTML parsing spec's "in head" insertion mode
|
|
235
|
+
// does: whitespace, comments, doctype and the head-only elements keep the head open;
|
|
236
|
+
// the first text node, the first body-level element and </head>, </body>, </html> or
|
|
237
|
+
// </br> close it; any other end tag in the head is a parse error and is ignored.
|
|
238
|
+
var HEAD_ELEMENTS = ["base", "basefont", "bgsound", "link", "meta", "noframes", "script", "style", "template", "title", "noscript"];
|
|
239
|
+
// Once </head> has been seen the parser is in "after head", and that list is the one above
|
|
240
|
+
// WITHOUT noscript: a noscript there opens the body instead of staying in the head.
|
|
241
|
+
var AFTER_HEAD_ELEMENTS = ["base", "basefont", "bgsound", "link", "meta", "noframes", "script", "style", "template", "title"];
|
|
242
|
+
// Their content is not markup: script, style, title, noscript (a parser with scripting on
|
|
243
|
+
// reads it as raw text) and noframes are raw text or RCDATA, and template content is inert.
|
|
244
|
+
var HEAD_SKIPPED_CONTENT = ["script", "style", "title", "noscript", "noframes", "template"];
|
|
245
|
+
|
|
246
|
+
// The end of a raw text or RCDATA element: the first </name that is followed by optional
|
|
247
|
+
// whitespace, an optional "/" (a parser closes on </script/> too) and a ">". Returns the
|
|
248
|
+
// index after it, or -1 when the element never closes, which means the rest of the
|
|
249
|
+
// document is inside it.
|
|
250
|
+
function skipSpace(lower, j) {
|
|
251
|
+
while (j < lower.length && (lower[j] === " " || lower[j] === "\t" || lower[j] === "\n" || lower[j] === "\r" || lower[j] === "\f")) j++;
|
|
252
|
+
return j;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// True when the character ends a tag name: whitespace, "/", ">" or the end of the input.
|
|
256
|
+
function isTagBoundary(c) {
|
|
257
|
+
return c === undefined || c === ">" || c === "/" || c === " " || c === "\t" || c === "\n" || c === "\r" || c === "\f";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// The index just after "</name ... >", or -1 when that is not an end tag there.
|
|
261
|
+
function endTagAt(lower, name, at) {
|
|
262
|
+
if (!lower.startsWith("</" + name, at)) return -1;
|
|
263
|
+
let j = skipSpace(lower, at + name.length + 2);
|
|
264
|
+
if (lower[j] === "/") j = skipSpace(lower, j + 1);
|
|
265
|
+
return lower[j] === ">" ? j + 1 : -1;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function rawTextEnd(lower, name, from) {
|
|
269
|
+
for (let at = lower.indexOf("</" + name, from); at !== -1; at = lower.indexOf("</" + name, at + name.length + 2)) {
|
|
270
|
+
const end = endTagAt(lower, name, at);
|
|
271
|
+
if (end !== -1) return end;
|
|
272
|
+
}
|
|
273
|
+
return -1;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// script is not plain raw text: <!-- puts the tokenizer in the escaped state, a nested
|
|
277
|
+
// <script there puts it in the double escaped state, and in THAT state </script only ends
|
|
278
|
+
// the escape, not the element. Without this the legacy shape
|
|
279
|
+
// <script><!-- ... <script>...</script> ... --></script> ended early and the rest of the
|
|
280
|
+
// script was read as markup, which is the wrong direction. 4 of 240 000 fuzz inputs.
|
|
281
|
+
function scriptEnd(lower, from) {
|
|
282
|
+
let i = from, escaped = false, doubleEscaped = false;
|
|
283
|
+
while (i < lower.length) {
|
|
284
|
+
if (lower.startsWith("<!--", i)) { escaped = true; i += 4; continue; }
|
|
285
|
+
if (escaped && lower.startsWith("-->", i)) { escaped = false; doubleEscaped = false; i += 3; continue; }
|
|
286
|
+
if (escaped && !doubleEscaped && lower.startsWith("<script", i) && isTagBoundary(lower[i + 7])) { doubleEscaped = true; i += 7; continue; }
|
|
287
|
+
if (lower.startsWith("</script", i)) {
|
|
288
|
+
if (doubleEscaped) { doubleEscaped = false; i += 8; continue; }
|
|
289
|
+
const end = endTagAt(lower, "script", i);
|
|
290
|
+
if (end !== -1) return end;
|
|
291
|
+
i += 8;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const next = lower.indexOf("<", i + 1);
|
|
295
|
+
i = next === -1 ? lower.length : next;
|
|
296
|
+
}
|
|
297
|
+
return -1;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// The end of a nested template: templates count, so an inner </template> does not close
|
|
301
|
+
// an outer one. Returns the index after the closing tag, or -1 when it never closes.
|
|
302
|
+
function templateEnd(lower, from) {
|
|
303
|
+
let depth = 1;
|
|
304
|
+
// Both searches resume from their own previous hit. Restarting either one from a shared
|
|
305
|
+
// cursor is quadratic: "</templateX" repeated made every round scan to the end of the
|
|
306
|
+
// input again, and 1 MB of it measured 15 686 ms.
|
|
307
|
+
let open = lower.indexOf("<template", from);
|
|
308
|
+
let close = lower.indexOf("</template", from);
|
|
309
|
+
for (;;) {
|
|
310
|
+
if (close === -1) return -1;
|
|
311
|
+
if (open !== -1 && open < close) {
|
|
312
|
+
if (isTagBoundary(lower[open + 9])) depth++;
|
|
313
|
+
open = lower.indexOf("<template", open + 9);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
let j = close + 10;
|
|
317
|
+
j = skipSpace(lower, j);
|
|
318
|
+
if (lower[j] === "/") j = skipSpace(lower, j + 1);
|
|
319
|
+
if (lower[j] !== ">") {
|
|
320
|
+
// Not an end tag: for the tokenizer the rest of the name runs to the next ">", and a
|
|
321
|
+
// "<" inside it is part of the name rather than a new tag.
|
|
322
|
+
const bogus = lower.indexOf(">", close + 10);
|
|
323
|
+
if (bogus === -1) return -1;
|
|
324
|
+
close = lower.indexOf("</template", bogus + 1);
|
|
325
|
+
if (open !== -1 && open < bogus) open = lower.indexOf("<template", bogus + 1);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
depth--;
|
|
329
|
+
if (depth === 0) return j + 1;
|
|
330
|
+
close = lower.indexOf("</template", j + 1);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// One left to right scan by index that returns the head, with comments and the content of
|
|
335
|
+
// the raw text and template elements already removed. Why a scan and not a regex: a
|
|
336
|
+
// character class that reads a tag's attributes is quadratic on input the TARGET site
|
|
337
|
+
// controls (CodeQL js/polynomial-redos, alerts #4 and #5, 2026-08-24), and bounding the
|
|
338
|
+
// class trades that speed bug for a worse correctness bug, because an open tag longer than
|
|
339
|
+
// the bound stops being recognised and the element's own text is then read as markup. A
|
|
340
|
+
// scan has no bound and no backtracking, and it visits every character once.
|
|
341
|
+
//
|
|
342
|
+
// The shapes, all measured against a real HTML parser (parse5) rather than reasoned about.
|
|
343
|
+
// An unterminated <!-- comments out the rest of the document. <!--> and <!---> are EMPTY
|
|
344
|
+
// comments, not unterminated ones, and --!> ends a comment as well. A "<" that no letter,
|
|
345
|
+
// "!", "/" or "?" follows is text. A ">" inside a quoted attribute value does not end a
|
|
346
|
+
// tag. </script/> closes a raw text element as well as </script> does.
|
|
347
|
+
function headOfDocument(html) {
|
|
348
|
+
const text = String(html || "");
|
|
349
|
+
const lower = text.toLowerCase();
|
|
350
|
+
const out = [];
|
|
351
|
+
let i = 0, afterHead = false;
|
|
352
|
+
for (;;) {
|
|
353
|
+
const lt = lower.indexOf("<", i);
|
|
354
|
+
const gap = lt === -1 ? text.slice(i) : text.slice(i, lt);
|
|
355
|
+
if (gap.trim() !== "") break;
|
|
356
|
+
out.push(gap);
|
|
357
|
+
if (lt === -1) break;
|
|
358
|
+
if (lower.startsWith("<!--", lt)) {
|
|
359
|
+
if (lower.startsWith("<!-->", lt)) { i = lt + 5; continue; }
|
|
360
|
+
if (lower.startsWith("<!--->", lt)) { i = lt + 6; continue; }
|
|
361
|
+
const dashes = lower.indexOf("-->", lt + 4);
|
|
362
|
+
const bang = lower.indexOf("--!>", lt + 4);
|
|
363
|
+
if (dashes === -1 && bang === -1) break;
|
|
364
|
+
i = (bang === -1 || (dashes !== -1 && dashes <= bang)) ? dashes + 3 : bang + 4;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (!startsTag(lower[lt + 1])) break;
|
|
368
|
+
const gt = tagEnd(text, lt);
|
|
369
|
+
if (gt === -1) break;
|
|
370
|
+
if (lower[lt + 1] === "!" || lower[lt + 1] === "?") { i = gt + 1; continue; }
|
|
371
|
+
const head14 = lower.slice(lt, Math.min(lt + 14, gt + 1));
|
|
372
|
+
const endTag = (/^<\/([a-z]+)/.exec(head14) || [])[1];
|
|
373
|
+
if (endTag) {
|
|
374
|
+
// </body>, </html> and </br> start the body. </head> does NOT end the search: after
|
|
375
|
+
// it a parser still puts base, link, meta, script, style, title and template into the
|
|
376
|
+
// HEAD element until real body content starts, and every other end tag in the head is
|
|
377
|
+
// a parse error that is ignored. Measured against parse5: without this, 2 660 of
|
|
378
|
+
// 200 000 fuzz inputs lost a relation the head really carries.
|
|
379
|
+
if (endTag === "body" || endTag === "html" || endTag === "br") break;
|
|
380
|
+
if (endTag === "head") afterHead = true;
|
|
381
|
+
i = gt + 1;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const name = (/^<([a-z]+)(?=[\s/>]|$)/.exec(head14) || [])[1];
|
|
385
|
+
if (!name) break;
|
|
386
|
+
if (name === "html" || name === "head") { i = gt + 1; continue; }
|
|
387
|
+
if (!(afterHead ? AFTER_HEAD_ELEMENTS : HEAD_ELEMENTS).includes(name)) break; // <body> and the first body level element
|
|
388
|
+
if (HEAD_SKIPPED_CONTENT.includes(name)) {
|
|
389
|
+
const end = name === "template" ? templateEnd(lower, gt + 1)
|
|
390
|
+
: name === "script" ? scriptEnd(lower, gt + 1)
|
|
391
|
+
: rawTextEnd(lower, name, gt + 1);
|
|
392
|
+
if (end === -1) break;
|
|
393
|
+
i = end;
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
out.push(text.slice(lt, gt + 1));
|
|
397
|
+
i = gt + 1;
|
|
398
|
+
}
|
|
399
|
+
return out.join("");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Tags are found by index and not by /<link\b[^>]*>/g, on purpose. That regex is
|
|
403
|
+
// quadratic on input the TARGET site controls: every "<link" with no ">" after it makes
|
|
404
|
+
// the character class scan to the end of the document, and "<link" repeated 16 000 times
|
|
405
|
+
// measured 196 ms where this loop measures under 1 ms. CodeQL reports the same shape as
|
|
406
|
+
// js/polynomial-redos (alerts #4 and #5 on the package repo, 2026-08-24). This scan
|
|
407
|
+
// visits every character once: from each "<" it reads to the next ">" and then continues
|
|
408
|
+
// after it, which is what the regex meant to say.
|
|
409
|
+
function* htmlTags(text) {
|
|
410
|
+
const lower = text.toLowerCase();
|
|
411
|
+
let i = 0;
|
|
412
|
+
for (;;) {
|
|
413
|
+
const open = lower.indexOf("<", i);
|
|
414
|
+
if (open === -1) return;
|
|
415
|
+
if (!startsTag(lower[open + 1])) { i = open + 1; continue; }
|
|
416
|
+
const close = tagEnd(text, open);
|
|
417
|
+
if (close === -1) return;
|
|
418
|
+
yield text.slice(open, close + 1);
|
|
419
|
+
i = close + 1;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
180
423
|
// v2 of the llms.txt proposal (August 2026) left the file format alone and added one
|
|
181
424
|
// thing: a page should say where its markdown version and its llms.txt are, using
|
|
182
425
|
// rel="alternate" type="text/markdown" and rel="describedby", as HTML link elements or
|
|
@@ -185,26 +428,38 @@ export function validateLlmsTxt(f) {
|
|
|
185
428
|
// summary line is unchanged, and --strict keeps exiting on warnings only.
|
|
186
429
|
export function findLinkRelations(html, linkHeader) {
|
|
187
430
|
const found = { describedby: null, markdown: null };
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
// does not
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
431
|
+
// Only the head, and only what a parser would put there: a commented-out link element,
|
|
432
|
+
// one inside a script or a template, and one the parser moves into the body are all not
|
|
433
|
+
// what these two checks are about, and counting them would report a relation the site
|
|
434
|
+
// does not serve. See headOfDocument above for the rules shape by shape. The 64 KB bound
|
|
435
|
+
// is a cap on work, not a rule: a head longer than that is not a head.
|
|
436
|
+
const head = headOfDocument(html).slice(0, 65536);
|
|
437
|
+
for (const tag of htmlTags(head)) {
|
|
438
|
+
// The name has to END at "link": a real parser reads "<link<link" as ONE tag whose
|
|
439
|
+
// NAME is "link<link", not as a link element, so \b would count a relation the site
|
|
440
|
+
// does not publish. Measured against parse5, 2026-08-24.
|
|
441
|
+
if (!/^<link(?=[\s/>])/i.test(tag)) continue;
|
|
442
|
+
// The attribute name has to start the token. \b sits between the hyphen and the name,
|
|
443
|
+
// so data-rel, data-type and data-href were read as the real attributes until
|
|
444
|
+
// 2026-08-29, and a page could claim a relation it does not publish.
|
|
445
|
+
const rel = ((tag.match(/(?:^|[\s/])rel\s*=\s*["']?([^"'>]+)/i) || [])[1] || "").toLowerCase().trim().split(/\s+/);
|
|
446
|
+
const type = ((tag.match(/(?:^|[\s/])type\s*=\s*["']?([^"'>\s]+)/i) || [])[1] || "").toLowerCase();
|
|
447
|
+
const href = ((tag.match(/(?:^|[\s/])href\s*=\s*"([^"]*)"|(?:^|[\s/])href\s*=\s*'([^']*)'|(?:^|[\s/])href\s*=\s*([^\s"'>]+)/i) || []).slice(1).find((x) => x !== undefined) || "").trim();
|
|
448
|
+
// text/markdown, not anything that starts with it, and a relation without a target is
|
|
449
|
+
// not a relation: both passed until 2026-08-29.
|
|
450
|
+
const isMarkdown = type.split(";")[0].trim() === "text/markdown";
|
|
451
|
+
if (!found.describedby && href && rel.includes("describedby")) found.describedby = href;
|
|
452
|
+
if (!found.markdown && href && rel.includes("alternate") && isMarkdown) found.markdown = href;
|
|
201
453
|
}
|
|
202
454
|
for (const part of String(linkHeader || "").split(/,(?=\s*<)/)) {
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
455
|
+
const lt = part.indexOf("<");
|
|
456
|
+
const gt = lt === -1 ? -1 : part.indexOf(">", lt + 1);
|
|
457
|
+
const href = (gt === -1 ? "" : part.slice(lt + 1, gt)).trim();
|
|
458
|
+
const rel = ((part.match(/(?:^|[;\s])rel\s*=\s*"?([^";,]+)"?/i) || [])[1] || "").toLowerCase().trim().split(/\s+/);
|
|
459
|
+
const type = ((part.match(/(?:^|[;\s])type\s*=\s*"?([^";,]+)"?/i) || [])[1] || "").toLowerCase().trim();
|
|
460
|
+
const isMarkdownHeader = type.split(";")[0].trim() === "text/markdown";
|
|
461
|
+
if (!found.describedby && href && rel.includes("describedby")) found.describedby = href;
|
|
462
|
+
if (!found.markdown && href && rel.includes("alternate") && isMarkdownHeader) found.markdown = href;
|
|
208
463
|
}
|
|
209
464
|
return found;
|
|
210
465
|
}
|