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 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}}", }; 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 escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">"); } function escapeAttr(value) { return escapeHtml(value).replaceAll('"', """).replaceAll("'", "'"); } function renderTemplateTokens(html, block) { return html.replace(/\{\{\s*(text|html|attr):([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); } return value == null ? "" : escapeHtml(value); }); } 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 = renderTemplateTokens(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(""); } let output = shell; 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); console.log(`Rendered ${page.slug} -> ${outputPath}`);