diff --git a/.github/workflows/check-redirects.yml b/.github/workflows/check-redirects.yml new file mode 100644 index 0000000000..c56e5aa12f --- /dev/null +++ b/.github/workflows/check-redirects.yml @@ -0,0 +1,39 @@ +name: Check Redirect Loops + +on: + pull_request: + paths: + - "static/_redirects" + push: + branches: + - main + paths: + - "static/_redirects" + +jobs: + check-redirects: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + + - name: Check for redirect loops + run: node scripts/check-redirect-loops.js + + - name: Comment on PR (if loops found) + if: failure() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '❌ **Redirect Loop Detected**\n\nThe `_redirects` file contains one or more redirect loops. Please check the workflow logs for details and fix the loops before merging.' + }) diff --git a/package.json b/package.json index 93af9e443b..18a309b58b 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", - "format": "prettier --write ." + "format": "prettier --write .", + "check-redirects": "node scripts/check-redirect-loops.js" }, "dependencies": { "@docsearch/react": "^4.0.1", diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000000..0e4993141a --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,63 @@ +# Scripts + +## check-redirect-loops.js + +Detects redirect loops in the `static/_redirects` file. + +### Usage + +**Local testing:** +```bash +npm run check-redirects +``` + +**Direct execution:** +```bash +node scripts/check-redirect-loops.js +``` + +### What it checks + +- ✅ Detects circular redirects (A → B → C → A) +- ✅ Detects self-redirects (A → A) +- ✅ Detects hash fragment loops (/path#hash → /docs/path → /path) +- ✅ Detects chains exceeding max depth (potential infinite loops) +- ✅ Ignores external redirects (http/https URLs) +- ✅ Handles splat patterns and hash fragments + +### Exit codes + +- `0` - No loops detected +- `1` - Loops detected or error + +### CI Integration + +This script runs automatically in GitHub Actions on: +- Pull requests that modify `static/_redirects` +- Pushes to `main` that modify `static/_redirects` + +See `.github/workflows/check-redirects.yml` for the workflow configuration. + +### Example output + +**No loops:** +``` +🔍 Checking for redirect loops... + +📋 Found 538 internal redirects to check + +✅ No redirect loops detected! +``` + +**Loops detected:** +``` +🔍 Checking for redirect loops... + +📋 Found 538 internal redirects to check + +❌ Found 1 redirect loop(s): + +Loop 1: + Chain: /getting-started → /docs/getting-started → /getting-started + +``` diff --git a/scripts/check-redirect-loops.js b/scripts/check-redirect-loops.js new file mode 100644 index 0000000000..ab194323d9 --- /dev/null +++ b/scripts/check-redirect-loops.js @@ -0,0 +1,151 @@ +#!/usr/bin/env node + +/** + * CI Script to detect redirect loops in _redirects file + * Usage: node scripts/check-redirect-loops.js + * Exit code: 0 if no loops found, 1 if loops detected + */ + +const fs = require('fs'); +const path = require('path'); + +const REDIRECTS_FILE = path.join(__dirname, '../static/_redirects'); +const MAX_REDIRECT_DEPTH = 10; + +function parseRedirects(content) { + const lines = content.split('\n'); + const redirects = new Map(); + + for (const line of lines) { + // Skip comments, empty lines, and the footer + if (line.trim().startsWith('#') || line.trim() === '' || line.includes('NO REDIRECTS BELOW')) { + continue; + } + + // Parse redirect line: source destination [status] + const parts = line.trim().split(/\s+/); + if (parts.length >= 2) { + const source = parts[0]; + const destination = parts[1]; + + // Skip external redirects (http/https) + if (destination.startsWith('http://') || destination.startsWith('https://')) { + continue; + } + + // Normalize paths by removing splat patterns + // Keep hash fragments in source to detect loops like /path#hash → /docs/path → /path + const normalizedSource = source.replace(/\*/g, ''); + const baseSource = normalizedSource.replace(/#.*$/, ''); // Base path without hash + const normalizedDest = destination.replace(/:splat$/, '').replace(/#.*$/, ''); + + // Store both the full source (with hash) and base source + redirects.set(normalizedSource, normalizedDest); + + // If source has a hash, also check if base path redirects create a loop + if (normalizedSource.includes('#')) { + // This allows us to detect: /path#hash → /docs/path → /path (loop!) + if (!redirects.has(baseSource)) { + redirects.set(baseSource, normalizedDest); + } + } + } + } + + return redirects; +} + +function findRedirectChain(source, redirects, visited = new Set()) { + const chain = [source]; + let current = source; + let depth = 0; + + while (depth < MAX_REDIRECT_DEPTH) { + if (visited.has(current)) { + // Loop detected! + const loopStart = chain.indexOf(current); + return { + isLoop: true, + chain: chain.slice(loopStart), + fullChain: chain + }; + } + + visited.add(current); + const next = redirects.get(current); + + if (!next) { + // End of chain, no loop + return { isLoop: false, chain }; + } + + chain.push(next); + current = next; + depth++; + } + + // Max depth reached - potential infinite loop + return { + isLoop: true, + chain, + fullChain: chain, + reason: 'max_depth_exceeded' + }; +} + +function checkForLoops() { + console.log('🔍 Checking for redirect loops...\n'); + + if (!fs.existsSync(REDIRECTS_FILE)) { + console.error(`❌ Error: ${REDIRECTS_FILE} not found`); + process.exit(1); + } + + const content = fs.readFileSync(REDIRECTS_FILE, 'utf-8'); + const redirects = parseRedirects(content); + + console.log(`📋 Found ${redirects.size} internal redirects to check\n`); + + const loops = []; + const checked = new Set(); + + for (const [source] of redirects) { + if (checked.has(source)) continue; + + const result = findRedirectChain(source, redirects); + + if (result.isLoop) { + loops.push({ + source, + ...result + }); + + // Mark all items in the loop as checked + result.fullChain.forEach(item => checked.add(item)); + } else { + // Mark all items in the chain as checked + result.chain.forEach(item => checked.add(item)); + } + } + + if (loops.length === 0) { + console.log('✅ No redirect loops detected!'); + process.exit(0); + } else { + console.error(`❌ Found ${loops.length} redirect loop(s):\n`); + + loops.forEach((loop, index) => { + console.error(`Loop ${index + 1}:`); + console.error(` Chain: ${loop.chain.join(' → ')}`); + if (loop.reason === 'max_depth_exceeded') { + console.error(` Reason: Exceeded maximum redirect depth (${MAX_REDIRECT_DEPTH})`); + } + console.error(''); + }); + + process.exit(1); + } +} + +// Run the check +checkForLoops();