// 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( `` ); } else { items.push(``); } } } return ``; } function renderToasts(alerts) { if (!Array.isArray(alerts) || alerts.length === 0) { return ""; } const items = alerts .map((a) => ``) .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 ? `` + `` : ""; return ``; } 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 };