diff --git a/.github/workflows/check-redirects-on-rename.yml b/.github/workflows/check-redirects-on-rename.yml index 3c5f1784cdda0..61ba315002111 100644 --- a/.github/workflows/check-redirects-on-rename.yml +++ b/.github/workflows/check-redirects-on-rename.yml @@ -140,17 +140,17 @@ jobs: comment += '---\n'; comment += '_Note: `middleware.ts` is recommended for simple exact-match redirects. Use `redirects.js` for redirects with path parameters (e.g., `:path*`)._\n'; - // Check for existing comments from this action - const {data: comments} = await github.rest.issues.listComments({ + // Check for existing comments from this action (paginate to handle PRs with 30+ comments) + const comments = await github.paginate(github.rest.issues.listComments, { owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, }); - const existingComment = comments.find(comment => - comment.user.type === 'Bot' && - (comment.user.login === 'github-actions[bot]' || comment.user.login.includes('bot')) && - comment.body.includes('Missing Redirects Detected') + const existingComment = comments.find(c => + c.user.type === 'Bot' && + c.user.login === 'github-actions[bot]' && + c.body.includes('Missing Redirects Detected') ); if (existingComment) { diff --git a/.github/workflows/lint-redirect-chains.yml b/.github/workflows/lint-redirect-chains.yml new file mode 100644 index 0000000000000..c62942722c218 --- /dev/null +++ b/.github/workflows/lint-redirect-chains.yml @@ -0,0 +1,166 @@ +name: Lint Redirect Chains + +on: + pull_request: + branches: [master] + paths: + - 'middleware.ts' + - 'redirects.js' + - 'docs/**/*.mdx' + - 'docs/**/*.md' + - 'develop-docs/**/*.mdx' + - 'develop-docs/**/*.md' + - 'includes/**/*.mdx' + - 'includes/**/*.md' + - 'platform-includes/**/*.mdx' + - 'platform-includes/**/*.md' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + lint-redirect-chains: + name: Check for redirect chains + runs-on: ubuntu-latest + continue-on-error: true # Fail the check but don't block merge + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Setup Node + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v4 + with: + node-version-file: 'package.json' + + - name: Run redirect chain lint + id: lint + continue-on-error: true + run: | + set +e + OUTPUT=$(npx tsx scripts/lint-redirect-chains.ts 2>&1) + EXIT_CODE=$? + set -e + + echo "$OUTPUT" + + # Extract JSON output if present + HAS_JSON=false + if echo "$OUTPUT" | grep -Fq -- "---JSON_OUTPUT---"; then + JSON_OUTPUT=$(echo "$OUTPUT" | sed -n '/---JSON_OUTPUT---/,/---JSON_OUTPUT---/p' | sed '1d;$d') + echo "lint_result<> $GITHUB_OUTPUT + echo "$JSON_OUTPUT" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + HAS_JSON=true + fi + + echo "has_results=$HAS_JSON" >> $GITHUB_OUTPUT + echo "exit_code=$EXIT_CODE" >> $GITHUB_OUTPUT + + - name: Post comment if redirect chains found + if: steps.lint.outputs.exit_code == '1' && steps.lint.outputs.has_results == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + script: | + const lintResultJsonString = ${{ toJSON(steps.lint.outputs.lint_result) }}; + let lintResult; + try { + const jsonString = JSON.parse(lintResultJsonString); + lintResult = JSON.parse(jsonString); + } catch (e) { + console.error('Failed to parse lint result:', e); + core.setFailed('Failed to parse redirect chain lint output'); + return; + } + + const redirectChains = lintResult.redirectChains || []; + const contentLinkIssues = lintResult.contentLinkIssues || []; + + if (redirectChains.length === 0 && contentLinkIssues.length === 0) { + return; + } + + let comment = '## \u26a0\ufe0f Redirect Chain Issues Detected\n\n'; + comment += 'This PR introduces or contains redirect chains that degrade SEO and add latency.\n\n'; + + if (redirectChains.length > 0) { + comment += '### Redirect-to-Redirect Chains\n\n'; + comment += 'These redirect destinations point to another redirect, creating multi-hop chains. '; + comment += 'Update the destination to point directly to the final URL:\n\n'; + comment += '| Source | Current Destination | Should Be | File |\n'; + comment += '|--------|-------------------|-----------|------|\n'; + for (const chain of redirectChains) { + const docLabel = chain.isDeveloperDocs ? ' (dev)' : ''; + comment += `| \`${chain.source}\` | \`${chain.currentDest}\` | \`${chain.finalDest}\` | ${chain.file}${docLabel} |\n`; + } + comment += '\n'; + } + + if (contentLinkIssues.length > 0) { + // Group by file + const byFile = {}; + for (const issue of contentLinkIssues) { + if (!byFile[issue.filePath]) byFile[issue.filePath] = []; + byFile[issue.filePath].push(issue); + } + + comment += `### Content Links Pointing to Redirects (${contentLinkIssues.length} found)\n\n`; + comment += 'These links point to URLs that redirect. Update them to point directly to the final destination:\n\n'; + comment += '| File | Line | Current Link | Should Be |\n'; + comment += '|------|------|--------------|-----------|\n'; + for (const [file, issues] of Object.entries(byFile)) { + for (const issue of issues) { + comment += `| \`${file}\` | ${issue.line} | \`${issue.linkPath}\` | \`${issue.finalDest}\` |\n`; + } + } + comment += '\n'; + } + + comment += '---\n'; + comment += '*Each redirect hop loses ~15% of SEO link equity and adds latency for users.*\n'; + + // Check for existing comments from this action (paginate to handle PRs with 30+ comments) + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existingComment = comments.find(c => + c.user.type === 'Bot' && + c.user.login === 'github-actions[bot]' && + c.body.includes('Redirect Chain Issues Detected') + ); + + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: comment, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment, + }); + } + + - name: Report failure if redirect chains found + if: steps.lint.outputs.exit_code == '1' && steps.lint.outputs.has_results == 'true' + run: | + echo "::warning::Redirect chain issues detected. See PR comment for details." + exit 1 + + - name: Success + if: steps.lint.outputs.exit_code == '0' + run: | + echo "No redirect chains or content link issues found." diff --git a/package.json b/package.json index f50acfc66258e..49f5ba83e3820 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "lint:prettier": "prettier --check \"./{src,app,scripts}/**/*.{md,mdx,ts,tsx,js,jsx,mjs}\"", "lint:prettier:fix": "prettier --write \"./{src,app,scripts}/**/*.{md,mdx,ts,tsx,js,jsx,mjs}\"", "lint:typos": "typos", + "lint:redirect-chains": "tsx scripts/lint-redirect-chains.ts", "lint:fix": "pnpm run lint:prettier:fix && pnpm run lint:eslint:fix", "sidecar": "spotlight-sidecar", "test": "vitest", diff --git a/scripts/lint-redirect-chains.spec.ts b/scripts/lint-redirect-chains.spec.ts new file mode 100644 index 0000000000000..6c1362f530fad --- /dev/null +++ b/scripts/lint-redirect-chains.spec.ts @@ -0,0 +1,370 @@ +import fs from 'fs'; +import path from 'path'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; + +import {parseMiddlewareTs, parseRedirectsJs} from './check-redirects-on-rename'; +import {detectRedirectChains, resolveToFinal, walkChain} from './lint-redirect-chains'; + +describe('walkChain', () => { + it('should return just the source when no chain exists', () => { + const map = new Map([ + ['/a/', '/b/'], + ['/c/', '/d/'], + ]); + expect(walkChain('/a/', map)).toEqual(['/a/', '/b/']); + }); + + it('should walk a 2-hop chain', () => { + const map = new Map([ + ['/a/', '/b/'], + ['/b/', '/c/'], + ]); + expect(walkChain('/a/', map)).toEqual(['/a/', '/b/', '/c/']); + }); + + it('should walk a 3-hop chain', () => { + const map = new Map([ + ['/a/', '/b/'], + ['/b/', '/c/'], + ['/c/', '/d/'], + ]); + expect(walkChain('/a/', map)).toEqual(['/a/', '/b/', '/c/', '/d/']); + }); + + it('should detect cycles', () => { + const map = new Map([ + ['/a/', '/b/'], + ['/b/', '/a/'], + ]); + const result = walkChain('/a/', map); + expect(result).toEqual(['/a/', '/b/', '/a/ (CYCLE)']); + }); + + it('should detect self-referencing cycles', () => { + const map = new Map([['/a/', '/a/']]); + const result = walkChain('/a/', map); + expect(result).toEqual(['/a/', '/a/ (CYCLE)']); + }); + + it('should cap at maxDepth', () => { + const map = new Map(); + for (let i = 0; i < 20; i++) { + map.set(`/${i}/`, `/${i + 1}/`); + } + const result = walkChain('/0/', map, 5); + expect(result).toHaveLength(6); // source + 5 hops + }); +}); + +describe('resolveToFinal', () => { + it('should resolve a single redirect', () => { + const map = new Map([['/a/', '/b/']]); + expect(resolveToFinal('/a/', map)).toBe('/b/'); + }); + + it('should resolve a chain to the final destination', () => { + const map = new Map([ + ['/a/', '/b/'], + ['/b/', '/c/'], + ['/c/', '/d/'], + ]); + expect(resolveToFinal('/a/', map)).toBe('/d/'); + }); + + it('should handle self-referencing cycles without infinite loop', () => { + const map = new Map([['/a/', '/a/']]); + expect(resolveToFinal('/a/', map)).toBe('/a/'); + }); + + it('should return the source when not in the map', () => { + const map = new Map([['/a/', '/b/']]); + expect(resolveToFinal('/x/', map)).toBe('/x/'); + }); +}); + +describe('detectRedirectChains', () => { + it('should detect chains within a single redirect source', () => { + const jsRedirects = { + developerDocsRedirects: [] as Array<{destination: string; source: string}>, + userDocsRedirects: [ + {source: '/old/', destination: '/middle/'}, + {source: '/middle/', destination: '/new/'}, + ], + }; + const mwRedirects = { + developerDocsRedirects: [] as Array<{destination: string; source: string}>, + userDocsRedirects: [] as Array<{destination: string; source: string}>, + }; + + const chains = detectRedirectChains(jsRedirects, mwRedirects); + expect(chains).toHaveLength(1); + expect(chains[0].source).toBe('/old/'); + expect(chains[0].currentDest).toBe('/middle/'); + expect(chains[0].finalDest).toBe('/new/'); + expect(chains[0].file).toBe('redirects.js'); + expect(chains[0].isDeveloperDocs).toBe(false); + }); + + it('should detect cross-system chains (middleware -> redirects.js)', () => { + const jsRedirects = { + developerDocsRedirects: [] as Array<{destination: string; source: string}>, + userDocsRedirects: [{source: '/middle/', destination: '/final/'}], + }; + const mwRedirects = { + developerDocsRedirects: [] as Array<{destination: string; source: string}>, + userDocsRedirects: [{source: '/old/', destination: '/middle/'}], + }; + + const chains = detectRedirectChains(jsRedirects, mwRedirects); + expect(chains).toHaveLength(1); + expect(chains[0].source).toBe('/old/'); + expect(chains[0].finalDest).toBe('/final/'); + expect(chains[0].file).toBe('middleware.ts'); + }); + + it('should report duplicate sources from both files separately', () => { + const jsRedirects = { + developerDocsRedirects: [] as Array<{destination: string; source: string}>, + userDocsRedirects: [{source: '/dup/', destination: '/middle/'}], + }; + const mwRedirects = { + developerDocsRedirects: [] as Array<{destination: string; source: string}>, + userDocsRedirects: [ + {source: '/dup/', destination: '/other-middle/'}, + {source: '/middle/', destination: '/final/'}, + {source: '/other-middle/', destination: '/other-final/'}, + ], + }; + + const chains = detectRedirectChains(jsRedirects, mwRedirects); + // Both entries for /dup/ should be reported with correct file attribution + const dupChains = chains.filter(c => c.source === '/dup/'); + expect(dupChains).toHaveLength(2); + + const jsChain = dupChains.find(c => c.file === 'redirects.js'); + const mwChain = dupChains.find(c => c.file === 'middleware.ts'); + expect(jsChain).toBeDefined(); + expect(mwChain).toBeDefined(); + + // Each entry's chain should be consistent with its own currentDest + expect(jsChain!.currentDest).toBe('/middle/'); + expect(jsChain!.chain[1]).toBe('/middle/'); // chain starts source -> currentDest + expect(jsChain!.finalDest).toBe('/final/'); + + expect(mwChain!.currentDest).toBe('/other-middle/'); + expect(mwChain!.chain[1]).toBe('/other-middle/'); + expect(mwChain!.finalDest).toBe('/other-final/'); + }); + + it('should return empty array when no chains exist', () => { + const jsRedirects = { + developerDocsRedirects: [] as Array<{destination: string; source: string}>, + userDocsRedirects: [ + {source: '/a/', destination: '/b/'}, + {source: '/c/', destination: '/d/'}, + ], + }; + const mwRedirects = { + developerDocsRedirects: [] as Array<{destination: string; source: string}>, + userDocsRedirects: [] as Array<{destination: string; source: string}>, + }; + + const chains = detectRedirectChains(jsRedirects, mwRedirects); + expect(chains).toHaveLength(0); + }); + + it('should handle self-redirect cycles', () => { + const jsRedirects = { + developerDocsRedirects: [{source: '/loop/', destination: '/loop/'}], + userDocsRedirects: [] as Array<{destination: string; source: string}>, + }; + const mwRedirects = { + developerDocsRedirects: [] as Array<{destination: string; source: string}>, + userDocsRedirects: [] as Array<{destination: string; source: string}>, + }; + + const chains = detectRedirectChains(jsRedirects, mwRedirects); + expect(chains).toHaveLength(1); + expect(chains[0].finalDest).toBe('(CYCLE DETECTED)'); + }); + + it('should handle parameterized redirect chains', () => { + const jsRedirects = { + developerDocsRedirects: [] as Array<{destination: string; source: string}>, + userDocsRedirects: [ + {source: '/old/:path*', destination: '/middle/:path*'}, + {source: '/middle/:path*', destination: '/new/:path*'}, + ], + }; + const mwRedirects = { + developerDocsRedirects: [] as Array<{destination: string; source: string}>, + userDocsRedirects: [] as Array<{destination: string; source: string}>, + }; + + const chains = detectRedirectChains(jsRedirects, mwRedirects); + expect(chains).toHaveLength(1); + expect(chains[0].source).toBe('/old/:path*'); + expect(chains[0].finalDest).toBe('/new/:path*'); + }); + + it('should separate user docs and developer docs', () => { + const jsRedirects = { + developerDocsRedirects: [ + {source: '/sdk/old/', destination: '/sdk/middle/'}, + {source: '/sdk/middle/', destination: '/sdk/new/'}, + ], + userDocsRedirects: [] as Array<{destination: string; source: string}>, + }; + const mwRedirects = { + developerDocsRedirects: [] as Array<{destination: string; source: string}>, + userDocsRedirects: [] as Array<{destination: string; source: string}>, + }; + + const chains = detectRedirectChains(jsRedirects, mwRedirects); + expect(chains).toHaveLength(1); + expect(chains[0].isDeveloperDocs).toBe(true); + }); + + it('should detect chains with trailing slash mismatches', () => { + const jsRedirects = { + developerDocsRedirects: [] as Array<{destination: string; source: string}>, + userDocsRedirects: [ + {source: '/old/', destination: '/middle'}, // no trailing slash on dest + {source: '/middle/', destination: '/new/'}, // source has trailing slash + ], + }; + const mwRedirects = { + developerDocsRedirects: [] as Array<{destination: string; source: string}>, + userDocsRedirects: [] as Array<{destination: string; source: string}>, + }; + + const chains = detectRedirectChains(jsRedirects, mwRedirects); + expect(chains).toHaveLength(1); + expect(chains[0].source).toBe('/old/'); + expect(chains[0].finalDest).toBe('/new/'); + }); +}); + +describe('detectContentLinkIssues', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = path.join(process.cwd(), '__test_mdx_temp__'); + fs.mkdirSync(path.join(tempDir, 'docs'), {recursive: true}); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('should detect markdown links pointing to redirect sources', () => { + const mdxContent = `--- +title: Test +--- + +Check out the [old page](/old/path/) for more info. +`; + fs.writeFileSync(path.join(tempDir, 'docs', 'test.mdx'), mdxContent); + + // Test the link detection logic by checking the regex patterns directly + const markdownLinkRegex = /\]\((\/[^)#\s]+?)(#[^)]+)?\)/g; + const matches = [...mdxContent.matchAll(markdownLinkRegex)]; + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/old/path/'); + }); + + it('should detect JSX links pointing to redirect sources', () => { + const mdxContent = ``; + + const jsxLinkRegex = /((?:href|to|url))="(\/[^"#]+?)(#[^"]+)?"/g; + const matches = [...mdxContent.matchAll(jsxLinkRegex)]; + expect(matches).toHaveLength(1); + expect(matches[0][2]).toBe('/old/path/'); + }); + + it('should extract path and fragment separately from anchored links', () => { + const mdxContent = `[link](/old/path/#section)`; + + const markdownLinkRegex = /\]\((\/[^)#\s]+?)(#[^)]+)?\)/g; + const matches = [...mdxContent.matchAll(markdownLinkRegex)]; + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/old/path/'); + expect(matches[0][2]).toBe('#section'); + }); + + it('should skip external links', () => { + const mdxContent = `[link](https://example.com/path/)`; + + const markdownLinkRegex = /\]\((\/[^)#\s]+?)(#[^)]+)?\)/g; + const matches = [...mdxContent.matchAll(markdownLinkRegex)]; + expect(matches).toHaveLength(0); + }); + + it('should skip hash-only links', () => { + const mdxContent = `[link](#section)`; + + const markdownLinkRegex = /\]\((\/[^)#\s]+?)(#[^)]+)?\)/g; + const matches = [...mdxContent.matchAll(markdownLinkRegex)]; + expect(matches).toHaveLength(0); + }); + + it('should handle links without trailing slashes', () => { + const mdxContent = `[link](/old/path)`; + + const markdownLinkRegex = /\]\((\/[^)#\s]+?)(#[^)]+)?\)/g; + const matches = [...mdxContent.matchAll(markdownLinkRegex)]; + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/old/path'); + // Normalization adds trailing slash: /old/path -> /old/path/ + }); + + it('should skip PlatformLink to= attributes (platform-relative paths)', () => { + // PlatformLink to= should be skipped since PlatformLink prepends + // the platform base URL, making these platform-relative paths. + // The linter checks for text`; + const isPlatformLinkLine = line.includes(' { + // Handles case where PlatformLink has other props first + const line = `text`; + const isPlatformLinkLine = line.includes(' { + const line = `text`; + const isPlatformLinkLine = line.includes(' { + it('should parse real redirects.js without errors', () => { + const result = parseRedirectsJs('redirects.js'); + expect(result.userDocsRedirects.length).toBeGreaterThan(0); + expect(result.developerDocsRedirects.length).toBeGreaterThan(0); + }); + + it('should parse real middleware.ts without errors', () => { + const result = parseMiddlewareTs('middleware.ts'); + expect(result.userDocsRedirects.length).toBeGreaterThan(0); + expect(result.developerDocsRedirects.length).toBeGreaterThan(0); + }); +}); diff --git a/scripts/lint-redirect-chains.ts b/scripts/lint-redirect-chains.ts new file mode 100644 index 0000000000000..5755344dd4160 --- /dev/null +++ b/scripts/lint-redirect-chains.ts @@ -0,0 +1,371 @@ +/** + * Lint redirect chains: detects multi-hop redirects and content links + * pointing to redirect sources. + * + * Phase 1: Finds redirect-to-redirect chains in middleware.ts and redirects.js + * (where redirect A's destination is another redirect B's source). + * Phase 2: Finds internal links in .mdx content files that point to URLs + * which are redirect sources (and should point to the final destination). + * + * Outputs structured JSON for the GitHub Action workflow to post as a PR comment. + */ +import fs from 'fs'; +import path from 'path'; + +import {parseMiddlewareTs, parseRedirectsJs} from './check-redirects-on-rename'; + +interface Redirect { + destination: string; + source: string; +} + +interface RedirectChain { + chain: string[]; + currentDest: string; + file: string; + finalDest: string; + isDeveloperDocs: boolean; + source: string; +} + +interface ContentLinkIssue { + filePath: string; + finalDest: string; + fragment: string; + isDeveloperDocs: boolean; + line: number; + linkPath: string; +} + +/** + * Recursively finds all .mdx and .md files in the given directories + */ +function findAllMdxFiles(dirs: string[]): string[] { + const files: string[] = []; + for (const dir of dirs) { + if (!fs.existsSync(dir)) { + continue; + } + const walk = (d: string) => { + for (const entry of fs.readdirSync(d, {withFileTypes: true})) { + const full = path.join(d, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.name.endsWith('.mdx') || entry.name.endsWith('.md')) { + files.push(full); + } + } + }; + walk(dir); + } + return files; +} + +/** + * Walks a redirect chain to its final destination. + * Returns the full chain path including the starting source. + * Detects cycles and caps at maxDepth to prevent infinite loops. + */ +function walkChain( + source: string, + redirectMap: Map, + maxDepth = 10 +): string[] { + const chain = [source]; + let current = source; + const seen = new Set(); + seen.add(current); + let depth = 0; + while (redirectMap.has(current) && depth < maxDepth) { + const next = redirectMap.get(current)!; + if (seen.has(next)) { + chain.push(next + ' (CYCLE)'); + break; + } + seen.add(next); + chain.push(next); + current = next; + depth++; + } + return chain; +} + +/** + * Resolves a path through the redirect map to its final destination. + */ +function resolveToFinal(source: string, map: Map): string { + let current = source; + const seen = new Set(); + while (map.has(current) && !seen.has(current)) { + seen.add(current); + current = map.get(current)!; + } + return current; +} + +/** + * Phase 1: Detect redirect-to-redirect chains + * + * Processes each file's redirects separately to correctly attribute chains + * and handle cases where the same source exists in both files. + */ +function detectRedirectChains( + jsRedirects: {developerDocsRedirects: Redirect[]; userDocsRedirects: Redirect[]}, + mwRedirects: {developerDocsRedirects: Redirect[]; userDocsRedirects: Redirect[]} +): RedirectChain[] { + const chains: RedirectChain[] = []; + + for (const docType of ['user', 'developer'] as const) { + const isDevDocs = docType === 'developer'; + const jsArr = isDevDocs + ? jsRedirects.developerDocsRedirects + : jsRedirects.userDocsRedirects; + const mwArr = isDevDocs + ? mwRedirects.developerDocsRedirects + : mwRedirects.userDocsRedirects; + + // Build unified map for chain resolution (last entry wins, matching runtime). + // Store both with and without trailing slash so lookups catch mismatches. + const allRedirects = [...jsArr, ...mwArr]; + const redirectMap = new Map(); + for (const r of allRedirects) { + redirectMap.set(r.source, r.destination); + // Also index the normalized form (with trailing slash) if different + const normalized = r.source.endsWith('/') ? r.source : r.source + '/'; + if (normalized !== r.source) { + redirectMap.set(normalized, r.destination); + } + } + + // Process each file's redirects separately to get correct file attribution. + // Use the unified map for chain walking so cross-file chains are detected. + const fileSources: Array<{file: string; redirects: Redirect[]}> = [ + {file: 'redirects.js', redirects: jsArr}, + {file: 'middleware.ts', redirects: mwArr}, + ]; + + for (const {file, redirects} of fileSources) { + for (const r of redirects) { + // Check if this entry's destination chains into another redirect, + // normalizing trailing slashes for the lookup + const dest = r.destination; + const destNormalized = dest.endsWith('/') ? dest : dest + '/'; + const matchedDest = redirectMap.has(dest) + ? dest + : redirectMap.has(destNormalized) + ? destNormalized + : null; + + if (matchedDest) { + // Walk from the entry's own destination (not source) to build a + // consistent chain: source -> currentDest -> ... -> finalDest. + const tailChain = walkChain(matchedDest, redirectMap); + const chain = [r.source, ...tailChain]; + const finalDest = resolveToFinal(matchedDest, redirectMap); + + // Skip self-referencing cycles + if (finalDest === r.source || chain.some(s => s.includes('(CYCLE)'))) { + chains.push({ + source: r.source, + currentDest: r.destination, + finalDest: '(CYCLE DETECTED)', + chain, + file, + isDeveloperDocs: isDevDocs, + }); + continue; + } + + chains.push({ + source: r.source, + currentDest: r.destination, + finalDest, + chain, + file, + isDeveloperDocs: isDevDocs, + }); + } + } + } + } + + return chains; +} + +/** + * Phase 2: Detect content links pointing to redirect sources + */ +function detectContentLinkIssues( + jsRedirects: {developerDocsRedirects: Redirect[]; userDocsRedirects: Redirect[]}, + mwRedirects: {developerDocsRedirects: Redirect[]; userDocsRedirects: Redirect[]} +): ContentLinkIssue[] { + // Build redirect maps + const userRedirectMap = new Map(); + for (const r of [...jsRedirects.userDocsRedirects, ...mwRedirects.userDocsRedirects]) { + userRedirectMap.set(r.source, r.destination); + } + const devRedirectMap = new Map(); + for (const r of [ + ...jsRedirects.developerDocsRedirects, + ...mwRedirects.developerDocsRedirects, + ]) { + devRedirectMap.set(r.source, r.destination); + } + + const mdxFiles = findAllMdxFiles([ + 'docs', + 'develop-docs', + 'includes', + 'platform-includes', + ]); + + // Regex patterns for extracting internal links (capturing fragment separately) + // Markdown: [text](/path/) or [text](/path/#anchor) + const markdownLinkRegex = /\]\((\/[^)#\s]+?)(#[^)]+)?\)/g; + // JSX: href="/path/", to="/path/", or url="/path/" + const jsxLinkRegex = /((?:href|to|url))="(\/[^"#]+?)(#[^"]+)?"/g; + + const issues: ContentLinkIssue[] = []; + + for (const filePath of mdxFiles) { + const content = fs.readFileSync(filePath, 'utf8'); + const lines = content.split('\n'); + const isDevDocsFile = filePath.startsWith('develop-docs/'); + const map = isDevDocsFile ? devRedirectMap : userRedirectMap; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Check if this line contains a PlatformLink tag (platform-relative paths) + const isPlatformLinkLine = line.includes(' 0) { + console.log(`\n❌ Found ${chains.length} redirect chain(s):\n`); + for (const c of chains) { + console.log(` [${c.file}] ${c.chain.join(' -> ')}`); + console.log(` Fix: Change destination of "${c.source}" to "${c.finalDest}"`); + } + } else { + console.log('\n✅ No redirect chains found.'); + } + + // Phase 2: Content links + const contentIssues = detectContentLinkIssues(jsRedirects, mwRedirects); + if (contentIssues.length > 0) { + console.log( + `\n❌ Found ${contentIssues.length} content link(s) pointing to redirect sources:\n` + ); + + // Group by file for readable output + const byFile = new Map(); + for (const issue of contentIssues) { + const arr = byFile.get(issue.filePath) || []; + arr.push(issue); + byFile.set(issue.filePath, arr); + } + + for (const [file, fileIssues] of byFile) { + console.log(` ${file}:`); + for (const issue of fileIssues) { + console.log( + ` L${issue.line}: ${issue.linkPath} -> should be ${issue.finalDest}` + ); + } + } + } else { + console.log('\n✅ No content links pointing to redirect sources.'); + } + + // Summary and JSON output + const hasIssues = chains.length > 0 || contentIssues.length > 0; + + if (hasIssues) { + console.log('\n---JSON_OUTPUT---'); + console.log( + JSON.stringify( + { + redirectChains: chains, + contentLinkIssues: contentIssues, + }, + null, + 2 + ) + ); + console.log('---JSON_OUTPUT---\n'); + + process.exit(1); + } else { + console.log( + '\n✅ All clear: no redirect chains or content links pointing to redirect sources.' + ); + process.exit(0); + } +} + +export { + detectContentLinkIssues, + detectRedirectChains, + findAllMdxFiles, + resolveToFinal, + walkChain, +};