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}`); }; /** * Преобразовать sidebar `link` в относительный путь файла внутри архива * (от корня папки `nextjs-style-guide/`). Это путь, по которому файл лежит * в распакованной папке, без расширения добавляется `.md`. */ const linkToArchiveRel = (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; }; /** * Заменить во всех `.md` архива ссылки `[text](/ru/foo)` на относительные * пути от расположения файла. Без этого внутренние ссылки в распакованной * папке не работают. */ const transformLinksInDir = (rootDir: string, lang: Lang): void => { const linkRe = /\]\(\/([a-z]{2})\/([^)\s#]*)(#[^)]*)?\)/g; const walk = (dir: string): void => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { walk(full); continue; } if (!entry.isFile() || !entry.name.endsWith('.md')) continue; const content = fs.readFileSync(full, 'utf8'); const fileDir = path.dirname(full); const updated = content.replace(linkRe, (match, urlLang, route, hash = '') => { // Ссылки на другую локаль не трогаем — её в архиве нет. if (urlLang !== lang) return match; const fakeLink = `/${urlLang}/${route}${route.endsWith('/') ? '' : ''}`; const targetRel = linkToArchiveRel(fakeLink, lang); const targetAbs = path.join(rootDir, targetRel); let rel = path.relative(fileDir, targetAbs); if (!rel.startsWith('.')) rel = './' + rel; return `](${rel}${hash})`; }); if (updated !== content) { fs.writeFileSync(full, updated, 'utf8'); } } }; walk(rootDir); }; /** * Сгенерировать `README.md` — точка входа архива. Карта документации * с относительными ссылками, описаниями из frontmatter/первого абзаца * и метаинфо сборки. */ const buildArchiveReadme = (lang: Lang, rootDir: string): void => { const cfg = config as unknown as { title: string; locales: Record< string, { description?: string; llmsBlockquote?: string; llmsContext?: string; themeConfig?: { sidebar?: SidebarItem[] }; } >; }; const locale = cfg.locales[lang]; const sidebar = locale?.themeConfig?.sidebar; if (!sidebar) return; const blockquote = locale.llmsBlockquote ?? locale.description ?? ''; const context = locale.llmsContext; const entries = flattenSidebar(sidebar).filter( // «Главная» из sidebar — это страница локали для веба, в архиве не нужна. (e) => !(e.section === 'Главная' || e.section === 'Home'), ); const grouped = groupBySection(entries); const lines: string[] = []; lines.push(`# ${cfg.title}`); lines.push(''); if (blockquote) { lines.push(`> ${blockquote}`); lines.push(''); } if (context) { lines.push(context); lines.push(''); } const heading = lang === 'ru' ? 'Содержание' : 'Contents'; lines.push(`## ${heading}`); lines.push(''); for (const [section, items] of grouped) { lines.push(`### ${section}`); lines.push(''); for (const entry of items) { const targetRel = './' + linkToArchiveRel(entry.link, lang); const filePath = path.join(rootDir, linkToArchiveRel(entry.link, lang)); 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); } const display = entry.prefix ? `${entry.prefix}: ${entry.text}` : entry.text; const descPart = description ? ` — ${description}` : ''; lines.push(`- [${display}](${targetRel})${descPart}`); } lines.push(''); } lines.push('---'); lines.push(''); lines.push(`Версия: ${VERSION} · Сборка: ${BUILD_DATE}`); lines.push(''); fs.writeFileSync(path.join(rootDir, 'README.md'), lines.join('\n'), 'utf8'); }; /** * Собрать `nextjs-style-guide-{lang}.zip`. Внутри архива — единая папка * `nextjs-style-guide/` с `.md`-файлами локали, README-точкой входа, * `llms-full.txt` и `VERSION`. Внутренние ссылки в `.md` преобразуются * в относительные. * * Веб-`index.md` локали из архива удаляется — его роль выполняет README.md. */ 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 }); // 1. Копируем все .md локали в staging. copyDirSync(path.join('docs', lang), stage, (name) => name.endsWith('.md')); // 2. Удаляем веб-index.md локали — в архиве он избыточен. const indexPath = path.join(stage, 'index.md'); if (fs.existsSync(indexPath)) fs.unlinkSync(indexPath); // 3. Преобразуем абсолютные ссылки `/ru/...` в относительные. transformLinksInDir(stage, lang); // 4. Генерируем точку входа README.md. buildArchiveReadme(lang, stage); // 5. Кладём llms-full.txt — удобно для одноразового чтения LLM. const llmsFullSrc = path.join(PUBLIC_DIR, lang, 'llms-full.txt'); if (fs.existsSync(llmsFullSrc)) { fs.copyFileSync(llmsFullSrc, path.join(stage, 'llms-full.txt')); } // 6. Метаинформация сборки. 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})`); }; /** Удалить YAML frontmatter из исходника `.md`. */ const stripFrontmatter = (content: string): string => content.replace(/^---\n[\s\S]*?\n---\n*/, ''); /** * Сдвинуть уровень заголовков на 1 вниз (h1→h2, h2→h3, ...). * Игнорирует строки внутри блоков кода. */ const shiftHeadings = (content: string): string => { const lines = content.split('\n'); let inCodeBlock = false; return lines .map((line) => { if (line.startsWith('```')) inCodeBlock = !inCodeBlock; if (inCodeBlock) return line; if (/^#{1,5}\s/.test(line)) return '#' + line; return line; }) .join('\n'); }; /** * Собрать `llms-full.txt` — все страницы локали в одном файле. * Порядок страниц повторяет порядок в sidebar. */ const buildLlmsFull = (lang: Lang): void => { const cfg = config as unknown as { title: string; locales: Record< string, { description?: string; llmsBlockquote?: string; llmsContext?: string; themeConfig?: { sidebar?: SidebarItem[] }; } >; }; const locale = cfg.locales[lang]; const sidebar = locale?.themeConfig?.sidebar; if (!sidebar) return; const entries = flattenSidebar(sidebar); const blockquote = locale.llmsBlockquote ?? locale.description ?? ''; const parts: string[] = []; parts.push(`# ${cfg.title}`); parts.push(''); if (blockquote) parts.push(`> ${blockquote}`); if (locale.llmsContext) { parts.push(''); parts.push(locale.llmsContext); } parts.push(''); for (const entry of entries) { const filePath = linkToFilePath(entry.link, lang); if (!fs.existsSync(filePath)) continue; const raw = fs.readFileSync(filePath, 'utf8'); const content = shiftHeadings(stripFrontmatter(raw)).trim(); if (!content) continue; // Мета-якорь: путь страницы для ориентации LLM parts.push(``); parts.push(''); parts.push(content); parts.push(''); } const outDir = path.join(PUBLIC_DIR, lang); fs.mkdirSync(outDir, { recursive: true }); const outFile = path.join(outDir, 'llms-full.txt'); fs.writeFileSync(outFile, parts.join('\n'), 'utf8'); console.log(`${outFile} создан`); }; /** Манифест сборки — для лендинга и внешних потребителей. */ const writeManifest = (): void => { const manifest = { version: VERSION, buildDate: BUILD_DATE, languages: { ru: { llms: '/ru/llms.txt', llmsFull: '/ru/llms-full.txt', zip: '/nextjs-style-guide-ru.zip', }, en: { llms: '/en/llms.txt', llmsFull: '/en/llms-full.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'); buildLlmsFull('ru'); buildLlmsFull('en'); buildRootIndex(); copyMdFiles('ru'); copyMdFiles('en'); buildZip('ru'); buildZip('en'); writeManifest(); buildReadme('en', 'README.md'); buildReadme('ru', 'README_RU.md');