turva-llms-txt-validator 0.3.2 → 0.3.4

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 +130 -43
  2. package/package.json +1 -1
  3. package/src/index.mjs +688 -49
package/README.md CHANGED
@@ -1,72 +1,159 @@
1
1
  # turva-llms-txt-validator
2
2
 
3
- Validate a site's llms.txt structure from the command line, from Node or in CI. Eight structure checks, each reported as pass, warn or fail with a one-line detail, and two v2 discovery checks that read pass or info and never move the summary. No score on purpose: a structure check can say what is there and what is missing, and a number on top of ten checks would look like an agent-readiness score without measuring one.
3
+ Check a website's `llms.txt` from the command line, Node or CI. Get a clear result for each check, with readable output or JSON.
4
4
 
5
- This is the open-source form of the hosted validator at [turva.dev/llms-txt-validator](https://turva.dev/llms-txt-validator), which runs the same logic inside the open [turva.dev Cloudflare Worker](https://github.com/erekola/turva-worker). The hosted validator stays canonical: if the two ever disagree, the hosted one wins and this package gets the fix.
5
+ **Eight structure checks and two informational discovery checks.** The result describes the file's structure and the home page's discovery links. It does not measure overall agent readiness or predict AI-search citations.
6
6
 
7
- ## Install
7
+ [npm package](https://www.npmjs.com/package/turva-llms-txt-validator) · [Try it in your browser](https://turva.dev/llms-txt-validator) · [llms.txt guide](https://turva.dev/guides/llms-txt) · [All turva.dev tools](https://turva.dev/tools)
8
8
 
9
- npm install -g turva-llms-txt-validator
9
+ ## Quick start
10
10
 
11
- Or run it without installing:
11
+ Requires **Node.js 18.17 or newer**. Run it without a global install:
12
12
 
13
- npx turva-llms-txt-validator example.com
13
+ ```sh
14
+ npx --yes turva-llms-txt-validator example.com
15
+ ```
14
16
 
15
- ## CLI
17
+ Replace `example.com` with the domain you want to check. A domain, HTTP URL or HTTPS URL is accepted. Validation uses the host's `/llms.txt` and home page over HTTPS, regardless of the supplied path.
16
18
 
17
- llms-txt-validate example.com
18
- llms-txt-validate example.com --json
19
- llms-txt-validate example.com --strict
19
+ For repeated use, install the CLI globally:
20
20
 
21
- Exit codes: 0 valid (warnings allowed), 1 not valid (with --strict, warnings also exit 1), 2 fetch or input error. The two v2 checks are reported as information and move no exit code. Two documents are fetched from the target site over https, its /llms.txt and its home page, each following a redirect only to the same host or its www/apex twin (an off-site or unsafe redirect fails the first check), each fetch times out after 8 seconds and each read is capped at 256 KB. Nothing else is requested, the site is never crawled and nothing is stored.
21
+ ```sh
22
+ npm install -g turva-llms-txt-validator
23
+ llms-txt-validate example.com
24
+ ```
22
25
 
23
- The target has to be a public domain name: an IP literal, a bracketed IPv6 address, localhost and the internal-use TLDs local, internal, home, lan, corp, test and invalid are all refused before any request goes out, and every redirect hop is checked by the same rule. Names are not resolved here, so a public name that points at a private address is stopped by the network the fetch runs on rather than by this code. On the hosted validator that network is the Cloudflare edge, which does not route to private address space.
26
+ ## Read the result
24
27
 
25
- ## The checks
28
+ The summary comes last. Each preceding line reports one check. This shortened example uses the hosted validator's turva.dev result observed on 2026-09-09, formatted as the CLI prints it. The remaining passing checks are omitted here:
26
29
 
27
- | # | Check | fail | warn |
28
- |---|-------|------|------|
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
- | 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, indentation included, since four spaces make it a code block | |
32
- | 4 | Blockquote summary after the title | | missing one-line summary |
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
- | 7 | Small enough to be cheap to read | | over 50 KB, or read truncated at 256 KB |
36
- | 8 | No HTML markup in the file | | HTML tags found |
30
+ ```text
31
+ https://turva.dev/llms.txt
32
+ ok File exists at /llms.txt (HTTP 200)
33
+ ok Response is plain text (text/plain)
34
+ ok Starts with an H1 title ("# turva.dev")
35
+ result: valid
36
+ ```
37
37
 
38
- Two more checks read the site's home page for the link relations v2 of the format recommends. They describe the site rather than the file, so they carry the status `pass` when the relation is there and `info` when it is not, never `warn` and never `fail`, and they leave both the summary line and the `--strict` exit code alone. v2 is two weeks old at the time of writing, so warning about these would turn valid files into files with warnings for following the version of the format they were written against.
38
+ A `warn` identifies something to review, such as a missing blockquote summary. It does not fail the command unless you add `--strict`. A `FAIL` means a structural requirement failed. Each line includes the observed detail.
39
39
 
40
- | # | Check | pass | info |
41
- |---|-------|------|------|
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 |
40
+ The eight structure checks determine the summary:
44
41
 
45
- ## Node API
42
+ | Summary | Meaning | Default exit code | With `--strict` |
43
+ | --- | --- | --- | --- |
44
+ | `valid` | No structure warnings or failures | `0` | `0` |
45
+ | `valid with warnings` | At least one warning, no failures | `0` | `1` |
46
+ | `not valid` | At least one failure | `1` | `1` |
47
+ | Input or fetch error | Validation could not complete | `2` | `2` |
46
48
 
47
- import { validateHost } from "turva-llms-txt-validator";
49
+ The two discovery checks return `pass` or `info`. They never change the summary or exit code, including in strict mode. If the home page cannot be read, those checks report `info`. The file's structure result still stands.
48
50
 
49
- const result = await validateHost("example.com");
50
- console.log(result.summary);
51
- for (const c of result.checks) console.log(c.status, c.label, c.detail);
51
+ Use JSON for automation and strict mode when warnings should fail a CI step:
52
52
 
53
- The summary is one of valid, valid with warnings, or not valid, and the two v2 checks never change it. The result object is { target, summary, checks }, the same shape the hosted validator returns as JSON:
53
+ ```sh
54
+ npx --yes turva-llms-txt-validator example.com --json --strict
55
+ ```
54
56
 
55
- curl -H "Accept: application/json" "https://turva.dev/llms-txt-validator?url=example.com"
57
+ Completed validation returns `{ target, summary, checks }`. Each check contains `{ id, status, label, detail }`. With `--json`, input and fetch errors return `{ "error": "..." }` and exit with code `2`.
56
58
 
57
- ## CI
59
+ ## What it checks
58
60
 
59
- GitHub Actions:
61
+ ### File structure
60
62
 
61
- - name: Validate llms.txt
62
- run: npx turva-llms-txt-validator your-domain.com --strict
63
+ | # | Check | Failure | Warning |
64
+ | --- | --- | --- | --- |
65
+ | 1 | `/llms.txt` returns HTTP 200 | Non-200 response, rejected or excessive redirects | none |
66
+ | 2 | Response is plain text | Body looks like an HTML page | Content type is neither `text/plain` nor `text/markdown` |
67
+ | 3 | First non-empty line is a Markdown H1 | Missing H1, including a title indented as a code block | none |
68
+ | 4 | Blockquote summary follows the title | none | Missing summary |
69
+ | 5 | H2 sections group the content | none | No H2 sections, or no section contains a Markdown link list |
70
+ | 6 | Markdown links have names and absolute HTTP or HTTPS targets | none | Missing links, empty names, relative targets or unsupported targets |
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 |
63
73
 
64
- Woodpecker or any other runner: the same npx line works anywhere Node 18.17 or newer is present.
74
+ 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.
65
75
 
66
- ## Why these checks
76
+ ### Home-page discovery
67
77
 
68
- The llms.txt format is a plain text map of a site for AI agents: an H1 title, a one-line blockquote summary and H2 sections of markdown links. v2 of the proposal, published in August 2026, left that format alone and added two link relations so an agent can find a page's markdown version and its llms.txt without guessing. The first eight checks test the structure and nothing else, and the last two report those two relations. What the format is and why it matters is written out at [llms.txt explained](https://turva.dev/guides/llms-txt), and the free tools this package belongs to are collected at [turva.dev/tools](https://turva.dev/tools).
78
+ These checks are labelled **v2** in the output. They look for link relations in the home page's HTML head or HTTP `Link` header.
79
+
80
+ | # | Check | Pass condition | Otherwise |
81
+ | --- | --- | --- | --- |
82
+ | 9 | Home page points to its llms.txt | `rel="describedby"` has a non-empty target | `info` |
83
+ | 10 | Home page points to a Markdown version | `rel="alternate"` has media type `text/markdown` and a non-empty target | `info` |
84
+
85
+ The validator detects these declarations without fetching their targets. It also does not follow the links inside `llms.txt`, crawl the site or test whether an AI agent can complete a task there.
86
+
87
+ ## Use from Node
88
+
89
+ Install the package in your project:
90
+
91
+ ```sh
92
+ npm install turva-llms-txt-validator
93
+ ```
94
+
95
+ Use an ES module, for example `validate.mjs`:
96
+
97
+ ```js
98
+ import { validateHost } from "turva-llms-txt-validator";
99
+
100
+ const result = await validateHost("example.com");
101
+ console.log(result.summary);
102
+ for (const check of result.checks) {
103
+ console.log(check.status, check.label, check.detail);
104
+ }
105
+ ```
106
+
107
+ `validateHost` rejects on invalid input or a failed `/llms.txt` fetch. Structural failures are returned in `checks` with the summary `not valid`.
108
+
109
+ ## Use in CI
110
+
111
+ Add this step to a GitHub Actions job that already has Node.js 18.17 or newer available. Replace `your-domain.com` with your domain:
112
+
113
+ ```yaml
114
+ - name: Validate llms.txt
115
+ run: npx --yes turva-llms-txt-validator your-domain.com --strict
116
+ ```
117
+
118
+ The same command works in Woodpecker and other runners with Node installed. Omit `--strict` if warnings should remain advisory.
119
+
120
+ ## Fetch limits and security
121
+
122
+ The default validation requests two documents: `https://<host>/llms.txt` and `https://<host>/`. Redirects may add requests, but linked pages and discovery targets are never fetched. The validator stores no results.
123
+
124
+ - Timeout: eight seconds per document, shared across its redirect chain.
125
+ - Read limit: 256 KB per response.
126
+ - Redirects: up to four hops, over HTTPS, to the same host or its `www`/apex equivalent. Off-site redirects, embedded credentials and unsupported ports are rejected.
127
+ - Host checks: IP literals, bracketed IPv6 addresses, localhost and the internal-use TLDs `local`, `internal`, `home`, `lan`, `corp`, `test` and `invalid` are rejected before a request is sent. Redirect targets are checked too.
128
+
129
+ **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.
130
+
131
+ For vulnerability reporting and supported versions, see [SECURITY.md](SECURITY.md).
132
+
133
+ ## Hosted version and source
134
+
135
+ The [hosted validator](https://turva.dev/llms-txt-validator) accepts any public domain in the browser. It requests the same two documents, `/llms.txt` and the home page, and runs the same checks. Its source is in the [turva.dev Cloudflare Worker](https://github.com/erekola/turva-worker). The hosted validator is the canonical implementation: if results diverge, this package is updated to match it.
136
+
137
+ The hosted version also returns JSON with the same result shape:
138
+
139
+ ```sh
140
+ curl -H "Accept: application/json" "https://turva.dev/llms-txt-validator?url=example.com"
141
+ ```
142
+
143
+ In Windows PowerShell, use `curl.exe` if `curl` resolves to `Invoke-WebRequest`.
144
+
145
+ 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.
146
+
147
+ ## Check HTML and Markdown content
148
+
149
+ [markdown-parity-check](https://github.com/erekola/markdown-parity-check) is a separate tool for a different question. This validator reads the structure of `llms.txt` and the home page's discovery links. The parity check compares the main content of one page's HTML and Markdown versions and reports changed text, numbers, links or blocks. It requires Node.js 22 or newer:
150
+
151
+ ```sh
152
+ npx --yes markdown-parity-check --url https://example.com/page --format json
153
+ ```
154
+
155
+ Replace the URL with your page. If Markdown is served at another address, add `--markdown-url https://example.com/page.md`. Its [browser version](https://turva.dev/markdown-parity-check) on turva.dev checks turva.dev's own pages only. See the [comparison README](https://github.com/erekola/markdown-parity-check) for local-file mode, exit codes and limits.
69
156
 
70
157
  ## License
71
158
 
72
- MIT. Source at [github.com/erekola/llms-txt-validator](https://github.com/erekola/llms-txt-validator). Published to npm from GitHub Actions with provenance, a signed attestation of where and how the package was built, verifiable on the npm package page.
159
+ [MIT](LICENSE). Built by [Erik Rekola](https://github.com/erekola) at [turva.dev](https://turva.dev).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turva-llms-txt-validator",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
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
@@ -19,8 +19,10 @@ const UA = "turva-llms-txt-validator/" + VERSION + " (+https://turva.dev/llms-tx
19
19
 
20
20
  // Cut a string to at most n UTF-16 code units without leaving a lone high surrogate at the
21
21
  // end. String.prototype.slice counts code units, so a cut that lands inside a surrogate
22
- // pair leaves half of it, and that half serialises as bytes that are not valid UTF-8
23
- // (round 16 S3-1 and S3-2, measured on the hosted validator). Mirrors worker.js cut().
22
+ // pair leaves half of it, and the character is lost. How the half is written depends on
23
+ // the runtime: Node's UTF-8 encoder replaces it with U+FFFD, and the hosted validator in
24
+ // workerd wrote three bytes that were not valid UTF-8 (round 16 S3-1 and S3-2, measured
25
+ // there 2026-09-03). Mirrors worker.js cut().
24
26
  export function cut(s, n) {
25
27
  s = String(s).slice(0, n);
26
28
  return /[\uD800-\uDBFF]$/.test(s) ? s.slice(0, -1) : s;
@@ -68,6 +70,11 @@ export async function fetchLlmsTxt(host, opts = {}) {
68
70
  headers: { "user-agent": opts.userAgent ?? UA, "accept": opts.accept ?? "text/plain, text/markdown;q=0.9, */*;q=0.1" }
69
71
  });
70
72
  if (res.status >= 300 && res.status < 400) {
73
+ // Six paths leave this branch, five returns and one continue, and not one of them
74
+ // ever reads the redirect body. undici holds the connection until a body is read or
75
+ // cancelled, so it is released here, once, before the location is even parsed. A
76
+ // failed cancel must not turn a redirect verdict into a throw (2026-09-10).
77
+ try { await res.body?.cancel(); } catch { /* the verdict below is the answer */ }
71
78
  const loc = res.headers.get("location") || "";
72
79
  if (!loc) return { redirect: true, reason: "no-location", status: res.status, location: "" };
73
80
  if (hop >= 4) return { redirect: true, reason: "too-many", status: res.status, location: cut(loc, 120) };
@@ -142,6 +149,41 @@ function collectLinks(text) {
142
149
  return out;
143
150
  }
144
151
 
152
+ // CommonMark fenced code blocks, marked line by line. A "## " or a link inside a fence is
153
+ // example text and not the file's own structure, but until 2026-09-10 both counted, so a
154
+ // file whose only section and only link lived inside ``` or ~~~ was reported valid. The
155
+ // scan is one pass over the lines with no backtracking pattern, because the ReDoS repair
156
+ // of 2026-08-24 bought a worse accuracy defect with a bounded quantifier and the rule out
157
+ // of it was to scan by index instead (mds/gotchas.md 2026-08-24 (jatko 12)).
158
+ // Returns one boolean per line: true for a fence line and for everything inside it.
159
+ function fenceMask(lines) {
160
+ const mask = new Array(lines.length).fill(false);
161
+ let fenceChar = "", fenceLen = 0;
162
+ for (let i = 0; i < lines.length; i++) {
163
+ const l = lines[i];
164
+ let p = 0;
165
+ while (p < 4 && l[p] === " ") p++;
166
+ if (p > 3) { mask[i] = fenceChar !== ""; continue; }
167
+ const c = l[p];
168
+ let run = 0;
169
+ if (c === "`" || c === "~") { while (l[p + run] === c) run++; }
170
+ if (fenceChar === "") {
171
+ // An opening backtick fence may not carry a backtick in its info string; a tilde
172
+ // fence may. Anything shorter than three markers is not a fence at all. A later
173
+ // round will read the consequence as a bug and it is not: in ```js `x` the info
174
+ // string holds a backtick, so that line is prose, and a bare ``` after it OPENS a
175
+ // block instead of closing one. CommonMark reads the same input the same way,
176
+ // measured against the spec 2026-09-10. Leave it.
177
+ if (run >= 3 && (c !== "`" || l.indexOf("`", p + run) === -1)) {
178
+ fenceChar = c; fenceLen = run; mask[i] = true;
179
+ }
180
+ continue;
181
+ }
182
+ mask[i] = true;
183
+ if (c === fenceChar && run >= fenceLen && l.slice(p + run).trim() === "") { fenceChar = ""; fenceLen = 0; }
184
+ }
185
+ return mask;
186
+ }
145
187
  // A markdown list item that carries a link, scanned once from left to right instead of
146
188
  // matched with /^ {0,3}[-*+] .*\[[^\][]*\]\([^)\s]+\)/. That pattern is quadratic on a line
147
189
  // such as "- " followed by "[a](" repeated, because every candidate rescans the target to the
@@ -225,15 +267,22 @@ export function validateLlmsTxt(f) {
225
267
  } else {
226
268
  add("summary", "warn", "Blockquote summary after the title", "recommended by the format (> one-line summary), not required");
227
269
  }
228
- const h2Count = (f.text.match(/^## /gm) || []).length;
270
+ // Headings are read outside fences only, and with the same indentation the H1 check and
271
+ // listItemHasLink have allowed since 2026-08-29. Until 2026-09-10 this one line still
272
+ // demanded column zero, so a correct file indented by one to three spaces was reported as
273
+ // having no sections at all while its list under the same indentation counted fine.
274
+ const fenced = fenceMask(lines);
275
+ const h2Count = lines.filter((l, i) => !fenced[i] && /^ {0,3}## /.test(l)).length;
229
276
  // A section counts when it carries a file list. An H2 followed by a paragraph satisfied
230
277
  // this check until 2026-08-29, and the format puts each section's links in a list.
231
278
  let sectionsWithList = 0;
232
279
  {
233
280
  let inSection = false, counted = false;
234
- for (const l of lines) {
235
- if (/^## /.test(l)) { inSection = true; counted = false; continue; }
236
- if (/^# /.test(l)) { inSection = false; continue; }
281
+ for (let i = 0; i < lines.length; i++) {
282
+ const l = lines[i];
283
+ if (fenced[i]) continue;
284
+ if (/^ {0,3}## /.test(l)) { inSection = true; counted = false; continue; }
285
+ if (/^ {0,3}# /.test(l)) { inSection = false; continue; }
237
286
  if (inSection && !counted && listItemHasLink(l)) { sectionsWithList++; counted = true; }
238
287
  }
239
288
  }
@@ -244,7 +293,9 @@ export function validateLlmsTxt(f) {
244
293
  } else {
245
294
  add("sections", "warn", "H2 sections group the content", "no H2 sections found; sections are the convention for grouping links");
246
295
  }
247
- const links = collectLinks(f.text);
296
+ // Links are collected from the prose only, for the same reason the headings are: a link
297
+ // shown inside a code fence is an example of a link, not one an agent can follow.
298
+ const links = collectLinks(lines.filter((l, i) => !fenced[i]).join("\n"));
248
299
  // An entry an agent can use has a name and a target with a host. An empty name and a
249
300
  // bare "https://" both counted as valid absolute links until 2026-08-29.
250
301
  const named = links.filter((m) => m.name.trim() !== "");
@@ -284,24 +335,503 @@ function startsTag(c) {
284
335
  return c !== undefined && (c === "!" || c === "/" || c === "?" || (c >= "a" && c <= "z"));
285
336
  }
286
337
 
287
- // The end of the tag that starts at lt: the first ">" that is NOT inside a quoted
288
- // attribute value. A quote opens a value only right after "=", which is what the
289
- // tokenizer does. indexOf(">") is wrong here: <link data-x="a>b" rel="describedby"> is
290
- // one tag for a parser and two for indexOf, and the relation was lost.
291
- function tagEnd(text, lt) {
292
- let quote = "", afterEq = false;
293
- for (let j = lt + 1; j < text.length; j++) {
338
+ // One tag read with the tokenizer's own states, from the "<" at lt: the tag name, then before
339
+ // attribute name, attribute name, after attribute name, before attribute value, the three value
340
+ // states, after a quoted value and self-closing start tag. Returns the index of the ">" that
341
+ // ends the tag, or -1 when the input ends first, in which case a parser drops the tag. When attrs
342
+ // is an object it also receives the attributes: names ASCII lowercased, values with their
343
+ // character references decoded, and the first declaration of a name wins.
344
+ // indexOf(">") is wrong here: <link data-x="a>b" rel="describedby"> is one tag for a parser and
345
+ // two for indexOf, and the relation was lost. The shortcut that replaced it opened a quote after
346
+ // any "=", which the tokenizer does not do either: in a="x"=">" the second "=" starts an attribute
347
+ // NAME, and so does an "=" where a name is expected, so the quote after it is part of that name
348
+ // and the ">" inside it ends the tag. Measured against parse5, 2026-09-12.
349
+ function readTag(text, lt, attrs) {
350
+ const n = text.length;
351
+ let j = lt + 1;
352
+ if (text[j] === "/") j++;
353
+ while (j < n && !isTagBoundary(text[j])) j++;
354
+ let state = "before", name = "", start = 0, quote = "";
355
+ const add = (value) => {
356
+ if (attrs && !(name in attrs)) attrs[name] = decodeAttributeValue(value);
357
+ };
358
+ for (; j < n; j++) {
294
359
  const c = text[j];
295
- if (quote) { if (c === quote) quote = ""; continue; }
296
- if (c === ">") return j;
297
- if (c === "=") { afterEq = true; continue; }
298
- if (c === " " || c === "\t" || c === "\n" || c === "\r" || c === "\f") continue;
299
- if (afterEq && (c === '"' || c === "'")) { quote = c; afterEq = false; continue; }
300
- afterEq = false;
360
+ if (state === "before") {
361
+ if (HTML_WHITESPACE.includes(c)) continue;
362
+ if (c === "/") { state = "selfClosing"; continue; }
363
+ if (c === ">") return j;
364
+ // Any other character starts a name, "=" included.
365
+ start = j;
366
+ state = "name";
367
+ } else if (state === "name") {
368
+ if (HTML_WHITESPACE.includes(c)) { name = asciiLower(text.slice(start, j)); state = "afterName"; continue; }
369
+ if (c === "/" || c === ">") {
370
+ name = asciiLower(text.slice(start, j));
371
+ add("");
372
+ if (c === ">") return j;
373
+ state = "selfClosing";
374
+ continue;
375
+ }
376
+ if (c === "=" && j > start) { name = asciiLower(text.slice(start, j)); state = "beforeValue"; }
377
+ } else if (state === "afterName") {
378
+ if (HTML_WHITESPACE.includes(c)) continue;
379
+ if (c === "=") { state = "beforeValue"; continue; }
380
+ add("");
381
+ if (c === "/") { state = "selfClosing"; continue; }
382
+ if (c === ">") return j;
383
+ start = j;
384
+ state = "name";
385
+ } else if (state === "beforeValue") {
386
+ if (HTML_WHITESPACE.includes(c)) continue;
387
+ if (c === '"' || c === "'") { quote = c; start = j + 1; state = "quoted"; continue; }
388
+ if (c === ">") { add(""); return j; }
389
+ start = j;
390
+ state = "unquoted";
391
+ } else if (state === "quoted") {
392
+ if (c === quote) { add(text.slice(start, j)); state = "afterQuoted"; }
393
+ } else if (state === "unquoted") {
394
+ if (HTML_WHITESPACE.includes(c)) { add(text.slice(start, j)); state = "before"; continue; }
395
+ if (c === ">") { add(text.slice(start, j)); return j; }
396
+ } else if (state === "afterQuoted") {
397
+ if (HTML_WHITESPACE.includes(c)) { state = "before"; continue; }
398
+ if (c === "/") { state = "selfClosing"; continue; }
399
+ if (c === ">") return j;
400
+ // A missing space between attributes: the character starts the next name.
401
+ start = j;
402
+ state = "name";
403
+ } else {
404
+ // selfClosing: only ">" ends the tag, anything else is read again as before attribute name.
405
+ if (c === ">") return j;
406
+ state = "before";
407
+ j--;
408
+ }
301
409
  }
302
410
  return -1;
303
411
  }
304
412
 
413
+ function tagEnd(text, lt) {
414
+ return readTag(text, lt, null);
415
+ }
416
+
417
+ // ASCII only, on purpose. The HTML tokenizer lowercases A to Z and nothing else, and HTML
418
+ // whitespace is tab, LF, FF, CR and space. toLowerCase, trim and \s are Unicode aware, and each
419
+ // of them read a page differently from a parser: U+0130 lowercases to two UTF-16 code units, so
420
+ // every index taken from the lowered copy pointed past its place in the original and a relation
421
+ // after it was lost; U+212A lowercases to "k"; and NBSP counted as whitespace between head
422
+ // elements, where a parser starts the body, and between rel tokens. Measured against parse5,
423
+ // 2026-09-12.
424
+ function asciiLower(s) {
425
+ return s.replace(/[A-Z]+/g, (m) => m.toLowerCase());
426
+ }
427
+
428
+ var HTML_WHITESPACE = " \t\n\r\f";
429
+ // A MIME type is trimmed of HTTP whitespace, which does not include FF.
430
+ var HTTP_WHITESPACE = " \t\n\r";
431
+
432
+ function trimChars(s, chars) {
433
+ let a = 0, b = s.length;
434
+ while (a < b && chars.includes(s[a])) a++;
435
+ while (b > a && chars.includes(s[b - 1])) b--;
436
+ return s.slice(a, b);
437
+ }
438
+
439
+ function isHtmlWhitespace(s) {
440
+ for (let k = 0; k < s.length; k++) if (!HTML_WHITESPACE.includes(s[k])) return false;
441
+ return true;
442
+ }
443
+
444
+ function isAsciiAlphanumeric(c) {
445
+ return c !== undefined && ((c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9"));
446
+ }
447
+
448
+ // Character references in an attribute value, decoded after the tag has been tokenized and
449
+ // never before it: decoding the source first would let a decoded quote move where a value
450
+ // ends. The rules are those of the tokenizer's character reference state inside an attribute
451
+ // value. The longest named reference wins. A name without its ";" decodes only for the legacy
452
+ // names that allow it, and not when "=" or an ASCII letter or digit follows, so "?a=1&ampb=2"
453
+ // stays as written. A numeric reference takes decimal or hex digits with the ";" optional;
454
+ // zero, a surrogate and anything past U+10FFFF become U+FFFD, and 0x80 to 0x9F go through
455
+ // the replacement table below. A NUL in the value becomes U+FFFD as well. Before this,
456
+ // rel="described&#98;y" read as no relation and href="/llms.txt?a=1&amp;b=2" was reported
457
+ // with "&amp;" still in it.
458
+ var C1_REPLACEMENTS = {
459
+ 0x80: 0x20ac, 0x82: 0x201a, 0x83: 0x0192, 0x84: 0x201e, 0x85: 0x2026, 0x86: 0x2020, 0x87: 0x2021,
460
+ 0x88: 0x02c6, 0x89: 0x2030, 0x8a: 0x0160, 0x8b: 0x2039, 0x8c: 0x0152, 0x8e: 0x017d, 0x91: 0x2018,
461
+ 0x92: 0x2019, 0x93: 0x201c, 0x94: 0x201d, 0x95: 0x2022, 0x96: 0x2013, 0x97: 0x2014, 0x98: 0x02dc,
462
+ 0x99: 0x2122, 0x9a: 0x0161, 0x9b: 0x203a, 0x9c: 0x0153, 0x9e: 0x017e, 0x9f: 0x0178,
463
+ };
464
+
465
+ // The full WHATWG list of named character references, 2 231 names: "name!hex" is a name that
466
+ // also decodes without its ";", "name=hex" one that needs it, and several code points are
467
+ // separated by ",". Generated 2026-09-12 from the character-entities package, with every value
468
+ // and every legacy name checked against the decoder of the entities package.
469
+ var NAMED_REFERENCE_DATA =
470
+ "AElig!c6 AMP!26 Aacute!c1 Abreve=102 Acirc!c2 Acy=410 Afr=1d504 Agrave!c0 Alpha=391 Amacr=100 " +
471
+ "And=2a53 Aogon=104 Aopf=1d538 ApplyFunction=2061 Aring!c5 Ascr=1d49c Assign=2254 Atilde!c3 " +
472
+ "Auml!c4 Backslash=2216 Barv=2ae7 Barwed=2306 Bcy=411 Because=2235 Bernoullis=212c Beta=392 " +
473
+ "Bfr=1d505 Bopf=1d539 Breve=2d8 Bscr=212c Bumpeq=224e CHcy=427 COPY!a9 Cacute=106 Cap=22d2 " +
474
+ "CapitalDifferentialD=2145 Cayleys=212d Ccaron=10c Ccedil!c7 Ccirc=108 Cconint=2230 Cdot=10a " +
475
+ "Cedilla=b8 CenterDot=b7 Cfr=212d Chi=3a7 CircleDot=2299 CircleMinus=2296 CirclePlus=2295 " +
476
+ "CircleTimes=2297 ClockwiseContourIntegral=2232 CloseCurlyDoubleQuote=201d CloseCurlyQuote=2019 " +
477
+ "Colon=2237 Colone=2a74 Congruent=2261 Conint=222f ContourIntegral=222e Copf=2102 Coproduct=2210 " +
478
+ "CounterClockwiseContourIntegral=2233 Cross=2a2f Cscr=1d49e Cup=22d3 CupCap=224d DD=2145 " +
479
+ "DDotrahd=2911 DJcy=402 DScy=405 DZcy=40f Dagger=2021 Darr=21a1 Dashv=2ae4 Dcaron=10e Dcy=414 " +
480
+ "Del=2207 Delta=394 Dfr=1d507 DiacriticalAcute=b4 DiacriticalDot=2d9 DiacriticalDoubleAcute=2dd " +
481
+ "DiacriticalGrave=60 DiacriticalTilde=2dc Diamond=22c4 DifferentialD=2146 Dopf=1d53b Dot=a8 " +
482
+ "DotDot=20dc DotEqual=2250 DoubleContourIntegral=222f DoubleDot=a8 DoubleDownArrow=21d3 " +
483
+ "DoubleLeftArrow=21d0 DoubleLeftRightArrow=21d4 DoubleLeftTee=2ae4 DoubleLongLeftArrow=27f8 " +
484
+ "DoubleLongLeftRightArrow=27fa DoubleLongRightArrow=27f9 DoubleRightArrow=21d2 " +
485
+ "DoubleRightTee=22a8 DoubleUpArrow=21d1 DoubleUpDownArrow=21d5 DoubleVerticalBar=2225 " +
486
+ "DownArrow=2193 DownArrowBar=2913 DownArrowUpArrow=21f5 DownBreve=311 DownLeftRightVector=2950 " +
487
+ "DownLeftTeeVector=295e DownLeftVector=21bd DownLeftVectorBar=2956 DownRightTeeVector=295f " +
488
+ "DownRightVector=21c1 DownRightVectorBar=2957 DownTee=22a4 DownTeeArrow=21a7 Downarrow=21d3 " +
489
+ "Dscr=1d49f Dstrok=110 ENG=14a ETH!d0 Eacute!c9 Ecaron=11a Ecirc!ca Ecy=42d Edot=116 Efr=1d508 " +
490
+ "Egrave!c8 Element=2208 Emacr=112 EmptySmallSquare=25fb EmptyVerySmallSquare=25ab Eogon=118 " +
491
+ "Eopf=1d53c Epsilon=395 Equal=2a75 EqualTilde=2242 Equilibrium=21cc Escr=2130 Esim=2a73 Eta=397 " +
492
+ "Euml!cb Exists=2203 ExponentialE=2147 Fcy=424 Ffr=1d509 FilledSmallSquare=25fc " +
493
+ "FilledVerySmallSquare=25aa Fopf=1d53d ForAll=2200 Fouriertrf=2131 Fscr=2131 GJcy=403 GT!3e " +
494
+ "Gamma=393 Gammad=3dc Gbreve=11e Gcedil=122 Gcirc=11c Gcy=413 Gdot=120 Gfr=1d50a Gg=22d9 " +
495
+ "Gopf=1d53e GreaterEqual=2265 GreaterEqualLess=22db GreaterFullEqual=2267 GreaterGreater=2aa2 " +
496
+ "GreaterLess=2277 GreaterSlantEqual=2a7e GreaterTilde=2273 Gscr=1d4a2 Gt=226b HARDcy=42a " +
497
+ "Hacek=2c7 Hat=5e Hcirc=124 Hfr=210c HilbertSpace=210b Hopf=210d HorizontalLine=2500 Hscr=210b " +
498
+ "Hstrok=126 HumpDownHump=224e HumpEqual=224f IEcy=415 IJlig=132 IOcy=401 Iacute!cd Icirc!ce " +
499
+ "Icy=418 Idot=130 Ifr=2111 Igrave!cc Im=2111 Imacr=12a ImaginaryI=2148 Implies=21d2 Int=222c " +
500
+ "Integral=222b Intersection=22c2 InvisibleComma=2063 InvisibleTimes=2062 Iogon=12e Iopf=1d540 " +
501
+ "Iota=399 Iscr=2110 Itilde=128 Iukcy=406 Iuml!cf Jcirc=134 Jcy=419 Jfr=1d50d Jopf=1d541 " +
502
+ "Jscr=1d4a5 Jsercy=408 Jukcy=404 KHcy=425 KJcy=40c Kappa=39a Kcedil=136 Kcy=41a Kfr=1d50e " +
503
+ "Kopf=1d542 Kscr=1d4a6 LJcy=409 LT!3c Lacute=139 Lambda=39b Lang=27ea Laplacetrf=2112 Larr=219e " +
504
+ "Lcaron=13d Lcedil=13b Lcy=41b LeftAngleBracket=27e8 LeftArrow=2190 LeftArrowBar=21e4 " +
505
+ "LeftArrowRightArrow=21c6 LeftCeiling=2308 LeftDoubleBracket=27e6 LeftDownTeeVector=2961 " +
506
+ "LeftDownVector=21c3 LeftDownVectorBar=2959 LeftFloor=230a LeftRightArrow=2194 " +
507
+ "LeftRightVector=294e LeftTee=22a3 LeftTeeArrow=21a4 LeftTeeVector=295a LeftTriangle=22b2 " +
508
+ "LeftTriangleBar=29cf LeftTriangleEqual=22b4 LeftUpDownVector=2951 LeftUpTeeVector=2960 " +
509
+ "LeftUpVector=21bf LeftUpVectorBar=2958 LeftVector=21bc LeftVectorBar=2952 Leftarrow=21d0 " +
510
+ "Leftrightarrow=21d4 LessEqualGreater=22da LessFullEqual=2266 LessGreater=2276 LessLess=2aa1 " +
511
+ "LessSlantEqual=2a7d LessTilde=2272 Lfr=1d50f Ll=22d8 Lleftarrow=21da Lmidot=13f " +
512
+ "LongLeftArrow=27f5 LongLeftRightArrow=27f7 LongRightArrow=27f6 Longleftarrow=27f8 " +
513
+ "Longleftrightarrow=27fa Longrightarrow=27f9 Lopf=1d543 LowerLeftArrow=2199 LowerRightArrow=2198 " +
514
+ "Lscr=2112 Lsh=21b0 Lstrok=141 Lt=226a Map=2905 Mcy=41c MediumSpace=205f Mellintrf=2133 Mfr=1d510 " +
515
+ "MinusPlus=2213 Mopf=1d544 Mscr=2133 Mu=39c NJcy=40a Nacute=143 Ncaron=147 Ncedil=145 Ncy=41d " +
516
+ "NegativeMediumSpace=200b NegativeThickSpace=200b NegativeThinSpace=200b " +
517
+ "NegativeVeryThinSpace=200b NestedGreaterGreater=226b NestedLessLess=226a NewLine=a Nfr=1d511 " +
518
+ "NoBreak=2060 NonBreakingSpace=a0 Nopf=2115 Not=2aec NotCongruent=2262 NotCupCap=226d " +
519
+ "NotDoubleVerticalBar=2226 NotElement=2209 NotEqual=2260 NotEqualTilde=2242,338 NotExists=2204 " +
520
+ "NotGreater=226f NotGreaterEqual=2271 NotGreaterFullEqual=2267,338 NotGreaterGreater=226b,338 " +
521
+ "NotGreaterLess=2279 NotGreaterSlantEqual=2a7e,338 NotGreaterTilde=2275 NotHumpDownHump=224e,338 " +
522
+ "NotHumpEqual=224f,338 NotLeftTriangle=22ea NotLeftTriangleBar=29cf,338 NotLeftTriangleEqual=22ec " +
523
+ "NotLess=226e NotLessEqual=2270 NotLessGreater=2278 NotLessLess=226a,338 " +
524
+ "NotLessSlantEqual=2a7d,338 NotLessTilde=2274 NotNestedGreaterGreater=2aa2,338 " +
525
+ "NotNestedLessLess=2aa1,338 NotPrecedes=2280 NotPrecedesEqual=2aaf,338 NotPrecedesSlantEqual=22e0 " +
526
+ "NotReverseElement=220c NotRightTriangle=22eb NotRightTriangleBar=29d0,338 " +
527
+ "NotRightTriangleEqual=22ed NotSquareSubset=228f,338 NotSquareSubsetEqual=22e2 " +
528
+ "NotSquareSuperset=2290,338 NotSquareSupersetEqual=22e3 NotSubset=2282,20d2 NotSubsetEqual=2288 " +
529
+ "NotSucceeds=2281 NotSucceedsEqual=2ab0,338 NotSucceedsSlantEqual=22e1 NotSucceedsTilde=227f,338 " +
530
+ "NotSuperset=2283,20d2 NotSupersetEqual=2289 NotTilde=2241 NotTildeEqual=2244 " +
531
+ "NotTildeFullEqual=2247 NotTildeTilde=2249 NotVerticalBar=2224 Nscr=1d4a9 Ntilde!d1 Nu=39d " +
532
+ "OElig=152 Oacute!d3 Ocirc!d4 Ocy=41e Odblac=150 Ofr=1d512 Ograve!d2 Omacr=14c Omega=3a9 " +
533
+ "Omicron=39f Oopf=1d546 OpenCurlyDoubleQuote=201c OpenCurlyQuote=2018 Or=2a54 Oscr=1d4aa " +
534
+ "Oslash!d8 Otilde!d5 Otimes=2a37 Ouml!d6 OverBar=203e OverBrace=23de OverBracket=23b4 " +
535
+ "OverParenthesis=23dc PartialD=2202 Pcy=41f Pfr=1d513 Phi=3a6 Pi=3a0 PlusMinus=b1 " +
536
+ "Poincareplane=210c Popf=2119 Pr=2abb Precedes=227a PrecedesEqual=2aaf PrecedesSlantEqual=227c " +
537
+ "PrecedesTilde=227e Prime=2033 Product=220f Proportion=2237 Proportional=221d Pscr=1d4ab Psi=3a8 " +
538
+ "QUOT!22 Qfr=1d514 Qopf=211a Qscr=1d4ac RBarr=2910 REG!ae Racute=154 Rang=27eb Rarr=21a0 " +
539
+ "Rarrtl=2916 Rcaron=158 Rcedil=156 Rcy=420 Re=211c ReverseElement=220b ReverseEquilibrium=21cb " +
540
+ "ReverseUpEquilibrium=296f Rfr=211c Rho=3a1 RightAngleBracket=27e9 RightArrow=2192 " +
541
+ "RightArrowBar=21e5 RightArrowLeftArrow=21c4 RightCeiling=2309 RightDoubleBracket=27e7 " +
542
+ "RightDownTeeVector=295d RightDownVector=21c2 RightDownVectorBar=2955 RightFloor=230b " +
543
+ "RightTee=22a2 RightTeeArrow=21a6 RightTeeVector=295b RightTriangle=22b3 RightTriangleBar=29d0 " +
544
+ "RightTriangleEqual=22b5 RightUpDownVector=294f RightUpTeeVector=295c RightUpVector=21be " +
545
+ "RightUpVectorBar=2954 RightVector=21c0 RightVectorBar=2953 Rightarrow=21d2 Ropf=211d " +
546
+ "RoundImplies=2970 Rrightarrow=21db Rscr=211b Rsh=21b1 RuleDelayed=29f4 SHCHcy=429 SHcy=428 " +
547
+ "SOFTcy=42c Sacute=15a Sc=2abc Scaron=160 Scedil=15e Scirc=15c Scy=421 Sfr=1d516 " +
548
+ "ShortDownArrow=2193 ShortLeftArrow=2190 ShortRightArrow=2192 ShortUpArrow=2191 Sigma=3a3 " +
549
+ "SmallCircle=2218 Sopf=1d54a Sqrt=221a Square=25a1 SquareIntersection=2293 SquareSubset=228f " +
550
+ "SquareSubsetEqual=2291 SquareSuperset=2290 SquareSupersetEqual=2292 SquareUnion=2294 Sscr=1d4ae " +
551
+ "Star=22c6 Sub=22d0 Subset=22d0 SubsetEqual=2286 Succeeds=227b SucceedsEqual=2ab0 " +
552
+ "SucceedsSlantEqual=227d SucceedsTilde=227f SuchThat=220b Sum=2211 Sup=22d1 Superset=2283 " +
553
+ "SupersetEqual=2287 Supset=22d1 THORN!de TRADE=2122 TSHcy=40b TScy=426 Tab=9 Tau=3a4 Tcaron=164 " +
554
+ "Tcedil=162 Tcy=422 Tfr=1d517 Therefore=2234 Theta=398 ThickSpace=205f,200a ThinSpace=2009 " +
555
+ "Tilde=223c TildeEqual=2243 TildeFullEqual=2245 TildeTilde=2248 Topf=1d54b TripleDot=20db " +
556
+ "Tscr=1d4af Tstrok=166 Uacute!da Uarr=219f Uarrocir=2949 Ubrcy=40e Ubreve=16c Ucirc!db Ucy=423 " +
557
+ "Udblac=170 Ufr=1d518 Ugrave!d9 Umacr=16a UnderBar=5f UnderBrace=23df UnderBracket=23b5 " +
558
+ "UnderParenthesis=23dd Union=22c3 UnionPlus=228e Uogon=172 Uopf=1d54c UpArrow=2191 " +
559
+ "UpArrowBar=2912 UpArrowDownArrow=21c5 UpDownArrow=2195 UpEquilibrium=296e UpTee=22a5 " +
560
+ "UpTeeArrow=21a5 Uparrow=21d1 Updownarrow=21d5 UpperLeftArrow=2196 UpperRightArrow=2197 Upsi=3d2 " +
561
+ "Upsilon=3a5 Uring=16e Uscr=1d4b0 Utilde=168 Uuml!dc VDash=22ab Vbar=2aeb Vcy=412 Vdash=22a9 " +
562
+ "Vdashl=2ae6 Vee=22c1 Verbar=2016 Vert=2016 VerticalBar=2223 VerticalLine=7c " +
563
+ "VerticalSeparator=2758 VerticalTilde=2240 VeryThinSpace=200a Vfr=1d519 Vopf=1d54d Vscr=1d4b1 " +
564
+ "Vvdash=22aa Wcirc=174 Wedge=22c0 Wfr=1d51a Wopf=1d54e Wscr=1d4b2 Xfr=1d51b Xi=39e Xopf=1d54f " +
565
+ "Xscr=1d4b3 YAcy=42f YIcy=407 YUcy=42e Yacute!dd Ycirc=176 Ycy=42b Yfr=1d51c Yopf=1d550 " +
566
+ "Yscr=1d4b4 Yuml=178 ZHcy=416 Zacute=179 Zcaron=17d Zcy=417 Zdot=17b ZeroWidthSpace=200b Zeta=396 " +
567
+ "Zfr=2128 Zopf=2124 Zscr=1d4b5 aacute!e1 abreve=103 ac=223e acE=223e,333 acd=223f acirc!e2 " +
568
+ "acute!b4 acy=430 aelig!e6 af=2061 afr=1d51e agrave!e0 alefsym=2135 aleph=2135 alpha=3b1 " +
569
+ "amacr=101 amalg=2a3f amp!26 and=2227 andand=2a55 andd=2a5c andslope=2a58 andv=2a5a ang=2220 " +
570
+ "ange=29a4 angle=2220 angmsd=2221 angmsdaa=29a8 angmsdab=29a9 angmsdac=29aa angmsdad=29ab " +
571
+ "angmsdae=29ac angmsdaf=29ad angmsdag=29ae angmsdah=29af angrt=221f angrtvb=22be angrtvbd=299d " +
572
+ "angsph=2222 angst=c5 angzarr=237c aogon=105 aopf=1d552 ap=2248 apE=2a70 apacir=2a6f ape=224a " +
573
+ "apid=224b apos=27 approx=2248 approxeq=224a aring!e5 ascr=1d4b6 ast=2a asymp=2248 asympeq=224d " +
574
+ "atilde!e3 auml!e4 awconint=2233 awint=2a11 bNot=2aed backcong=224c backepsilon=3f6 " +
575
+ "backprime=2035 backsim=223d backsimeq=22cd barvee=22bd barwed=2305 barwedge=2305 bbrk=23b5 " +
576
+ "bbrktbrk=23b6 bcong=224c bcy=431 bdquo=201e becaus=2235 because=2235 bemptyv=29b0 bepsi=3f6 " +
577
+ "bernou=212c beta=3b2 beth=2136 between=226c bfr=1d51f bigcap=22c2 bigcirc=25ef bigcup=22c3 " +
578
+ "bigodot=2a00 bigoplus=2a01 bigotimes=2a02 bigsqcup=2a06 bigstar=2605 bigtriangledown=25bd " +
579
+ "bigtriangleup=25b3 biguplus=2a04 bigvee=22c1 bigwedge=22c0 bkarow=290d blacklozenge=29eb " +
580
+ "blacksquare=25aa blacktriangle=25b4 blacktriangledown=25be blacktriangleleft=25c2 " +
581
+ "blacktriangleright=25b8 blank=2423 blk12=2592 blk14=2591 blk34=2593 block=2588 bne=3d,20e5 " +
582
+ "bnequiv=2261,20e5 bnot=2310 bopf=1d553 bot=22a5 bottom=22a5 bowtie=22c8 boxDL=2557 boxDR=2554 " +
583
+ "boxDl=2556 boxDr=2553 boxH=2550 boxHD=2566 boxHU=2569 boxHd=2564 boxHu=2567 boxUL=255d " +
584
+ "boxUR=255a boxUl=255c boxUr=2559 boxV=2551 boxVH=256c boxVL=2563 boxVR=2560 boxVh=256b " +
585
+ "boxVl=2562 boxVr=255f boxbox=29c9 boxdL=2555 boxdR=2552 boxdl=2510 boxdr=250c boxh=2500 " +
586
+ "boxhD=2565 boxhU=2568 boxhd=252c boxhu=2534 boxminus=229f boxplus=229e boxtimes=22a0 boxuL=255b " +
587
+ "boxuR=2558 boxul=2518 boxur=2514 boxv=2502 boxvH=256a boxvL=2561 boxvR=255e boxvh=253c " +
588
+ "boxvl=2524 boxvr=251c bprime=2035 breve=2d8 brvbar!a6 bscr=1d4b7 bsemi=204f bsim=223d bsime=22cd " +
589
+ "bsol=5c bsolb=29c5 bsolhsub=27c8 bull=2022 bullet=2022 bump=224e bumpE=2aae bumpe=224f " +
590
+ "bumpeq=224f cacute=107 cap=2229 capand=2a44 capbrcup=2a49 capcap=2a4b capcup=2a47 capdot=2a40 " +
591
+ "caps=2229,fe00 caret=2041 caron=2c7 ccaps=2a4d ccaron=10d ccedil!e7 ccirc=109 ccups=2a4c " +
592
+ "ccupssm=2a50 cdot=10b cedil!b8 cemptyv=29b2 cent!a2 centerdot=b7 cfr=1d520 chcy=447 check=2713 " +
593
+ "checkmark=2713 chi=3c7 cir=25cb cirE=29c3 circ=2c6 circeq=2257 circlearrowleft=21ba " +
594
+ "circlearrowright=21bb circledR=ae circledS=24c8 circledast=229b circledcirc=229a " +
595
+ "circleddash=229d cire=2257 cirfnint=2a10 cirmid=2aef cirscir=29c2 clubs=2663 clubsuit=2663 " +
596
+ "colon=3a colone=2254 coloneq=2254 comma=2c commat=40 comp=2201 compfn=2218 complement=2201 " +
597
+ "complexes=2102 cong=2245 congdot=2a6d conint=222e copf=1d554 coprod=2210 copy!a9 copysr=2117 " +
598
+ "crarr=21b5 cross=2717 cscr=1d4b8 csub=2acf csube=2ad1 csup=2ad0 csupe=2ad2 ctdot=22ef " +
599
+ "cudarrl=2938 cudarrr=2935 cuepr=22de cuesc=22df cularr=21b6 cularrp=293d cup=222a cupbrcap=2a48 " +
600
+ "cupcap=2a46 cupcup=2a4a cupdot=228d cupor=2a45 cups=222a,fe00 curarr=21b7 curarrm=293c " +
601
+ "curlyeqprec=22de curlyeqsucc=22df curlyvee=22ce curlywedge=22cf curren!a4 curvearrowleft=21b6 " +
602
+ "curvearrowright=21b7 cuvee=22ce cuwed=22cf cwconint=2232 cwint=2231 cylcty=232d dArr=21d3 " +
603
+ "dHar=2965 dagger=2020 daleth=2138 darr=2193 dash=2010 dashv=22a3 dbkarow=290f dblac=2dd " +
604
+ "dcaron=10f dcy=434 dd=2146 ddagger=2021 ddarr=21ca ddotseq=2a77 deg!b0 delta=3b4 demptyv=29b1 " +
605
+ "dfisht=297f dfr=1d521 dharl=21c3 dharr=21c2 diam=22c4 diamond=22c4 diamondsuit=2666 diams=2666 " +
606
+ "die=a8 digamma=3dd disin=22f2 div=f7 divide!f7 divideontimes=22c7 divonx=22c7 djcy=452 " +
607
+ "dlcorn=231e dlcrop=230d dollar=24 dopf=1d555 dot=2d9 doteq=2250 doteqdot=2251 dotminus=2238 " +
608
+ "dotplus=2214 dotsquare=22a1 doublebarwedge=2306 downarrow=2193 downdownarrows=21ca " +
609
+ "downharpoonleft=21c3 downharpoonright=21c2 drbkarow=2910 drcorn=231f drcrop=230c dscr=1d4b9 " +
610
+ "dscy=455 dsol=29f6 dstrok=111 dtdot=22f1 dtri=25bf dtrif=25be duarr=21f5 duhar=296f dwangle=29a6 " +
611
+ "dzcy=45f dzigrarr=27ff eDDot=2a77 eDot=2251 eacute!e9 easter=2a6e ecaron=11b ecir=2256 ecirc!ea " +
612
+ "ecolon=2255 ecy=44d edot=117 ee=2147 efDot=2252 efr=1d522 eg=2a9a egrave!e8 egs=2a96 egsdot=2a98 " +
613
+ "el=2a99 elinters=23e7 ell=2113 els=2a95 elsdot=2a97 emacr=113 empty=2205 emptyset=2205 " +
614
+ "emptyv=2205 emsp13=2004 emsp14=2005 emsp=2003 eng=14b ensp=2002 eogon=119 eopf=1d556 epar=22d5 " +
615
+ "eparsl=29e3 eplus=2a71 epsi=3b5 epsilon=3b5 epsiv=3f5 eqcirc=2256 eqcolon=2255 eqsim=2242 " +
616
+ "eqslantgtr=2a96 eqslantless=2a95 equals=3d equest=225f equiv=2261 equivDD=2a78 eqvparsl=29e5 " +
617
+ "erDot=2253 erarr=2971 escr=212f esdot=2250 esim=2242 eta=3b7 eth!f0 euml!eb euro=20ac excl=21 " +
618
+ "exist=2203 expectation=2130 exponentiale=2147 fallingdotseq=2252 fcy=444 female=2640 ffilig=fb03 " +
619
+ "fflig=fb00 ffllig=fb04 ffr=1d523 filig=fb01 fjlig=66,6a flat=266d fllig=fb02 fltns=25b1 fnof=192 " +
620
+ "fopf=1d557 forall=2200 fork=22d4 forkv=2ad9 fpartint=2a0d frac12!bd frac13=2153 frac14!bc " +
621
+ "frac15=2155 frac16=2159 frac18=215b frac23=2154 frac25=2156 frac34!be frac35=2157 frac38=215c " +
622
+ "frac45=2158 frac56=215a frac58=215d frac78=215e frasl=2044 frown=2322 fscr=1d4bb gE=2267 " +
623
+ "gEl=2a8c gacute=1f5 gamma=3b3 gammad=3dd gap=2a86 gbreve=11f gcirc=11d gcy=433 gdot=121 ge=2265 " +
624
+ "gel=22db geq=2265 geqq=2267 geqslant=2a7e ges=2a7e gescc=2aa9 gesdot=2a80 gesdoto=2a82 " +
625
+ "gesdotol=2a84 gesl=22db,fe00 gesles=2a94 gfr=1d524 gg=226b ggg=22d9 gimel=2137 gjcy=453 gl=2277 " +
626
+ "glE=2a92 gla=2aa5 glj=2aa4 gnE=2269 gnap=2a8a gnapprox=2a8a gne=2a88 gneq=2a88 gneqq=2269 " +
627
+ "gnsim=22e7 gopf=1d558 grave=60 gscr=210a gsim=2273 gsime=2a8e gsiml=2a90 gt!3e gtcc=2aa7 " +
628
+ "gtcir=2a7a gtdot=22d7 gtlPar=2995 gtquest=2a7c gtrapprox=2a86 gtrarr=2978 gtrdot=22d7 " +
629
+ "gtreqless=22db gtreqqless=2a8c gtrless=2277 gtrsim=2273 gvertneqq=2269,fe00 gvnE=2269,fe00 " +
630
+ "hArr=21d4 hairsp=200a half=bd hamilt=210b hardcy=44a harr=2194 harrcir=2948 harrw=21ad hbar=210f " +
631
+ "hcirc=125 hearts=2665 heartsuit=2665 hellip=2026 hercon=22b9 hfr=1d525 hksearow=2925 " +
632
+ "hkswarow=2926 hoarr=21ff homtht=223b hookleftarrow=21a9 hookrightarrow=21aa hopf=1d559 " +
633
+ "horbar=2015 hscr=1d4bd hslash=210f hstrok=127 hybull=2043 hyphen=2010 iacute!ed ic=2063 icirc!ee " +
634
+ "icy=438 iecy=435 iexcl!a1 iff=21d4 ifr=1d526 igrave!ec ii=2148 iiiint=2a0c iiint=222d " +
635
+ "iinfin=29dc iiota=2129 ijlig=133 imacr=12b image=2111 imagline=2110 imagpart=2111 imath=131 " +
636
+ "imof=22b7 imped=1b5 in=2208 incare=2105 infin=221e infintie=29dd inodot=131 int=222b intcal=22ba " +
637
+ "integers=2124 intercal=22ba intlarhk=2a17 intprod=2a3c iocy=451 iogon=12f iopf=1d55a iota=3b9 " +
638
+ "iprod=2a3c iquest!bf iscr=1d4be isin=2208 isinE=22f9 isindot=22f5 isins=22f4 isinsv=22f3 " +
639
+ "isinv=2208 it=2062 itilde=129 iukcy=456 iuml!ef jcirc=135 jcy=439 jfr=1d527 jmath=237 jopf=1d55b " +
640
+ "jscr=1d4bf jsercy=458 jukcy=454 kappa=3ba kappav=3f0 kcedil=137 kcy=43a kfr=1d528 kgreen=138 " +
641
+ "khcy=445 kjcy=45c kopf=1d55c kscr=1d4c0 lAarr=21da lArr=21d0 lAtail=291b lBarr=290e lE=2266 " +
642
+ "lEg=2a8b lHar=2962 lacute=13a laemptyv=29b4 lagran=2112 lambda=3bb lang=27e8 langd=2991 " +
643
+ "langle=27e8 lap=2a85 laquo!ab larr=2190 larrb=21e4 larrbfs=291f larrfs=291d larrhk=21a9 " +
644
+ "larrlp=21ab larrpl=2939 larrsim=2973 larrtl=21a2 lat=2aab latail=2919 late=2aad lates=2aad,fe00 " +
645
+ "lbarr=290c lbbrk=2772 lbrace=7b lbrack=5b lbrke=298b lbrksld=298f lbrkslu=298d lcaron=13e " +
646
+ "lcedil=13c lceil=2308 lcub=7b lcy=43b ldca=2936 ldquo=201c ldquor=201e ldrdhar=2967 " +
647
+ "ldrushar=294b ldsh=21b2 le=2264 leftarrow=2190 leftarrowtail=21a2 leftharpoondown=21bd " +
648
+ "leftharpoonup=21bc leftleftarrows=21c7 leftrightarrow=2194 leftrightarrows=21c6 " +
649
+ "leftrightharpoons=21cb leftrightsquigarrow=21ad leftthreetimes=22cb leg=22da leq=2264 leqq=2266 " +
650
+ "leqslant=2a7d les=2a7d lescc=2aa8 lesdot=2a7f lesdoto=2a81 lesdotor=2a83 lesg=22da,fe00 " +
651
+ "lesges=2a93 lessapprox=2a85 lessdot=22d6 lesseqgtr=22da lesseqqgtr=2a8b lessgtr=2276 " +
652
+ "lesssim=2272 lfisht=297c lfloor=230a lfr=1d529 lg=2276 lgE=2a91 lhard=21bd lharu=21bc " +
653
+ "lharul=296a lhblk=2584 ljcy=459 ll=226a llarr=21c7 llcorner=231e llhard=296b lltri=25fa " +
654
+ "lmidot=140 lmoust=23b0 lmoustache=23b0 lnE=2268 lnap=2a89 lnapprox=2a89 lne=2a87 lneq=2a87 " +
655
+ "lneqq=2268 lnsim=22e6 loang=27ec loarr=21fd lobrk=27e6 longleftarrow=27f5 " +
656
+ "longleftrightarrow=27f7 longmapsto=27fc longrightarrow=27f6 looparrowleft=21ab " +
657
+ "looparrowright=21ac lopar=2985 lopf=1d55d loplus=2a2d lotimes=2a34 lowast=2217 lowbar=5f " +
658
+ "loz=25ca lozenge=25ca lozf=29eb lpar=28 lparlt=2993 lrarr=21c6 lrcorner=231f lrhar=21cb " +
659
+ "lrhard=296d lrm=200e lrtri=22bf lsaquo=2039 lscr=1d4c1 lsh=21b0 lsim=2272 lsime=2a8d lsimg=2a8f " +
660
+ "lsqb=5b lsquo=2018 lsquor=201a lstrok=142 lt!3c ltcc=2aa6 ltcir=2a79 ltdot=22d6 lthree=22cb " +
661
+ "ltimes=22c9 ltlarr=2976 ltquest=2a7b ltrPar=2996 ltri=25c3 ltrie=22b4 ltrif=25c2 lurdshar=294a " +
662
+ "luruhar=2966 lvertneqq=2268,fe00 lvnE=2268,fe00 mDDot=223a macr!af male=2642 malt=2720 " +
663
+ "maltese=2720 map=21a6 mapsto=21a6 mapstodown=21a7 mapstoleft=21a4 mapstoup=21a5 marker=25ae " +
664
+ "mcomma=2a29 mcy=43c mdash=2014 measuredangle=2221 mfr=1d52a mho=2127 micro!b5 mid=2223 midast=2a " +
665
+ "midcir=2af0 middot!b7 minus=2212 minusb=229f minusd=2238 minusdu=2a2a mlcp=2adb mldr=2026 " +
666
+ "mnplus=2213 models=22a7 mopf=1d55e mp=2213 mscr=1d4c2 mstpos=223e mu=3bc multimap=22b8 " +
667
+ "mumap=22b8 nGg=22d9,338 nGt=226b,20d2 nGtv=226b,338 nLeftarrow=21cd nLeftrightarrow=21ce " +
668
+ "nLl=22d8,338 nLt=226a,20d2 nLtv=226a,338 nRightarrow=21cf nVDash=22af nVdash=22ae nabla=2207 " +
669
+ "nacute=144 nang=2220,20d2 nap=2249 napE=2a70,338 napid=224b,338 napos=149 napprox=2249 " +
670
+ "natur=266e natural=266e naturals=2115 nbsp!a0 nbump=224e,338 nbumpe=224f,338 ncap=2a43 " +
671
+ "ncaron=148 ncedil=146 ncong=2247 ncongdot=2a6d,338 ncup=2a42 ncy=43d ndash=2013 ne=2260 " +
672
+ "neArr=21d7 nearhk=2924 nearr=2197 nearrow=2197 nedot=2250,338 nequiv=2262 nesear=2928 " +
673
+ "nesim=2242,338 nexist=2204 nexists=2204 nfr=1d52b ngE=2267,338 nge=2271 ngeq=2271 ngeqq=2267,338 " +
674
+ "ngeqslant=2a7e,338 nges=2a7e,338 ngsim=2275 ngt=226f ngtr=226f nhArr=21ce nharr=21ae nhpar=2af2 " +
675
+ "ni=220b nis=22fc nisd=22fa niv=220b njcy=45a nlArr=21cd nlE=2266,338 nlarr=219a nldr=2025 " +
676
+ "nle=2270 nleftarrow=219a nleftrightarrow=21ae nleq=2270 nleqq=2266,338 nleqslant=2a7d,338 " +
677
+ "nles=2a7d,338 nless=226e nlsim=2274 nlt=226e nltri=22ea nltrie=22ec nmid=2224 nopf=1d55f not!ac " +
678
+ "notin=2209 notinE=22f9,338 notindot=22f5,338 notinva=2209 notinvb=22f7 notinvc=22f6 notni=220c " +
679
+ "notniva=220c notnivb=22fe notnivc=22fd npar=2226 nparallel=2226 nparsl=2afd,20e5 npart=2202,338 " +
680
+ "npolint=2a14 npr=2280 nprcue=22e0 npre=2aaf,338 nprec=2280 npreceq=2aaf,338 nrArr=21cf " +
681
+ "nrarr=219b nrarrc=2933,338 nrarrw=219d,338 nrightarrow=219b nrtri=22eb nrtrie=22ed nsc=2281 " +
682
+ "nsccue=22e1 nsce=2ab0,338 nscr=1d4c3 nshortmid=2224 nshortparallel=2226 nsim=2241 nsime=2244 " +
683
+ "nsimeq=2244 nsmid=2224 nspar=2226 nsqsube=22e2 nsqsupe=22e3 nsub=2284 nsubE=2ac5,338 nsube=2288 " +
684
+ "nsubset=2282,20d2 nsubseteq=2288 nsubseteqq=2ac5,338 nsucc=2281 nsucceq=2ab0,338 nsup=2285 " +
685
+ "nsupE=2ac6,338 nsupe=2289 nsupset=2283,20d2 nsupseteq=2289 nsupseteqq=2ac6,338 ntgl=2279 " +
686
+ "ntilde!f1 ntlg=2278 ntriangleleft=22ea ntrianglelefteq=22ec ntriangleright=22eb " +
687
+ "ntrianglerighteq=22ed nu=3bd num=23 numero=2116 numsp=2007 nvDash=22ad nvHarr=2904 " +
688
+ "nvap=224d,20d2 nvdash=22ac nvge=2265,20d2 nvgt=3e,20d2 nvinfin=29de nvlArr=2902 nvle=2264,20d2 " +
689
+ "nvlt=3c,20d2 nvltrie=22b4,20d2 nvrArr=2903 nvrtrie=22b5,20d2 nvsim=223c,20d2 nwArr=21d6 " +
690
+ "nwarhk=2923 nwarr=2196 nwarrow=2196 nwnear=2927 oS=24c8 oacute!f3 oast=229b ocir=229a ocirc!f4 " +
691
+ "ocy=43e odash=229d odblac=151 odiv=2a38 odot=2299 odsold=29bc oelig=153 ofcir=29bf ofr=1d52c " +
692
+ "ogon=2db ograve!f2 ogt=29c1 ohbar=29b5 ohm=3a9 oint=222e olarr=21ba olcir=29be olcross=29bb " +
693
+ "oline=203e olt=29c0 omacr=14d omega=3c9 omicron=3bf omid=29b6 ominus=2296 oopf=1d560 opar=29b7 " +
694
+ "operp=29b9 oplus=2295 or=2228 orarr=21bb ord=2a5d order=2134 orderof=2134 ordf!aa ordm!ba " +
695
+ "origof=22b6 oror=2a56 orslope=2a57 orv=2a5b oscr=2134 oslash!f8 osol=2298 otilde!f5 otimes=2297 " +
696
+ "otimesas=2a36 ouml!f6 ovbar=233d par=2225 para!b6 parallel=2225 parsim=2af3 parsl=2afd part=2202 " +
697
+ "pcy=43f percnt=25 period=2e permil=2030 perp=22a5 pertenk=2031 pfr=1d52d phi=3c6 phiv=3d5 " +
698
+ "phmmat=2133 phone=260e pi=3c0 pitchfork=22d4 piv=3d6 planck=210f planckh=210e plankv=210f " +
699
+ "plus=2b plusacir=2a23 plusb=229e pluscir=2a22 plusdo=2214 plusdu=2a25 pluse=2a72 plusmn!b1 " +
700
+ "plussim=2a26 plustwo=2a27 pm=b1 pointint=2a15 popf=1d561 pound!a3 pr=227a prE=2ab3 prap=2ab7 " +
701
+ "prcue=227c pre=2aaf prec=227a precapprox=2ab7 preccurlyeq=227c preceq=2aaf precnapprox=2ab9 " +
702
+ "precneqq=2ab5 precnsim=22e8 precsim=227e prime=2032 primes=2119 prnE=2ab5 prnap=2ab9 prnsim=22e8 " +
703
+ "prod=220f profalar=232e profline=2312 profsurf=2313 prop=221d propto=221d prsim=227e prurel=22b0 " +
704
+ "pscr=1d4c5 psi=3c8 puncsp=2008 qfr=1d52e qint=2a0c qopf=1d562 qprime=2057 qscr=1d4c6 " +
705
+ "quaternions=210d quatint=2a16 quest=3f questeq=225f quot!22 rAarr=21db rArr=21d2 rAtail=291c " +
706
+ "rBarr=290f rHar=2964 race=223d,331 racute=155 radic=221a raemptyv=29b3 rang=27e9 rangd=2992 " +
707
+ "range=29a5 rangle=27e9 raquo!bb rarr=2192 rarrap=2975 rarrb=21e5 rarrbfs=2920 rarrc=2933 " +
708
+ "rarrfs=291e rarrhk=21aa rarrlp=21ac rarrpl=2945 rarrsim=2974 rarrtl=21a3 rarrw=219d ratail=291a " +
709
+ "ratio=2236 rationals=211a rbarr=290d rbbrk=2773 rbrace=7d rbrack=5d rbrke=298c rbrksld=298e " +
710
+ "rbrkslu=2990 rcaron=159 rcedil=157 rceil=2309 rcub=7d rcy=440 rdca=2937 rdldhar=2969 rdquo=201d " +
711
+ "rdquor=201d rdsh=21b3 real=211c realine=211b realpart=211c reals=211d rect=25ad reg!ae " +
712
+ "rfisht=297d rfloor=230b rfr=1d52f rhard=21c1 rharu=21c0 rharul=296c rho=3c1 rhov=3f1 " +
713
+ "rightarrow=2192 rightarrowtail=21a3 rightharpoondown=21c1 rightharpoonup=21c0 " +
714
+ "rightleftarrows=21c4 rightleftharpoons=21cc rightrightarrows=21c9 rightsquigarrow=219d " +
715
+ "rightthreetimes=22cc ring=2da risingdotseq=2253 rlarr=21c4 rlhar=21cc rlm=200f rmoust=23b1 " +
716
+ "rmoustache=23b1 rnmid=2aee roang=27ed roarr=21fe robrk=27e7 ropar=2986 ropf=1d563 roplus=2a2e " +
717
+ "rotimes=2a35 rpar=29 rpargt=2994 rppolint=2a12 rrarr=21c9 rsaquo=203a rscr=1d4c7 rsh=21b1 " +
718
+ "rsqb=5d rsquo=2019 rsquor=2019 rthree=22cc rtimes=22ca rtri=25b9 rtrie=22b5 rtrif=25b8 " +
719
+ "rtriltri=29ce ruluhar=2968 rx=211e sacute=15b sbquo=201a sc=227b scE=2ab4 scap=2ab8 scaron=161 " +
720
+ "sccue=227d sce=2ab0 scedil=15f scirc=15d scnE=2ab6 scnap=2aba scnsim=22e9 scpolint=2a13 " +
721
+ "scsim=227f scy=441 sdot=22c5 sdotb=22a1 sdote=2a66 seArr=21d8 searhk=2925 searr=2198 " +
722
+ "searrow=2198 sect!a7 semi=3b seswar=2929 setminus=2216 setmn=2216 sext=2736 sfr=1d530 " +
723
+ "sfrown=2322 sharp=266f shchcy=449 shcy=448 shortmid=2223 shortparallel=2225 shy!ad sigma=3c3 " +
724
+ "sigmaf=3c2 sigmav=3c2 sim=223c simdot=2a6a sime=2243 simeq=2243 simg=2a9e simgE=2aa0 siml=2a9d " +
725
+ "simlE=2a9f simne=2246 simplus=2a24 simrarr=2972 slarr=2190 smallsetminus=2216 smashp=2a33 " +
726
+ "smeparsl=29e4 smid=2223 smile=2323 smt=2aaa smte=2aac smtes=2aac,fe00 softcy=44c sol=2f " +
727
+ "solb=29c4 solbar=233f sopf=1d564 spades=2660 spadesuit=2660 spar=2225 sqcap=2293 " +
728
+ "sqcaps=2293,fe00 sqcup=2294 sqcups=2294,fe00 sqsub=228f sqsube=2291 sqsubset=228f " +
729
+ "sqsubseteq=2291 sqsup=2290 sqsupe=2292 sqsupset=2290 sqsupseteq=2292 squ=25a1 square=25a1 " +
730
+ "squarf=25aa squf=25aa srarr=2192 sscr=1d4c8 ssetmn=2216 ssmile=2323 sstarf=22c6 star=2606 " +
731
+ "starf=2605 straightepsilon=3f5 straightphi=3d5 strns=af sub=2282 subE=2ac5 subdot=2abd sube=2286 " +
732
+ "subedot=2ac3 submult=2ac1 subnE=2acb subne=228a subplus=2abf subrarr=2979 subset=2282 " +
733
+ "subseteq=2286 subseteqq=2ac5 subsetneq=228a subsetneqq=2acb subsim=2ac7 subsub=2ad5 subsup=2ad3 " +
734
+ "succ=227b succapprox=2ab8 succcurlyeq=227d succeq=2ab0 succnapprox=2aba succneqq=2ab6 " +
735
+ "succnsim=22e9 succsim=227f sum=2211 sung=266a sup1!b9 sup2!b2 sup3!b3 sup=2283 supE=2ac6 " +
736
+ "supdot=2abe supdsub=2ad8 supe=2287 supedot=2ac4 suphsol=27c9 suphsub=2ad7 suplarr=297b " +
737
+ "supmult=2ac2 supnE=2acc supne=228b supplus=2ac0 supset=2283 supseteq=2287 supseteqq=2ac6 " +
738
+ "supsetneq=228b supsetneqq=2acc supsim=2ac8 supsub=2ad4 supsup=2ad6 swArr=21d9 swarhk=2926 " +
739
+ "swarr=2199 swarrow=2199 swnwar=292a szlig!df target=2316 tau=3c4 tbrk=23b4 tcaron=165 tcedil=163 " +
740
+ "tcy=442 tdot=20db telrec=2315 tfr=1d531 there4=2234 therefore=2234 theta=3b8 thetasym=3d1 " +
741
+ "thetav=3d1 thickapprox=2248 thicksim=223c thinsp=2009 thkap=2248 thksim=223c thorn!fe tilde=2dc " +
742
+ "times!d7 timesb=22a0 timesbar=2a31 timesd=2a30 tint=222d toea=2928 top=22a4 topbot=2336 " +
743
+ "topcir=2af1 topf=1d565 topfork=2ada tosa=2929 tprime=2034 trade=2122 triangle=25b5 " +
744
+ "triangledown=25bf triangleleft=25c3 trianglelefteq=22b4 triangleq=225c triangleright=25b9 " +
745
+ "trianglerighteq=22b5 tridot=25ec trie=225c triminus=2a3a triplus=2a39 trisb=29cd tritime=2a3b " +
746
+ "trpezium=23e2 tscr=1d4c9 tscy=446 tshcy=45b tstrok=167 twixt=226c twoheadleftarrow=219e " +
747
+ "twoheadrightarrow=21a0 uArr=21d1 uHar=2963 uacute!fa uarr=2191 ubrcy=45e ubreve=16d ucirc!fb " +
748
+ "ucy=443 udarr=21c5 udblac=171 udhar=296e ufisht=297e ufr=1d532 ugrave!f9 uharl=21bf uharr=21be " +
749
+ "uhblk=2580 ulcorn=231c ulcorner=231c ulcrop=230f ultri=25f8 umacr=16b uml!a8 uogon=173 " +
750
+ "uopf=1d566 uparrow=2191 updownarrow=2195 upharpoonleft=21bf upharpoonright=21be uplus=228e " +
751
+ "upsi=3c5 upsih=3d2 upsilon=3c5 upuparrows=21c8 urcorn=231d urcorner=231d urcrop=230e uring=16f " +
752
+ "urtri=25f9 uscr=1d4ca utdot=22f0 utilde=169 utri=25b5 utrif=25b4 uuarr=21c8 uuml!fc uwangle=29a7 " +
753
+ "vArr=21d5 vBar=2ae8 vBarv=2ae9 vDash=22a8 vangrt=299c varepsilon=3f5 varkappa=3f0 " +
754
+ "varnothing=2205 varphi=3d5 varpi=3d6 varpropto=221d varr=2195 varrho=3f1 varsigma=3c2 " +
755
+ "varsubsetneq=228a,fe00 varsubsetneqq=2acb,fe00 varsupsetneq=228b,fe00 varsupsetneqq=2acc,fe00 " +
756
+ "vartheta=3d1 vartriangleleft=22b2 vartriangleright=22b3 vcy=432 vdash=22a2 vee=2228 veebar=22bb " +
757
+ "veeeq=225a vellip=22ee verbar=7c vert=7c vfr=1d533 vltri=22b2 vnsub=2282,20d2 vnsup=2283,20d2 " +
758
+ "vopf=1d567 vprop=221d vrtri=22b3 vscr=1d4cb vsubnE=2acb,fe00 vsubne=228a,fe00 vsupnE=2acc,fe00 " +
759
+ "vsupne=228b,fe00 vzigzag=299a wcirc=175 wedbar=2a5f wedge=2227 wedgeq=2259 weierp=2118 wfr=1d534 " +
760
+ "wopf=1d568 wp=2118 wr=2240 wreath=2240 wscr=1d4cc xcap=22c2 xcirc=25ef xcup=22c3 xdtri=25bd " +
761
+ "xfr=1d535 xhArr=27fa xharr=27f7 xi=3be xlArr=27f8 xlarr=27f5 xmap=27fc xnis=22fb xodot=2a00 " +
762
+ "xopf=1d569 xoplus=2a01 xotime=2a02 xrArr=27f9 xrarr=27f6 xscr=1d4cd xsqcup=2a06 xuplus=2a04 " +
763
+ "xutri=25b3 xvee=22c1 xwedge=22c0 yacute!fd yacy=44f ycirc=177 ycy=44b yen!a5 yfr=1d536 yicy=457 " +
764
+ "yopf=1d56a yscr=1d4ce yucy=44e yuml!ff zacute=17a zcaron=17e zcy=437 zdot=17c zeetrf=2128 " +
765
+ "zeta=3b6 zfr=1d537 zhcy=436 zigrarr=21dd zopf=1d56b zscr=1d4cf zwj=200d zwnj=200c";
766
+
767
+ let namedReferences = null;
768
+ function namedReference(key) {
769
+ if (namedReferences === null) {
770
+ namedReferences = new Map();
771
+ for (const entry of NAMED_REFERENCE_DATA.split(" ")) {
772
+ const sep = entry.search(/[!=]/);
773
+ const name = entry.slice(0, sep);
774
+ const value = String.fromCodePoint(...entry.slice(sep + 1).split(",").map((h) => parseInt(h, 16)));
775
+ namedReferences.set(name + ";", value);
776
+ if (entry[sep] === "!") namedReferences.set(name, value);
777
+ }
778
+ }
779
+ return namedReferences.get(key);
780
+ }
781
+
782
+ function decodeAttributeValue(value) {
783
+ if (!value.includes("&") && !value.includes("\u0000")) return value;
784
+ const s = value.replace(/\u0000/g, "\uFFFD");
785
+ const n = s.length;
786
+ let out = "";
787
+ let i = 0;
788
+ for (;;) {
789
+ const amp = s.indexOf("&", i);
790
+ if (amp === -1) return out + s.slice(i);
791
+ out += s.slice(i, amp);
792
+ i = amp + 1;
793
+ if (s[i] === "#") {
794
+ let j = i + 1;
795
+ const hex = s[j] === "x" || s[j] === "X";
796
+ if (hex) j++;
797
+ const digits = j;
798
+ let code = 0;
799
+ for (; j < n; j++) {
800
+ const c = s[j];
801
+ const d = c >= "0" && c <= "9" ? c.charCodeAt(0) - 48
802
+ : hex && c >= "a" && c <= "f" ? c.charCodeAt(0) - 87
803
+ : hex && c >= "A" && c <= "F" ? c.charCodeAt(0) - 55
804
+ : -1;
805
+ if (d === -1) break;
806
+ code = Math.min(code * (hex ? 16 : 10) + d, 0x110000);
807
+ }
808
+ if (j === digits) { out += "&"; continue; }
809
+ if (s[j] === ";") j++;
810
+ if (code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) code = 0xfffd;
811
+ else if (C1_REPLACEMENTS[code]) code = C1_REPLACEMENTS[code];
812
+ out += String.fromCodePoint(code);
813
+ i = j;
814
+ continue;
815
+ }
816
+ // A name is at most 31 letters and digits, so the run is read no further than 32.
817
+ let end = i;
818
+ while (end < n && end - i < 32 && isAsciiAlphanumeric(s[end])) end++;
819
+ let match, matchEnd = -1;
820
+ if (end > i && s[end] === ";") {
821
+ match = namedReference(s.slice(i, end) + ";");
822
+ if (match !== undefined) matchEnd = end + 1;
823
+ }
824
+ for (let k = end; matchEnd === -1 && k > i; k--) {
825
+ match = namedReference(s.slice(i, k));
826
+ if (match !== undefined) matchEnd = k;
827
+ }
828
+ if (matchEnd === -1) { out += "&"; continue; }
829
+ if (s[matchEnd - 1] !== ";" && (s[matchEnd] === "=" || isAsciiAlphanumeric(s[matchEnd]))) { out += "&"; continue; }
830
+ out += match;
831
+ i = matchEnd;
832
+ }
833
+ }
834
+
305
835
  // The head is where a real parser says it ends, not where the text "</head>" happens to
306
836
  // appear. Erik's decision 2026-08-24: the two v2 checks describe what the page's HEAD
307
837
  // points at, so a link element the parser moves into the body is not one of them. That is
@@ -405,6 +935,19 @@ function templateEnd(lower, from) {
405
935
  }
406
936
  }
407
937
 
938
+ // The index after the "-->" or "--!>" that ends a comment whose text starts at from, or -1 when
939
+ // the comment never ends. One forward pass over the "--" pairs. The earlier code searched for
940
+ // "--!>" separately from every comment, and on a page that never uses that ending, which is
941
+ // almost every page, each search ran to the end of the document: 100 KB of short comments
942
+ // measured 770 ms and each doubling took four times as long (found by a verifier, 2026-09-12).
943
+ function commentEnd(lower, from) {
944
+ for (let p = lower.indexOf("--", from); p !== -1; p = lower.indexOf("--", p + 1)) {
945
+ if (lower[p + 2] === ">") return p + 3;
946
+ if (lower[p + 2] === "!" && lower[p + 3] === ">") return p + 4;
947
+ }
948
+ return -1;
949
+ }
950
+
408
951
  // One left to right scan by index that returns the head, with comments and the content of
409
952
  // the raw text and template elements already removed. Why a scan and not a regex: a
410
953
  // character class that reads a tag's attributes is quadratic on input the TARGET site
@@ -419,32 +962,42 @@ function templateEnd(lower, from) {
419
962
  // "!", "/" or "?" follows is text. A ">" inside a quoted attribute value does not end a
420
963
  // tag. </script/> closes a raw text element as well as </script> does.
421
964
  function headOfDocument(html) {
422
- const text = String(html || "");
423
- const lower = text.toLowerCase();
965
+ // A parser tokenizes an input stream whose CRLF and CR have already become LF, so a value read
966
+ // from the head carries LF where the page wrote CR (measured against parse5, 2026-09-12).
967
+ const text = String(html || "").replace(/\r\n?/g, "\n");
968
+ const lower = asciiLower(text);
424
969
  const out = [];
425
970
  let i = 0, afterHead = false;
426
971
  for (;;) {
427
972
  const lt = lower.indexOf("<", i);
428
973
  const gap = lt === -1 ? text.slice(i) : text.slice(i, lt);
429
- if (gap.trim() !== "") break;
974
+ if (!isHtmlWhitespace(gap)) break;
430
975
  out.push(gap);
431
976
  if (lt === -1) break;
432
977
  if (lower.startsWith("<!--", lt)) {
433
978
  if (lower.startsWith("<!-->", lt)) { i = lt + 5; continue; }
434
979
  if (lower.startsWith("<!--->", lt)) { i = lt + 6; continue; }
435
- const dashes = lower.indexOf("-->", lt + 4);
436
- const bang = lower.indexOf("--!>", lt + 4);
437
- if (dashes === -1 && bang === -1) break;
438
- i = (bang === -1 || (dashes !== -1 && dashes <= bang)) ? dashes + 3 : bang + 4;
980
+ const end = commentEnd(lower, lt + 4);
981
+ if (end === -1) break;
982
+ i = end;
439
983
  continue;
440
984
  }
441
985
  if (!startsTag(lower[lt + 1])) break;
986
+ // "<!", "<?" and a "</" that no letter follows open a bogus comment or a doctype, and the
987
+ // tokenizer ends each of them at the first ">", inside quotes or not.
988
+ if (lower[lt + 1] === "!" || lower[lt + 1] === "?" || (lower[lt + 1] === "/" && !(lower[lt + 2] >= "a" && lower[lt + 2] <= "z"))) {
989
+ const bogusEnd = lower.indexOf(">", lt + 2);
990
+ if (bogusEnd === -1) break;
991
+ i = bogusEnd + 1;
992
+ continue;
993
+ }
442
994
  const gt = tagEnd(text, lt);
443
995
  if (gt === -1) break;
444
- if (lower[lt + 1] === "!" || lower[lt + 1] === "?") { i = gt + 1; continue; }
445
996
  const head14 = lower.slice(lt, Math.min(lt + 14, gt + 1));
446
- const endTag = (/^<\/([a-z]+)/.exec(head14) || [])[1];
447
- if (endTag) {
997
+ if (lower[lt + 1] === "/") {
998
+ // The name has to END where a tag name ends: </bodyx> is an unknown end tag, which the
999
+ // head ignores, and not </body>.
1000
+ const endTag = (/^<\/([a-z]+)(?=[\t\n\f\r />]|$)/.exec(head14) || [])[1];
448
1001
  // </body>, </html> and </br> start the body. </head> does NOT end the search: after
449
1002
  // it a parser still puts base, link, meta, script, style, title and template into the
450
1003
  // HEAD element until real body content starts, and every other end tag in the head is
@@ -455,7 +1008,7 @@ function headOfDocument(html) {
455
1008
  i = gt + 1;
456
1009
  continue;
457
1010
  }
458
- const name = (/^<([a-z]+)(?=[\s/>]|$)/.exec(head14) || [])[1];
1011
+ const name = (/^<([a-z]+)(?=[\t\n\f\r />]|$)/.exec(head14) || [])[1];
459
1012
  if (!name) break;
460
1013
  if (name === "html" || name === "head") { i = gt + 1; continue; }
461
1014
  if (!(afterHead ? AFTER_HEAD_ELEMENTS : HEAD_ELEMENTS).includes(name)) break; // <body> and the first body level element
@@ -481,7 +1034,7 @@ function headOfDocument(html) {
481
1034
  // visits every character once: from each "<" it reads to the next ">" and then continues
482
1035
  // after it, which is what the regex meant to say.
483
1036
  function* htmlTags(text) {
484
- const lower = text.toLowerCase();
1037
+ const lower = asciiLower(text);
485
1038
  let i = 0;
486
1039
  for (;;) {
487
1040
  const open = lower.indexOf("<", i);
@@ -494,6 +1047,87 @@ function* htmlTags(text) {
494
1047
  }
495
1048
  }
496
1049
 
1050
+ function tagAttributes(tag) {
1051
+ const out = {};
1052
+ readTag(tag, 0, out);
1053
+ return out;
1054
+ }
1055
+
1056
+ // RFC 8288 section 3: a Link header is a comma separated list, each value an angle
1057
+ // bracketed URI reference followed by semicolon separated parameters, and a parameter
1058
+ // value may be a quoted string that contains commas, semicolons and backslash escapes.
1059
+ // Reading rel and type with one regex over the whole value read the CONTENT of
1060
+ // title="note; rel=alternate; type=text/markdown" as parameters of the link.
1061
+ function parseLinkHeader(header) {
1062
+ const s = String(header || "");
1063
+ const space = " \t";
1064
+ const n = s.length;
1065
+ const out = [];
1066
+ let i = 0;
1067
+ while (i < n) {
1068
+ while (i < n && (s[i] === "," || space.includes(s[i]))) i++;
1069
+ if (i >= n) break;
1070
+ if (s[i] !== "<") { i = nextLinkValue(s, i); continue; }
1071
+ const gt = s.indexOf(">", i + 1);
1072
+ if (gt === -1) break;
1073
+ const href = s.slice(i + 1, gt).trim();
1074
+ i = gt + 1;
1075
+ const params = {};
1076
+ while (i < n) {
1077
+ while (i < n && space.includes(s[i])) i++;
1078
+ if (i >= n) break;
1079
+ if (s[i] === ",") { i++; break; }
1080
+ if (s[i] !== ";") { i = nextLinkValue(s, i); break; }
1081
+ i++;
1082
+ while (i < n && space.includes(s[i])) i++;
1083
+ const nameStart = i;
1084
+ while (i < n && s[i] !== "=" && s[i] !== ";" && s[i] !== "," && !space.includes(s[i])) i++;
1085
+ const name = asciiLower(s.slice(nameStart, i));
1086
+ while (i < n && space.includes(s[i])) i++;
1087
+ let value = "";
1088
+ if (s[i] === "=") {
1089
+ i++;
1090
+ while (i < n && space.includes(s[i])) i++;
1091
+ if (s[i] === '"') {
1092
+ i++;
1093
+ let buf = "";
1094
+ while (i < n && s[i] !== '"') {
1095
+ if (s[i] === "\\" && i + 1 < n) { buf += s[i + 1]; i += 2; continue; }
1096
+ buf += s[i];
1097
+ i++;
1098
+ }
1099
+ if (i < n) i++;
1100
+ value = buf;
1101
+ } else {
1102
+ const valueStart = i;
1103
+ while (i < n && s[i] !== ";" && s[i] !== "," && !space.includes(s[i])) i++;
1104
+ value = s.slice(valueStart, i);
1105
+ }
1106
+ }
1107
+ if (name && !(name in params)) params[name] = value;
1108
+ }
1109
+ out.push({ href: href, params: params });
1110
+ }
1111
+ return out;
1112
+ }
1113
+
1114
+ // Move to the start of the next comma separated value without stopping inside a quoted
1115
+ // string, so a comma in title="a, b" does not split one value into two.
1116
+ function nextLinkValue(s, from) {
1117
+ let quote = "";
1118
+ for (let j = from; j < s.length; j++) {
1119
+ const c = s[j];
1120
+ if (quote) {
1121
+ if (c === "\\") { j++; continue; }
1122
+ if (c === quote) quote = "";
1123
+ continue;
1124
+ }
1125
+ if (c === '"') { quote = c; continue; }
1126
+ if (c === ",") return j + 1;
1127
+ }
1128
+ return s.length;
1129
+ }
1130
+
497
1131
  // v2 of the llms.txt proposal (August 2026) left the file format alone and added one
498
1132
  // thing: a page should say where its markdown version and its llms.txt are, using
499
1133
  // rel="alternate" type="text/markdown" and rel="describedby", as HTML link elements or
@@ -505,33 +1139,38 @@ export function findLinkRelations(html, linkHeader) {
505
1139
  // Only the head, and only what a parser would put there: a commented-out link element,
506
1140
  // one inside a script or a template, and one the parser moves into the body are all not
507
1141
  // what these two checks are about, and counting them would report a relation the site
508
- // does not serve. See headOfDocument above for the rules shape by shape. The 64 KB bound
509
- // is a cap on work, not a rule: a head longer than that is not a head.
1142
+ // does not serve. See headOfDocument above for the rules shape by shape.
1143
+ // The scan reads only the first 65,536 UTF-16 code units of what headOfDocument returns.
1144
+ // The cap applies after the head is collected, so it bounds this tag scan and not the head
1145
+ // parse, and a relation declared past it is not found here. The Link response header is
1146
+ // read separately below.
510
1147
  const head = headOfDocument(html).slice(0, 65536);
511
1148
  for (const tag of htmlTags(head)) {
512
1149
  // The name has to END at "link": a real parser reads "<link<link" as ONE tag whose
513
1150
  // NAME is "link<link", not as a link element, so \b would count a relation the site
514
1151
  // does not publish. Measured against parse5, 2026-08-24.
515
- if (!/^<link(?=[\s/>])/i.test(tag)) continue;
516
- // The attribute name has to start the token. \b sits between the hyphen and the name,
517
- // so data-rel, data-type and data-href were read as the real attributes until
518
- // 2026-08-29, and a page could claim a relation it does not publish.
519
- const rel = ((tag.match(/(?:^|[\s/])rel\s*=\s*["']?([^"'>]+)/i) || [])[1] || "").toLowerCase().trim().split(/\s+/);
520
- const type = ((tag.match(/(?:^|[\s/])type\s*=\s*["']?([^"'>\s]+)/i) || [])[1] || "").toLowerCase();
521
- 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();
1152
+ if (!/^<link(?=[\t\n\f\r />])/i.test(tag)) continue;
1153
+ // Attributes come from ONE quote aware scan, not from three independent regex matches
1154
+ // over the whole tag. Those three could not tell an attribute from the quoted CONTENT
1155
+ // of another attribute, so data-note=" rel='alternate' type='text/markdown'
1156
+ // href='/fake.md'" reported a relation the page does not publish. The earlier
1157
+ // data-rel fix, 2026-08-29, moved the START of the name and does not reach this.
1158
+ // Measured and fixed 2026-09-09.
1159
+ const attrs = tagAttributes(tag);
1160
+ const rel = trimChars(asciiLower(attrs.rel || ""), HTML_WHITESPACE).split(/[\t\n\f\r ]+/);
1161
+ const type = trimChars(asciiLower(attrs.type || ""), HTTP_WHITESPACE);
1162
+ const href = trimChars(attrs.href || "", HTML_WHITESPACE);
522
1163
  // text/markdown, not anything that starts with it, and a relation without a target is
523
1164
  // not a relation: both passed until 2026-08-29.
524
- const isMarkdown = type.split(";")[0].trim() === "text/markdown";
1165
+ const isMarkdown = trimChars(type.split(";")[0], HTTP_WHITESPACE) === "text/markdown";
525
1166
  if (!found.describedby && href && rel.includes("describedby")) found.describedby = href;
526
1167
  if (!found.markdown && href && rel.includes("alternate") && isMarkdown) found.markdown = href;
527
1168
  }
528
- for (const part of String(linkHeader || "").split(/,(?=\s*<)/)) {
529
- const lt = part.indexOf("<");
530
- const gt = lt === -1 ? -1 : part.indexOf(">", lt + 1);
531
- const href = (gt === -1 ? "" : part.slice(lt + 1, gt)).trim();
532
- const rel = ((part.match(/(?:^|[;\s])rel\s*=\s*"?([^";,]+)"?/i) || [])[1] || "").toLowerCase().trim().split(/\s+/);
533
- const type = ((part.match(/(?:^|[;\s])type\s*=\s*"?([^";,]+)"?/i) || [])[1] || "").toLowerCase().trim();
534
- const isMarkdownHeader = type.split(";")[0].trim() === "text/markdown";
1169
+ for (const link of parseLinkHeader(linkHeader)) {
1170
+ const href = link.href;
1171
+ const rel = trimChars(asciiLower(link.params.rel || ""), HTTP_WHITESPACE).split(/[\t\n\r ]+/);
1172
+ const type = trimChars(asciiLower(link.params.type || ""), HTTP_WHITESPACE);
1173
+ const isMarkdownHeader = trimChars(type.split(";")[0], HTTP_WHITESPACE) === "text/markdown";
535
1174
  if (!found.describedby && href && rel.includes("describedby")) found.describedby = href;
536
1175
  if (!found.markdown && href && rel.includes("alternate") && isMarkdownHeader) found.markdown = href;
537
1176
  }