Исправления ссылок

This commit is contained in:
Александр Анисин
2026-05-26 09:35:35 +03:00
parent ed36dfb22b
commit 06ed9845f1
5 changed files with 210 additions and 24 deletions
+4 -1
View File
@@ -4,9 +4,12 @@
"description": "Beeline Cloud docs", "description": "Beeline Cloud docs",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"predev": "node scripts/check-links.mjs",
"dev": "vitepress dev src", "dev": "vitepress dev src",
"prebuild": "node scripts/check-links.mjs",
"build": "vitepress build src", "build": "vitepress build src",
"preview": "vitepress preview src" "preview": "vitepress preview src",
"check-links": "node scripts/check-links.mjs"
}, },
"keywords": [], "keywords": [],
"authors": { "authors": {
+186
View File
@@ -0,0 +1,186 @@
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);
+19 -19
View File
@@ -164,7 +164,7 @@ export default defineConfig({
collapsed: true, collapsed: true,
items: [ items: [
{ {
text: 'Состав сервиса SA', link: '/security/Cloud-SA/compond-index.md', text: 'Состав сервиса SA', link: '/security/Cloud-SA/compond-SA/compond-index.md',
collapsed: true, collapsed: true,
items: [ items: [
{ text: 'Обзор сервиса', link: '/security/Cloud-SA/compond-SA/about.md' }, { text: 'Обзор сервиса', link: '/security/Cloud-SA/compond-SA/about.md' },
@@ -206,7 +206,7 @@ export default defineConfig({
items: [ items: [
{ text: 'Обзор сервиса', link: '/security/Cloud-MDM/about.md' }, { text: 'Обзор сервиса', link: '/security/Cloud-MDM/about.md' },
{ {
text: 'Описание сервиса MDM', link: '/security/Cloud-MDM/discription-index.md', text: 'Описание сервиса MDM', link: '/security/Cloud-MDM/description/description-index.md',
collapsed: true, collapsed: true,
items: [ items: [
{ text: 'Состав сервиса', link: '/security/Cloud-MDM/description/compound.md' }, { text: 'Состав сервиса', link: '/security/Cloud-MDM/description/compound.md' },
@@ -220,7 +220,7 @@ export default defineConfig({
{ text: 'Сроки и условия предоставления сервиса', link: '/security/Cloud-MDM/provision.md' }, { text: 'Сроки и условия предоставления сервиса', link: '/security/Cloud-MDM/provision.md' },
{ text: 'Порядок платежей', link: '/security/Cloud-MDM/payments.md' }, { text: 'Порядок платежей', link: '/security/Cloud-MDM/payments.md' },
{ {
text: 'Инструкиця', link: '/security/Cloud-MDM/instructions-index.md', text: 'Инструкиця', link: '/security/Cloud-MDM/instructions/index.md',
collapsed: true, collapsed: true,
items: [ items: [
{ text: 'Инструкция', link: '/security/Cloud-MDM/instructions/instructions.md' }, { text: 'Инструкция', link: '/security/Cloud-MDM/instructions/instructions.md' },
@@ -231,15 +231,15 @@ export default defineConfig({
], ],
}, },
{ {
text: 'Cloud NGFW', link: '/security/NGFW-index.md', text: 'Cloud NGFW', link: '/security/Cloud-NGFW/NGFW-index.md',
collapsed: true, collapsed: true,
items: [ items: [
{ text: 'Обзор сервиса', link: '/security/Cloud-NGFW/about.md'}, { text: 'Обзор сервиса', link: '/security/Cloud-NGFW/about.md'},
{ text: 'Основные возможности', link: '/security/Cloud-NGFW/provision.md' }, { text: 'Основные возможности', link: '/security/Cloud-NGFW/possibilities.md' },
{ text: 'Спецификация сервиса', link: '/security/Cloud-NGFW/specification.md' }, { text: 'Спецификация сервиса', link: '/security/Cloud-NGFW/specification.md' },
{ text: 'Состав сервиса', link: '/security/Cloud-NGFW/compound.md' }, { text: 'Состав сервиса', link: '/security/Cloud-NGFW/compound.md' },
{ text: 'Сроки и условия предоставления сервиса. Зоны ответственности', link: '/security/Cloud-NGFW/provision.md' }, { text: 'Сроки и условия предоставления сервиса. Зоны ответственности', link: '/security/Cloud-NGFW/provision.md' },
{ text: 'Структура платежей', link: '/security/Cloud-NGFW/payment-structure.md' }, { text: 'Структура платежей', link: '/security/Cloud-NGFW/payment.md' },
] ]
}, },
{ {
@@ -276,19 +276,19 @@ export default defineConfig({
// ], // ],
'/backups/': [ // '/backups/': [
{ // {
text: 'Резервное копирование', link: '/backups/index.md', // text: 'Резервное копирование', link: '/backups/index.md',
}, // },
{ // {
text: 'Обзор сервиса', link: '/backups/backups-overview.md', // text: 'Обзор сервиса', link: '/backups/backups-overview.md',
collapsed: true, // collapsed: true,
items: [ // items: [
{text: 'О сервисе', link: '/backups/about.md'}, // {text: 'О сервисе', link: '/backups/about.md'},
{text: 'Квоты и лимиты', link: '/backups/backup-quatos.md'}, // {text: 'Квоты и лимиты', link: '/backups/backup-quatos.md'},
] // ]
}, // },
], // ],
'/PostgreSQL/': [ '/PostgreSQL/': [
+1 -1
View File
@@ -1,7 +1,7 @@
--- ---
section_links: section_links:
- title: Cloud PostgreSQL - title: Cloud PostgreSQL
link: /PostgreSQL/service-index.md link: /PostgreSQL/service/service-index.md
description: Обзор сервиса PostgreSQL description: Обзор сервиса PostgreSQL
- title: IPSEC - title: IPSEC
link: /PostgreSQL/IPSEC.md link: /PostgreSQL/IPSEC.md
-3
View File
@@ -6,9 +6,6 @@ section_links:
- title: Подключиться к виртуальному дата-центру - title: Подключиться к виртуальному дата-центру
link: /vdc/vdc-getting-started.md link: /vdc/vdc-getting-started.md
description: Подключение 2FA, подключение к дата-центру и создание виртуальной машины description: Подключение 2FA, подключение к дата-центру и создание виртуальной машины
- title: Виртуальные дата-центры
link: /vdc/vdc-how-to/vdc-index.md
description: Создание виртуального дата-цента, вход с двухфакторной аутентификацией
- title: Виртуальные машины - title: Виртуальные машины
link: /vdc/vdc-how-to/vm/vm-index.md link: /vdc/vdc-how-to/vm/vm-index.md
description: Управление виртуальными машинами в дата-центре с помощью Cloud Director description: Управление виртуальными машинами в дата-центре с помощью Cloud Director