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("<", "<") .replaceAll(">", ">"); } function escapeAttr(value) { return escapeHtml(value).replaceAll('"', """).replaceAll("'", "'"); } function escapeXml(value) { return escapeHtml(value).replaceAll('"', """).replaceAll("'", "'"); } function escapeJson(value) { return JSON.stringify(value).replaceAll("/gi, " ") .replace(//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(""", '"') .replaceAll(""", '"') .replaceAll("'", "'") .replaceAll("'", "'") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll("&", "&"); } 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 `${escapeHtml(label)}`; }); } 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 ``; } function searchIcon() { return ''; } function anchorIcon(id, title) { return `Раздел «${escapeHtml(title)}»`; } 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(/]*)>([\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 `
${inner}${anchorIcon(id, title)}
`; }); 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 `
  • ${title}
  • `; } const restoreIndex = restoreCounter.value; restoreCounter.value += 1; const open = currentIds.has(record.node.id) || record.parents.length === 0; const label = nodeHasPage(record.node) ? `${title}` : `${title}`; return `
  • ${label}${caretIcon()}
      ${children}
  • `; } 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 ``; } function menuIcons() { return ``; } 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 `
  • ${escapeHtml(item.title)}
  • `; }) .join(""); } function renderRightSidebar(toc) { const items = renderTocItems(toc); const mobileItems = renderTocItems(toc, true); return ``; } function renderThemeIconsTemplate() { return ``; } function renderThemeSelect() { return ``; } function themeProviderScript() { const defaultTheme = escapeAttr(knowledge.settings?.themeDefault || "dark"); return ``; } function interactionScript() { return ``; } 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 `
    ${renderThemeSelect()}
    `; } 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 `

    Материалы

    `; } function renderContent(record, bodyHtml) { const title = visibleTitle(record); return `

    ${escapeHtml(title)}

    ${bodyHtml}
    `; } 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 ` ${escapeHtml(title)} ${themeProviderScript()} ${renderThemeIconsTemplate()} К содержанию
    ${renderHeader(searchPlaceholder)} ${renderSidebar(record)}
    ${renderRightSidebar(toc)} ${renderContent(record, bodyHtml)}
    ${interactionScript()} ${searchScript()} `; } function searchScript() { return ``; } 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 = ` ${sitemapUrls .map( (item) => ` ${escapeXml(item.loc)} weekly ${item.priority} `, ) .join("\n")} `; 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 || `

    ${escapeHtml(knowledge.settings?.description || "Материалы NODE.DC для пользователей и поисковых систем.")}

    `, ); 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()}/`);