import { readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const knowledgePath = resolve(repoRoot, "content", "knowledge.json");
const args = process.argv.slice(2);
const inputPath = args.find((value) => !value.startsWith("--"));
const dryRun = args.includes("--dry-run");
if (!inputPath) {
throw new Error("Укажите путь к NODEDC_Knowledge_SEO_replacement_pack.md");
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
function cleanMetaValue(value) {
const trimmed = String(value || "").trim();
return trimmed.startsWith("`") && trimmed.endsWith("`") ? trimmed.slice(1, -1).trim() : trimmed;
}
function metadataValue(section, label) {
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = section.match(new RegExp(`^\\*\\*${escapedLabel}:\\*\\*\\s*(.+?)\\s*$`, "m"));
return cleanMetaValue(match?.[1] || "");
}
function inlineMarkdown(value) {
return escapeHtml(String(value || "").trim())
.replace(/`([^`]+)`/g, "$1")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '$1');
}
function buttonShortcode(label, href) {
const name = String(label || "Открыть").replaceAll('"', """);
const link = String(href || "/").replaceAll('"', """);
return `
[button name="${name}" link="${link}"]
`; } function markdownToHtml(markdown) { const lines = String(markdown || "").replaceAll("\r\n", "\n").split("\n"); const blocks = []; let index = 0; while (index < lines.length) { const line = lines[index].trim(); if (!line || line === "---") { index += 1; continue; } if (/^#\s+/.test(line)) { index += 1; continue; } const heading = /^(#{2,3})\s+(.+)$/.exec(line); if (heading) { const level = heading[1].length; blocks.push(`${inlineMarkdown(paragraph.join(" "))}
`); } else { index += 1; } } return `${blocks.join("\n")}\n`; } function parsePack(markdown) { const matches = [...markdown.matchAll(/^## (\d+)\. (.+)$/gm)]; const pages = new Map(); for (let index = 0; index < matches.length; index += 1) { const match = matches[index]; const number = Number(match[1]); if (!Number.isInteger(number) || number < 1 || number > 23) continue; const section = markdown.slice(match.index + match[0].length, matches[index + 1]?.index || markdown.length); const h1 = /^# (.+)$/m.exec(section); if (!h1) throw new Error(`В SEO-паке не найден H1 для раздела ${number}`); const bodyStart = (h1.index || 0) + h1[0].length; const page = { number, title: h1[1].trim(), id: metadataValue(section, "ID"), slug: metadataValue(section, "Slug"), seoTitle: metadataValue(section, "SEO title"), seoDescription: metadataValue(section, "SEO description"), bodyHtml: markdownToHtml(section.slice(bodyStart)), }; if (number > 1 && (!page.id || !page.slug || !page.seoTitle || !page.seoDescription)) { throw new Error(`В SEO-паке неполные метаданные для «${page.title}»`); } pages.set(number, page); } if (pages.size !== 23 || !pages.has(1)) { throw new Error(`Ожидалось 23 страницы SEO-пака, найдено ${pages.size}`); } return pages; } function walk(nodes, callback) { for (const node of nodes || []) { callback(node); walk(node.children, callback); } } function leadingMediaParagraph(html) { return String(html || "").match(/^\s*(\s*<(?:img|video)\b[\s\S]*?<\/p>)/i)?.[1] || ""; } function materialFromPage(page, existing) { const media = leadingMediaParagraph(existing?.bodyHtml); return { ...(existing || {}), id: page.id, title: page.title, slug: page.slug, enabled: true, useContent: true, seo: { title: page.seoTitle, description: page.seoDescription, }, bodyHtml: `${media ? `${media}\n` : ""}${page.bodyHtml}`, children: [], }; } const treeOrder = [ { id: "platform-overview", children: ["hub", "engine", "ops", "integration-layer", "artifact-event-layer"], }, { id: "agent-context", children: ["document-agent", "agent-catalog"], }, { id: "applied-modules", children: [ "tender-agent", "1c-integration", "drone-management", "city-digital-twins", "industrial-contour", "enterprise-management", "construction-contour", "seo-module", "procurement-contour", "project-office", "video-analytics", "engineering-contour", ], }, ]; const [markdown, rawKnowledge] = await Promise.all([ readFile(resolve(inputPath), "utf8"), readFile(knowledgePath, "utf8"), ]); const pages = parsePack(markdown); const knowledge = JSON.parse(rawKnowledge); const existingNodes = new Map(); walk(knowledge.tree, (node) => existingNodes.set(node.id, node)); const rootPage = pages.get(1); const pageById = new Map([...pages.values()].filter((page) => page.number > 1).map((page) => [page.id, page])); const materials = new Map([...pageById].map(([id, page]) => [id, materialFromPage(page, existingNodes.get(id))])); for (const { id, children } of treeOrder) { const node = materials.get(id); if (!node) throw new Error(`Не найден раздел «${id}» в SEO-паке`); node.children = children.map((childId) => { const child = materials.get(childId); if (!child) throw new Error(`Не найден дочерний раздел «${childId}» в SEO-паке`); return child; }); } knowledge.title = rootPage.title; knowledge.seo = { ...(knowledge.seo || {}), title: rootPage.seoTitle, description: rootPage.seoDescription, }; knowledge.settings ||= {}; knowledge.settings.description = rootPage.seoDescription; knowledge.introHtml = rootPage.bodyHtml; knowledge.tree = treeOrder.map(({ id }) => materials.get(id)); const summary = { pages: pages.size, updated: [...pageById.keys()].filter((id) => existingNodes.has(id)).length, added: [...pageById.keys()].filter((id) => !existingNodes.has(id)).length, tree: knowledge.tree.map((node) => ({ id: node.id, children: node.children.map((child) => child.id) })), }; if (!dryRun) { await writeFile(knowledgePath, `${JSON.stringify(knowledge, null, 2)}\n`); } console.log(`${dryRun ? "Checked" : "Imported"} Knowledge SEO pack: ${JSON.stringify(summary)}`);