527 lines
15 KiB
JavaScript
527 lines
15 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 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("<", "<")
|
||
.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("</script", "<\\/script");
|
||
}
|
||
|
||
function escapeXml(value) {
|
||
return String(value)
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
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}`);
|