154 lines
6.8 KiB
JavaScript
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, "&").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 ? `<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 };
|