Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/check-redirects.yml
Original file line number Diff line number Diff line change
@@ -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.'
})
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
63 changes: 63 additions & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
@@ -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

```
151 changes: 151 additions & 0 deletions scripts/check-redirect-loops.js
Original file line number Diff line number Diff line change
@@ -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();
Loading