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

112 lines
3.8 KiB
JavaScript

// lib/page.js
// The Builder SPA shell page (ARCHITECTURE.md 8.2).
//
// renderEditorShell(req, res) builds a COMPLETE, dependency-free HTML document
// (no React in the served shell) that:
// (a) loads the compiled Phase-1 editor from the plugin's static public dir,
// /plugins/public/theme-builder/builderApp.js (plugins.js:1246-1276),
// (b) bootstraps window.__TB__ = { apiBase, cssRoute, csrfToken } -- the CSRF
// token is read from req exactly the way core does it (req.csrfToken(),
// e.g. server/wrapper.js:259, server/app.js:74 where it defaults to ""),
// (c) exposes a single mount point <div id="theme-builder-root"> the editor
// attaches to.
//
// The function RETURNS the complete document string; when a res is supplied
// (the apiHandlers.getEditor path) it also res.send()s that document. We send a
// standalone document rather than res.sendWrap()'ing a body fragment so the SPA
// owns the whole page and stays free of the core React chrome -- per the task's
// "COMPLETE HTML document / dependency-free shell" contract.
const { API_BASE, CSS_ROUTE, URL_PREFIX, PLUGIN_NAME } = require("./constants");
// The plugin's committed editor bundle, served by core's static plugin route.
const BUNDLE_URL = `/plugins/public/${PLUGIN_NAME}/builderApp.js`;
// Mirror core's CSRF read: req.csrfToken is a function (csurf adds it; in some
// modes app.js stubs it to return ""). Guard so a missing token never throws.
function readCsrfToken(req) {
if (req && typeof req.csrfToken === "function") {
try {
return req.csrfToken() || "";
} catch (e) {
return "";
}
}
return "";
}
// Minimal HTML-escaper for the <title> and any attribute text. The JSON blob is
// emitted via JSON.stringify with "<" escaped so it cannot break out of the
// <script> element.
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// JSON for inline <script>: prevent "</script>" / "<!--" from terminating the
// element, and keep it ASCII-only.
function safeJson(value) {
return JSON.stringify(value)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(/[\u2028]/g, "\\u2028")
.replace(/[\u2029]/g, "\\u2029");
}
// Build the complete HTML document string. openThemeId (?id=) lets a deep-link
// open straight into a theme; the editor reads it from the boot blob.
function buildShellHtml(req) {
const csrfToken = readCsrfToken(req);
const openThemeId = (req && req.query && req.query.id) ? String(req.query.id) : null;
const boot = {
apiBase: API_BASE,
cssRoute: CSS_ROUTE,
base: URL_PREFIX,
csrfToken,
openThemeId,
};
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>${escapeHtml("Theme Builder")}</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
#theme-builder-root { height: 100%; }
#theme-builder-root .tb-loading { padding: 2rem; color: #555; }
</style>
</head>
<body>
<div id="theme-builder-root"><div class="tb-loading">Loading Theme Builder...</div></div>
<script>window.__TB__ = ${safeJson(boot)};</script>
<script src="${BUNDLE_URL}" defer></script>
</body>
</html>
`;
}
// Public entry point. Returns the document string; if res is provided, sends it.
function renderEditorShell(req, res) {
const html = buildShellHtml(req);
if (res && typeof res.send === "function") {
res.set("Content-Type", "text/html; charset=utf-8");
res.send(html);
}
return html;
}
module.exports = { renderEditorShell, buildShellHtml, BUNDLE_URL };