VEGA-6128 Генерация ссылок для index старниц

This commit is contained in:
Rusovich Violetta
2025-12-25 14:13:59 +03:00
parent f83c928c47
commit 7fd1755a7d
14 changed files with 592 additions and 89 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "docs",
"version": "0.6.4",
"version": "0.6.4-content",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "docs",
"version": "0.6.4",
"version": "0.6.4-content",
"license": "MIT",
"dependencies": {
"@beeline/design-tokens": "^1.31.0",
+84 -80
View File
@@ -1,6 +1,8 @@
import { defineConfig } from 'vitepress'
import { tabsMarkdownPlugin } from 'vitepress-plugin-tabs'
import { overrideComponents } from './override-components'
import { autoSectionLinksPlugin } from './plugins/auto-section-links'
import { resolve } from 'path'
const gitlab = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
@@ -39,81 +41,7 @@ const gitlab = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
const new_version = process.env?.VITE_NEW_VERSION;
console.log({ base: typeof new_version !== 'undefined' ? '/' : '/docs/' })
// https://vitepress.dev/reference/site-config
export default defineConfig({
srcDir: ".",
title: " ",
description: "Документация Beeline Cloud",
head: [['link', { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/bee-favicon.png' }]],
base: typeof new_version !== 'undefined' ? '/' : '/docs/',
markdown: {
config(md) {
md.use(tabsMarkdownPlugin)
}
},
vite: {
resolve: {
alias: overrideComponents(),
}
},
locales: {
root: {
label: 'Русский',
lang: 'ru',
}
},
themeConfig: {
logo: {
light: '/img/logo-cloud.svg',
dark: '/img/logo-cloud.svg',
alt: '',
},
search: {
provider: 'local',
options: {
locales: {
root: {
translations: {
button: {
buttonText: 'Поиск',
buttonAriaLabel: 'Поиск'
},
modal: {
noResultsText: 'По вашему запросу ничего не найдено',
resetButtonTitle: 'Сбросить',
}
}
}
}
}
},
// https://vitepress.dev/reference/default-theme-config
// nav: [
// {
// text: 'Документация',
// link: '/guide/',
// },
// {
// text: 'API',
// link: '',
// },
// {
// text: 'Terraform',
// // link: '/terraform/',
// link: '',
// },
// ],
docFooter: {
next: 'Вперед',
prev: 'Назад'
},
outline: {
label: 'Содержание'
},
sidebar: {
const sidebarConfig = {
'/platform/': [
{
text: 'Платформа Beeline Cloud', link: '/platform/index.md',
@@ -140,11 +68,10 @@ export default defineConfig({
text: 'Резервное копирование', link: '/backups/index.md',
},
{
text: 'Обзор сервиса', link: '/backups/backups-overview.md',
text: 'Обзор сервиса', link: '/backups/about.md',
collapsed: true,
items: [
{text: 'О сервисе', link: '/backups/about.md'},
{text: 'Квоты и лимиты', link: '/backups/backup-quatos.md'},
{text: 'Квоты и лимиты', link: '/backups/backup-quatos.md'},
]
},
{text: 'Резервное копирование виртуальных машин Beeline Cloud', link: '/backups/backup-internal-infra.md'},
@@ -216,7 +143,6 @@ export default defineConfig({
{ text: 'Настройка site-to-site подключения с помощью IPSec', link: '/vdc/vdc-how-to/networks/how-to-setup-ipsec-vpn.md',
collapsed: true,
items: [
{text: 'Настройка IPSec VPN', link: '/vdc/vdc-how-to/networks/ipsec/setup-ipsec-vpn.md'},
{text: 'Настройка ASAv', link: '/vdc/vdc-how-to/networks/ipsec/asav.md'},
{text: 'Настройка CSR 1000v', link: '/vdc/vdc-how-to/networks/ipsec/csr1000v.md'},
{text: 'Настройка Fortigate', link: '/vdc/vdc-how-to/networks/ipsec/fortigate.md'},
@@ -321,7 +247,85 @@ export default defineConfig({
text: 'Подключение к виртуальному рабочему месту', link: '/vdi/vdi-how-to/vdi-connect.md'
},
],
},
}
// https://vitepress.dev/reference/site-config
export default defineConfig({
srcDir: ".",
title: " ",
description: "Документация Beeline Cloud",
head: [['link', { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/bee-favicon.png' }]],
base: typeof new_version !== 'undefined' ? '/' : '/docs/',
markdown: {
config(md) {
md.use(tabsMarkdownPlugin)
}
},
vite: {
resolve: {
alias: overrideComponents(),
},
plugins: [
autoSectionLinksPlugin(resolve(__dirname, '..'), sidebarConfig) as any,
]
},
locales: {
root: {
label: 'Русский',
lang: 'ru',
}
},
themeConfig: {
logo: {
light: '/img/logo-cloud.svg',
dark: '/img/logo-cloud.svg',
alt: '',
},
search: {
provider: 'local',
options: {
locales: {
root: {
translations: {
button: {
buttonText: 'Поиск',
buttonAriaLabel: 'Поиск'
},
modal: {
noResultsText: 'По вашему запросу ничего не найдено',
resetButtonTitle: 'Сбросить',
}
}
}
}
}
},
// https://vitepress.dev/reference/default-theme-config
// nav: [
// {
// text: 'Документация',
// link: '/guide/',
// },
// {
// text: 'API',
// link: '',
// },
// {
// text: 'Terraform',
// // link: '/terraform/',
// link: '',
// },
// ],
docFooter: {
next: 'Вперед',
prev: 'Назад'
},
outline: {
label: 'Содержание'
},
sidebar: sidebarConfig,
},
}
)
@@ -0,0 +1,16 @@
import type { Plugin } from 'vite'
import type { SidebarItem } from './utils/types'
import { processAllFiles } from './hook/process-files'
export const autoSectionLinksPlugin = (
srcDir: string,
sidebarConfig: Record<string, SidebarItem[]>
): Plugin => ({
name: 'auto-section-links',
buildStart: () => {
processAllFiles(srcDir, sidebarConfig)
},
configureServer: () => {
processAllFiles(srcDir, sidebarConfig)
}
})
+4
View File
@@ -0,0 +1,4 @@
export const INDEX_FILE = 'index.md'
export const INDEX_FILE_PATTERN = /-index\.md$|index\.md$/
export const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)\n---/
export const SECTION_LINK_KEYS = ['title', 'link', 'description'] as const
@@ -0,0 +1,36 @@
import { existsSync } from 'fs'
import { relative, resolve } from 'path'
import type { SidebarItem } from '../utils/types'
import { findMarkdownFiles } from '../utils/file-finder'
import { processIndexFile, processPageWithItems } from '../utils/file-processor'
import { INDEX_FILE, INDEX_FILE_PATTERN } from '../constants'
export const processAllFiles = (
srcDir: string,
sidebarConfig: Record<string, SidebarItem[]>
) => {
const srcPath = resolve(srcDir)
for (const [folderPath, items] of Object.entries(sidebarConfig)) {
const normalizedFolder = folderPath.replace(/^\/+|\/+$/g, '')
const folderFullPath = resolve(srcPath, normalizedFolder)
if (!existsSync(folderFullPath)) {
continue
}
const mdFiles = findMarkdownFiles(folderFullPath)
for (const filePath of mdFiles) {
const fileName = filePath.split(/[/\\]/).pop() || ''
const relativePath = relative(srcPath, filePath).replace(/\\/g, '/')
const normalizedPath = relativePath.startsWith('/') ? relativePath : `/${relativePath}`
if (fileName === INDEX_FILE) {
processIndexFile(filePath, items, srcPath)
} else if (INDEX_FILE_PATTERN.test(fileName) && fileName !== INDEX_FILE) {
processPageWithItems(filePath, items, srcPath)
}
}
}
}
@@ -0,0 +1,17 @@
import { readFileSync, existsSync } from 'fs'
import { resolve } from 'path'
import { parseFrontmatter } from './frontmatter'
export const getPageDescription = (filePath: string, srcDir: string) => {
try {
const fullPath = resolve(srcDir, filePath.replace(/^\//, ''))
if (!existsSync(fullPath)) return undefined
const fileContent = readFileSync(fullPath, 'utf-8')
const { frontmatter } = parseFrontmatter(fileContent)
return frontmatter.description as string | undefined
} catch {
return undefined
}
}
@@ -0,0 +1,24 @@
import { readdirSync, statSync } from 'fs'
import { join } from 'path'
export const findMarkdownFiles = (dir: string) => {
const files: string[] = []
try {
const entries = readdirSync(dir)
for (const entry of entries) {
const fullPath = join(dir, entry)
const stat = statSync(fullPath)
if (stat.isDirectory()) {
files.push(...findMarkdownFiles(fullPath))
} else if (entry.endsWith('.md')) {
files.push(fullPath)
}
}
} catch {
}
return files
}
@@ -0,0 +1,135 @@
import { readFileSync, writeFileSync, existsSync } from 'fs'
import { relative, resolve } from 'path'
import type { SidebarItem } from './types'
import { parseFrontmatter, stringifyFrontmatter } from './frontmatter'
import { mergeSectionLinks, extractTopLevelLinks, extractItemsForPage } from './links'
import { normalizeLink } from './path-utils'
import { SectionLinkListItem } from '../../theme/components/SectionLinkList/SectionLinkList.types'
const removeDuplicates = (links: SectionLinkListItem[]) => {
const result: SectionLinkListItem[] = []
const seenLinks = new Set<string>()
for (const link of links) {
if (link.link) {
const normalized = normalizeLink(link.link)
if (!seenLinks.has(normalized)) {
seenLinks.add(normalized)
result.push(link)
}
}
}
return result
}
const hasChanges = (
existingLinks: SectionLinkListItem[],
mergedLinks: SectionLinkListItem[],
newLinks: SectionLinkListItem[]
) => {
const existingLinksSet = new Set(
existingLinks
.map(link => link.link)
.filter((link): link is string => Boolean(link))
.map(normalizeLink)
)
const hasNewLinks = newLinks.some(
link => link.link && !existingLinksSet.has(normalizeLink(link.link))
)
if (hasNewLinks) return true
const sidebarLinksSet = new Set(
newLinks
.map(link => link.link)
.filter((link): link is string => Boolean(link))
.map(normalizeLink)
)
const existingSidebarLinks = existingLinks
.filter(link => link.link && sidebarLinksSet.has(normalizeLink(link.link)))
.map(link => normalizeLink(link.link!))
.join('|')
const mergedSidebarLinks = mergedLinks
.slice(0, newLinks.length)
.filter(link => link.link && sidebarLinksSet.has(normalizeLink(link.link)))
.map(link => normalizeLink(link.link!))
.join('|')
return existingSidebarLinks !== mergedSidebarLinks && mergedLinks.length === existingLinks.length
}
export const processIndexFile = (
filePath: string,
sidebarItems: SidebarItem[],
srcDir: string
) => {
if (!existsSync(filePath)) return false
try {
const fileContent = readFileSync(filePath, 'utf-8')
const { frontmatter, content } = parseFrontmatter(fileContent)
const relativePath = relative(srcDir, filePath).replace(/\\/g, '/')
const normalizedPath = relativePath.startsWith('/') ? relativePath : `/${relativePath}`
const rawExistingLinks: SectionLinkListItem[] = Array.isArray(frontmatter.section_links)
? [...frontmatter.section_links]
: []
const existingLinks = removeDuplicates(rawExistingLinks)
const newLinks = extractTopLevelLinks(sidebarItems, normalizedPath, srcDir)
if (!newLinks.length) return false
const mergedLinks = removeDuplicates(mergeSectionLinks(existingLinks, newLinks))
if (!hasChanges(existingLinks, mergedLinks, newLinks)) return false
frontmatter.section_links = mergedLinks
const updatedContent = stringifyFrontmatter(frontmatter, content)
writeFileSync(filePath, updatedContent, 'utf-8')
return true
} catch {
return false
}
}
export const processPageWithItems = (
filePath: string,
sidebarItems: SidebarItem[],
srcDir: string
) => {
if (!existsSync(filePath)) return false
try {
const fileContent = readFileSync(filePath, 'utf-8')
const { frontmatter, content } = parseFrontmatter(fileContent)
const relativePath = relative(srcDir, filePath).replace(/\\/g, '/')
const normalizedPath = relativePath.startsWith('/') ? relativePath : `/${relativePath}`
const rawExistingLinks: SectionLinkListItem[] = Array.isArray(frontmatter.section_links)
? [...frontmatter.section_links]
: []
const existingLinks = removeDuplicates(rawExistingLinks)
const newLinks = extractItemsForPage(sidebarItems, normalizedPath, srcDir)
if (!newLinks.length) return false
const mergedLinks = removeDuplicates(mergeSectionLinks(existingLinks, newLinks))
if (!hasChanges(existingLinks, mergedLinks, newLinks)) return false
frontmatter.section_links = mergedLinks
const updatedContent = stringifyFrontmatter(frontmatter, content)
writeFileSync(filePath, updatedContent, 'utf-8')
return true
} catch {
return false
}
}
+126
View File
@@ -0,0 +1,126 @@
import type { Frontmatter } from './types'
import { SECTION_LINK_KEYS, FRONTMATTER_REGEX } from '../constants'
import { SectionLinkListItem } from '../../theme/components/SectionLinkList/SectionLinkList.types'
export const parseFrontmatter = (content: string) => {
const frontmatterMatch = content.match(FRONTMATTER_REGEX)
if (!frontmatterMatch) {
const cleanContent = content.replace(/^---[\s\S]*?---\s*\n*/g, '').trim()
return { frontmatter: {}, content: cleanContent || content }
}
const [, frontmatterText] = frontmatterMatch
const frontmatterEnd = frontmatterMatch[0].length
const cleanContent = content.slice(frontmatterEnd).replace(/^---[\s\S]*?---\s*\n*/g, '').trim()
const frontmatter: Frontmatter = {}
const lines = frontmatterText.split('\n')
let currentKey: string | undefined
let currentValue: SectionLinkListItem[] = []
let currentItem: Partial<SectionLinkListItem> | undefined
let inArray = false
for (const line of lines) {
const trimmed = line.trim()
if (trimmed && !trimmed.startsWith('#')) {
const lineIndent = line.match(/^(\s*)/)?.[1]?.length ?? 0
if (lineIndent === 0 && trimmed.includes(':')) {
if (inArray && currentKey) {
frontmatter[currentKey] = currentValue
currentValue = []
inArray = false
}
const colonMatch = trimmed.match(/^([^:]+):\s*(.*)$/)
if (colonMatch) {
const [, key, value] = colonMatch
currentKey = key.trim()
const trimmedValue = value.trim()
if (trimmedValue === '') {
inArray = true
currentValue = []
} else {
frontmatter[currentKey] = trimmedValue.replace(/^["']|["']$/g, '')
}
}
} else if (lineIndent === 2 && trimmed.startsWith('- ')) {
if (!inArray && currentKey) {
inArray = true
currentValue = []
}
if (currentItem && !currentValue.includes(currentItem as SectionLinkListItem)) {
currentValue.push(currentItem as SectionLinkListItem)
}
const itemText = trimmed.slice(2).trim()
if (itemText.includes(':')) {
currentItem = {}
const parts = itemText.split(',').map(p => p.trim())
for (const part of parts) {
const colonMatch = part.match(/^(\w+):\s*(.+)$/)
if (colonMatch) {
const [, key, value] = colonMatch
const sectionKey = key as keyof SectionLinkListItem
if (SECTION_LINK_KEYS.includes(sectionKey)) {
currentItem[sectionKey] = value.replace(/^["']|["']$/g, '')
}
}
}
} else {
currentValue.push({ title: itemText, link: '' })
}
} else if (lineIndent >= 4 && currentItem && trimmed.includes(':')) {
const colonIndex = trimmed.indexOf(':')
if (colonIndex !== -1) {
const key = trimmed.substring(0, colonIndex).trim()
const value = trimmed.substring(colonIndex + 1).trim()
const sectionKey = key as keyof SectionLinkListItem
if (SECTION_LINK_KEYS.includes(sectionKey)) {
currentItem[sectionKey] = value === '' ? '' : value.replace(/^["']|["']$/g, '')
}
}
}
}
}
if (currentItem && inArray && !currentValue.includes(currentItem as SectionLinkListItem)) {
currentValue.push(currentItem as SectionLinkListItem)
}
if (inArray && currentKey) {
frontmatter[currentKey] = currentValue
}
return { frontmatter, content: cleanContent || content }
}
export const stringifyFrontmatter = (frontmatter: Frontmatter, content: string) => {
const lines: string[] = []
for (const [key, value] of Object.entries(frontmatter)) {
if (Array.isArray(value)) {
lines.push(`${key}:`)
for (const item of value) {
if (typeof item === 'object' && item) {
const link = item as SectionLinkListItem
lines.push(` - title: ${link.title ?? ''}`)
if (link.link) lines.push(` link: ${link.link}`)
if ('description' in link && link.description !== undefined && link.description !== null && String(link.description).trim() !== '') {
lines.push(` description: ${String(link.description)}`)
}
} else {
lines.push(` - ${item}`)
}
}
} else if (value) {
const strValue = String(value)
const needsQuotes = strValue.includes(':') || (strValue.includes(' ') && !strValue.startsWith('"')) || strValue === ''
const escaped = strValue.replace(/"/g, '\\"')
lines.push(`${key}: ${needsQuotes ? `"${escaped}"` : strValue}`)
}
}
return `---\n${lines.join('\n')}\n---\n\n${content}`
}
+120
View File
@@ -0,0 +1,120 @@
import type { SidebarItem } from './types'
import { getPageDescription } from './descriptions'
import { normalizeLink } from './path-utils'
import { SectionLinkListItem } from '../../theme/components/SectionLinkList/SectionLinkList.types'
export const mergeSectionLinks = (
existingLinks: SectionLinkListItem[],
newLinks: SectionLinkListItem[]
) => {
const existingLinksMap = new Map<string, SectionLinkListItem>()
const result: SectionLinkListItem[] = []
const processedLinks = new Set<string>()
for (const existingLink of existingLinks) {
if (existingLink.link) {
const normalizedLink = normalizeLink(existingLink.link)
if (!existingLinksMap.has(normalizedLink)) {
existingLinksMap.set(normalizedLink, existingLink)
}
}
}
for (const newLink of newLinks) {
if (newLink.link) {
const normalizedLink = normalizeLink(newLink.link)
if (!processedLinks.has(normalizedLink)) {
processedLinks.add(normalizedLink)
if (existingLinksMap.has(normalizedLink)) {
const existingLink = existingLinksMap.get(normalizedLink)!
result.push({ ...existingLink, title: newLink.title })
} else {
result.push(newLink)
}
}
}
}
for (const existingLink of existingLinks) {
if (existingLink.link) {
const normalizedLink = normalizeLink(existingLink.link)
if (!processedLinks.has(normalizedLink)) {
processedLinks.add(normalizedLink)
result.push({ ...existingLink })
}
}
}
return result
}
export const extractTopLevelLinks = (
items: SidebarItem[],
currentIndexPath: string,
srcDir: string
) => {
const links: SectionLinkListItem[] = []
const normalizedCurrentPath = normalizeLink(currentIndexPath)
for (const item of items) {
if (item.link) {
const normalizedItemPath = normalizeLink(item.link)
if (normalizedItemPath === normalizedCurrentPath) {
continue
}
links.push({
title: item.text || '',
link: item.link,
description: getPageDescription(item.link, srcDir)
})
}
}
return links
}
const findItemsRecursive = (
itemsList: SidebarItem[],
pagePath: string,
srcDir: string
): SectionLinkListItem[] => {
const normalizedPagePath = normalizeLink(pagePath)
const links: SectionLinkListItem[] = []
for (const item of itemsList) {
if (item.link) {
const normalizedItemPath = normalizeLink(item.link)
if (normalizedItemPath === normalizedPagePath && item.items && Array.isArray(item.items) && item.items.length > 0) {
for (const subItem of item.items) {
if (subItem.link) {
links.push({
title: subItem.text || '',
link: subItem.link,
description: getPageDescription(subItem.link, srcDir)
})
}
}
return links
}
}
if (item.items && Array.isArray(item.items)) {
const found = findItemsRecursive(item.items, pagePath, srcDir)
if (found.length > 0) {
return found
}
}
}
return links
}
export const extractItemsForPage = (
items: SidebarItem[],
pagePath: string,
srcDir: string
) => findItemsRecursive(items, pagePath, srcDir)
@@ -0,0 +1,6 @@
export const normalizeLink = (link: string): string =>
!link ? '' : link
.replace(/^\/+/, '')
.replace(/\.md$/, '')
.replace(/\\/g, '/')
.trim()
+12
View File
@@ -0,0 +1,12 @@
import { SectionLinkListItem } from "../../theme/components/SectionLinkList/SectionLinkList.types"
export type SidebarItem = {
text: string
link?: string
items?: SidebarItem[]
}
export type Frontmatter = {
section_links?: SectionLinkListItem[]
[key: string]: any
}
@@ -7,8 +7,7 @@ section_links:
link: /compute/compute-how-to/compute-connect-inside.md
description: Подключиться к виртуальной машине по SSH с помощью ключевой пары по внутреннему IP-адресу через джамп-хост
- title: Подключение по SSH по логину и паролю
link: /compute/compute-how-to/compute-connect-inside.md
description: Подключиться к виртуальной машине по SSH с помощью логина и пароля
---
link: /compute/compute-how-to/compute-connect-pwd.md
---
# Подключение к ВМ
# Подключение к ВМ
+7 -3
View File
@@ -3,6 +3,8 @@ section_links:
- title: Обзор сервиса
link: /compute/compute-overview-index.md
description: Обзор сервиса, решаемые задачи, характеристики оборудования
- title: Быстрый старт
link: /compute/compute-getting-started.md
- title: Виртуальные машины
link: /compute/compute-how-to/compute-index.md
description: Создание виртуальной машины и подключение к ней, управление виртуальной машиной
@@ -14,9 +16,11 @@ section_links:
description: Резервирование, назначение IP-адреса виртуальной машине, удаление IP-адресов
- title: Группы размещения
link: /compute/compute-how-to/compute-affinity.md
description: Создание правил размещения виртуальных машин на физических хостах, управление группами размещения
---
description: Создание правил размещения виртуальных машин на физических хостах, управление группами размещения
- title: Сети
link: /compute/compute-how-to/compute-network/compute-network-index.md
---
# Виртуальные машины
Сервис **Виртуальные машины** предоставляет пользователям виртуальные машины.
Сервис **Виртуальные машины** предоставляет пользователям виртуальные машины.