@queryrev/ai-readiness 0.0.0-stage → 1.0.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.
Files changed (66) hide show
  1. package/CITATION.cff +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +190 -2
  4. package/SECURITY.md +34 -0
  5. package/data/LICENSE +409 -0
  6. package/dist/chunks/cli-BK65ic84.js +56 -0
  7. package/dist/chunks/fetch-text-NUUnM91O.js +866 -0
  8. package/dist/chunks/findings-_twhMw3_.js +2950 -0
  9. package/dist/chunks/main-CJu6SE-W.js +1491 -0
  10. package/dist/cli/args.d.ts +27 -0
  11. package/dist/cli/bin.d.ts +2 -0
  12. package/dist/cli/commands/check.d.ts +4 -0
  13. package/dist/cli/commands/crawlers.d.ts +3 -0
  14. package/dist/cli/commands/llms-generate.d.ts +4 -0
  15. package/dist/cli/commands/llms-validate.d.ts +4 -0
  16. package/dist/cli/commands/robots-test.d.ts +4 -0
  17. package/dist/cli/common.d.ts +27 -0
  18. package/dist/cli/finding.d.ts +3 -0
  19. package/dist/cli/main.d.ts +7 -0
  20. package/dist/cli/node-context.d.ts +3 -0
  21. package/dist/cli/render.d.ts +26 -0
  22. package/dist/cli/run.d.ts +11 -0
  23. package/dist/cli/sanitize.d.ts +8 -0
  24. package/dist/cli/types.d.ts +59 -0
  25. package/dist/cli/usage.d.ts +1 -0
  26. package/dist/cli.js +36 -0
  27. package/dist/crawlers/index.d.ts +79 -0
  28. package/dist/facts/app-shell.d.ts +24 -0
  29. package/dist/facts/check-site.d.ts +13 -0
  30. package/dist/facts/domain.d.ts +15 -0
  31. package/dist/facts/findings.d.ts +5 -0
  32. package/dist/facts/home-page.d.ts +18 -0
  33. package/dist/facts/home-response.d.ts +5 -0
  34. package/dist/facts/html.d.ts +37 -0
  35. package/dist/facts/index.d.ts +16 -0
  36. package/dist/facts/json-ld.d.ts +35 -0
  37. package/dist/facts/marks.d.ts +51 -0
  38. package/dist/facts/organization.d.ts +23 -0
  39. package/dist/facts/origin-robots.d.ts +20 -0
  40. package/dist/facts/profiles.d.ts +13 -0
  41. package/dist/facts/reader.d.ts +2 -0
  42. package/dist/facts/schema-org-organization-types.d.ts +6 -0
  43. package/dist/facts/schema-org-types.d.ts +2 -0
  44. package/dist/facts/sitemap.d.ts +24 -0
  45. package/dist/facts/types.d.ts +214 -0
  46. package/dist/facts/words.d.ts +15 -0
  47. package/dist/generate/generate-llms.d.ts +51 -0
  48. package/dist/generate/index.d.ts +70 -0
  49. package/dist/index.d.ts +10 -0
  50. package/dist/index.js +1023 -0
  51. package/dist/llms/findings.d.ts +7 -0
  52. package/dist/llms/index.d.ts +88 -0
  53. package/dist/llms/links.d.ts +46 -0
  54. package/dist/net/address.d.ts +26 -0
  55. package/dist/net/charset.d.ts +20 -0
  56. package/dist/net/cli.d.ts +13 -0
  57. package/dist/net/fetch-text.d.ts +191 -0
  58. package/dist/net/guard.d.ts +71 -0
  59. package/dist/net/testing/fixture-fetcher.d.ts +15 -0
  60. package/dist/net/testing/fixture-guard.d.ts +20 -0
  61. package/dist/net/testing/index.d.ts +7 -0
  62. package/dist/net-cli.js +1 -0
  63. package/dist/robots/index.d.ts +67 -0
  64. package/dist/run.js +1 -0
  65. package/dist/testing.js +58 -0
  66. package/package.json +80 -6
