diff --git a/packages/code-infra/package.json b/packages/code-infra/package.json
index 2ad8820dc..3726ce147 100644
--- a/packages/code-infra/package.json
+++ b/packages/code-infra/package.json
@@ -39,6 +39,10 @@
"./stylelint": {
"types": "./build/stylelint/index.d.mts",
"default": "./src/stylelint/index.mjs"
+ },
+ "./brokenLinksChecker": {
+ "types": "./build/brokenLinksChecker/index.d.mts",
+ "default": "./src/brokenLinksChecker/index.mjs"
}
},
"bin": {
@@ -79,6 +83,7 @@
"babel-plugin-transform-remove-imports": "^1.8.1",
"chalk": "^5.6.2",
"clipboardy": "^5.0.0",
+ "content-type": "^1.0.5",
"env-ci": "^11.2.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
@@ -96,6 +101,7 @@
"globals": "^16.4.0",
"globby": "^15.0.0",
"minimatch": "^10.0.3",
+ "node-html-parser": "^7.0.1",
"open": "^10.2.0",
"postcss-styled-syntax": "^0.7.1",
"regexp.escape": "^2.0.1",
@@ -106,14 +112,16 @@
"yargs": "^18.0.0"
},
"peerDependencies": {
+ "@next/eslint-plugin-next": "*",
"eslint": "^9.0.0",
"prettier": "^3.5.3",
- "typescript": "^5.0.0",
- "@next/eslint-plugin-next": "*"
+ "typescript": "^5.0.0"
},
"devDependencies": {
+ "@octokit/types": "^15.0.1",
"@types/babel__core": "^7.20.5",
"@types/babel__preset-env": "^7.10.0",
+ "@types/content-type": "^1.1.9",
"@types/env-ci": "^3.1.4",
"@types/eslint-plugin-jsx-a11y": "^6.10.1",
"@types/estree": "^1.0.8",
@@ -123,8 +131,9 @@
"@typescript-eslint/parser": "^8.46.2",
"@typescript-eslint/rule-tester": "^8.46.2",
"eslint": "^9.38.0",
- "@octokit/types": "^15.0.1",
+ "get-port": "^7.1.0",
"prettier": "^3.6.2",
+ "serve": "^14.2.5",
"typescript-eslint": "^8.46.2"
},
"files": [
diff --git a/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/broken-links.html b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/broken-links.html
new file mode 100644
index 000000000..30e4cad7a
--- /dev/null
+++ b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/broken-links.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Page with Broken Links
+
+
+ Page with Broken Links
+ This page contains links to non-existent pages.
+
+
+
+
+
diff --git a/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/broken-targets.html b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/broken-targets.html
new file mode 100644
index 000000000..43432271e
--- /dev/null
+++ b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/broken-targets.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ Page with Broken Targets
+
+
+ Page with Broken Targets
+ This page contains links to valid pages but with invalid hash targets.
+
+
+
+
+
diff --git a/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/example.md b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/example.md
new file mode 100644
index 000000000..f36aa4a57
--- /dev/null
+++ b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/example.md
@@ -0,0 +1,9 @@
+# Example Markdown File
+
+This is a markdown file with an HTML code snippet:
+
+```html
+This link is in a code snippet
+```
+
+This link should not be crawled because this is a markdown file, not HTML.
diff --git a/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/external-links.html b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/external-links.html
new file mode 100644
index 000000000..8a7c86248
--- /dev/null
+++ b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/external-links.html
@@ -0,0 +1,21 @@
+
+
+
+
+
+ Page with External Links
+
+
+ Page with External Links
+ This page contains external links that should be ignored.
+
+
+
+
+
diff --git a/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/ignored-page.html b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/ignored-page.html
new file mode 100644
index 000000000..2b2053b5e
--- /dev/null
+++ b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/ignored-page.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Ignored Page
+
+
+ Ignored Page
+ This page should be ignored by the crawler.
+
+
+
+
+
diff --git a/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/index.html b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/index.html
new file mode 100644
index 000000000..e773c4dfc
--- /dev/null
+++ b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/index.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ Test Site Home
+
+
+ Test Site Home
+
+
+
+
+
diff --git a/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/known-targets.json b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/known-targets.json
new file mode 100644
index 000000000..e900dbce1
--- /dev/null
+++ b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/known-targets.json
@@ -0,0 +1,5 @@
+{
+ "targets": {
+ "/api-page.html": ["#method1", "#method2", "#method3"]
+ }
+}
diff --git a/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/nested/page.html b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/nested/page.html
new file mode 100644
index 000000000..9cfd3acd9
--- /dev/null
+++ b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/nested/page.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+ Nested Page
+
+
+ Nested Page
+ This is a page in a nested directory.
+
+
+
+
+
diff --git a/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/orphaned-page.html b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/orphaned-page.html
new file mode 100644
index 000000000..acb3df281
--- /dev/null
+++ b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/orphaned-page.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Orphaned Page
+
+
+ Orphaned Page
+ This page is not linked from anywhere and can only be discovered via seedUrls.
+
+
+
+
+
diff --git a/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/page-with-api-links.html b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/page-with-api-links.html
new file mode 100644
index 000000000..7a44d4bdf
--- /dev/null
+++ b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/page-with-api-links.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Page with API Links
+
+
+ Page with API Links
+ This page links to API documentation with known targets.
+
+
+
+
+
diff --git a/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/page-with-custom-targets.html b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/page-with-custom-targets.html
new file mode 100644
index 000000000..bc1c3ffc5
--- /dev/null
+++ b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/page-with-custom-targets.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+ Page with Custom Targets
+
+
+ Page with Custom Targets
+
+
+
+
+
+ This target should be ignored
+
+
+
diff --git a/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/page-with-ignored-content.html b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/page-with-ignored-content.html
new file mode 100644
index 000000000..2b6203ab0
--- /dev/null
+++ b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/page-with-ignored-content.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+ Page with Ignored Content
+
+
+ Page with Ignored Content
+
+
+
+
+
+ Main content
+
+
+
+
+
+
diff --git a/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/page-with-known-target-links.html b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/page-with-known-target-links.html
new file mode 100644
index 000000000..ce58e30d8
--- /dev/null
+++ b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/page-with-known-target-links.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+ Page with Known Target Links
+
+
+ Page with Known Target Links
+ This page links to external pages with known targets.
+
+
+
+
+
diff --git a/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/valid.html b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/valid.html
new file mode 100644
index 000000000..fde0a32f7
--- /dev/null
+++ b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/valid.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Valid Page
+
+
+ Valid Page
+ This page has only valid internal links.
+
+
+
+
+
diff --git a/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/with-anchors.html b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/with-anchors.html
new file mode 100644
index 000000000..b870c0fae
--- /dev/null
+++ b/packages/code-infra/src/brokenLinksChecker/__fixtures__/static-site/with-anchors.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+ Page with Anchors
+
+
+ Page with Anchors
+
+
+
+
+ Section 1
+ Content for section 1
+
+
+ Section 2
+ Content for section 2
+
+
+ Section 3
+ Content for section 3
+
+
+
diff --git a/packages/code-infra/src/brokenLinksChecker/index.mjs b/packages/code-infra/src/brokenLinksChecker/index.mjs
new file mode 100644
index 000000000..5d04171ee
--- /dev/null
+++ b/packages/code-infra/src/brokenLinksChecker/index.mjs
@@ -0,0 +1,635 @@
+/* eslint-disable no-console */
+import { execaCommand } from 'execa';
+import timers from 'node:timers/promises';
+import { parse } from 'node-html-parser';
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+import chalk from 'chalk';
+import { Transform } from 'node:stream';
+import contentType from 'content-type';
+
+const DEFAULT_CONCURRENCY = 4;
+
+/**
+ * Creates a Transform stream that prefixes each line with a given string.
+ * Useful for distinguishing server logs from other output.
+ * @param {string} prefix - String to prepend to each line
+ * @returns {Transform} Transform stream that adds the prefix to each line
+ */
+const prefixLines = (prefix) => {
+ let leftover = '';
+ return new Transform({
+ transform(chunk, enc, cb) {
+ const lines = (leftover + chunk.toString()).split(/\r?\n/);
+ leftover = /** @type {string} */ (lines.pop());
+ this.push(lines.map((l) => `${prefix + l}\n`).join(''));
+ cb();
+ },
+ flush(cb) {
+ if (leftover) {
+ this.push(`${prefix + leftover}\n`);
+ }
+ cb();
+ },
+ });
+};
+
+/**
+ * Maps page URLs to sets of known target IDs (anchors) on that page.
+ * Used to track which link targets (e.g., #section-id) exist on each page.
+ * @typedef {Map>} LinkStructure
+ */
+
+/**
+ * Serialized representation of LinkStructure for JSON storage.
+ * Converts Maps and Sets to plain objects and arrays for file persistence.
+ * @typedef {Object} SerializedLinkStructure
+ * @property {Record} targets - Object mapping page URLs to arrays of target IDs
+ */
+
+/**
+ * Fetches a URL and throws an error if the response is not OK.
+ * @param {string | URL} url - URL to fetch
+ * @returns {Promise} Fetch response if successful
+ * @throws {Error} If the response status is not OK (not in 200-299 range)
+ */
+async function fetchUrl(url) {
+ const res = await fetch(url);
+ if (!res.ok) {
+ throw new Error(`Failed to fetch ${url}: [${res.status}] ${res.statusText}`);
+ }
+ return res;
+}
+
+/**
+ * Polls a URL until it responds successfully or times out.
+ * Used to wait for a dev server to start.
+ * @param {string} url - URL to poll
+ * @param {number} timeout - Maximum milliseconds to wait before timing out
+ * @returns {Promise} Resolves when URL responds successfully
+ * @throws {Error} If timeout is reached before URL responds
+ */
+async function pollUrl(url, timeout) {
+ const start = Date.now();
+ while (true) {
+ try {
+ // eslint-disable-next-line no-await-in-loop
+ await fetchUrl(url);
+ return;
+ } catch (/** @type {any} */ error) {
+ if (Date.now() - start > timeout) {
+ throw new Error(`Timeout waiting for ${url}: ${error.message}`, { cause: error });
+ }
+ // eslint-disable-next-line no-await-in-loop
+ await timers.setTimeout(1000);
+ }
+ }
+}
+
+/**
+ * Converts serialized link structure (from JSON) back to Map/Set form.
+ * @param {SerializedLinkStructure} data - Serialized structure with plain objects/arrays
+ * @returns {LinkStructure} Deserialized structure using Map and Set
+ */
+function deserializeLinkStructure(data) {
+ const linkStructure = new Map();
+ for (const url of Object.keys(data.targets)) {
+ linkStructure.set(url, new Set(data.targets[url]));
+ }
+ return linkStructure;
+}
+
+/**
+ * Data about a crawled page including its URL, HTTP status, and available link targets.
+ * @typedef {Object} PageData
+ * @property {string} url - The normalized page URL (without trailing slash unless root)
+ * @property {number} status - HTTP status code from the response (e.g., 200, 404, 500)
+ * @property {Set} targets - Set of available anchor targets on the page, keyed by hash (e.g., '#intro')
+ */
+
+/**
+ * Serializes and writes discovered page targets to a JSON file.
+ * @param {Map} pages - Map of crawled pages with their targets
+ * @param {string} outPath - File path to write the JSON output
+ * @returns {Promise}
+ */
+async function writePagesToFile(pages, outPath) {
+ /** @type {SerializedLinkStructure} */
+ const fileContent = { targets: {} };
+ for (const [url, pageData] of pages.entries()) {
+ fileContent.targets[url] = Array.from(pageData.targets.keys());
+ }
+ const dir = path.dirname(outPath);
+ await fs.mkdir(dir, { recursive: true });
+ await fs.writeFile(outPath, JSON.stringify(fileContent, null, 2), 'utf-8');
+}
+
+/**
+ * Computes the accessible name of an element according to ARIA rules.
+ * Polyfill for `node.computedName` available only in Chrome v112+.
+ * Checks in order: aria-label, aria-labelledby, label[for], img alt, innerText.
+ * @param {import('node-html-parser').HTMLElement | null} elm - Element to compute name for
+ * @param {import('node-html-parser').HTMLElement} ownerDocument - Document containing the element
+ * @returns {string} The computed accessible name, or empty string if none found
+ */
+function getAccessibleName(elm, ownerDocument) {
+ if (!elm) {
+ return '';
+ }
+
+ // 1. aria-label
+ const ariaLabel = elm.getAttribute('aria-label')?.trim();
+ if (ariaLabel) {
+ return ariaLabel;
+ }
+
+ // 2. aria-labelledby
+ const labelledby = elm.getAttribute('aria-labelledby');
+ if (labelledby) {
+ const labels = [];
+ for (const id of labelledby.split(/\s+/)) {
+ const label = getAccessibleName(ownerDocument.getElementById(id), ownerDocument);
+ if (label) {
+ labels.push(label);
+ }
+ }
+ const label = labels.join(' ').trim();
+ if (label) {
+ return label;
+ }
+ }
+
+ // 3.
+ if (elm.id) {
+ const label = ownerDocument.querySelector(`label[for="${elm.id}"]`);
+ if (label) {
+ return getAccessibleName(label, ownerDocument);
+ }
+ }
+
+ // 4.
+ if (elm.tagName === 'IMG') {
+ const alt = elm.getAttribute('alt')?.trim();
+ if (alt) {
+ return alt;
+ }
+ }
+
+ // 5. Fallback: visible text
+ return elm.innerText.trim();
+}
+
+/**
+ * Generic concurrent task queue with configurable concurrency limit.
+ * Processes tasks in FIFO order with a maximum number of concurrent workers.
+ * @template T
+ */
+class Queue {
+ /** Array of pending tasks waiting to be processed */
+ /** @type {T[]} */
+ tasks = [];
+
+ /** Set of currently running task promises */
+ /** @type {Set>} */
+ pending = new Set();
+
+ /**
+ * Creates a new queue with a worker function and concurrency limit.
+ * @param {(task: T) => Promise} worker - Async function to process each task
+ * @param {number} concurrency - Maximum number of tasks to run simultaneously
+ */
+ constructor(worker, concurrency) {
+ this.worker = worker;
+ this.concurrency = concurrency;
+ }
+
+ /**
+ * Adds a task to the queue and starts processing if under concurrency limit.
+ * @param {T} task - Task to add to the queue
+ */
+ add(task) {
+ this.tasks.push(task);
+ this.run();
+ }
+
+ async run() {
+ while (this.pending.size < this.concurrency && this.tasks.length > 0) {
+ const task = /** @type {T} */ (this.tasks.shift());
+ const p = this.worker(task).finally(() => {
+ this.pending.delete(p);
+ this.run();
+ });
+ this.pending.add(p);
+ }
+ }
+
+ /**
+ * Waits for all pending and queued tasks to complete.
+ * @returns {Promise}
+ */
+ async waitAll() {
+ while (this.pending.size > 0) {
+ // eslint-disable-next-line no-await-in-loop
+ await Promise.all(this.pending);
+ }
+ }
+}
+
+/**
+ * Represents a hyperlink found during crawling.
+ * @typedef {Object} Link
+ * @property {string | null} src - URL of the page where this link was found, or null for seed URLs
+ * @property {string | null} text - Accessible name/text content of the link element, or null for seed URLs
+ * @property {string} href - The href attribute value (may be relative or absolute, with or without hash)
+ */
+
+/**
+ * Extracts and normalizes the page URL from a link href.
+ * Returns null for external links, ignored paths, or non-standard URLs.
+ * Normalizes by removing trailing slashes (except root) and preserving query params.
+ * @param {string} href - Link href to process (e.g., '/docs/api#section?query=1')
+ * @param {RegExp[]} ignoredPaths - Array of patterns to exclude
+ * @returns {string | null} Normalized page URL with query but without hash, or null if external/ignored
+ */
+function getPageUrl(href, ignoredPaths = []) {
+ if (!href.startsWith('/')) {
+ return null;
+ }
+ const parsed = new URL(href, 'http://localhost');
+ if (ignoredPaths.some((pattern) => pattern.test(parsed.pathname))) {
+ return null;
+ }
+ // Normalize pathname by removing trailing slash (except for root)
+ let pathname = parsed.pathname;
+ if (pathname !== '/' && pathname.endsWith('/')) {
+ pathname = pathname.slice(0, -1);
+ }
+ const link = pathname + parsed.search;
+ return link;
+}
+
+/**
+ * Configuration options for the broken links crawler.
+ * @typedef {Object} CrawlOptions
+ * @property {string | null} [startCommand] - Shell command to start the dev server (e.g., 'npm run dev'). If null, assumes server is already running
+ * @property {string} host - Base URL of the site to crawl (e.g., 'http://localhost:3000')
+ * @property {string | null} [outPath] - File path to write discovered link targets to. If null, targets are not persisted
+ * @property {RegExp[]} [ignoredPaths] - Array of regex patterns to exclude from crawling (e.g., [/^\/api\//] to skip /api/* routes)
+ * @property {string[]} [ignoredContent] - CSS selectors for elements whose nested links should be ignored (e.g., ['.sidebar', 'footer'])
+ * @property {Set} [ignoredTargets] - Set of element IDs to ignore as link targets (defaults to '__next', '__NEXT_DATA__')
+ * @property {Map>} [knownTargets] - Pre-populated map of known valid targets to skip crawling (useful for external pages)
+ * @property {string[]} [knownTargetsDownloadUrl] - URLs to fetch known targets from (fetched JSON will be merged with knownTargets)
+ * @property {number} [concurrency] - Number of concurrent page fetches (defaults to 4)
+ * @property {string[]} [seedUrls] - Starting URLs for the crawl (defaults to ['/'])
+ */
+
+/**
+ * Fully resolved configuration with all optional fields filled with defaults.
+ * @typedef {Required} ResolvedCrawlOptions
+ */
+
+/**
+ * Resolves partial crawl options by filling in defaults for all optional fields.
+ * @param {CrawlOptions} rawOptions - Partial options from user
+ * @returns {ResolvedCrawlOptions} Fully resolved options with all defaults applied
+ */
+function resolveOptions(rawOptions) {
+ return {
+ startCommand: rawOptions.startCommand ?? null,
+ host: rawOptions.host,
+ outPath: rawOptions.outPath ?? null,
+ ignoredPaths: rawOptions.ignoredPaths ?? [],
+ ignoredContent: rawOptions.ignoredContent ?? [],
+ ignoredTargets: rawOptions.ignoredTargets ?? new Set(['__next', '__NEXT_DATA__']),
+ knownTargets: rawOptions.knownTargets ?? new Map(),
+ knownTargetsDownloadUrl: rawOptions.knownTargetsDownloadUrl ?? [],
+ concurrency: rawOptions.concurrency ?? DEFAULT_CONCURRENCY,
+ seedUrls: rawOptions.seedUrls ?? ['/'],
+ };
+}
+
+/**
+ * Merges multiple Maps, similar to Object.assign for objects.
+ * Later sources override earlier ones for duplicate keys.
+ * @template K, V
+ * @param {Map} target - Target map to merge into (will be mutated)
+ * @param {...Map} sources - Source maps to merge from
+ * @returns {Map} The mutated target map
+ */
+function mergeMaps(target, ...sources) {
+ for (const source of sources) {
+ for (const [key, value] of source.entries()) {
+ target.set(key, value);
+ }
+ }
+ return target;
+}
+
+/**
+ * Downloads and deserializes known link targets from remote URLs.
+ * Fetches JSON files containing serialized link structures in parallel.
+ * @param {string[]} urls - Array of URLs to fetch known targets from
+ * @returns {Promise} Array of deserialized link structures
+ */
+async function downloadKnownTargets(urls) {
+ if (urls.length === 0) {
+ return [];
+ }
+
+ console.log(chalk.blue(`Downloading known targets from ${urls.length} URL(s)...`));
+
+ const results = await Promise.all(
+ urls.map(async (url) => {
+ console.log(` Fetching ${chalk.underline(url)}`);
+ const res = await fetchUrl(url);
+ const data = await res.json();
+ return deserializeLinkStructure(data);
+ }),
+ );
+
+ return results;
+}
+
+/**
+ * Resolves all known targets by downloading remote ones and merging with user-provided.
+ * User-provided targets take priority over downloaded ones.
+ * @param {ResolvedCrawlOptions} options - Resolved crawl options
+ * @returns {Promise} Merged map of all known targets
+ */
+async function resolveKnownTargets(options) {
+ const downloaded = await downloadKnownTargets(options.knownTargetsDownloadUrl);
+ // Merge downloaded with user-provided, user-provided takes priority
+ return mergeMaps(new Map(), ...downloaded, options.knownTargets);
+}
+
+/**
+ * Represents a broken link or broken link target discovered during crawling.
+ * @typedef {Object} Issue
+ * @property {'broken-link' | 'broken-target'} type - Type of issue: 'broken-link' for 404 pages, 'broken-target' for missing anchors
+ * @property {string} message - Human-readable description of the issue (e.g., 'Target not found', 'Page returned error 404')
+ * @property {Link} link - The link object that has the issue
+ */
+
+/**
+ * Results from a complete crawl operation.
+ * @typedef {Object} CrawlResult
+ * @property {Set } links - All links discovered during the crawl
+ * @property {Map} pages - All pages crawled, keyed by normalized URL
+ * @property {Issue[]} issues - All broken links and broken targets found
+ */
+
+/**
+ * Reports broken links to stderr, grouped by source page for better readability.
+ * @param {Issue[]} issuesList - Array of issues to report
+ */
+function reportIssues(issuesList) {
+ if (issuesList.length === 0) {
+ return;
+ }
+
+ console.error('\nBroken links found:\n');
+
+ // Group issues by source URL
+ /** @type {Map} */
+ const issuesBySource = new Map();
+ for (const issue of issuesList) {
+ const sourceUrl = issue.link.src ?? '(unknown)';
+ const sourceIssues = issuesBySource.get(sourceUrl) ?? [];
+ if (sourceIssues.length === 0) {
+ issuesBySource.set(sourceUrl, sourceIssues);
+ }
+ sourceIssues.push(issue);
+ }
+
+ // Report issues grouped by source
+ for (const [sourceUrl, sourceIssues] of issuesBySource.entries()) {
+ console.error(`Source ${chalk.cyan(sourceUrl)}:`);
+ for (const issue of sourceIssues) {
+ const reason = issue.type === 'broken-target' ? 'target not found' : 'returned status 404';
+ console.error(` [${issue.link.text}](${chalk.cyan(issue.link.href)}) (${reason})`);
+ }
+ }
+}
+
+/**
+ * Crawls a website starting from seed URLs, discovering all internal links and checking for broken links/targets.
+ * @param {CrawlOptions} rawOptions - Configuration options for the crawl
+ * @returns {Promise} Crawl results including all links, pages, and issues found
+ */
+export async function crawl(rawOptions) {
+ const options = resolveOptions(rawOptions);
+ const startTime = Date.now();
+
+ /** @type {import('execa').ResultPromise | undefined} */
+ let appProcess;
+ if (options.startCommand) {
+ console.log(chalk.blue(`Starting server with "${options.startCommand}"...`));
+ appProcess = execaCommand(options.startCommand, {
+ stdout: 'pipe',
+ stderr: 'pipe',
+ env: {
+ FORCE_COLOR: '1',
+ ...process.env,
+ },
+ });
+
+ // Prefix server logs
+ const serverPrefix = chalk.gray('server: ');
+ appProcess.stdout?.pipe(prefixLines(serverPrefix)).pipe(process.stdout);
+ appProcess.stderr?.pipe(prefixLines(serverPrefix)).pipe(process.stderr);
+
+ await pollUrl(options.host, 10000);
+
+ console.log(`Server started on ${chalk.underline(options.host)}`);
+ }
+
+ const knownTargets = await resolveKnownTargets(options);
+
+ /** @type {Map>} */
+ const crawledPages = new Map();
+ /** @type {Set } */
+ const crawledLinks = new Set();
+
+ const queue = new Queue(async (/** @type {Link} */ link) => {
+ crawledLinks.add(link);
+
+ const pageUrl = getPageUrl(link.href, options.ignoredPaths);
+ if (pageUrl === null) {
+ return;
+ }
+
+ if (knownTargets.has(pageUrl)) {
+ return;
+ }
+
+ if (crawledPages.has(pageUrl)) {
+ return;
+ }
+
+ const pagePromise = Promise.resolve().then(async () => {
+ console.log(`Crawling ${chalk.cyan(pageUrl)}...`);
+ const res = await fetch(new URL(pageUrl, options.host));
+
+ /** @type {PageData} */
+ const pageData = {
+ url: pageUrl,
+ status: res.status,
+ targets: new Set(),
+ };
+
+ if (pageData.status < 200 || pageData.status >= 400) {
+ console.warn(chalk.yellow(`Warning: ${pageUrl} returned status ${pageData.status}`));
+ return pageData;
+ }
+
+ const contentTypeHeader = res.headers.get('content-type');
+ let type = 'text/html';
+
+ if (contentTypeHeader) {
+ try {
+ const parsed = contentType.parse(contentTypeHeader);
+ type = parsed.type;
+ } catch {
+ console.warn(
+ chalk.yellow(`Warning: ${pageUrl} returned invalid content-type: ${contentTypeHeader}`),
+ );
+ }
+ }
+
+ if (type.startsWith('image/')) {
+ // Skip images
+ return pageData;
+ }
+
+ if (type !== 'text/html') {
+ console.warn(chalk.yellow(`Warning: ${pageUrl} returned non-HTML content-type: ${type}`));
+ // TODO: Handle text/markdown. Parse content as markdown and extract links/targets.
+ return pageData;
+ }
+
+ const content = await res.text();
+
+ const dom = parse(content);
+
+ const ignoredSelector = Array.from(options.ignoredContent)
+ .flatMap((selector) => [selector, `${selector} *`])
+ .join(',');
+ const linksSelector = `a[href]:not(${ignoredSelector})`;
+
+ const pageLinks = dom.querySelectorAll(linksSelector).map((a) => ({
+ src: pageUrl,
+ text: getAccessibleName(a, dom),
+ href: a.getAttribute('href') ?? '',
+ }));
+
+ for (const target of dom.querySelectorAll('*[id]')) {
+ if (!options.ignoredTargets.has(target.id)) {
+ pageData.targets.add(`#${target.id}`);
+ }
+ }
+
+ for (const pageLink of pageLinks) {
+ queue.add(pageLink);
+ }
+
+ return pageData;
+ });
+
+ crawledPages.set(pageUrl, pagePromise);
+
+ await pagePromise;
+ }, options.concurrency);
+
+ for (const seedUrl of options.seedUrls) {
+ queue.add({ src: null, text: null, href: seedUrl });
+ }
+
+ await queue.waitAll();
+
+ if (appProcess) {
+ appProcess.kill();
+ await appProcess.catch(() => {});
+ }
+
+ const results = new Map(
+ await Promise.all(
+ Array.from(crawledPages.entries(), async ([a, b]) => /** @type {const} */ ([a, await b])),
+ ),
+ );
+
+ if (options.outPath) {
+ await writePagesToFile(results, options.outPath);
+ }
+
+ /** Array to collect all issues found during validation */
+ /** @type {Issue[]} */
+ const issues = [];
+
+ /**
+ * Records a broken link or target issue.
+ * @param {Link} link - The link with the issue
+ * @param {'broken-target' | 'broken-link'} type - Type of issue
+ * @param {string} message - Human-readable error message
+ */
+ function recordBrokenLink(link, type, message) {
+ issues.push({
+ type,
+ message,
+ link,
+ });
+ }
+
+ for (const crawledLink of crawledLinks) {
+ const pageUrl = getPageUrl(crawledLink.href, options.ignoredPaths);
+ if (pageUrl !== null) {
+ // Internal link
+ const parsed = new URL(crawledLink.href, 'http://localhost');
+
+ const knownPage = knownTargets.get(pageUrl);
+ if (knownPage) {
+ if (parsed.hash && !knownPage.has(parsed.hash)) {
+ recordBrokenLink(crawledLink, 'broken-target', 'Target not found');
+ } else {
+ // all good
+ }
+ } else {
+ const page = results.get(pageUrl);
+
+ if (!page) {
+ recordBrokenLink(crawledLink, 'broken-link', 'Page not crawled');
+ } else if (page.status >= 400) {
+ recordBrokenLink(crawledLink, 'broken-link', `Page returned error ${page.status}`);
+ } else if (parsed.hash) {
+ if (!page.targets.has(parsed.hash)) {
+ recordBrokenLink(crawledLink, 'broken-target', 'Target not found');
+ }
+ } else {
+ // all good
+ }
+ }
+ }
+ }
+
+ reportIssues(issues);
+
+ // Derive counts from issues
+ const brokenLinks = issues.filter((issue) => issue.type === 'broken-link').length;
+ const brokenLinkTargets = issues.filter((issue) => issue.type === 'broken-target').length;
+
+ const endTime = Date.now();
+ const durationSeconds = (endTime - startTime) / 1000;
+ const duration = new Intl.NumberFormat('en-US', {
+ style: 'unit',
+ unit: 'second',
+ maximumFractionDigits: 2,
+ }).format(durationSeconds);
+ console.log(chalk.blue(`\nCrawl completed in ${duration}`));
+ console.log(` Total links found: ${chalk.cyan(crawledLinks.size)}`);
+ console.log(` Total broken links: ${chalk.cyan(brokenLinks)}`);
+ console.log(` Total broken link targets: ${chalk.cyan(brokenLinkTargets)}`);
+ if (options.outPath) {
+ console.log(chalk.blue(`Output written to: ${options.outPath}`));
+ }
+
+ return { links: crawledLinks, pages: results, issues };
+}
diff --git a/packages/code-infra/src/brokenLinksChecker/index.test.ts b/packages/code-infra/src/brokenLinksChecker/index.test.ts
new file mode 100644
index 000000000..152152566
--- /dev/null
+++ b/packages/code-infra/src/brokenLinksChecker/index.test.ts
@@ -0,0 +1,178 @@
+import path from 'node:path';
+import getPort from 'get-port';
+import { describe, expect, it } from 'vitest';
+
+// eslint-disable-next-line import/extensions
+import { crawl, Issue, Link } from './index.mjs';
+
+type ExpectedIssue = Omit, 'link'> & { link?: Partial };
+
+function objectMatchingIssue(expectedIssue: ExpectedIssue) {
+ return expect.objectContaining({
+ ...expectedIssue,
+ ...(expectedIssue.link ? { link: expect.objectContaining(expectedIssue.link) } : {}),
+ });
+}
+
+/**
+ * Helper to assert that an issue with matching properties exists in the issues array
+ */
+function expectIssue(issues: Issue[], expectedIssue: ExpectedIssue) {
+ expect(issues).toEqual(expect.arrayContaining([objectMatchingIssue(expectedIssue)]));
+}
+
+/**
+ * Helper to assert that no issue with matching properties exists in the issues array
+ */
+function expectNotIssue(issues: Issue[], notExpectedIssue: ExpectedIssue) {
+ expect(issues).not.toEqual(expect.arrayContaining([objectMatchingIssue(notExpectedIssue)]));
+}
+
+describe('Broken Links Checker', () => {
+ const fixtureDir = path.join(import.meta.dirname, '__fixtures__', 'static-site');
+ const servePath = path.join(import.meta.dirname, '..', '..', 'node_modules', '.bin', 'serve');
+
+ it('should detect all broken links and targets across the entire site', async () => {
+ const port = await getPort();
+ const host = `http://localhost:${port}`;
+
+ const result = await crawl({
+ startCommand: `${servePath} ${fixtureDir} -p ${port}`,
+ host,
+ ignoredPaths: [/ignored-page\.html$/],
+ ignoredContent: ['.sidebar'],
+ ignoredTargets: new Set(['__should-be-ignored']),
+ knownTargets: new Map([['/external-page.html', new Set(['#valid-target'])]]),
+ knownTargetsDownloadUrl: [`${host}/known-targets.json`],
+ seedUrls: ['/', '/orphaned-page.html'],
+ });
+
+ expect(result.links).toHaveLength(54);
+ expect(result.issues).toHaveLength(8);
+
+ // Check broken-link type issues
+ expectIssue(result.issues, {
+ type: 'broken-link',
+ link: {
+ src: '/broken-links.html',
+ href: '/does-not-exist.html',
+ text: 'This page does not exist',
+ },
+ });
+
+ expectIssue(result.issues, {
+ type: 'broken-link',
+ link: {
+ src: '/broken-links.html',
+ href: '/another-missing-page.html',
+ text: 'Another missing page',
+ },
+ });
+
+ // Check broken-target type issues
+ expectIssue(result.issues, {
+ type: 'broken-target',
+ link: {
+ src: '/broken-targets.html',
+ href: '/with-anchors.html#nonexistent',
+ text: 'Non-existent anchor',
+ },
+ });
+
+ expectIssue(result.issues, {
+ type: 'broken-target',
+ link: {
+ src: '/broken-targets.html',
+ href: '/valid.html#missing-target',
+ text: 'Valid page, missing target',
+ },
+ });
+
+ expectIssue(result.issues, {
+ type: 'broken-target',
+ link: {
+ src: '/broken-targets.html',
+ href: '/with-anchors.html#also-missing',
+ text: 'Also missing',
+ },
+ });
+
+ // Verify that valid links are not reported
+ expectNotIssue(result.issues, { link: { href: '/' } });
+ expectNotIssue(result.issues, { link: { href: '/valid.html' } });
+ expectNotIssue(result.issues, { link: { href: '/with-anchors.html' } });
+ expectNotIssue(result.issues, { link: { href: '/with-anchors.html#section1' } });
+ expectNotIssue(result.issues, { link: { href: '/with-anchors.html#section2' } });
+ expectNotIssue(result.issues, { link: { href: '/with-anchors.html#section3' } });
+ expectNotIssue(result.issues, { link: { href: '/nested/page.html' } });
+
+ // Verify that external links are not reported
+ expectNotIssue(result.issues, { link: { href: 'https://example.com' } });
+ expectNotIssue(result.issues, { link: { href: 'https://github.com/mui' } });
+
+ // Test ignoredPaths: ignored-page.html should not be crawled
+ expectNotIssue(result.issues, { link: { src: '/ignored-page.html' } });
+ expectNotIssue(result.issues, { link: { href: '/this-link-should-not-be-checked.html' } });
+
+ // Test ignoredContent: links in .sidebar should be ignored
+ expectNotIssue(result.issues, { link: { href: '/sidebar-broken-link.html' } });
+
+ // Test ignoredTargets: __should-be-ignored target should not cause issues
+ expectNotIssue(result.issues, {
+ link: { href: '/page-with-custom-targets.html#__should-be-ignored' },
+ });
+
+ // Test that non-ignored custom target is valid
+ expectNotIssue(result.issues, { link: { href: '/page-with-custom-targets.html#custom-id' } });
+
+ // Test knownTargets: valid-target is known and should not cause issues
+ expectNotIssue(result.issues, { link: { href: '/external-page.html#valid-target' } });
+
+ // Test knownTargets: invalid-target is not in knownTargets and should cause an issue
+ expectIssue(result.issues, {
+ type: 'broken-target',
+ link: {
+ src: '/page-with-known-target-links.html',
+ href: '/external-page.html#invalid-target',
+ text: 'Invalid external target',
+ },
+ });
+
+ // Test knownTargetsDownloadUrl: method1 and method2 are in downloaded known targets
+ expectNotIssue(result.issues, { link: { href: '/api-page.html#method1' } });
+ expectNotIssue(result.issues, { link: { href: '/api-page.html#method2' } });
+
+ // Test knownTargetsDownloadUrl: unknown-method is not in downloaded known targets and should cause an issue
+ expectIssue(result.issues, {
+ type: 'broken-target',
+ link: {
+ src: '/page-with-api-links.html',
+ href: '/api-page.html#unknown-method',
+ text: 'Unknown API method',
+ },
+ });
+
+ // Test seedUrls: orphaned-page.html should be crawled even though it's not linked from anywhere
+ expect(result.pages.has('/orphaned-page.html')).toBe(true);
+
+ // Test seedUrls: broken link from orphaned page should be detected
+ expectIssue(result.issues, {
+ type: 'broken-link',
+ link: {
+ src: '/orphaned-page.html',
+ href: '/orphaned-broken-link.html',
+ text: 'Broken link from orphaned page',
+ },
+ });
+
+ // Test trailing slash normalization: /valid.html and /valid.html/ should be treated as the same page
+ // The orphaned page has both links, but they should not cause duplicate page crawls
+ expectNotIssue(result.issues, { link: { href: '/valid.html/' } });
+
+ // Test content-type checking: links inside markdown files should not be crawled
+ // The example.md file contains a link to /this-should-not-be-checked.html in an HTML snippet
+ expectNotIssue(result.issues, { link: { href: '/this-should-not-be-checked.html' } });
+ // The markdown file itself should not cause issues (it's a valid file, just not HTML)
+ expectNotIssue(result.issues, { link: { href: '/example.md' } });
+ }, 30000);
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bec9c3c99..f05a5739d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -443,6 +443,9 @@ importers:
clipboardy:
specifier: ^5.0.0
version: 5.0.0
+ content-type:
+ specifier: ^1.0.5
+ version: 1.0.5
env-ci:
specifier: ^11.2.0
version: 11.2.0
@@ -494,6 +497,9 @@ importers:
minimatch:
specifier: ^10.0.3
version: 10.0.3
+ node-html-parser:
+ specifier: ^7.0.1
+ version: 7.0.1
open:
specifier: ^10.2.0
version: 10.2.0
@@ -531,6 +537,9 @@ importers:
'@types/babel__preset-env':
specifier: ^7.10.0
version: 7.10.0
+ '@types/content-type':
+ specifier: ^1.1.9
+ version: 1.1.9
'@types/env-ci':
specifier: ^3.1.4
version: 3.1.4
@@ -558,9 +567,15 @@ importers:
eslint:
specifier: ^9.38.0
version: 9.38.0(jiti@2.5.1)
+ get-port:
+ specifier: ^7.1.0
+ version: 7.1.0
prettier:
specifier: ^3.6.2
version: 3.6.2
+ serve:
+ specifier: ^14.2.5
+ version: 14.2.5
packages/docs-infra:
dependencies:
@@ -820,6 +835,7 @@ packages:
'@aws-sdk/credential-provider-web-identity@3.917.0':
resolution: {integrity: sha512-pZncQhFbwW04pB0jcD5OFv3x2gAddDYCVxyJVixgyhSw7bKCYxqu6ramfq1NxyVpmm+qsw+ijwi/3cCmhUHF/A==}
engines: {node: '>=18.0.0'}
+ deprecated: This version contains a compilation TypeScript error https://github.com/aws/aws-sdk-js-v3/issues/7457 - please use @aws-sdk/credential-providers@3.918.0 or higher
'@aws-sdk/credential-providers@3.916.0':
resolution: {integrity: sha512-wazu2awF69ohF3AaDlYkD+tanaqwJ309o9GawNg3o1oW7orhdcvh6P8BftSjuIzuAMiauvQquxcUrNTLxHtvOA==}
@@ -5449,6 +5465,9 @@ packages:
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
+ '@types/content-type@1.1.9':
+ resolution: {integrity: sha512-Hq9IMnfekuOCsEmYl4QX2HBrT+XsfXiupfrLLY8Dcf3Puf4BkBOxSbWYTITSOQAhJoYPBez+b4MJRpIYL65z8A==}
+
'@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
@@ -6034,6 +6053,9 @@ packages:
resolution: {integrity: sha512-/HcYgtUSiJiot/XWGLOlGxPYUG65+/31V8oqk17vZLW1xlCoR4PampyePljOxY2n8/3jz9+tIFzICsyGujJZoA==}
engines: {node: '>=18.12.0'}
+ '@zeit/schemas@2.36.0':
+ resolution: {integrity: sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==}
+
'@zkochan/js-yaml@0.0.7':
resolution: {integrity: sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==}
hasBin: true
@@ -6131,6 +6153,9 @@ packages:
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
+ ajv@8.12.0:
+ resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==}
+
ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
@@ -6185,6 +6210,9 @@ packages:
aproba@2.0.0:
resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==}
+ arch@2.2.0:
+ resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==}
+
archiver-utils@2.1.0:
resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==}
engines: {node: '>= 6'}
@@ -6488,6 +6516,10 @@ packages:
bowser@2.12.1:
resolution: {integrity: sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==}
+ boxen@7.0.0:
+ resolution: {integrity: sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==}
+ engines: {node: '>=14.16'}
+
boxen@8.0.1:
resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==}
engines: {node: '>=18'}
@@ -6553,6 +6585,10 @@ packages:
resolution: {integrity: sha512-tUkzZWK0M/qdoLEqikxBWe4kumyuwjl3HO6zHTr4yEI23EojPtLYXdG1+AQY7MN0cGyNDvEaJ8wiYQm6P2bPxg==}
engines: {node: '>=12.17'}
+ bytes@3.0.0:
+ resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==}
+ engines: {node: '>= 0.8'}
+
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -6603,6 +6639,10 @@ packages:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
+ camelcase@7.0.1:
+ resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==}
+ engines: {node: '>=14.16'}
+
camelcase@8.0.0:
resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
engines: {node: '>=16'}
@@ -6620,6 +6660,10 @@ packages:
chainsaw@0.1.0:
resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==}
+ chalk-template@0.4.0:
+ resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==}
+ engines: {node: '>=12'}
+
chalk@4.1.0:
resolution: {integrity: sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==}
engines: {node: '>=10'}
@@ -6628,6 +6672,10 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
+ chalk@5.0.1:
+ resolution: {integrity: sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==}
+ engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
+
chalk@5.4.1:
resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
@@ -6732,6 +6780,10 @@ packages:
clipboard-copy@4.0.1:
resolution: {integrity: sha512-wOlqdqziE/NNTUJsfSgXmBMIrYmfd5V0HCGsR8uAKHcg+h9NENWINcfRjtWGU77wDHC8B8ijV4hMTGYbrKovng==}
+ clipboardy@3.0.0:
+ resolution: {integrity: sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
clipboardy@4.0.0:
resolution: {integrity: sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w==}
engines: {node: '>=18'}
@@ -6899,6 +6951,10 @@ packages:
console-control-strings@1.1.0:
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
+ content-disposition@0.5.2:
+ resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==}
+ engines: {node: '>= 0.6'}
+
content-disposition@0.5.4:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
@@ -8555,6 +8611,10 @@ packages:
hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
+ he@1.2.0:
+ resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
+ hasBin: true
+
hermes-estree@0.25.1:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
@@ -8994,6 +9054,10 @@ packages:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
+ is-port-reachable@4.0.0:
+ resolution: {integrity: sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
@@ -9796,6 +9860,10 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
+ mime-db@1.33.0:
+ resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==}
+ engines: {node: '>= 0.6'}
+
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
@@ -9804,6 +9872,10 @@ packages:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'}
+ mime-types@2.1.18:
+ resolution: {integrity: sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==}
+ engines: {node: '>= 0.6'}
+
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
@@ -10121,6 +10193,9 @@ packages:
engines: {node: ^18.17.0 || >=20.5.0}
hasBin: true
+ node-html-parser@7.0.1:
+ resolution: {integrity: sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA==}
+
node-machine-id@1.1.12:
resolution: {integrity: sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==}
@@ -10578,6 +10653,9 @@ packages:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
engines: {node: '>=0.10.0'}
+ path-is-inside@1.0.2:
+ resolution: {integrity: sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==}
+
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
@@ -10603,6 +10681,9 @@ packages:
path-to-regexp@0.1.12:
resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==}
+ path-to-regexp@3.3.0:
+ resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==}
+
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
@@ -10970,6 +11051,10 @@ packages:
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
+ range-parser@1.2.0:
+ resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==}
+ engines: {node: '>= 0.6'}
+
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
@@ -11216,10 +11301,17 @@ packages:
resolution: {integrity: sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==}
engines: {node: '>=4'}
+ registry-auth-token@3.3.2:
+ resolution: {integrity: sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==}
+
registry-auth-token@5.1.0:
resolution: {integrity: sha512-GdekYuwLXLxMuFTwAPg5UKGLW/UXzQrZvH/Zj791BQif5T05T0RsaLfHc9q3ZOKi7n+BoprPD9mJ0O0k4xzUlw==}
engines: {node: '>=14'}
+ registry-url@3.1.0:
+ resolution: {integrity: sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==}
+ engines: {node: '>=0.10.0'}
+
registry-url@6.0.1:
resolution: {integrity: sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==}
engines: {node: '>=12'}
@@ -11481,10 +11573,18 @@ packages:
serialize-javascript@6.0.2:
resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
+ serve-handler@6.1.6:
+ resolution: {integrity: sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==}
+
serve-static@1.16.2:
resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==}
engines: {node: '>= 0.8.0'}
+ serve@14.2.5:
+ resolution: {integrity: sha512-Qn/qMkzCcMFVPb60E/hQy+iRLpiU8PamOfOSYoAHmmF+fFFmpPpqa6Oci2iWYpTdOUM3VF+TINud7CfbQnsZbA==}
+ engines: {node: '>= 14'}
+ hasBin: true
+
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
@@ -12460,6 +12560,9 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
+ update-check@1.5.4:
+ resolution: {integrity: sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==}
+
update-notifier@7.3.1:
resolution: {integrity: sha512-+dwUY4L35XFYEzE+OAL3sarJdUioVovq+8f7lcIJ7wnmnYQV5UD1Y/lcwaMSyaQ6Bj3JMj1XSTjZbNLHn/19yA==}
engines: {node: '>=18'}
@@ -12788,6 +12891,10 @@ packages:
wide-align@1.1.5:
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==}
+ widest-line@4.0.1:
+ resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==}
+ engines: {node: '>=12'}
+
widest-line@5.0.0:
resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==}
engines: {node: '>=18'}
@@ -18949,6 +19056,8 @@ snapshots:
dependencies:
'@types/node': 22.18.12
+ '@types/content-type@1.1.9': {}
+
'@types/cookie@0.6.0': {}
'@types/cors@2.8.17':
@@ -19757,6 +19866,8 @@ snapshots:
js-yaml: 3.14.1
tslib: 2.8.1
+ '@zeit/schemas@2.36.0': {}
+
'@zkochan/js-yaml@0.0.7':
dependencies:
argparse: 2.0.1
@@ -19836,6 +19947,13 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
+ ajv@8.12.0:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ json-schema-traverse: 1.0.0
+ require-from-string: 2.0.2
+ uri-js: 4.4.1
+
ajv@8.17.1:
dependencies:
fast-deep-equal: 3.1.3
@@ -19882,6 +20000,8 @@ snapshots:
aproba@2.0.0: {}
+ arch@2.2.0: {}
+
archiver-utils@2.1.0:
dependencies:
glob: 7.2.3
@@ -20271,6 +20391,17 @@ snapshots:
bowser@2.12.1: {}
+ boxen@7.0.0:
+ dependencies:
+ ansi-align: 3.0.1
+ camelcase: 7.0.1
+ chalk: 5.6.2
+ cli-boxes: 3.0.0
+ string-width: 5.1.2
+ type-fest: 2.19.0
+ widest-line: 4.0.1
+ wrap-ansi: 8.1.0
+
boxen@8.0.1:
dependencies:
ansi-align: 3.0.1
@@ -20337,6 +20468,8 @@ snapshots:
byte-size@8.1.1: {}
+ bytes@3.0.0: {}
+
bytes@3.1.2: {}
cacache@19.0.1:
@@ -20417,6 +20550,8 @@ snapshots:
camelcase@5.3.1: {}
+ camelcase@7.0.1: {}
+
camelcase@8.0.0: {}
caniuse-lite@1.0.30001751: {}
@@ -20429,6 +20564,10 @@ snapshots:
dependencies:
traverse: 0.3.9
+ chalk-template@0.4.0:
+ dependencies:
+ chalk: 4.1.2
+
chalk@4.1.0:
dependencies:
ansi-styles: 4.3.0
@@ -20439,6 +20578,8 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
+ chalk@5.0.1: {}
+
chalk@5.4.1: {}
chalk@5.6.2: {}
@@ -20519,6 +20660,12 @@ snapshots:
clipboard-copy@4.0.1: {}
+ clipboardy@3.0.0:
+ dependencies:
+ arch: 2.2.0
+ execa: 5.1.1
+ is-wsl: 2.2.0
+
clipboardy@4.0.0:
dependencies:
execa: 8.0.1
@@ -20710,6 +20857,8 @@ snapshots:
console-control-strings@1.1.0: {}
+ content-disposition@0.5.2: {}
+
content-disposition@0.5.4:
dependencies:
safe-buffer: 5.2.1
@@ -22846,6 +22995,8 @@ snapshots:
property-information: 7.1.0
space-separated-tokens: 2.0.2
+ he@1.2.0: {}
+
hermes-estree@0.25.1: {}
hermes-parser@0.25.1:
@@ -23320,6 +23471,8 @@ snapshots:
is-plain-object@5.0.0: {}
+ is-port-reachable@4.0.0: {}
+
is-potential-custom-element-name@1.0.1: {}
is-property@1.0.2: {}
@@ -24355,10 +24508,16 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.1
+ mime-db@1.33.0: {}
+
mime-db@1.52.0: {}
mime-db@1.54.0: {}
+ mime-types@2.1.18:
+ dependencies:
+ mime-db: 1.33.0
+
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
@@ -24783,6 +24942,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ node-html-parser@7.0.1:
+ dependencies:
+ css-select: 5.2.2
+ he: 1.2.0
+
node-machine-id@1.1.12: {}
node-mock-http@1.0.2: {}
@@ -25356,6 +25520,8 @@ snapshots:
path-is-absolute@1.0.1: {}
+ path-is-inside@1.0.2: {}
+
path-key@3.1.1: {}
path-key@4.0.0: {}
@@ -25376,6 +25542,8 @@ snapshots:
path-to-regexp@0.1.12: {}
+ path-to-regexp@3.3.0: {}
+
path-to-regexp@6.3.0: {}
path-type@3.0.0:
@@ -25789,6 +25957,8 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
+ range-parser@1.2.0: {}
+
range-parser@1.2.1: {}
rasterizehtml@1.3.1:
@@ -26108,10 +26278,19 @@ snapshots:
unicode-match-property-ecmascript: 2.0.0
unicode-match-property-value-ecmascript: 2.2.0
+ registry-auth-token@3.3.2:
+ dependencies:
+ rc: 1.2.8
+ safe-buffer: 5.2.1
+
registry-auth-token@5.1.0:
dependencies:
'@pnpm/npm-conf': 2.3.1
+ registry-url@3.1.0:
+ dependencies:
+ rc: 1.2.8
+
registry-url@6.0.1:
dependencies:
rc: 1.2.8
@@ -26388,6 +26567,16 @@ snapshots:
dependencies:
randombytes: 2.1.0
+ serve-handler@6.1.6:
+ dependencies:
+ bytes: 3.0.0
+ content-disposition: 0.5.2
+ mime-types: 2.1.18
+ minimatch: 3.1.2
+ path-is-inside: 1.0.2
+ path-to-regexp: 3.3.0
+ range-parser: 1.2.0
+
serve-static@1.16.2:
dependencies:
encodeurl: 2.0.0
@@ -26397,6 +26586,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ serve@14.2.5:
+ dependencies:
+ '@zeit/schemas': 2.36.0
+ ajv: 8.12.0
+ arg: 5.0.2
+ boxen: 7.0.0
+ chalk: 5.0.1
+ chalk-template: 0.4.0
+ clipboardy: 3.0.0
+ compression: 1.8.1
+ is-port-reachable: 4.0.0
+ serve-handler: 6.1.6
+ update-check: 1.5.4
+ transitivePeerDependencies:
+ - supports-color
+
set-blocking@2.0.0: {}
set-cookie-parser@2.7.1: {}
@@ -27454,6 +27659,11 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
+ update-check@1.5.4:
+ dependencies:
+ registry-auth-token: 3.3.2
+ registry-url: 3.1.0
+
update-notifier@7.3.1:
dependencies:
boxen: 8.0.1
@@ -27795,6 +28005,10 @@ snapshots:
dependencies:
string-width: 4.2.3
+ widest-line@4.0.1:
+ dependencies:
+ string-width: 5.1.2
+
widest-line@5.0.0:
dependencies:
string-width: 7.2.0