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

81 lines
4.3 KiB
JavaScript

// lib/layout.js
// layout(cfg) -- Phase-1 returns undefined; Phase-2 returns a real PluginLayout
// (ARCHITECTURE.md 5.1, 7.10). When Phase 2 is enabled the plugin registers into
// state.layouts["theme-builder"] (state.ts:1113-1117) and is selectable via
// layout_by_role[role] === "theme-builder" (or user._attributes.layout).
//
// wrap() OWNS the document: it links the vendored stock Bootstrap CSS + the
// theme overlay (var(--bs-*)) CSS, renders the page chrome from the active
// layoutTree (renderTree.js), and threads Saltcorn's headersInHead/Body. Because
// wrap() emits the token <link> itself, headers(cfg)'s only_if suppresses the
// Phase-1 overlay header for theme-builder-served requests (7.6). Sass is the
// documented opt-in; the default engine is the overlay on stock Bootstrap.
const { PLUGIN_NAME, CSS_ROUTE } = require("./constants");
const { isLayoutMode, activeHashHint, activeLayoutTree } = require("./cfgReaders");
const { renderTreeToHtml, renderToasts, esc } = require("./renderTree");
const { headersInHead, headersInBody } = require("@saltcorn/markup/layout_utils");
const { renderForm } = require("@saltcorn/markup");
const ASSET_BASE = `/plugins/public/${PLUGIN_NAME}`;
const BOOTSTRAP_CSS = `${ASSET_BASE}/themeBootstrap.min.css`;
const BOOTSTRAP_JS = `${ASSET_BASE}/themeBootstrap.bundle.min.js`;
function headLinks(cfg, role, headers) {
const v = activeHashHint(cfg, role);
return `<meta charset="utf-8">`
+ `<meta name="viewport" content="width=device-width, initial-scale=1">`
+ `<link rel="stylesheet" href="${BOOTSTRAP_CSS}">`
+ `<link rel="stylesheet" href="${CSS_ROUTE}?v=${v}">`
+ headersInHead(headers || []);
}
function layout(cfg) {
if (!isLayoutMode(cfg)) {
return undefined; // falsy => not registered as a layout (layout mode off)
}
return {
pluginName: PLUGIN_NAME,
wrap: ({ title, body, brand, menu, alerts, headers, bodyClass, role, req, currentUrl } = {}) => {
const chrome = renderTreeToHtml(activeLayoutTree(cfg, role), {
title, body, brand, menu, alerts: alerts || [], role, req, currentUrl,
});
return `<!doctype html><html lang="en"><head>${headLinks(cfg, role, headers)}`
+ `<title>${esc(title || "")}</title></head>`
+ `<body class="${esc(bodyClass || "")}">${chrome}`
+ `<script src="${BOOTSTRAP_JS}"></script>${headersInBody(headers || [])}`
+ `</body></html>`;
},
// Auth pages (login/signup): a centered card. The login/signup FORM arrives
// as a Saltcorn Form OBJECT under `form` and MUST be rendered via
// renderForm(form, csrfToken) -- it carries the _csrf field. authLinks are
// the login/forgot/signup cross-links; afterForm is extra HTML.
authWrap: ({ title, form, afterForm, authLinks, alerts, headers, csrfToken, role, req } = {}) => {
const links = [];
if (authLinks) {
if (authLinks.login) links.push(`<a href="${esc(authLinks.login)}">Login</a>`);
if (authLinks.forgot) links.push(`<a href="${esc(authLinks.forgot)}">Forgot password?</a>`);
if (authLinks.signup) links.push(`<a href="${esc(authLinks.signup)}">Create an account</a>`);
}
const formHtml = form ? renderForm(form, csrfToken || "") : "";
return `<!doctype html><html lang="en"><head>${headLinks(cfg, role, headers)}`
+ `<title>${esc(title || "")}</title></head>`
+ `<body class="tb-auth"><div class="container" style="max-width:28rem"><div class="py-5">`
+ `<div class="card shadow-sm"><div class="card-body p-4">`
+ `<h1 class="h4 mb-3 text-center">${esc(title || "")}</h1>`
+ `${formHtml}<div class="mt-3 text-center small">${links.join(" | ")}</div>${afterForm || ""}`
+ `</div></div></div></div>${renderToasts(alerts || [])}`
+ `<script src="${BOOTSTRAP_JS}"></script>${headersInBody(headers || [])}`
+ `</body></html>`;
},
// renderBody: passthrough -- the body content is already rendered HTML.
renderBody: ({ body } = {}) => body || "",
};
}
module.exports = { layout };