diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml new file mode 100644 index 000000000..e0dab4ba3 --- /dev/null +++ b/.github/workflows/check-links.yml @@ -0,0 +1,121 @@ +name: Check links + +# Reports internal links and #anchors that this PR breaks, compared with the +# merge base, absolute links to this site, and external links on added lines +# that 404. Pre-existing broken links on the base branch are ignored. + +on: + pull_request: + +# A new push supersedes the run for the previous one +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +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 github-slugger, the only dependency of dev/check-links.mjs + # Into a scratch prefix, not the repo: `npm install ` next to + # package.json would install every dependency of the site + run: | + npm install --prefix "$RUNNER_TEMP/deps" --no-package-lock --no-audit --no-fund \ + "github-slugger@$(node -p 'require("./package.json").dependencies["github-slugger"]')" + ln -s "$RUNNER_TEMP/deps/node_modules" node_modules + + - name: Check out merge base + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + merge_base=$(git merge-base "$BASE_SHA" HEAD) + git worktree add "$RUNNER_TEMP/base" "$merge_base" + git diff -U0 "$merge_base" HEAD > "$RUNNER_TEMP/changes.diff" + + - 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 --check-self-links --format json \ + --root "$RUNNER_TEMP/base" > "$RUNNER_TEMP/base-links.json" \ + || [ $? -eq 1 ] + + - name: Find broken links introduced by this PR + id: check + env: + # File links in the report open the file on the PR branch + LINK_BASE: ${{ github.event.pull_request.head.repo.html_url }}/blob/${{ github.event.pull_request.head.ref }} + run: | + if node dev/check-links.mjs --check-anchors --check-self-links --check-external --format markdown \ + --baseline "$RUNNER_TEMP/base-links.json" \ + --diff "$RUNNER_TEMP/changes.diff" \ + --review "$RUNNER_TEMP/review.json" \ + --link-base "$LINK_BASE" > "$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: Suggest fixes as review comments + # One suggested change per added line with a fix. Suggestions already on + # the PR (same file, line, and text) are not posted again. + if: steps.check.outputs.broken == 'true' && github.event.pull_request.head.repo.full_name == github.repository + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments" --paginate \ + --jq '.[] | {path, line, body}' | jq -s . > "$RUNNER_TEMP/posted.json" + jq --slurpfile posted "$RUNNER_TEMP/posted.json" \ + '.comments |= map(select(. as $comment | $posted[0] | index({path: $comment.path, line: $comment.line, body: $comment.body}) | not))' \ + "$RUNNER_TEMP/review.json" > "$RUNNER_TEMP/review-new.json" + + if [ "$(jq '.comments | length' "$RUNNER_TEMP/review-new.json")" -gt 0 ]; then + gh api --method POST "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews" \ + --input "$RUNNER_TEMP/review-new.json" > /dev/null \ + || echo "::warning::Could not post the suggested fixes; they are in the report above" + 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..e0c1083c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,8 @@ - **Build**: `npm run build` - **Dev**: `npm run dev` - **Lint**: `npm run lint` +- **Check links**: `npm run check-links -- --check-anchors --check-self-links` (CI comments on PRs that break links; see `dev/check-links.mjs`; `next build` runs it without flags, so only dead page links fail a deploy). When moving a page or renaming a heading, update every link to it; a redirect in `src/data/redirects.ts` does not satisfy the check. Link to this site with relative paths (`/admin/config/site-config`), never `https://sourcegraph.com/docs/…` or `https://docs.sourcegraph.com/…`. To also probe the external links you added: `npm run check-links -- --check-anchors --check-self-links --check-external --diff <(git diff -U0 origin/main)` +- **Prove changed links resolve on a deploy**: `node dev/verify-links-live.mjs --site ` prints a Markdown table for the PR description ## AI Chat Integration diff --git a/dev/check-links.mjs b/dev/check-links.mjs index 2a8b24093..e37c940f7 100644 --- a/dev/check-links.mjs +++ b/dev/check-links.mjs @@ -8,106 +8,248 @@ * * 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] + * - With --check-self-links, absolute links to this site (https://sourcegraph.com/docs/..., + * the legacy https://docs.sourcegraph.com/... host, http://, //, www.), which + * should be relative links; the finding proposes one, following src/data/redirects.ts + * - With --check-external, external links on added lines that return 404 or 410 + * + * next.config.js runs this with no flags on every build, so only dead page links + * can fail a deploy; the flags below are for the pull request workflow. + * + * Usage: node dev/check-links.mjs [options] + * --check-anchors Also validate #anchors against headings + * --check-self-links Also report absolute links to this site + * --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) + * --link-base Markdown output links each file path to /, + * e.g. https://github.com/sourcegraph/docs/blob/ + * --diff Unified diff of the change under review, e.g. from + * `git diff -U0 origin/main`. Markdown output splits + * findings into outbound (in a file the diff touches) and + * inbound (elsewhere); the added lines scope the two flags below + * --check-external Request every external link on an added line and report + * 404s and 410s. Follows redirects; ignores #anchors, other + * statuses, and network errors. Requires --diff + * --review Write a GitHub pull request review (JSON body for + * POST /repos/{owner}/{repo}/pulls/{n}/reviews) with one + * suggested-change comment per added line that has a fix + * + * Exits 1 when any finding is reported. */ import fs from 'fs'; import path from 'path'; -import { glob } from 'glob'; import GithubSlugger from 'github-slugger'; -import { fileURLToPath } from 'url'; +import { fileURLToPath, pathToFileURL } 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 CHECK_SELF_LINKS = args.includes('--check-self-links'); +const ROOT_DIR = path.resolve(flagValue('--root') ?? path.dirname(__dirname)); +const FORMAT = flagValue('--format') ?? 'text'; +const BASELINE_FILE = flagValue('--baseline'); +const LINK_BASE = flagValue('--link-base')?.replace(/\/$/, ''); +const DIFF = parseDiff(flagValue('--diff')); +const CHECK_EXTERNAL = args.includes('--check-external'); +const REVIEW_FILE = flagValue('--review'); + +if (CHECK_EXTERNAL && !DIFF) { + throw new Error('--check-external needs --diff, to know which lines were added'); +} + +// Files and added lines of a unified diff, with paths relative to the repository: +// { files: Set<'docs/foo.mdx'>, addedLines: Map<'docs/foo.mdx', Set> }. +// Works with any amount of context, so `git diff` and `git diff -U0` both do. +function parseDiff(file) { + if (!file) return undefined; + const files = new Set(); + const addedLines = new Map(); + let currentFile; + let lineNumber; + for (const line of fs.readFileSync(file, 'utf-8').split('\n')) { + if (line.startsWith('diff --git ')) { + currentFile = undefined; + } else if (line.startsWith('+++ ') && !currentFile) { + // `+++ /dev/null` is a deleted file, which has no added lines + currentFile = line.slice(4).replace(/^b\//, ''); + if (currentFile === '/dev/null') continue; + files.add(currentFile); + addedLines.set(currentFile, new Set()); + } else if (line.startsWith('@@ ')) { + lineNumber = Number(line.match(/^@@ -\S+ \+(\d+)/)[1]); + } else if (line.startsWith('+') && currentFile) { + addedLines.get(currentFile).add(lineNumber++); + } else if (line.startsWith(' ')) { + lineNumber++; + } + } + return { files, addedLines }; +} + +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_EXTENSIONS = ['.md', '.mdx']; +const ROUTE_EXTENSIONS = ['.mdx']; + +function flagValue(name) { + const index = args.indexOf(name); + return index === -1 ? undefined : args[index + 1]; +} + +// Sorted relative paths of every file under dir, optionally limited to some +// extensions. 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. +export function listFiles(dir, extensions) { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile() && (!extensions || extensions.includes(path.extname(entry.name)))) + .map(entry => path.relative(dir, path.join(entry.parentPath, entry.name))) + .sort(); +} // 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 -function extractHeadings(content) { +// 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 +export 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) { - // Handle headings with links: [Text](/path) -> Text - const linkMatch = match[1].match(/\[([^\]]+)\]\([^)]+\)/); - const title = linkMatch ? linkMatch[1] : match[1]; + // rehype-slug slugs the heading's full text, with links reduced to their text: + // "How can I use [GitHub expression syntax](url) literally" -> "How can I use GitHub expression syntax literally" + const title = match[1].replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); 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 -> / +export 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 }); +function buildPathMap() { + const files = listFiles(DOCS_DIR, ROUTE_EXTENSIONS); 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: buildAssetMap(), + redirects: loadRedirects() + }; } -// Check if a path exists in public directory -function checkPublicPath(linkPath) { - const publicPath = path.join(path.dirname(__dirname), 'public', linkPath); - return fs.existsSync(publicPath); +// Source route -> destination of src/data/redirects.ts. The middleware uses the +// first rule whose source equals the requested path, so first entry wins here too. +function loadRedirects() { + const redirects = new Map(); + const source = fs.readFileSync(path.join(ROOT_DIR, 'src/data/redirects.ts'), 'utf-8'); + const ruleRegex = /source:\s*(['"])(.*?)\1,\s*destination:\s*(['"])(.*?)\3/gs; + for (const [, , from, , to] of source.matchAll(ruleRegex)) { + if (!redirects.has(from)) redirects.set(from, to); + } + return redirects; } -// 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. +function buildAssetMap() { + const assetsByLowerCase = new Map(); + for (const dir of ['public', 'docs']) { + for (const file of listFiles(path.join(ROOT_DIR, dir))) { + 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; @@ -136,12 +278,64 @@ function extractLinks(content, filePath) { return links; } -// Check if a link is valid -function validateLink(link, currentFile, pathMap, headingsMap) { +// Absolute links to this site, in every form the docs have used: http or https, +// scheme-relative, www., the legacy docs.sourcegraph.com host, or sourcegraph.com/docs. +// Links pinned to an old version (/@5.1/..., /v/5.1/...) are external: the +// middleware sends them to that version's own site (5.1.sourcegraph.com), whose +// pages are not in this repo, so only --check-external can validate them. +const SELF_LINK_REGEX = /^(?:https?:)?\/\/(?:www\.)?(?:docs\.sourcegraph\.com|sourcegraph\.com\/docs)(?=[/#?]|$)(?!\/@|\/v\/)/i; + +export function isSelfLink(url) { + return SELF_LINK_REGEX.test(url); +} + +// The relative form of an absolute self-link: https://sourcegraph.com/docs/a/b/#c -> /a/b#c. +// A ?query has no meaning on a docs page and is dropped. +function relativeSelfLink(url) { + const [pathAndQuery, anchor] = url.replace(SELF_LINK_REGEX, '').split('#'); + const route = pathAndQuery.split('?')[0].replace(/\/$/, '') || '/'; + return anchor ? `${route}#${anchor}` : route; +} + +// Absolute self-links break on preview deployments and local dev, and hide moved +// pages behind redirects, so they are findings even when the target exists. The +// fix is the relative link, following src/data/redirects.ts when the page moved. +// The redirect destination's own #anchor wins over the link's, like the middleware. +function validateSelfLink(url, currentFile, maps) { + const relative = relativeSelfLink(url); + const anchor = relative.split('#')[1]; + const visited = new Set(); + let candidate = relative; + while (true) { + const moved = candidate === relative ? '' : ' to a moved page'; + const problem = validateLink({ url: candidate }, currentFile, maps); + if (!problem) { + return { error: `Absolute self-link${moved}; use "${candidate}" instead`, fix: candidate }; + } + const destination = maps.redirects.get(candidate.split('#')[0]); + if (!destination || visited.has(destination)) { + const replaced = moved ? `; "${candidate}" replaced it, but` : ', and'; + return { error: `Absolute self-link${moved}${replaced} ${problem[0].toLowerCase()}${problem.slice(1)}` }; + } + visited.add(destination); + candidate = isSelfLink(destination) ? relativeSelfLink(destination) : destination; + if (anchor && !candidate.includes('#') && !/^https?:/.test(candidate)) { + candidate += `#${anchor}`; + } + } +} + +// Check if a link is valid. Returns null, an error string, or { error, fix }. +function validateLink(link, currentFile, maps) { + const { pathMap, routesByLowerCase, headingsMap, headingsByFile, assetsByLowerCase } = maps; const { url } = link; + + if (isSelfLink(url)) { + return CHECK_SELF_LINKS ? validateSelfLink(url, currentFile, maps) : null; + } // Skip external links, mailto, tel, javascript, etc. - if (url.startsWith('http://') || url.startsWith('https://') || + if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//') || url.startsWith('mailto:') || url.startsWith('tel:') || url.startsWith('javascript:') || url.startsWith('data:') || url.startsWith('command:')) { @@ -164,10 +358,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,80 +402,292 @@ 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 = []; +function isAddedLine(file, line) { + return DIFF?.addedLines.get(file)?.has(line) ?? false; +} + +// Find every broken link: [{ file, line, url, error, fix? }] +async function findBrokenLinks() { + const maps = buildPathMap(); + const findings = []; + const externalLinks = []; - for (const file of files) { + for (const file of listFiles(DOCS_DIR, SOURCE_EXTENSIONS)) { 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); - if (error) { - fileErrors.push({ - line: link.lineNumber, - url: link.url, - error - }); + for (const link of extractLinks(content, fullPath)) { + const location = { file: `docs/${file}`, line: link.lineNumber, url: link.url }; + const problem = validateLink(link, fullPath, maps); + if (problem) { + findings.push({ ...location, ...(typeof problem === 'string' ? { error: problem } : problem) }); + } else if (CHECK_EXTERNAL && isExternalLink(link.url) && isAddedLine(location.file, location.line)) { + externalLinks.push(location); } } - - 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, ...(await findDeadExternalLinks(externalLinks))]; +} + +// Hosts reserved for examples and documentation (RFC 2606, RFC 6761), never requested +const PLACEHOLDER_HOST_REGEX = /(^|\.)(example\.(com|net|org)|example|test|invalid|localhost|local|internal)$/i; +// Templated URLs like https:/// or https://$HOST/, never requested +const PLACEHOLDER_URL_REGEX = /[<>{}$*]/; + +function isExternalLink(url) { + if (!/^https?:\/\//i.test(url) || PLACEHOLDER_URL_REGEX.test(url)) return false; + try { + return !PLACEHOLDER_HOST_REGEX.test(new URL(url).hostname); + } catch { + return false; + } +} + +// HTTP status of url after redirects, or undefined on a network error or timeout. +// HEAD first; some servers refuse or misreport HEAD, so an error status is +// confirmed with a GET whose body is not read. +async function probeUrl(url) { + const request = method => + fetch(url, { + method, + redirect: 'follow', + signal: AbortSignal.timeout(15_000), + headers: { 'user-agent': 'sourcegraph-docs-check-links (+https://github.com/sourcegraph/docs)' } + }); + try { + let response = await request('HEAD'); + if (response.status >= 400) { + response = await request('GET'); + await response.body?.cancel(); + } + return response.status; + } catch { + return undefined; + } +} + +// Findings for external links whose target is gone. Only 404 and 410 count: rate +// limits, bot blocks, server errors, and network failures are not the PR's fault. +async function findDeadExternalLinks(links) { + const urls = [...new Set(links.map(link => link.url.split('#')[0]))]; + const statusByUrl = new Map(); + const queue = [...urls]; + const worker = async () => { + for (let url = queue.shift(); url !== undefined; url = queue.shift()) { + statusByUrl.set(url, await probeUrl(url)); + } + }; + await Promise.all(Array.from({ length: 8 }, worker)); + + return links.flatMap(link => { + const status = statusByUrl.get(link.url.split('#')[0]); + return status === 404 || status === 410 ? [{ ...link, error: `External link returns HTTP ${status}` }] : []; + }); +} + +// 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'; +} + +function linkTo(text, url) { + return url ? `[${text}](${url})` : text; +} + +// Markdown list of findings grouped by file, linked to the source when --link-base is set +function markdownFindingList(findings) { + const lines = []; + for (const [file, fileFindings] of groupByFile(findings)) { + // ?plain=1 opens GitHub's code view, where #L anchors work; the rendered + // Markdown preview ignores them + const fileUrl = LINK_BASE && `${LINK_BASE}/${file}?plain=1`; + lines.push(linkTo(`**\`${file}\`**`, fileUrl)); + for (const { line, url, error } of fileFindings) { + lines.push(`- ${linkTo(`line ${line}`, fileUrl && `${fileUrl}#L${line}`)}: \`${url}\` β€” ${error}`); + } + lines.push(''); + } + return lines; +} + +// Body for a pull request comment. With --diff, findings are split into +// outbound (in a file this PR changed: the PR added or edited a bad link) and +// inbound (in a file it did not: the PR renamed or removed a link target). +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)`, '']; + if (DIFF) { + const outbound = findings.filter(finding => DIFF.files.has(finding.file)); + const inbound = findings.filter(finding => !DIFF.files.has(finding.file)); + if (outbound.length > 0) { + lines.push( + '### Outbound', + '', + 'Your PR includes links to pages or anchors that do not exist, or absolute links to this site.', + '', + ...markdownFindingList(outbound) + ); } + if (inbound.length > 0) { + lines.push( + '### Inbound', + '', + 'A change your PR made broke inbound links from elsewhere. ' + + 'Please fix the inbound links on the other pages.', + '', + ...markdownFindingList(inbound) + ); + } + } else { + lines.push(...markdownFindingList(findings)); + } + if (findings.some(finding => isSelfLink(finding.url))) { + lines.push( + 'Write links to this site as relative paths (`/admin/config/site-config`), ' + + 'not `https://sourcegraph.com/docs/…` or `https://docs.sourcegraph.com/…`: ' + + 'absolute links leave the preview deployment and local dev server, and ' + + 'hide moved pages behind redirects.', + '' + ); + } + lines.push( + 'Reproduce locally with `pnpm check-links --check-anchors` ' + + '(see `dev/check-links.mjs`).', + '', + 'Adding a redirect in `src/data/redirects.ts` does not satisfy this ' + + 'check, because it’s a workaround instead of a fix.' + ); + return lines.join('\n') + '\n'; +} + +// Body for POST /repos/{owner}/{repo}/pulls/{n}/reviews: one suggested change per +// added line that has fixes, so the author can apply them from the PR. The comment +// lists every finding on the line, so the ones the suggestion cannot fix are not +// mistaken for accepted. Review comments must sit on a line of the diff, hence the +// added-line restriction. +function reviewRequest(findings) { + const findingsByLine = new Map(); + for (const finding of findings) { + if (!isAddedLine(finding.file, finding.line)) continue; + const key = `${finding.file}:${finding.line}`; + if (!findingsByLine.has(key)) findingsByLine.set(key, []); + findingsByLine.get(key).push(finding); + } + + const comments = [...findingsByLine.values()] + .filter(lineFindings => lineFindings.some(finding => finding.fix)) + .map(lineFindings => { + const { file, line } = lineFindings[0]; + const source = fs.readFileSync(path.join(ROOT_DIR, file), 'utf-8').split('\n')[line - 1]; + const fixed = lineFindings + .filter(finding => finding.fix) + .reduce((text, { url, fix }) => text.split(url).join(fix), source); + const notes = lineFindings.map( + ({ url, error, fix }) => `- \`${url}\`: ${error}${fix ? '' : ' (not fixed by this suggestion)'}` + ); + return { path: file, line, side: 'RIGHT', body: [...notes, '```suggestion', fixed, '```'].join('\n') }; + }); + return { + event: 'COMMENT', + body: 'Suggested fixes for the links this PR adds; details in the check-links comment.', + comments + }; +} + +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); + } + + if (REVIEW_FILE) { + fs.writeFileSync(REVIEW_FILE, JSON.stringify(reviewRequest(findings), null, '\t') + '\n'); + } + process.stdout.write(format(findings)); + process.exit(findings.length === 0 ? 0 : 1); } -main().catch(err => { - console.error('Error running link checker:', err); - process.exit(1); -}); +// Only run when executed directly; dev/verify-links-live.mjs and dev/check-redirects.mjs +// import the exported helpers. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/dev/verify-links-live.mjs b/dev/verify-links-live.mjs new file mode 100644 index 000000000..7f4434a3e --- /dev/null +++ b/dev/verify-links-live.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +// Prove that the links a branch changed resolve on a deployed site. +// +// For every internal link that differs between a base ref and the working +// tree, fetch the target page on --site and check the rendered HTML, not the +// HTTP status: the docs site serves its not-found page with 200 (see the +// dynamicParams fix) and even real pages embed the not-found text in their +// RSC payload, so neither status nor page text proves anything. A link passes +// when +// - the target page's own first heading id (from its local MDX source) is an +// id in the HTML, which the not-found page never has, and +// - the link's #fragment, when present, is an id in the HTML. +// Prints a Markdown table to paste into a PR. Old links point at --old-site so +// reviewers can see the current breakage. +// +// node dev/verify-links-live.mjs --site https://.vercel.app [--old-site https://sourcegraph.com/docs] [--base origin/main] +// +// Production serves under https://sourcegraph.com/docs (basePath in +// next.config.js); Vercel previews serve at the root, so pass the full prefix +// in --site. +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { extractHeadings } from './check-links.mjs'; + +const args = process.argv.slice(2); +const argValue = (flag, fallback) => { + const index = args.indexOf(flag); + return index === -1 ? fallback : args[index + 1]; +}; +const SITE = argValue('--site', 'https://sourcegraph.com/docs').replace(/\/$/, ''); +const OLD_SITE = argValue('--old-site', 'https://sourcegraph.com/docs').replace(/\/$/, ''); +const BASE_REF = argValue('--base', 'origin/main'); + +const LINK = /\]\(([^)\s]+)\)|href=["']([^"']+)["']/g; + +function routeFor(file) { + return '/' + file.replace(/^docs\//, '').replace(/\.mdx$/, '').replace(/\/index$/, ''); +} + +// Same precedence as the site (first glob match): foo.mdx before foo/index.mdx. +function sourceFileFor(route) { + const stem = route === '/' ? 'docs/index' : `docs${route}`; + return [`${stem}.mdx`, `${stem}/index.mdx`].find(candidate => fs.existsSync(candidate)); +} + +const firstHeadingCache = new Map(); +function firstHeadingId(route) { + if (!firstHeadingCache.has(route)) { + const file = sourceFileFor(route); + const [first] = file ? extractHeadings(fs.readFileSync(file, 'utf-8')) : []; + firstHeadingCache.set(route, first); + } + return firstHeadingCache.get(route); +} + +// Collect { file, oldUrl, newUrl } for every link that changed. +function changedLinks() { + const diff = execSync(`git diff -U0 ${BASE_REF}`, { encoding: 'utf-8' }); + const result = []; + let file, removed = [], added = []; + const flush = () => { + if (removed.length === added.length) removed.forEach((oldLine, index) => { + const oldLinks = [...oldLine.matchAll(LINK)].map(m => m[1] ?? m[2]); + const newLinks = [...added[index].matchAll(LINK)].map(m => m[1] ?? m[2]); + if (oldLinks.length !== newLinks.length) return; + oldLinks.forEach((oldUrl, i) => { + if (oldUrl !== newLinks[i]) result.push({ file, oldUrl, newUrl: newLinks[i] }); + }); + }); + removed = []; added = []; + }; + for (const line of diff.split('\n')) { + if (line.startsWith('+++ b/')) { flush(); file = line.slice(6); continue; } + if (line.startsWith('@@')) { flush(); continue; } + if (line.startsWith('---')) continue; + if (line.startsWith('-')) removed.push(line.slice(1)); + else if (line.startsWith('+')) added.push(line.slice(1)); + } + flush(); + return result; +} + +function resolveTarget(file, url) { + if (/^(https?:|mailto:|tel:)/.test(url)) return null; + const [pagePart, fragment] = url.split('#'); + let page; + if (pagePart === '') page = routeFor(file); + else if (pagePart.startsWith('/')) page = pagePart; + else page = path.posix.join(path.posix.dirname(routeFor(file)), pagePart); + page = page.replace(/\/$/, '') || '/'; + return { page, fragment, href: `${page}${fragment ? '#' + fragment : ''}` }; +} + +const pageCache = new Map(); +async function fetchPage(page) { + if (!pageCache.has(page)) { + pageCache.set(page, fetch(`${SITE}${page}`, { redirect: 'follow' }).then(response => response.text())); + } + return pageCache.get(page); +} + +function hasId(html, id) { + const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`\\sid=["']${escaped}["']`).test(html); +} + +const links = changedLinks(); +const rows = []; +let failures = 0; +for (const { file, oldUrl, newUrl } of links) { + const target = resolveTarget(file, newUrl); + if (!target) continue; + const html = await fetchPage(target.page); + const heading = firstHeadingId(target.page); + // No local source file means no such page; a page with no headings cannot be verified either. + const pageOk = Boolean(heading) && hasId(html, heading); + const anchorOk = !target.fragment || hasId(html, target.fragment); + if (!pageOk || !anchorOk) failures++; + const oldTarget = resolveTarget(file, oldUrl); + const oldCell = oldTarget ? `[\`${oldUrl}\`](${OLD_SITE}${oldTarget.href})` : `\`${oldUrl}\``; + rows.push(`| \`${file}\` | ${oldCell} | [\`${newUrl}\`](${SITE}${target.href}) | ${pageOk ? 'βœ…' : '❌'} | ${target.fragment ? (anchorOk ? 'βœ…' : '❌') : 'β€”'} |`); +} + +console.log(`Checked ${rows.length} changed links against ${SITE}: ${rows.length - failures} resolve, ${failures} fail.`); +console.log('Page rendered = the target page\'s first heading id is present (the 404 page never has it); Anchor = the #fragment is an id on the page. Old links point at the current site.\n'); +console.log('| File containing the link | Old link (broken today) | New link (preview) | Page rendered | Anchor found |'); +console.log('|--------------------------|-------------------------|--------------------|---------------|--------------|'); +console.log(rows.join('\n')); +process.exitCode = failures ? 1 : 0;