Исправления ссылок
This commit is contained in:
+4
-1
@@ -4,9 +4,12 @@
|
||||
"description": "Beeline Cloud docs",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"predev": "node scripts/check-links.mjs",
|
||||
"dev": "vitepress dev src",
|
||||
"prebuild": "node scripts/check-links.mjs",
|
||||
"build": "vitepress build src",
|
||||
"preview": "vitepress preview src"
|
||||
"preview": "vitepress preview src",
|
||||
"check-links": "node scripts/check-links.mjs"
|
||||
},
|
||||
"keywords": [],
|
||||
"authors": {
|
||||
|
||||
@@ -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
@@ -164,7 +164,7 @@ export default defineConfig({
|
||||
collapsed: true,
|
||||
items: [
|
||||
{
|
||||
text: 'Состав сервиса SA', link: '/security/Cloud-SA/compond-index.md',
|
||||
text: 'Состав сервиса SA', link: '/security/Cloud-SA/compond-SA/compond-index.md',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: 'Обзор сервиса', link: '/security/Cloud-SA/compond-SA/about.md' },
|
||||
@@ -206,7 +206,7 @@ export default defineConfig({
|
||||
items: [
|
||||
{ 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,
|
||||
items: [
|
||||
{ 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/payments.md' },
|
||||
{
|
||||
text: 'Инструкиця', link: '/security/Cloud-MDM/instructions-index.md',
|
||||
text: 'Инструкиця', link: '/security/Cloud-MDM/instructions/index.md',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ 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,
|
||||
items: [
|
||||
{ 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/compound.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/': [
|
||||
{
|
||||
text: 'Резервное копирование', link: '/backups/index.md',
|
||||
},
|
||||
{
|
||||
text: 'Обзор сервиса', link: '/backups/backups-overview.md',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{text: 'О сервисе', link: '/backups/about.md'},
|
||||
{text: 'Квоты и лимиты', link: '/backups/backup-quatos.md'},
|
||||
]
|
||||
},
|
||||
],
|
||||
// '/backups/': [
|
||||
// {
|
||||
// text: 'Резервное копирование', link: '/backups/index.md',
|
||||
// },
|
||||
// {
|
||||
// text: 'Обзор сервиса', link: '/backups/backups-overview.md',
|
||||
// collapsed: true,
|
||||
// items: [
|
||||
// {text: 'О сервисе', link: '/backups/about.md'},
|
||||
// {text: 'Квоты и лимиты', link: '/backups/backup-quatos.md'},
|
||||
// ]
|
||||
// },
|
||||
// ],
|
||||
|
||||
|
||||
'/PostgreSQL/': [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
section_links:
|
||||
- title: Cloud PostgreSQL
|
||||
link: /PostgreSQL/service-index.md
|
||||
link: /PostgreSQL/service/service-index.md
|
||||
description: Обзор сервиса PostgreSQL
|
||||
- title: IPSEC
|
||||
link: /PostgreSQL/IPSEC.md
|
||||
|
||||
@@ -6,9 +6,6 @@ section_links:
|
||||
- title: Подключиться к виртуальному дата-центру
|
||||
link: /vdc/vdc-getting-started.md
|
||||
description: Подключение 2FA, подключение к дата-центру и создание виртуальной машины
|
||||
- title: Виртуальные дата-центры
|
||||
link: /vdc/vdc-how-to/vdc-index.md
|
||||
description: Создание виртуального дата-цента, вход с двухфакторной аутентификацией
|
||||
- title: Виртуальные машины
|
||||
link: /vdc/vdc-how-to/vm/vm-index.md
|
||||
description: Управление виртуальными машинами в дата-центре с помощью Cloud Director
|
||||
|
||||
Reference in New Issue
Block a user