NODEDC_SITE/tools/render-home.mjs

200 lines
5.6 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 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 isTruthy(value) {
if (Array.isArray(value)) return value.length > 0;
return Boolean(value);
}
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 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("");
}
let output = renderTemplate(shell, { id: page.slug, ...page });
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}`);