NODEDC_SITE/tools/render-home.mjs

527 lines
15 KiB
JavaScript
Raw 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 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");
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 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;
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 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">
<url>
<loc>${escapeXml(canonical)}</loc>
<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>
`;
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;
}
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 "";
}
const templateName = block.template;
const renderCount = renderCounts.get(templateName) ?? 0;
renderCounts.set(templateName, renderCount + 1);
const html = renderTemplate(await readTemplate(templateName), block);
const rendered = renderCount === 0 && block.scopeIds !== true ? html : scopeDuplicateIds(html, block.id);
return withLandingTarget(rendered, block);
}
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);
validateLandingTargets(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}`);