263 lines
8.0 KiB
JavaScript
263 lines
8.0 KiB
JavaScript
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, "<code>$1</code>")
|
||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2">$1</a>');
|
||
}
|
||
|
||
function buttonShortcode(label, href) {
|
||
const name = String(label || "Открыть").replaceAll('"', """);
|
||
const link = String(href || "/").replaceAll('"', """);
|
||
return `<p>[button name="${name}" link="${link}"]</p>`;
|
||
}
|
||
|
||
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(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
|
||
index += 1;
|
||
continue;
|
||
}
|
||
|
||
const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(line);
|
||
if (link) {
|
||
blocks.push(buttonShortcode(link[1], link[2]));
|
||
index += 1;
|
||
continue;
|
||
}
|
||
|
||
const bullet = /^[-*]\s+(.+)$/.exec(line);
|
||
if (bullet) {
|
||
const items = [];
|
||
while (index < lines.length) {
|
||
const item = /^[-*]\s+(.+)$/.exec(lines[index].trim());
|
||
if (!item) break;
|
||
items.push(`<li>${inlineMarkdown(item[1])}</li>`);
|
||
index += 1;
|
||
}
|
||
blocks.push(`<ul>${items.join("")}</ul>`);
|
||
continue;
|
||
}
|
||
|
||
const ordered = /^\d+\.\s+(.+)$/.exec(line);
|
||
if (ordered) {
|
||
const items = [];
|
||
while (index < lines.length) {
|
||
const item = /^\d+\.\s+(.+)$/.exec(lines[index].trim());
|
||
if (!item) break;
|
||
items.push(`<li>${inlineMarkdown(item[1])}</li>`);
|
||
index += 1;
|
||
}
|
||
blocks.push(`<ol>${items.join("")}</ol>`);
|
||
continue;
|
||
}
|
||
|
||
const paragraph = [];
|
||
while (index < lines.length) {
|
||
const next = lines[index].trim();
|
||
if (!next || next === "---" || /^#{1,3}\s+/.test(next) || /^[-*]\s+/.test(next) || /^\d+\.\s+/.test(next) || /^\[[^\]]+\]\([^)\s]+\)$/.test(next)) {
|
||
break;
|
||
}
|
||
paragraph.push(next);
|
||
index += 1;
|
||
}
|
||
if (paragraph.length) {
|
||
blocks.push(`<p>${inlineMarkdown(paragraph.join(" "))}</p>`);
|
||
} 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*(<p>\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)}`);
|