Import expanded Knowledge SEO content
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
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)}`);
|
||||
@@ -548,7 +548,7 @@ function renderHeader(searchPlaceholder) {
|
||||
}
|
||||
|
||||
function pageTitle(record = null) {
|
||||
if (!record) return knowledge.title || "База знаний";
|
||||
if (!record) return knowledge.seo?.title || knowledge.title || "База знаний";
|
||||
return record.node.seo?.title || record.node.title || "Материал";
|
||||
}
|
||||
|
||||
@@ -558,7 +558,7 @@ function visibleTitle(record = null) {
|
||||
}
|
||||
|
||||
function pageDescription(record = null) {
|
||||
if (!record) return knowledge.settings?.description || "";
|
||||
if (!record) return knowledge.seo?.description || knowledge.settings?.description || "";
|
||||
return record.node.seo?.description || stripHtml(renderKnowledgeShortcodes(record.node.bodyHtml)).slice(0, 180);
|
||||
}
|
||||
|
||||
@@ -723,8 +723,8 @@ async function writePage(urlPath, html) {
|
||||
async function writeSeoFiles(records) {
|
||||
const sitemapUrls = [
|
||||
{ loc: canonicalHome(), priority: "1.0" },
|
||||
{ loc: canonicalForPath(`/${knowledgeBasePath()}/`), priority: "0.8" },
|
||||
...records.map((record) => ({ loc: canonicalForPath(record.urlPath), priority: record.parents.length ? "0.6" : "0.7" })),
|
||||
{ loc: canonicalForPath(`/${knowledgeBasePath()}/`), priority: "0.7" },
|
||||
...records.map((record) => ({ loc: canonicalForPath(record.urlPath), priority: "0.7" })),
|
||||
];
|
||||
const sitemapUrl = new URL("sitemap.xml", canonicalHome()).href;
|
||||
const robots = ["User-agent: *", "Allow: /", `Sitemap: ${sitemapUrl}`, ""].join("\n");
|
||||
|
||||
Reference in New Issue
Block a user