// Phase-2 layout "bones": the layoutTree schema, the structural node-type set // (the server-side resolver mirror), built-in PRESETS, and validation. A theme's // layout_tree (stored JSON) is one of these trees; renderTree.js emits HTML from // it. Pure module -- no @saltcorn, no DB. // // A node is { type, props?, children? }. Slots are filled at render time from the // wrap() context (brand/menu -> Navbar/Sidebar, body -> Content, alerts -> toasts). // The structural subset every serialized tree may use (resolver completeness: // renderTree must handle each of these or it falls back to a passthrough). const NODE_TYPES = ["Root", "Navbar", "Sidebar", "Content", "Footer", "Row", "Col", "Html", "AlertsSlot"]; const MAX_NODES = 200; const MAX_DEPTH = 12; // ---- built-in presets ------------------------------------------------------ const TOPNAV = Object.freeze({ type: "Root", props: { className: "min-vh-100 d-flex flex-column" }, children: [ { type: "Navbar", props: { variant: "dark", bg: "primary", expand: "lg", brand: true, menu: true, fluid: false } }, { type: "Content", props: { container: "container" } }, { type: "Footer", props: { text: "" } }, ], }); const SIDEBAR = Object.freeze({ type: "Root", props: { className: "min-vh-100 d-flex" }, children: [ { type: "Sidebar", props: { variant: "dark", bg: "dark", brand: true, menu: true, width: "240px" } }, { type: "Content", props: { container: "fluid", grow: true } }, ], }); const PRESETS = Object.freeze([ Object.freeze({ id: "topnav", label: "Top navbar", tree: TOPNAV }), Object.freeze({ id: "sidebar", label: "Left sidebar", tree: SIDEBAR }), ]); const DEFAULT_LAYOUT_TREE = TOPNAV; function listPresets() { return PRESETS.map((p) => ({ id: p.id, label: p.label })); } function getPreset(id) { const p = PRESETS.find((x) => x.id === id); return p ? structuredClone(p.tree) : null; } // ---- validation ------------------------------------------------------------ function isPlainObject(x) { return !!x && typeof x === "object" && !Array.isArray(x); } // Walk the tree counting nodes / Content slots and checking node types + depth. function walk(node, depth, acc) { if (!isPlainObject(node)) { acc.errors.push("node is not an object"); return; } acc.count += 1; if (acc.count > MAX_NODES) { acc.errors.push("too many nodes"); return; } if (depth > MAX_DEPTH) { acc.errors.push("tree too deep"); return; } if (!NODE_TYPES.includes(node.type)) { acc.errors.push(`unknown node type: ${node.type}`); } if (node.type === "Content") { acc.contentSlots += 1; } if (node.children != null) { if (!Array.isArray(node.children)) { acc.errors.push(`${node.type}.children must be an array`); } else { for (const c of node.children) { walk(c, depth + 1, acc); } } } } // Returns { ok, errors[] }. A valid tree has a Root, exactly one Content slot, // only known node types, and is within the size/depth caps. function validateLayoutTree(tree) { const acc = { errors: [], count: 0, contentSlots: 0 }; if (!isPlainObject(tree)) { return { ok: false, errors: ["layoutTree must be an object"] }; } if (tree.type !== "Root") { acc.errors.push("layoutTree root must be a Root node"); } walk(tree, 0, acc); if (acc.contentSlots !== 1) { acc.errors.push(`exactly one Content slot required (found ${acc.contentSlots})`); } return { ok: acc.errors.length === 0, errors: acc.errors }; } // Return the tree if it is valid, else the default preset (never white-screen). function normalizeLayoutTree(tree) { if (tree == null) { return structuredClone(DEFAULT_LAYOUT_TREE); } return validateLayoutTree(tree).ok ? tree : structuredClone(DEFAULT_LAYOUT_TREE); } module.exports = { NODE_TYPES, MAX_NODES, MAX_DEPTH, PRESETS, DEFAULT_LAYOUT_TREE, listPresets, getPreset, validateLayoutTree, normalizeLayoutTree, };