187 lines
6.0 KiB
JavaScript
187 lines
6.0 KiB
JavaScript
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 = /<!--\s*@include:\s*([^\s>-]+(?:\.md)?)[^>]*-->/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);
|