diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..f30a96f --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,53 @@ +# Blocking documentation checks for pull requests. +# +# `validate-links` also runs inside `prebuild`, so a broken link already fails +# the Vercel build. This job exists to make that a fast, named status check +# that branch protection can require, rather than a failure buried in a +# deploy log. +name: Docs + +on: + pull_request: + # v2 is the live documentation line. `main` is deliberately excluded: it + # trails v2 by ~100 commits and its bun.lock is out of sync with its + # package.json, so `--frozen-lockfile` fails there for reasons that have + # nothing to do with links. Add `main` here once #37 lands and makes it + # current. + branches: [v2] + push: + branches: [v2] + workflow_dispatch: + +# A PR that gets pushed to twice shouldn't queue behind its own stale run. +concurrency: + group: docs-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + links: + name: Internal links + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + + - uses: oven-sh/setup-bun@v2 + with: + # Pinned rather than `latest` so a bun release can't turn a + # required check red on its own. + bun-version: 1.3.13 + + - run: bun install --frozen-lockfile + + # The TypeDoc reference under content/stack/reference/stack/latest/ is + # gitignored and built from a clone of cipherstash/stack. Roughly 18 + # links point into it, so without this step they all report as missing. + # `prebuild` generates before validating for the same reason. + - name: Generate the SDK reference + run: bun run generate-docs + + - name: Validate internal links and anchors + run: bun run validate-links diff --git a/content/docs/reference/eql/v2/index.mdx b/content/docs/reference/eql/v2/index.mdx index d25131a..9095659 100644 --- a/content/docs/reference/eql/v2/index.mdx +++ b/content/docs/reference/eql/v2/index.mdx @@ -76,19 +76,19 @@ EQL supports multiple searchable encryption index types. Each index type enables Enables exact equality queries and unique constraints using HMAC-SHA256. -[Learn more about exact indexes](/stack/cipherstash/encryption/searchable-encryption#exact-match) +[Learn more about exact indexes](/stack/cipherstash/encryption/searchable-encryption#exact-matching) ### `ore`: Range queries Enables range comparisons (`<`, `>`, `BETWEEN`) and ordering (`ORDER BY`) using Order Revealing Encryption. -[Learn more about range indexes](/stack/cipherstash/encryption/searchable-encryption#range--order) +[Learn more about range indexes](/stack/cipherstash/encryption/searchable-encryption#sorting-and-range-queries) ### `match`: Pattern matching Enables substring and full-text search (`LIKE`, `ILIKE`) using encrypted Bloom filters with trigrams. -[Learn more about match indexes](/stack/cipherstash/encryption/searchable-encryption#match-pattern) +[Learn more about match indexes](/stack/cipherstash/encryption/searchable-encryption#free-text-search) ### `ste_vec`: Structured data diff --git a/content/docs/reference/eql/v2/payload.mdx b/content/docs/reference/eql/v2/payload.mdx index 953264d..3e7503a 100644 --- a/content/docs/reference/eql/v2/payload.mdx +++ b/content/docs/reference/eql/v2/payload.mdx @@ -157,7 +157,7 @@ Enables exact match queries using HMAC-SHA256. **Type**: Hex-encoded string (64 characters) -**Index Type**: [Exact](/stack/cipherstash/encryption/searchable-encryption#exact-match) +**Index Type**: [Exact](/stack/cipherstash/encryption/searchable-encryption#exact-matching) ```json { @@ -171,7 +171,7 @@ Enables range queries and ordering using Order Revealing Encryption. **Type**: Array of strings -**Index Type**: [Order / Range](/stack/cipherstash/encryption/searchable-encryption#range--order) +**Index Type**: [Order / Range](/stack/cipherstash/encryption/searchable-encryption#sorting-and-range-queries) ```json { @@ -188,7 +188,7 @@ Enables substring and pattern matching queries using encrypted Bloom filters wit **Type**: Array of integers -**Index Type**: [Match](/stack/cipherstash/encryption/searchable-encryption#match-pattern) +**Index Type**: [Match](/stack/cipherstash/encryption/searchable-encryption#free-text-search) ```json { diff --git a/content/stack/reference/cipher-cell.mdx b/content/stack/reference/cipher-cell.mdx index 078c4ea..a9f60e5 100644 --- a/content/stack/reference/cipher-cell.mdx +++ b/content/stack/reference/cipher-cell.mdx @@ -149,7 +149,7 @@ Enables exact match queries using HMAC-SHA256. **Type**: Hex-encoded string (64 characters) -**Index Type**: [Exact](/stack/cipherstash/encryption/searchable-encryption#exact-match) +**Index Type**: [Exact](/stack/cipherstash/encryption/searchable-encryption#exact-matching) ```json { @@ -163,7 +163,7 @@ Enables range queries and ordering using Order Revealing Encryption. **Type**: Array of strings -**Index Type**: [Order / Range](/stack/cipherstash/encryption/searchable-encryption#range--order) +**Index Type**: [Order / Range](/stack/cipherstash/encryption/searchable-encryption#sorting-and-range-queries) ```json { @@ -180,7 +180,7 @@ Enables substring and pattern matching queries using encrypted Bloom filters wit **Type**: Array of integers -**Index Type**: [Match](/stack/cipherstash/encryption/searchable-encryption#match-pattern) +**Index Type**: [Match](/stack/cipherstash/encryption/searchable-encryption#free-text-search) ```json { diff --git a/content/stack/reference/drizzle.mdx b/content/stack/reference/drizzle.mdx index a19ef0f..c69a68f 100644 --- a/content/stack/reference/drizzle.mdx +++ b/content/stack/reference/drizzle.mdx @@ -131,7 +131,7 @@ See the [CipherStash CLI reference](/stack/cipherstash/cli) for all `db install` ## Full API surface -The Drizzle adapter now ships as its own package, `@cipherstash/stack-drizzle`. The EQL v3 dialect is on `@cipherstash/stack-drizzle/v3`. See the [Drizzle integration](/integrations/drizzle) for the current, EQL v3 surface: +The Drizzle adapter now ships as its own package, `@cipherstash/stack-drizzle`. The EQL v3 dialect is on `@cipherstash/stack-drizzle/v3`. Its surface: - `encryptedType` — column builder - `extractEncryptionSchema` — schema conversion diff --git a/content/stack/reference/eql-guide.mdx b/content/stack/reference/eql-guide.mdx index 9dfa917..3649d17 100644 --- a/content/stack/reference/eql-guide.mdx +++ b/content/stack/reference/eql-guide.mdx @@ -87,19 +87,19 @@ EQL supports multiple searchable encryption index types. Each index type enables Enables exact equality queries and unique constraints using HMAC-SHA256. -[Learn more about exact indexes](/stack/cipherstash/encryption/searchable-encryption#exact-match) +[Learn more about exact indexes](/stack/cipherstash/encryption/searchable-encryption#exact-matching) ### `ore`: Range queries Enables range comparisons (`<`, `>`, `BETWEEN`) and ordering (`ORDER BY`) using Order Revealing Encryption. -[Learn more about range indexes](/stack/cipherstash/encryption/searchable-encryption#range--order) +[Learn more about range indexes](/stack/cipherstash/encryption/searchable-encryption#sorting-and-range-queries) ### `match`: Pattern matching Enables substring and full-text search (`LIKE`, `ILIKE`) using encrypted Bloom filters with trigrams. -[Learn more about match indexes](/stack/cipherstash/encryption/searchable-encryption#match-pattern) +[Learn more about match indexes](/stack/cipherstash/encryption/searchable-encryption#free-text-search) ### `ste_vec`: Structured data diff --git a/content/stack/reference/glossary.mdx b/content/stack/reference/glossary.mdx index d52d0c6..559e5b0 100644 --- a/content/stack/reference/glossary.mdx +++ b/content/stack/reference/glossary.mdx @@ -131,7 +131,7 @@ It facilitates secure single sign-on (SSO) and simplifies the authentication pro ### ORE (Order Revealing Encryption) A searchable encryption technique allowing for search, comparison, and sorting of encrypted data without decryption. -See [Range queries](/stack/cipherstash/encryption/searchable-encryption#range--order) for details. +See [Range queries](/stack/cipherstash/encryption/searchable-encryption#sorting-and-range-queries) for details. ## P diff --git a/scripts/validate-links.ts b/scripts/validate-links.ts index 6e38477..5f1dc8b 100644 --- a/scripts/validate-links.ts +++ b/scripts/validate-links.ts @@ -1,210 +1,388 @@ +/** + * Internal-link validation across every MDX page the site serves. + * + * Both content collections are covered: `content/docs` (the v2 IA, served from + * the site root) and `content/stack` (the legacy tree, served under /stack). + * This script used to know only about content/stack and only about markdown + * link syntax, which left the entire v2 tree — and every `` in + * both trees — unchecked. A page could be renamed or merged away and every + * link to it would 404 against a green build. + * + * For markdown links (`[text](/url)`) and JSX href attributes (`href="/url"`) + * alike, it checks that: + * + * - the target page exists, in either collection; + * - a `#fragment` matches a heading on that page; + * - the link carries no `/docs` prefix (Next's basePath prepends it), no + * `/index` suffix, and no `.mdx` extension — each of which 404s. + * + * Non-page targets are resolved rather than waved through: static files under + * `public/`, and static route handlers under `src/app` (`/llms.txt` and + * friends). Anything genuinely unresolvable is an error, so the exceptions + * stay honest. + * + * Anchors are slugged with the same `github-slugger` Fumadocs uses, one + * slugger per page so repeated headings get the `-1`/`-2` suffixes the + * renderer produces. Hand-rolled slugification gets this wrong in ways that + * matter: "Range & order" is `range--order`, not `range-order`. + * + * Every line carries its true origin (see `SourceLine`), so a broken link + * inside an ``d partial is reported against the partial's own path + * and line rather than an offset position in whichever page pulled it in. + */ import fs from "node:fs"; import path from "node:path"; +import GithubSlugger from "github-slugger"; + +const ROOT = process.cwd(); + +/** + * Content collections and the URL prefix each is served under. Mirrors the + * `loader({ baseUrl })` calls in src/lib/source.ts — keep them in step. + */ +const COLLECTIONS = [ + { dir: "content/docs", baseUrl: "" }, + { dir: "content/stack", baseUrl: "/stack" }, +]; -const CONTENT_DIR = path.join(process.cwd(), "content/stack"); +/** Shared MDX fragments, pulled into pages by ``. Not routable. */ +const PARTIALS_DIR = "content/partials"; /** - * Recursively collect all .mdx files in a directory. + * A line of MDX plus where it actually came from. Inlining a partial would + * otherwise renumber every line below the `` directive, silently + * shifting reported positions by the length of the partial. */ +interface SourceLine { + text: string; + /** Repo-relative path of the file this line is really in. */ + file: string; + /** 1-indexed line number within that file. */ + line: number; +} + +interface Page { + /** Repo-relative path of the .mdx file, for error messages. */ + file: string; + /** Heading ids usable as #fragments on this page. */ + anchors: Set; +} + +interface BrokenLink { + file: string; + line: number; + url: string; + reason: string; +} + function collectMdxFiles(dir: string): string[] { const results: string[] = []; + if (!fs.existsSync(dir)) return results; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - results.push(...collectMdxFiles(fullPath)); - } else if (entry.isFile() && entry.name.endsWith(".mdx")) { + if (entry.isDirectory()) results.push(...collectMdxFiles(fullPath)); + else if (entry.isFile() && entry.name.endsWith(".mdx")) results.push(fullPath); - } } return results; } /** - * Convert a file path to its corresponding URL. - * content/stack/cipherstash/encryption/getting-started.mdx → /stack/cipherstash/encryption/getting-started - * content/stack/reference/stack/latest/schema/index.mdx → /stack/reference/stack/latest/schema + * Read a file into `SourceLine`s with fenced code blocks blanked out. Blanking + * rather than dropping keeps every line's index aligned with the file on disk. + * Headings and links inside a code sample are illustrative, not real. */ -function filePathToUrl(filePath: string): string { - let rel = path.relative(CONTENT_DIR, filePath); - // Remove .mdx extension - rel = rel.replace(/\.mdx$/, ""); - // Remove trailing /index - rel = rel.replace(/\/index$/, ""); - return `/stack/${rel}`; +function readLines(file: string): SourceLine[] { + const rel = path.relative(ROOT, file); + let fence: string | null = null; + return fs + .readFileSync(file, "utf8") + .split("\n") + .map((text, i) => { + const open = /^\s*(`{3,}|~{3,})/.exec(text); + let out = text; + if (fence) { + if (open && open[1][0] === fence[0] && open[1].length >= fence.length) + fence = null; + out = ""; + } else if (open) { + fence = open[1]; + out = ""; + } + return { text: out, file: rel, line: i + 1 }; + }); } /** - * Check if a URL has a /index suffix that would 404 at runtime. - * Fumadocs serves index.mdx at the directory URL, so /path/index is not a valid route. + * Fumadocs' `path` inlines a partial at build time, so + * the partial's headings become anchors on the including page and its links + * are rendered by it. One level deep — partials don't nest today. + * + * The inlined lines keep the partial's own file and line numbers, so positions + * below the directive stay correct in the including page. */ -function hasIndexSuffix(url: string): boolean { - return /\/index$/.test(url); +function inlineIncludes(lines: SourceLine[]): SourceLine[] { + const out: SourceLine[] = []; + for (const line of lines) { + out.push(line); + const match = /([^<]+)<\/include>/.exec(line.text); + if (!match) continue; + const partial = path.join(ROOT, match[1].trim()); + if (fs.existsSync(partial)) out.push(...readLines(partial)); + } + return out; } -/** - * Check if a URL has a .mdx extension that should have been stripped. - */ -function hasMdxExtension(url: string): boolean { - return /\.mdx$/.test(url); +/** Reduce inline markdown to the plain text the slugger would see. */ +function headingText(raw: string): string { + return raw + .replace(/\[#[^\]]+\]\s*$/, "") // explicit anchor suffix + .replace(/`([^`]*)`/g, "$1") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/\*\*([^*]*)\*\*/g, "$1") + .replace(/\*([^*]*)\*/g, "$1") + .replace(/<[^>]+>/g, "") + .trim(); } -/** - * Build set of all valid internal URLs from MDX files. - */ -function buildValidUrls(mdxFiles: string[]): Set { - const urls = new Set(); - for (const file of mdxFiles) { - urls.add(filePathToUrl(file)); +function collectAnchors(lines: SourceLine[]): Set { + const slugger = new GithubSlugger(); + const anchors = new Set(); + for (const { text } of lines) { + const match = /^#{1,6}\s+(.+?)\s*$/.exec(text); + if (!match) continue; + const explicit = /\[#([^\]]+)\]\s*$/.exec(match[1]); + if (explicit) { + anchors.add(explicit[1]); + continue; + } + anchors.add(slugger.slug(headingText(match[1]))); } - return urls; + return anchors; } -interface BrokenLink { - file: string; - line: number; - url: string; - reason: string; +/** content/docs/a/b.mdx → /a/b ; content/stack/a/index.mdx → /stack/a */ +function fileToUrl(file: string, dir: string, baseUrl: string): string { + const rel = path + .relative(path.join(ROOT, dir), file) + .replace(/\.mdx$/, "") + .replace(/(^|\/)index$/, ""); + return `${baseUrl}/${rel}`.replace(/\/+$/, "") || "/"; } +/** True when `child` is `parent` itself or sits underneath it. */ +function isInside(child: string, parent: string): boolean { + return child === parent || child.startsWith(parent + path.sep); +} + +type Resolution = + | { url: string } + | { error: string } + // Not a link we can or should resolve (external, anchor-only, expression). + | null; + /** - * Resolve a relative link to an absolute URL path using the file's actual location. - * Does NOT strip /index or .mdx — the caller should detect and report those as errors. + * Resolve a relative link against the collection directory of the file it + * appears in. Relative links that escape the collection, or that live in a + * shared partial where there is no single base to resolve against, are + * reported rather than skipped — a silent skip is how a typo gets through. */ -function resolveRelativeLink(link: string, currentFilePath: string): string { - const currentDir = path.dirname(currentFilePath); - const resolvedPath = path.join(currentDir, link); - const rel = path.relative(CONTENT_DIR, resolvedPath); - return `/stack/${rel}`; +function resolveRelative(file: string, link: string): Resolution { + const abs = path.join(ROOT, file); + if (isInside(abs, path.join(ROOT, PARTIALS_DIR))) { + return { + error: + "Relative link inside a shared partial — it would resolve differently " + + "per including page. Use an absolute path.", + }; + } + for (const { dir, baseUrl } of COLLECTIONS) { + const base = path.join(ROOT, dir); + if (!isInside(abs, base)) continue; + const resolved = path.resolve(path.dirname(abs), link); + if (!isInside(resolved, base)) + return { error: `Relative link resolves outside ${dir}.` }; + return { + url: `${baseUrl}/${path.relative(base, resolved)}`.replace(/\/+$/, ""), + }; + } + return { error: "Relative link in a file outside every content collection." }; +} + +// --- Build the set of things a link may legitimately point at --------------- + +const pages = new Map(); +/** Per-page line arrays, so each file is read and de-fenced exactly once. */ +const fileLines = new Map(); + +for (const { dir, baseUrl } of COLLECTIONS) { + for (const file of collectMdxFiles(path.join(ROOT, dir))) { + const lines = inlineIncludes(readLines(file)); + fileLines.set(file, lines); + pages.set(fileToUrl(file, dir, baseUrl), { + file: path.relative(ROOT, file), + anchors: collectAnchors(lines), + }); + } } +/** Static files under public/ are served at the site root. */ +const assets = new Set(); +const walkAssets = (dir: string, prefix = "") => { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) + walkAssets(path.join(dir, entry.name), `${prefix}/${entry.name}`); + else assets.add(`${prefix}/${entry.name}`); + } +}; +walkAssets(path.join(ROOT, "public")); + /** - * Scan an MDX file for broken internal links. + * Static route handlers (src/app/llms.txt/route.ts → /llms.txt). Dynamic and + * group segments are skipped: their paths can't be enumerated statically, and + * no documentation link targets one. */ -function scanFile(filePath: string, validUrls: Set): BrokenLink[] { - const broken: BrokenLink[] = []; - const content = fs.readFileSync(filePath, "utf8"); - const lines = content.split("\n"); +const routes = new Set(); +const walkRoutes = (dir: string, prefix = "") => { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (entry.name.startsWith("[") || entry.name.startsWith("(")) continue; + walkRoutes(path.join(dir, entry.name), `${prefix}/${entry.name}`); + } else if (/^route\.tsx?$/.test(entry.name) && prefix) { + routes.add(prefix); + } + } +}; +walkRoutes(path.join(ROOT, "src/app")); - // Match markdown links: [text](url) - const linkRegex = /\[([^\]]*)\]\(([^)]+)\)/g; +// --- Scan ------------------------------------------------------------------ - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; +const LINK_PATTERNS = [ + /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, // [text](/url "title") + /href="([^"]+)"/g, // +]; - for (const match of line.matchAll(linkRegex)) { - const url = match[2]; +function scanLines(lines: SourceLine[]): BrokenLink[] { + const broken: BrokenLink[] = []; - // Skip external links - if (url.startsWith("http://") || url.startsWith("https://")) continue; + for (const { text, file, line } of lines) { + const report = (url: string, reason: string) => + broken.push({ file, line, url, reason }); - // Skip anchor-only links - if (url.startsWith("#")) continue; + for (const pattern of LINK_PATTERNS) { + for (const match of text.matchAll(pattern)) { + const url = match[1]; + // External, protocol-relative, anchor-only, or a JSX expression. + if (/^(https?:|mailto:|tel:|\/\/|#|\{)/.test(url)) continue; - // Skip mailto links - if (url.startsWith("mailto:")) continue; + const [targetRaw, fragment] = url.split("#"); + if (!targetRaw) continue; - // Strip anchor from URL for validation - const urlWithoutAnchor = url.split("#")[0]; + let target: string; + if (targetRaw.startsWith("/")) { + target = targetRaw; + } else { + const resolved = resolveRelative(file, targetRaw); + if (resolved === null) continue; + if ("error" in resolved) { + report(url, resolved.error); + continue; + } + target = resolved.url; + } - // Check for wrong /docs/ prefix - if (urlWithoutAnchor.startsWith("/docs/")) { - broken.push({ - file: path.relative(process.cwd(), filePath), - line: i + 1, - url, - reason: - "Uses /docs/ prefix — Next.js basePath prepends this automatically. Use /stack/ instead.", - }); - continue; - } + // Normalise a trailing slash before the shape checks, so `/a/index/` + // and `/a.mdx/` get their precise diagnosis rather than falling + // through to a generic "no such page". + const normalized = target.replace(/\/+$/, "") || "/"; - // Internal absolute links (start with /stack/) - if (urlWithoutAnchor.startsWith("/stack/")) { - if (hasIndexSuffix(urlWithoutAnchor)) { - broken.push({ - file: path.relative(process.cwd(), filePath), - line: i + 1, + if (normalized === "/docs" || normalized.startsWith("/docs/")) { + report( url, - reason: - "Link ends with /index which will 404. Remove /index — Fumadocs serves index.mdx at the directory URL.", - }); - } else if (hasMdxExtension(urlWithoutAnchor)) { - broken.push({ - file: path.relative(process.cwd(), filePath), - line: i + 1, - url, - reason: - "Link has .mdx extension which will 404. Remove the .mdx extension.", - }); - } else if (!validUrls.has(urlWithoutAnchor)) { - broken.push({ - file: path.relative(process.cwd(), filePath), - line: i + 1, + "Uses a /docs prefix — Next's basePath prepends it automatically, so this resolves to /docs/docs/… and 404s.", + ); + continue; + } + if (/\/index$/.test(normalized)) { + report( url, - reason: "Page not found", - }); + "Ends with /index, which 404s — Fumadocs serves index.mdx at the directory URL.", + ); + continue; + } + if (/\.mdx$/.test(normalized)) { + report(url, "Has a .mdx extension, which 404s as a page link."); + continue; } - continue; - } - - // Skip other absolute links (e.g., /api/, external paths) - if (urlWithoutAnchor.startsWith("/")) continue; - // Relative links — resolve against current file's directory - const resolved = resolveRelativeLink(urlWithoutAnchor, filePath); - if (resolved.startsWith("/stack/")) { - if (hasIndexSuffix(resolved)) { - broken.push({ - file: path.relative(process.cwd(), filePath), - line: i + 1, - url, - reason: `Link resolves to ${resolved} which ends with /index and will 404. Remove /index from the link target.`, - }); - } else if (hasMdxExtension(resolved)) { - broken.push({ - file: path.relative(process.cwd(), filePath), - line: i + 1, - url, - reason: `Link resolves to ${resolved} which has .mdx extension and will 404. Remove the .mdx extension.`, - }); - } else if (!validUrls.has(resolved)) { - broken.push({ - file: path.relative(process.cwd(), filePath), - line: i + 1, + const page = pages.get(normalized); + if (!page) { + if (assets.has(normalized) || routes.has(normalized)) continue; + report(url, "No such page, static asset, or route."); + continue; + } + if (fragment && !page.anchors.has(fragment)) { + report( url, - reason: `Page not found (resolved to ${resolved})`, - }); + `No heading on ${normalized} (${page.file}) produces the anchor #${fragment}.`, + ); } } } } - return broken; } -// Main -const mdxFiles = collectMdxFiles(CONTENT_DIR); -console.log(`Found ${mdxFiles.length} MDX files in content/stack/`); +/** + * A partial included by several pages is scanned once per including page, so + * de-duplicate before reporting — otherwise one typo in a shared fragment + * shows up three times. + */ +const seen = new Set(); +const allBroken: BrokenLink[] = []; +for (const lines of fileLines.values()) { + for (const b of scanLines(lines)) { + const key = `${b.file}:${b.line}:${b.url}`; + if (seen.has(key)) continue; + seen.add(key); + allBroken.push(b); + } +} +allBroken.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line); -const validUrls = buildValidUrls(mdxFiles); -console.log(`Built ${validUrls.size} valid URLs\n`); +/** + * TypeDoc output is gitignored and produced by `generate-docs`, which + * `prebuild` runs before this script. Run standalone on a fresh checkout, + * every link into those pages looks broken — say so rather than let a hundred + * spurious errors imply the docs are falling apart. + */ +const GENERATED = ["content/stack/reference/stack/latest"]; +const missing = GENERATED.filter((d) => !fs.existsSync(path.join(ROOT, d))); -const allBroken: BrokenLink[] = []; +console.log( + `Checked ${fileLines.size} MDX file(s) across ${COLLECTIONS.map((c) => c.dir).join(" + ")} ` + + `→ ${pages.size} page(s), ${assets.size} static asset(s), ${routes.size} route(s).`, +); -for (const file of mdxFiles) { - const broken = scanFile(file, validUrls); - allBroken.push(...broken); +if (missing.length > 0) { + console.log( + `\n! Generated API pages are absent (${missing.join(", ")}), so links into\n` + + " them will be reported as missing. Run `bun run generate-docs` first —\n" + + " `prebuild` does, which is why CI does not hit this.", + ); } if (allBroken.length === 0) { - console.log("No broken links found!"); + console.log("✓ every internal link and anchor resolves."); process.exit(0); -} else { - console.log(`Found ${allBroken.length} broken link(s):\n`); - for (const { file, line, url, reason } of allBroken) { - console.log(` ${file}:${line}`); - console.log(` Link: ${url}`); - console.log(` ${reason}\n`); - } - process.exit(1); } + +console.log(`\n✗ ${allBroken.length} broken link(s):\n`); +for (const { file, line, url, reason } of allBroken) { + console.log(` ${file}:${line}`); + console.log(` Link: ${url}`); + console.log(` ${reason}\n`); +} +process.exit(1);