diff --git a/scripts/README.md b/scripts/README.md index a7a9b9a01..04a1889f8 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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). diff --git a/scripts/audit-redirects.js b/scripts/audit-redirects.js new file mode 100644 index 000000000..81e401f5c --- /dev/null +++ b/scripts/audit-redirects.js @@ -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, +}; diff --git a/scripts/audit-redirects.test.js b/scripts/audit-redirects.test.js new file mode 100644 index 000000000..8ca6096ec --- /dev/null +++ b/scripts/audit-redirects.test.js @@ -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'])), []); +});