Files
fox/scripts/check-links.mjs
T
Александр Анисин 06ed9845f1 Исправления ссылок
2026-05-26 09:35:35 +03:00

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);