sc-theme-builder/lib/renderTree.js
2026-07-01 20:07:28 -05:00

154 lines
6.8 KiB
JavaScript

// Phase-2 server-side tree -> HTML emitter. Walks a layoutTree (see layoutTree.js)
// and produces the page chrome, filling slots from the wrap() context:
// brand/menu -> Navbar/Sidebar, body -> Content, alerts -> toasts.
//
// body/menu/brand are server-generated HTML from Saltcorn (trusted) and are NOT
// escaped; only admin/user TEXT (brand name, footer text) is escaped. Pure module.
const { normalizeLayoutTree } = require("./layoutTree");
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function bsAlertType(t) {
const map = { error: "danger", danger: "danger", success: "success", warning: "warning", info: "info", primary: "primary" };
return map[t] || "info";
}
function renderBrand(brand) {
if (!brand) {
return "";
}
if (typeof brand === "string") {
return brand; // already HTML
}
const logo = brand.logo ? `<img src="${esc(brand.logo)}" height="30" class="me-2" alt="">` : "";
return `<a class="navbar-brand" href="/">${logo}${esc(brand.name || "")}</a>`;
}
// Render Saltcorn's menu array into Bootstrap nav markup. Defensive about shape:
// menu is an array of sections, each with an `items` array (or an item itself).
function renderMenu(menu, variant, ctx) {
if (!Array.isArray(menu) || menu.length === 0) {
return "";
}
const ulClass = variant === "sidebar" ? "nav flex-column" : "navbar-nav me-auto mb-2 mb-lg-0";
const items = [];
for (const section of menu) {
const list = Array.isArray(section?.items) ? section.items : [section];
for (const it of list) {
if (!it || (it.label == null && it.link == null)) {
continue;
}
const icon = it.icon ? `<i class="${esc(it.icon)}"></i> ` : "";
const active = it.link && it.link === ctx.currentUrl ? " active" : "";
if (Array.isArray(it.subitems) && it.subitems.length) {
const sub = it.subitems
.filter((s) => s && (s.label != null || s.link != null))
.map((s) => `<li><a class="dropdown-item" href="${esc(s.link || "#")}">${esc(s.label || "")}</a></li>`)
.join("");
items.push(
`<li class="nav-item dropdown"><a class="nav-link dropdown-toggle${active}" href="#" role="button" data-bs-toggle="dropdown">${icon}${esc(it.label || "")}</a><ul class="dropdown-menu">${sub}</ul></li>`
);
} else {
items.push(`<li class="nav-item"><a class="nav-link${active}" href="${esc(it.link || "#")}">${icon}${esc(it.label || "")}</a></li>`);
}
}
}
return `<ul class="${ulClass}">${items.join("")}</ul>`;
}
function renderToasts(alerts) {
if (!Array.isArray(alerts) || alerts.length === 0) {
return "";
}
const items = alerts
.map((a) => `<div class="alert alert-${bsAlertType(a.type)} shadow-sm" role="alert">${a.msg || ""}</div>`)
.join("");
return `<div class="tb-alerts position-fixed top-0 end-0 p-3" style="z-index:1080;max-width:90vw">${items}</div>`;
}
function renderChildren(node, ctx) {
if (!Array.isArray(node.children)) {
return "";
}
return node.children.map((c) => renderNode(c, ctx)).join("");
}
function renderNode(node, ctx) {
const p = node.props || {};
switch (node.type) {
case "Root":
return `<div id="tb-root" class="${esc(p.className || "")}">${renderChildren(node, ctx)}</div>`;
case "Navbar": {
const variantClass = p.variant === "light" ? "navbar-light" : "navbar-dark";
const expand = `navbar-expand-${p.expand || "lg"}`;
const bg = p.bg ? `bg-${esc(p.bg)}` : "";
const brand = p.brand ? renderBrand(ctx.brand) : "";
const menu = p.menu
? `<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#tbNav" aria-label="Toggle navigation"><span class="navbar-toggler-icon"></span></button>`
+ `<div class="collapse navbar-collapse" id="tbNav">${renderMenu(ctx.menu, "navbar", ctx)}</div>`
: "";
return `<nav class="navbar ${expand} ${variantClass} ${bg}"><div class="container${p.fluid ? "-fluid" : ""}">${brand}${menu}</div></nav>`;
}
case "Sidebar": {
const variantClass = p.variant === "light" ? "navbar-light" : "navbar-dark";
const bg = p.bg ? `bg-${esc(p.bg)}` : "bg-dark";
const brand = p.brand ? `<div class="mb-3">${renderBrand(ctx.brand)}</div>` : "";
const menu = p.menu ? renderMenu(ctx.menu, "sidebar", ctx) : "";
return `<aside class="tb-sidebar ${variantClass} ${bg} p-3" style="width:${esc(p.width || "240px")};min-height:100vh">${brand}${menu}</aside>`;
}
case "Content": {
ctx._bodyEmitted = true;
const container = p.container === "fluid" ? "container-fluid" : "container";
const grow = p.grow ? " flex-grow-1" : "";
return `<main class="tb-content${grow}" style="min-width:0"><div class="${container} py-3">${ctx.body || ""}</div></main>`;
}
case "Footer": {
const inner = p.html != null ? String(p.html) : esc(p.text || "");
return `<footer class="tb-footer border-top py-3 mt-auto text-center text-muted small"><div class="container">${inner}</div></footer>`;
}
case "Row":
return `<div class="row ${esc(p.className || "")}">${renderChildren(node, ctx)}</div>`;
case "Col":
return `<div class="col ${esc(p.className || "")}">${renderChildren(node, ctx)}</div>`;
case "Html":
return p.html != null ? String(p.html) : "";
case "AlertsSlot":
ctx._alertsEmitted = true;
return renderToasts(ctx.alerts);
default:
// resolver fallback: render children so an unknown wrapper never drops the body
return renderChildren(node, ctx);
}
}
// renderTreeToHtml(tree, ctx) -> HTML string. ctx = {title, body, brand, menu,
// alerts, role, req, currentUrl}. Guarantees the body and alerts are emitted even
// if the tree omits a Content/AlertsSlot node (never silently drop page content).
function renderTreeToHtml(tree, ctx) {
const t = normalizeLayoutTree(tree);
const c = { ...ctx, _bodyEmitted: false, _alertsEmitted: false };
let html = renderNode(t, c);
if (!c._bodyEmitted) {
html += `<div class="container py-3">${ctx.body || ""}</div>`;
}
if (!c._alertsEmitted) {
html += renderToasts(ctx.alerts);
}
return html;
}
module.exports = { renderTreeToHtml, renderMenu, renderBrand, renderToasts, esc };