diff --git a/.github/workflows/check-redirects-on-rename.yml b/.github/workflows/check-redirects-on-rename.yml new file mode 100644 index 00000000000000..3845384a89b23b --- /dev/null +++ b/.github/workflows/check-redirects-on-rename.yml @@ -0,0 +1,162 @@ +name: Check Redirects on File Rename + +on: + pull_request: + branches: [master] + +jobs: + check-redirects: + name: Check redirects for renamed files + 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@v4 + + - name: Install bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Set up git for diff + run: | + git fetch origin ${{ github.event.pull_request.base.ref }}:${{ github.event.pull_request.base.ref }} + echo "GITHUB_BASE_REF=${{ github.event.pull_request.base.ref }}" >> $GITHUB_ENV + echo "GITHUB_BASE_SHA=$(git rev-parse origin/${{ github.event.pull_request.base.ref }})" >> $GITHUB_ENV + echo "GITHUB_SHA=${{ github.event.pull_request.head.sha }}" >> $GITHUB_ENV + + - name: Run redirect validation + id: validate + continue-on-error: true + run: | + set +e + OUTPUT=$(bun scripts/check-redirects-on-rename.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 "validation_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 redirects are missing + if: steps.validate.outputs.exit_code == '1' && steps.validate.outputs.has_results == 'true' + uses: actions/github-script@v7 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + script: | + // Use toJSON() to safely escape the JSON string for JavaScript interpolation + // toJSON() will JSON-encode the string, so we need to parse it once to get the original JSON string, + // then parse again to get the actual object + const validationResultJsonString = ${{ toJSON(steps.validate.outputs.validation_result) }}; + let validationResult; + try { + // First parse: convert from JSON-encoded string to original JSON string + const jsonString = JSON.parse(validationResultJsonString); + // Second parse: convert from JSON string to object + validationResult = JSON.parse(jsonString); + } catch (e) { + console.error('Failed to parse validation result:', e); + return; + } + + const missingRedirects = validationResult.missingRedirects || []; + + if (missingRedirects.length === 0) { + return; + } + + // Group by redirects array type + const devDocsRedirects = missingRedirects.filter(mr => mr.isDeveloperDocs); + const userDocsRedirects = missingRedirects.filter(mr => !mr.isDeveloperDocs); + + let comment = '## ⚠️ Missing Redirects Detected\n\n'; + comment += 'This PR renames or moves MDX files, but some redirects may be missing from `redirects.js`.\n\n'; + comment += 'Please add the following redirects to ensure old URLs continue to work:\n\n'; + + if (userDocsRedirects.length > 0) { + comment += '### User Docs Redirects (userDocsRedirects array)\n\n'; + comment += '```javascript\n'; + userDocsRedirects.forEach(mr => { + comment += ` {\n`; + comment += ` source: '${mr.oldUrl}',\n`; + comment += ` destination: '${mr.newUrl}',\n`; + comment += ` },\n`; + }); + comment += '```\n\n'; + } + + if (devDocsRedirects.length > 0) { + comment += '### Developer Docs Redirects (developerDocsRedirects array)\n\n'; + comment += '```javascript\n'; + devDocsRedirects.forEach(mr => { + comment += ` {\n`; + comment += ` source: '${mr.oldUrl}',\n`; + comment += ` destination: '${mr.newUrl}',\n`; + comment += ` },\n`; + }); + comment += '```\n\n'; + } + + comment += '---\n'; + comment += '_Note: This check will fail until redirects are added. Adding redirects ensures old links continue to work._\n'; + + // Check for existing comments from this action + const {data: comments} = await 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') + ); + + if (existingComment) { + // Update existing comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: comment, + }); + console.log(`Updated existing comment ${existingComment.id}`); + } else { + // Create new comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment, + }); + console.log('Created new comment'); + } + + - name: Report failure if redirects are missing + if: steps.validate.outputs.exit_code == '1' && steps.validate.outputs.has_results == 'true' + run: | + echo "::warning::Missing redirects detected. Please add the redirects shown in the PR comment above." + echo "::warning::This check will show as failed, but will not block merging. However, adding redirects is recommended." + exit 1 + + - name: Success - no redirects needed + if: steps.validate.outputs.exit_code == '0' + run: | + echo "✅ No file renames detected, or all renames have corresponding redirects." diff --git a/redirects.js b/redirects.js index a8a7135a1ca0e2..55a924449b64ce 100644 --- a/redirects.js +++ b/redirects.js @@ -1269,4 +1269,4 @@ const redirects = async () => { }); }; -module.exports = {redirects}; +module.exports = {redirects, developerDocsRedirects, userDocsRedirects}; diff --git a/scripts/check-redirects-on-rename.spec.ts b/scripts/check-redirects-on-rename.spec.ts new file mode 100644 index 00000000000000..2a6ae51962b9d3 --- /dev/null +++ b/scripts/check-redirects-on-rename.spec.ts @@ -0,0 +1,342 @@ +import fs from 'fs'; +import path from 'path'; + +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; + +import { + filePathToUrls, + parseRedirectsJs, + redirectMatches, +} from './check-redirects-on-rename'; + +// Mock redirects fixture +const mockRedirectsJs = ` +const isDeveloperDocs = !!process.env.NEXT_PUBLIC_DEVELOPER_DOCS; + +const developerDocsRedirects = [ + { + source: '/sdk/old-path/', + destination: '/sdk/new-path/', + }, +]; + +const userDocsRedirects = [ + { + source: '/platforms/javascript/old-guide/', + destination: '/platforms/javascript/new-guide/', + }, + { + source: '/platforms/python/old-tutorial', + destination: '/platforms/python/new-tutorial', + }, +]; + +module.exports = {developerDocsRedirects, userDocsRedirects}; +`; + +describe('filePathToUrls', () => { + it('should convert docs file path to canonical URL with trailing slash', () => { + const result = filePathToUrls('docs/platforms/javascript/index.mdx'); + expect(result.isDeveloperDocs).toBe(false); + expect(result.urls).toEqual(['/platforms/javascript/']); // Canonical with trailing slash + }); + + it('should convert develop-docs file path to canonical URL with trailing slash', () => { + const result = filePathToUrls('develop-docs/backend/api/index.mdx'); + expect(result.isDeveloperDocs).toBe(true); + expect(result.urls).toEqual(['/backend/api/']); // Canonical with trailing slash + }); + + it('should handle non-index files with trailing slash', () => { + const result = filePathToUrls('docs/platforms/javascript/guide.mdx'); + expect(result.isDeveloperDocs).toBe(false); + expect(result.urls).toEqual(['/platforms/javascript/guide/']); // Canonical with trailing slash + }); + + it('should return empty for paths outside docs/develop-docs', () => { + const result = filePathToUrls('scripts/something.mdx'); + expect(result.isDeveloperDocs).toBe(false); + expect(result.urls).toEqual([]); + }); +}); + +describe('parseRedirectsJs', () => { + let tempFile: string; + + beforeEach(() => { + tempFile = path.join(process.cwd(), 'redirects-test-temp.js'); + }); + + afterEach(() => { + if (fs.existsSync(tempFile)) { + fs.unlinkSync(tempFile); + } + // Clear require cache + delete require.cache[path.resolve(tempFile)]; + }); + + it('should parse developer docs and user docs redirects', () => { + fs.writeFileSync(tempFile, mockRedirectsJs); + const result = parseRedirectsJs(tempFile); + expect(result.developerDocsRedirects).toHaveLength(1); + expect(result.developerDocsRedirects[0].source).toBe('/sdk/old-path/'); + expect(result.developerDocsRedirects[0].destination).toBe('/sdk/new-path/'); + expect(result.userDocsRedirects).toHaveLength(2); + expect(result.userDocsRedirects[0].source).toBe('/platforms/javascript/old-guide/'); + expect(result.userDocsRedirects[0].destination).toBe( + '/platforms/javascript/new-guide/' + ); + }); + + it('should return empty arrays for non-existent file', () => { + const result = parseRedirectsJs('/nonexistent/file.js'); + expect(result.developerDocsRedirects).toEqual([]); + expect(result.userDocsRedirects).toEqual([]); + }); + + it('should parse real redirects.js file', () => { + const result = parseRedirectsJs('redirects.js'); + // Should have some redirects + expect(result.developerDocsRedirects.length).toBeGreaterThan(0); + expect(result.userDocsRedirects.length).toBeGreaterThan(0); + }); +}); + +describe('redirectMatches', () => { + it('should match exact redirects', () => { + const redirect = { + source: '/old/path/', + destination: '/new/path/', + }; + expect(redirectMatches(redirect, '/old/path/', '/new/path/')).toBe(true); + expect(redirectMatches(redirect, '/different/path/', '/new/path/')).toBe(false); + }); + + it('should match redirects with path parameters', () => { + const redirect = { + source: '/platforms/:platform/old/:path*', + destination: '/platforms/:platform/new/:path*', + }; + expect( + redirectMatches( + redirect, + '/platforms/javascript/old/guide', + '/platforms/javascript/new/guide' + ) + ).toBe(true); + expect( + redirectMatches( + redirect, + '/platforms/python/old/tutorial/', + '/platforms/python/new/tutorial/' + ) + ).toBe(true); + }); + + it('should handle redirects with single path parameter', () => { + const redirect = { + source: '/platforms/:platform/old', + destination: '/platforms/:platform/new', + }; + expect( + redirectMatches(redirect, '/platforms/javascript/old', '/platforms/javascript/new') + ).toBe(true); + expect( + redirectMatches(redirect, '/platforms/python/old', '/platforms/python/new') + ).toBe(true); + }); + + it('should not match when source pattern does not match', () => { + const redirect = { + source: '/platforms/:platform/old', + destination: '/platforms/:platform/new', + }; + expect( + redirectMatches(redirect, '/different/path', '/platforms/javascript/new') + ).toBe(false); + }); + + it('should match destination with exact path when no params', () => { + const redirect = { + source: '/old/path', + destination: '/new/exact/path', + }; + expect(redirectMatches(redirect, '/old/path', '/new/exact/path')).toBe(true); + expect(redirectMatches(redirect, '/old/path', '/different/path')).toBe(false); + }); + + it('should handle :path* with nested paths correctly', () => { + const redirect = { + source: '/sdk/basics/:path*', + destination: '/sdk/processes/basics/:path*', + }; + // File moves with :path* redirect - should match + expect( + redirectMatches(redirect, '/sdk/basics/guide/', '/sdk/processes/basics/guide/') + ).toBe(true); + expect( + redirectMatches( + redirect, + '/sdk/basics/advanced/tutorial/', + '/sdk/processes/basics/advanced/tutorial/' + ) + ).toBe(true); + // File stays in same directory but renamed - should NOT match + expect( + redirectMatches(redirect, '/sdk/basics/old-file/', '/sdk/basics/new-file/') + ).toBe(false); + // File moves to different base - should NOT match + expect(redirectMatches(redirect, '/sdk/basics/guide/', '/sdk/other/guide/')).toBe( + false + ); + }); + + it('should handle :path* with empty path', () => { + const redirect = { + source: '/sdk/basics/:path*', + destination: '/sdk/processes/basics/:path*', + }; + // Empty path (just directory) should match + expect(redirectMatches(redirect, '/sdk/basics/', '/sdk/processes/basics/')).toBe( + true + ); + }); + + it('should handle :path* source to exact destination', () => { + const redirect = { + source: '/old/:path*', + destination: '/new/', + }; + // :path* source with any path should redirect to exact destination + expect(redirectMatches(redirect, '/old/something/', '/new/')).toBe(true); + expect(redirectMatches(redirect, '/old/nested/path/', '/new/')).toBe(true); + expect(redirectMatches(redirect, '/old/something/', '/new/other/')).toBe(false); + }); + + it('should handle complex :path* patterns with multiple params', () => { + const redirect = { + source: '/platforms/:platform/guides/:guide/configuration/capture/:path*', + destination: '/platforms/:platform/guides/:guide/usage/', + }; + // Should match when all params align correctly + expect( + redirectMatches( + redirect, + '/platforms/javascript/guides/react/configuration/capture/setup/', + '/platforms/javascript/guides/react/usage/' + ) + ).toBe(true); + // Note: Our regex matching has a limitation - it checks if patterns match, + // but Next.js redirects preserve parameter values. In practice, this edge case + // (where params change between old and new URL) is rare and would be caught + // by manual review. For now, we accept that pattern matches are sufficient. + // If the new URL matches the destination pattern, we consider it covered. + expect( + redirectMatches( + redirect, + '/platforms/javascript/guides/react/configuration/capture/setup/', + '/platforms/python/guides/react/usage/' + ) + ).toBe(true); // Pattern matches, even though actual redirect would preserve 'javascript' + }); + + it('should escape regex special characters in URLs', () => { + // Test URLs with special regex characters that should be treated as literals + const redirect = { + source: '/platforms/javascript/guide(v2)/', + destination: '/platforms/javascript/guide-v2/', + }; + // Should match exact URLs with special characters + expect( + redirectMatches( + redirect, + '/platforms/javascript/guide(v2)/', + '/platforms/javascript/guide-v2/' + ) + ).toBe(true); + // Should not match URLs that don't exactly match + expect( + redirectMatches( + redirect, + '/platforms/javascript/guide(v3)/', + '/platforms/javascript/guide-v2/' + ) + ).toBe(false); + }); + + it('should handle URLs with dots and other special characters', () => { + const redirect = { + source: '/platforms/javascript/guide.old/', + destination: '/platforms/javascript/guide.new/', + }; + // Dot should be treated as literal, not regex "any character" + expect( + redirectMatches( + redirect, + '/platforms/javascript/guide.old/', + '/platforms/javascript/guide.new/' + ) + ).toBe(true); + // Should not match "guidexold" (if dot was treated as regex) + expect( + redirectMatches( + redirect, + '/platforms/javascript/guidexold/', + '/platforms/javascript/guide.new/' + ) + ).toBe(false); + }); + + it('should handle URLs with brackets and parentheses', () => { + const redirect = { + source: '/platforms/javascript/guide[deprecated]/', + destination: '/platforms/javascript/guide/', + }; + // Brackets should be treated as literal, not regex character class + expect( + redirectMatches( + redirect, + '/platforms/javascript/guide[deprecated]/', + '/platforms/javascript/guide/' + ) + ).toBe(true); + // Should not match without brackets + expect( + redirectMatches( + redirect, + '/platforms/javascript/guide/', + '/platforms/javascript/guide/' + ) + ).toBe(false); + }); + + it('should escape special characters while preserving path parameters', () => { + const redirect = { + source: '/platforms/:platform/guide(v1)/', + destination: '/platforms/:platform/guide-v1/', + }; + // Path parameters should still work, but special chars should be escaped + expect( + redirectMatches( + redirect, + '/platforms/javascript/guide(v1)/', + '/platforms/javascript/guide-v1/' + ) + ).toBe(true); + expect( + redirectMatches( + redirect, + '/platforms/python/guide(v1)/', + '/platforms/python/guide-v1/' + ) + ).toBe(true); + // Should not match different version + expect( + redirectMatches( + redirect, + '/platforms/javascript/guide(v2)/', + '/platforms/javascript/guide-v1/' + ) + ).toBe(false); + }); +}); diff --git a/scripts/check-redirects-on-rename.ts b/scripts/check-redirects-on-rename.ts new file mode 100644 index 00000000000000..5c959ee9a020c7 --- /dev/null +++ b/scripts/check-redirects-on-rename.ts @@ -0,0 +1,403 @@ +/* eslint-disable no-console */ +import {execFileSync} from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +interface Redirect { + destination: string; + source: string; +} + +interface RenamedFile { + isDeveloperDocs: boolean; + newPath: string; + newUrl: string; + oldPath: string; + oldUrl: string; +} + +interface MissingRedirect { + isDeveloperDocs: boolean; + newPath: string; + newUrl: string; + oldPath: string; + oldUrl: string; +} + +/** + * Converts a file path to a URL slug + * - Removes `docs/` or `develop-docs/` prefix + * - Handles `index.mdx` files by converting to directory path + * - Returns both with and without trailing slash variants + */ +function filePathToUrls(filePath: string): {isDeveloperDocs: boolean; urls: string[]} { + const isDeveloperDocs = filePath.startsWith('develop-docs/'); + const prefix = isDeveloperDocs ? 'develop-docs/' : 'docs/'; + + if (!filePath.startsWith(prefix)) { + return {isDeveloperDocs: false, urls: []}; + } + + // Remove prefix and extension + let slug = filePath.slice(prefix.length); + if (slug.endsWith('.mdx') || slug.endsWith('.md')) { + slug = slug.replace(/\.(mdx|md)$/, ''); + } + + // Handle index files + if (slug.endsWith('/index')) { + slug = slug.replace(/\/index$/, ''); + // Return canonical URL with trailing slash (Next.js has trailingSlash: true) + return {isDeveloperDocs, urls: [`/${slug}/`]}; + } + + // Return canonical URL with trailing slash (Next.js has trailingSlash: true) + return {isDeveloperDocs, urls: [`/${slug}/`]}; +} + +/** + * Detects renamed/moved MDX files using git diff + */ +function detectRenamedFiles(): RenamedFile[] { + try { + // Get base branch (usually origin/master or the PR's base) + const baseBranch = process.env.GITHUB_BASE_REF || 'master'; + const baseSha = process.env.GITHUB_BASE_SHA || `origin/${baseBranch}`; + const headSha = process.env.GITHUB_SHA || 'HEAD'; + + // Use git diff to find renames (similarity threshold of 50%) + const diffOutput = execFileSync( + 'git', + ['diff', '--find-renames=50%', '--name-status', `${baseSha}...${headSha}`], + {encoding: 'utf8', stdio: 'pipe'} + ) + .toString() + .trim(); + + const renamedFiles: RenamedFile[] = []; + + for (const line of diffOutput.split('\n')) { + if (!line.trim()) continue; + + // Format: R old-path new-path + // or R old-path new-path + const match = line.match(/^R(\d+)?\s+(.+?)\s+(.+)$/); + if (!match) continue; + + const [, , oldPath, newPath] = match; + + // Only process MDX/MD files + if (!oldPath.match(/\.(mdx|md)$/) || !newPath.match(/\.(mdx|md)$/)) { + continue; + } + + // Only process files in docs/ or develop-docs/ + if (!oldPath.startsWith('docs/') && !oldPath.startsWith('develop-docs/')) { + continue; + } + if (!newPath.startsWith('docs/') && !newPath.startsWith('develop-docs/')) { + continue; + } + + const oldPathInfo = filePathToUrls(oldPath); + const newPathInfo = filePathToUrls(newPath); + + // They should be in the same category (both docs or both develop-docs) + if (oldPathInfo.isDeveloperDocs !== newPathInfo.isDeveloperDocs) { + console.warn( + `⚠️ Warning: File moved between docs/ and develop-docs/: ${oldPath} → ${newPath}` + ); + } + + // Create entry with canonical URL (Next.js normalizes to trailing slash) + // Since trailingSlash: true is set, we only need one redirect per file pair + renamedFiles.push({ + oldPath, + newPath, + oldUrl: oldPathInfo.urls[0], // Canonical URL (with trailing slash) + newUrl: newPathInfo.urls[0], // Canonical URL (with trailing slash) + isDeveloperDocs: oldPathInfo.isDeveloperDocs, + }); + } + + return renamedFiles; + } catch (error) { + console.error('Error detecting renamed files:', error); + return []; + } +} + +/** + * Parses redirects.js to extract redirect entries by directly requiring the file + */ +function parseRedirectsJs(filePath: string): { + developerDocsRedirects: Redirect[]; + userDocsRedirects: Redirect[]; +} { + if (!fs.existsSync(filePath)) { + console.warn(`⚠️ redirects.js not found at ${filePath}`); + return {developerDocsRedirects: [], userDocsRedirects: []}; + } + + try { + // Clear require cache to ensure we get fresh data + const resolvedPath = path.resolve(filePath); + delete require.cache[resolvedPath]; + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const redirects = require(resolvedPath); + return { + developerDocsRedirects: redirects.developerDocsRedirects || [], + userDocsRedirects: redirects.userDocsRedirects || [], + }; + } catch (error) { + console.warn(`⚠️ Error loading redirects from ${filePath}:`, error); + return {developerDocsRedirects: [], userDocsRedirects: []}; + } +} + +/** + * Escapes special regex characters in a string so they are treated as literals + */ +function escapeRegexSpecialChars(str: string): string { + // Escape special regex characters: . * + ? ^ $ | ( ) [ ] { } \ + // We need to escape backslashes first, then other special chars + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Converts a redirect pattern with path parameters to a regex pattern + * Escapes special regex characters while preserving path parameter patterns + */ +function convertRedirectPatternToRegex(pattern: string): string { + // Strategy: Replace path parameters with placeholders first, escape everything, + // then replace placeholders with regex patterns + const placeholderPathStar = '__PATH_STAR_PLACEHOLDER__'; + const placeholderParam = '__PARAM_PLACEHOLDER__'; + + // Replace path parameters with placeholders + let result = pattern + .replace(/:\w+\*/g, placeholderPathStar) // :path* -> placeholder + .replace(/:\w+/g, placeholderParam); // :param -> placeholder + + // Escape all special regex characters + result = escapeRegexSpecialChars(result); + + // Replace placeholders with regex patterns + result = result + .replace(new RegExp(escapeRegexSpecialChars(placeholderPathStar), 'g'), '.*') // placeholder -> .* + .replace(new RegExp(escapeRegexSpecialChars(placeholderParam), 'g'), '[^/]+'); // placeholder -> [^/]+ + + return result; +} + +/** + * Checks if a redirect matches the expected old → new URL pattern + * Handles path parameters like :path*, :platform, etc. + * + * Important considerations for :path*: + * 1. If a redirect uses :path* (e.g., /old/:path* -> /new/:path*), it matches + * any path under /old/ and redirects to the same path under /new/ + * 2. For a file rename, we need to verify that the redirect correctly maps + * the old URL to the new URL + * 3. If the redirect destination doesn't match where the file actually moved, + * we need a specific redirect + * + * Examples: + * - Redirect: /sdk/basics/:path* -> /sdk/processes/basics/:path* + * - File: /sdk/basics/old.mdx -> /sdk/processes/basics/old.mdx ✅ Covered + * - File: /sdk/basics/old.mdx -> /sdk/basics/new.mdx ❌ Needs specific redirect + * - File: /sdk/basics/old.mdx -> /sdk/other/new.mdx ❌ Needs specific redirect + */ +function redirectMatches(redirect: Redirect, oldUrl: string, newUrl: string): boolean { + // Simple exact match first + if (redirect.source === oldUrl && redirect.destination === newUrl) { + return true; + } + + // Handle path parameters - convert patterns to regex + // :path* matches zero or more path segments (including nested paths) + // :param matches a single path segment + const sourcePattern = convertRedirectPatternToRegex(redirect.source); + const sourceRegex = new RegExp(`^${sourcePattern}$`); + + // Check if oldUrl matches the source pattern + if (!sourceRegex.test(oldUrl)) { + return false; + } + + // Old URL matches the source pattern, now check if destination matches new URL + const destPattern = convertRedirectPatternToRegex(redirect.destination); + const destRegex = new RegExp(`^${destPattern}$`); + + // If destination has no path parameters, require exact match + if (!redirect.destination.includes(':')) { + return redirect.destination === newUrl; + } + + // If destination has path parameters, check if newUrl matches the pattern + // This handles cases like: + // - /old/:path* -> /new/:path* where /old/file/ -> /new/file/ ✅ + // - /old/:path* -> /new/ where /old/file/ -> /new/ ✅ + // - /old/:path* -> /new/:path* where /old/file/ -> /other/file/ ❌ + // + // Note: Next.js redirects preserve parameter values (e.g., /platforms/:platform/old + // with request /platforms/javascript/old redirects to /platforms/javascript/new). + // Our pattern matching doesn't extract and resolve parameter values, so we might + // have false positives in edge cases where parameters differ between old and new URLs. + // However, this is rare in practice (most renames preserve parameter values), and + // the pattern match is a good heuristic that a redirect exists. + return destRegex.test(newUrl); +} + +/** + * Main validation function + */ +function validateRedirects(): MissingRedirect[] { + const renamedFiles = detectRenamedFiles(); + + if (renamedFiles.length === 0) { + console.log('✅ No MDX file renames detected.'); + return []; + } + + console.log(`📝 Found ${renamedFiles.length} renamed file(s) to check:`); + renamedFiles.forEach(r => { + console.log(` ${r.oldPath} → ${r.newPath}`); + }); + + // Check if redirects.js was modified in this PR + const baseBranch = process.env.GITHUB_BASE_REF || 'master'; + const baseSha = process.env.GITHUB_BASE_SHA || `origin/${baseBranch}`; + const headSha = process.env.GITHUB_SHA || 'HEAD'; + + // Determine which version of redirects.js to check + // If redirects.js was modified in the PR, we should validate against the PR version + // Otherwise, validate against the base branch version + let redirectsFilePath = 'redirects.js'; + + try { + // Check if redirects.js was modified in this PR + const modifiedFiles = execFileSync( + 'git', + ['diff', '--name-only', `${baseSha}...${headSha}`], + { + encoding: 'utf8', + stdio: 'pipe', + } + ) + .toString() + .trim(); + + const redirectsModified = modifiedFiles.includes('redirects.js'); + + if (redirectsModified) { + console.log('📝 redirects.js was modified in this PR, using PR version'); + redirectsFilePath = 'redirects.js'; + } else { + // Try to get base version for comparison + try { + const baseRedirects = execFileSync('git', ['show', `${baseSha}:redirects.js`], { + encoding: 'utf8', + stdio: 'pipe', + }); + // Write to temp file for parsing + const tmpFile = path.join(process.cwd(), 'redirects-base.js'); + fs.writeFileSync(tmpFile, baseRedirects); + redirectsFilePath = tmpFile; + console.log('📝 redirects.js was not modified, using base branch version'); + } catch (err) { + // If we can't get base version, use current file + console.log( + '⚠️ Could not get base version of redirects.js, using current version' + ); + redirectsFilePath = 'redirects.js'; + } + } + } catch (err) { + // If we can't determine, use current file + console.log('⚠️ Could not determine redirects.js status, using current version'); + redirectsFilePath = 'redirects.js'; + } + + const {developerDocsRedirects, userDocsRedirects} = parseRedirectsJs(redirectsFilePath); + + // Clean up temp file after use + try { + const tmpFile = path.join(process.cwd(), 'redirects-base.js'); + if (fs.existsSync(tmpFile)) { + fs.unlinkSync(tmpFile); + } + } catch { + // Ignore cleanup errors + } + + console.log( + `📋 Found ${developerDocsRedirects.length} developer docs redirects and ${userDocsRedirects.length} user docs redirects` + ); + + const missingRedirects: MissingRedirect[] = []; + + for (const renamedFile of renamedFiles) { + const redirectsToCheck = renamedFile.isDeveloperDocs + ? developerDocsRedirects + : userDocsRedirects; + + // Check if any redirect matches + const hasRedirect = redirectsToCheck.some(redirect => + redirectMatches(redirect, renamedFile.oldUrl, renamedFile.newUrl) + ); + + if (!hasRedirect) { + // Check if this file pair has already been reported + // Since we only generate one URL variant per file (canonical with trailing slash), + // we can deduplicate by file paths + const alreadyReported = missingRedirects.some( + mr => + mr.oldPath === renamedFile.oldPath && + mr.newPath === renamedFile.newPath && + mr.isDeveloperDocs === renamedFile.isDeveloperDocs + ); + + if (!alreadyReported) { + missingRedirects.push({ + oldPath: renamedFile.oldPath, + newPath: renamedFile.newPath, + oldUrl: renamedFile.oldUrl, + newUrl: renamedFile.newUrl, + isDeveloperDocs: renamedFile.isDeveloperDocs, + }); + } + } + } + + return missingRedirects; +} + +// Main execution +if (require.main === module) { + const missingRedirects = validateRedirects(); + + if (missingRedirects.length > 0) { + console.error('\n❌ Missing redirects detected:'); + missingRedirects.forEach(mr => { + console.error(` ${mr.oldUrl} → ${mr.newUrl}`); + console.error(` File: ${mr.oldPath} → ${mr.newPath}`); + console.error( + ` Array: ${mr.isDeveloperDocs ? 'developerDocsRedirects' : 'userDocsRedirects'}\n` + ); + }); + + // Output JSON for GitHub Action + console.log('\n---JSON_OUTPUT---'); + console.log(JSON.stringify({missingRedirects}, null, 2)); + console.log('---JSON_OUTPUT---\n'); + + process.exit(1); + } else { + console.log('\n✅ All renamed files have corresponding redirects in redirects.js'); + process.exit(0); + } +} + +export {validateRedirects, filePathToUrls, parseRedirectsJs, redirectMatches};