Files
nextjs-style-guide/generate-llms.ts
S.Gromov 7c224fed99 fix(generate-llms): корректные ссылки в корневом README
- ссылки /docs/... преобразованы в относительные пути docs/docs/*.md
- корневые ресурсы (/llms.txt, *.zip и т.п.) переписаны на абсолютный SITE_URL
- после описания добавлена строка с URL сайта
2026-04-27 12:04:31 +03:00

602 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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/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<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;
};
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`.
*/
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(`скопировано ${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);
};
/**
* Сгенерировать `README.md` — точка входа архива. Карта документации
* с относительными ссылками, описаниями из frontmatter/первого абзаца
* и метаинфо сборки.
*/
const buildArchiveReadme = (rootDir: string): void => {
const sidebar = cfg.themeConfig.sidebar;
const blockquote = cfg.llmsBlockquote ?? cfg.description ?? '';
const context = cfg.llmsContext;
const entries = flattenSidebar(sidebar).filter(
// «Главная» из sidebar — это страница раздела для веба, в архиве не нужна.
(e) => e.section !== 'Главная',
);
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('');
}
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);
const filePath = path.join(rootDir, linkToArchiveRel(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);
}
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.zip`. Внутри — папка `nextjs-style-guide/`
* с `.md`-файлами, README, `llms-full.txt` и `VERSION`. Внутренние ссылки
* преобразуются в относительные.
*/
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('docs/docs', stage, (name) => name.endsWith('.md'));
// 2. Удаляем веб-index.md — в архиве его роль выполняет README.md.
const indexPath = path.join(stage, 'index.md');
if (fs.existsSync(indexPath)) fs.unlinkSync(indexPath);
// 3. Преобразуем абсолютные ссылки `/docs/...` в относительные.
transformLinksInDir(stage);
// 4. Генерируем точку входа README.md.
buildArchiveReadme(stage);
// 5. Кладём llms-full.txt — удобно для одноразового чтения LLM.
const llmsFullSrc = path.join(PUBLIC_DIR, '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.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(`<!-- ${entry.link} -->`);
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<string>();
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 = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
...[...urls].map(
(loc) => ` <url><loc>${loc}</loc><lastmod>${today}</lastmod></url>`,
),
'</urlset>',
'',
].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();