From ffba038b35162685788b7e264c78ab41e64b12a1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 6 Aug 2026 16:37:42 -0400 Subject: [PATCH] fix: restore inspector template hot reload --- bliss-cli/live-editor/artifact-editor.eta | 57 +++++++++++ bliss-cli/live-editor/install.js | 1 + bliss-cli/live-editor/route-editor.js | 9 +- bliss-cli/live-editor/template-editor.js | 9 +- db.js | 51 +++++++++- index.js | 23 +++++ public/js/bliss_inspector.js | 117 +++++++++++++++++----- 7 files changed, 232 insertions(+), 35 deletions(-) create mode 100644 bliss-cli/live-editor/artifact-editor.eta diff --git a/bliss-cli/live-editor/artifact-editor.eta b/bliss-cli/live-editor/artifact-editor.eta new file mode 100644 index 0000000..7e5c1a8 --- /dev/null +++ b/bliss-cli/live-editor/artifact-editor.eta @@ -0,0 +1,57 @@ +
+
+ <% if (it.kind === 'route') { %><%= it.artifact.verb %> <%= it.artifact.path %><% } else { %><%= it.artifact.name %><% } %> + <%= it.kind %> +
+
+ <% if (it.kind === 'route' && it.artifact.verb !== 'GET') { %> +

Saving does not replay this <%= it.artifact.verb %> route.

+ <% } %> + +
<%= it.kind === 'route' ? it.artifact.handler : it.artifact.content %>
+ <% if (it.kind === 'template') { %> +
+ Rendering test +
<%= it.artifact.test_object || '' %>
+ +
+ <% } %> + +
+
+ diff --git a/bliss-cli/live-editor/install.js b/bliss-cli/live-editor/install.js index d7eebcd..9ca0487 100644 --- a/bliss-cli/live-editor/install.js +++ b/bliss-cli/live-editor/install.js @@ -40,6 +40,7 @@ const templates = [ ["inspector/slideout", "slideout.eta"], ["inspector/route_editor", "route-editor.eta"], ["inspector/template_editor", "template-editor.eta"], + ["inspector/artifact_editor", "artifact-editor.eta"], ]; for (const [name, filename] of templates) { let template = state.templates.find((item) => item.name === name); diff --git a/bliss-cli/live-editor/route-editor.js b/bliss-cli/live-editor/route-editor.js index 7b1a13d..85e2cb9 100644 --- a/bliss-cli/live-editor/route-editor.js +++ b/bliss-cli/live-editor/route-editor.js @@ -1,8 +1,7 @@ function handler(req, res) { const sql = require("db")("bliss").sql; - const sourceRoute = sql - .prepare("SELECT * FROM routes WHERE id = ?") - .get(req.params.id); - if (!sourceRoute) return res.status(404).send("Route not found"); - res.render("inspector/route_editor", { sourceRoute }); + const kind = "route"; + const artifact = sql.prepare("SELECT * FROM routes WHERE id = ?").get(req.params.id); + if (!artifact) return res.status(404).send("Route not found"); + res.render("inspector/artifact_editor", { kind, artifact }); } diff --git a/bliss-cli/live-editor/template-editor.js b/bliss-cli/live-editor/template-editor.js index e97f348..00843a1 100644 --- a/bliss-cli/live-editor/template-editor.js +++ b/bliss-cli/live-editor/template-editor.js @@ -1,8 +1,7 @@ function handler(req, res) { const sql = require("db")("bliss").sql; - const sourceTemplate = sql - .prepare("SELECT * FROM templates WHERE id = ?") - .get(req.params.id); - if (!sourceTemplate) return res.status(404).send("Template not found"); - res.render("inspector/template_editor", { sourceTemplate }); + const kind = "template"; + const artifact = sql.prepare("SELECT * FROM templates WHERE id = ?").get(req.params.id); + if (!artifact) return res.status(404).send("Template not found"); + res.render("inspector/artifact_editor", { kind, artifact }); } diff --git a/db.js b/db.js index 1fd0556..da94c1c 100644 --- a/db.js +++ b/db.js @@ -2,6 +2,7 @@ const fs = require("fs"); const path = require("path"); const betterSqlite3 = require("better-sqlite3"); const { LRUCache } = require("lru-cache"); +const { randomUUID } = require("node:crypto"); const { Eta } = require("eta"); const cheerio = require("cheerio"); @@ -95,8 +96,37 @@ function getAllRoutes() { } const templateCache = new LRUCache({ max: 100 }); +const templateContextCache = new LRUCache({ max: 2000, ttl: 30 * 60 * 1000 }); -function annotateTemplateHtml(html, template) { +// The inspector needs the values that produced each rendered template, not a +// fresh request's values. Contexts frequently contain helpers (such as +// `route`) or cyclic request objects, neither of which belongs in HTML. Keep +// the JSON-shaped portion and omit the rest rather than making a page fail to +// render just because it cannot be inspected. +function inspectableContext(data) { + const seen = new WeakSet(); + try { + return JSON.parse( + JSON.stringify(data ?? {}, (_key, value) => { + if (typeof value === "function" || typeof value === "undefined") { + return undefined; + } + if (typeof value === "object" && value !== null) { + if (seen.has(value)) return undefined; + seen.add(value); + } + return value; + }), + ); + } catch (_) { + return {}; + } +} + +function annotateTemplateHtml(html, template, data) { + const context = inspectableContext(data); + const contextId = randomUUID(); + templateContextCache.set(contextId, { templateId: String(template.id), context }); function annotate($, element) { const current = $(element).attr("data-bliss-templates"); let templates = []; @@ -109,10 +139,20 @@ function annotateTemplateHtml(html, template) { templates.push({ id: String(template.id), name: template.name }); } $(element).attr("data-bliss-templates", JSON.stringify(templates)); + const rawContextIds = $(element).attr("data-bliss-template-context-ids"); + let contextIds = {}; + if (rawContextIds) { + try { + contextIds = JSON.parse(rawContextIds); + } catch (_) {} + } + contextIds[String(template.id)] = contextId; + $(element).attr("data-bliss-template-context-ids", JSON.stringify(contextIds)); if (!$(element).attr("data-bliss-template-id")) { $(element).attr({ "data-bliss-template-id": String(template.id), "data-bliss-template-name": template.name, + "data-bliss-template-context-id": contextId, }); } } @@ -143,7 +183,7 @@ function getTemplater(structId) { const html = etaRender.call(this, templateName, data, meta); if (typeof templateName !== "string") return html; const template = getTemplateByName(structId, templateName); - return template ? annotateTemplateHtml(html, template) : html; + return template ? annotateTemplateHtml(html, template, data) : html; }; templateCache.set(structId, etaInstance); } @@ -302,6 +342,12 @@ function getTemplate(templateId) { return db.prepare("SELECT * from templates where id = ?").get(templateId); } +function getInspectableTemplateContext(contextId, templateId) { + const entry = templateContextCache.get(contextId); + if (!entry || entry.templateId !== String(templateId)) return null; + return entry.context; +} + function getTemplateContentByName(structId, name) { return db .prepare( @@ -736,6 +782,7 @@ module.exports = { getTemplater, getTemplates, getTemplate, + getInspectableTemplateContext, getTemplateContentByName, getTemplateByName, createTemplate, diff --git a/index.js b/index.js index 243f814..670d039 100644 --- a/index.js +++ b/index.js @@ -464,6 +464,29 @@ function renderTemplate(structureId, templateName, context) { return model.getTemplater(structureId).render(templateName, context); } +// Re-render one saved template instance with the JSON-safe values captured on +// that instance when the page was first produced. This is deliberately a +// platform endpoint rather than a user route: editing a template must not +// replay a route handler (and its side effects) just to update the markup. +app.post("/_bliss/render-template", (req, res) => { + const structureId = String(req.body?.structureId ?? ""); + const templateId = String(req.body?.templateId ?? ""); + const template = model.getTemplate(templateId); + if (!template || String(template.structure_id) !== structureId) { + return res.status(404).send("Template not found"); + } + + const structure = model.getStructure(structureId); + const savedContext = model.getInspectableTemplateContext( + req.body?.contextId, + templateId, + ); + if (!savedContext) return res.status(410).send("Template context expired; refresh the page"); + const context = { ...savedContext }; + context.route = makeRoute(structure); + return res.type("html").send(renderTemplate(structureId, template.name, context)); +}); + // Provenance for a rendered response: GET routes are re-embeddable, so they // carry the copy-embed snippet; other verbs don't. function sourceFor(route, req, templateName = null) { diff --git a/public/js/bliss_inspector.js b/public/js/bliss_inspector.js index 3836e06..2ce5a76 100644 --- a/public/js/bliss_inspector.js +++ b/public/js/bliss_inspector.js @@ -36,6 +36,14 @@ : []; } + function contextIdFor(element, templateId) { + try { + const ids = JSON.parse(element.dataset.blissTemplateContextIds || "{}"); + if (Object.hasOwn(ids, String(templateId))) return ids[String(templateId)]; + } catch (_) {} + return element.dataset.blissTemplateContextId || null; + } + function inventory() { const items = []; for (const owner of routeElements()) { @@ -89,44 +97,107 @@ } } + async function replaceBody(html) { + const ui = [...document.body.querySelectorAll(":scope > [data-bliss-ui]")]; + const next = new DOMParser().parseFromString(html, "text/html"); + for (const attribute of [...document.body.attributes]) { + document.body.removeAttribute(attribute.name); + } + for (const attribute of [...next.body.attributes]) { + document.body.setAttribute(attribute.name, attribute.value); + } + document.body.innerHTML = next.body.innerHTML; + for (const node of ui) document.body.appendChild(node); + htmx.process(document.body); + } + async function refreshElement(element) { if (element.dataset.blissMethod !== "GET") return; const url = element.dataset.blissRequestUrl; if (!url) return; if (element === document.body) { - const ui = [...document.body.querySelectorAll(":scope > [data-bliss-ui]")]; - const response = await fetch(url, { headers: { "HX-Request": "true" } }); + // A page root must be fetched as a document. Asking for an HTMX + // fragment here used to parse an empty and erase the page. + const response = await fetch(url); if (!response.ok) throw new Error(`GET ${url} returned ${response.status}`); - const next = new DOMParser().parseFromString(await response.text(), "text/html"); - for (const attribute of [...document.body.attributes]) { - document.body.removeAttribute(attribute.name); - } - for (const attribute of [...next.body.attributes]) { - document.body.setAttribute(attribute.name, attribute.value); - } - document.body.innerHTML = next.body.innerHTML; - for (const node of ui) document.body.appendChild(node); - htmx.process(document.body); + await replaceBody(await response.text()); return; } await htmx.ajax("GET", url, { target: element, swap: "outerHTML" }); } + function outermost(elements, selector) { + const set = new Set(elements); + return elements.filter((element) => { + let parent = element.parentElement; + while (parent) { + if (set.has(parent) && parent.matches(selector)) return false; + parent = parent.parentElement; + } + return true; + }); + } + + async function refreshTemplateElement(element, templateId, previousRoots = []) { + const response = await fetch("/_bliss/render-template", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + structureId: element.closest(ROUTE_SELECTOR)?.dataset.blissStructureId, + templateId, + contextId: contextIdFor(element, templateId), + }), + }); + if (!response.ok) { + throw new Error(`Template ${templateId} returned ${response.status}`); + } + const html = await response.text(); + if (element === document.body) return replaceBody(html); + const fragment = document.createElement("template"); + fragment.innerHTML = html; + // A template may legally have several top-level nodes. They share one + // captured context id, so replace that one render as a group instead of + // rendering the same template once per root and duplicating its output. + for (const root of previousRoots) { + if (root !== element && root.isConnected) root.remove(); + } + element.replaceWith(fragment.content); + htmx.process(document.body); + } + + function templateRefreshGroups(templateId) { + const elements = outermost( + templateElements().filter((element) => + matches(element, { kind: "template", id: templateId }), + ), + TEMPLATE_SELECTOR, + ); + const groups = new Map(); + for (const element of elements) { + const contextId = contextIdFor(element, templateId); + const key = contextId || `element:${Math.random()}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(element); + } + return [...groups.values()]; + } + async function refreshSources({ kind, id, method }) { if (method && method !== "GET") return; - const elements = routeElements().filter((element) => { - if (kind === "template") { - return templateElements().some( - (template) => - matches(template, { kind: "template", id }) && - template.closest(ROUTE_SELECTOR) === element, - ); - } - return matches(element, { kind, id }); - }); - const settled = await Promise.allSettled(elements.map(refreshElement)); + const elements = + kind === "template" + ? templateRefreshGroups(id) + : outermost( + routeElements().filter((element) => matches(element, { kind, id })), + ROUTE_SELECTOR, + ); + const refresh = + kind === "template" + ? (group) => refreshTemplateElement(group[0], id, group) + : refreshElement; + const settled = await Promise.allSettled(elements.map(refresh)); const failure = settled.find((result) => result.status === "rejected"); if (failure) console.error("Bliss live refresh failed", failure.reason); publishInventory();