Skip to content
Clone
194c44c
65 lines 2.6 KB
#!/usr/bin/env bun
/**
* Navigation guard: outside the auth flow every route change must go through
* react-router (`Link` / `useNavigate`) so the SPA never pays a full document
* reload. Raw anchors, `href=` attributes and `window.location` writes are
* reported as errors. Auth pages are exempt (e.g. the `#terms` anchors).
*
* Usage: bun scripts/lint-nav.mjs [dir] (default: src)
*/
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const scanRoot = resolve(process.argv[2] ?? join(repoRoot, 'src'))
const AUTH_EXEMPT = [
'src/features/auth/',
'src/app/routes/deferred-routes',
]
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'livekit'])
const PATTERNS = [
['raw <a> anchor', /<a[\s>]/],
['href attribute', /\bhref=["'{]/],
['full-page navigation', /(?:window\.)?location\s*\.\s*(?:href\s*=|assign\s*\(|replace\s*\()/],
// A reload is a browser-level refresh: route state must be changed through
// react-router instead (see pages/workspace/model/repo-location).
['browser-level refresh', /(?:window\.|document\.)?location\s*\.\s*reload\s*\(/],
]
function* walk(dir) {
for (const entry of readdirSync(dir)) {
const path = join(dir, entry)
if (statSync(path).isDirectory()) {
if (!SKIP_DIRS.has(entry)) yield* walk(path)
} else if (/\.tsx?$/.test(entry) && !entry.endsWith('.d.ts')) {
yield path
}
}
}
const violations = []
for (const file of walk(scanRoot)) {
const rel = relative(repoRoot, file).replaceAll('\\', '/')
if (rel.startsWith('src/shared/api/') || AUTH_EXEMPT.some(prefix => rel.startsWith(prefix))) continue
readFileSync(file, 'utf8')
.split('\n')
.forEach((line, i) => {
// Fragment-only anchors (`href="#L12"`) never trigger a document reload,
// so they are allowed outside the auth flow as in-page permalinks.
const fragmentOnly = line.includes('href={`#') || /href=["'{]#/.test(line)
for (const [label, re] of PATTERNS) {
if (fragmentOnly && label !== 'full-page navigation' && label !== 'browser-level refresh') continue
if (re.test(line)) violations.push(`${rel}:${i + 1}: ${label} — ${line.trim()}`)
}
})
}
if (violations.length > 0) {
console.error('navigation guard: use <Link> / useNavigate() outside auth pages')
for (const v of violations) console.error(' ' + v)
process.exit(1)
}
console.log('navigation guard: OK — no href/location navigation outside auth pages')