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'; /** Префикс URL документации. Соответствует структуре `docs/docs/...`. */ const DOC_PREFIX = '/docs/'; /** Канонический хост сайта (для sitemap/robots). Можно переопределить через ENV. */ const SITE_URL = (process.env.SITE_URL || 'https://nextjs-style-guide.gromlab.ru').replace(/\/$/, ''); 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/foo`) в относительный * путь файла внутри `docs/docs/`. Префикс `/docs/` отрезается. */ const linkToRel = (link: string): string => { let rel = link.startsWith(DOC_PREFIX) ? link.slice(DOC_PREFIX.length) : link.replace(/^\//, ''); if (rel === '' || rel.endsWith('/')) { rel += 'index.md'; } else { rel += '.md'; } return rel; }; const linkToFilePath = (link: string): string => path.join('docs/docs', linkToRel(link)); /** Абсолютный URL `.md`-копии страницы на сайте. */ const linkToSiteUrl = (link: string): string => `${DOC_PREFIX}${linkToRel(link)}`; /** * Развернуть sidebar в плоский список с сохранением группы и * накопленного префикса вложенных групп. Поддерживает произвольную * глубину вложенности — префиксы подгрупп склеиваются через `: `. */ const flattenSidebar = (sidebar: SidebarItem[]): Entry[] => { const entries: Entry[] = []; const walk = ( items: SidebarItem[], section: string, prefix: string | null, ): void => { for (const item of items) { const hasChildren = !!item.items && item.items.length > 0; if (item.link) { entries.push({ section, prefix, text: item.text, link: item.link }); } if (hasChildren) { const nextPrefix = prefix ? `${prefix}: ${item.text}` : item.text; walk(item.items!, section, nextPrefix); } } }; for (const top of sidebar) { const hasChildren = !!top.items && top.items.length > 0; if (top.link && !hasChildren) { entries.push({ section: top.text, prefix: null, text: top.text, link: top.link }); continue; } if (hasChildren) { walk(top.items!, top.text, null); } } 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; }; interface SiteConfig { title: string; description: string; themeConfig: { sidebar: SidebarItem[] }; llmsBlockquote?: string; llmsContext?: string; } const cfg = config as unknown as SiteConfig; const buildLlms = (): void => { const sidebar = cfg.themeConfig.sidebar; const blockquote = cfg.llmsBlockquote ?? cfg.description; const context = cfg.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); const url = linkToSiteUrl(entry.link); 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(`файл не найден: ${filePath}`); } const display = entry.prefix ? `${entry.prefix}: ${entry.text}` : entry.text; const descPart = description ? `: ${description}` : ''; lines.push(`- [${display}](${url})${descPart}`); } lines.push(''); } 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/docs/`, * чтобы они попали в build `dist/` и были доступны по URL `/docs/path.md`. * * `DEVELOP.md` исключается — это точка входа архива, ссылается * на офлайн-файлы (`MAP.md`), которых нет на сайте. */ const copyMdFiles = (): void => { const srcDir = 'docs/docs'; const destDir = path.join(PUBLIC_DIR, 'docs'); if (!fs.existsSync(srcDir)) return; const copied = copyDirSync( srcDir, destDir, (name) => name.endsWith('.md') && name !== 'DEVELOP.md', ); console.log(`скопировано ${copied} .md-файлов в ${destDir}`); }; /** * Преобразовать sidebar `link` в относительный путь файла внутри архива * (от корня папки `nextjs-style-guide/`). */ const linkToArchiveRel = (link: string): string => { let rel = link.startsWith(DOC_PREFIX) ? link.slice(DOC_PREFIX.length) : link.replace(/^\//, ''); if (rel === '' || rel.endsWith('/')) { rel += 'index.md'; } else { rel += '.md'; } return rel; }; /** * Заменить во всех `.md` архива ссылки `[text](/docs/foo)` на относительные * пути от расположения файла. Без этого внутренние ссылки в распакованной * папке не работают. */ const transformLinksInDir = (rootDir: string): void => { const linkRe = /\]\(\/docs\/([^)\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, route, hash = '') => { const targetRel = linkToArchiveRel(`${DOC_PREFIX}${route}`); 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); }; /** * Собрать `nextjs-style-guide.zip`. Внутри — папка `nextjs-style-guide/` * с `.md`-файлами, DEVELOP.md (точка входа), MAP.md (навигационная карта) * и `VERSION`. Внутренние ссылки преобразуются в относительные. * * Точка входа архива — `docs/docs/DEVELOP.md`, навигационная карта — * `docs/docs/MAP.md`. Оба файла редактируются вручную и копируются * в архив как есть. */ const buildZip = (): 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 (включая DEVELOP.md и MAP.md). copyDirSync('docs/docs', stage, (name) => name.endsWith('.md')); // 2. Удаляем веб-index.md — в архиве его роль выполняет DEVELOP.md. const indexPath = path.join(stage, 'index.md'); if (fs.existsSync(indexPath)) fs.unlinkSync(indexPath); // 3. Преобразуем абсолютные ссылки `/docs/...` в относительные. transformLinksInDir(stage); // 4. Метаинформация сборки. fs.writeFileSync( path.join(stage, 'VERSION'), `${VERSION}\n${BUILD_DATE}\n`, ); const outFile = path.resolve(PUBLIC_DIR, 'nextjs-style-guide.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 = (): void => { const sidebar = cfg.themeConfig.sidebar; const entries = flattenSidebar(sidebar); const blockquote = cfg.llmsBlockquote ?? cfg.description ?? ''; const parts: string[] = []; parts.push(`# ${cfg.title}`); parts.push(''); if (blockquote) parts.push(`> ${blockquote}`); if (cfg.llmsContext) { parts.push(''); parts.push(cfg.llmsContext); } parts.push(''); for (const entry of entries) { const filePath = linkToFilePath(entry.link); 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(''); } fs.mkdirSync(PUBLIC_DIR, { recursive: true }); const outFile = path.join(PUBLIC_DIR, 'llms-full.txt'); fs.writeFileSync(outFile, parts.join('\n'), 'utf8'); console.log(`${outFile} создан`); }; /** Манифест сборки — для лендинга и внешних потребителей. */ const writeManifest = (): void => { const manifest = { version: VERSION, buildDate: BUILD_DATE, llms: '/llms.txt', llmsFull: '/llms-full.txt', zip: '/nextjs-style-guide.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 создан`); }; /** * Сгенерировать `robots.txt` с указанием sitemap и явными ссылками * на llms.txt/llms-full.txt — стандартные файлы, которые читают агенты. */ const buildRobots = (): void => { const lines = [ 'User-agent: *', 'Allow: /', '', `Sitemap: ${SITE_URL}/sitemap.xml`, '', '# Карта документации для AI-агентов:', `# ${SITE_URL}/llms.txt`, `# ${SITE_URL}/llms-full.txt`, '', ]; fs.mkdirSync(PUBLIC_DIR, { recursive: true }); fs.writeFileSync(path.join(PUBLIC_DIR, 'robots.txt'), lines.join('\n'), 'utf8'); console.log(`${PUBLIC_DIR}/robots.txt создан`); }; /** * Сгенерировать `sitemap.xml` из sidebar + корневые ресурсы для LLM * (llms.txt, llms-full.txt) — чтобы агенты, читающие sitemap, видели их. */ const buildSitemap = (): void => { const sidebar = cfg.themeConfig.sidebar; const entries = flattenSidebar(sidebar); const urls = new Set(); urls.add(`${SITE_URL}/`); urls.add(`${SITE_URL}/llms.txt`); urls.add(`${SITE_URL}/llms-full.txt`); for (const entry of entries) { const link = entry.link; // cleanUrls: канон без `.html`. Index-страницы — каталог со слешем. urls.add(`${SITE_URL}${link}`); } const today = BUILD_DATE.slice(0, 10); const xml = [ '', '', ...[...urls].map( (loc) => ` ${loc}${today}`, ), '', '', ].join('\n'); fs.mkdirSync(PUBLIC_DIR, { recursive: true }); fs.writeFileSync(path.join(PUBLIC_DIR, 'sitemap.xml'), xml, 'utf8'); console.log(`${PUBLIC_DIR}/sitemap.xml создан`); }; /** * Преобразовать абсолютные ссылки `index.md` в рабочие при открытии * README в репозитории: * - `/docs/foo` → относительный путь `docs/docs/foo.md`; * - корневые ресурсы (`/llms.txt`, `/llms-full.txt`, `*.zip`, `/manifest.json`, * `/sitemap.xml`, `/robots.txt`) — генерируемые, в репозитории отсутствуют, * поэтому ссылки переписываются на абсолютный `SITE_URL`. */ const transformReadmeLinks = (content: string): string => { const linkRe = /\]\((\/[^)\s]*)\)/g; return content.replace(linkRe, (match, href: string) => { const [pathPart, hash = ''] = href.split('#'); const hashPart = hash ? `#${hash}` : ''; if (pathPart.startsWith(DOC_PREFIX)) { const rel = linkToArchiveRel(pathPart); return `](docs/docs/${rel}${hashPart})`; } return `](${SITE_URL}${pathPart}${hashPart})`; }); }; /** Скопировать `index.md` документации в корневой README без frontmatter. */ const buildReadme = (): void => { const indexPath = 'docs/docs/index.md'; if (!fs.existsSync(indexPath)) { console.warn(`Пропуск README.md: ${indexPath} не найден`); return; } const raw = fs.readFileSync(indexPath, 'utf8'); const { body } = parseFrontmatter(raw); const transformed = transformReadmeLinks(body.trimStart()); // Порядок: H1 → описание (первый абзац) → ссылка на сайт. const withSiteLink = transformed.replace( /^(#\s[^\n]*\n\n[^\n]+\n)/, `$1\nСайт: ${SITE_URL}\n`, ); fs.writeFileSync('README.md', withSiteLink, 'utf8'); console.log(`README.md обновлён из ${indexPath}`); }; buildLlms(); buildLlmsFull(); copyMdFiles(); buildZip(); writeManifest(); buildRobots(); buildSitemap(); buildReadme();