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..391c97ea3 --- /dev/null +++ b/scripts/audit-redirects.js @@ -0,0 +1,192 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { CONSTANTS, 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 })) { + if (entry.name.startsWith('.')) continue; + if (CONSTANTS.skipFiles.includes(entry.name)) continue; + if (entry.isDirectory() && CONSTANTS.skipDirs.includes(entry.name)) continue; + + 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() || !CONSTANTS.extensions.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, +}; diff --git a/scripts/audit-redirects.test.js b/scripts/audit-redirects.test.js new file mode 100644 index 000000000..21e2378b8 --- /dev/null +++ b/scripts/audit-redirects.test.js @@ -0,0 +1,199 @@ +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, + isExternalDestination, + matchesRoutePattern, + 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('matches trailing Mintlify wildcard destinations against the published route subtree', () => { + const routes = new Set([ + '/base-chain/specs/reference/b20', + '/base-chain/specs/reference/b20/changelog', + '/base-chain/specs/reference/b20/errors-and-events', + ]); + + assert.equal(matchesRoutePattern('/base-chain/specs/reference/b20/:slug*', routes), true); + assert.equal(matchesRoutePattern('/base-chain/specs/reference/missing/:slug*', routes), false); + assert.equal(matchesRoutePattern('/base-chain/specs/reference/b20/:slug', routes), false); +}); + +test('collects published md and mdx routes, including hidden pages, and collapses index files', (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, '.mintignore'), 'ignored.mdx\n/apps/private.mdx\n/drafts/*\n'); + fs.writeFileSync(path.join(docsDir, 'index.mdx'), '# Home\n'); + fs.writeFileSync(path.join(docsDir, 'guide.md'), '# Guide\n'); + fs.writeFileSync(path.join(docsDir, 'README.md'), '# Repository docs\n'); + fs.writeFileSync(path.join(docsDir, 'AGENTS.md'), '# Agent instructions\n'); + fs.writeFileSync(path.join(docsDir, 'ignored.mdx'), '# Ignored\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, 'apps', 'private.mdx'), '# Private\n'); + fs.mkdirSync(path.join(docsDir, 'drafts'), { recursive: true }); + fs.writeFileSync(path.join(docsDir, 'drafts', 'wip.mdx'), '# WIP\n'); + fs.mkdirSync(path.join(docsDir, 'snippets'), { recursive: true }); + fs.writeFileSync(path.join(docsDir, 'snippets', 'shared.mdx'), '# Shared snippet\n'); + fs.mkdirSync(path.join(docsDir, '.internal'), { recursive: true }); + fs.writeFileSync(path.join(docsDir, '.internal', 'notes.mdx'), '# Internal\n'); + fs.writeFileSync(path.join(docsDir, 'ignored.txt'), 'Ignored\n'); + + assert.deepEqual( + [...collectRoutes(docsDir)].sort(), + ['/', '/apps', '/apps/hidden', '/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('accepts wildcard redirect destinations that target an existing docs subtree', () => { + const config = { + redirects: [ + { + source: '/legacy/:slug*', + destination: '/base-chain/specs/reference/b20/:slug*', + }, + ], + }; + + assert.deepEqual( + auditRedirects( + config, + new Set([ + '/base-chain/specs/reference/b20', + '/base-chain/specs/reference/b20/errors-and-events', + ]), + ), + [], + ); +}); + +test('reports wildcard redirect destinations whose target subtree does not exist', () => { + const config = { + redirects: [ + { + source: '/legacy/:slug*', + destination: '/base-chain/specs/reference/missing/:slug*', + }, + ], + }; + + assert.deepEqual(auditRedirects(config, new Set(['/base-chain/specs/reference/b20'])), [ + { + destination: '/base-chain/specs/reference/missing/:slug*', + terminal: '/base-chain/specs/reference/missing/:slug*', + reason: 'missing', + count: 1, + sources: ['/legacy/:slug*'], + }, + ]); +}); + +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'])), []); +});