refactor: удалить английскую локаль и упростить структуру
All checks were successful
CI/CD Pipeline / docker (push) Successful in 48s
CI/CD Pipeline / deploy (push) Successful in 7s

- Удалена английская версия документации (docs/en/) и артефакты en
- Контент перенесён docs/ru/ → docs/docs/, URL /ru/ заменён на /docs/
- Из .vitepress/config.ts убраны locales и enSidebar, оставлен один sidebar
- Из лендинга удалён переключатель языка ru/en и en-словарь
- generate-llms.ts переписан без параметра lang; llms.txt, llms-full.txt
  и nextjs-style-guide.zip генерируются в корень docs/public/
- README_RU.md занял место корневого README.md
- Обновлены CONTRIBUTING.md, custom.css, комментарий в Dockerfile
This commit is contained in:
2026-04-26 15:04:10 +03:00
parent 90bf360c06
commit f645b2ad40
70 changed files with 263 additions and 901 deletions

View File

@@ -11,7 +11,8 @@ const BUILD_DATE = new Date().toISOString();
/** Корневая папка для генерируемой статики (попадает в build dist). */
const PUBLIC_DIR = 'docs/public';
type Lang = 'ru' | 'en';
/** Префикс URL документации. Соответствует структуре `docs/docs/...`. */
const DOC_PREFIX = '/docs/';
interface SidebarItem {
text: string;
@@ -76,13 +77,13 @@ const firstParagraphAfterH1 = (body: string): string | null => {
};
/**
* Преобразовать sidebar `link` в относительный путь файла внутри
* `docs/{lang}/`. Sidebar links содержат полный префикс локали
* (`/ru/...`, `/en/...`) — отрезаем его.
* Преобразовать sidebar `link` (например `/docs/foo`) в относительный
* путь файла внутри `docs/docs/`. Префикс `/docs/` отрезается.
*/
const linkToRel = (link: string, lang: Lang): string => {
const prefix = `/${lang}/`;
let rel = link.startsWith(prefix) ? link.slice(prefix.length) : link.replace(/^\//, '');
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 {
@@ -91,15 +92,12 @@ const linkToRel = (link: string, lang: Lang): string => {
return rel;
};
const linkToFilePath = (link: string, lang: Lang): string =>
path.join('docs', lang, linkToRel(link, lang));
const linkToFilePath = (link: string): string =>
path.join('docs/docs', linkToRel(link));
/**
* Абсолютный путь от корня сайта к `.md`-копии страницы.
* После build файлы лежат в `dist/{lang}/...md` (через `docs/public/`).
*/
const linkToSiteUrl = (link: string, lang: Lang): string =>
`/${lang}/${linkToRel(link, lang)}`;
/** Абсолютный URL `.md`-копии страницы на сайте. */
const linkToSiteUrl = (link: string): string =>
`${DOC_PREFIX}${linkToRel(link)}`;
/**
* Развернуть sidebar в плоский список с сохранением группы и
@@ -153,34 +151,20 @@ const groupBySection = (entries: Entry[]): Map<string, 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[] };
}
>;
};
interface SiteConfig {
title: string;
description: string;
themeConfig: { sidebar: SidebarItem[] };
llmsBlockquote?: string;
llmsContext?: string;
}
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 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);
@@ -200,19 +184,16 @@ const buildLlms = (lang: Lang): void => {
lines.push('');
for (const entry of items) {
const filePath = linkToFilePath(entry.link, lang);
const url = linkToSiteUrl(entry.link, lang);
const filePath = linkToFilePath(entry.link);
const url = linkToSiteUrl(entry.link);
// Текст ссылки берём из 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}`);
console.warn(`файл не найден: ${filePath}`);
}
const display = entry.prefix
@@ -225,40 +206,6 @@ const buildLlms = (lang: Lang): void => {
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');
@@ -288,26 +235,26 @@ const copyDirSync = (
};
/**
* Скопировать все `.md`-файлы локали в `docs/public/{lang}/`,
* чтобы они попали в build `dist/` и были доступны по URL `/lang/path.md`.
* Скопировать все `.md`-файлы документации в `docs/public/docs/`,
* чтобы они попали в build `dist/` и были доступны по URL `/docs/path.md`.
*/
const copyMdFiles = (lang: Lang): void => {
const srcDir = path.join('docs', lang);
const destDir = path.join(PUBLIC_DIR, lang);
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'));
console.log(`[${lang}] скопировано ${copied} .md-файлов в ${destDir}`);
console.log(`скопировано ${copied} .md-файлов в ${destDir}`);
};
/**
* Преобразовать sidebar `link` в относительный путь файла внутри архива
* (от корня папки `nextjs-style-guide/`). Это путь, по которому файл лежит
* в распакованной папке, без расширения добавляется `.md`.
* (от корня папки `nextjs-style-guide/`).
*/
const linkToArchiveRel = (link: string, lang: Lang): string => {
const prefix = `/${lang}/`;
let rel = link.startsWith(prefix) ? link.slice(prefix.length) : link.replace(/^\//, '');
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 {
@@ -317,12 +264,12 @@ const linkToArchiveRel = (link: string, lang: Lang): string => {
};
/**
* Заменить во всех `.md` архива ссылки `[text](/ru/foo)` на относительные
* Заменить во всех `.md` архива ссылки `[text](/docs/foo)` на относительные
* пути от расположения файла. Без этого внутренние ссылки в распакованной
* папке не работают.
*/
const transformLinksInDir = (rootDir: string, lang: Lang): void => {
const linkRe = /\]\(\/([a-z]{2})\/([^)\s#]*)(#[^)]*)?\)/g;
const transformLinksInDir = (rootDir: string): void => {
const linkRe = /\]\(\/docs\/([^)\s#]*)(#[^)]*)?\)/g;
const walk = (dir: string): void => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -336,12 +283,8 @@ const transformLinksInDir = (rootDir: string, lang: Lang): void => {
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 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;
@@ -362,29 +305,14 @@ const transformLinksInDir = (rootDir: string, lang: Lang): void => {
* с относительными ссылками, описаниями из 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 buildArchiveReadme = (rootDir: string): void => {
const sidebar = cfg.themeConfig.sidebar;
const blockquote = cfg.llmsBlockquote ?? cfg.description ?? '';
const context = cfg.llmsContext;
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'),
// «Главная» из sidebar — это страница раздела для веба, в архиве не нужна.
(e) => e.section !== 'Главная',
);
const grouped = groupBySection(entries);
@@ -400,16 +328,15 @@ const buildArchiveReadme = (lang: Lang, rootDir: string): void => {
lines.push('');
}
const heading = lang === 'ru' ? 'Содержание' : 'Contents';
lines.push(`## ${heading}`);
lines.push('## Содержание');
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));
const targetRel = './' + linkToArchiveRel(entry.link);
const filePath = path.join(rootDir, linkToArchiveRel(entry.link));
let description: string | null = null;
if (fs.existsSync(filePath)) {
@@ -436,33 +363,30 @@ const buildArchiveReadme = (lang: Lang, rootDir: string): void => {
};
/**
* Собрать `nextjs-style-guide-{lang}.zip`. Внутри архива — единая папка
* `nextjs-style-guide/` с `.md`-файлами локали, README-точкой входа,
* `llms-full.txt` и `VERSION`. Внутренние ссылки в `.md` преобразуются
* в относительные.
*
* Веб-`index.md` локали из архива удаляется — его роль выполняет README.md.
* Собрать `nextjs-style-guide.zip`. Внутри — папка `nextjs-style-guide/`
* с `.md`-файлами, README, `llms-full.txt` и `VERSION`. Внутренние ссылки
* преобразуются в относительные.
*/
const buildZip = (lang: Lang): void => {
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.
copyDirSync(path.join('docs', lang), stage, (name) => name.endsWith('.md'));
// 1. Копируем все .md в staging.
copyDirSync('docs/docs', stage, (name) => name.endsWith('.md'));
// 2. Удаляем веб-index.md локали — в архиве он избыточен.
// 2. Удаляем веб-index.md — в архиве его роль выполняет README.md.
const indexPath = path.join(stage, 'index.md');
if (fs.existsSync(indexPath)) fs.unlinkSync(indexPath);
// 3. Преобразуем абсолютные ссылки `/ru/...` в относительные.
transformLinksInDir(stage, lang);
// 3. Преобразуем абсолютные ссылки `/docs/...` в относительные.
transformLinksInDir(stage);
// 4. Генерируем точку входа README.md.
buildArchiveReadme(lang, stage);
buildArchiveReadme(stage);
// 5. Кладём llms-full.txt — удобно для одноразового чтения LLM.
const llmsFullSrc = path.join(PUBLIC_DIR, lang, 'llms-full.txt');
const llmsFullSrc = path.join(PUBLIC_DIR, 'llms-full.txt');
if (fs.existsSync(llmsFullSrc)) {
fs.copyFileSync(llmsFullSrc, path.join(stage, 'llms-full.txt'));
}
@@ -473,10 +397,7 @@ const buildZip = (lang: Lang): void => {
`${VERSION}\n${BUILD_DATE}\n`,
);
const outFile = path.resolve(
PUBLIC_DIR,
`nextjs-style-guide-${lang}.zip`,
);
const outFile = path.resolve(PUBLIC_DIR, 'nextjs-style-guide.zip');
fs.rmSync(outFile, { force: true });
execFileSync('zip', ['-rq', outFile, 'nextjs-style-guide'], {
@@ -509,42 +430,26 @@ const shiftHeadings = (content: string): string => {
};
/**
* Собрать `llms-full.txt` — все страницы локали в одном файле.
* Собрать `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 buildLlmsFull = (): void => {
const sidebar = cfg.themeConfig.sidebar;
const entries = flattenSidebar(sidebar);
const blockquote = locale.llmsBlockquote ?? locale.description ?? '';
const blockquote = cfg.llmsBlockquote ?? cfg.description ?? '';
const parts: string[] = [];
parts.push(`# ${cfg.title}`);
parts.push('');
if (blockquote) parts.push(`> ${blockquote}`);
if (locale.llmsContext) {
if (cfg.llmsContext) {
parts.push('');
parts.push(locale.llmsContext);
parts.push(cfg.llmsContext);
}
parts.push('');
for (const entry of entries) {
const filePath = linkToFilePath(entry.link, lang);
const filePath = linkToFilePath(entry.link);
if (!fs.existsSync(filePath)) continue;
const raw = fs.readFileSync(filePath, 'utf8');
@@ -558,9 +463,8 @@ const buildLlmsFull = (lang: Lang): void => {
parts.push('');
}
const outDir = path.join(PUBLIC_DIR, lang);
fs.mkdirSync(outDir, { recursive: true });
const outFile = path.join(outDir, 'llms-full.txt');
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} создан`);
};
@@ -570,18 +474,9 @@ 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',
},
},
llms: '/llms.txt',
llmsFull: '/llms-full.txt',
zip: '/nextjs-style-guide.zip',
};
fs.mkdirSync(PUBLIC_DIR, { recursive: true });
fs.writeFileSync(
@@ -592,28 +487,22 @@ const writeManifest = (): void => {
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');
/** Скопировать `index.md` документации в корневой README без frontmatter. */
const buildReadme = (): void => {
const indexPath = 'docs/docs/index.md';
if (!fs.existsSync(indexPath)) {
console.warn(`Пропуск ${outFile}: ${indexPath} не найден`);
console.warn(`Пропуск README.md: ${indexPath} не найден`);
return;
}
const raw = fs.readFileSync(indexPath, 'utf8');
const { body } = parseFrontmatter(raw);
fs.writeFileSync(outFile, body.trimStart(), 'utf8');
console.log(`${outFile} обновлён из ${indexPath}`);
fs.writeFileSync('README.md', body.trimStart(), 'utf8');
console.log(`README.md обновлён из ${indexPath}`);
};
buildLlms('ru');
buildLlms('en');
buildLlmsFull('ru');
buildLlmsFull('en');
buildRootIndex();
copyMdFiles('ru');
copyMdFiles('en');
buildZip('ru');
buildZip('en');
buildLlms();
buildLlmsFull();
copyMdFiles();
buildZip();
writeManifest();
buildReadme('en', 'README.md');
buildReadme('ru', 'README_RU.md');
buildReadme();