262 lines
7.2 KiB
JavaScript
262 lines
7.2 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}}",
|
|
sections: "{{NODEDC_BLOCKS_SECTIONS}}",
|
|
};
|
|
|
|
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 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 ?? "";
|
|
}
|
|
}
|
|
|
|
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 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("");
|
|
}
|
|
|
|
applySharedDemoRequest(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);
|
|
|
|
console.log(`Rendered ${page.slug} -> ${outputPath}`);
|