// 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, "&").replace(//g, ">")
.replace(/"/g, """).replace(/'/g, "'");
}
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 ? ` ` : "";
return `${logo}${esc(brand.name || "")} `;
}
// 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 ? ` ` : "";
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) => `
${esc(s.label || "")} `)
.join("");
items.push(
`${icon}${esc(it.label || "")} `
);
} else {
items.push(`${icon}${esc(it.label || "")} `);
}
}
}
return ``;
}
function renderToasts(alerts) {
if (!Array.isArray(alerts) || alerts.length === 0) {
return "";
}
const items = alerts
.map((a) => `${a.msg || ""}
`)
.join("");
return `${items}
`;
}
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 `${renderChildren(node, ctx)}
`;
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
? ` `
+ `${renderMenu(ctx.menu, "navbar", ctx)}
`
: "";
return `${brand}${menu}
`;
}
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 ? `${renderBrand(ctx.brand)}
` : "";
const menu = p.menu ? renderMenu(ctx.menu, "sidebar", ctx) : "";
return ``;
}
case "Content": {
ctx._bodyEmitted = true;
const container = p.container === "fluid" ? "container-fluid" : "container";
const grow = p.grow ? " flex-grow-1" : "";
return `${ctx.body || ""}
`;
}
case "Footer": {
const inner = p.html != null ? String(p.html) : esc(p.text || "");
return ``;
}
case "Row":
return `${renderChildren(node, ctx)}
`;
case "Col":
return `${renderChildren(node, ctx)}
`;
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 += `${ctx.body || ""}
`;
}
if (!c._alertsEmitted) {
html += renderToasts(ctx.alerts);
}
return html;
}
module.exports = { renderTreeToHtml, renderMenu, renderBrand, renderToasts, esc };