Skip to content
Open
Show file tree
Hide file tree
Changes from all 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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix capitalization of GitHub.

As per coding guidelines.

Apply this diff:

-See `.github/workflows/check-redirects.yml` for the workflow configuration.
+See `.github/workflows/check-redirects.yml` for the workflow configuration.

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 LanguageTool

[uncategorized] ~39-~39: The official name of this software platform is spelled with a capital “H”.
Context: ...nthat modifystatic/_redirects See.github/workflows/check-redirects.yml` for the ...

(GITHUB)

🤖 Prompt for AI Agents
In scripts/README.md around line 39, the reference to GitHub should use the
proper capitalization; update the sentence to mention "GitHub" (capitalized)
when referring to the platform while keeping the repository path
`.github/workflows/check-redirects.yml` lowercase — e.g. reword to "See the
GitHub workflow configuration at `.github/workflows/check-redirects.yml`."


### 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')) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Consider more specific footer marker detection.

The condition line.includes('NO REDIRECTS BELOW') will match this text anywhere in the line. If a redirect path or comment contains this phrase, it will be incorrectly skipped.

Apply this diff to make the check more specific:

-    if (line.trim().startsWith('#') || line.trim() === '' || line.includes('NO REDIRECTS BELOW')) {
+    if (line.trim().startsWith('#') || line.trim() === '' || line.trim().includes('# NO REDIRECTS BELOW')) {

This ensures the marker is in a comment, not part of a redirect path.

🤖 Prompt for AI Agents
In scripts/check-redirect-loops.js around line 21, the current check uses
line.includes('NO REDIRECTS BELOW') which will match the phrase anywhere in the
line; change it to only detect the footer when it appears as a comment (e.g.,
after optional leading whitespace the line starts with a '#' followed by the
marker) so redirect paths or inline comments that contain the phrase are not
treated as the footer; implement this by trimming leading whitespace and testing
for a comment-start marker with the exact marker (or a regex like /^\s*#\s*NO
REDIRECTS BELOW\b/).

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