@@ -0,0 +1,1491 @@
1
+ import { $ as looksLikeHtml, F as sitemapLocations, I as stripUnsafeCharacters, M as attributes, N as pageMeta, P as renderLlmsTxt, X as registrableDomain, _t as parseRobots, a as robotsRead, ct as crawlerAccess, f as READER_TOKEN, g as xRobotsNoindex, gt as matchRobots, ht as isAllowedByRobots, i as checkSite, it as CRAWLERS, j as PAGE_SCAN_MAX_CHARS, lt as crawlersCoveredLine, mt as crawlDelayFor, n as llmsFinding, nt as validateLlmsTxt, r as llmsFindings } from "./findings-_twhMw3_.js";
2
+ import { a as GENERATE_BUDGET_MS, i as FETCH_LIMITS, m as createTextFetcher, n as CHECK_BUDGET_MS, p as checkTextUrl } from "./fetch-text-NUUnM91O.js";
3
+ import { parseArgs } from "node:util";
4
+ import { gunzipSync } from "node:zlib";
5
+ // src/cli/types.ts
6
+ /** A wrong command, option or input: exit code 2. */
7
+ var UsageError = class extends Error {
8
+ name = "UsageError";
9
+ };
10
+ // src/cli/args.ts
11
+ var COMMON = {
12
+ json: { type: "boolean" },
13
+ "no-color": { type: "boolean" },
14
+ help: {
15
+ type: "boolean",
16
+ short: "h"
17
+ }
18
+ };
19
+ var FAIL_ON = { "fail-on": { type: "string" } };
20
+ var PRIVATE = { "allow-private-network": { type: "boolean" } };
21
+ var COMMANDS = {
22
+ check: {
23
+ name: "check",
24
+ options: {
25
+ ...COMMON,
26
+ ...FAIL_ON,
27
+ ...PRIVATE
28
+ },
29
+ positionals: {
30
+ min: 1,
31
+ max: 1,
32
+ label: "<url>"
33
+ }
34
+ },
35
+ "robots test": {
36
+ name: "robots test",
37
+ options: {
38
+ ...COMMON,
39
+ ...FAIL_ON,
40
+ ...PRIVATE,
41
+ agent: { type: "string" },
42
+ path: { type: "string" }
43
+ },
44
+ positionals: {
45
+ min: 1,
46
+ max: 1,
47
+ label: "<file|url>"
48
+ }
49
+ },
50
+ "llms validate": {
51
+ name: "llms validate",
52
+ options: {
53
+ ...COMMON,
54
+ ...FAIL_ON,
55
+ ...PRIVATE,
56
+ links: { type: "boolean" },
57
+ base: { type: "string" }
58
+ },
59
+ positionals: {
60
+ min: 1,
61
+ max: 1,
62
+ label: "<file|url>"
63
+ }
64
+ },
65
+ "llms generate": {
66
+ name: "llms generate",
67
+ options: {
68
+ ...COMMON,
69
+ ...PRIVATE,
70
+ "max-pages": { type: "string" },
71
+ out: { type: "string" },
72
+ force: { type: "boolean" }
73
+ },
74
+ positionals: {
75
+ min: 1,
76
+ max: 1,
77
+ label: "<url>"
78
+ }
79
+ },
80
+ crawlers: {
81
+ name: "crawlers",
82
+ options: { ...COMMON },
83
+ positionals: {
84
+ min: 0,
85
+ max: 0,
86
+ label: ""
87
+ }
88
+ }
89
+ };
90
+ /** The command words at the start of the arguments, and how many of them there are. */
91
+ function commandOf(argv) {
92
+ const first = argv[0];
93
+ if (first === void 0 || first.startsWith("-")) throw new UsageError("Give a command: check, robots test, llms validate, llms generate or crawlers.");
94
+ if (first === "robots" || first === "llms") {
95
+ const second = argv[1];
96
+ const name = `${first} ${second ?? ""}`;
97
+ if (second === void 0 || !(name in COMMANDS)) throw new UsageError(first === "robots" ? "\"robots\" needs a subcommand: robots test <file|url>." : "\"llms\" needs a subcommand: llms validate <file|url> or llms generate <url>.");
98
+ return {
99
+ name,
100
+ words: 2
101
+ };
102
+ }
103
+ if (first in COMMANDS) return {
104
+ name: first,
105
+ words: 1
106
+ };
107
+ throw new UsageError(`Unknown command "${first.slice(0, 40)}".`);
108
+ }
109
+ function parseCommand(argv, offersPrivateNetwork = true) {
110
+ const { name, words } = commandOf(argv);
111
+ const spec = COMMANDS[name];
112
+ const specOptions = offersPrivateNetwork ? spec.options : Object.fromEntries(Object.entries(spec.options).filter(([key]) => !(key in PRIVATE)));
113
+ let parsed;
114
+ try {
115
+ parsed = parseArgs({
116
+ args: [...argv.slice(words)],
117
+ options: Object.fromEntries(Object.entries(specOptions).map(([key, option]) => [key, option.short === void 0 ? { type: option.type } : {
118
+ type: option.type,
119
+ short: option.short
120
+ }])),
121
+ allowPositionals: true,
122
+ strict: true
123
+ });
124
+ } catch (error) {
125
+ throw new UsageError(error instanceof Error ? error.message.split("\n")[0].slice(0, 200) : "Invalid arguments.");
126
+ }
127
+ const count = parsed.positionals.length;
128
+ if (parsed.values["help"] === true) return {
129
+ command: name,
130
+ positionals: parsed.positionals,
131
+ values: parsed.values
132
+ };
133
+ if (count < spec.positionals.min) throw new UsageError(`"${name}" needs ${spec.positionals.label}.`);
134
+ if (count > spec.positionals.max) throw new UsageError(`"${name}" takes ${spec.positionals.max === 0 ? "no argument" : `only ${spec.positionals.label}`}.`);
135
+ return {
136
+ command: name,
137
+ positionals: parsed.positionals,
138
+ values: parsed.values
139
+ };
140
+ }
141
+ function failOnOf(parsed) {
142
+ const value = parsed.values["fail-on"];
143
+ if (value === void 0) return "fail";
144
+ if (value === "fail" || value === "warn") return value;
145
+ throw new UsageError("--fail-on takes \"fail\" or \"warn\".");
146
+ }
147
+ var flag = (parsed, name) => parsed.values[name] === true;
148
+ var text = (parsed, name) => {
149
+ const value = parsed.values[name];
150
+ return typeof value === "string" ? value : void 0;
151
+ };
152
+ // src/cli/common.ts
153
+ /** 1 when a finding is at or above the threshold, else 0. */
154
+ function exitForFindings(findings, failOn) {
155
+ return findings.some((finding) => finding.level === "fail" || failOn === "warn" && finding.level === "warn") ? 1 : 0;
156
+ }
157
+ var OUTCOME_TEXT = {
158
+ refused_url: "the address is not one this tool will request",
159
+ refused_address: "the address resolves to a private or reserved network",
160
+ dns_failed: "the host name does not resolve",
161
+ too_many_redirects: "too many redirects",
162
+ redirect_loop: "a redirect loop",
163
+ invalid_redirect: "a redirect without a usable address",
164
+ redirect_refused: "a redirect to another site, which was not followed",
165
+ timeout: "the request timed out",
166
+ budget_exceeded: "the time limit of the run was reached",
167
+ host_stopped: "the host asked this tool to stop (429 or 503)",
168
+ network_error: "the connection failed"
169
+ };
170
+ /** Why a fetch gave no usable answer, in a few plain words. Empty for an answer. */
171
+ function describeFetchFailure(result, offersPrivateNetwork = true) {
172
+ if (result.outcome === "response") return result.status === null ? "no answer" : `status ${result.status}`;
173
+ const text = OUTCOME_TEXT[result.outcome] ?? "no answer";
174
+ return result.outcome === "refused_address" && offersPrivateNetwork ? `${text} (use --allow-private-network for a staging site)` : text;
175
+ }
176
+ /** A name that ends like a file is a file, even where the ending is also a country's domain. */
177
+ var FILE_NAME = /\.(?:txt|md|markdown)$/i;
178
+ var HOST_LIKE = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?:[/?#].*)?$/i;
179
+ /**
180
+ * Tells a file from a site: an http(s) address is a site, a name that is an existing file is a
181
+ * file, and a bare host name (`example.com`) is a site.
182
+ */
183
+ async function classifySource(context, input, maxBytes) {
184
+ if (/^https?:\/\//i.test(input)) return {
185
+ source: {
186
+ kind: "url",
187
+ location: input
188
+ },
189
+ file: null
190
+ };
191
+ try {
192
+ const file = await context.readFile(input, maxBytes);
193
+ return {
194
+ source: {
195
+ kind: "file",
196
+ location: input
197
+ },
198
+ file
199
+ };
200
+ } catch (error) {
201
+ const code = error.code;
202
+ if (code === "ENOENT" && HOST_LIKE.test(input) && !FILE_NAME.test(input)) return {
203
+ source: {
204
+ kind: "url",
205
+ location: `https://${input}`
206
+ },
207
+ file: null
208
+ };
209
+ if (code === "ENOENT") throw new UsageError(`No such file: ${input.slice(0, 200)}`);
210
+ if (code === "EISDIR") throw new UsageError(`${input.slice(0, 200)} is a directory.`);
211
+ if (code === "EACCES" || code === "EPERM") throw new UsageError(`Cannot read ${input.slice(0, 200)}: permission denied.`);
212
+ throw new UsageError(`Cannot read ${input.slice(0, 200)}.`);
213
+ }
214
+ }
215
+ /** The address of a well-known file on a site: the given address, or `/name` at the site's root. */
216
+ function fileAddress(input, name) {
217
+ let url;
218
+ try {
219
+ url = new URL(input);
220
+ } catch {
221
+ throw new UsageError(`Not a web address: ${input.slice(0, 200)}`);
222
+ }
223
+ if (url.pathname === "" || url.pathname === "/") {
224
+ url.pathname = `/${name}`;
225
+ url.search = "";
226
+ }
227
+ url.hash = "";
228
+ return url.href;
229
+ }
230
+ var ROBOTS_READ_LIMIT = FETCH_LIMITS.robots;
231
+ var LLMS_READ_LIMIT = FETCH_LIMITS.llms;
232
+ /** The text of a file read from disk; a byte order mark is left for the parsers to handle. */
233
+ function decodeFile(bytes) {
234
+ return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
235
+ }
236
+ // src/cli/sanitize.ts
237
+ /**
238
+ * Text that came from a site (a title, a heading, a line of its robots.txt) can carry terminal
239
+ * escape sequences and characters that reorder how text is shown. Every string of a result goes
240
+ * through here before it is printed or written as JSON.
241
+ */
242
+ function sanitizeDeep(value) {
243
+ if (typeof value === "string") return stripUnsafeCharacters(value);
244
+ if (Array.isArray(value)) return value.map((item) => sanitizeDeep(item));
245
+ if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeDeep(item)]));
246
+ return value;
247
+ }
248
+ /** One line: whitespace runs, line breaks included, become one space. */
249
+ function oneLine(text) {
250
+ return stripUnsafeCharacters(text).replace(/\s+/g, " ").trim();
251
+ }
252
+ // src/cli/render.ts
253
+ /** The "full report" link every human report ends with. It is printed, never opened. */
254
+ function fullReportUrl(command) {
255
+ return `https://queryrev.com/tools?utm_source=ai-readiness-cli&utm_medium=cli&utm_campaign=full-report&utm_content=${command}`;
256
+ }
257
+ /** What this tool cannot see, said once in the same words wherever it matters. */
258
+ var CANNOT_SEE = "Not seen: a CDN or firewall rule can answer AI crawlers differently from how it answers this tool.";
259
+ var CODES = {
260
+ pass: "32",
261
+ warn: "33",
262
+ fail: "31",
263
+ info: "36"
264
+ };
265
+ /** Colour only on a terminal, and never when NO_COLOR is set or `--no-color` is given. */
266
+ function makeStyle(options) {
267
+ const noColor = options.env["NO_COLOR"] !== void 0 && options.env["NO_COLOR"] !== "";
268
+ const on = options.isTTY && !noColor && !options.noColorFlag && options.env["TERM"] !== "dumb";
269
+ const wrap = (code, text) => on ? `\u001b[${code}m${text}\u001b[0m` : text;
270
+ return {
271
+ bold: (text) => wrap("1", text),
272
+ dim: (text) => wrap("2", text),
273
+ level: (level, text) => wrap(CODES[level], text)
274
+ };
275
+ }
276
+ var LABEL = {
277
+ pass: "PASS",
278
+ warn: "WARN",
279
+ fail: "FAIL",
280
+ info: "INFO"
281
+ };
282
+ function summarize(findings) {
283
+ const counts = {
284
+ pass: 0,
285
+ warn: 0,
286
+ fail: 0,
287
+ info: 0
288
+ };
289
+ for (const finding of findings) counts[finding.level] += 1;
290
+ return counts;
291
+ }
292
+ /** The findings as lines: a label, the id, the sentence, and the evidence under it. */
293
+ function renderFindings(findings, style) {
294
+ const idWidth = Math.min(30, Math.max(0, ...findings.map((finding) => finding.id.length)));
295
+ const lines = [];
296
+ for (const finding of findings) {
297
+ lines.push(` ${style.level(finding.level, LABEL[finding.level])} ${finding.id.padEnd(idWidth)} ${oneLine(finding.message)}`);
298
+ if (finding.snippet !== void 0) for (const line of finding.snippet.split(/\r\n|\r|\n/)) {
299
+ if (line.trim() === "") continue;
300
+ lines.push(` ${style.dim("|")} ${style.dim(line.trim())}`);
301
+ }
302
+ }
303
+ return lines.join("\n");
304
+ }
305
+ function renderSummary(findings) {
306
+ const counts = summarize(findings);
307
+ return `${counts.pass} pass, ${counts.warn} warn, ${counts.fail} fail, ${counts.info} info`;
308
+ }
309
+ /** The covered crawlers as a table: token, vendor, purpose. */
310
+ function renderCrawlerList(style) {
311
+ const width = Math.max(...CRAWLERS.map((crawler) => crawler.token.length));
312
+ const vendorWidth = Math.max(...CRAWLERS.map((crawler) => crawler.vendor.length));
313
+ return [` ${style.bold("Crawler".padEnd(width))} ${style.bold("Vendor".padEnd(vendorWidth))} ${style.bold("Purpose")}`, ...CRAWLERS.map((crawler) => ` ${crawler.token.padEnd(width)} ${crawler.vendor.padEnd(vendorWidth)} ${crawler.purpose}`)].join("\n");
314
+ }
315
+ /** A table with a header row; cells are padded to the widest entry of their column. */
316
+ function renderTable(header, rows, style) {
317
+ const widths = header.map((cell, column) => Math.max(cell.length, ...rows.map((row) => (row[column] ?? "").length)));
318
+ const line = (cells, bold) => ` ${cells.map((cell, column) => {
319
+ const padded = column === cells.length - 1 ? cell : cell.padEnd(widths[column] ?? 0);
320
+ return bold ? style.bold(padded) : padded;
321
+ }).join(" ")}`;
322
+ return [line(header, true), ...rows.map((row) => line(row, false))].join("\n");
323
+ }
324
+ // src/cli/commands/check.ts
325
+ var PRIVATE_NETWORK_HINT = "A staging site on a private network is reached with --allow-private-network.";
326
+ /** A site that gave no robots.txt answer to read, or an address that cannot be checked. */
327
+ function unreadable(site) {
328
+ if (site.findings.some((finding) => finding.id === "site-refused")) return site.findings[0]?.snippet ?? "the address cannot be checked";
329
+ if (site.robots.outcome === "not_read") return site.robots.status === null ? "no answer to the request for robots.txt" : `robots.txt answered ${site.robots.status}`;
330
+ return null;
331
+ }
332
+ async function runCheck(context, parsed, style) {
333
+ const failOn = failOnOf(parsed);
334
+ const fetcher = context.createFetcher({ allowPrivateNetwork: flag(parsed, "allow-private-network") });
335
+ let raw;
336
+ try {
337
+ raw = await checkSite(parsed.positionals[0], { fetcher });
338
+ } finally {
339
+ await fetcher.close();
340
+ }
341
+ const site = sanitizeDeep(raw);
342
+ const why = unreadable(site);
343
+ const exit = why !== null ? 3 : exitForFindings(site.findings, failOn);
344
+ const lines = [style.bold(`AI readiness check: ${site.origin === "" ? parsed.positionals[0] : site.origin}`), ""];
345
+ if (site.crawlerAccess.length > 0) {
346
+ lines.push(style.bold("Crawler access (robots.txt, whole site)"));
347
+ lines.push(renderTable([
348
+ "Crawler",
349
+ "Purpose",
350
+ "Access"
351
+ ], site.crawlerAccess.map((entry) => [
352
+ entry.token,
353
+ entry.purpose,
354
+ entry.access === "partial" ? "partial (some paths closed)" : entry.access
355
+ ]), style));
356
+ lines.push("");
357
+ }
358
+ lines.push(style.bold("Findings"));
359
+ lines.push(renderFindings(site.findings, style));
360
+ lines.push("");
361
+ lines.push(`${renderSummary(site.findings)}`);
362
+ if (why !== null) {
363
+ lines.push("", `The site could not be read: ${why}. Exit code 3.`);
364
+ if (site.robots.status === null && context.offersPrivateNetwork !== false && !flag(parsed, "allow-private-network")) lines.push(PRIVATE_NETWORK_HINT);
365
+ }
366
+ lines.push("", site.crawlersCovered, CANNOT_SEE, "", `Full report: ${fullReportUrl("check")}`);
367
+ return {
368
+ exit,
369
+ json: {
370
+ schemaVersion: 1,
371
+ command: "check",
372
+ exitCode: exit,
373
+ failOn,
374
+ summary: summarize(site.findings),
375
+ ...why === null ? {} : { error: {
376
+ code: "unreadable",
377
+ message: why
378
+ } },
379
+ site
380
+ },
381
+ text: `${lines.join("\n")}\n`
382
+ };
383
+ }
384
+ // src/cli/commands/crawlers.ts
385
+ function runCrawlers(style) {
386
+ return {
387
+ exit: 0,
388
+ json: {
389
+ schemaVersion: 1,
390
+ command: "crawlers",
391
+ exitCode: 0,
392
+ crawlersCovered: crawlersCoveredLine(),
393
+ crawlers: CRAWLERS.map((crawler) => ({
394
+ token: crawler.token,
395
+ vendor: crawler.vendor,
396
+ purpose: crawler.purpose
397
+ }))
398
+ },
399
+ text: `${renderCrawlerList(style)}\n\n${crawlersCoveredLine()}\n`
400
+ };
401
+ }
402
+ // src/facts/origin-robots.ts
403
+ /**
404
+ * robots.txt per origin. An address on a subdomain, or on the other scheme, is a different
405
+ * origin with its own robots.txt: before this tool requests anything there, it reads that
406
+ * origin's file once and asks whether its own token may fetch the path. Answers are kept for
407
+ * the run.
408
+ */
409
+ function createOriginRobots(options) {
410
+ const known = /* @__PURE__ */ new Map();
411
+ const robotsOf = (origin) => {
412
+ const cached = known.get(origin);
413
+ if (cached !== void 0) return cached;
414
+ const url = `${origin}/robots.txt`;
415
+ const read = options.fetcher.fetchText(url, {
416
+ kind: "robots",
417
+ ...options.signal === void 0 ? {} : { signal: options.signal },
418
+ ...options.redirectAllowed === void 0 ? {} : { redirectAllowed: options.redirectAllowed }
419
+ }).then((result) => {
420
+ const { robots } = robotsRead(result, url);
421
+ if (robots !== null) {
422
+ const delay = crawlDelayFor(robots, READER_TOKEN);
423
+ options.fetcher.setCrawlDelay(new URL(origin).hostname, delay === null ? null : Math.min(delay, 10));
424
+ }
425
+ return robots;
426
+ });
427
+ known.set(origin, read);
428
+ return read;
429
+ };
430
+ return {
431
+ robotsOf,
432
+ async check(url) {
433
+ let parsed;
434
+ try {
435
+ parsed = new URL(url);
436
+ } catch {
437
+ return "closed";
438
+ }
439
+ const robots = await robotsOf(parsed.origin);
440
+ if (robots === null) return "unreadable";
441
+ return isAllowedByRobots(robots, "QueryRev-AIReadiness", `${parsed.pathname}${parsed.search}`) ? "allowed" : "closed";
442
+ }
443
+ };
444
+ }
445
+ /** Bytes of each page that are read: the part `pageMeta` looks at. */
446
+ var GENERATE_PAGE_BYTES = PAGE_SCAN_MAX_CHARS;
447
+ /** Addresses taken from the home page when the site lists no sitemap. */
448
+ var HOME_LINKS_MAX = 500;
449
+ /** Pages requested per page listed, at most: skipped pages cost requests too. */
450
+ var ATTEMPT_FACTOR = 2;
451
+ var ASSET_EXTENSION = /\.(?:jpe?g|png|gif|svg|webp|avif|ico|pdf|zip|gz|xml|json|css|m?js|txt|mp[34]|webm|woff2?|ttf|otf|docx?|xlsx?|pptx?|csv|rss|atom)$/i;
452
+ /** The addresses of the `<a href>` links of a page, resolved against `base`; at most `max`. */
453
+ function homeLinks(html, base, max = HOME_LINKS_MAX) {
454
+ const text = html.length > 262144 ? html.slice(0, PAGE_SCAN_MAX_CHARS) : html;
455
+ const out = [];
456
+ const seen = /* @__PURE__ */ new Set();
457
+ let at = 0;
458
+ while (out.length < max) {
459
+ const open = text.indexOf("<a", at);
460
+ if (open === -1) break;
461
+ const next = text[open + 2];
462
+ if (next === void 0 || !(next === " " || next === "\n" || next === " " || next === "\r")) {
463
+ at = open + 2;
464
+ continue;
465
+ }
466
+ const end = text.indexOf(">", open + 2);
467
+ if (end === -1) break;
468
+ at = end + 1;
469
+ const href = attributes(text.slice(open + 2, end)).get("href");
470
+ if (href === void 0 || href.trim() === "") continue;
471
+ let url;
472
+ try {
473
+ url = new URL(stripUnsafeCharacters(href.trim()), base);
474
+ } catch {
475
+ continue;
476
+ }
477
+ if (url.protocol !== "http:" && url.protocol !== "https:") continue;
478
+ url.hash = "";
479
+ if (!seen.has(url.href)) {
480
+ seen.add(url.href);
481
+ out.push(url.href);
482
+ }
483
+ }
484
+ return out;
485
+ }
486
+ function sitemapText(rawBody, text) {
487
+ if (rawBody !== null && rawBody.length > 2 && rawBody[0] === 31 && rawBody[1] === 139) try {
488
+ return gunzipSync(rawBody, { maxOutputLength: FETCH_LIMITS.sitemap }).toString("utf8");
489
+ } catch {
490
+ return null;
491
+ }
492
+ return text;
493
+ }
494
+ function clampPages(value) {
495
+ if (value === void 0 || !Number.isFinite(value)) return 30;
496
+ return Math.min(100, Math.max(1, Math.floor(value)));
497
+ }
498
+ function empty(outcome, origin, host, notes) {
499
+ return Object.freeze({
500
+ schemaVersion: 1,
501
+ outcome,
502
+ origin,
503
+ host,
504
+ text: null,
505
+ site: null,
506
+ pages: Object.freeze([]),
507
+ source: "none",
508
+ skipped: Object.freeze([]),
509
+ budgetReached: false,
510
+ notes: Object.freeze([...notes])
511
+ });
512
+ }
513
+ async function generateLlmsTxt(input, options = {}) {
514
+ const registrable = options.registrableDomain ?? registrableDomain;
515
+ const typed = input.trim();
516
+ const checked = checkTextUrl(/^[a-z][a-z0-9+.-]*:\/\//i.test(typed) ? typed : `https://${typed}`);
517
+ if ("refusal" in checked) return empty("refused", "", "", ["The address cannot be checked: it must be a web site name or an http(s) address."]);
518
+ const origin = checked.url.origin;
519
+ const host = checked.url.hostname;
520
+ const siteDomain = registrable(host);
521
+ const maxPages = clampPages(options.maxPages);
522
+ const owned = options.fetcher === void 0;
523
+ const fetcher = options.fetcher ?? createTextFetcher();
524
+ const budget = AbortSignal.timeout(GENERATE_BUDGET_MS);
525
+ const signal = options.signal === void 0 ? budget : AbortSignal.any([options.signal, budget]);
526
+ const sameSite = (target) => registrable(target.hostname) === siteDomain;
527
+ const notes = [];
528
+ const skipped = [];
529
+ try {
530
+ const origins = createOriginRobots({
531
+ fetcher,
532
+ signal,
533
+ redirectAllowed: sameSite
534
+ });
535
+ const robots = await origins.robotsOf(origin);
536
+ if (robots === null) return empty("robots_unreadable", origin, host, ["robots.txt could not be read, so nothing else was requested."]);
537
+ const allowed = async (url) => await origins.check(url) === "allowed";
538
+ let site = {
539
+ name: host,
540
+ summary: null
541
+ };
542
+ const pages = [];
543
+ const listed = /* @__PURE__ */ new Set();
544
+ let homeHtml = null;
545
+ if (await allowed(`${origin}/`)) {
546
+ const page = readPage(await fetcher.fetchText(`${origin}/`, {
547
+ kind: "page",
548
+ maxBytes: GENERATE_PAGE_BYTES,
549
+ signal,
550
+ redirectAllowed: sameSite
551
+ }), sameSite);
552
+ if (page.kind === "page") {
553
+ homeHtml = page.html;
554
+ const meta = pageMeta(page.html);
555
+ site = {
556
+ name: meta.title ?? meta.h1 ?? host,
557
+ summary: meta.description
558
+ };
559
+ if (!meta.noindex && !page.noindex) {
560
+ pages.push({
561
+ url: page.finalUrl,
562
+ title: meta.title ?? meta.h1 ?? host,
563
+ description: meta.description
564
+ });
565
+ listed.add(page.finalUrl);
566
+ }
567
+ } else notes.push("The home page could not be read, so the site is named by its host.");
568
+ } else notes.push("robots.txt keeps this reader from the home page, so it was not requested.");
569
+ let source = "none";
570
+ let candidates = [];
571
+ const fromSitemaps = await sitemapAddresses(fetcher, robots, allowed, sameSite, signal, notes);
572
+ if (fromSitemaps.length > 0) {
573
+ source = "sitemap";
574
+ candidates = fromSitemaps;
575
+ } else if (homeHtml !== null) {
576
+ source = "home_links";
577
+ candidates = homeLinks(homeHtml, `${origin}/`);
578
+ notes.push("No usable sitemap was found, so the links on the home page were used.");
579
+ }
580
+ candidates = candidates.filter((url) => {
581
+ try {
582
+ const parsed = new URL(url);
583
+ return (parsed.protocol === "http:" || parsed.protocol === "https:") && sameSite(parsed) && !ASSET_EXTENSION.test(parsed.pathname);
584
+ } catch {
585
+ return false;
586
+ }
587
+ });
588
+ let attempts = 0;
589
+ const attemptLimit = Math.min(200, maxPages * ATTEMPT_FACTOR);
590
+ let budgetReached = false;
591
+ for (const candidate of candidates) {
592
+ if (pages.length >= maxPages || attempts >= attemptLimit) break;
593
+ if (signal.aborted) {
594
+ budgetReached = true;
595
+ break;
596
+ }
597
+ if (listed.has(candidate)) continue;
598
+ if (!await allowed(candidate)) {
599
+ skipped.push({
600
+ url: candidate,
601
+ reason: "robots"
602
+ });
603
+ continue;
604
+ }
605
+ attempts += 1;
606
+ const result = await fetcher.fetchText(candidate, {
607
+ kind: "page",
608
+ maxBytes: GENERATE_PAGE_BYTES,
609
+ signal,
610
+ redirectAllowed: sameSite
611
+ });
612
+ if (result.outcome === "budget_exceeded") {
613
+ budgetReached = true;
614
+ break;
615
+ }
616
+ const page = readPage(result, sameSite);
617
+ if (page.kind !== "page") {
618
+ skipped.push({
619
+ url: candidate,
620
+ reason: page.kind
621
+ });
622
+ continue;
623
+ }
624
+ if (listed.has(page.finalUrl)) {
625
+ skipped.push({
626
+ url: candidate,
627
+ reason: "duplicate"
628
+ });
629
+ continue;
630
+ }
631
+ const meta = pageMeta(page.html);
632
+ if (meta.noindex || page.noindex) {
633
+ skipped.push({
634
+ url: candidate,
635
+ reason: "noindex"
636
+ });
637
+ continue;
638
+ }
639
+ listed.add(page.finalUrl);
640
+ pages.push({
641
+ url: page.finalUrl,
642
+ title: meta.title ?? meta.h1 ?? "",
643
+ description: meta.description
644
+ });
645
+ }
646
+ if (budgetReached) notes.push("The time limit of 120 seconds was reached; the file lists the pages read so far.");
647
+ if (pages.length === 0) return Object.freeze({
648
+ ...empty("nothing_found", origin, host, [...notes, "No page could be read from this site."]),
649
+ skipped: Object.freeze(skipped),
650
+ budgetReached
651
+ });
652
+ return Object.freeze({
653
+ schemaVersion: 1,
654
+ outcome: "generated",
655
+ origin,
656
+ host,
657
+ text: renderLlmsTxt(site, pages),
658
+ site,
659
+ pages: Object.freeze(pages),
660
+ source,
661
+ skipped: Object.freeze(skipped),
662
+ budgetReached,
663
+ notes: Object.freeze(notes)
664
+ });
665
+ } finally {
666
+ if (owned) await fetcher.close();
667
+ }
668
+ }
669
+ function readPage(result, sameSite) {
670
+ if (result.outcome === "redirect_refused") return { kind: "off_site" };
671
+ if (result.outcome !== "response" || result.status === null) return { kind: "unreachable" };
672
+ if (result.status !== 200) return { kind: "status" };
673
+ if (result.text === null || result.contentType === null || !(result.contentType === "text/html" || result.contentType === "application/xhtml+xml")) return { kind: "not_html" };
674
+ let final;
675
+ try {
676
+ final = new URL(result.finalUrl);
677
+ } catch {
678
+ return { kind: "unreachable" };
679
+ }
680
+ if (!sameSite(final)) return { kind: "off_site" };
681
+ final.hash = "";
682
+ return {
683
+ kind: "page",
684
+ html: result.text,
685
+ finalUrl: final.href,
686
+ noindex: xRobotsNoindex(result.headers["x-robots-tag"])
687
+ };
688
+ }
689
+ /** The page addresses of the site's sitemaps; empty when there are none to read. */
690
+ async function sitemapAddresses(fetcher, robots, allowed, sameSite, signal, notes) {
691
+ const queue = [];
692
+ for (const line of robots.sitemaps) try {
693
+ const url = new URL(line);
694
+ if ((url.protocol === "http:" || url.protocol === "https:") && sameSite(url)) queue.push(url.href);
695
+ } catch {}
696
+ const pages = [];
697
+ const read = /* @__PURE__ */ new Set();
698
+ let files = 0;
699
+ while (queue.length > 0 && files < 5 && !signal.aborted) {
700
+ const url = queue.shift();
701
+ if (read.has(url)) continue;
702
+ read.add(url);
703
+ if (!await allowed(url)) continue;
704
+ files += 1;
705
+ const result = await fetcher.fetchText(url, {
706
+ kind: "sitemap",
707
+ signal,
708
+ redirectAllowed: sameSite
709
+ });
710
+ if (result.outcome !== "response" || result.status === null || result.status < 200 || result.status >= 300) continue;
711
+ const body = sitemapText(result.rawBody, result.text);
712
+ if (body === null) continue;
713
+ const found = sitemapLocations(body, 5e3);
714
+ if (found.kind === "index") for (const location of found.locations) try {
715
+ const child = new URL(location);
716
+ if (sameSite(child)) queue.push(child.href);
717
+ } catch {}
718
+ else if (found.kind === "urlset") pages.push(...found.locations);
719
+ }
720
+ if (queue.length > 0 && files >= 5) notes.push(`Only the first 5 sitemap files were read.`);
721
+ return pages;
722
+ }
723
+ // src/cli/commands/llms-generate.ts
724
+ function pagesOption(value) {
725
+ if (value === void 0) return 30;
726
+ if (!/^\d{1,4}$/.test(value)) throw new UsageError(`--max-pages takes a whole number from 1 to 100.`);
727
+ const count = Number(value);
728
+ if (count < 1 || count > 100) throw new UsageError(`--max-pages takes a whole number from 1 to 100.`);
729
+ return count;
730
+ }
731
+ var SOURCE_TEXT = {
732
+ sitemap: "the sitemap",
733
+ home_links: "the links on the home page",
734
+ none: "no source"
735
+ };
736
+ var SKIPPED_TEXT = {
737
+ robots: "closed to this reader by robots.txt",
738
+ noindex: "marked noindex",
739
+ not_html: "not an HTML page",
740
+ status: "not a 200 answer",
741
+ off_site: "redirected to another site",
742
+ unreachable: "no answer",
743
+ duplicate: "the same page as one listed"
744
+ };
745
+ function skippedSummary(result) {
746
+ const counts = /* @__PURE__ */ new Map();
747
+ for (const entry of result.skipped) counts.set(entry.reason, (counts.get(entry.reason) ?? 0) + 1);
748
+ return [...counts].map(([reason, count]) => `${count} ${SKIPPED_TEXT[reason]}`);
749
+ }
750
+ async function runLlmsGenerate(context, parsed, style) {
751
+ const maxPages = pagesOption(text(parsed, "max-pages"));
752
+ const out = text(parsed, "out");
753
+ const force = flag(parsed, "force");
754
+ if (out !== void 0) {
755
+ if (out === "") throw new UsageError("--out needs a file name.");
756
+ if (!force) {
757
+ let exists = true;
758
+ try {
759
+ await context.readFile(out, 1);
760
+ } catch (error) {
761
+ exists = error.code !== "ENOENT";
762
+ }
763
+ if (exists) throw new UsageError(`${out.slice(0, 200)} already exists (or cannot be checked). Use --force to replace it.`);
764
+ }
765
+ }
766
+ const fetcher = context.createFetcher({ allowPrivateNetwork: flag(parsed, "allow-private-network") });
767
+ let raw;
768
+ try {
769
+ raw = await generateLlmsTxt(parsed.positionals[0], {
770
+ maxPages,
771
+ fetcher
772
+ });
773
+ } finally {
774
+ await fetcher.close();
775
+ }
776
+ const result = sanitizeDeep(raw);
777
+ const exit = result.outcome === "generated" ? 0 : 3;
778
+ if (result.text !== null && out !== void 0) await context.writeFile(out, result.text, force);
779
+ const summary = [];
780
+ if (result.outcome === "generated") {
781
+ const skipped = skippedSummary(result);
782
+ summary.push(style.bold(`Generated an llms.txt for ${result.origin}: ${result.pages.length} page${result.pages.length === 1 ? "" : "s"} from ${SOURCE_TEXT[result.source]}.`));
783
+ if (skipped.length > 0) summary.push(`Left out: ${skipped.join(", ")}.`);
784
+ if (out !== void 0) summary.push(`Wrote ${out}.`);
785
+ } else summary.push(style.bold(`No llms.txt was generated for ${result.origin === "" ? parsed.positionals[0] : result.origin}.`));
786
+ for (const note of result.notes) summary.push(oneLine(note));
787
+ if (result.outcome === "generated") summary.push("Titles and descriptions are the pages' own words. Read the file and edit it before you publish it.");
788
+ else {
789
+ summary.push("Exit code 3.");
790
+ if (result.outcome === "robots_unreadable" && context.offersPrivateNetwork !== false && !flag(parsed, "allow-private-network")) summary.push("A staging site on a private network is reached with --allow-private-network.");
791
+ }
792
+ summary.push("", crawlersCoveredLine(), "", `Full report: ${fullReportUrl("llms-generate")}`);
793
+ const json = {
794
+ schemaVersion: 1,
795
+ command: "llms-generate",
796
+ exitCode: exit,
797
+ crawlersCovered: crawlersCoveredLine(),
798
+ ...exit === 3 ? { error: {
799
+ code: "unreadable",
800
+ message: result.notes.join(" ")
801
+ } } : {},
802
+ ...out === void 0 ? {} : { out },
803
+ result
804
+ };
805
+ if (out !== void 0 || result.text === null) return {
806
+ exit,
807
+ json,
808
+ text: `${summary.join("\n")}\n`
809
+ };
810
+ return {
811
+ exit,
812
+ json,
813
+ text: result.text,
814
+ textErr: `${summary.join("\n")}\n`
815
+ };
816
+ }
817
+ /** Bytes of each linked page that are read. */
818
+ var LLMS_LINK_BODY_BYTES = 65536;
819
+ /** The distinct http(s) addresses among the targets, resolved against `base`, fragments removed. */
820
+ function resolveTargets(targets, base, sameSite) {
821
+ const seen = /* @__PURE__ */ new Set();
822
+ const onSite = [];
823
+ let skipped = 0;
824
+ for (const target of targets) {
825
+ let url;
826
+ try {
827
+ url = new URL(target, base);
828
+ } catch {
829
+ skipped += 1;
830
+ continue;
831
+ }
832
+ url.hash = "";
833
+ if (url.protocol !== "http:" && url.protocol !== "https:" || !sameSite(url)) {
834
+ skipped += 1;
835
+ continue;
836
+ }
837
+ if (seen.has(url.href)) continue;
838
+ seen.add(url.href);
839
+ onSite.push(url.href);
840
+ }
841
+ return {
842
+ onSite,
843
+ skipped
844
+ };
845
+ }
846
+ async function checkLlmsLinks(targets, options) {
847
+ const registrable = options.registrableDomain ?? registrableDomain;
848
+ const base = new URL(options.base);
849
+ const siteDomain = registrable(base.hostname);
850
+ const { onSite, skipped } = resolveTargets(targets, base, (url) => registrable(url.hostname) === siteDomain);
851
+ const chosen = onSite.slice(0, 20);
852
+ const beyondLimit = onSite.length - chosen.length;
853
+ if (chosen.length === 0) return {
854
+ skipped,
855
+ beyondLimit,
856
+ robotsRead: true,
857
+ results: []
858
+ };
859
+ const budget = AbortSignal.timeout(CHECK_BUDGET_MS);
860
+ const signal = options.signal === void 0 ? budget : AbortSignal.any([options.signal, budget]);
861
+ const sameSite = (target) => registrable(target.hostname) === siteDomain;
862
+ const origins = createOriginRobots({
863
+ fetcher: options.fetcher,
864
+ signal,
865
+ redirectAllowed: sameSite
866
+ });
867
+ if (await origins.robotsOf(base.origin) === null) return {
868
+ skipped,
869
+ beyondLimit,
870
+ robotsRead: false,
871
+ results: []
872
+ };
873
+ const results = [];
874
+ let outOfTime = false;
875
+ for (const url of chosen) {
876
+ if (outOfTime || signal.aborted) {
877
+ outOfTime = true;
878
+ results.push({
879
+ url,
880
+ outcome: "not_checked",
881
+ status: null
882
+ });
883
+ continue;
884
+ }
885
+ const access = await origins.check(url);
886
+ if (signal.aborted) {
887
+ outOfTime = true;
888
+ results.push({
889
+ url,
890
+ outcome: "not_checked",
891
+ status: null
892
+ });
893
+ continue;
894
+ }
895
+ if (access !== "allowed") {
896
+ results.push({
897
+ url,
898
+ outcome: access === "closed" ? "not_allowed" : "robots_unreadable",
899
+ status: null
900
+ });
901
+ continue;
902
+ }
903
+ const result = await options.fetcher.fetchText(url, {
904
+ kind: "page",
905
+ maxBytes: LLMS_LINK_BODY_BYTES,
906
+ signal,
907
+ redirectAllowed: sameSite
908
+ });
909
+ if (result.outcome === "budget_exceeded") {
910
+ outOfTime = true;
911
+ results.push({
912
+ url,
913
+ outcome: "not_checked",
914
+ status: null
915
+ });
916
+ continue;
917
+ }
918
+ const status = result.outcome === "response" ? result.status : null;
919
+ const outcome = status === null ? "unreachable" : status >= 200 && status < 300 ? "ok" : status === 404 || status === 410 ? "missing" : "error_status";
920
+ results.push({
921
+ url,
922
+ outcome,
923
+ status
924
+ });
925
+ }
926
+ return {
927
+ skipped,
928
+ beyondLimit,
929
+ robotsRead: true,
930
+ results
931
+ };
932
+ }
933
+ /** The findings of a link check. One finding for the set; the evidence lists what is wrong. */
934
+ function llmsLinkFindings(report) {
935
+ if (!report.robotsRead) return [llmsFinding("llms-txt-links", "warn", "robots.txt could not be read, so no link was requested.")];
936
+ if (report.results.length === 0) return [llmsFinding("llms-txt-links", "info", "The file has no link on this site to check.")];
937
+ const notRequested = /* @__PURE__ */ new Set([
938
+ "not_allowed",
939
+ "robots_unreadable",
940
+ "not_checked"
941
+ ]);
942
+ const checked = report.results.filter((entry) => !notRequested.has(entry.outcome));
943
+ const missing = checked.filter((entry) => entry.outcome === "missing");
944
+ const other = checked.filter((entry) => entry.outcome === "error_status" || entry.outcome === "unreachable");
945
+ const notAllowed = report.results.filter((entry) => entry.outcome === "not_allowed" || entry.outcome === "robots_unreadable").length;
946
+ const notChecked = report.results.filter((entry) => entry.outcome === "not_checked").length;
947
+ const list = (entries) => entries.slice(0, 5).map((entry) => `${entry.status ?? "no answer"} ${entry.url}`).join("\n");
948
+ const tail = [
949
+ notAllowed > 0 ? `${notAllowed} not requested (robots.txt)` : "",
950
+ notChecked > 0 ? `${notChecked} not checked (time budget)` : "",
951
+ report.beyondLimit > 0 ? `${report.beyondLimit} beyond the first 20` : ""
952
+ ].filter((part) => part !== "").join("; ");
953
+ const suffix = tail === "" ? "" : ` (${tail})`;
954
+ if (checked.length === 0) return [llmsFinding("llms-txt-links", notChecked > 0 ? "warn" : "info", `No link was requested${suffix}.`)];
955
+ if (missing.length > 0) return [llmsFinding("llms-txt-links", "fail", `${missing.length} of ${checked.length} checked links answer 404 or 410${suffix}.`, list(missing))];
956
+ if (other.length > 0) return [llmsFinding("llms-txt-links", "warn", `${other.length} of ${checked.length} checked links did not answer with a 2xx${suffix}.`, list(other))];
957
+ return [llmsFinding("llms-txt-links", "pass", `All ${checked.length} checked link${checked.length === 1 ? "" : "s"} on this site answer 2xx${suffix}.`)];
958
+ }
959
+ // src/cli/commands/llms-validate.ts
960
+ function reportJson(report) {
961
+ return {
962
+ present: report.present,
963
+ verdict: report.verdict,
964
+ status: report.status,
965
+ contentType: report.contentType,
966
+ bytes: report.bytes,
967
+ truncated: report.truncated,
968
+ h1: report.h1,
969
+ h1Count: report.h1Count,
970
+ structure: report.structure === null ? null : {
971
+ summary: report.structure.summary,
972
+ sections: report.structure.sections,
973
+ links: report.structure.links,
974
+ structured: report.structure.structured
975
+ }
976
+ };
977
+ }
978
+ async function runLlmsValidate(context, parsed, style) {
979
+ const failOn = failOnOf(parsed);
980
+ const wantLinks = flag(parsed, "links");
981
+ const baseOption = text(parsed, "base");
982
+ let base = null;
983
+ if (baseOption !== void 0) {
984
+ const checked = checkTextUrl(baseOption);
985
+ if ("refusal" in checked) throw new UsageError("--base takes the site address, such as https://example.com.");
986
+ base = checked.url.origin;
987
+ }
988
+ const { source, file } = await classifySource(context, parsed.positionals[0], LLMS_READ_LIMIT);
989
+ if (source.kind === "file" && wantLinks && base === null) throw new UsageError("--links on a file needs --base <site address> to resolve and limit the links.");
990
+ let report;
991
+ let location = source.location;
992
+ const fetcher = context.createFetcher({ allowPrivateNetwork: flag(parsed, "allow-private-network") });
993
+ try {
994
+ if (source.kind === "file" && file !== null) report = validateLlmsTxt({
995
+ status: 200,
996
+ contentType: "text/plain",
997
+ body: decodeFile(file.bytes),
998
+ truncated: file.truncated
999
+ });
1000
+ else {
1001
+ const address = fileAddress(source.location, "llms.txt");
1002
+ location = address;
1003
+ const signal = AbortSignal.timeout(CHECK_BUDGET_MS);
1004
+ const result = await fetcher.fetchText(address, {
1005
+ kind: "llms",
1006
+ signal
1007
+ });
1008
+ if (result.outcome !== "response") {
1009
+ const message = `${address} could not be read: ${describeFetchFailure(result, context.offersPrivateNetwork !== false)}`;
1010
+ return {
1011
+ exit: 3,
1012
+ json: sanitizeDeep({
1013
+ schemaVersion: 1,
1014
+ command: "llms-validate",
1015
+ exitCode: 3,
1016
+ source: {
1017
+ kind: "url",
1018
+ location: address
1019
+ },
1020
+ error: {
1021
+ code: "unreadable",
1022
+ message
1023
+ }
1024
+ }),
1025
+ text: `${oneLine(message)}\nExit code 3.\n`
1026
+ };
1027
+ }
1028
+ report = validateLlmsTxt({
1029
+ status: result.status,
1030
+ contentType: result.contentType,
1031
+ body: result.text,
1032
+ truncated: result.truncated
1033
+ });
1034
+ if (base === null) base = new URL(result.finalUrl).origin;
1035
+ }
1036
+ const findings = llmsFindings(report);
1037
+ let links = null;
1038
+ if (wantLinks && report.structure !== null && base !== null) {
1039
+ links = await checkLlmsLinks(report.structure.targets, {
1040
+ fetcher,
1041
+ base,
1042
+ signal: AbortSignal.timeout(CHECK_BUDGET_MS)
1043
+ });
1044
+ findings.push(...llmsLinkFindings(links));
1045
+ }
1046
+ const exit = exitForFindings(findings, failOn);
1047
+ const safe = sanitizeDeep({
1048
+ findings,
1049
+ report: reportJson(report),
1050
+ links,
1051
+ source: {
1052
+ kind: source.kind,
1053
+ location
1054
+ }
1055
+ });
1056
+ const lines = [
1057
+ style.bold(`llms.txt validation: ${safe.source.location}`),
1058
+ "",
1059
+ renderFindings(safe.findings, style)
1060
+ ];
1061
+ if (safe.links !== null && safe.links.results.length > 0) lines.push("", style.bold("Links checked"), renderTable([
1062
+ "Result",
1063
+ "Status",
1064
+ "Address"
1065
+ ], safe.links.results.map((entry) => [
1066
+ entry.outcome === "ok" ? "OK" : entry.outcome.replace("_", " "),
1067
+ entry.status === null ? "-" : String(entry.status),
1068
+ entry.url
1069
+ ]), style));
1070
+ lines.push("", renderSummary(findings), "", crawlersCoveredLine(), "", `Full report: ${fullReportUrl("llms-validate")}`);
1071
+ return {
1072
+ exit,
1073
+ json: {
1074
+ schemaVersion: 1,
1075
+ command: "llms-validate",
1076
+ exitCode: exit,
1077
+ failOn,
1078
+ crawlersCovered: crawlersCoveredLine(),
1079
+ source: safe.source,
1080
+ report: safe.report,
1081
+ ...safe.links === null ? {} : { links: safe.links },
1082
+ findings: safe.findings
1083
+ },
1084
+ text: `${lines.join("\n")}\n`
1085
+ };
1086
+ } finally {
1087
+ await fetcher.close();
1088
+ }
1089
+ }
1090
+ // src/cli/finding.ts
1091
+ var DOC_BASE = "https://queryrev.com/research/ai-search-readiness#";
1092
+ /** A finding of the command line's own checks (a robots.txt test); the evidence is cut short. */
1093
+ function cliFinding(id, level, message, snippet, anchor = "readable-by-ai-search") {
1094
+ const evidence = stripUnsafeCharacters(snippet ?? "").slice(0, 300);
1095
+ const docUrl = `${DOC_BASE}${anchor}`;
1096
+ return Object.freeze(evidence === "" ? {
1097
+ id,
1098
+ level,
1099
+ message: stripUnsafeCharacters(message),
1100
+ docUrl
1101
+ } : {
1102
+ id,
1103
+ level,
1104
+ message: stripUnsafeCharacters(message),
1105
+ docUrl,
1106
+ snippet: evidence
1107
+ });
1108
+ }
1109
+ // src/cli/commands/robots-test.ts
1110
+ var AGENT_TOKEN = /^[A-Za-z0-9._*-]{1,64}$/;
1111
+ var PATH_MAX = 2048;
1112
+ /** Edit distance, for "did you mean" on a mistyped crawler token. Both strings are short. */
1113
+ function distance(a, b) {
1114
+ const row = Array.from({ length: b.length + 1 }, (_, index) => index);
1115
+ for (let i = 1; i <= a.length; i += 1) {
1116
+ let previous = row[0];
1117
+ row[0] = i;
1118
+ for (let j = 1; j <= b.length; j += 1) {
1119
+ const above = row[j];
1120
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
1121
+ previous = above;
1122
+ }
1123
+ }
1124
+ return row[b.length];
1125
+ }
1126
+ /** The covered crawler a typed token is probably meant to be, if it is not one. */
1127
+ function suggestion(token) {
1128
+ const lower = token.toLowerCase();
1129
+ if (CRAWLERS.some((crawler) => crawler.token.toLowerCase() === lower)) return null;
1130
+ let best = null;
1131
+ for (const crawler of CRAWLERS) {
1132
+ const score = distance(lower, crawler.token.toLowerCase());
1133
+ if (score <= 3 && (best === null || score < best.score)) best = {
1134
+ token: crawler.token,
1135
+ score
1136
+ };
1137
+ }
1138
+ return best?.token ?? null;
1139
+ }
1140
+ function describeMatch(match) {
1141
+ const group = match.via === "none" ? "no group applies" : match.via === "named" ? `the group for this agent (line ${match.group?.line ?? 0})` : `the "*" group (line ${match.group?.line ?? 0})`;
1142
+ if (match.rule === null) return {
1143
+ sentence: match.via === "none" ? "no group applies, so nothing is closed" : `${group}, and no rule matches`,
1144
+ evidence: match.group === null ? "no group applies" : `${match.via === "named" ? "group for this agent" : "\"*\" group"} at line ${match.group.line}, no rule matches`
1145
+ };
1146
+ const rule = `${match.rule.allow ? "Allow" : "Disallow"}: ${match.rule.pattern}`;
1147
+ return {
1148
+ sentence: `${group}, decided by line ${match.rule.line} (${rule})`,
1149
+ evidence: `line ${match.rule.line}: ${rule}`
1150
+ };
1151
+ }
1152
+ function flatMatch(match) {
1153
+ return {
1154
+ allowed: match.allowed,
1155
+ via: match.via,
1156
+ group: match.group === null ? null : {
1157
+ line: match.group.line,
1158
+ agents: [...match.group.agents]
1159
+ },
1160
+ rule: match.rule === null ? null : {
1161
+ type: match.rule.allow ? "allow" : "disallow",
1162
+ pattern: match.rule.pattern,
1163
+ line: match.rule.line
1164
+ }
1165
+ };
1166
+ }
1167
+ async function load(context, parsed) {
1168
+ const { source, file } = await classifySource(context, parsed.positionals[0], ROBOTS_READ_LIMIT);
1169
+ const notes = [];
1170
+ if (source.kind === "file" && file !== null) {
1171
+ const body = decodeFile(file.bytes);
1172
+ if (looksLikeHtml(null, body)) throw new UsageError(`${source.location.slice(0, 200)} is an HTML page, not a robots.txt.`);
1173
+ if (file.truncated) notes.push(cliFinding("robots-file-cut", "info", "The file is larger than 500 KiB; it was read as far as that limit."));
1174
+ return {
1175
+ robots: parseRobots(body),
1176
+ source,
1177
+ notes
1178
+ };
1179
+ }
1180
+ const address = fileAddress(source.location, "robots.txt");
1181
+ const fetcher = context.createFetcher({ allowPrivateNetwork: flag(parsed, "allow-private-network") });
1182
+ try {
1183
+ const result = await fetcher.fetchText(address, {
1184
+ kind: "robots",
1185
+ signal: AbortSignal.timeout(CHECK_BUDGET_MS)
1186
+ });
1187
+ const { read, robots } = robotsRead(result, address);
1188
+ if (robots === null) return {
1189
+ unreadable: `${address}: ${describeFetchFailure(result, context.offersPrivateNetwork !== false)}`,
1190
+ source
1191
+ };
1192
+ if (read.outcome === "missing") notes.push(cliFinding("robots-missing", "info", `There is no robots.txt at ${address} (status ${read.status ?? 0}), so nothing is closed.`));
1193
+ if (read.truncated) notes.push(cliFinding("robots-file-cut", "info", "The file is larger than 500 KiB; it was read as far as that limit."));
1194
+ return {
1195
+ robots,
1196
+ source: {
1197
+ kind: "url",
1198
+ location: address
1199
+ },
1200
+ notes
1201
+ };
1202
+ } finally {
1203
+ await fetcher.close();
1204
+ }
1205
+ }
1206
+ async function runRobotsTest(context, parsed, style) {
1207
+ const failOn = failOnOf(parsed);
1208
+ const agent = text(parsed, "agent");
1209
+ const path = text(parsed, "path") ?? "/";
1210
+ if (agent !== void 0 && !AGENT_TOKEN.test(agent)) throw new UsageError("--agent takes a crawler token such as OAI-SearchBot (letters, digits, . _ - *).");
1211
+ if (!path.startsWith("/") || path.length > PATH_MAX || [...path].some((char) => char <= " " || char === "")) throw new UsageError("--path must start with \"/\" and hold no spaces or control characters.");
1212
+ const loaded = await load(context, parsed);
1213
+ if ("unreadable" in loaded) {
1214
+ const message = `robots.txt could not be read: ${loaded.unreadable}`;
1215
+ const exit = 3;
1216
+ return {
1217
+ exit,
1218
+ json: sanitizeDeep({
1219
+ schemaVersion: 1,
1220
+ command: "robots-test",
1221
+ exitCode: exit,
1222
+ source: loaded.source,
1223
+ error: {
1224
+ code: "unreadable",
1225
+ message
1226
+ }
1227
+ }),
1228
+ text: `${oneLine(message)}\nExit code 3.\n`
1229
+ };
1230
+ }
1231
+ const { robots, source, notes } = loaded;
1232
+ const findings = [...notes];
1233
+ if (robots.groups.length === 0) findings.push(cliFinding("robots-groups", "info", "The file has no User-agent group, so nothing is closed."));
1234
+ const lines = [
1235
+ style.bold(`robots.txt test: ${source.location}`),
1236
+ `Path: ${path}`,
1237
+ ""
1238
+ ];
1239
+ let detail;
1240
+ let covered = null;
1241
+ if (agent !== void 0) {
1242
+ const match = matchRobots(robots, agent, path);
1243
+ const { sentence, evidence } = describeMatch(match);
1244
+ const level = match.allowed ? "pass" : "fail";
1245
+ findings.push(cliFinding("robots-access", level, `${agent} ${match.allowed ? "may" : "may not"} fetch ${path}: ${sentence}.`, evidence));
1246
+ const near = suggestion(agent);
1247
+ if (near !== null) findings.push(cliFinding("robots-agent-unlisted", "info", `${agent} is not one of the crawlers this tool covers. Did you mean ${near}? The rules are tested for any token you type.`));
1248
+ else if (!CRAWLERS.some((crawler) => crawler.token.toLowerCase() === agent.toLowerCase())) findings.push(cliFinding("robots-agent-unlisted", "info", `${agent} is not one of the crawlers this tool covers; the rules are tested for any token you type.`));
1249
+ lines.push(`Agent: ${agent}`, "", renderFindings(findings, style));
1250
+ detail = {
1251
+ agent,
1252
+ match: flatMatch(match)
1253
+ };
1254
+ } else {
1255
+ const rows = [];
1256
+ const entries = crawlerAccess(robots, { path }).map((entry) => {
1257
+ const match = entry.path ?? entry.root;
1258
+ const training = entry.crawler.purpose === "training";
1259
+ const level = !match.allowed ? training ? "info" : "fail" : !training && path === "/" && entry.access === "partial" ? "warn" : "pass";
1260
+ const { sentence, evidence } = describeMatch(match);
1261
+ findings.push(cliFinding(`robots-access-${entry.crawler.token.toLowerCase()}`, level, `${entry.crawler.token} ${match.allowed ? "may" : "may not"} fetch ${path}: ${sentence}.${level === "warn" ? " Parts of the site are closed to it." : ""}`, evidence));
1262
+ rows.push([
1263
+ level.toUpperCase(),
1264
+ entry.crawler.token,
1265
+ entry.crawler.purpose,
1266
+ match.allowed ? "allowed" : "blocked",
1267
+ evidence
1268
+ ]);
1269
+ return {
1270
+ token: entry.crawler.token,
1271
+ purpose: entry.crawler.purpose,
1272
+ vendor: entry.crawler.vendor,
1273
+ siteAccess: entry.access,
1274
+ match: flatMatch(match)
1275
+ };
1276
+ });
1277
+ lines.push(renderTable([
1278
+ "Level",
1279
+ "Crawler",
1280
+ "Purpose",
1281
+ "Access",
1282
+ "Decided by"
1283
+ ], rows, style));
1284
+ const other = findings.filter((finding) => !finding.id.startsWith("robots-access-"));
1285
+ if (other.length > 0) lines.push("", renderFindings(other, style));
1286
+ detail = {
1287
+ crawlersCovered: crawlersCoveredLine(),
1288
+ crawlers: entries
1289
+ };
1290
+ covered = crawlersCoveredLine();
1291
+ }
1292
+ const exit = exitForFindings(findings, failOn);
1293
+ const safe = sanitizeDeep({
1294
+ findings,
1295
+ detail,
1296
+ source
1297
+ });
1298
+ lines.push("", renderSummary(findings));
1299
+ if (covered !== null) lines.push("", covered);
1300
+ lines.push("", `Full report: ${fullReportUrl("robots-test")}`);
1301
+ return {
1302
+ exit,
1303
+ json: {
1304
+ schemaVersion: 1,
1305
+ command: "robots-test",
1306
+ exitCode: exit,
1307
+ failOn,
1308
+ path,
1309
+ source: safe.source,
1310
+ ...safe.detail,
1311
+ findings: safe.findings
1312
+ },
1313
+ text: `${lines.join("\n")}\n`
1314
+ };
1315
+ }
1316
+ // src/cli/usage.ts
1317
+ var USAGE = `ai-readiness: checks how a site's public files treat AI crawlers.
1318
+
1319
+ Usage: ai-readiness <command> [arguments] [options]
1320
+
1321
+ Commands
1322
+ check <url> robots.txt access of the covered AI crawlers, llms.txt, the
1323
+ home page as served, structured data and the sitemap
1324
+ robots test <file|url> which robots.txt line decides for a crawler and a path
1325
+ --agent <token> one crawler token (any token); all covered crawlers without it
1326
+ --path <path> the path to test, default /
1327
+ llms validate <file|url> checks an llms.txt against llmstxt.org
1328
+ --links also request up to 20 links on the same site
1329
+ --base <url> the site of a local file's links (needed with --links)
1330
+ llms generate <url> builds the skeleton of an llms.txt from the site's own pages
1331
+ --max-pages <n> pages to list, 1 to 100, default 30
1332
+ --out <file> write the file here (--force replaces an existing file)
1333
+ crawlers the AI crawlers covered, with vendor and purpose
1334
+
1335
+ Options
1336
+ --json one JSON document with schemaVersion 1, no colour, no other output
1337
+ --fail-on <level> exit 1 for findings at "fail" (default) or at "warn" (check, robots test, llms validate)
1338
+ --allow-private-network
1339
+ reach a staging site on a private network (never cloud metadata addresses)
1340
+ --no-color no colour (also off without a terminal, or when NO_COLOR is set)
1341
+ -h, --help this text
1342
+ -v, --version the version
1343
+
1344
+ Exit codes
1345
+ 0 nothing at or above --fail-on
1346
+ 1 checks failed
1347
+ 2 usage error
1348
+ 3 the site could not be read (refused, unreachable, timed out)
1349
+ 4 internal error
1350
+
1351
+ Reads public files only. Sends nothing to queryrev.com.
1352
+ `;
1353
+ // src/cli/main.ts
1354
+ var CLI_VERSION = {
1355
+ name: "@queryrev/ai-readiness",
1356
+ version: "1.0.0",
1357
+ description: "robots.txt parsing, AI crawler access verdicts, llms.txt checks and a guarded network layer. No model calls.",
1358
+ license: "MIT",
1359
+ author: "QueryRev",
1360
+ homepage: "https://queryrev.com/research/ai-search-readiness",
1361
+ repository: {
1362
+ "type": "git",
1363
+ "url": "git+https://github.com/QueryRevAI/ai-readiness.git"
1364
+ },
1365
+ bugs: { "url": "https://github.com/QueryRevAI/ai-readiness/issues" },
1366
+ keywords: [
1367
+ "robots.txt",
1368
+ "llms.txt",
1369
+ "ai-crawlers",
1370
+ "geo",
1371
+ "aeo",
1372
+ "seo",
1373
+ "cli"
1374
+ ],
1375
+ type: "module",
1376
+ engines: { "node": ">=22.19" },
1377
+ sideEffects: false,
1378
+ main: "./dist/index.js",
1379
+ types: "./dist/index.d.ts",
1380
+ bin: { "ai-readiness": "./dist/cli.js" },
1381
+ exports: {
1382
+ ".": {
1383
+ "types": "./dist/index.d.ts",
1384
+ "default": "./dist/index.js"
1385
+ },
1386
+ "./cli": {
1387
+ "types": "./dist/net/cli.d.ts",
1388
+ "default": "./dist/net-cli.js"
1389
+ },
1390
+ "./run": {
1391
+ "types": "./dist/cli/run.d.ts",
1392
+ "default": "./dist/run.js"
1393
+ },
1394
+ "./testing": {
1395
+ "types": "./dist/net/testing/index.d.ts",
1396
+ "default": "./dist/testing.js"
1397
+ },
1398
+ "./package.json": "./package.json"
1399
+ },
1400
+ files: [
1401
+ "dist",
1402
+ "data",
1403
+ "README.md",
1404
+ "LICENSE",
1405
+ "CITATION.cff",
1406
+ "SECURITY.md"
1407
+ ],
1408
+ publishConfig: { "access": "public" },
1409
+ scripts: {
1410
+ "build": "vite build --config vite.build.config.ts && node scripts/build-types.mjs",
1411
+ "prepublishOnly": "node scripts/check-publishable.mjs && npm run build",
1412
+ "typecheck": "tsc --noEmit",
1413
+ "test": "vitest run"
1414
+ },
1415
+ dependencies: { "undici": "^8.12.3" },
1416
+ devDependencies: {
1417
+ "@types/node": "^24.0.0",
1418
+ "eslint": "^9.39.5",
1419
+ "typescript": "~6.0.3",
1420
+ "vite": "^8.3.4",
1421
+ "vitest": "^5.0.3"
1422
+ }
1423
+ }.version;
1424
+ async function dispatch(context, parsed, style) {
1425
+ switch (parsed.command) {
1426
+ case "check": return runCheck(context, parsed, style);
1427
+ case "robots test": return runRobotsTest(context, parsed, style);
1428
+ case "llms validate": return runLlmsValidate(context, parsed, style);
1429
+ case "llms generate": return runLlmsGenerate(context, parsed, style);
1430
+ case "crawlers": return runCrawlers(style);
1431
+ }
1432
+ }
1433
+ function errorDocument(code, message, exit) {
1434
+ return `${JSON.stringify({
1435
+ schemaVersion: 1,
1436
+ exitCode: exit,
1437
+ error: {
1438
+ code,
1439
+ message
1440
+ }
1441
+ }, null, 2)}\n`;
1442
+ }
1443
+ /**
1444
+ * Runs the command line. Returns the exit code and sets no global state: output goes through the
1445
+ * context, so a test can run it with a fixture fetcher and read what it printed.
1446
+ */
1447
+ async function runCli(argv, context) {
1448
+ const json = argv.includes("--json");
1449
+ try {
1450
+ if (argv.length === 0) {
1451
+ context.stderr.write(USAGE);
1452
+ return 2;
1453
+ }
1454
+ if (argv[0] === "--help" || argv[0] === "-h" || argv[0] === "help") {
1455
+ context.stdout.write(USAGE);
1456
+ return 0;
1457
+ }
1458
+ if (argv[0] === "--version" || argv[0] === "-v") {
1459
+ context.stdout.write(`${CLI_VERSION}\n`);
1460
+ return 0;
1461
+ }
1462
+ const parsed = parseCommand(argv, context.offersPrivateNetwork !== false);
1463
+ if (flag(parsed, "help")) {
1464
+ context.stdout.write(USAGE);
1465
+ return 0;
1466
+ }
1467
+ const result = await dispatch(context, parsed, makeStyle({
1468
+ isTTY: context.stdout.isTTY === true && !flag(parsed, "json"),
1469
+ env: context.env,
1470
+ noColorFlag: flag(parsed, "no-color")
1471
+ }));
1472
+ if (flag(parsed, "json")) context.stdout.write(`${JSON.stringify(result.json, null, 2)}\n`);
1473
+ else {
1474
+ context.stdout.write(result.text);
1475
+ if (result.textErr !== void 0) context.stderr.write(result.textErr);
1476
+ }
1477
+ return result.exit;
1478
+ } catch (error) {
1479
+ if (error instanceof UsageError) {
1480
+ const message = oneLine(error.message);
1481
+ if (json) context.stdout.write(errorDocument("usage", message, 2));
1482
+ else context.stderr.write(`ai-readiness: ${message}\nRun "ai-readiness --help" for usage.\n`);
1483
+ return 2;
1484
+ }
1485
+ const message = `Internal error: ${error instanceof Error ? oneLine(error.name) : "unknown"}.`;
1486
+ if (json) context.stdout.write(errorDocument("internal", message, 4));
1487
+ else context.stderr.write(`ai-readiness: ${message}\n`);
1488
+ return 4;
1489
+ }
1490
+ }
1491
+ export { runCli as n, CLI_VERSION as t };