NODEDC_SITE/tools/render-knowledge.mjs

770 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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("&", "&")
.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.seo?.title || 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.seo?.description || 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.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");
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()}/`);