2026-05-01 21:00:25 +03:00
|
|
|
|
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";
|
2026-05-02 06:53:35 +03:00
|
|
|
|
const DOCS_PUBLIC_DIR = path.join(SRC_DIR, "public");
|
|
|
|
|
|
const DOC_ROUTE_PREFIX = "/docs";
|
2026-05-01 21:00:25 +03:00
|
|
|
|
const PUBLIC_ARCHITECTURE_FILE = "ARCHITECTURE.md";
|
2026-05-02 06:53:35 +03:00
|
|
|
|
const PUBLIC_ARCHITECTURE_NOTICE = `> Локальная копия канонической спецификации SLM Design.
|
|
|
|
|
|
> Источник: https://slm-design.gromlab.ru/ARCHITECTURE.md
|
|
|
|
|
|
> Не редактировать вручную в этом проекте.`;
|
2026-05-01 21:00:25 +03:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
|
2026-05-02 06:53:35 +03:00
|
|
|
|
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}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-02 18:51:44 +03:00
|
|
|
|
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}`;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-01 21:00:25 +03:00
|
|
|
|
function getAllFiles(): string[] {
|
2026-05-02 06:53:35 +03:00
|
|
|
|
return SIDEBAR.flatMap((g) => g.items.map((item) => linkToFileRel(item.link)));
|
2026-05-01 21:00:25 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const stripFrontmatter = (content: string) =>
|
|
|
|
|
|
content.replace(/^---[\s\S]*?---\n*/m, "");
|
|
|
|
|
|
|
|
|
|
|
|
const stripRulesLink = (content: string) =>
|
|
|
|
|
|
content.replace(/<!-- rules-link -->[\s\S]*?<!-- \/rules-link -->\n*/g, "");
|
|
|
|
|
|
|
2026-05-02 18:51:44 +03:00
|
|
|
|
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}`;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-01 21:00:25 +03:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-05-02 06:53:35 +03:00
|
|
|
|
const route = routePrefix + fileRelToRoute(file).replace(DOC_ROUTE_PREFIX, "");
|
2026-05-02 18:51:44 +03:00
|
|
|
|
const shifted = file.endsWith("index.md") ? content : shiftHeadings(content);
|
|
|
|
|
|
const processed = transformSingleFileLinks(shifted);
|
2026-05-01 21:00:25 +03:00
|
|
|
|
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) {
|
2026-05-02 06:53:35 +03:00
|
|
|
|
const fileRel = linkToFileRel(item.link);
|
2026-05-01 21:00:25 +03:00
|
|
|
|
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] || "" : "";
|
|
|
|
|
|
}
|
2026-05-02 06:53:35 +03:00
|
|
|
|
const route = fileRelToMdUrl(fileRel);
|
2026-05-01 21:00:25 +03:00
|
|
|
|
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}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-02 06:53:35 +03:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-05-02 18:51:44 +03:00
|
|
|
|
const content = transformSiteMarkdownLinks(fs.readFileSync(src, "utf8"));
|
2026-05-02 06:53:35 +03:00
|
|
|
|
const dest = path.join(DOCS_PUBLIC_DIR, file);
|
|
|
|
|
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
2026-05-02 18:51:44 +03:00
|
|
|
|
fs.writeFileSync(dest, content, "utf8");
|
2026-05-02 06:53:35 +03:00
|
|
|
|
copied++;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`скопировано ${copied} .md-файлов в ${DOCS_PUBLIC_DIR}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-01 21:00:25 +03:00
|
|
|
|
function buildPublicArchitecture() {
|
|
|
|
|
|
const outPath = path.join(PUBLIC_DIR, PUBLIC_ARCHITECTURE_FILE);
|
2026-05-02 06:53:35 +03:00
|
|
|
|
const content = `${PUBLIC_ARCHITECTURE_NOTICE}\n\n${buildArchitectureMarkdown("/docs")}`;
|
2026-05-01 21:00:25 +03:00
|
|
|
|
fs.mkdirSync(PUBLIC_DIR, { recursive: true });
|
2026-05-02 06:53:35 +03:00
|
|
|
|
fs.writeFileSync(outPath, content, "utf8");
|
2026-05-01 21:00:25 +03:00
|
|
|
|
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();
|
2026-05-02 18:51:44 +03:00
|
|
|
|
content = transformArchiveLinks(content);
|
2026-05-01 21:00:25 +03:00
|
|
|
|
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, "");
|
2026-05-02 18:51:44 +03:00
|
|
|
|
content = transformReadmeLinks(content);
|
2026-05-01 21:00:25 +03:00
|
|
|
|
fs.writeFileSync("./README.md", content, "utf8");
|
|
|
|
|
|
console.log("README.md создан");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
buildLlms();
|
|
|
|
|
|
buildLlmsFull();
|
2026-05-02 06:53:35 +03:00
|
|
|
|
copyMarkdownFiles();
|
2026-05-01 21:00:25 +03:00
|
|
|
|
buildPublicArchitecture();
|
|
|
|
|
|
buildZip();
|
|
|
|
|
|
buildReadme();
|