From 06ed9845f1abe3b708b3612f618d339a7b091c00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=90=D0=BD=D0=B8=D1=81=D0=B8=D0=BD?= <72320596+alexanderanisin@users.noreply.github.com> Date: Tue, 26 May 2026 09:35:35 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D1=81=D1=8B=D0=BB=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 5 +- scripts/check-links.mjs | 186 +++++++++++++++++++++++++++++ src/.vitepress/config.mts | 38 +++--- src/PostgreSQL/PostgreSQL-index.md | 2 +- src/vdc/index.md | 3 - 5 files changed, 210 insertions(+), 24 deletions(-) create mode 100644 scripts/check-links.mjs diff --git a/package.json b/package.json index e52a80f..2dafcee 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/check-links.mjs b/scripts/check-links.mjs new file mode 100644 index 0000000..939503f --- /dev/null +++ b/scripts/check-links.mjs @@ -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 = //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); diff --git a/src/.vitepress/config.mts b/src/.vitepress/config.mts index 258fd6a..895e5f9 100644 --- a/src/.vitepress/config.mts +++ b/src/.vitepress/config.mts @@ -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/': [ diff --git a/src/PostgreSQL/PostgreSQL-index.md b/src/PostgreSQL/PostgreSQL-index.md index 281454f..4f87ad0 100644 --- a/src/PostgreSQL/PostgreSQL-index.md +++ b/src/PostgreSQL/PostgreSQL-index.md @@ -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 diff --git a/src/vdc/index.md b/src/vdc/index.md index 17ecbbd..c08aac8 100644 --- a/src/vdc/index.md +++ b/src/vdc/index.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