127 lines
4.1 KiB
JavaScript
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,
|
|
};
|