You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
102 lines
3.6 KiB
102 lines
3.6 KiB
/**
|
|
* 路由审计脚本:交叉校验代码中引用的路由 vs app/ 下实际存在的路由文件。
|
|
* 用法:node scripts/check-routes.mjs
|
|
*/
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
const appDir = path.resolve('app');
|
|
const routeFiles = [];
|
|
function walk(dir, rel = '') {
|
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, e.name);
|
|
const relPath = rel ? rel + '/' + e.name : e.name;
|
|
if (e.isDirectory()) walk(full, relPath);
|
|
else if (/\.(tsx|ts|jsx|js)$/.test(e.name)) routeFiles.push(relPath);
|
|
}
|
|
}
|
|
walk(appDir);
|
|
|
|
function toRoute(file) {
|
|
let r = file.replace(/\.(tsx|ts|jsx|js)$/, '');
|
|
r = r.split('/').filter((s) => !/^\(.*\)$/.test(s)).join('/');
|
|
r = r.replace(/\/index$/, '');
|
|
return '/' + r.replace(/_layout$/, '').replace(/\/$/, '');
|
|
}
|
|
const actualRoutes = new Set(routeFiles.map(toRoute).filter(Boolean));
|
|
|
|
function normalize(t) {
|
|
const clean = t.split('?')[0].replace(/\/$/, '');
|
|
// 去掉路由组前缀:expo-router 允许写 "/(tabs)/jobs" 这类完整路径
|
|
return clean.split('/').filter((s) => s && !/^\(.*\)$/.test(s)).join('/');
|
|
}
|
|
|
|
function routeExists(target) {
|
|
const clean = target.split('?')[0].replace(/\/$/, '');
|
|
if (clean === '' || clean === 'index') return true;
|
|
if (actualRoutes.has(clean)) return true;
|
|
const n = normalize(clean);
|
|
for (const r of actualRoutes) {
|
|
const rn = normalize(r);
|
|
if (rn === n) return true;
|
|
const pattern =
|
|
'^' + rn.replace(/\[\[?\.\.\.(\w+)\]\]?/g, '.+').replace(/\[(\w+)\]/g, '[^/]+') + '$';
|
|
if (new RegExp(pattern).test(n)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const srcDirs = ['app', 'components', 'src'];
|
|
const files = [];
|
|
function walkSrc(dir) {
|
|
if (!fs.existsSync(dir)) return;
|
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, e.name);
|
|
if (e.isDirectory()) walkSrc(full);
|
|
else if (/\.(tsx|ts)$/.test(e.name)) files.push(full);
|
|
}
|
|
}
|
|
srcDirs.forEach(walkSrc);
|
|
|
|
const routesFound = new Map();
|
|
const reLocal = /['"`](\/[a-zA-Z0-9_()\-[\]/?=&.][a-zA-Z0-9_()\-[\]/?=&.]*)['"`]/g;
|
|
for (const f of files) {
|
|
const code = fs.readFileSync(f, 'utf8');
|
|
let m;
|
|
while ((m = reLocal.exec(code)) !== null) {
|
|
const r = m[1];
|
|
if (r.startsWith('//')) continue;
|
|
if (!routesFound.has(r)) routesFound.set(r, []);
|
|
routesFound.get(r).push(path.relative(process.cwd(), f).replace(/\\/g, '/'));
|
|
}
|
|
}
|
|
|
|
console.log('=== 实际存在的路由 (' + actualRoutes.size + ') ===');
|
|
console.log([...actualRoutes].sort().join('\n'));
|
|
console.log('\n=== 引用但不存在的路由 ===');
|
|
let bad = 0;
|
|
for (const [r, srcs] of [...routesFound.entries()].sort()) {
|
|
if (r.includes('${')) continue;
|
|
if (/\.(png|jpg|jpeg|svg|json|ttf|css)$/i.test(r)) continue;
|
|
if (r.startsWith('/node_modules')) continue;
|
|
if (!routeExists(r)) {
|
|
console.log(`${r} <-- ${[...new Set(srcs)].join(', ')}`);
|
|
bad++;
|
|
}
|
|
}
|
|
console.log('\n不存在的路由数量: ' + bad);
|
|
|
|
// 反向:孤立页面(没有任何入口指向)
|
|
console.log('\n=== 无入口指向的孤立页面 ===');
|
|
const referenced = new Set();
|
|
function markRef(r) {
|
|
referenced.add(r);
|
|
referenced.add('/' + normalize(r));
|
|
// 动态拼接的路径(如 '/chat/' + id)按前缀登记,前缀命中即视为已引用
|
|
const n = normalize(r);
|
|
const seg = n.split('/');
|
|
if (seg.length > 1) referenced.add('/' + seg.slice(0, seg.length - 1).join('/'));
|
|
}
|
|
for (const r of routesFound.keys()) markRef(r);
|
|
const orphans = [...actualRoutes].filter((r) => !referenced.has(r) && !referenced.has('/' + normalize(r)));
|
|
console.log(orphans.sort().join('\n') || '(无)');
|
|
|