Skip to content

Commit 497a305

Browse files
committed
tooling: add redirect destination auditor
1 parent d1fae2e commit 497a305

4 files changed

Lines changed: 435 additions & 0 deletions

File tree

scripts/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,23 @@ node scripts/lint-mdx.js all
4747
node scripts/lint-mdx.js all || exit 1
4848
```
4949

50+
## Redirect destination auditor
51+
52+
`audit-redirects.js` checks every internal destination in `docs/docs.json` against the current MDX route tree. It follows redirect chains, detects cycles, and groups repeated broken destinations so large redirect migrations can be audited without guessing from individual entries.
53+
54+
```bash
55+
# Report broken internal redirect destinations without failing
56+
node scripts/audit-redirects.js
57+
58+
# Exit with code 1 when broken destinations are found
59+
node scripts/audit-redirects.js --strict
60+
61+
# Run the focused unit tests
62+
node --test scripts/audit-redirects.test.js
63+
```
64+
65+
External redirect destinations are treated as valid terminal targets. The default report-only mode is useful while known redirect debt is being repaired; `--strict` can be used once the tree is clean or in targeted validation workflows.
66+
5067
## Docs index generators
5168

5269
Two generators emit AI-facing site indexes from the `docs/` tree. Both share helpers in `lib/docs-utils.js` (frontmatter parser, `.mintignore` loader, file walker, section discovery).
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
const test = require('node:test');
2+
const assert = require('node:assert/strict');
3+
const fs = require('node:fs');
4+
const os = require('node:os');
5+
const path = require('node:path');
6+
7+
const { collectRoutes } = require('./audit-redirects');
8+
9+
test('excludes an entire nested .mintignore directory subtree from redirect routes', (t) => {
10+
const docsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-redirects-mintignore-'));
11+
t.after(() => fs.rmSync(docsDir, { recursive: true, force: true }));
12+
13+
fs.writeFileSync(path.join(docsDir, '.mintignore'), '/drafts/private/*\n');
14+
fs.writeFileSync(path.join(docsDir, 'index.mdx'), '# Home\n');
15+
16+
fs.mkdirSync(path.join(docsDir, 'drafts', 'private', 'nested'), { recursive: true });
17+
fs.writeFileSync(path.join(docsDir, 'drafts', 'private', 'page.mdx'), '# Private\n');
18+
fs.writeFileSync(path.join(docsDir, 'drafts', 'private', 'nested', 'page.mdx'), '# Nested private\n');
19+
20+
fs.mkdirSync(path.join(docsDir, 'drafts', 'public'), { recursive: true });
21+
fs.writeFileSync(path.join(docsDir, 'drafts', 'public', 'page.mdx'), '# Public\n');
22+
23+
assert.deepEqual(
24+
[...collectRoutes(docsDir)].sort(),
25+
['/', '/drafts/public/page'],
26+
);
27+
});

scripts/audit-redirects.js

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
#!/usr/bin/env node
2+
3+
const fs = require('fs');
4+
const path = require('path');
5+
const { CONSTANTS, loadMintIgnore } = require('./lib/docs-utils');
6+
7+
function isExternalDestination(value) {
8+
return (
9+
typeof value === 'string' &&
10+
(value.startsWith('//') || /^[A-Za-z][A-Za-z\d+.-]*:/.test(value))
11+
);
12+
}
13+
14+
function normalizeInternalPath(value) {
15+
if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')) return null;
16+
17+
const clean = value.split(/[?#]/, 1)[0].replace(/\/+$/, '');
18+
return clean || '/';
19+
}
20+
21+
function matchesRoutePattern(value, routes) {
22+
const internal = normalizeInternalPath(value);
23+
if (!internal) return false;
24+
25+
const match = internal.match(/^(.*)\/:([A-Za-z][A-Za-z\d_]*)\*$/);
26+
if (!match) return false;
27+
28+
const prefix = match[1] || '/';
29+
return routes.has(prefix) || [...routes].some((route) => route.startsWith(`${prefix}/`));
30+
}
31+
32+
function isMintIgnored(docsDir, fullPath, ignored) {
33+
const relative = path.relative(docsDir, fullPath).split(path.sep).join('/');
34+
const withoutExtension = relative.replace(/\.mdx?$/, '');
35+
const basenameWithoutExtension = path.posix.basename(withoutExtension);
36+
37+
if (ignored.files.has(relative) || ignored.files.has(withoutExtension)) return true;
38+
if (ignored.bareFiles.has(withoutExtension) || ignored.bareFiles.has(basenameWithoutExtension)) {
39+
return true;
40+
}
41+
42+
for (const ignoredDir of ignored.dirs) {
43+
if (relative === ignoredDir || relative.startsWith(`${ignoredDir}/`)) return true;
44+
}
45+
46+
return false;
47+
}
48+
49+
function collectRoutes(docsDir) {
50+
const routes = new Set(['/']);
51+
const ignored = loadMintIgnore(path.join(docsDir, '.mintignore'));
52+
53+
function walk(dir) {
54+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
55+
if (entry.name.startsWith('.')) continue;
56+
if (CONSTANTS.skipFiles.includes(entry.name)) continue;
57+
if (entry.isDirectory() && CONSTANTS.skipDirs.includes(entry.name)) continue;
58+
59+
const fullPath = path.join(dir, entry.name);
60+
61+
if (isMintIgnored(docsDir, fullPath, ignored)) continue;
62+
63+
if (entry.isDirectory()) {
64+
walk(fullPath);
65+
continue;
66+
}
67+
68+
const extension = path.extname(entry.name).toLowerCase();
69+
if (!entry.isFile() || !CONSTANTS.extensions.includes(extension)) continue;
70+
71+
let route = path
72+
.relative(docsDir, fullPath)
73+
.split(path.sep)
74+
.join('/')
75+
.replace(/\.mdx?$/, '');
76+
77+
if (route === 'index') route = '';
78+
if (route.endsWith('/index')) route = route.slice(0, -'/index'.length);
79+
80+
routes.add(`/${route}`.replace(/\/+$/, '') || '/');
81+
}
82+
}
83+
84+
walk(docsDir);
85+
return routes;
86+
}
87+
88+
function auditRedirects(config, routes) {
89+
const redirects = Array.isArray(config.redirects) ? config.redirects : [];
90+
const redirectMap = new Map();
91+
92+
for (const redirect of redirects) {
93+
const source = normalizeInternalPath(redirect.source);
94+
if (source && typeof redirect.destination === 'string') {
95+
redirectMap.set(source, redirect.destination);
96+
}
97+
}
98+
99+
function resolve(destination) {
100+
let current = destination;
101+
const visited = new Set();
102+
103+
while (true) {
104+
if (isExternalDestination(current)) {
105+
return { ok: true, terminal: current, reason: 'external' };
106+
}
107+
108+
const internal = normalizeInternalPath(current);
109+
if (!internal) return { ok: false, terminal: current, reason: 'invalid' };
110+
if (routes.has(internal)) return { ok: true, terminal: internal, reason: 'page' };
111+
if (matchesRoutePattern(internal, routes)) {
112+
return { ok: true, terminal: internal, reason: 'pattern' };
113+
}
114+
if (visited.has(internal)) return { ok: false, terminal: internal, reason: 'cycle' };
115+
116+
visited.add(internal);
117+
const next = redirectMap.get(internal);
118+
if (!next) return { ok: false, terminal: internal, reason: 'missing' };
119+
current = next;
120+
}
121+
}
122+
123+
const brokenByDestination = new Map();
124+
125+
for (const redirect of redirects) {
126+
if (typeof redirect.destination !== 'string') continue;
127+
if (isExternalDestination(redirect.destination)) continue;
128+
129+
const destination = normalizeInternalPath(redirect.destination) || redirect.destination;
130+
const result = resolve(redirect.destination);
131+
if (result.ok) continue;
132+
133+
const existing = brokenByDestination.get(destination) || {
134+
destination,
135+
terminal: result.terminal,
136+
reason: result.reason,
137+
count: 0,
138+
sources: [],
139+
};
140+
141+
existing.count += 1;
142+
if (typeof redirect.source === 'string') existing.sources.push(redirect.source);
143+
brokenByDestination.set(destination, existing);
144+
}
145+
146+
return [...brokenByDestination.values()].sort(
147+
(a, b) => b.count - a.count || a.destination.localeCompare(b.destination),
148+
);
149+
}
150+
151+
function printReport(broken) {
152+
if (broken.length === 0) {
153+
console.log('All internal redirect destinations resolve to an existing docs page.');
154+
return;
155+
}
156+
157+
const totalEntries = broken.reduce((sum, item) => sum + item.count, 0);
158+
console.log(
159+
`Found ${broken.length} broken internal redirect destinations across ${totalEntries} redirect entries.`,
160+
);
161+
console.log('');
162+
console.log('Count\tDestination\tTerminal\tReason');
163+
164+
for (const item of broken) {
165+
console.log(`${item.count}\t${item.destination}\t${item.terminal}\t${item.reason}`);
166+
}
167+
}
168+
169+
function main() {
170+
const repoRoot = path.resolve(__dirname, '..');
171+
const docsDir = path.join(repoRoot, 'docs');
172+
const configPath = path.join(docsDir, 'docs.json');
173+
const strict = process.argv.includes('--strict');
174+
175+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
176+
const routes = collectRoutes(docsDir);
177+
const broken = auditRedirects(config, routes);
178+
179+
printReport(broken);
180+
181+
if (strict && broken.length > 0) process.exitCode = 1;
182+
}
183+
184+
if (require.main === module) main();
185+
186+
module.exports = {
187+
auditRedirects,
188+
collectRoutes,
189+
isExternalDestination,
190+
matchesRoutePattern,
191+
normalizeInternalPath,
192+
};

0 commit comments

Comments
 (0)