diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml
new file mode 100644
index 000000000..8c9af1d1b
--- /dev/null
+++ b/.github/workflows/check-links.yml
@@ -0,0 +1,92 @@
+name: Check links
+
+# Reports internal links and #anchors that this PR breaks, compared with the
+# merge base. Pre-existing broken links on the base branch are ignored.
+
+on:
+ pull_request:
+
+permissions:
+ contents: read
+ pull-requests: write
+
+jobs:
+ check-links:
+ name: Broken links introduced by this PR
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out pull request head
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
+ fetch-depth: 0
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 10.25.0
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20.19.6
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Check out merge base
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ run: git worktree add "$RUNNER_TEMP/base" "$(git merge-base "$BASE_SHA" HEAD)"
+
+ - name: Record broken links already present on the base branch
+ # Exit 1 means findings, which is expected here
+ run: |
+ node dev/check-links.mjs --check-anchors --format json \
+ --root "$RUNNER_TEMP/base" > "$RUNNER_TEMP/base-links.json" \
+ || [ $? -eq 1 ]
+
+ - name: Find broken links introduced by this PR
+ id: check
+ run: |
+ if node dev/check-links.mjs --check-anchors --format markdown \
+ --baseline "$RUNNER_TEMP/base-links.json" > "$RUNNER_TEMP/report.md"; then
+ echo "broken=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "broken=true" >> "$GITHUB_OUTPUT"
+ fi
+ cat "$RUNNER_TEMP/report.md"
+
+ - name: Comment on the pull request
+ # Fork PRs get a read-only token; the report is still in the job log
+ if: github.event.pull_request.head.repo.full_name == github.repository
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ BROKEN: ${{ steps.check.outputs.broken }}
+ run: |
+ marker=''
+ existing_comment=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
+ --paginate --jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -n 1)
+
+ # Comment only when there is something to report, or an earlier report to resolve
+ if [ "$BROKEN" = true ]; then
+ { echo "$marker"; cat "$RUNNER_TEMP/report.md"; } > "$RUNNER_TEMP/comment.md"
+ elif [ -n "$existing_comment" ]; then
+ printf '%s\n### ā
The broken links an earlier revision of this PR introduced are fixed\n' \
+ "$marker" > "$RUNNER_TEMP/comment.md"
+ else
+ exit 0
+ fi
+
+ if [ -n "$existing_comment" ]; then
+ gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing_comment" \
+ --field body=@"$RUNNER_TEMP/comment.md"
+ else
+ gh pr comment "$PR_NUMBER" --body-file "$RUNNER_TEMP/comment.md"
+ fi
+
+ - name: Fail when this PR introduces broken links
+ if: steps.check.outputs.broken == 'true'
+ run: exit 1
diff --git a/AGENTS.md b/AGENTS.md
index 9a5e9b001..beafaaf42 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,6 +6,7 @@
- **Build**: `npm run build`
- **Dev**: `npm run dev`
- **Lint**: `npm run lint`
+- **Check links**: `npm run check-links -- --check-anchors` (CI comments on PRs that break links; see `dev/check-links.mjs`)
## AI Chat Integration
diff --git a/dev/check-links.mjs b/dev/check-links.mjs
index 2a8b24093..196c202a0 100644
--- a/dev/check-links.mjs
+++ b/dev/check-links.mjs
@@ -8,10 +8,18 @@
*
* Checks for:
* - Broken internal links (markdown and JSX/HTML style)
+ * - Links whose case differs from the real path (work on macOS, 404 on Linux)
* - Missing anchor/heading references
* - Invalid file paths
*
- * Usage: node dev/check-links.mjs [--check-anchors]
+ * Usage: node dev/check-links.mjs [options]
+ * --check-anchors Also validate #anchors against headings
+ * --root
Repository to check (default: this repository)
+ * --format Output as text (default), json, or markdown
+ * --baseline Only report findings absent from this JSON file
+ * (produced by --format json on another revision)
+ *
+ * Exits 1 when any finding is reported.
*/
import fs from 'fs';
@@ -23,25 +31,68 @@ import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
-const DOCS_DIR = path.join(path.dirname(__dirname), 'docs');
-
// Parse CLI flags
const args = process.argv.slice(2);
const CHECK_ANCHORS = args.includes('--check-anchors');
+const ROOT_DIR = path.resolve(flagValue('--root') ?? path.dirname(__dirname));
+const FORMAT = flagValue('--format') ?? 'text';
+const BASELINE_FILE = flagValue('--baseline');
+
+const DOCS_DIR = path.join(ROOT_DIR, 'docs');
+// Files whose links are checked. Only .mdx files become site routes; see
+// `filePathPattern` in contentlayer.config.ts.
+const SOURCE_GLOB = '**/*.{md,mdx}';
+const ROUTE_GLOB = '**/*.mdx';
+
+function flagValue(name) {
+ const index = args.indexOf(name);
+ return index === -1 ? undefined : args[index + 1];
+}
// Regex patterns for extracting links
const MARKDOWN_LINK_REGEX = /\[([^\]]*)\]\(([^)]+)\)/g;
const JSX_HREF_REGEX = /href=["']([^"']+)["']/g;
const SRC_ATTR_REGEX = /src=["']([^"']+)["']/g;
-// Extract headings from MDX content to build anchor map
+// A fence opener/closer is a run of 3+ backticks or tildes at the start of a line.
+const FENCE_LINE_REGEX = /^\s*(`{3,}|~{3,})/;
+
+// Blank out fenced code blocks, keeping line numbers intact, so `# comment`
+// lines and example links inside them are ignored. Walks line by line: a naive
+// /```[\s\S]*?```/ regex also matches inline backtick runs in prose (e.g.
+// `"true```), which flips every later fence pairing.
+function stripFencedCodeBlocks(content) {
+ let openFence;
+ return content.split('\n').map(line => {
+ const fence = line.match(FENCE_LINE_REGEX)?.[1];
+ if (openFence) {
+ const closesOpenFence =
+ fence !== undefined &&
+ fence[0] === openFence[0] &&
+ fence.length >= openFence.length &&
+ line.trim() === fence;
+ if (closesOpenFence) {
+ openFence = undefined;
+ }
+ return '';
+ }
+ if (fence) {
+ openFence = fence;
+ return '';
+ }
+ return line;
+ }).join('\n');
+}
+
+// Extract anchor targets from MDX content: heading slugs, plus explicit
+// and id="..." attributes
function extractHeadings(content) {
const slugger = new GithubSlugger();
const headingRegex = /^#{1,6}\s+(.+)$/gm;
+ const explicitAnchorRegex = /<[a-zA-Z][^>]*\s(?:id|name)=["']([^"']+)["']/g;
const headings = new Set();
- // Remove code blocks to avoid false positives
- const contentWithoutCode = content.replace(/```[\s\S]*?```/g, '');
+ const contentWithoutCode = stripFencedCodeBlocks(content);
let match;
while ((match = headingRegex.exec(contentWithoutCode)) !== null) {
@@ -51,63 +102,69 @@ function extractHeadings(content) {
headings.add(slugger.slug(title.trim()));
}
+ while ((match = explicitAnchorRegex.exec(contentWithoutCode)) !== null) {
+ headings.add(match[1]);
+ }
+
return headings;
}
+// Site route for a file under docs/: foo/bar.mdx -> /foo/bar, foo/index.mdx -> /foo, index.mdx -> /
+function routeFor(file) {
+ return '/' + file.replace(/\.mdx$/, '').replace(/(^|\/)index$/, '');
+}
+
// Get all MDX files and build a map of valid paths
async function buildPathMap() {
- const files = await glob('**/*.mdx', { cwd: DOCS_DIR });
+ // Sorted so foo.mdx precedes foo/index.mdx; when both exist the site serves
+ // the first match (allPosts.find), so the first file owns the route here too.
+ const files = (await glob(ROUTE_GLOB, { cwd: DOCS_DIR })).sort();
const pathMap = new Map();
+ // Lowercased route -> real route, to detect case mismatches
+ const routesByLowerCase = new Map();
const headingsMap = new Map();
+ // Absolute file path -> headings, for same-page #anchor links
+ const headingsByFile = new Map();
for (const file of files) {
const fullPath = path.join(DOCS_DIR, file);
- const content = fs.readFileSync(fullPath, 'utf-8');
+ const headings = extractHeadings(fs.readFileSync(fullPath, 'utf-8'));
+ headingsByFile.set(fullPath, headings);
- // Route path (without .mdx extension)
- const routePath = '/' + file.replace(/\.mdx$/, '').replace(/\/index$/, '');
+ const routePath = routeFor(file);
+ if (pathMap.has(routePath)) continue;
// Also allow trailing slash variant
pathMap.set(routePath, fullPath);
pathMap.set(routePath + '/', fullPath);
-
- // Handle index files
- if (file.endsWith('index.mdx')) {
- const dirPath = '/' + file.replace(/\/index\.mdx$/, '');
- pathMap.set(dirPath, fullPath);
- pathMap.set(dirPath + '/', fullPath);
- }
-
- // Extract headings for anchor validation
- const headings = extractHeadings(content);
+ routesByLowerCase.set(routePath.toLowerCase(), routePath);
headingsMap.set(routePath, headings);
headingsMap.set(routePath + '/', headings);
}
- return { pathMap, headingsMap };
+ return { pathMap, routesByLowerCase, headingsMap, headingsByFile, assetsByLowerCase: await buildAssetMap() };
}
-// Check if a path exists in public directory
-function checkPublicPath(linkPath) {
- const publicPath = path.join(path.dirname(__dirname), 'public', linkPath);
- return fs.existsSync(publicPath);
-}
-
-// Check if a path exists in docs directory (for images in docs/)
-function checkDocsPath(linkPath) {
- const docsPath = path.join(DOCS_DIR, linkPath);
- return fs.existsSync(docsPath);
+// Lowercased link path -> real link path, for files under public/ and docs/
+// (images, PDFs, ...). An enumerated map rather than fs.existsSync, which is
+// case-insensitive on macOS and would hide links that 404 on Linux.
+async function buildAssetMap() {
+ const assetsByLowerCase = new Map();
+ for (const dir of ['public', 'docs']) {
+ const files = await glob('**/*', { cwd: path.join(ROOT_DIR, dir), nodir: true });
+ for (const file of files) {
+ const linkPath = '/' + file;
+ assetsByLowerCase.set(linkPath.toLowerCase(), linkPath);
+ }
+ }
+ return assetsByLowerCase;
}
// Parse and validate links in a single file
function extractLinks(content, filePath) {
const links = [];
- // Remove code blocks to avoid checking links in code examples
- const contentWithoutCode = content.replace(/```[\s\S]*?```/g, (match) => {
- // Replace with same number of newlines to preserve line numbers
- return match.replace(/[^\n]/g, ' ');
- });
+ const contentWithoutCode = stripFencedCodeBlocks(content);
// Extract markdown links [text](url)
let match;
@@ -137,7 +194,7 @@ function extractLinks(content, filePath) {
}
// Check if a link is valid
-function validateLink(link, currentFile, pathMap, headingsMap) {
+function validateLink(link, currentFile, { pathMap, routesByLowerCase, headingsMap, headingsByFile, assetsByLowerCase }) {
const { url } = link;
// Skip external links, mailto, tel, javascript, etc.
@@ -164,10 +221,7 @@ function validateLink(link, currentFile, pathMap, headingsMap) {
return null;
}
const anchor = url.substring(1);
- const currentRoute = '/' + path.relative(DOCS_DIR, currentFile)
- .replace(/\.mdx$/, '')
- .replace(/\/index$/, '');
- const headings = headingsMap.get(currentRoute);
+ const headings = headingsByFile.get(currentFile);
if (headings && !headings.has(anchor)) {
return `Anchor "${anchor}" not found in current file`;
@@ -211,77 +265,147 @@ function validateLink(link, currentFile, pathMap, headingsMap) {
return null;
}
- // Check if it's a public asset
- if (checkPublicPath(resolvedPath)) {
+ // Check if it's an asset under public/ or docs/
+ const realAsset = assetsByLowerCase.get(resolvedPath.toLowerCase());
+ if (realAsset === resolvedPath) {
return null;
}
+ // Same route or asset with different case: resolves on macOS, 404s on the Linux build
+ const realPath = realAsset ?? routesByLowerCase.get(
+ resolvedPath.replace(/\/$/, '').toLowerCase()
+ );
+ if (realPath) {
+ return `Case mismatch: "${resolvedPath}" should be "${realPath}"`;
+ }
+
// Check if it's a file with extension (like .png, .pdf)
if (path.extname(resolvedPath)) {
- // Could be an asset - check public folder or docs folder
- if (checkPublicPath(resolvedPath) || checkDocsPath(resolvedPath)) {
- return null;
- }
return `File not found: "${resolvedPath}"`;
}
return `Page not found: "${resolvedPath}"`;
}
-async function main() {
- console.log('š Checking for dead links in MDX files...\n');
-
- const { pathMap, headingsMap } = await buildPathMap();
- const files = await glob('**/*.mdx', { cwd: DOCS_DIR });
-
- let totalErrors = 0;
- const errors = [];
+// Find every broken link: [{ file, line, url, error }]
+async function findBrokenLinks() {
+ const maps = await buildPathMap();
+ const files = await glob(SOURCE_GLOB, { cwd: DOCS_DIR });
+ const findings = [];
- for (const file of files) {
+ for (const file of files.sort()) {
const fullPath = path.join(DOCS_DIR, file);
const content = fs.readFileSync(fullPath, 'utf-8');
- const links = extractLinks(content, fullPath);
-
- const fileErrors = [];
- for (const link of links) {
- const error = validateLink(link, fullPath, pathMap, headingsMap);
+ for (const link of extractLinks(content, fullPath)) {
+ const error = validateLink(link, fullPath, maps);
if (error) {
- fileErrors.push({
+ findings.push({
+ file: `docs/${file}`,
line: link.lineNumber,
url: link.url,
error
});
}
}
-
- if (fileErrors.length > 0) {
- errors.push({
- file: `docs/${file}`,
- errors: fileErrors
- });
- totalErrors += fileErrors.length;
- }
}
- // Output results
- if (errors.length === 0) {
- console.log('ā
No dead links found!');
- process.exit(0);
+ return findings;
+}
+
+// Identity of a finding across revisions: line numbers shift, so ignore them
+function findingKey({ file, url, error }) {
+ return `${file}\n${url}\n${error}`;
+}
+
+function withoutBaseline(findings, baselineFile) {
+ const baseline = new Set(
+ JSON.parse(fs.readFileSync(baselineFile, 'utf-8')).map(findingKey)
+ );
+ return findings.filter(finding => !baseline.has(findingKey(finding)));
+}
+
+function groupByFile(findings) {
+ const byFile = new Map();
+ for (const finding of findings) {
+ if (!byFile.has(finding.file)) {
+ byFile.set(finding.file, []);
+ }
+ byFile.get(finding.file).push(finding);
+ }
+ return byFile;
+}
+
+function formatText(findings) {
+ const scope = BASELINE_FILE ? 'new ' : '';
+ if (findings.length === 0) {
+ return `ā
No ${scope}dead links found!\n`;
}
- console.log(`ā Found ${totalErrors} dead link(s) in ${errors.length} file(s):\n`);
+ const byFile = groupByFile(findings);
+ const lines = [
+ `ā Found ${findings.length} ${scope}dead link(s) in ${byFile.size} file(s):\n`
+ ];
+ for (const [file, fileFindings] of byFile) {
+ lines.push(`\nš ${file}`);
+ for (const { line, url, error } of fileFindings) {
+ lines.push(` Line ${line}: ${url}`);
+ lines.push(` āā ${error}`);
+ }
+ }
+ return lines.join('\n') + '\n';
+}
+
+// Body for a pull request comment
+function formatMarkdown(findings) {
+ if (findings.length === 0) {
+ return '### ā
This PR introduces no broken links\n';
+ }
- for (const { file, errors: fileErrors } of errors) {
- console.log(`\nš ${file}`);
- for (const { line, url, error } of fileErrors) {
- console.log(` Line ${line}: ${url}`);
- console.log(` āā ${error}`);
+ const lines = [
+ `### ā This PR introduces ${findings.length} broken link(s)`,
+ '',
+ 'Findings in files this PR did not change mean the PR removed or ' +
+ 'renamed a page or heading that those files link to.',
+ ''
+ ];
+ for (const [file, fileFindings] of groupByFile(findings)) {
+ lines.push(`**\`${file}\`**`);
+ for (const { line, url, error } of fileFindings) {
+ lines.push(`- line ${line}: \`${url}\` ā ${error}`);
}
+ lines.push('');
+ }
+ lines.push(
+ 'Reproduce locally with `pnpm check-links --check-anchors` ' +
+ '(see `dev/check-links.mjs`).'
+ );
+ return lines.join('\n') + '\n';
+}
+
+const FORMATTERS = {
+ text: formatText,
+ json: findings => JSON.stringify(findings, null, '\t') + '\n',
+ markdown: formatMarkdown
+};
+
+async function main() {
+ const format = FORMATTERS[FORMAT];
+ if (!format) {
+ throw new Error(`Unknown --format "${FORMAT}"; use text, json, or markdown`);
}
- console.log('\n');
- process.exit(1);
+ if (FORMAT === 'text') {
+ console.log('š Checking for dead links in MDX files...\n');
+ }
+
+ let findings = await findBrokenLinks();
+ if (BASELINE_FILE) {
+ findings = withoutBaseline(findings, BASELINE_FILE);
+ }
+
+ process.stdout.write(format(findings));
+ process.exit(findings.length === 0 ? 0 : 1);
}
main().catch(err => {