2026-04-25 18:06:27 +03:00
|
|
|
|
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<string, string>; body: string } => {
|
|
|
|
|
|
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
|
|
|
|
if (!match) return { data: {}, body: content };
|
|
|
|
|
|
|
|
|
|
|
|
const data: Record<string, string> = {};
|
|
|
|
|
|
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<string, Entry[]> => {
|
|
|
|
|
|
const map = new Map<string, Entry[]>();
|
|
|
|
|
|
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<string, { description?: string }>;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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}`);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-25 19:56:44 +03:00
|
|
|
|
* Преобразовать 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` преобразуются
|
|
|
|
|
|
* в относительные.
|
2026-04-25 18:06:27 +03:00
|
|
|
|
*
|
2026-04-25 19:56:44 +03:00
|
|
|
|
* Веб-`index.md` локали из архива удаляется — его роль выполняет README.md.
|
2026-04-25 18:06:27 +03:00
|
|
|
|
*/
|
|
|
|
|
|
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 });
|
|
|
|
|
|
|
2026-04-25 19:56:44 +03:00
|
|
|
|
// 1. Копируем все .md локали в staging.
|
2026-04-25 18:06:27 +03:00
|
|
|
|
copyDirSync(path.join('docs', lang), stage, (name) => name.endsWith('.md'));
|
|
|
|
|
|
|
2026-04-25 19:56:44 +03:00
|
|
|
|
// 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. Метаинформация сборки.
|
2026-04-25 18:06:27 +03:00
|
|
|
|
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})`);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-25 19:56:44 +03:00
|
|
|
|
/** Удалить 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(`<!-- ${entry.link} -->`);
|
|
|
|
|
|
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} создан`);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-25 18:06:27 +03:00
|
|
|
|
/** Манифест сборки — для лендинга и внешних потребителей. */
|
|
|
|
|
|
const writeManifest = (): void => {
|
|
|
|
|
|
const manifest = {
|
|
|
|
|
|
version: VERSION,
|
|
|
|
|
|
buildDate: BUILD_DATE,
|
|
|
|
|
|
languages: {
|
|
|
|
|
|
ru: {
|
|
|
|
|
|
llms: '/ru/llms.txt',
|
2026-04-25 19:56:44 +03:00
|
|
|
|
llmsFull: '/ru/llms-full.txt',
|
2026-04-25 18:06:27 +03:00
|
|
|
|
zip: '/nextjs-style-guide-ru.zip',
|
|
|
|
|
|
},
|
|
|
|
|
|
en: {
|
|
|
|
|
|
llms: '/en/llms.txt',
|
2026-04-25 19:56:44 +03:00
|
|
|
|
llmsFull: '/en/llms-full.txt',
|
2026-04-25 18:06:27 +03:00
|
|
|
|
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');
|
2026-04-25 19:56:44 +03:00
|
|
|
|
buildLlmsFull('ru');
|
|
|
|
|
|
buildLlmsFull('en');
|
2026-04-25 18:06:27 +03:00
|
|
|
|
buildRootIndex();
|
|
|
|
|
|
copyMdFiles('ru');
|
|
|
|
|
|
copyMdFiles('en');
|
|
|
|
|
|
buildZip('ru');
|
|
|
|
|
|
buildZip('en');
|
|
|
|
|
|
writeManifest();
|
|
|
|
|
|
buildReadme('en', 'README.md');
|
|
|
|
|
|
buildReadme('ru', 'README_RU.md');
|