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
17 changes: 17 additions & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,23 @@ node scripts/lint-mdx.js all
node scripts/lint-mdx.js all || exit 1
```

## Redirect destination auditor

`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.

```bash
# Report broken internal redirect destinations without failing
node scripts/audit-redirects.js

# Exit with code 1 when broken destinations are found
node scripts/audit-redirects.js --strict

# Run the focused unit tests
node --test scripts/audit-redirects.test.js
```

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.

## Docs index generators

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).
Expand Down
188 changes: 188 additions & 0 deletions scripts/audit-redirects.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
#!/usr/bin/env node

const fs = require('fs');
const path = require('path');
const { loadMintIgnore } = require('./lib/docs-utils');

function isExternalDestination(value) {
return (
typeof value === 'string' &&
(value.startsWith('//') || /^[A-Za-z][A-Za-z\d+.-]*:/.test(value))
);
}

function normalizeInternalPath(value) {
if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')) return null;

const clean = value.split(/[?#]/, 1)[0].replace(/\/+$/, '');
return clean || '/';
}

function matchesRoutePattern(value, routes) {
const internal = normalizeInternalPath(value);
if (!internal) return false;

const match = internal.match(/^(.*)\/:([A-Za-z][A-Za-z\d_]*)\*$/);
if (!match) return false;

const prefix = match[1] || '/';
return routes.has(prefix) || [...routes].some((route) => route.startsWith(`${prefix}/`));
}

function isMintIgnored(docsDir, fullPath, ignored) {
const relative = path.relative(docsDir, fullPath).split(path.sep).join('/');
const withoutExtension = relative.replace(/\.mdx?$/, '');
const basenameWithoutExtension = path.posix.basename(withoutExtension);

if (ignored.files.has(relative) || ignored.files.has(withoutExtension)) return true;
if (ignored.bareFiles.has(withoutExtension) || ignored.bareFiles.has(basenameWithoutExtension)) {
return true;
}

for (const ignoredDir of ignored.dirs) {
if (relative === ignoredDir || relative.startsWith(`${ignoredDir}/`)) return true;
}

return false;
}

function collectRoutes(docsDir) {
const routes = new Set(['/']);
const ignored = loadMintIgnore(path.join(docsDir, '.mintignore'));

function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);

if (isMintIgnored(docsDir, fullPath, ignored)) continue;

if (entry.isDirectory()) {
walk(fullPath);
continue;
}

const extension = path.extname(entry.name).toLowerCase();
if (!entry.isFile() || !['.md', '.mdx'].includes(extension)) continue;

let route = path
.relative(docsDir, fullPath)
.split(path.sep)
.join('/')
.replace(/\.mdx?$/, '');

if (route === 'index') route = '';
if (route.endsWith('/index')) route = route.slice(0, -'/index'.length);

routes.add(`/${route}`.replace(/\/+$/, '') || '/');
}
}

walk(docsDir);
return routes;
}

function auditRedirects(config, routes) {
const redirects = Array.isArray(config.redirects) ? config.redirects : [];
const redirectMap = new Map();

for (const redirect of redirects) {
const source = normalizeInternalPath(redirect.source);
if (source && typeof redirect.destination === 'string') {
redirectMap.set(source, redirect.destination);
}
}

function resolve(destination) {
let current = destination;
const visited = new Set();

while (true) {
if (isExternalDestination(current)) {
return { ok: true, terminal: current, reason: 'external' };
}

const internal = normalizeInternalPath(current);
if (!internal) return { ok: false, terminal: current, reason: 'invalid' };
if (routes.has(internal)) return { ok: true, terminal: internal, reason: 'page' };
if (matchesRoutePattern(internal, routes)) {
return { ok: true, terminal: internal, reason: 'pattern' };
}
if (visited.has(internal)) return { ok: false, terminal: internal, reason: 'cycle' };

visited.add(internal);
const next = redirectMap.get(internal);
if (!next) return { ok: false, terminal: internal, reason: 'missing' };
current = next;
}
}

const brokenByDestination = new Map();

for (const redirect of redirects) {
if (typeof redirect.destination !== 'string') continue;
if (isExternalDestination(redirect.destination)) continue;

const destination = normalizeInternalPath(redirect.destination) || redirect.destination;
const result = resolve(redirect.destination);
if (result.ok) continue;

const existing = brokenByDestination.get(destination) || {
destination,
terminal: result.terminal,
reason: result.reason,
count: 0,
sources: [],
};

existing.count += 1;
if (typeof redirect.source === 'string') existing.sources.push(redirect.source);
brokenByDestination.set(destination, existing);
}

return [...brokenByDestination.values()].sort(
(a, b) => b.count - a.count || a.destination.localeCompare(b.destination),
);
}

function printReport(broken) {
if (broken.length === 0) {
console.log('All internal redirect destinations resolve to an existing docs page.');
return;
}

const totalEntries = broken.reduce((sum, item) => sum + item.count, 0);
console.log(
`Found ${broken.length} broken internal redirect destinations across ${totalEntries} redirect entries.`,
);
console.log('');
console.log('Count\tDestination\tTerminal\tReason');

for (const item of broken) {
console.log(`${item.count}\t${item.destination}\t${item.terminal}\t${item.reason}`);
}
}

function main() {
const repoRoot = path.resolve(__dirname, '..');
const docsDir = path.join(repoRoot, 'docs');
const configPath = path.join(docsDir, 'docs.json');
const strict = process.argv.includes('--strict');

const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const routes = collectRoutes(docsDir);
const broken = auditRedirects(config, routes);

printReport(broken);

if (strict && broken.length > 0) process.exitCode = 1;
}

if (require.main === module) main();

module.exports = {
auditRedirects,
collectRoutes,
isExternalDestination,
matchesRoutePattern,
normalizeInternalPath,
};
Loading