import { readFileSync, readdirSync, statSync, existsSync, realpathSync } from 'node:fs'; import { join, dirname, resolve, relative, extname } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = resolve(fileURLToPath(import.meta.url), '../..'); const SRC = join(ROOT, 'src'); const PUBLIC_DIR = join(SRC, 'public'); const CONFIG = join(SRC, '.vitepress/config.mts'); const HOME = join(SRC, 'index.md'); function walk(dir, out = [], skipDirs = new Set()) { if (!existsSync(dir)) return out; for (const name of readdirSync(dir)) { if (skipDirs.has(name)) continue; const p = join(dir, name); const s = statSync(p); if (s.isDirectory()) walk(p, out, skipDirs); else out.push(p); } return out; } const docFiles = walk(SRC, [], new Set(['node_modules', '.vitepress', 'public', 'assets'])); const mdFiles = docFiles.filter(f => f.endsWith('.md')); function checkTarget(target, fromFile) { const url = target.trim(); if (!url) return { ok: false, reason: 'empty' }; if (/^(https?:|mailto:|tel:|ftp:|data:|javascript:|#)/i.test(url)) return { ok: true, skip: true }; const [pathPart] = url.split('#'); const [pathOnly] = pathPart.split('?'); if (!pathOnly) return { ok: true, skip: true }; let abs; if (pathOnly.startsWith('/')) { abs = join(SRC, pathOnly); if (!existsSync(abs)) { const inPublic = join(PUBLIC_DIR, pathOnly); if (existsSync(inPublic)) return { ok: true }; } } else { abs = resolve(dirname(fromFile), pathOnly); } if (existsSync(abs)) return { ok: true }; if (extname(abs) === '') { if (existsSync(abs + '.md')) return { ok: true }; if (existsSync(join(abs, 'index.md'))) return { ok: true }; } if (extname(abs) === '.html') { if (existsSync(abs.replace(/\.html$/, '.md'))) return { ok: true }; } return { ok: false, reason: 'not found' }; } function resolveToMd(target, fromFile) { const url = target.trim(); if (!url) return null; if (/^(https?:|mailto:|tel:|ftp:|data:|javascript:|#)/i.test(url)) return null; const [pathPart] = url.split('#'); const [pathOnly] = pathPart.split('?'); if (!pathOnly) return null; const abs = pathOnly.startsWith('/') ? join(SRC, pathOnly) : resolve(dirname(fromFile), pathOnly); if (extname(abs) === '.md' && existsSync(abs)) return abs; if (extname(abs) === '') { if (existsSync(abs + '.md')) return abs + '.md'; if (existsSync(join(abs, 'index.md'))) return join(abs, 'index.md'); } if (extname(abs) === '.html') { const asMd = abs.replace(/\.html$/, '.md'); if (existsSync(asMd)) return asMd; } return null; } const broken = []; const usedMd = new Set(); const mdLinkRe = /(!?)\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; const htmlLinkRe = /<(?:img[^>]+src|a[^>]+href|source[^>]+src|video[^>]+src|link[^>]+href)\s*=\s*["']([^"']+)["']/gi; const frontmatterLinkRe = /^\s*-?\s*link:\s*['"]?([^\s'"#]+)['"]?\s*$/; const includeRe = //g; function recordUse(target, fromFile) { const md = resolveToMd(target, fromFile); if (md) usedMd.add(md); } for (const file of mdFiles) { const content = readFileSync(file, 'utf8'); const lines = content.split('\n'); let fmStart = -1, fmEnd = -1; if (lines[0]?.trim() === '---') { fmStart = 0; for (let i = 1; i < lines.length; i++) { if (lines[i].trim() === '---') { fmEnd = i; break; } } } for (let i = 0; i < lines.length; i++) { const line = lines[i]; const inFrontmatter = fmStart !== -1 && i > fmStart && i < fmEnd; if (inFrontmatter) { const m = line.match(frontmatterLinkRe); if (m) { const res = checkTarget(m[1], file); if (!res.ok && !res.skip) { broken.push({ file: relative(ROOT, file), line: i + 1, target: m[1], type: 'frontmatter' }); } else { recordUse(m[1], file); } } continue; } for (const m of line.matchAll(mdLinkRe)) { const res = checkTarget(m[3], file); if (!res.ok && !res.skip) { broken.push({ file: relative(ROOT, file), line: i + 1, target: m[3], type: m[1] === '!' ? 'image' : 'link' }); } else { recordUse(m[3], file); } } for (const m of line.matchAll(htmlLinkRe)) { const res = checkTarget(m[1], file); if (!res.ok && !res.skip) { broken.push({ file: relative(ROOT, file), line: i + 1, target: m[1], type: 'html' }); } else { recordUse(m[1], file); } } for (const m of line.matchAll(includeRe)) { recordUse(m[1], file); } } } const cfgLines = readFileSync(CONFIG, 'utf8').split('\n'); const sidebarLinkRe = /link:\s*['"]([^'"]+)['"]/g; for (let i = 0; i < cfgLines.length; i++) { const line = cfgLines[i]; if (/^\s*\/\//.test(line)) continue; for (const m of line.matchAll(sidebarLinkRe)) { const target = m[1]; if (!target) continue; const res = checkTarget(target, CONFIG); if (!res.ok && !res.skip) { broken.push({ file: relative(ROOT, CONFIG), line: i + 1, target, type: 'sidebar' }); } else { recordUse(target, CONFIG); } } } const orphans = mdFiles .filter(f => f !== HOME && !usedMd.has(f)) .map(f => relative(ROOT, f)) .sort(); broken.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line); let hasBroken = false; if (broken.length === 0) { console.log('OK: битых ссылок не найдено'); } else { hasBroken = true; console.log(`Найдено битых ссылок: ${broken.length}\n`); let lastFile = ''; for (const b of broken) { if (b.file !== lastFile) { console.log(`\n${b.file}`); lastFile = b.file; } console.log(` L${b.line} [${b.type}] -> ${b.target}`); } } console.log(''); if (orphans.length === 0) { console.log('OK: неиспользуемых страниц не найдено'); } else { console.log(`WARN: найдено неиспользуемых страниц (не упомянуты ни в sidebar, ни в других md): ${orphans.length}\n`); for (const f of orphans) console.log(` ${f}`); } process.exit(hasBroken ? 1 : 0);