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 pagePath = join(repoRoot, "content", "pages", "home.json"); const robotsPath = join(repoRoot, "robots.txt"); const sitemapPath = join(repoRoot, "sitemap.xml"); const templateRoot = join(repoRoot, "templates", "pages", "home"); const shellPath = join(templateRoot, "shell.html"); const blockRoot = join(templateRoot, "blocks"); const REGION_TOKEN = { beforeCViz: "{{NODEDC_BLOCKS_BEFORE_CVIZ}}", cViz: "{{NODEDC_BLOCKS_CVIZ}}", sections: "{{NODEDC_BLOCKS_SECTIONS}}", }; const PAGE_SEO_DEFAULTS = { title: "NODE.DC - платформа для процессов, данных и ИИ-агентов", description: "NODE.DC объединяет Hub, Engine, Ops, прикладные модули и сквозного ассистента для задач, workflow, данных, ролей, интеграций и инженерных моделей.", canonicalUrl: "https://nodedc.dctouch.ru/", robots: "index,follow,max-image-preview:large", siteName: "NODE.DC", locale: "ru_RU", ogImage: "https://nodedc.dctouch.ru/assets/site/images/69b6acdde3123ef078e2b7f3_og-img.jpg", imageAlt: "NODE.DC - платформа для процессов, данных и ИИ-агентов", organizationName: "DCTOUCH", organizationUrl: "https://dctouch.ru/", applicationCategory: "BusinessApplication", }; const page = JSON.parse(await readFile(pagePath, "utf8")); const shell = await readFile(shellPath, "utf8"); const templateCache = new Map(); async function readTemplate(templateName) { if (!templateName || templateName.includes("..") || templateName.includes("/")) { throw new Error(`Unsafe template name: ${templateName}`); } if (!templateCache.has(templateName)) { templateCache.set(templateName, await readFile(join(blockRoot, templateName), "utf8")); } return templateCache.get(templateName); } function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function getByPath(source, path) { return path.split(".").reduce((value, key) => value?.[key], source); } function isTruthy(value) { if (Array.isArray(value)) return value.length > 0; return Boolean(value); } function cloneValue(value) { return value == null ? value : JSON.parse(JSON.stringify(value)); } function allBlocks(model) { return (model.regions ?? []).flatMap((region) => region.blocks ?? []); } function applySeoDefaults(model) { model.seo = { ...PAGE_SEO_DEFAULTS, ...(model.seo || {}), }; } function applySharedDemoRequest(model) { const blocks = allBlocks(model); const source = blocks.find((block) => block.id === "hero-waitlist"); if (!source?.content) return; for (const block of blocks) { if (block.id === source.id || block.type !== "waitlistFiles") continue; block.content ??= {}; block.content.accessCard = cloneValue(source.content.accessCard); block.content.form = cloneValue(source.content.form); block.content.legal ??= { enabled: true, html: "" }; block.content.legal.html = source.content.legalHtml ?? block.content.legal.html ?? ""; } } const LINK_WINDOW_TARGETS = new Set(["_blank", "_self", "_parent", "_top"]); function isWindowTarget(value) { return LINK_WINDOW_TARGETS.has(String(value || "").trim()); } function isExternalHref(value) { return /^(https?:)?\/\//i.test(String(value || "")) || /^(mailto:|tel:)/i.test(String(value || "")); } function isInertHref(value) { const href = String(value || "").trim(); return !href || href === "#" || href === "#hero" || href.toLowerCase().startsWith("javascript:"); } function normalizeLinkForRender(item) { if (!item || typeof item !== "object") return; let href = item.href ?? ""; let target = item.target ?? ""; let linkTarget = item.linkTarget ?? ""; if (isWindowTarget(target)) { linkTarget ||= target; target = ""; } const mode = item.linkMode === "href" || item.linkMode === "target" ? item.linkMode : isExternalHref(href) ? "href" : "target"; if (mode === "target" && !target && href && !isExternalHref(href)) { target = href; href = ""; } const primaryHref = mode === "href" ? href : target; const fallbackHref = mode === "href" ? target : href; item.resolvedHref = primaryHref || fallbackHref || "#"; item.resolvedTarget = mode === "href" ? linkTarget : ""; } function normalizeDockItemForRender(item) { if (!item || typeof item !== "object") return; const href = String(item.href || "").trim(); const opensWindow = !isInertHref(href); item.resolvedDockHref = href || "#"; item.resolvedDockTarget = opensWindow ? "_blank" : ""; item.resolvedDockRel = opensWindow ? "noopener noreferrer" : ""; } function applyRenderableLinks(model) { const staticLinks = model.staticElements?.content?.navigation?.links; if (Array.isArray(staticLinks)) { staticLinks.forEach(normalizeLinkForRender); } const dockItems = model.staticElements?.content?.dock?.items; if (Array.isArray(dockItems)) { dockItems.forEach(normalizeDockItemForRender); } for (const block of allBlocks(model)) { const footer = block.content?.footer; if (!footer) continue; if (Array.isArray(footer.navLinks)) footer.navLinks.forEach(normalizeLinkForRender); if (Array.isArray(footer.contactLinks)) footer.contactLinks.forEach(normalizeLinkForRender); } } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">"); } function escapeAttr(value) { return escapeHtml(value).replaceAll('"', """).replaceAll("'", "'"); } function escapeJsString(value) { return String(value) .replaceAll("\\", "\\\\") .replaceAll('"', '\\"') .replaceAll("\n", "\\n") .replaceAll("\r", "\\r") .replaceAll("\u2028", "\\u2028") .replaceAll("\u2029", "\\u2029") .replaceAll("", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function canonicalUrl(model) { const fallback = "https://nodedc.dctouch.ru/"; const value = String(model.seo?.canonicalUrl || fallback).trim() || fallback; try { const url = new URL(value); return url.href; } catch { return fallback; } } async function writeSeoFiles(model) { const canonical = canonicalUrl(model); const sitemapUrl = new URL("sitemap.xml", canonical).href; const robots = ["User-agent: *", "Allow: /", `Sitemap: ${sitemapUrl}`, ""].join("\n"); const sitemap = ` ${escapeXml(canonical)} weekly 1.0 `; await writeFile(robotsPath, robots); await writeFile(sitemapPath, sitemap); } function renderTemplateTokens(html, block) { return html.replace(/\{\{\s*(text|html|attr|js):([a-zA-Z0-9_.-]+)\s*\}\}/g, (_match, mode, path) => { const value = getByPath(block, path); if (value === undefined) { throw new Error(`Missing token value "${path}" in block "${block.id}"`); } if (mode === "html") { return value == null ? "" : String(value); } if (mode === "attr") { return value == null ? "" : escapeAttr(value); } if (mode === "js") { return value == null ? "" : escapeJsString(value); } return value == null ? "" : escapeHtml(value); }); } function renderIfBlocks(html, block) { let rendered = html; let previous = null; while (rendered !== previous) { previous = rendered; rendered = rendered.replace( /\{\{#if\s+([a-zA-Z0-9_.-]+)\s*\}\}((?:(?!\{\{#if|\{\{\/if\}\})[\s\S])*)\{\{\/if\}\}/g, (_match, path, innerHtml) => { const value = getByPath(block, path); return isTruthy(value) ? renderTemplate(innerHtml, block) : ""; }, ); } return rendered; } function renderEachBlocks(html, block) { return html.replace(/\{\{#each\s+([a-zA-Z0-9_.-]+)\s*\}\}([\s\S]*?)\{\{\/each\}\}/g, (_match, path, innerHtml) => { const items = getByPath(block, path); if (!Array.isArray(items)) { throw new Error(`Each token "${path}" in block "${block.id}" must point to an array`); } return items .map((item) => { const itemScope = item && typeof item === "object" && !Array.isArray(item) ? item : { value: item }; return renderTemplate(innerHtml, { ...block, ...itemScope }); }) .join(""); }); } function renderTemplate(html, block) { return renderTemplateTokens(renderIfBlocks(renderEachBlocks(html, block), block), block); } function scopeDuplicateIds(html, scope) { const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]); if (!ids.length) { return html; } let scoped = html.replace(/\sid="([^"]+)"/g, (_match, id) => ` id="${scope}-${id}"`); for (const id of ids) { const escaped = escapeRegExp(id); scoped = scoped.replace( new RegExp(`\\s(data-target|for|aria-controls|aria-labelledby|aria-describedby)="${escaped}"`, "g"), (_match, attr) => ` ${attr}="${scope}-${id}"`, ); scoped = scoped.replace(new RegExp(`(href="[^"]*)#${escaped}(")`, "g"), `$1#${scope}-${id}$2`); } return scoped; } async function renderBlock(block, renderCounts) { if (block.enabled === false) { return ""; } const templateName = block.template; const renderCount = renderCounts.get(templateName) ?? 0; renderCounts.set(templateName, renderCount + 1); const html = renderTemplate(await readTemplate(templateName), block); if (renderCount === 0 && block.scopeIds !== true) { return html; } return scopeDuplicateIds(html, block.id); } async function renderRegion(region) { const renderCounts = new Map(); const blocks = []; for (const block of region.blocks ?? []) { blocks.push(await renderBlock(block, renderCounts)); } return blocks.join(""); } async function renderSections() { const renderCounts = new Map(); const output = []; let cVizRun = []; const flushCVizRun = () => { if (!cVizRun.length) return; output.push(`
${cVizRun.join("")}
`); cVizRun = []; }; for (const region of page.regions ?? []) { for (const block of region.blocks ?? []) { const html = await renderBlock(block, renderCounts); if (!html) continue; if (block.region === "cViz") { cVizRun.push(html); } else { flushCVizRun(); output.push(html); } } } flushCVizRun(); return output.join(""); } applySeoDefaults(page); applySharedDemoRequest(page); applyRenderableLinks(page); let output = renderTemplate(shell, { id: page.slug, ...page }); if (output.includes(REGION_TOKEN.sections)) { output = output.replace(REGION_TOKEN.sections, await renderSections()); } else { for (const region of page.regions ?? []) { const token = REGION_TOKEN[region.id]; if (!token) { throw new Error(`Unknown region: ${region.id}`); } output = output.replace(token, await renderRegion(region)); } } for (const token of Object.values(REGION_TOKEN)) { if (output.includes(token)) { throw new Error(`Unrendered token left in shell: ${token}`); } } const outputPath = join(repoRoot, page.output || "index.html"); await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, output); await writeSeoFiles(page); console.log(`Rendered ${page.slug} -> ${outputPath}`);