import path from 'node:path'; import fs from 'node:fs'; import os from 'node:os'; import { execFileSync } from 'node:child_process'; import config from './.vitepress/config'; /** Версия сборки. Передаётся CI через ENV; локально — `dev`. */ const VERSION = process.env.BUILD_VERSION || 'dev'; const BUILD_DATE = new Date().toISOString(); /** Корневая папка для генерируемой статики (попадает в build dist). */ const PUBLIC_DIR = 'docs/public'; type Lang = 'ru' | 'en'; interface SidebarItem { text: string; link?: string; items?: SidebarItem[]; collapsed?: boolean; } interface Entry { /** Название группы верхнего уровня (sidebar[].text) */ section: string; /** Префикс из вложенной группы (например "Архитектура") */ prefix: string | null; /** Текст пункта в sidebar */ text: string; /** Ссылка из sidebar */ link: string; } /** Разобрать YAML frontmatter (плоский, без вложенностей) */ const parseFrontmatter = ( content: string, ): { data: Record; body: string } => { const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); if (!match) return { data: {}, body: content }; const data: Record = {}; for (const line of match[1].split('\n')) { const lineMatch = line.match(/^([^:]+):\s*(.*)$/); if (!lineMatch) continue; let value = lineMatch[2].trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } data[lineMatch[1].trim()] = value; } return { data, body: match[2] }; }; /** Первый абзац после h1 — однострочное описание для llms.txt */ const firstParagraphAfterH1 = (body: string): string | null => { const lines = body.split('\n'); const h1Idx = lines.findIndex((l) => /^#\s/.test(l)); if (h1Idx === -1) return null; let i = h1Idx + 1; while (i < lines.length && lines[i].trim() === '') i++; const para: string[] = []; while ( i < lines.length && lines[i].trim() !== '' && !lines[i].startsWith('#') ) { para.push(lines[i].trim()); i++; } return para.join(' ').trim() || null; }; /** * Преобразовать sidebar `link` в относительный путь файла внутри * `docs/{lang}/`. Sidebar links содержат полный префикс локали * (`/ru/...`, `/en/...`) — отрезаем его. */ const linkToRel = (link: string, lang: Lang): string => { const prefix = `/${lang}/`; let rel = link.startsWith(prefix) ? link.slice(prefix.length) : link.replace(/^\//, ''); if (rel === '' || rel.endsWith('/')) { rel += 'index.md'; } else { rel += '.md'; } return rel; }; const linkToFilePath = (link: string, lang: Lang): string => path.join('docs', lang, linkToRel(link, lang)); /** * Абсолютный путь от корня сайта к `.md`-копии страницы. * После build файлы лежат в `dist/{lang}/...md` (через `docs/public/`). */ const linkToSiteUrl = (link: string, lang: Lang): string => `/${lang}/${linkToRel(link, lang)}`; /** * Развернуть sidebar в плоский список с сохранением группы и * опционального префикса вложенной группы. */ const flattenSidebar = (sidebar: SidebarItem[]): Entry[] => { const entries: Entry[] = []; for (const top of sidebar) { const section = top.text; if (top.link && !top.items) { entries.push({ section, prefix: null, text: top.text, link: top.link }); continue; } if (!top.items) continue; for (const item of top.items) { if (item.items) { for (const sub of item.items) { if (!sub.link) continue; entries.push({ section, prefix: item.text, text: sub.text, link: sub.link, }); } } else if (item.link) { entries.push({ section, prefix: null, text: item.text, link: item.link, }); } } } return entries; }; const groupBySection = (entries: Entry[]): Map => { const map = new Map(); for (const entry of entries) { const list = map.get(entry.section); if (list) list.push(entry); else map.set(entry.section, [entry]); } return map; }; const buildLlms = (lang: Lang): void => { const localeKey = lang; // VitePress-конфиг типизирован как `UserConfig`, но обращаемся к // фактически переданным значениям — сужаем тип через any. const cfg = config as unknown as { title: string; description: string; locales: Record< string, { description?: string; llmsBlockquote?: string; llmsContext?: string; themeConfig?: { sidebar?: SidebarItem[] }; } >; }; const locale = cfg.locales[localeKey]; const sidebar = locale?.themeConfig?.sidebar; if (!sidebar) { console.warn(`[${lang}] sidebar не найден в config`); return; } // Для blockquote предпочитаем расширенный llms-текст; короткий // description — fallback и используется для HTML meta-тега VitePress. const blockquote = locale.llmsBlockquote ?? locale.description ?? cfg.description; const context = locale.llmsContext; const entries = flattenSidebar(sidebar); const grouped = groupBySection(entries); const lines: string[] = []; lines.push(`# ${cfg.title}`); lines.push(''); lines.push(`> ${blockquote}`); lines.push(''); if (context) { lines.push(context); lines.push(''); } for (const [section, items] of grouped) { lines.push(`## ${section}`); lines.push(''); for (const entry of items) { const filePath = linkToFilePath(entry.link, lang); const url = linkToSiteUrl(entry.link, lang); // Текст ссылки берём из sidebar — он специально написан для навигации // и точнее отражает иерархию (например "Обзор" внутри группы "Архитектура"). let description: string | null = null; if (fs.existsSync(filePath)) { const raw = fs.readFileSync(filePath, 'utf8'); const { data, body } = parseFrontmatter(raw); description = data.description || firstParagraphAfterH1(body); } else { console.warn(`[${lang}] файл не найден: ${filePath}`); } const display = entry.prefix ? `${entry.prefix}: ${entry.text}` : entry.text; const descPart = description ? `: ${description}` : ''; lines.push(`- [${display}](${url})${descPart}`); } lines.push(''); } const outDir = path.join(PUBLIC_DIR, lang); fs.mkdirSync(outDir, { recursive: true }); const outFile = path.join(outDir, 'llms.txt'); fs.writeFileSync(outFile, lines.join('\n'), 'utf8'); console.log(`${outFile} создан`); }; /** * Корневой `/llms.txt` — роутер. По стандарту llmstxt.org это * единственный файл в корне сайта; для двуязычного проекта он * указывает LLM на локализованные карты документации. */ const buildRootIndex = (): void => { const cfg = config as unknown as { title: string; description: string; locales: Record; }; const ruDesc = cfg.locales.ru?.description ?? cfg.description; const enDesc = cfg.locales.en?.description ?? cfg.description; const lines: string[] = [ `# ${cfg.title}`, '', `> ${enDesc}.`, '', '## Documentation', '', `- [Русская версия (Russian)](/ru/llms.txt): ${ruDesc}.`, '- English version: in development', '', ]; fs.mkdirSync(PUBLIC_DIR, { recursive: true }); const outFile = path.join(PUBLIC_DIR, 'llms.txt'); fs.writeFileSync(outFile, lines.join('\n'), 'utf8'); console.log(`${outFile} создан`); }; /** Рекурсивно скопировать дерево, фильтруя по предикату. */ const copyDirSync = ( src: string, dest: string, filter: (name: string) => boolean = () => true, ): number => { let count = 0; for (const entry of fs.readdirSync(src, { withFileTypes: true })) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { fs.mkdirSync(destPath, { recursive: true }); count += copyDirSync(srcPath, destPath, filter); } else if (entry.isFile() && filter(entry.name)) { fs.mkdirSync(dest, { recursive: true }); fs.copyFileSync(srcPath, destPath); count++; } } return count; }; /** * Скопировать все `.md`-файлы локали в `docs/public/{lang}/`, * чтобы они попали в build `dist/` и были доступны по URL `/lang/path.md`. */ const copyMdFiles = (lang: Lang): void => { const srcDir = path.join('docs', lang); const destDir = path.join(PUBLIC_DIR, lang); if (!fs.existsSync(srcDir)) return; const copied = copyDirSync(srcDir, destDir, (name) => name.endsWith('.md')); console.log(`[${lang}] скопировано ${copied} .md-файлов в ${destDir}`); }; /** * Собрать `nextjs-style-guide-{lang}.zip` со всеми `.md` локали и `VERSION`. * Внутри архива — единая папка `nextjs-style-guide/`. * * `llms.txt` в архив не кладём: его ссылки указывают на сайт и локально * не работают. Структура папки сама по себе является картой документации. */ const buildZip = (lang: Lang): void => { const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'nsg-')); const stage = path.join(tmpRoot, 'nextjs-style-guide'); fs.mkdirSync(stage, { recursive: true }); copyDirSync(path.join('docs', lang), stage, (name) => name.endsWith('.md')); fs.writeFileSync( path.join(stage, 'VERSION'), `${VERSION}\n${BUILD_DATE}\n`, ); const outFile = path.resolve( PUBLIC_DIR, `nextjs-style-guide-${lang}.zip`, ); fs.rmSync(outFile, { force: true }); execFileSync('zip', ['-rq', outFile, 'nextjs-style-guide'], { cwd: tmpRoot, }); fs.rmSync(tmpRoot, { recursive: true, force: true }); console.log(`${outFile} создан (${VERSION})`); }; /** Манифест сборки — для лендинга и внешних потребителей. */ const writeManifest = (): void => { const manifest = { version: VERSION, buildDate: BUILD_DATE, languages: { ru: { llms: '/ru/llms.txt', zip: '/nextjs-style-guide-ru.zip', }, en: { llms: '/en/llms.txt', zip: '/nextjs-style-guide-en.zip', }, }, }; fs.mkdirSync(PUBLIC_DIR, { recursive: true }); fs.writeFileSync( path.join(PUBLIC_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8', ); console.log(`${PUBLIC_DIR}/manifest.json создан`); }; /** Скопировать `index.md` локали в корневой README без frontmatter */ const buildReadme = (lang: Lang, outFile: string): void => { const indexPath = path.join('docs', lang, 'index.md'); if (!fs.existsSync(indexPath)) { console.warn(`Пропуск ${outFile}: ${indexPath} не найден`); return; } const raw = fs.readFileSync(indexPath, 'utf8'); const { body } = parseFrontmatter(raw); fs.writeFileSync(outFile, body.trimStart(), 'utf8'); console.log(`${outFile} обновлён из ${indexPath}`); }; buildLlms('ru'); buildLlms('en'); buildRootIndex(); copyMdFiles('ru'); copyMdFiles('en'); buildZip('ru'); buildZip('en'); writeManifest(); buildReadme('en', 'README.md'); buildReadme('ru', 'README_RU.md');