eaa-kit 0.1.1 → 0.2.1

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.
@@ -1,2 +1,2 @@
1
- import { a as readBaseline, r as applyBaseline, t as BaselineError } from "./baseline-DQTnNlc4.js";
1
+ import { a as readBaseline, r as applyBaseline, t as BaselineError } from "./baseline-CgBmzFTr.js";
2
2
  export { BaselineError, applyBaseline, readBaseline };
@@ -1,30 +1,30 @@
1
+ import { _ as withDefault, c as object, f as safeParse, i as isoDate$1, l as optional, o as nullable, p as string, s as number, t as array } from "./schema-CMZ8ItGk.js";
1
2
  import { t as elementFingerprint } from "./fingerprint-DRoneAjj.js";
2
- import { z } from "zod";
3
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  /** Default filename, used by the CLI when no path is given. */
6
6
  const DEFAULT_BASELINE_FILE = "eaa-baseline.json";
7
- const entrySchema = z.object({
7
+ const entrySchema = object({
8
8
  /** Page path relative to the audited directory, POSIX separators. */
9
- page: z.string(),
10
- ruleId: z.string(),
9
+ page: string(),
10
+ ruleId: string(),
11
11
  /** Identity of the element, from elementFingerprint. */
12
- fingerprint: z.string(),
12
+ fingerprint: string(),
13
13
  /** Carried for readability only; matching never looks at these. */
14
- selector: z.string().default(""),
15
- help: z.string().default(""),
16
- impact: z.string().nullable().default(null),
14
+ selector: withDefault(string(), () => ""),
15
+ help: withDefault(string(), () => ""),
16
+ impact: withDefault(nullable(string()), () => null),
17
17
  /** ISO date the entry was written. */
18
- acceptedOn: z.string().default(""),
18
+ acceptedOn: withDefault(string(), () => ""),
19
19
  /** ISO date after which this entry stops suppressing anything. */
20
- expiresOn: z.iso.date().optional(),
20
+ expiresOn: optional(isoDate$1()),
21
21
  /** Why this is being lived with. Free text, for whoever reads the file. */
22
- note: z.string().optional()
22
+ note: optional(string())
23
23
  });
24
- const baselineSchema = z.object({
25
- schemaVersion: z.number(),
26
- createdOn: z.string().default(""),
27
- entries: z.array(entrySchema).default([])
24
+ const baselineSchema = object({
25
+ schemaVersion: number(),
26
+ createdOn: withDefault(string(), () => ""),
27
+ entries: withDefault(array(entrySchema), () => [])
28
28
  });
29
29
  var BaselineError = class extends Error {
30
30
  name = "BaselineError";
@@ -142,7 +142,7 @@ async function readBaseline(file, cwd = process.cwd()) {
142
142
  } catch (cause) {
143
143
  throw new BaselineError(`${path.basename(target)} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`);
144
144
  }
145
- const result = baselineSchema.safeParse(value);
145
+ const result = safeParse(baselineSchema, value);
146
146
  if (!result.success) {
147
147
  const issues = result.error.issues.map((issue) => `${issue.path.join(".") || "document"}: ${issue.message}`).slice(0, 5);
148
148
  throw new BaselineError(`${path.basename(target)} is not an eaa-kit baseline (${issues.join("; ")})`);
package/dist/cli/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { f as COUNTRIES, h as STATEMENT_LOCALES, i as readAuditReport, l as loadConfig, o as StatementError, p as ConfigError, t as renderStatement } from "../render-K9KxDDSA.js";
2
+ import { l as STATEMENT_LOCALES, o as COUNTRIES, r as loadConfig, s as ConfigError } from "../load-sCkKsvGQ.js";
3
+ import { i as readAuditReport, o as StatementError, t as renderStatement } from "../render-BO0nVrrZ.js";
3
4
  import { i as isImpactLevel, n as IMPACT_LEVELS, t as DEFAULT_FAIL_ON } from "../impact-DvgBjupx.js";
4
5
  import { t as TOOL_VERSION } from "../version-B3v4rNoG.js";
5
- import { i as buildBaseline, n as DEFAULT_BASELINE_FILE, s as writeBaseline, t as BaselineError } from "../baseline-DQTnNlc4.js";
6
- import { a as collectPages, i as BuildDirectoryError, n as isOutputFormat, o as emptyDirectoryHint, r as runAuditCommand, t as OUTPUT_FORMATS } from "../audit-CApiPaiG.js";
6
+ import { i as buildBaseline, n as DEFAULT_BASELINE_FILE, s as writeBaseline, t as BaselineError } from "../baseline-CgBmzFTr.js";
7
+ import { i as resolvePages, n as isOutputFormat, r as runAuditCommand, t as OUTPUT_FORMATS } from "../audit-DXpKkXsC.js";
7
8
  import { mkdir, writeFile } from "node:fs/promises";
8
9
  import path from "node:path";
9
10
  import { Command, InvalidArgumentError } from "commander";
@@ -23,39 +24,27 @@ import pc from "picocolors";
23
24
  */
24
25
  async function runBaselineCommand(dir, options = {}) {
25
26
  const cwd = options.cwd ?? process.cwd();
26
- let pages;
27
- try {
28
- pages = await collectPages(path.resolve(cwd, dir), {
29
- ...options.include ? { include: options.include } : {},
30
- ...options.exclude ? { exclude: options.exclude } : {}
31
- });
32
- } catch (cause) {
33
- if (cause instanceof BuildDirectoryError) {
34
- process.stderr.write(`${pc.red("error")} ${cause.message}\n`);
35
- return {
36
- entries: 0,
37
- exitCode: 2
38
- };
39
- }
40
- throw cause;
41
- }
42
- if (pages.length === 0) {
43
- process.stderr.write(`${pc.yellow("warning")} ${await emptyDirectoryHint(dir, options.cwd ?? process.cwd())}\n`);
44
- return {
45
- entries: 0,
46
- exitCode: 2
47
- };
48
- }
49
- process.stderr.write(pc.dim(`Auditing ${pages.length} ${pages.length === 1 ? "page" : "pages"} in ${dir}…\n`));
27
+ const resolved = await resolvePages(path.resolve(cwd, dir), {
28
+ ...options,
29
+ label: dir
30
+ });
31
+ if (!resolved) return {
32
+ entries: 0,
33
+ exitCode: 2
34
+ };
35
+ const { pages, origin, label } = resolved;
36
+ process.stderr.write(pc.dim(`Auditing ${pages.length} ${pages.length === 1 ? "page" : "pages"} in ${label}…\n`));
37
+ const effectiveBaseUrl = options.baseUrl ?? origin;
50
38
  const runnerOptions = {
51
- ...options.baseUrl ? { baseUrl: options.baseUrl } : {},
39
+ cwd,
40
+ ...effectiveBaseUrl === void 0 ? {} : { baseUrl: effectiveBaseUrl },
52
41
  ...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
53
42
  };
54
43
  let audits;
55
44
  if (options.browser) {
56
- const { BrowserUnavailableError, runBrowserAudit } = await import("../playwright-BfWuTG_u.js");
45
+ const { BrowserUnavailableError, runBrowserAudit } = await import("../playwright-DSRnXmcd.js");
57
46
  try {
58
- audits = await runBrowserAudit(path.resolve(cwd, dir), pages, runnerOptions);
47
+ audits = await runBrowserAudit(options.url === void 0 ? path.resolve(cwd, dir) : void 0, pages, runnerOptions);
59
48
  } catch (cause) {
60
49
  if (cause instanceof BrowserUnavailableError) {
61
50
  process.stderr.write(`${pc.red("error")} ${cause.message}\n`);
@@ -177,8 +166,10 @@ function formatFor(output) {
177
166
  const program = new Command();
178
167
  program.exitOverride();
179
168
  program.name("eaa-kit").description("WCAG 2.2 AA auditor and EU accessibility statement generator for static sites.\nNot legal advice.").version(TOOL_VERSION, "-v, --version");
180
- program.command("audit").description("Audit built HTML against WCAG 2.2 AA").argument("[dir]", "directory holding the built site", "./dist").option("--include <globs...>", "glob patterns to audit, relative to dir").option("--exclude <globs...>", "glob patterns to skip").option("--base-url <url>", "audit pages under their real site URL").option("--fail-on <impact>", `exit 1 on violations at or above this impact (${IMPACT_LEVELS.join("|")})`, parseImpact, DEFAULT_FAIL_ON).option("--format <format>", `output format (${OUTPUT_FORMATS.join("|")})`, parseFormat, "console").option("--output <path>", "write the report to a file instead of stdout").option("--browser", "audit in real Chromium, covering the rules jsdom cannot evaluate").option("--concurrency <n>", "worker threads to audit with, or 1 for none (default: from the page and core count)", parseConcurrency).option("--baseline <path>", "accept the violations recorded in this file; fail only on new ones").action(async (dir, options) => {
169
+ program.command("audit").description("Audit built HTML against WCAG 2.2 AA").argument("[dir]", "directory holding the built site (default: found automatically)").option("--include <globs...>", "glob patterns to audit, relative to dir").option("--exclude <globs...>", "glob patterns to skip").option("--base-url <url>", "audit pages under their real site URL").option("--url <url>", "audit a running site instead of a build directory").option("--no-build", "never run the project build or start its server").option("--per-page", "also list every page and its result").option("--allow-remote", "allow --url to crawl a host that is not localhost").option("--ignore-robots", "crawl paths robots.txt disallows").option("--max-pages <n>", "stop the crawl after this many pages", parsePositive).option("--max-depth <n>", "how far from the entry URL to follow links", parseDepth).option("--fail-on <impact>", `exit 1 on violations at or above this impact (${IMPACT_LEVELS.join("|")})`, parseImpact, DEFAULT_FAIL_ON).option("--format <format>", `output format (${OUTPUT_FORMATS.join("|")})`, parseFormat, "console").option("--output <path>", "write the report to a file instead of stdout").option("--browser", "audit in real Chromium, covering the rules jsdom cannot evaluate").option("--concurrency <n>", "worker threads to audit with, or 1 for none (default: from the page and core count)", parseConcurrency).option("--baseline <path>", "accept the violations recorded in this file; fail only on new ones").action(async (dir, options) => {
181
170
  const { exitCode } = await runAuditCommand(dir, {
171
+ ...options["build"] === false ? { noBuild: true } : {},
172
+ ...options["perPage"] === true ? { perPage: true } : {},
182
173
  ...Array.isArray(options["include"]) ? { include: options["include"] } : {},
183
174
  ...Array.isArray(options["exclude"]) ? { exclude: options["exclude"] } : {},
184
175
  ...typeof options["baseUrl"] === "string" ? { baseUrl: options["baseUrl"] } : {},
@@ -187,11 +178,16 @@ program.command("audit").description("Audit built HTML against WCAG 2.2 AA").arg
187
178
  ...typeof options["output"] === "string" ? { output: options["output"] } : {},
188
179
  ...options["browser"] === true ? { browser: true } : {},
189
180
  ...typeof options["concurrency"] === "number" ? { concurrency: options["concurrency"] } : {},
190
- ...typeof options["baseline"] === "string" ? { baseline: options["baseline"] } : {}
181
+ ...typeof options["baseline"] === "string" ? { baseline: options["baseline"] } : {},
182
+ ...typeof options["url"] === "string" ? { url: options["url"] } : {},
183
+ ...options["allowRemote"] === true ? { allowRemote: true } : {},
184
+ ...options["ignoreRobots"] === true ? { ignoreRobots: true } : {},
185
+ ...typeof options["maxPages"] === "number" ? { maxPages: options["maxPages"] } : {},
186
+ ...typeof options["maxDepth"] === "number" ? { maxDepth: options["maxDepth"] } : {}
191
187
  });
192
188
  process.exitCode = exitCode;
193
189
  });
194
- program.command("baseline").description("Record the violations a build already has, so later runs fail only on new ones").argument("[dir]", "directory holding the built site", "./dist").option("--include <globs...>", "glob patterns to audit, relative to dir").option("--exclude <globs...>", "glob patterns to skip").option("--base-url <url>", "audit pages under their real site URL").option("--output <path>", `where to write it (default: ${DEFAULT_BASELINE_FILE})`).option("--note <text>", "recorded on every entry, for whoever reads the file").option("--expires-on <date>", "ISO date after which the entries stop suppressing", parseDate).option("--browser", "audit in real Chromium instead of jsdom").option("--concurrency <n>", "worker threads to audit with, or 1 for none", parseConcurrency).action(async (dir, options) => {
190
+ program.command("baseline").description("Record the violations a build already has, so later runs fail only on new ones").argument("[dir]", "directory holding the built site", "./dist").option("--include <globs...>", "glob patterns to audit, relative to dir").option("--exclude <globs...>", "glob patterns to skip").option("--base-url <url>", "audit pages under their real site URL").option("--url <url>", "record a baseline from a running site instead of a directory").option("--allow-remote", "allow --url to crawl a host that is not localhost").option("--ignore-robots", "crawl paths robots.txt disallows").option("--max-pages <n>", "stop the crawl after this many pages", parsePositive).option("--max-depth <n>", "how far from the entry URL to follow links", parseDepth).option("--output <path>", `where to write it (default: ${DEFAULT_BASELINE_FILE})`).option("--note <text>", "recorded on every entry, for whoever reads the file").option("--expires-on <date>", "ISO date after which the entries stop suppressing", parseDate).option("--browser", "audit in real Chromium instead of jsdom").option("--concurrency <n>", "worker threads to audit with, or 1 for none", parseConcurrency).action(async (dir, options) => {
195
191
  const { exitCode } = await runBaselineCommand(dir, {
196
192
  ...Array.isArray(options["include"]) ? { include: options["include"] } : {},
197
193
  ...Array.isArray(options["exclude"]) ? { exclude: options["exclude"] } : {},
@@ -200,7 +196,21 @@ program.command("baseline").description("Record the violations a build already h
200
196
  ...typeof options["note"] === "string" ? { note: options["note"] } : {},
201
197
  ...typeof options["expiresOn"] === "string" ? { expiresOn: options["expiresOn"] } : {},
202
198
  ...options["browser"] === true ? { browser: true } : {},
203
- ...typeof options["concurrency"] === "number" ? { concurrency: options["concurrency"] } : {}
199
+ ...typeof options["concurrency"] === "number" ? { concurrency: options["concurrency"] } : {},
200
+ ...typeof options["url"] === "string" ? { url: options["url"] } : {},
201
+ ...options["allowRemote"] === true ? { allowRemote: true } : {},
202
+ ...options["ignoreRobots"] === true ? { ignoreRobots: true } : {},
203
+ ...typeof options["maxPages"] === "number" ? { maxPages: options["maxPages"] } : {},
204
+ ...typeof options["maxDepth"] === "number" ? { maxDepth: options["maxDepth"] } : {}
205
+ });
206
+ process.exitCode = exitCode;
207
+ });
208
+ program.command("init").description("Write an eaa.config.json to generate statements from").option("--output <path>", "write here instead of eaa.config.json").option("--force", "overwrite a config that is already there").option("-y, --yes", "take every default without asking").action(async (options) => {
209
+ const { runInitCommand } = await import("../init-DRKIdpK1.js");
210
+ const { exitCode } = await runInitCommand({
211
+ ...typeof options["output"] === "string" ? { output: options["output"] } : {},
212
+ ...options["force"] === true ? { force: true } : {},
213
+ ...options["yes"] === true ? { yes: true } : {}
204
214
  });
205
215
  process.exitCode = exitCode;
206
216
  });
@@ -232,6 +242,17 @@ function parseDate(value) {
232
242
  if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(Date.parse(`${value}T00:00:00Z`))) throw new InvalidArgumentError("expected an ISO date, e.g. 2026-12-31");
233
243
  return value;
234
244
  }
245
+ function parsePositive(value) {
246
+ const parsed = Number(value);
247
+ if (!Number.isInteger(parsed) || parsed < 1) throw new InvalidArgumentError("expected a whole number of 1 or more");
248
+ return parsed;
249
+ }
250
+ /** Depth 0 is meaningful here: audit only the entry page. */
251
+ function parseDepth(value) {
252
+ const parsed = Number(value);
253
+ if (!Number.isInteger(parsed) || parsed < 0) throw new InvalidArgumentError("expected a whole number of 0 or more");
254
+ return parsed;
255
+ }
235
256
  function parseConcurrency(value) {
236
257
  const parsed = Number(value);
237
258
  if (!Number.isInteger(parsed) || parsed < 1) throw new InvalidArgumentError("expected a whole number of 1 or more");
@@ -0,0 +1,254 @@
1
+ /** Requests in flight at once. Politeness, not throughput. */
2
+ const REQUEST_CONCURRENCY = 4;
3
+ var CrawlError = class extends Error {
4
+ name = "CrawlError";
5
+ };
6
+ /** Hosts that are this machine. Anything else needs allowRemote. */
7
+ function isLoopback(hostname) {
8
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
9
+ return host === "localhost" || host === "::1" || host.endsWith(".localhost") || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
10
+ }
11
+ function parseEntryUrl(raw, allowRemote = false) {
12
+ let url;
13
+ try {
14
+ url = new URL(raw);
15
+ } catch {
16
+ throw new CrawlError(`${raw} is not a URL. Include the scheme, e.g. http://localhost:3000`);
17
+ }
18
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new CrawlError(`${raw} is not an http or https URL`);
19
+ if (!allowRemote && !isLoopback(url.hostname)) throw new CrawlError(`${url.host} is not a local address, and eaa-kit does not crawl remote hosts by default.\n Auditing a site you do not control sends it traffic and reads pages you may not
20
+ have meant to. Pass --allow-remote if this is your site and you meant to.`);
21
+ return url;
22
+ }
23
+ /**
24
+ * Page path used as the page's identity, relative to the origin.
25
+ *
26
+ * This is what appears in reports and what a baseline matches on, so it has to
27
+ * be stable across runs and independent of how the link was written. The query
28
+ * string is dropped — two URLs differing only by a tracking parameter are the
29
+ * same page — and the fragment with it.
30
+ */
31
+ function pageIdentity(url) {
32
+ const path = url.pathname.replace(/^\/+/, "");
33
+ return path === "" ? "/" : path;
34
+ }
35
+ /** Same site: same protocol, host and port. */
36
+ function sameOrigin(a, b) {
37
+ return a.origin === b.origin;
38
+ }
39
+ /**
40
+ * Links worth following. Anything that is not a page — an asset, a download, a
41
+ * mailto: — is left alone, and so is anything off-origin.
42
+ */
43
+ const NON_PAGE = /\.(?:css|js|mjs|json|xml|txt|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|eot|pdf|zip|gz|mp4|webm|mp3|wav)$/i;
44
+ function linksFrom(html, from) {
45
+ const found = [];
46
+ const seen = /* @__PURE__ */ new Set();
47
+ for (const match of html.matchAll(/<a\b[^>]*?\shref\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/gi)) {
48
+ const raw = (match[2] ?? match[3] ?? match[4] ?? "").trim();
49
+ if (raw === "" || raw.startsWith("#")) continue;
50
+ if (/^(?:mailto|tel|javascript|data):/i.test(raw)) continue;
51
+ let target;
52
+ try {
53
+ target = new URL(raw, from);
54
+ } catch {
55
+ continue;
56
+ }
57
+ target.hash = "";
58
+ if (!sameOrigin(target, from)) continue;
59
+ if (NON_PAGE.test(target.pathname)) continue;
60
+ if (seen.has(target.href)) continue;
61
+ seen.add(target.href);
62
+ found.push(target);
63
+ }
64
+ return found;
65
+ }
66
+ /** Page URLs listed in a sitemap, including one level of sitemap index. */
67
+ function urlsFromSitemap(xml, origin) {
68
+ const urls = [];
69
+ for (const match of xml.matchAll(/<loc>\s*([^<]+?)\s*<\/loc>/gi)) {
70
+ const raw = match[1];
71
+ if (!raw) continue;
72
+ try {
73
+ const url = new URL(raw);
74
+ url.hash = "";
75
+ if (sameOrigin(url, origin)) urls.push(url);
76
+ } catch {}
77
+ }
78
+ return urls;
79
+ }
80
+ /** One request, with a timeout, returning HTML or a reason it is not a page. */
81
+ async function fetchPage(url, impl, timeoutMs, origin) {
82
+ const controller = new AbortController();
83
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
84
+ try {
85
+ const response = await impl(url.href, {
86
+ signal: controller.signal,
87
+ redirect: "follow",
88
+ headers: {
89
+ accept: "text/html,application/xhtml+xml",
90
+ "user-agent": "eaa-kit"
91
+ }
92
+ });
93
+ if (!response.ok) return {
94
+ ok: false,
95
+ reason: `HTTP ${response.status}`
96
+ };
97
+ const finalUrl = new URL(response.url || url.href);
98
+ finalUrl.hash = "";
99
+ if (finalUrl.origin !== origin) return {
100
+ ok: false,
101
+ reason: `redirected off ${origin} to ${finalUrl.origin}`
102
+ };
103
+ const type = response.headers.get("content-type") ?? "";
104
+ if (!/\b(?:text\/html|application\/xhtml\+xml)\b/i.test(type)) return {
105
+ ok: false,
106
+ reason: `not HTML (${type.split(";")[0] || "no content-type"})`
107
+ };
108
+ return {
109
+ ok: true,
110
+ value: {
111
+ url: finalUrl,
112
+ html: await response.text()
113
+ }
114
+ };
115
+ } catch (cause) {
116
+ const message = cause instanceof Error ? cause.message : String(cause);
117
+ return {
118
+ ok: false,
119
+ reason: controller.signal.aborted ? `timed out after ${timeoutMs}ms` : message
120
+ };
121
+ } finally {
122
+ clearTimeout(timer);
123
+ }
124
+ }
125
+ /** Paths robots.txt disallows for us. Only the wildcard group is read. */
126
+ function disallowedPaths(robots) {
127
+ const lines = robots.split(/\r?\n/).map((line) => line.replace(/#.*$/, "").trim());
128
+ const paths = [];
129
+ let applies = false;
130
+ for (const line of lines) {
131
+ const agent = /^user-agent\s*:\s*(.+)$/i.exec(line);
132
+ if (agent) {
133
+ applies = (agent[1] ?? "").trim() === "*";
134
+ continue;
135
+ }
136
+ const rule = /^disallow\s*:\s*(.*)$/i.exec(line);
137
+ if (rule && applies) {
138
+ const value = (rule[1] ?? "").trim();
139
+ if (value !== "") paths.push(value);
140
+ }
141
+ }
142
+ return paths;
143
+ }
144
+ /**
145
+ * Paths this crawler must not visit, from the site's own robots.txt.
146
+ *
147
+ * Read directly rather than through fetchPage, which correctly refuses anything
148
+ * that is not a page. A site with no robots.txt disallows nothing.
149
+ */
150
+ async function blockedPaths(entry, impl) {
151
+ try {
152
+ const response = await impl(new URL("/robots.txt", entry).href, { redirect: "follow" });
153
+ return response.ok ? disallowedPaths(await response.text()) : [];
154
+ } catch {
155
+ return [];
156
+ }
157
+ }
158
+ /**
159
+ * Page URLs the site lists for itself.
160
+ *
161
+ * Worth one request: a sitemap finds pages nothing links to, which link
162
+ * following alone never reaches.
163
+ */
164
+ async function sitemapUrls(entry, impl) {
165
+ try {
166
+ const response = await impl(new URL("/sitemap.xml", entry).href, { redirect: "follow" });
167
+ return response.ok ? urlsFromSitemap(await response.text(), entry) : [];
168
+ } catch {
169
+ return [];
170
+ }
171
+ }
172
+ /**
173
+ * Fetch a site's pages, starting at `entry`.
174
+ *
175
+ * Pages come back sorted by identity, so two crawls of the same site produce
176
+ * the same report even though the requests finish in whatever order the server
177
+ * answers them — the same guarantee the directory collector gives.
178
+ */
179
+ async function crawlSite(entry, options = {}) {
180
+ const impl = options.fetchImpl ?? fetch;
181
+ const maxPages = options.maxPages ?? 200;
182
+ const maxDepth = options.maxDepth ?? 3;
183
+ const timeoutMs = options.timeoutMs ?? 15e3;
184
+ const failures = [];
185
+ const blocked = options.ignoreRobots ? [] : await blockedPaths(entry, impl);
186
+ const allowed = (url) => !blocked.some((path) => url.pathname.startsWith(path));
187
+ let discovery = "links";
188
+ const queue = [];
189
+ const queued = /* @__PURE__ */ new Set();
190
+ const enqueue = (url, depth) => {
191
+ if (queued.has(url.href) || !allowed(url)) return;
192
+ queued.add(url.href);
193
+ queue.push({
194
+ url,
195
+ depth
196
+ });
197
+ };
198
+ const listed = await sitemapUrls(entry, impl);
199
+ if (listed.length > 0) {
200
+ discovery = "sitemap";
201
+ for (const url of listed) enqueue(url, 0);
202
+ }
203
+ enqueue(entry, 0);
204
+ const pages = [];
205
+ while (queue.length > 0 && pages.length < maxPages) {
206
+ const batch = queue.splice(0, Math.min(REQUEST_CONCURRENCY, maxPages - pages.length));
207
+ const results = await Promise.all(batch.map(async (item) => ({
208
+ item,
209
+ result: await fetchPage(item.url, impl, timeoutMs, entry.origin)
210
+ })));
211
+ for (const { item, result } of results) {
212
+ if (!result.ok) {
213
+ failures.push({
214
+ url: item.url.href,
215
+ reason: result.reason
216
+ });
217
+ continue;
218
+ }
219
+ const { url, html } = result.value;
220
+ pages.push({
221
+ absolutePath: url.href,
222
+ relativePath: pageIdentity(url),
223
+ html: html.charCodeAt(0) === 65279 ? html.slice(1) : html
224
+ });
225
+ options.onProgress?.(pages.length, queue.length);
226
+ if (item.depth < maxDepth) for (const link of linksFrom(html, url)) enqueue(link, item.depth + 1);
227
+ }
228
+ }
229
+ return {
230
+ pages: byIdentity(pages),
231
+ origin: entry.origin,
232
+ failures,
233
+ truncated: queue.length > 0,
234
+ discovery
235
+ };
236
+ }
237
+ /**
238
+ * One page per identity, in a stable order.
239
+ *
240
+ * The queue already refuses a URL it has seen, so this catches the case the
241
+ * queue cannot: two different URLs that redirect to the same page. Sorting is
242
+ * what makes two crawls of one site produce the same report even though the
243
+ * requests finish in whatever order the server answers them.
244
+ */
245
+ function byIdentity(pages) {
246
+ const seen = /* @__PURE__ */ new Set();
247
+ return pages.filter((page) => {
248
+ if (seen.has(page.relativePath)) return false;
249
+ seen.add(page.relativePath);
250
+ return true;
251
+ }).sort((a, b) => a.relativePath.localeCompare(b.relativePath));
252
+ }
253
+ //#endregion
254
+ export { CrawlError, crawlSite, parseEntryUrl };