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

127 lines
4.1 KiB
JavaScript

// 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,
};