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 => {