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

const fs = require('fs');
const path = require('path');

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 hasHiddenFrontmatter(content) {
if (typeof content !== 'string' || !content.startsWith('---')) return false;

const closing = content.indexOf('\n---', 3);
if (closing === -1) return false;

const frontmatter = content.slice(3, closing);
return /^\s*hidden\s*:\s*true\s*$/im.test(frontmatter);
}

function collectRoutes(docsDir) {
const routes = new Set(['/']);

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

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

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

const content = fs.readFileSync(fullPath, 'utf8');
if (hasHiddenFrontmatter(content)) 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 (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,
hasHiddenFrontmatter,
isExternalDestination,
normalizeInternalPath,
};
139 changes: 139 additions & 0 deletions scripts/audit-redirects.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');

const {
auditRedirects,
collectRoutes,
hasHiddenFrontmatter,
isExternalDestination,
normalizeInternalPath,
} = require('./audit-redirects');

test('normalizes internal paths without query, hash, or trailing slash', () => {
assert.equal(normalizeInternalPath('/apps/quickstart/?foo=1#bar'), '/apps/quickstart');
assert.equal(normalizeInternalPath('/'), '/');
assert.equal(normalizeInternalPath('https://example.com/docs'), null);
assert.equal(normalizeInternalPath('//example.com/docs'), null);
});

test('recognizes only explicit external destination forms', () => {
assert.equal(isExternalDestination('https://example.com/docs'), true);
assert.equal(isExternalDestination('mailto:docs@example.com'), true);
assert.equal(isExternalDestination('//example.com/docs'), true);
assert.equal(isExternalDestination('apps/quickstart'), false);
});

test('detects hidden true only inside frontmatter', () => {
assert.equal(hasHiddenFrontmatter('---\nhidden: true\ntitle: Hidden\n---\nBody\n'), true);
assert.equal(hasHiddenFrontmatter('---\nhidden: false\n---\nBody\n'), false);
assert.equal(hasHiddenFrontmatter('# Body\n\nhidden: true\n'), false);
});

test('collects visible md and mdx page routes and collapses index files to directory routes', (t) => {
const docsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-redirects-'));
t.after(() => fs.rmSync(docsDir, { recursive: true, force: true }));

fs.writeFileSync(path.join(docsDir, 'index.mdx'), '# Home\n');
fs.writeFileSync(path.join(docsDir, 'guide.md'), '# Guide\n');
fs.mkdirSync(path.join(docsDir, 'apps'), { recursive: true });
fs.writeFileSync(path.join(docsDir, 'apps', 'index.md'), '# Apps\n');
fs.writeFileSync(path.join(docsDir, 'apps', 'quickstart.mdx'), '# Quickstart\n');
fs.writeFileSync(path.join(docsDir, 'apps', 'hidden.mdx'), '---\nhidden: true\n---\n# Hidden\n');
fs.writeFileSync(path.join(docsDir, 'ignored.txt'), 'Ignored\n');

assert.deepEqual(
[...collectRoutes(docsDir)].sort(),
['/', '/apps', '/apps/quickstart', '/guide'],
);
});

test('accepts destinations that resolve directly to a docs page', () => {
const config = {
redirects: [{ source: '/old', destination: '/new' }],
};

assert.deepEqual(auditRedirects(config, new Set(['/new'])), []);
});

test('follows redirect chains that terminate at a docs page', () => {
const config = {
redirects: [
{ source: '/old', destination: '/middle' },
{ source: '/middle', destination: '/new' },
],
};

assert.deepEqual(auditRedirects(config, new Set(['/new'])), []);
});

test('reports a missing terminal destination with usage count', () => {
const config = {
redirects: [
{ source: '/a', destination: '/legacy' },
{ source: '/b', destination: '/legacy' },
{ source: '/legacy', destination: '/missing' },
],
};

assert.deepEqual(auditRedirects(config, new Set(['/existing'])), [
{
destination: '/legacy',
terminal: '/missing',
reason: 'missing',
count: 2,
sources: ['/a', '/b'],
},
{
destination: '/missing',
terminal: '/missing',
reason: 'missing',
count: 1,
sources: ['/legacy'],
},
]);
});

test('reports malformed relative destinations instead of treating them as external', () => {
const config = {
redirects: [{ source: '/old', destination: 'apps/quickstart' }],
};

assert.deepEqual(auditRedirects(config, new Set(['/apps/quickstart'])), [
{
destination: 'apps/quickstart',
terminal: 'apps/quickstart',
reason: 'invalid',
count: 1,
sources: ['/old'],
},
]);
});

test('reports redirect cycles instead of looping forever', () => {
const config = {
redirects: [
{ source: '/a', destination: '/b' },
{ source: '/b', destination: '/a' },
],
};

const broken = auditRedirects(config, new Set(['/real-page']));

assert.equal(broken.length, 2);
assert.equal(broken[0].reason, 'cycle');
assert.equal(broken[1].reason, 'cycle');
});

test('ignores redirects whose destination is external', () => {
const config = {
redirects: [
{ source: '/https-external', destination: 'https://example.com/new' },
{ source: '/protocol-relative', destination: '//example.com/new' },
],
};

assert.deepEqual(auditRedirects(config, new Set(['/existing'])), []);
});