Integrate knowledge docs and admin updates

This commit is contained in:
dcconstructions
2026-07-06 00:27:04 +03:00
parent 5e26b9b80f
commit bb7c54e9ca
43 changed files with 7038 additions and 72 deletions
+79 -4
View File
@@ -10,6 +10,7 @@ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const port = Number(process.env.PORT || 8090);
const host = process.env.HOST || "127.0.0.1";
const pagePath = join(repoRoot, "content", "pages", "home.json");
const knowledgePath = join(repoRoot, "content", "knowledge.json");
const blockTemplatesPath = join(repoRoot, "content", "block-templates", "home.json");
const assetRoot = join(repoRoot, "assets");
const assetTrashRoot = join(assetRoot, ".trash");
@@ -169,6 +170,45 @@ function sendJson(res, status, payload) {
send(res, status, `${JSON.stringify(payload, null, 2)}\n`, "application/json; charset=utf-8");
}
function allPageBlocks(page) {
return (page.regions || []).flatMap((region) => region.blocks || []);
}
function normalizeLandingTarget(value) {
const raw = String(value || "").trim();
if (!raw || /^javascript:/i.test(raw) || /^(https?:)?\/\//i.test(raw) || /^(mailto:|tel:)/i.test(raw)) return "";
const hashValue = raw.includes("#") ? raw.slice(raw.indexOf("#") + 1) : raw;
return hashValue
.replace(/^\/+/, "")
.replace(/^index\.html#?/i, "")
.replace(/^#+/, "")
.trim();
}
function landingTargetsForPage(page) {
return allPageBlocks(page)
.filter((block) => block.enabled !== false)
.map((block) => ({
id: block.id,
label: block.adminLabel || block.id,
template: block.template || block.type || "",
target: normalizeLandingTarget(block.anchor),
}))
.filter((item) => item.target);
}
function validateLandingTargets(page) {
const targets = new Map();
for (const item of landingTargetsForPage(page)) {
if (targets.has(item.target)) {
const first = targets.get(item.target);
throw new Error(`Target "${item.target}" уже используется в блоках "${first.label}" и "${item.label}"`);
}
targets.set(item.target, item);
}
}
async function readBodyBuffer(req) {
const chunks = [];
for await (const chunk of req) {
@@ -377,12 +417,15 @@ function collectAssetRefsFromText(text) {
}
async function buildMediaUsage() {
const [pageSource, indexSource] = await Promise.all([
const [pageSource, knowledgeSource, indexSource, knowledgeIndexSource] = await Promise.all([
readFile(pagePath, "utf8").catch(() => "{}"),
readFile(knowledgePath, "utf8").catch(() => "{}"),
readFile(join(repoRoot, "index.html"), "utf8").catch(() => ""),
readFile(join(repoRoot, "knowledge", "index.html"), "utf8").catch(() => ""),
]);
const pageRefs = collectAssetRefsFromValue(JSON.parse(pageSource));
const renderedRefs = collectAssetRefsFromText(indexSource);
collectAssetRefsFromValue(JSON.parse(knowledgeSource), pageRefs);
const renderedRefs = collectAssetRefsFromText(`${indexSource}\n${knowledgeIndexSource}`);
return { pageRefs, renderedRefs };
}
@@ -1389,14 +1432,19 @@ function safePublicPath(urlPath) {
}
async function serveStatic(req, res) {
const filePath = safePublicPath(new URL(req.url, `http://${host}:${port}`).pathname);
let filePath = safePublicPath(new URL(req.url, `http://${host}:${port}`).pathname);
if (!filePath) {
send(res, 403, "Forbidden");
return;
}
try {
const fileStat = await stat(filePath);
let fileStat = await stat(filePath);
if (fileStat.isDirectory()) {
filePath = join(filePath, "index.html");
fileStat = await stat(filePath);
}
if (!fileStat.isFile()) {
send(res, 404, "Not found");
return;
@@ -1462,6 +1510,18 @@ const server = createServer(async (req, res) => {
return;
}
if (req.method === "GET" && url.pathname === "/api/knowledge") {
send(res, 200, await readFile(knowledgePath, "utf8"), "application/json; charset=utf-8");
return;
}
if (req.method === "GET" && url.pathname === "/api/landing-targets") {
const page = JSON.parse(await readFile(pagePath, "utf8"));
validateLandingTargets(page);
sendJson(res, 200, { ok: true, targets: landingTargetsForPage(page) });
return;
}
if (req.method === "GET" && url.pathname === "/api/block-templates/home") {
send(res, 200, await readFile(blockTemplatesPath, "utf8"), "application/json; charset=utf-8");
return;
@@ -1490,17 +1550,32 @@ const server = createServer(async (req, res) => {
if (req.method === "PUT" && url.pathname === "/api/page/home") {
const body = await readBody(req);
const parsed = JSON.parse(body);
validateLandingTargets(parsed);
await writeFile(pagePath, `${JSON.stringify(parsed, null, 2)}\n`);
sendJson(res, 200, { ok: true });
return;
}
if (req.method === "PUT" && url.pathname === "/api/knowledge") {
const body = await readBody(req);
const parsed = JSON.parse(body);
await writeFile(knowledgePath, `${JSON.stringify(parsed, null, 2)}\n`);
sendJson(res, 200, { ok: true });
return;
}
if (req.method === "POST" && url.pathname === "/api/render/home") {
const result = await runNodeScript("render-home.mjs");
sendJson(res, 200, { ok: true, ...result });
return;
}
if (req.method === "POST" && url.pathname === "/api/render/knowledge") {
const result = await runNodeScript("render-knowledge.mjs");
sendJson(res, 200, { ok: true, ...result });
return;
}
if (req.method === "POST" && url.pathname === "/api/upload/asset") {
const result = isMultipartRequest(req)
? await saveUploadedAssetBuffer(parseMultipartUpload(req, await readBodyBuffer(req)))
+134 -5
View File
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const pagePath = join(repoRoot, "content", "pages", "home.json");
const knowledgePath = join(repoRoot, "content", "knowledge.json");
const robotsPath = join(repoRoot, "robots.txt");
const sitemapPath = join(repoRoot, "sitemap.xml");
const templateRoot = join(repoRoot, "templates", "pages", "home");
@@ -193,6 +194,115 @@ function escapeXml(value) {
.replaceAll("'", "'");
}
function stripHtml(value) {
return String(value ?? "")
.replace(/<[^>]*>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function normalizeLandingTarget(value) {
const raw = String(value || "").trim();
if (!raw || /^javascript:/i.test(raw) || isExternalHref(raw)) return "";
const hashValue = raw.includes("#") ? raw.slice(raw.indexOf("#") + 1) : raw;
return hashValue
.replace(/^\/+/, "")
.replace(/^index\.html#?/i, "")
.replace(/^#+/, "")
.trim();
}
function validateLandingTargets(model) {
const targets = new Map();
for (const block of allBlocks(model)) {
if (block.enabled === false) continue;
const target = normalizeLandingTarget(block.anchor);
if (!target) continue;
if (targets.has(target)) {
const first = targets.get(target);
throw new Error(
`Duplicate landing target "${target}" in blocks "${first.adminLabel || first.id}" and "${block.adminLabel || block.id}"`,
);
}
targets.set(target, block);
}
}
function slugify(value, fallback = "section") {
const map = {
а: "a",
б: "b",
в: "v",
г: "g",
д: "d",
е: "e",
ё: "e",
ж: "zh",
з: "z",
и: "i",
й: "y",
к: "k",
л: "l",
м: "m",
н: "n",
о: "o",
п: "p",
р: "r",
с: "s",
т: "t",
у: "u",
ф: "f",
х: "h",
ц: "c",
ч: "ch",
ш: "sh",
щ: "sch",
ъ: "",
ы: "y",
ь: "",
э: "e",
ю: "yu",
я: "ya",
};
const safe = String(value ?? "")
.toLowerCase()
.replace(/[а-яё]/g, (char) => map[char] ?? "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return safe || fallback;
}
function collectKnowledgeUrls(source, canonical) {
let model;
try {
model = JSON.parse(source);
} catch {
return [];
}
const basePath = String(model.basePath || "knowledge").replace(/^\/+|\/+$/g, "") || "knowledge";
const urls = [new URL(`${basePath}/`, canonical).href];
function walk(nodes = [], parentSegments = []) {
nodes.forEach((node, index) => {
if (node.enabled === false) return;
const segment = slugify(node.slug || node.title || node.id, `page-${index + 1}`);
const segments = [...parentSegments, segment];
if (node.useContent !== false && stripHtml(node.bodyHtml).length > 0) {
urls.push(new URL(`${basePath}/${segments.join("/")}/`, canonical).href);
}
if (Array.isArray(node.children)) walk(node.children, segments);
});
}
walk(model.tree || []);
return urls;
}
function canonicalUrl(model) {
const fallback = "https://nodedc.dctouch.ru/";
const value = String(model.seo?.canonicalUrl || fallback).trim() || fallback;
@@ -208,6 +318,8 @@ function canonicalUrl(model) {
async function writeSeoFiles(model) {
const canonical = canonicalUrl(model);
const sitemapUrl = new URL("sitemap.xml", canonical).href;
const knowledgeSource = await readFile(knowledgePath, "utf8").catch(() => "");
const knowledgeUrls = collectKnowledgeUrls(knowledgeSource, canonical);
const robots = ["User-agent: *", "Allow: /", `Sitemap: ${sitemapUrl}`, ""].join("\n");
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
@@ -216,6 +328,15 @@ async function writeSeoFiles(model) {
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
${knowledgeUrls
.map(
(url) => ` <url>
<loc>${escapeXml(url)}</loc>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>`,
)
.join("\n")}
</urlset>
`;
@@ -306,6 +427,16 @@ function scopeDuplicateIds(html, scope) {
return scoped;
}
function htmlHasId(html, id) {
return new RegExp(`\\sid="${escapeRegExp(id)}"`).test(html);
}
function withLandingTarget(html, block) {
const target = normalizeLandingTarget(block.anchor);
if (!target || htmlHasId(html, target)) return html;
return `<span id="${escapeAttr(target)}" class="landing-target" aria-hidden="true"></span>${html}`;
}
async function renderBlock(block, renderCounts) {
if (block.enabled === false) {
return "";
@@ -316,12 +447,9 @@ async function renderBlock(block, renderCounts) {
renderCounts.set(templateName, renderCount + 1);
const html = renderTemplate(await readTemplate(templateName), block);
const rendered = renderCount === 0 && block.scopeIds !== true ? html : scopeDuplicateIds(html, block.id);
if (renderCount === 0 && block.scopeIds !== true) {
return html;
}
return scopeDuplicateIds(html, block.id);
return withLandingTarget(rendered, block);
}
async function renderRegion(region) {
@@ -367,6 +495,7 @@ async function renderSections() {
applySeoDefaults(page);
applySharedDemoRequest(page);
applyRenderableLinks(page);
validateLandingTargets(page);
let output = renderTemplate(shell, { id: page.slug, ...page });
+769
View File
@@ -0,0 +1,769 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const knowledgePath = join(repoRoot, "content", "knowledge.json");
const homePath = join(repoRoot, "content", "pages", "home.json");
const robotsPath = join(repoRoot, "robots.txt");
const sitemapPath = join(repoRoot, "sitemap.xml");
const knowledge = JSON.parse(await readFile(knowledgePath, "utf8"));
const home = JSON.parse(await readFile(homePath, "utf8").catch(() => "{}"));
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function escapeAttr(value) {
return escapeHtml(value).replaceAll('"', "&quot;").replaceAll("'", "&#39;");
}
function escapeXml(value) {
return escapeHtml(value).replaceAll('"', "&quot;").replaceAll("'", "&apos;");
}
function escapeJson(value) {
return JSON.stringify(value).replaceAll("</script", "<\\/script");
}
function stripHtml(value) {
return String(value ?? "")
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]*>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function slugify(value, fallback = "section") {
const map = {
а: "a",
б: "b",
в: "v",
г: "g",
д: "d",
е: "e",
ё: "e",
ж: "zh",
з: "z",
и: "i",
й: "y",
к: "k",
л: "l",
м: "m",
н: "n",
о: "o",
п: "p",
р: "r",
с: "s",
т: "t",
у: "u",
ф: "f",
х: "h",
ц: "c",
ч: "ch",
ш: "sh",
щ: "sch",
ъ: "",
ы: "y",
ь: "",
э: "e",
ю: "yu",
я: "ya",
};
const transliterated = String(value ?? "")
.toLowerCase()
.replace(/[а-яё]/g, (char) => map[char] ?? "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return transliterated || fallback;
}
function normalizePathSegment(node, index) {
return slugify(node.slug || node.title || node.id, `page-${index + 1}`);
}
function publicAssetUrl(value) {
const url = String(value || "").trim();
if (!url) return "";
if (/^(https?:|data:|blob:|\/)/i.test(url)) return url;
if (url.startsWith("./")) return `/${url.slice(2)}`;
return `/${url.replace(/^\/+/, "")}`;
}
function normalizeContentAssets(html) {
return String(html || "")
.replace(/\s(src|href)="\.\/assets\//g, ' $1="/assets/')
.replace(/\s(src|href)="assets\//g, ' $1="/assets/');
}
function decodeShortcodeAttr(value) {
return String(value ?? "")
.replaceAll("&quot;", '"')
.replaceAll("&#34;", '"')
.replaceAll("&#39;", "'")
.replaceAll("&apos;", "'")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&amp;", "&");
}
function parseShortcodeAttrs(value) {
const attrs = {};
String(value || "").replace(
/([a-zA-Z][\w-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"']+))/g,
(match, key, doubleQuoted, singleQuoted, bare) => {
attrs[key.toLowerCase()] = decodeShortcodeAttr(doubleQuoted ?? singleQuoted ?? bare ?? "");
return match;
},
);
return attrs;
}
function normalizeKnowledgeButtonTarget(value) {
return String(value || "")
.trim()
.replace(/^#+/, "")
.replace(/^index\.html#/i, "")
.replace(/^\/?#/, "");
}
function normalizeKnowledgeButtonLink(value) {
return (String(value ?? "").trim() || "/").replace(/^\/+(https?:\/\/)/i, "$1").replace(/^\/+((?:mailto|tel):)/i, "$1");
}
function allHomeBlocks() {
return (home.regions || []).flatMap((region) => region.blocks || []);
}
function landingBlockById(id) {
const blockId = String(id || "").trim();
if (!blockId) return null;
return allHomeBlocks().find((block) => block.id === blockId && block.enabled !== false) || null;
}
function landingTargetForAttrs(attrs) {
const blockId = attrs.block || attrs["block-id"] || attrs.blockid || attrs.landingblock || attrs["landing-block"];
const blockTarget = normalizeKnowledgeButtonTarget(landingBlockById(blockId)?.anchor || "");
return blockTarget || normalizeKnowledgeButtonTarget(attrs.target || attrs.anchor || "");
}
function knowledgeButtonHref(link, attrs) {
const anchor = landingTargetForAttrs(attrs);
const rawLink = normalizeKnowledgeButtonLink(link);
if (!anchor) return rawLink;
const base = rawLink.split("#")[0] || "/";
if (/^index\.html$/i.test(base)) return `/#${anchor}`;
return `${base}#${anchor}`;
}
function renderKnowledgeShortcodes(html) {
return String(html || "").replace(/\[button\b([^\]]*)\]/gi, (match, rawAttrs) => {
const attrs = parseShortcodeAttrs(rawAttrs);
const label = attrs.name || attrs.label || attrs.text || "Посмотреть";
const href = knowledgeButtonHref(attrs.link || attrs.href || "/", attrs);
return `<a class="knowledge-button" href="${escapeAttr(href)}">${escapeHtml(label)}</a>`;
});
}
function canonicalHome() {
const fallback = "https://nodedc.dctouch.ru/";
const value = String(home.seo?.canonicalUrl || fallback).trim() || fallback;
try {
return new URL(value).href;
} catch {
return fallback;
}
}
function canonicalBase() {
const url = new URL(canonicalHome());
return `${url.origin}/`;
}
function knowledgeBasePath() {
return String(knowledge.basePath || "knowledge").replace(/^\/+|\/+$/g, "") || "knowledge";
}
function nodeChildren(node) {
return Array.isArray(node.children) ? node.children : [];
}
function nodeHasPage(node) {
return node?.enabled !== false && node.useContent !== false && stripHtml(renderKnowledgeShortcodes(node.bodyHtml || "")).length > 0;
}
function walkNodes(nodes = knowledge.tree || [], parentSegments = [], parents = [], result = []) {
nodes.forEach((node, index) => {
if (node.enabled === false) return;
const segment = normalizePathSegment(node, index);
const segments = [...parentSegments, segment];
const record = {
node,
parents,
segment,
segments,
urlPath: `/${knowledgeBasePath()}/${segments.join("/")}/`,
};
result.push(record);
walkNodes(nodeChildren(node), segments, [...parents, record], result);
});
return result;
}
function pageRecords() {
return walkNodes().filter((record) => nodeHasPage(record.node));
}
function currentAncestorIds(record) {
return new Set([record?.node?.id, ...(record?.parents || []).map((parent) => parent.node.id)].filter(Boolean));
}
function caretIcon(className = "caret astro-j63poqv6 astro-fr7meb3z", size = "1.25rem") {
return `<svg aria-hidden="true" class="${className}" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="--sl-icon-size: ${size};"><path d="m14.83 11.29-4.24-4.24a1 1 0 1 0-1.42 1.41L12.71 12l-3.54 3.54a1 1 0 0 0 0 1.41 1 1 0 0 0 .71.29 1 1 0 0 0 .71-.29l4.24-4.24a1.002 1.002 0 0 0 0-1.42Z"/></svg>`;
}
function searchIcon() {
return '<svg aria-hidden="true" class="astro-2f6du2pg astro-fr7meb3z" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="--sl-icon-size: 1em;"><path d="M21.71 20.29 18 16.61A9 9 0 1 0 16.61 18l3.68 3.68a.999.999 0 0 0 1.42 0 1 1 0 0 0 0-1.39ZM11 18a7 7 0 1 1 0-14 7 7 0 0 1 0 14Z"/></svg>';
}
function anchorIcon(id, title) {
return `<a class="sl-anchor-link" href="#${escapeAttr(id)}"><span aria-hidden="true" class="sl-anchor-icon"><svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentcolor" d="m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 0 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 1 0-1.42-1.42Zm8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 0 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 1 0 1.42 1.42l3.88-3.89a4.49 4.49 0 0 0 0-6.33ZM8.83 15.17a1 1 0 0 0 1.1.22 1 1 0 0 0 .32-.22l4.92-4.92a1 1 0 0 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42Z"></path></svg></span><span class="sr-only" data-pagefind-ignore="">Раздел «${escapeHtml(title)}»</span></a>`;
}
function headingId(rawTitle, usedIds) {
const base = slugify(stripHtml(rawTitle), "section");
let id = base;
let index = 2;
while (usedIds.has(id)) {
id = `${base}-${index}`;
index += 1;
}
usedIds.add(id);
return id;
}
function prepareArticleHtml(html) {
const usedIds = new Set();
const toc = [];
const normalized = renderKnowledgeShortcodes(normalizeContentAssets(html)).replace(/<h([23])([^>]*)>([\s\S]*?)<\/h\1>/gi, (match, level, attrs, inner) => {
const existingId = /\sid=(["'])(.*?)\1/i.exec(attrs);
const id = existingId?.[2] || headingId(inner, usedIds);
const title = stripHtml(inner);
if (existingId) usedIds.add(id);
toc.push({ level: Number(level), id, title });
const nextAttrs = existingId ? attrs : `${attrs} id="${escapeAttr(id)}"`;
return `<div class="sl-heading-wrapper level-h${level}"><h${level}${nextAttrs}>${inner}</h${level}>${anchorIcon(id, title)}</div>`;
});
return { html: normalized, toc };
}
function renderNavRecord(record, currentIds, currentId, restoreCounter) {
const children = nodeChildren(record.node)
.map((child, index) => {
if (child.enabled === false) return "";
const childSegment = normalizePathSegment(child, index);
return renderNavRecord(
{
node: child,
parents: [...record.parents, record],
segment: childSegment,
segments: [...record.segments, childSegment],
urlPath: `/${knowledgeBasePath()}/${[...record.segments, childSegment].join("/")}/`,
},
currentIds,
currentId,
restoreCounter,
);
})
.filter(Boolean)
.join("");
const title = escapeHtml(record.node.title || record.node.id || "Материал");
const isCurrent = record.node.id === currentId;
const currentAttr = isCurrent ? ' aria-current="page"' : "";
if (!children) {
const largeClass = record.parents.length === 0 ? "large " : "";
const href = nodeHasPage(record.node) ? record.urlPath : "#";
return `<li class="astro-j63poqv6"><a href="${escapeAttr(href)}"${currentAttr} class="${largeClass}astro-j63poqv6"><span class="astro-j63poqv6">${title}</span></a></li>`;
}
const restoreIndex = restoreCounter.value;
restoreCounter.value += 1;
const open = currentIds.has(record.node.id) || record.parents.length === 0;
const label = nodeHasPage(record.node)
? `<a href="${escapeAttr(record.urlPath)}" class="knowledge-summary-link astro-j63poqv6"><span class="large astro-j63poqv6">${title}</span></a>`
: `<span class="large astro-j63poqv6">${title}</span>`;
return `<li class="astro-j63poqv6"><details ${open ? "open " : ""}class="astro-j63poqv6"><summary${currentAttr} class="astro-j63poqv6"><span class="group-label astro-j63poqv6">${label}</span>${caretIcon()}</summary><sl-sidebar-restore data-index="${restoreIndex}"></sl-sidebar-restore><ul class="astro-j63poqv6">${children}</ul></details></li>`;
}
function renderSidebar(currentRecord) {
const currentIds = currentAncestorIds(currentRecord);
const currentId = currentRecord?.node?.id || "";
const restoreCounter = { value: 0 };
const baseActive = currentRecord ? "" : ' aria-current="page"';
const items = (knowledge.tree || [])
.map((node, index) => {
if (node.enabled === false) return "";
const segment = normalizePathSegment(node, index);
return renderNavRecord(
{
node,
parents: [],
segment,
segments: [segment],
urlPath: `/${knowledgeBasePath()}/${segment}/`,
},
currentIds,
currentId,
restoreCounter,
);
})
.filter(Boolean)
.join("");
return `<nav class="sidebar print:hidden astro-iyrf6vv4" aria-label="Основная навигация">
<starlight-menu-button class="print:hidden astro-yrbfzory">
<button aria-expanded="false" aria-label="Меню" aria-controls="starlight__sidebar" class="sl-flex md:sl-hidden astro-yrbfzory">${menuIcons()}</button>
</starlight-menu-button>
<div id="starlight__sidebar" class="sidebar-pane astro-iyrf6vv4">
<div class="sidebar-content sl-flex astro-iyrf6vv4">
<sl-sidebar-state-persist data-hash="nodedc-knowledge" class="astro-fouifdpt">
<ul class="top-level astro-j63poqv6">
<li class="astro-j63poqv6"><a href="/${knowledgeBasePath()}/"${baseActive} class="large astro-j63poqv6"><span class="astro-j63poqv6">${escapeHtml(knowledge.title || "База знаний")}</span></a></li>${items}
</ul>
</sl-sidebar-state-persist>
<div class="md:sl-hidden">
<div class="mobile-preferences sl-flex astro-22fp5ct5">
<div class="social-icons astro-22fp5ct5"></div>${renderThemeSelect()}
</div>
</div>
</div>
</div>
</nav>`;
}
function menuIcons() {
return `<svg aria-hidden="true" class="open-menu astro-yrbfzory astro-fr7meb3z" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="--sl-icon-size: 1em;"><path d="M3 8h18a1 1 0 1 0 0-2H3a1 1 0 0 0 0 2Zm18 8H3a1 1 0 0 0 0 2h18a1 1 0 0 0 0-2Zm0-5H3a1 1 0 0 0 0 2h18a1 1 0 0 0 0-2Z"/></svg><svg aria-hidden="true" class="close-menu astro-yrbfzory astro-fr7meb3z" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="--sl-icon-size: 1em;"><path d="m13.41 12 6.3-6.29a1.004 1.004 0 1 0-1.42-1.42L12 10.59l-6.29-6.3a1.004 1.004 0 0 0-1.42 1.42l6.3 6.29-6.3 6.29a1 1 0 0 0 0 1.42.998.998 0 0 0 1.42 0l6.29-6.3 6.29 6.3a.999.999 0 0 0 1.42 0 1 1 0 0 0 0-1.42L13.41 12Z"/></svg>`;
}
function renderTocItems(toc, mobile = false) {
const items = [{ level: 2, id: "_top", title: "Обзор" }, ...toc];
return items
.map((item) => {
const depth = Math.max(0, item.level - 2);
return `<li class="astro-uehw6aht" style="--depth: ${depth};"><a href="#${escapeAttr(item.id)}" class="astro-uehw6aht" style="--depth: ${depth};"><span class="astro-uehw6aht" style="--depth: ${depth};">${escapeHtml(item.title)}</span></a></li>`;
})
.join("");
}
function renderRightSidebar(toc) {
const items = renderTocItems(toc);
const mobileItems = renderTocItems(toc, true);
return `<aside class="right-sidebar-container print:hidden astro-6ptv7odt">
<div class="right-sidebar astro-6ptv7odt">
<script type="module" src="/assets/vendor/ssscript-docs/_astro/MobileTableOfContents.astro_astro_type_script_index_0_lang.hwBsy0Mo.js"></script>
<script type="module" src="/assets/vendor/ssscript-docs/_astro/TableOfContents.astro_astro_type_script_index_0_lang.FuRcXuRY.js"></script>
<div class="lg:sl-hidden astro-auw6kndz">
<mobile-starlight-toc data-min-h="2" data-max-h="3" class="astro-2zbszx3p">
<nav aria-labelledby="starlight__on-this-page--mobile" class="astro-2zbszx3p">
<details id="starlight__mobile-toc" class="astro-2zbszx3p">
<summary id="starlight__on-this-page--mobile" class="sl-flex astro-2zbszx3p"><span class="toggle sl-flex astro-2zbszx3p">На этой странице${caretIcon("caret astro-2zbszx3p astro-fr7meb3z", "1rem")}</span><span class="display-current astro-2zbszx3p"></span></summary>
<div class="dropdown astro-2zbszx3p"><ul class="isMobile astro-uehw6aht" style="--depth: 0;">${mobileItems}</ul></div>
</details>
</nav>
</mobile-starlight-toc>
</div>
<div class="right-sidebar-panel sl-hidden lg:sl-block astro-auw6kndz">
<div class="sl-container astro-auw6kndz">
<starlight-toc data-min-h="2" data-max-h="3">
<nav aria-labelledby="starlight__on-this-page">
<h2 id="starlight__on-this-page">На этой странице</h2>
<ul class="astro-uehw6aht" style="--depth: 0;">${items}</ul>
</nav>
</starlight-toc>
</div>
</div>
</div>
</aside>`;
}
function renderThemeIconsTemplate() {
return `<template id="theme-icons"><svg aria-hidden="true" class="light astro-fr7meb3z" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="--sl-icon-size: 1em;"><path d="M5 12a1 1 0 0 0-1-1H3a1 1 0 0 0 0 2h1a1 1 0 0 0 1-1Zm.64 5-.71.71a1 1 0 0 0 0 1.41 1 1 0 0 0 1.41 0l.71-.71A1 1 0 0 0 5.64 17ZM12 5a1 1 0 0 0 1-1V3a1 1 0 0 0-2 0v1a1 1 0 0 0 1 1Zm5.66 2.34a1 1 0 0 0 .7-.29l.71-.71a1 1 0 1 0-1.41-1.41l-.66.71a1 1 0 0 0 0 1.41 1 1 0 0 0 .66.29Zm-12-.29a1 1 0 0 0 1.41 0 1 1 0 0 0 0-1.41l-.71-.71a1.004 1.004 0 1 0-1.43 1.41l.73.71ZM21 11h-1a1 1 0 0 0 0 2h1a1 1 0 0 0 0-2Zm-2.64 6A1 1 0 0 0 17 18.36l.71.71a1 1 0 0 0 1.41 0 1 1 0 0 0 0-1.41l-.76-.66ZM12 6.5a5.5 5.5 0 1 0 5.5 5.5A5.51 5.51 0 0 0 12 6.5Zm0 9a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7Zm0 3.5a1 1 0 0 0-1 1v1a1 1 0 0 0 2 0v-1a1 1 0 0 0-1-1Z"/></svg><svg aria-hidden="true" class="dark astro-fr7meb3z" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="--sl-icon-size: 1em;"><path d="M21.64 13a1 1 0 0 0-1.05-.14 8.049 8.049 0 0 1-3.37.73 8.15 8.15 0 0 1-8.14-8.1 8.59 8.59 0 0 1 .25-2A1 1 0 0 0 8 2.36a10.14 10.14 0 1 0 14 11.69 1 1 0 0 0-.36-1.05Zm-9.5 6.69A8.14 8.14 0 0 1 7.08 5.22v.27a10.15 10.15 0 0 0 10.14 10.14 9.784 9.784 0 0 0 2.1-.22 8.11 8.11 0 0 1-7.18 4.32v-.04Z"/></svg><svg aria-hidden="true" class="auto astro-fr7meb3z" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="--sl-icon-size: 1em;"><path d="M21 14h-1V7a3 3 0 0 0-3-3H7a3 3 0 0 0-3 3v7H3a1 1 0 0 0-1 1v2a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-2a1 1 0 0 0-1-1ZM6 7a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v7H6V7Zm14 10a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-1h16v1Z"/></svg></template>`;
}
function renderThemeSelect() {
return `<starlight-theme-select><label style="--sl-select-width: 6.25em" class="astro-47nlhtql"><span class="sr-only astro-47nlhtql">Выбор темы</span><svg aria-hidden="true" class="icon label-icon astro-47nlhtql astro-fr7meb3z" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="--sl-icon-size: 1em;"><path d="M21 14h-1V7a3 3 0 0 0-3-3H7a3 3 0 0 0-3 3v7H3a1 1 0 0 0-1 1v2a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-2a1 1 0 0 0-1-1ZM6 7a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v7H6V7Zm14 10a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-1h16v1Z"/></svg><select autocomplete="off" class="astro-47nlhtql"><option value="dark" class="astro-47nlhtql">Dark</option><option value="light" class="astro-47nlhtql">Light</option><option value="auto" class="astro-47nlhtql">Auto</option></select><svg aria-hidden="true" class="icon caret astro-47nlhtql astro-fr7meb3z" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="--sl-icon-size: 1em;"><path d="M17 9.17a1 1 0 0 0-1.41 0L12 12.71 8.46 9.17a1 1 0 1 0-1.41 1.42l4.24 4.24a1.002 1.002 0 0 0 1.42 0L17 10.59a1.002 1.002 0 0 0 0-1.42Z"/></svg></label></starlight-theme-select>`;
}
function themeProviderScript() {
const defaultTheme = escapeAttr(knowledge.settings?.themeDefault || "dark");
return `<script>
window.StarlightThemeProvider = (() => {
const storedTheme = typeof localStorage !== "undefined" && localStorage.getItem("starlight-theme");
const theme = storedTheme || "${defaultTheme}";
document.documentElement.dataset.theme = theme === "auto" ? (window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : theme;
return {
updatePickers(theme = storedTheme || "${defaultTheme}") {
document.querySelectorAll("starlight-theme-select").forEach((picker) => {
const select = picker.querySelector("select");
if (select) select.value = theme;
const tmpl = document.querySelector("#theme-icons");
const newIcon = tmpl && tmpl.content.querySelector("." + theme);
if (newIcon) {
const oldIcon = picker.querySelector("svg.label-icon");
if (oldIcon) oldIcon.replaceChildren(...newIcon.cloneNode(true).childNodes);
}
});
},
};
})();
</script>`;
}
function interactionScript() {
return `<script type="module">
const themeKey = "starlight-theme";
const defaultTheme = "${escapeAttr(knowledge.settings?.themeDefault || "dark")}";
const normalizeTheme = (value) => value === "auto" || value === "dark" || value === "light" ? value : defaultTheme;
const readTheme = () => normalizeTheme(typeof localStorage !== "undefined" && localStorage.getItem(themeKey));
const systemTheme = () => matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
function storeTheme(theme) {
if (typeof localStorage !== "undefined") localStorage.setItem(themeKey, theme);
}
function applyTheme(theme) {
StarlightThemeProvider.updatePickers(theme);
document.documentElement.dataset.theme = theme === "auto" ? systemTheme() : theme;
storeTheme(theme);
}
matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => {
if (readTheme() === "auto") applyTheme("auto");
});
class StarlightThemeSelect extends HTMLElement {
constructor() {
super();
applyTheme(readTheme());
this.querySelector("select")?.addEventListener("change", (event) => {
if (event.currentTarget instanceof HTMLSelectElement) applyTheme(normalizeTheme(event.currentTarget.value));
});
}
}
if (!customElements.get("starlight-theme-select")) customElements.define("starlight-theme-select", StarlightThemeSelect);
class StarlightMenuButton extends HTMLElement {
constructor() {
super();
this.btn = this.querySelector("button");
this.btn?.addEventListener("click", () => this.toggleExpanded());
const nav = this.closest("nav");
nav?.addEventListener("keyup", (event) => this.closeOnEscape(event));
}
setExpanded(expanded) {
this.setAttribute("aria-expanded", String(expanded));
this.btn?.setAttribute("aria-expanded", String(expanded));
document.body.toggleAttribute("data-mobile-menu-expanded", expanded);
}
toggleExpanded() {
this.setExpanded(this.getAttribute("aria-expanded") !== "true");
}
closeOnEscape(event) {
if (event.code === "Escape") {
this.setExpanded(false);
this.btn?.focus();
}
}
}
if (!customElements.get("starlight-menu-button")) customElements.define("starlight-menu-button", StarlightMenuButton);
const sidebar = document.getElementById("starlight__sidebar");
const persist = sidebar?.querySelector("sl-sidebar-state-persist");
const storageKey = "sl-sidebar-state:nodedc-knowledge";
const readState = () => {
try {
return JSON.parse(sessionStorage.getItem(storageKey) || "{}");
} catch {
return {};
}
};
const writeState = () => {
if (!sidebar || !persist) return;
const details = [...persist.querySelectorAll("details")];
try {
sessionStorage.setItem(storageKey, JSON.stringify({ open: details.map((item) => item.open), scroll: sidebar.scrollTop }));
} catch {}
};
if (sidebar && persist && matchMedia("(min-width: 50em)").matches) {
const state = readState();
if (Array.isArray(state.open)) {
[...persist.querySelectorAll("details")].forEach((details, index) => {
if (typeof state.open[index] === "boolean") details.open = state.open[index];
});
}
if (typeof state.scroll === "number") sidebar.scrollTop = state.scroll;
persist.addEventListener("toggle", writeState, true);
addEventListener("pagehide", writeState);
}
</script>`;
}
function renderHeader(searchPlaceholder) {
const logoSrc = publicAssetUrl(home.staticElements?.content?.navigation?.logoSrc || home.staticElements?.content?.assets?.appIconSrc || "");
const logoAlt = home.staticElements?.content?.navigation?.logoAlt || "NODE.DC";
const brand = knowledge.settings?.navTitle || knowledge.title || "База знаний";
const headerHref = knowledge.settings?.homeHref || "/";
return `<header class="header astro-iyrf6vv4">
<div class="max-md:flex gap-(--sl-nav-gap) justify-between items-center h-full header-grid astro-3ef6ksr2">
<div class="overflow-clip p-1 -m-1 min-w-0 sl-flex title-wrapper astro-3ef6ksr2">
<a href="${escapeAttr(headerHref)}" class="flex items-center gap-(--sl-nav-gap) font-semibold no-underline whitespace-nowrap min-w-0 site-title astro-idrpryed">
<span class="site-title-logo inline-flex items-center shrink-0 astro-idrpryed" aria-hidden="true">${logoSrc ? `<img src="${escapeAttr(logoSrc)}" alt="${escapeAttr(logoAlt)}">` : ""}</span>
<span class="overflow-hidden astro-idrpryed" translate="no">${escapeHtml(brand)}</span>
</a>
</div>
<div class="sl-flex print:hidden search-wrapper justify-center astro-3ef6ksr2" data-search>
<site-search class="astro-3ef6ksr2 astro-2f6du2pg" data-translations="{&quot;placeholder&quot;:&quot;${escapeAttr(searchPlaceholder)}&quot;}">
<button data-open-modal aria-label="${escapeAttr(searchPlaceholder)}" aria-keyshortcuts="Meta+K" class="astro-2f6du2pg" type="button">${searchIcon()}<span class="sl-hidden md:sl-block astro-2f6du2pg" aria-hidden="true">${escapeHtml(searchPlaceholder)}</span><kbd class="sl-hidden md:sl-flex astro-2f6du2pg"><kbd class="astro-2f6du2pg">⌘</kbd><kbd class="astro-2f6du2pg">K</kbd></kbd></button>
</site-search>
<div class="knowledge-search-popover" data-search-popover hidden>
<input class="knowledge-search-input" type="search" placeholder="${escapeAttr(searchPlaceholder)}" autocomplete="off" data-search-input>
<div class="knowledge-search-results" data-search-results></div>
</div>
</div>
<div class="sl-hidden md:sl-flex print:hidden flex gap-4 items-center right-group astro-3ef6ksr2">
<div class="sl-flex gap-4 items-center social-icons astro-3ef6ksr2"></div>
<a href="#" class="inline-flex items-center gap-2 font-medium no-underline whitespace-nowrap transition-[color,filter] duration-150 download-cta knowledge-download-placeholder py-[0.3rem] px-[0.5rem] pl-[0.4rem] text-sm rounded-[8px] astro-sle7k2hz" aria-hidden="true" tabindex="-1"><span class="inline-flex items-center justify-center flex-shrink-0 w-6 h-6 astro-sle7k2hz"></span><span class="astro-sle7k2hz">Download</span></a>
${renderThemeSelect()}
</div>
</div>
</header>`;
}
function pageTitle(record = null) {
if (!record) return knowledge.title || "База знаний";
return record.node.seo?.title || record.node.title || "Материал";
}
function visibleTitle(record = null) {
if (!record) return knowledge.title || "База знаний";
return record.node.title || "Материал";
}
function pageDescription(record = null) {
if (!record) return knowledge.settings?.description || "";
return record.node.seo?.description || stripHtml(renderKnowledgeShortcodes(record.node.bodyHtml)).slice(0, 180);
}
function canonicalForPath(path) {
return new URL(path.replace(/^\//, ""), canonicalBase()).href;
}
function renderIndexCards(records) {
const topPages = records.filter((record) => record.parents.length <= 1).slice(0, 12);
if (!topPages.length) return "";
return `<h2>Материалы</h2><ul class="knowledge-card-list">${topPages
.map(
(record) =>
`<li><a href="${escapeAttr(record.urlPath)}">${escapeHtml(record.node.title)}</a> — ${escapeHtml(pageDescription(record))}</li>`,
)
.join("")}</ul>`;
}
function renderContent(record, bodyHtml) {
const title = visibleTitle(record);
return `<div class="main-pane astro-6ptv7odt">
<main data-pagefind-body class="astro-ept3ui5y" lang="ru" dir="ltr">
<div class="content-panel astro-hxjpthjh">
<div class="sl-container astro-hxjpthjh">
<h1 id="_top" data-page-title class="astro-gldynaio">${escapeHtml(title)}</h1>
<div class="sl-markdown-content">${bodyHtml}</div>
<footer class="sl-flex astro-vcuyfr7s"><div class="meta sl-flex astro-vcuyfr7s"></div></footer>
</div>
</div>
</main>
</div>`;
}
function renderPage({ record = null, bodyHtml, toc = [], searchRecords }) {
const title = pageTitle(record);
const description = pageDescription(record);
const path = record?.urlPath || `/${knowledgeBasePath()}/`;
const canonical = canonicalForPath(path);
const searchPlaceholder = knowledge.settings?.searchPlaceholder || "Поиск";
return `<!doctype html>
<html lang="ru" dir="ltr" data-theme="${escapeAttr(knowledge.settings?.themeDefault || "dark")}" data-has-toc data-has-sidebar class="astro-ept3ui5y">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<link rel="shortcut icon" href="/assets/vendor/ssscript-docs/favicon.svg" type="image/svg+xml">
<meta name="generator" content="Astro v5.18.0">
<meta name="generator" content="Starlight v0.37.7">
<meta name="description" content="${escapeAttr(description)}">
<meta name="robots" content="index,follow,max-image-preview:large">
<meta property="og:title" content="${escapeAttr(title)}">
<meta property="og:type" content="article">
<meta property="og:locale" content="ru">
<meta property="og:description" content="${escapeAttr(description)}">
<meta property="og:site_name" content="NODE.DC">
<meta property="og:url" content="${escapeAttr(canonical)}">
<meta name="twitter:card" content="summary_large_image">
<link rel="canonical" href="${escapeAttr(canonical)}">
${themeProviderScript()}
${renderThemeIconsTemplate()}
<link rel="stylesheet" href="/assets/vendor/ssscript-docs/_astro/print.DNXP8c50.css" media="print">
<link rel="stylesheet" href="/assets/vendor/ssscript-docs/_astro/index.Bh6uiXgK.css">
<link rel="stylesheet" href="/assets/custom/css/knowledge.css">
<script type="module" src="/assets/vendor/ssscript-docs/_astro/page.B1D-nYk3.js"></script>
<script type="application/ld+json">${escapeJson({
"@context": "https://schema.org",
"@type": record ? "Article" : "CollectionPage",
headline: title,
description,
url: canonical,
author: {
"@type": "Organization",
name: home.seo?.organizationName || "DCTOUCH",
},
publisher: {
"@type": "Organization",
name: home.seo?.organizationName || "DCTOUCH",
},
})}</script>
</head>
<body class="astro-ept3ui5y">
<a href="#_top" class="astro-yr4ykvqi">К содержанию</a>
<div class="page sl-flex astro-iyrf6vv4">
${renderHeader(searchPlaceholder)}
${renderSidebar(record)}
<div class="main-frame astro-iyrf6vv4">
<div class="lg:sl-flex astro-6ptv7odt">
${renderRightSidebar(toc)}
${renderContent(record, bodyHtml)}
</div>
</div>
</div>
<script type="application/json" id="knowledge-search-index">${escapeJson(searchRecords)}</script>
${interactionScript()}
${searchScript()}
</body>
</html>
`;
}
function searchScript() {
return `<script>
(() => {
const root = document.querySelector("[data-search]");
const button = root?.querySelector("[data-open-modal]");
const popover = root?.querySelector("[data-search-popover]");
const input = root?.querySelector("[data-search-input]");
const results = root?.querySelector("[data-search-results]");
const index = JSON.parse(document.getElementById("knowledge-search-index")?.textContent || "[]");
const escapeText = (value) => String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
const render = () => {
const query = input.value.trim().toLowerCase();
const matches = query
? index.filter((item) => [item.title, item.description, item.text].join(" ").toLowerCase().includes(query)).slice(0, 8)
: [];
results.innerHTML = !query
? '<div class="knowledge-search-result"><strong>Введите запрос</strong><span>Поиск идет по опубликованным материалам базы знаний.</span></div>'
: matches.length
? matches.map((item) => '<a class="knowledge-search-result" href="' + escapeText(item.url) + '"><strong>' + escapeText(item.title) + '</strong><span>' + escapeText(item.description) + '</span></a>').join("")
: '<div class="knowledge-search-result"><strong>Ничего не найдено</strong><span>Попробуйте другой запрос.</span></div>';
};
const open = () => {
if (!popover || !input) return;
popover.hidden = false;
render();
window.setTimeout(() => input.focus(), 0);
};
const close = () => {
if (popover) popover.hidden = true;
};
button?.addEventListener("click", () => {
if (popover?.hidden) open();
else close();
});
input?.addEventListener("input", render);
document.addEventListener("keydown", (event) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
open();
}
if (event.key === "Escape") close();
});
document.addEventListener("click", (event) => {
if (root && !root.contains(event.target)) close();
});
})();
</script>`;
}
async function writePage(urlPath, html) {
const outputDir = join(repoRoot, urlPath.replace(/^\/+|\/+$/g, ""));
await mkdir(outputDir, { recursive: true });
await writeFile(join(outputDir, "index.html"), 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" })),
];
const sitemapUrl = new URL("sitemap.xml", canonicalHome()).href;
const robots = ["User-agent: *", "Allow: /", `Sitemap: ${sitemapUrl}`, ""].join("\n");
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${sitemapUrls
.map(
(item) => ` <url>
<loc>${escapeXml(item.loc)}</loc>
<changefreq>weekly</changefreq>
<priority>${item.priority}</priority>
</url>`,
)
.join("\n")}
</urlset>
`;
await writeFile(robotsPath, robots);
await writeFile(sitemapPath, sitemap);
}
const records = pageRecords();
const searchRecords = records.map((record) => ({
title: record.node.title || "",
description: pageDescription(record),
url: record.urlPath,
text: stripHtml(renderKnowledgeShortcodes(record.node.bodyHtml || "")),
}));
const indexIntro = normalizeContentAssets(
knowledge.introHtml ||
`<p>${escapeHtml(knowledge.settings?.description || "Материалы NODE.DC для пользователей и поисковых систем.")}</p>`,
);
const indexPrepared = prepareArticleHtml(`${indexIntro}${renderIndexCards(records)}`);
await writePage(`/${knowledgeBasePath()}/`, renderPage({ bodyHtml: indexPrepared.html, toc: indexPrepared.toc, searchRecords }));
for (const record of records) {
const prepared = prepareArticleHtml(record.node.bodyHtml || "");
await writePage(record.urlPath, renderPage({ record, bodyHtml: prepared.html, toc: prepared.toc, searchRecords }));
}
await writeSeoFiles(records);
console.log(`Rendered ${records.length + 1} knowledge pages -> /${knowledgeBasePath()}/`);