Files
nextjs-style-guide/generate-llms.ts
S.Gromov e835210d6d
All checks were successful
CI/CD Pipeline / docker (push) Successful in 45s
CI/CD Pipeline / deploy (push) Successful in 7s
docs: переработать раздел REST-клиента и стратегий получения данных
- Добавлен обзор REST с разделением на «Создание клиента» и «Использование»
- Добавлена страница создания клиента с описанием структуры модуля
- Переписана автогенерация: npx без --swr, расширения типов вынесены в types/
- Ручной клиент сокращён до шаблона по файлам
- Добавлены GET-хуки REST-клиента с контрактом useGet..., key-функциями и isReady
- Добавлена страница выбора стратегий с приоритетом ISR перед SSR
- Добавлены стратегии: серверный await, параллельные запросы, передача промиса,
  начальные данные для клиентских хуков, клиентский GET-хук, business-композиция
- Уточнено влияние серверного await и SWR fallback на режим рендера
- Удалены устаревшие страницы fetching/server.md и fetching/client.md
- Обновлён generate-llms.ts: очистка stale-файлов перед копированием
- Обновлены сайдбар, MAP.md, data/index.md, page-level.md
2026-04-30 16:01:18 +03:00

545 lines
18 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()) {
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;
fs.rmSync(destDir, { recursive: true, force: true });
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 => {
fs.rmSync(path.resolve(PUBLIC_DIR, 'nextjs-style-guide'), { recursive: true, force: true });
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(`<!-- ${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();