Files
slm-design/generate.ts
S.Gromov 1a1de7cad4
All checks were successful
CI/CD Pipeline / build (push) Successful in 16s
CI/CD Pipeline / version (push) Successful in 4s
CI/CD Pipeline / docker (push) Successful in 43s
CI/CD Pipeline / deploy (push) Successful in 7s
fix: исправить доступность артефактов документации
- добавлены HTML-подсказки для обнаружения llms.txt агентами
- обновлена карточка скачивания спецификации и архива
- добавлен раздел с порядком чтения спецификации
- исправлена генерация ссылок для single-file, Markdown и ZIP
- обновлены сгенерированные README.md и ARCHITECTURE.md
2026-05-02 18:51:44 +03:00

283 lines
9.2 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 "path";
import fs from "fs";
import os from "os";
import { execFileSync } from "child_process";
const SRC_DIR = "./docs";
const PUBLIC_DIR = "./public";
const DOCS_PUBLIC_DIR = path.join(SRC_DIR, "public");
const DOC_ROUTE_PREFIX = "/docs";
const PUBLIC_ARCHITECTURE_FILE = "ARCHITECTURE.md";
const PUBLIC_ARCHITECTURE_NOTICE = `> Локальная копия канонической спецификации SLM Design.
> Источник: https://slm-design.gromlab.ru/ARCHITECTURE.md
> Не редактировать вручную в этом проекте.`;
interface SidebarItem {
text: string;
link: string;
}
interface SidebarGroup {
text: string;
items: SidebarItem[];
}
function parseSidebar(): SidebarGroup[] {
const configPath = path.join(".vitepress", "config.ts");
const raw = fs.readFileSync(configPath, "utf8");
const start = raw.indexOf("const sidebar = [");
const end = raw.indexOf("];", start) + 2;
const section = raw.substring(start, end);
const groups: SidebarGroup[] = [];
const groupParts = section.split(/\n\s*\}\s*,?\s*\n/).filter(Boolean);
for (const part of groupParts) {
const textMatch = part.match(/text:\s*'([^']*)'/);
if (!textMatch) continue;
const items: SidebarItem[] = [];
const itemRe = /\{\s*text:\s*'([^']*)'\s*,\s*link:\s*'([^']*)'\s*\}/g;
let im: RegExpExecArray | null;
while ((im = itemRe.exec(part)) !== null) {
items.push({ text: im[1], link: im[2] });
}
if (items.length > 0) groups.push({ text: textMatch[1], items });
}
return groups;
}
const SIDEBAR = parseSidebar();
function linkToFileRel(link: string): string {
const rel = link.replace(/^\//, "");
if (rel === "" || rel.endsWith("/")) return `${rel}index.md`;
return `${rel}.md`;
}
function fileRelToRoute(file: string): string {
const route = file.endsWith("/index.md")
? file.replace(/index\.md$/, "")
: file.replace(/\.md$/, "");
return `${DOC_ROUTE_PREFIX}/${route}`;
}
function fileRelToMdUrl(file: string): string {
return `${DOC_ROUTE_PREFIX}/${file}`;
}
const ARCHITECTURE_LINK_RE = /\]\((\/architecture(?:\/[^)\s#]*)?)(#[^)\s]*)?\)/g;
function architectureRouteToFileRel(route: string): string {
if (route.replace(/\/$/, "") === "/architecture") return "architecture/index.md";
return linkToFileRel(route);
}
function transformArchitectureLinks(
content: string,
toHref: (route: string, hash: string) => string,
): string {
return content.replace(ARCHITECTURE_LINK_RE, (_match, route: string, hash = "") => {
return `](${toHref(route, hash)})`;
});
}
function transformArchiveLinks(content: string): string {
return transformArchitectureLinks(content, (route, hash) => {
const fileName = path.basename(architectureRouteToFileRel(route));
return `./${fileName}${hash}`;
});
}
function transformSiteMarkdownLinks(content: string): string {
return transformArchitectureLinks(content, (route, hash) => {
return `${fileRelToMdUrl(architectureRouteToFileRel(route))}${hash}`;
});
}
function getAllFiles(): string[] {
return SIDEBAR.flatMap((g) => g.items.map((item) => linkToFileRel(item.link)));
}
const stripFrontmatter = (content: string) =>
content.replace(/^---[\s\S]*?---\n*/m, "");
const stripRulesLink = (content: string) =>
content.replace(/<!-- rules-link -->[\s\S]*?<!-- \/rules-link -->\n*/g, "");
function slugifyHeading(heading: string): string {
return heading
.trim()
.replace(/[`*_~[\]()]/g, "")
.toLowerCase()
.replace(/[^\p{L}\p{N}\s-]/gu, "")
.trim()
.replace(/\s+/g, "-");
}
function fileRelToSingleFileAnchor(file: string): string {
const filePath = path.join(SRC_DIR, file);
if (!fs.existsSync(filePath)) return slugifyHeading(path.basename(file, ".md"));
const raw = stripFrontmatter(fs.readFileSync(filePath, "utf8"));
const title = raw.match(/^#\s+(.+)$/m)?.[1];
return slugifyHeading(title ?? path.basename(file, ".md"));
}
function transformSingleFileLinks(content: string): string {
return transformArchitectureLinks(content, (route, hash) => {
if (hash) return hash;
return `#${fileRelToSingleFileAnchor(architectureRouteToFileRel(route))}`;
});
}
function transformReadmeLinks(content: string): string {
return transformArchitectureLinks(content, (route, hash) => {
return `docs/${architectureRouteToFileRel(route)}${hash}`;
});
}
const shiftHeadings = (content: 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");
};
const buildArchitectureMarkdown = (routePrefix: string) => {
const files = getAllFiles();
const parts: string[] = [];
for (const file of files) {
const filePath = path.join(SRC_DIR, file);
if (!fs.existsSync(filePath)) continue;
const raw = fs.readFileSync(filePath, "utf8");
const content = stripRulesLink(stripFrontmatter(raw)).trim();
if (!content) continue;
const route = routePrefix + fileRelToRoute(file).replace(DOC_ROUTE_PREFIX, "");
const shifted = file.endsWith("index.md") ? content : shiftHeadings(content);
const processed = transformSingleFileLinks(shifted);
parts.push(`<!-- ${route} -->\n${processed}`);
}
return parts.join("\n\n");
};
function buildLlms() {
const parts: string[] = [`# SLM Design\n`];
parts.push(`> Scoped Layered Module Design — модульная архитектура фронтенд-приложений\n`);
for (const group of SIDEBAR) {
parts.push(`## ${group.text}`);
for (const item of group.items) {
const fileRel = linkToFileRel(item.link);
const filePath = path.join(SRC_DIR, fileRel);
let desc = "";
if (fs.existsSync(filePath)) {
const raw = fs.readFileSync(filePath, "utf8");
const fm = raw.match(/^---[\s\S]*?---\n*/m);
desc = fm ? fm[0].match(/description:\s*(.+)/)?.[1] || "" : "";
}
const route = fileRelToMdUrl(fileRel);
const line = desc
? `- [${item.text}](${route}): ${desc}`
: `- [${item.text}](${route})`;
parts.push(line);
}
parts.push("");
}
const outPath = path.join(PUBLIC_DIR, "llms.txt");
fs.mkdirSync(PUBLIC_DIR, { recursive: true });
fs.writeFileSync(outPath, parts.join("\n"), "utf8");
console.log(`llms.txt создан: ${outPath}`);
}
function buildLlmsFull() {
const outPath = path.join(PUBLIC_DIR, "llms-full.txt");
fs.writeFileSync(outPath, buildArchitectureMarkdown("/docs"), "utf8");
console.log(`llms-full.txt создан: ${outPath}`);
}
function copyMarkdownFiles() {
fs.rmSync(DOCS_PUBLIC_DIR, { recursive: true, force: true });
let copied = 0;
for (const file of getAllFiles()) {
const src = path.join(SRC_DIR, file);
if (!fs.existsSync(src)) continue;
const content = transformSiteMarkdownLinks(fs.readFileSync(src, "utf8"));
const dest = path.join(DOCS_PUBLIC_DIR, file);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, content, "utf8");
copied++;
}
console.log(`скопировано ${copied} .md-файлов в ${DOCS_PUBLIC_DIR}`);
}
function buildPublicArchitecture() {
const outPath = path.join(PUBLIC_DIR, PUBLIC_ARCHITECTURE_FILE);
const content = `${PUBLIC_ARCHITECTURE_NOTICE}\n\n${buildArchitectureMarkdown("/docs")}`;
fs.mkdirSync(PUBLIC_DIR, { recursive: true });
fs.writeFileSync(outPath, content, "utf8");
console.log(`${PUBLIC_ARCHITECTURE_FILE} создан: ${outPath}`);
}
function buildZip() {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "slm-"));
const tmpDir = path.join(tmpRoot, "slm-design");
fs.mkdirSync(tmpDir, { recursive: true });
const files = getAllFiles();
for (const file of files) {
const src = path.join(SRC_DIR, file);
if (!fs.existsSync(src)) continue;
let content = fs.readFileSync(src, "utf8");
content = stripRulesLink(stripFrontmatter(content)).trim();
content = transformArchiveLinks(content);
const destName = path.basename(file);
fs.writeFileSync(path.join(tmpDir, destName), content, "utf8");
}
const pkg = JSON.parse(fs.readFileSync("./package.json", "utf8"));
const version = `v${pkg.version}`;
fs.writeFileSync(path.join(tmpDir, "VERSION"), `${version}\n${new Date().toISOString()}\n`, "utf8");
const outPath = path.resolve(PUBLIC_DIR, "slm-design.zip");
fs.mkdirSync(PUBLIC_DIR, { recursive: true });
if (fs.existsSync(outPath)) fs.unlinkSync(outPath);
execFileSync("zip", ["-rq", outPath, "slm-design"], { cwd: tmpRoot });
fs.rmSync(tmpRoot, { recursive: true });
console.log(`slm-design.zip создан: ${outPath}`);
}
function buildReadme() {
const indexPath = path.join(SRC_DIR, "architecture/index.md");
if (!fs.existsSync(indexPath)) {
console.log("Пропуск README: index.md не найден");
return;
}
let content = stripFrontmatter(fs.readFileSync(indexPath, "utf8"));
content = content.replace(/<!-- rules-link -->[\s\S]*?<!-- \/rules-link -->\n*/g, "");
content = transformReadmeLinks(content);
fs.writeFileSync("./README.md", content, "utf8");
console.log("README.md создан");
}
buildLlms();
buildLlmsFull();
copyMarkdownFiles();
buildPublicArchitecture();
buildZip();
buildReadme();