From 90b277afa612d3732d2ea497b5394c380c5d3cc1 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Mon, 3 Nov 2025 14:22:14 -0500 Subject: [PATCH 01/13] feat(Infra) if a file was moved, ensure there is a redirect --- .../workflows/check-redirects-on-rename.yml | 141 ++++++ .../{testing.mdx => testing-new.mdx} | 0 scripts/check-redirects-on-rename.spec.ts | 171 +++++++ scripts/check-redirects-on-rename.ts | 437 ++++++++++++++++++ 4 files changed, 749 insertions(+) create mode 100644 .github/workflows/check-redirects-on-rename.yml rename develop-docs/development-infrastructure/{testing.mdx => testing-new.mdx} (100%) create mode 100644 scripts/check-redirects-on-rename.spec.ts create mode 100644 scripts/check-redirects-on-rename.ts diff --git a/.github/workflows/check-redirects-on-rename.yml b/.github/workflows/check-redirects-on-rename.yml new file mode 100644 index 0000000000000..0f88dd4bd4dc7 --- /dev/null +++ b/.github/workflows/check-redirects-on-rename.yml @@ -0,0 +1,141 @@ +name: Check Redirects on File Rename + +on: + pull_request: + branches: [master] + +jobs: + check-redirects: + name: Check redirects for renamed files + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Need full history for git diff + + - 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 + if echo "$OUTPUT" | grep -q "---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 + echo "has_results=true" >> $GITHUB_OUTPUT + else + echo "has_results=false" >> $GITHUB_OUTPUT + fi + + # Save exit code + 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 + with: + script: | + const validationResultJson = `${{ steps.validate.outputs.validation_result }}`; + let validationResult; + try { + validationResult = JSON.parse(validationResultJson); + } 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 is a warning and will not block your PR. However, 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.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, + }); + } else { + // Create new comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment, + }); + } diff --git a/develop-docs/development-infrastructure/testing.mdx b/develop-docs/development-infrastructure/testing-new.mdx similarity index 100% rename from develop-docs/development-infrastructure/testing.mdx rename to develop-docs/development-infrastructure/testing-new.mdx diff --git a/scripts/check-redirects-on-rename.spec.ts b/scripts/check-redirects-on-rename.spec.ts new file mode 100644 index 0000000000000..4b9c00a4eba0f --- /dev/null +++ b/scripts/check-redirects-on-rename.spec.ts @@ -0,0 +1,171 @@ +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', + }, +]; +`; + +describe('filePathToUrls', () => { + it('should convert docs file path to URLs', () => { + const result = filePathToUrls('docs/platforms/javascript/index.mdx'); + expect(result.isDeveloperDocs).toBe(false); + expect(result.urls).toContain('/platforms/javascript/'); + expect(result.urls).toContain('/platforms/javascript'); + }); + + it('should convert develop-docs file path to URLs', () => { + const result = filePathToUrls('develop-docs/backend/api/index.mdx'); + expect(result.isDeveloperDocs).toBe(true); + expect(result.urls).toContain('/backend/api/'); + expect(result.urls).toContain('/backend/api'); + }); + + it('should handle non-index files', () => { + const result = filePathToUrls('docs/platforms/javascript/guide.mdx'); + expect(result.isDeveloperDocs).toBe(false); + expect(result.urls).toContain('/platforms/javascript/guide'); + expect(result.urls).toContain('/platforms/javascript/guide/'); + }); + + 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); + } + }); + + it('should parse developer 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/'); + }); + + it('should parse user docs redirects', () => { + fs.writeFileSync(tempFile, mockRedirectsJs); + const result = parseRedirectsJs(tempFile); + 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); + }); +}); diff --git a/scripts/check-redirects-on-rename.ts b/scripts/check-redirects-on-rename.ts new file mode 100644 index 0000000000000..6507a9e022491 --- /dev/null +++ b/scripts/check-redirects-on-rename.ts @@ -0,0 +1,437 @@ +/* eslint-disable no-console */ +import {execSync} 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 both with and without trailing slash + return {isDeveloperDocs, urls: [`/${slug}/`, `/${slug}`]}; + } + + // Return URL path + return {isDeveloperDocs, urls: [`/${slug}`, `/${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 = execSync( + `git diff --find-renames=50% --name-status ${baseSha}...${headSha}`, + {encoding: 'utf8', stdio: 'pipe'} + ).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 entries for all URL variants + for (const oldUrl of oldPathInfo.urls) { + for (const newUrl of newPathInfo.urls) { + renamedFiles.push({ + oldPath, + newPath, + oldUrl, + newUrl, + isDeveloperDocs: oldPathInfo.isDeveloperDocs, + }); + } + } + } + + return renamedFiles; + } catch (error) { + console.error('Error detecting renamed files:', error); + return []; + } +} + +/** + * Parses redirects.js to extract redirect entries + * This uses regex-based parsing since redirects.js is a JavaScript file + */ +function parseRedirectsJs(filePath: string): { + developerDocsRedirects: Redirect[]; + userDocsRedirects: Redirect[]; +} { + if (!fs.existsSync(filePath)) { + console.warn(`⚠️ redirects.js not found at ${filePath}`); + return {developerDocsRedirects: [], userDocsRedirects: []}; + } + + const content = fs.readFileSync(filePath, 'utf8'); + + const developerDocsRedirects: Redirect[] = []; + const userDocsRedirects: Redirect[] = []; + + // Extract developerDocsRedirects array + // Find the start of the array (look for the const declaration, not JSDoc comments) + const devDocsMatch = content.match(/const developerDocsRedirects\s*=/); + if (devDocsMatch && devDocsMatch.index !== undefined) { + // Find the opening bracket after the assignment + const arrayStart = content.indexOf('[', devDocsMatch.index); + if (arrayStart !== -1) { + // Find the matching closing bracket by counting braces + let depth = 0; + let inString = false; + let stringChar = ''; + let i = arrayStart; + + while (i < content.length) { + const char = content[i]; + const prevChar = i > 0 ? content[i - 1] : ''; + + // Handle string literals + if (!inString && (char === '"' || char === "'") && prevChar !== '\\') { + inString = true; + stringChar = char; + } else if (inString && char === stringChar && prevChar !== '\\') { + inString = false; + } + + // Count brackets only when not in string + if (!inString) { + if (char === '[') depth++; + if (char === ']') { + depth--; + if (depth === 0) { + // Found the closing bracket + const arrayContent = content.slice(arrayStart + 1, i); + developerDocsRedirects.push(...extractRedirectsFromArray(arrayContent)); + break; + } + } + } + i++; + } + } + } + + // Extract userDocsRedirects array + const userDocsMatch = content.match(/const userDocsRedirects\s*=/); + if (userDocsMatch && userDocsMatch.index !== undefined) { + const arrayStart = content.indexOf('[', userDocsMatch.index); + if (arrayStart !== -1) { + let depth = 0; + let inString = false; + let stringChar = ''; + let i = arrayStart; + + while (i < content.length) { + const char = content[i]; + const prevChar = i > 0 ? content[i - 1] : ''; + + if (!inString && (char === '"' || char === "'") && prevChar !== '\\') { + inString = true; + stringChar = char; + } else if (inString && char === stringChar && prevChar !== '\\') { + inString = false; + } + + if (!inString) { + if (char === '[') depth++; + if (char === ']') { + depth--; + if (depth === 0) { + const arrayContent = content.slice(arrayStart + 1, i); + userDocsRedirects.push(...extractRedirectsFromArray(arrayContent)); + break; + } + } + } + i++; + } + } + } + + return {developerDocsRedirects, userDocsRedirects}; +} + +/** + * Extracts redirect objects from JavaScript array string + * Handles both single and double quotes, and whitespace variations + */ +function extractRedirectsFromArray(arrayContent: string): Redirect[] { + const redirects: Redirect[] = []; + + // Match redirect objects with more flexible whitespace handling + // Handles both ' and " quotes, and various whitespace patterns including multiline + // The pattern needs to match objects that span multiple lines + const redirectRegex = + /\{[\s\S]*?source:\s*['"]([^'"]+)['"][\s\S]*?destination:\s*['"]([^'"]+)['"][\s\S]*?\}/g; + + let match: RegExpExecArray | null = redirectRegex.exec(arrayContent); + while (match !== null) { + const source = match[1]; + const destination = match[2]; + + if (source && destination) { + redirects.push({ + source, + destination, + }); + } + + match = redirectRegex.exec(arrayContent); + } + + return redirects; +} + +/** + * Checks if a redirect matches the expected old → new URL pattern + * Handles path parameters like :path*, :platform, etc. + */ +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 + const sourcePattern = redirect.source + .replace(/:\w+\*/g, '.*') + .replace(/:\w+/g, '[^/]+'); + const sourceRegex = new RegExp(`^${sourcePattern}$`); + + // Check if oldUrl matches the source pattern + if (sourceRegex.test(oldUrl)) { + // For destinations with path parameters, check if newUrl matches + const destPattern = redirect.destination + .replace(/:\w+\*/g, '.*') + .replace(/:\w+/g, '[^/]+'); + const destRegex = new RegExp(`^${destPattern}$`); + + // If destination has no params, exact match + if (!redirect.destination.includes(':')) { + return redirect.destination === newUrl; + } + + // If destination has params, check if pattern matches + return destRegex.test(newUrl); + } + + return false; +} + +/** + * 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 = execSync(`git diff --name-only ${baseSha}...${headSha}`, { + encoding: 'utf8', + stdio: 'pipe', + }).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 = execSync(`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 it's a duplicate (already reported for a different URL variant) + const alreadyReported = missingRedirects.some( + mr => mr.oldPath === renamedFile.oldPath && mr.newPath === renamedFile.newPath + ); + + 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}; From 8820d3aacd14ce3e013f0a2d4ba7004bd19a643a Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Mon, 3 Nov 2025 15:21:15 -0500 Subject: [PATCH 02/13] fix action --- .github/workflows/check-redirects-on-rename.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-redirects-on-rename.yml b/.github/workflows/check-redirects-on-rename.yml index 0f88dd4bd4dc7..5d6c2762171f8 100644 --- a/.github/workflows/check-redirects-on-rename.yml +++ b/.github/workflows/check-redirects-on-rename.yml @@ -42,7 +42,7 @@ jobs: echo "$OUTPUT" # Extract JSON output if present - if echo "$OUTPUT" | grep -q "---JSON_OUTPUT---"; then + 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 From 1e6b873f504433ecf4d3efecc7926439bb7e2153 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Mon, 3 Nov 2025 15:34:11 -0500 Subject: [PATCH 03/13] address comments --- scripts/check-redirects-on-rename.ts | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/scripts/check-redirects-on-rename.ts b/scripts/check-redirects-on-rename.ts index 6507a9e022491..8dab17862ec56 100644 --- a/scripts/check-redirects-on-rename.ts +++ b/scripts/check-redirects-on-rename.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -import {execSync} from 'child_process'; +import {execFileSync} from 'child_process'; import fs from 'fs'; import path from 'path'; @@ -66,10 +66,13 @@ function detectRenamedFiles(): RenamedFile[] { const headSha = process.env.GITHUB_SHA || 'HEAD'; // Use git diff to find renames (similarity threshold of 50%) - const diffOutput = execSync( - `git diff --find-renames=50% --name-status ${baseSha}...${headSha}`, + const diffOutput = execFileSync( + 'git', + ['diff', '--find-renames=50%', '--name-status', `${baseSha}...${headSha}`], {encoding: 'utf8', stdio: 'pipe'} - ).trim(); + ) + .toString() + .trim(); const renamedFiles: RenamedFile[] = []; @@ -323,10 +326,16 @@ function validateRedirects(): MissingRedirect[] { try { // Check if redirects.js was modified in this PR - const modifiedFiles = execSync(`git diff --name-only ${baseSha}...${headSha}`, { - encoding: 'utf8', - stdio: 'pipe', - }).trim(); + const modifiedFiles = execFileSync( + 'git', + ['diff', '--name-only', `${baseSha}...${headSha}`], + { + encoding: 'utf8', + stdio: 'pipe', + } + ) + .toString() + .trim(); const redirectsModified = modifiedFiles.includes('redirects.js'); @@ -336,7 +345,7 @@ function validateRedirects(): MissingRedirect[] { } else { // Try to get base version for comparison try { - const baseRedirects = execSync(`git show ${baseSha}:redirects.js`, { + const baseRedirects = execFileSync('git', ['show', `${baseSha}:redirects.js`], { encoding: 'utf8', stdio: 'pipe', }); From a1aed7571b4f6db4fe122b37bcc0450ac6231ddb Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Mon, 3 Nov 2025 15:56:09 -0500 Subject: [PATCH 04/13] update logic --- .../workflows/check-redirects-on-rename.yml | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/.github/workflows/check-redirects-on-rename.yml b/.github/workflows/check-redirects-on-rename.yml index 5d6c2762171f8..24ceefa352449 100644 --- a/.github/workflows/check-redirects-on-rename.yml +++ b/.github/workflows/check-redirects-on-rename.yml @@ -8,6 +8,7 @@ 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 @@ -42,22 +43,23 @@ jobs: echo "$OUTPUT" # Extract JSON output if present - if echo "$OUTPUT" | grep -Fq "---JSON_OUTPUT---"; then + 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 - echo "has_results=true" >> $GITHUB_OUTPUT - else - echo "has_results=false" >> $GITHUB_OUTPUT + HAS_JSON=true fi - # Save exit code + 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: | const validationResultJson = `${{ steps.validate.outputs.validation_result }}`; @@ -108,7 +110,7 @@ jobs: } comment += '---\n'; - comment += '_Note: This is a warning and will not block your PR. However, adding redirects ensures old links continue to work._\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({ @@ -119,6 +121,7 @@ jobs: 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') ); @@ -130,6 +133,7 @@ jobs: comment_id: existingComment.id, body: comment, }); + console.log(`Updated existing comment ${existingComment.id}`); } else { // Create new comment await github.rest.issues.createComment({ @@ -138,4 +142,17 @@ jobs: 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." From 919971aa3ab2c67f5b6bcce2b87ba6eabd574b66 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Mon, 3 Nov 2025 16:44:26 -0500 Subject: [PATCH 05/13] robot fixes --- scripts/check-redirects-on-rename.spec.ts | 134 ++++++++++++++++++++-- scripts/check-redirects-on-rename.ts | 132 ++++++++++++++------- 2 files changed, 217 insertions(+), 49 deletions(-) diff --git a/scripts/check-redirects-on-rename.spec.ts b/scripts/check-redirects-on-rename.spec.ts index 4b9c00a4eba0f..dc19a28cd3c29 100644 --- a/scripts/check-redirects-on-rename.spec.ts +++ b/scripts/check-redirects-on-rename.spec.ts @@ -33,25 +33,22 @@ const userDocsRedirects = [ `; describe('filePathToUrls', () => { - it('should convert docs file path to URLs', () => { + 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).toContain('/platforms/javascript/'); - expect(result.urls).toContain('/platforms/javascript'); + expect(result.urls).toEqual(['/platforms/javascript/']); // Canonical with trailing slash }); - it('should convert develop-docs file path to URLs', () => { + 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).toContain('/backend/api/'); - expect(result.urls).toContain('/backend/api'); + expect(result.urls).toEqual(['/backend/api/']); // Canonical with trailing slash }); - it('should handle non-index files', () => { + 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).toContain('/platforms/javascript/guide'); - expect(result.urls).toContain('/platforms/javascript/guide/'); + expect(result.urls).toEqual(['/platforms/javascript/guide/']); // Canonical with trailing slash }); it('should return empty for paths outside docs/develop-docs', () => { @@ -104,6 +101,50 @@ describe('parseRedirectsJs', () => { expect(result.developerDocsRedirects.length).toBeGreaterThan(0); expect(result.userDocsRedirects.length).toBeGreaterThan(0); }); + + it('should correctly handle escaped backslashes in strings', () => { + // Test case that verifies the fix for escaped backslash handling + // The bug: prevChar !== '\\' incorrectly treats "text\\" as escaped quote + // The fix: isEscapedQuote counts consecutive backslashes (odd = escaped, even = not escaped) + // Key test: "text\\\\" should correctly end the string (2 backslashes = even = not escaped) + // In template literals, we need \\\\ to get \\ in the file, and \\" to get \" in the file + const redirectsWithEscapedBackslashes = ` +const developerDocsRedirects = [ + { + source: '/simple/path/', + destination: '/simple/new/path/', + }, + { + source: '/path/with\\\\"quotes/', + destination: '/new/path/', + }, +]; + +const userDocsRedirects = [ + { + source: '/platforms/old/path/', + destination: '/platforms/new/path/', + }, +]; +`; + fs.writeFileSync(tempFile, redirectsWithEscapedBackslashes); + const result = parseRedirectsJs(tempFile); + + // The parser should correctly identify string boundaries even with \\" sequences + // The key is that \\" (2 backslashes + quote) should end the string, not be treated as escaped + // This ensures bracket counting works correctly and the array is parsed correctly + expect(result.developerDocsRedirects).toHaveLength(2); + expect(result.developerDocsRedirects[0].source).toBe('/simple/path/'); + expect(result.developerDocsRedirects[0].destination).toBe('/simple/new/path/'); + // The second redirect contains \\" which should correctly end the string + // The regex extraction may not capture the full value, but bracket counting should work + expect(result.developerDocsRedirects[1].source).toBeDefined(); + expect(result.developerDocsRedirects[1].destination).toBe('/new/path/'); + + expect(result.userDocsRedirects).toHaveLength(1); + expect(result.userDocsRedirects[0].source).toBe('/platforms/old/path/'); + expect(result.userDocsRedirects[0].destination).toBe('/platforms/new/path/'); + }); }); describe('redirectMatches', () => { @@ -168,4 +209,79 @@ describe('redirectMatches', () => { 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' + }); }); diff --git a/scripts/check-redirects-on-rename.ts b/scripts/check-redirects-on-rename.ts index 8dab17862ec56..b5ef47d760b4a 100644 --- a/scripts/check-redirects-on-rename.ts +++ b/scripts/check-redirects-on-rename.ts @@ -47,12 +47,12 @@ function filePathToUrls(filePath: string): {isDeveloperDocs: boolean; urls: stri // Handle index files if (slug.endsWith('/index')) { slug = slug.replace(/\/index$/, ''); - // Return both with and without trailing slash - return {isDeveloperDocs, urls: [`/${slug}/`, `/${slug}`]}; + // Return canonical URL with trailing slash (Next.js has trailingSlash: true) + return {isDeveloperDocs, urls: [`/${slug}/`]}; } - // Return URL path - return {isDeveloperDocs, urls: [`/${slug}`, `/${slug}/`]}; + // Return canonical URL with trailing slash (Next.js has trailingSlash: true) + return {isDeveloperDocs, urls: [`/${slug}/`]}; } /** @@ -109,18 +109,15 @@ function detectRenamedFiles(): RenamedFile[] { ); } - // Create entries for all URL variants - for (const oldUrl of oldPathInfo.urls) { - for (const newUrl of newPathInfo.urls) { - renamedFiles.push({ - oldPath, - newPath, - oldUrl, - newUrl, - isDeveloperDocs: oldPathInfo.isDeveloperDocs, - }); - } - } + // 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; @@ -130,6 +127,31 @@ function detectRenamedFiles(): RenamedFile[] { } } +/** + * Checks if a quote at the given index is escaped by counting consecutive backslashes. + * A quote is escaped (part of the string) if there's an odd number of backslashes before it. + * A quote is not escaped (ends the string) if there's an even number (including zero) of backslashes before it. + * + * Examples: + * - "text\" - 1 backslash (odd) → escaped + * - "text\\" - 2 backslashes (even) → not escaped + * - "text\\\" - 3 backslashes (odd) → escaped + */ +function isEscapedQuote(content: string, index: number): boolean { + if (index === 0) return false; + + // Count consecutive backslashes before this position + let backslashCount = 0; + let pos = index - 1; + while (pos >= 0 && content[pos] === '\\') { + backslashCount++; + pos--; + } + + // Quote is escaped if there's an odd number of backslashes + return backslashCount % 2 === 1; +} + /** * Parses redirects.js to extract redirect entries * This uses regex-based parsing since redirects.js is a JavaScript file @@ -163,13 +185,12 @@ function parseRedirectsJs(filePath: string): { while (i < content.length) { const char = content[i]; - const prevChar = i > 0 ? content[i - 1] : ''; // Handle string literals - if (!inString && (char === '"' || char === "'") && prevChar !== '\\') { + if (!inString && (char === '"' || char === "'") && !isEscapedQuote(content, i)) { inString = true; stringChar = char; - } else if (inString && char === stringChar && prevChar !== '\\') { + } else if (inString && char === stringChar && !isEscapedQuote(content, i)) { inString = false; } @@ -203,12 +224,11 @@ function parseRedirectsJs(filePath: string): { while (i < content.length) { const char = content[i]; - const prevChar = i > 0 ? content[i - 1] : ''; - if (!inString && (char === '"' || char === "'") && prevChar !== '\\') { + if (!inString && (char === '"' || char === "'") && !isEscapedQuote(content, i)) { inString = true; stringChar = char; - } else if (inString && char === stringChar && prevChar !== '\\') { + } else if (inString && char === stringChar && !isEscapedQuote(content, i)) { inString = false; } @@ -265,6 +285,20 @@ function extractRedirectsFromArray(arrayContent: string): Redirect[] { /** * 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 @@ -273,29 +307,42 @@ function redirectMatches(redirect: Redirect, oldUrl: string, newUrl: string): bo } // 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 = redirect.source - .replace(/:\w+\*/g, '.*') - .replace(/:\w+/g, '[^/]+'); + .replace(/:\w+\*/g, '.*') // :path* -> .* (matches any chars including slashes) + .replace(/:\w+/g, '[^/]+'); // :param -> [^/]+ (matches non-slash chars) const sourceRegex = new RegExp(`^${sourcePattern}$`); // Check if oldUrl matches the source pattern - if (sourceRegex.test(oldUrl)) { - // For destinations with path parameters, check if newUrl matches - const destPattern = redirect.destination - .replace(/:\w+\*/g, '.*') - .replace(/:\w+/g, '[^/]+'); - const destRegex = new RegExp(`^${destPattern}$`); - - // If destination has no params, exact match - if (!redirect.destination.includes(':')) { - return redirect.destination === newUrl; - } + if (!sourceRegex.test(oldUrl)) { + return false; + } + + // Old URL matches the source pattern, now check if destination matches new URL + const destPattern = redirect.destination + .replace(/:\w+\*/g, '.*') + .replace(/:\w+/g, '[^/]+'); + const destRegex = new RegExp(`^${destPattern}$`); - // If destination has params, check if pattern matches - return destRegex.test(newUrl); + // If destination has no path parameters, require exact match + if (!redirect.destination.includes(':')) { + return redirect.destination === newUrl; } - return false; + // 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); } /** @@ -397,9 +444,14 @@ function validateRedirects(): MissingRedirect[] { ); if (!hasRedirect) { - // Check if it's a duplicate (already reported for a different URL variant) + // 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 => + mr.oldPath === renamedFile.oldPath && + mr.newPath === renamedFile.newPath && + mr.isDeveloperDocs === renamedFile.isDeveloperDocs ); if (!alreadyReported) { From a169410b2f4d53b23c15d6ec51af7f66c9cf9219 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Mon, 3 Nov 2025 16:54:07 -0500 Subject: [PATCH 06/13] switch back --- .../development-infrastructure/{testing-new.mdx => testing.mdx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename develop-docs/development-infrastructure/{testing-new.mdx => testing.mdx} (100%) diff --git a/develop-docs/development-infrastructure/testing-new.mdx b/develop-docs/development-infrastructure/testing.mdx similarity index 100% rename from develop-docs/development-infrastructure/testing-new.mdx rename to develop-docs/development-infrastructure/testing.mdx From 71e34fefa20a026084f1781e691b100fc8839e69 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Mon, 3 Nov 2025 17:03:04 -0500 Subject: [PATCH 07/13] safe json --- .github/workflows/check-redirects-on-rename.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-redirects-on-rename.yml b/.github/workflows/check-redirects-on-rename.yml index 24ceefa352449..92671ed7f8764 100644 --- a/.github/workflows/check-redirects-on-rename.yml +++ b/.github/workflows/check-redirects-on-rename.yml @@ -62,10 +62,16 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: script: | - const validationResultJson = `${{ steps.validate.outputs.validation_result }}`; + // 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 { - validationResult = JSON.parse(validationResultJson); + // 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; From 499aa34b0c262d113fbc0d80f9f1052dbb02400e Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Tue, 4 Nov 2025 10:24:00 -0500 Subject: [PATCH 08/13] regex escape patterns --- scripts/check-redirects-on-rename.spec.ts | 88 +++++++++++++++++++++++ scripts/check-redirects-on-rename.ts | 43 +++++++++-- 2 files changed, 125 insertions(+), 6 deletions(-) diff --git a/scripts/check-redirects-on-rename.spec.ts b/scripts/check-redirects-on-rename.spec.ts index dc19a28cd3c29..7499029867d51 100644 --- a/scripts/check-redirects-on-rename.spec.ts +++ b/scripts/check-redirects-on-rename.spec.ts @@ -284,4 +284,92 @@ describe('redirectMatches', () => { ) ).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 index b5ef47d760b4a..2fed78dd7f77e 100644 --- a/scripts/check-redirects-on-rename.ts +++ b/scripts/check-redirects-on-rename.ts @@ -282,6 +282,41 @@ function extractRedirectsFromArray(arrayContent: string): Redirect[] { return redirects; } +/** + * 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. @@ -309,9 +344,7 @@ function redirectMatches(redirect: Redirect, oldUrl: string, newUrl: string): bo // 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 = redirect.source - .replace(/:\w+\*/g, '.*') // :path* -> .* (matches any chars including slashes) - .replace(/:\w+/g, '[^/]+'); // :param -> [^/]+ (matches non-slash chars) + const sourcePattern = convertRedirectPatternToRegex(redirect.source); const sourceRegex = new RegExp(`^${sourcePattern}$`); // Check if oldUrl matches the source pattern @@ -320,9 +353,7 @@ function redirectMatches(redirect: Redirect, oldUrl: string, newUrl: string): bo } // Old URL matches the source pattern, now check if destination matches new URL - const destPattern = redirect.destination - .replace(/:\w+\*/g, '.*') - .replace(/:\w+/g, '[^/]+'); + const destPattern = convertRedirectPatternToRegex(redirect.destination); const destRegex = new RegExp(`^${destPattern}$`); // If destination has no path parameters, require exact match From 52a252a1ad085dea8be60196d22b519d979cff81 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 15:24:56 +0000 Subject: [PATCH 09/13] [getsentry/action-github-commit] Auto commit --- scripts/check-redirects-on-rename.spec.ts | 18 +++++++++++++++--- scripts/check-redirects-on-rename.ts | 16 ++++++++-------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/scripts/check-redirects-on-rename.spec.ts b/scripts/check-redirects-on-rename.spec.ts index 7499029867d51..cf2a2df8b9468 100644 --- a/scripts/check-redirects-on-rename.spec.ts +++ b/scripts/check-redirects-on-rename.spec.ts @@ -316,11 +316,19 @@ describe('redirectMatches', () => { }; // Dot should be treated as literal, not regex "any character" expect( - redirectMatches(redirect, '/platforms/javascript/guide.old/', '/platforms/javascript/guide.new/') + 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/') + redirectMatches( + redirect, + '/platforms/javascript/guidexold/', + '/platforms/javascript/guide.new/' + ) ).toBe(false); }); @@ -339,7 +347,11 @@ describe('redirectMatches', () => { ).toBe(true); // Should not match without brackets expect( - redirectMatches(redirect, '/platforms/javascript/guide/', '/platforms/javascript/guide/') + redirectMatches( + redirect, + '/platforms/javascript/guide/', + '/platforms/javascript/guide/' + ) ).toBe(false); }); diff --git a/scripts/check-redirects-on-rename.ts b/scripts/check-redirects-on-rename.ts index 2fed78dd7f77e..e7d4b5a95044e 100644 --- a/scripts/check-redirects-on-rename.ts +++ b/scripts/check-redirects-on-rename.ts @@ -300,20 +300,20 @@ function convertRedirectPatternToRegex(pattern: string): string { // 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 - + .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 -> [^/]+ - + .replace(new RegExp(escapeRegexSpecialChars(placeholderPathStar), 'g'), '.*') // placeholder -> .* + .replace(new RegExp(escapeRegexSpecialChars(placeholderParam), 'g'), '[^/]+'); // placeholder -> [^/]+ + return result; } From 3420c5e257ef481acf1eb8168d39fe40db615a8d Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Tue, 4 Nov 2025 11:22:01 -0500 Subject: [PATCH 10/13] fixing order check --- scripts/check-redirects-on-rename.spec.ts | 87 ++++++++++++++++++ scripts/check-redirects-on-rename.ts | 103 +++++++++++++++++++--- 2 files changed, 179 insertions(+), 11 deletions(-) diff --git a/scripts/check-redirects-on-rename.spec.ts b/scripts/check-redirects-on-rename.spec.ts index cf2a2df8b9468..c805af74a6765 100644 --- a/scripts/check-redirects-on-rename.spec.ts +++ b/scripts/check-redirects-on-rename.spec.ts @@ -145,6 +145,93 @@ const userDocsRedirects = [ expect(result.userDocsRedirects[0].source).toBe('/platforms/old/path/'); expect(result.userDocsRedirects[0].destination).toBe('/platforms/new/path/'); }); + + it('should handle property order flexibility (destination before source)', () => { + // Test that the parser works even when destination comes before source + const redirectsWithReversedOrder = ` +const developerDocsRedirects = [ + { + destination: '/sdk/new-path/', + source: '/sdk/old-path/', + }, +]; + +const userDocsRedirects = [ + { + destination: '/platforms/javascript/new-guide/', + source: '/platforms/javascript/old-guide/', + }, +]; +`; + fs.writeFileSync(tempFile, redirectsWithReversedOrder); + 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(1); + expect(result.userDocsRedirects[0].source).toBe('/platforms/javascript/old-guide/'); + expect(result.userDocsRedirects[0].destination).toBe('/platforms/javascript/new-guide/'); + }); + + it('should handle escaped quotes in URLs', () => { + // Test that URLs containing escaped quotes are parsed correctly + // In JavaScript strings, \" represents a literal quote character + // Note: In template literals (backticks), \" is NOT escaped - it's just \ + " + // So we write it as \\" in the template to get \" in the actual string + const redirectsWithEscapedQuotes = ` +const developerDocsRedirects = [ + { + source: '/sdk/path/with\\"quotes/', + destination: '/sdk/new-path/', + }, +]; + +const userDocsRedirects = [ + { + source: '/platforms/javascript/guide\\"test/', + destination: '/platforms/javascript/new-guide/', + }, +]; +`; + fs.writeFileSync(tempFile, redirectsWithEscapedQuotes); + const result = parseRedirectsJs(tempFile); + + expect(result.developerDocsRedirects).toHaveLength(1); + // The string contains \" (backslash + quote), which is preserved as-is + expect(result.developerDocsRedirects[0].source).toBe('/sdk/path/with\\"quotes/'); + expect(result.developerDocsRedirects[0].destination).toBe('/sdk/new-path/'); + + expect(result.userDocsRedirects).toHaveLength(1); + expect(result.userDocsRedirects[0].source).toBe('/platforms/javascript/guide\\"test/'); + expect(result.userDocsRedirects[0].destination).toBe('/platforms/javascript/new-guide/'); + }); + + it('should handle escaped backslashes before quotes', () => { + // Test that \\" (escaped backslash + quote) in double-quoted strings is handled correctly + // In a double-quoted JavaScript string, \\" means: + // \\ escapes to a single literal backslash + // \" escapes to a literal quote + // So the string value contains \" (backslash + quote) + const redirectsWithEscapedBackslashQuote = ` +const developerDocsRedirects = [ + { + source: "/sdk/path/with\\"quotes/", + destination: '/sdk/new-path/', + }, +]; +`; + fs.writeFileSync(tempFile, redirectsWithEscapedBackslashQuote); + const result = parseRedirectsJs(tempFile); + + expect(result.developerDocsRedirects).toHaveLength(1); + // The string contains \" which should be parsed correctly + // When we read the file, \" is two characters: \ and " + // Our parser should handle this and include both in the value + expect(result.developerDocsRedirects[0].source).toBe('/sdk/path/with\\"quotes/'); + expect(result.developerDocsRedirects[0].destination).toBe('/sdk/new-path/'); + }); }); describe('redirectMatches', () => { diff --git a/scripts/check-redirects-on-rename.ts b/scripts/check-redirects-on-rename.ts index e7d4b5a95044e..6f8b2142b5ff8 100644 --- a/scripts/check-redirects-on-rename.ts +++ b/scripts/check-redirects-on-rename.ts @@ -251,24 +251,105 @@ function parseRedirectsJs(filePath: string): { return {developerDocsRedirects, userDocsRedirects}; } +/** + * Extracts a string value from a JavaScript string literal, handling escaped quotes + * Supports both single and double quotes + */ +function extractStringValue( + content: string, + startIndex: number +): {endIndex: number; value: string} | null { + const quoteChar = content[startIndex]; + if (quoteChar !== '"' && quoteChar !== "'") { + return null; + } + + let value = ''; + let i = startIndex + 1; // Start after the opening quote + + while (i < content.length) { + const char = content[i]; + + if (char === '\\') { + // Handle escaped characters + if (i + 1 < content.length) { + const nextChar = content[i + 1]; + + // Special case: \\" should be parsed as \" (escaped quote) + // In JavaScript: \\ escapes the backslash, \" escapes the quote + // So \\" becomes \" (backslash + quote) in the string value + if (nextChar === '\\' && i + 2 < content.length && content[i + 2] === quoteChar) { + // This is \\" which should be parsed as \" (escaped quote) + value += '\\' + quoteChar; + i += 3; + continue; + } + + // Handle escaped quote, backslash, and other escape sequences + if (nextChar === quoteChar || nextChar === '\\') { + value += char + nextChar; + i += 2; + continue; + } + // Handle other escape sequences like \n, \t, etc. + value += char + nextChar; + i += 2; + continue; + } + // Backslash at end of string - treat as literal + value += char; + i++; + } else if (char === quoteChar) { + // Found closing quote (not escaped) + return {endIndex: i, value}; + } else { + value += char; + i++; + } + } + + // No closing quote found + return null; +} + /** * Extracts redirect objects from JavaScript array string - * Handles both single and double quotes, and whitespace variations + * Handles both single and double quotes, escaped quotes, and flexible property order */ function extractRedirectsFromArray(arrayContent: string): Redirect[] { const redirects: Redirect[] = []; - // Match redirect objects with more flexible whitespace handling - // Handles both ' and " quotes, and various whitespace patterns including multiline - // The pattern needs to match objects that span multiple lines - const redirectRegex = - /\{[\s\S]*?source:\s*['"]([^'"]+)['"][\s\S]*?destination:\s*['"]([^'"]+)['"][\s\S]*?\}/g; + // Match redirect objects - handle both source-first and destination-first orders + // Look for opening brace, then find source and destination properties in any order + const objectRegex = /\{[\s\S]*?\}/g; + + let objectMatch: RegExpExecArray | null = objectRegex.exec(arrayContent); + while (objectMatch !== null) { + const objectContent = objectMatch[0]; + let source: string | null = null; + let destination: string | null = null; + + // Find source property + const sourceMatch = objectContent.match(/source\s*:\s*(['"])/); + if (sourceMatch && sourceMatch.index !== undefined) { + const stringStart = sourceMatch.index + sourceMatch[0].length - 1; // Position of quote char + const result = extractStringValue(objectContent, stringStart); + if (result) { + source = result.value; + } + } - let match: RegExpExecArray | null = redirectRegex.exec(arrayContent); - while (match !== null) { - const source = match[1]; - const destination = match[2]; + // Find destination property + const destMatch = objectContent.match(/destination\s*:\s*(['"])/); + if (destMatch && destMatch.index !== undefined) { + const stringStart = destMatch.index + destMatch[0].length - 1; // Position of quote char + const result = extractStringValue(objectContent, stringStart); + if (result) { + destination = result.value; + } + } + // If both properties found, add to redirects if (source && destination) { redirects.push({ source, @@ -276,7 +357,7 @@ function extractRedirectsFromArray(arrayContent: string): Redirect[] { }); } - match = redirectRegex.exec(arrayContent); + objectMatch = objectRegex.exec(arrayContent); } return redirects; From 655394bc222332b7a35b600c1ce50fd0a66a8ffb Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 16:22:55 +0000 Subject: [PATCH 11/13] [getsentry/action-github-commit] Auto commit --- scripts/check-redirects-on-rename.spec.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/scripts/check-redirects-on-rename.spec.ts b/scripts/check-redirects-on-rename.spec.ts index c805af74a6765..9b05c204584d1 100644 --- a/scripts/check-redirects-on-rename.spec.ts +++ b/scripts/check-redirects-on-rename.spec.ts @@ -172,7 +172,9 @@ const userDocsRedirects = [ expect(result.userDocsRedirects).toHaveLength(1); expect(result.userDocsRedirects[0].source).toBe('/platforms/javascript/old-guide/'); - expect(result.userDocsRedirects[0].destination).toBe('/platforms/javascript/new-guide/'); + expect(result.userDocsRedirects[0].destination).toBe( + '/platforms/javascript/new-guide/' + ); }); it('should handle escaped quotes in URLs', () => { @@ -204,8 +206,12 @@ const userDocsRedirects = [ expect(result.developerDocsRedirects[0].destination).toBe('/sdk/new-path/'); expect(result.userDocsRedirects).toHaveLength(1); - expect(result.userDocsRedirects[0].source).toBe('/platforms/javascript/guide\\"test/'); - expect(result.userDocsRedirects[0].destination).toBe('/platforms/javascript/new-guide/'); + expect(result.userDocsRedirects[0].source).toBe( + '/platforms/javascript/guide\\"test/' + ); + expect(result.userDocsRedirects[0].destination).toBe( + '/platforms/javascript/new-guide/' + ); }); it('should handle escaped backslashes before quotes', () => { From 860464f27b1e96bfa6e5b5427d05bc7c0ba70838 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Tue, 4 Nov 2025 11:39:12 -0500 Subject: [PATCH 12/13] fix Misparsed Escaped Quotes --- scripts/check-redirects-on-rename.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/scripts/check-redirects-on-rename.ts b/scripts/check-redirects-on-rename.ts index 6f8b2142b5ff8..455d1670fc79c 100644 --- a/scripts/check-redirects-on-rename.ts +++ b/scripts/check-redirects-on-rename.ts @@ -275,16 +275,6 @@ function extractStringValue( if (i + 1 < content.length) { const nextChar = content[i + 1]; - // Special case: \\" should be parsed as \" (escaped quote) - // In JavaScript: \\ escapes the backslash, \" escapes the quote - // So \\" becomes \" (backslash + quote) in the string value - if (nextChar === '\\' && i + 2 < content.length && content[i + 2] === quoteChar) { - // This is \\" which should be parsed as \" (escaped quote) - value += '\\' + quoteChar; - i += 3; - continue; - } - // Handle escaped quote, backslash, and other escape sequences if (nextChar === quoteChar || nextChar === '\\') { value += char + nextChar; From ea5e239a62c7d81017c29dde902381151e4016e7 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Tue, 4 Nov 2025 15:41:44 -0500 Subject: [PATCH 13/13] refactor: simplify redirect parser by using require() instead of manual parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced ~200 lines of fragile manual JavaScript parsing with a simple require() call. Removed parser-specific tests and unnecessary fetch-depth from workflow. - Replace manual bracket/quote tracking with require() - Export redirect arrays from redirects.js - Remove 140+ lines of parser edge case tests - Remove unnecessary fetch-depth: 0 from GitHub Actions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../workflows/check-redirects-on-rename.yml | 2 - redirects.js | 2 +- scripts/check-redirects-on-rename.spec.ts | 148 +----------- scripts/check-redirects-on-rename.ts | 227 ++---------------- 4 files changed, 21 insertions(+), 358 deletions(-) diff --git a/.github/workflows/check-redirects-on-rename.yml b/.github/workflows/check-redirects-on-rename.yml index 92671ed7f8764..3845384a89b23 100644 --- a/.github/workflows/check-redirects-on-rename.yml +++ b/.github/workflows/check-redirects-on-rename.yml @@ -16,8 +16,6 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 - with: - fetch-depth: 0 # Need full history for git diff - name: Install bun uses: oven-sh/setup-bun@v2 diff --git a/redirects.js b/redirects.js index a8a7135a1ca0e..55a924449b64c 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 index 9b05c204584d1..2a6ae51962b9d 100644 --- a/scripts/check-redirects-on-rename.spec.ts +++ b/scripts/check-redirects-on-rename.spec.ts @@ -30,6 +30,8 @@ const userDocsRedirects = [ destination: '/platforms/python/new-tutorial', }, ]; + +module.exports = {developerDocsRedirects, userDocsRedirects}; `; describe('filePathToUrls', () => { @@ -69,19 +71,16 @@ describe('parseRedirectsJs', () => { if (fs.existsSync(tempFile)) { fs.unlinkSync(tempFile); } + // Clear require cache + delete require.cache[path.resolve(tempFile)]; }); - it('should parse developer docs redirects', () => { + 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/'); - }); - - it('should parse user docs redirects', () => { - fs.writeFileSync(tempFile, mockRedirectsJs); - const result = parseRedirectsJs(tempFile); expect(result.userDocsRedirects).toHaveLength(2); expect(result.userDocsRedirects[0].source).toBe('/platforms/javascript/old-guide/'); expect(result.userDocsRedirects[0].destination).toBe( @@ -101,143 +100,6 @@ describe('parseRedirectsJs', () => { expect(result.developerDocsRedirects.length).toBeGreaterThan(0); expect(result.userDocsRedirects.length).toBeGreaterThan(0); }); - - it('should correctly handle escaped backslashes in strings', () => { - // Test case that verifies the fix for escaped backslash handling - // The bug: prevChar !== '\\' incorrectly treats "text\\" as escaped quote - // The fix: isEscapedQuote counts consecutive backslashes (odd = escaped, even = not escaped) - // Key test: "text\\\\" should correctly end the string (2 backslashes = even = not escaped) - // In template literals, we need \\\\ to get \\ in the file, and \\" to get \" in the file - const redirectsWithEscapedBackslashes = ` -const developerDocsRedirects = [ - { - source: '/simple/path/', - destination: '/simple/new/path/', - }, - { - source: '/path/with\\\\"quotes/', - destination: '/new/path/', - }, -]; - -const userDocsRedirects = [ - { - source: '/platforms/old/path/', - destination: '/platforms/new/path/', - }, -]; -`; - fs.writeFileSync(tempFile, redirectsWithEscapedBackslashes); - const result = parseRedirectsJs(tempFile); - - // The parser should correctly identify string boundaries even with \\" sequences - // The key is that \\" (2 backslashes + quote) should end the string, not be treated as escaped - // This ensures bracket counting works correctly and the array is parsed correctly - expect(result.developerDocsRedirects).toHaveLength(2); - expect(result.developerDocsRedirects[0].source).toBe('/simple/path/'); - expect(result.developerDocsRedirects[0].destination).toBe('/simple/new/path/'); - // The second redirect contains \\" which should correctly end the string - // The regex extraction may not capture the full value, but bracket counting should work - expect(result.developerDocsRedirects[1].source).toBeDefined(); - expect(result.developerDocsRedirects[1].destination).toBe('/new/path/'); - - expect(result.userDocsRedirects).toHaveLength(1); - expect(result.userDocsRedirects[0].source).toBe('/platforms/old/path/'); - expect(result.userDocsRedirects[0].destination).toBe('/platforms/new/path/'); - }); - - it('should handle property order flexibility (destination before source)', () => { - // Test that the parser works even when destination comes before source - const redirectsWithReversedOrder = ` -const developerDocsRedirects = [ - { - destination: '/sdk/new-path/', - source: '/sdk/old-path/', - }, -]; - -const userDocsRedirects = [ - { - destination: '/platforms/javascript/new-guide/', - source: '/platforms/javascript/old-guide/', - }, -]; -`; - fs.writeFileSync(tempFile, redirectsWithReversedOrder); - 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(1); - expect(result.userDocsRedirects[0].source).toBe('/platforms/javascript/old-guide/'); - expect(result.userDocsRedirects[0].destination).toBe( - '/platforms/javascript/new-guide/' - ); - }); - - it('should handle escaped quotes in URLs', () => { - // Test that URLs containing escaped quotes are parsed correctly - // In JavaScript strings, \" represents a literal quote character - // Note: In template literals (backticks), \" is NOT escaped - it's just \ + " - // So we write it as \\" in the template to get \" in the actual string - const redirectsWithEscapedQuotes = ` -const developerDocsRedirects = [ - { - source: '/sdk/path/with\\"quotes/', - destination: '/sdk/new-path/', - }, -]; - -const userDocsRedirects = [ - { - source: '/platforms/javascript/guide\\"test/', - destination: '/platforms/javascript/new-guide/', - }, -]; -`; - fs.writeFileSync(tempFile, redirectsWithEscapedQuotes); - const result = parseRedirectsJs(tempFile); - - expect(result.developerDocsRedirects).toHaveLength(1); - // The string contains \" (backslash + quote), which is preserved as-is - expect(result.developerDocsRedirects[0].source).toBe('/sdk/path/with\\"quotes/'); - expect(result.developerDocsRedirects[0].destination).toBe('/sdk/new-path/'); - - expect(result.userDocsRedirects).toHaveLength(1); - expect(result.userDocsRedirects[0].source).toBe( - '/platforms/javascript/guide\\"test/' - ); - expect(result.userDocsRedirects[0].destination).toBe( - '/platforms/javascript/new-guide/' - ); - }); - - it('should handle escaped backslashes before quotes', () => { - // Test that \\" (escaped backslash + quote) in double-quoted strings is handled correctly - // In a double-quoted JavaScript string, \\" means: - // \\ escapes to a single literal backslash - // \" escapes to a literal quote - // So the string value contains \" (backslash + quote) - const redirectsWithEscapedBackslashQuote = ` -const developerDocsRedirects = [ - { - source: "/sdk/path/with\\"quotes/", - destination: '/sdk/new-path/', - }, -]; -`; - fs.writeFileSync(tempFile, redirectsWithEscapedBackslashQuote); - const result = parseRedirectsJs(tempFile); - - expect(result.developerDocsRedirects).toHaveLength(1); - // The string contains \" which should be parsed correctly - // When we read the file, \" is two characters: \ and " - // Our parser should handle this and include both in the value - expect(result.developerDocsRedirects[0].source).toBe('/sdk/path/with\\"quotes/'); - expect(result.developerDocsRedirects[0].destination).toBe('/sdk/new-path/'); - }); }); describe('redirectMatches', () => { diff --git a/scripts/check-redirects-on-rename.ts b/scripts/check-redirects-on-rename.ts index 455d1670fc79c..5c959ee9a020c 100644 --- a/scripts/check-redirects-on-rename.ts +++ b/scripts/check-redirects-on-rename.ts @@ -128,33 +128,7 @@ function detectRenamedFiles(): RenamedFile[] { } /** - * Checks if a quote at the given index is escaped by counting consecutive backslashes. - * A quote is escaped (part of the string) if there's an odd number of backslashes before it. - * A quote is not escaped (ends the string) if there's an even number (including zero) of backslashes before it. - * - * Examples: - * - "text\" - 1 backslash (odd) → escaped - * - "text\\" - 2 backslashes (even) → not escaped - * - "text\\\" - 3 backslashes (odd) → escaped - */ -function isEscapedQuote(content: string, index: number): boolean { - if (index === 0) return false; - - // Count consecutive backslashes before this position - let backslashCount = 0; - let pos = index - 1; - while (pos >= 0 && content[pos] === '\\') { - backslashCount++; - pos--; - } - - // Quote is escaped if there's an odd number of backslashes - return backslashCount % 2 === 1; -} - -/** - * Parses redirects.js to extract redirect entries - * This uses regex-based parsing since redirects.js is a JavaScript file + * Parses redirects.js to extract redirect entries by directly requiring the file */ function parseRedirectsJs(filePath: string): { developerDocsRedirects: Redirect[]; @@ -165,192 +139,21 @@ function parseRedirectsJs(filePath: string): { return {developerDocsRedirects: [], userDocsRedirects: []}; } - const content = fs.readFileSync(filePath, 'utf8'); - - const developerDocsRedirects: Redirect[] = []; - const userDocsRedirects: Redirect[] = []; - - // Extract developerDocsRedirects array - // Find the start of the array (look for the const declaration, not JSDoc comments) - const devDocsMatch = content.match(/const developerDocsRedirects\s*=/); - if (devDocsMatch && devDocsMatch.index !== undefined) { - // Find the opening bracket after the assignment - const arrayStart = content.indexOf('[', devDocsMatch.index); - if (arrayStart !== -1) { - // Find the matching closing bracket by counting braces - let depth = 0; - let inString = false; - let stringChar = ''; - let i = arrayStart; - - while (i < content.length) { - const char = content[i]; - - // Handle string literals - if (!inString && (char === '"' || char === "'") && !isEscapedQuote(content, i)) { - inString = true; - stringChar = char; - } else if (inString && char === stringChar && !isEscapedQuote(content, i)) { - inString = false; - } - - // Count brackets only when not in string - if (!inString) { - if (char === '[') depth++; - if (char === ']') { - depth--; - if (depth === 0) { - // Found the closing bracket - const arrayContent = content.slice(arrayStart + 1, i); - developerDocsRedirects.push(...extractRedirectsFromArray(arrayContent)); - break; - } - } - } - i++; - } - } - } - - // Extract userDocsRedirects array - const userDocsMatch = content.match(/const userDocsRedirects\s*=/); - if (userDocsMatch && userDocsMatch.index !== undefined) { - const arrayStart = content.indexOf('[', userDocsMatch.index); - if (arrayStart !== -1) { - let depth = 0; - let inString = false; - let stringChar = ''; - let i = arrayStart; - - while (i < content.length) { - const char = content[i]; - - if (!inString && (char === '"' || char === "'") && !isEscapedQuote(content, i)) { - inString = true; - stringChar = char; - } else if (inString && char === stringChar && !isEscapedQuote(content, i)) { - inString = false; - } - - if (!inString) { - if (char === '[') depth++; - if (char === ']') { - depth--; - if (depth === 0) { - const arrayContent = content.slice(arrayStart + 1, i); - userDocsRedirects.push(...extractRedirectsFromArray(arrayContent)); - break; - } - } - } - i++; - } - } - } - - return {developerDocsRedirects, userDocsRedirects}; -} - -/** - * Extracts a string value from a JavaScript string literal, handling escaped quotes - * Supports both single and double quotes - */ -function extractStringValue( - content: string, - startIndex: number -): {endIndex: number; value: string} | null { - const quoteChar = content[startIndex]; - if (quoteChar !== '"' && quoteChar !== "'") { - return null; - } - - let value = ''; - let i = startIndex + 1; // Start after the opening quote - - while (i < content.length) { - const char = content[i]; - - if (char === '\\') { - // Handle escaped characters - if (i + 1 < content.length) { - const nextChar = content[i + 1]; - - // Handle escaped quote, backslash, and other escape sequences - if (nextChar === quoteChar || nextChar === '\\') { - value += char + nextChar; - i += 2; - continue; - } - // Handle other escape sequences like \n, \t, etc. - value += char + nextChar; - i += 2; - continue; - } - // Backslash at end of string - treat as literal - value += char; - i++; - } else if (char === quoteChar) { - // Found closing quote (not escaped) - return {endIndex: i, value}; - } else { - value += char; - i++; - } - } - - // No closing quote found - return null; -} - -/** - * Extracts redirect objects from JavaScript array string - * Handles both single and double quotes, escaped quotes, and flexible property order - */ -function extractRedirectsFromArray(arrayContent: string): Redirect[] { - const redirects: Redirect[] = []; - - // Match redirect objects - handle both source-first and destination-first orders - // Look for opening brace, then find source and destination properties in any order - const objectRegex = /\{[\s\S]*?\}/g; - - let objectMatch: RegExpExecArray | null = objectRegex.exec(arrayContent); - while (objectMatch !== null) { - const objectContent = objectMatch[0]; - let source: string | null = null; - let destination: string | null = null; - - // Find source property - const sourceMatch = objectContent.match(/source\s*:\s*(['"])/); - if (sourceMatch && sourceMatch.index !== undefined) { - const stringStart = sourceMatch.index + sourceMatch[0].length - 1; // Position of quote char - const result = extractStringValue(objectContent, stringStart); - if (result) { - source = result.value; - } - } - - // Find destination property - const destMatch = objectContent.match(/destination\s*:\s*(['"])/); - if (destMatch && destMatch.index !== undefined) { - const stringStart = destMatch.index + destMatch[0].length - 1; // Position of quote char - const result = extractStringValue(objectContent, stringStart); - if (result) { - destination = result.value; - } - } - - // If both properties found, add to redirects - if (source && destination) { - redirects.push({ - source, - destination, - }); - } - - objectMatch = objectRegex.exec(arrayContent); + 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: []}; } - - return redirects; } /**