NODEDC_SITE/tools/render-home.mjs

398 lines
12 KiB
JavaScript

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("<", "&lt;")
.replaceAll(">", "&gt;");
}
function escapeAttr(value) {
return escapeHtml(value).replaceAll('"', "&quot;").replaceAll("'", "&#39;");
}
function escapeJsString(value) {
return String(value)
.replaceAll("\\", "\\\\")
.replaceAll('"', '\\"')
.replaceAll("\n", "\\n")
.replaceAll("\r", "\\r")
.replaceAll("\u2028", "\\u2028")
.replaceAll("\u2029", "\\u2029")
.replaceAll("</script", "<\\/script");
}
function escapeXml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
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 = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>${escapeXml(canonical)}</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
</urlset>
`;
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(`<div class="c-viz">${cVizRun.join("")}</div>`);
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}`);