fix: restore inspector template hot reload

This commit is contained in:
Your Name 2026-08-06 16:37:42 -04:00
parent cbbb87cd2e
commit ffba038b35
7 changed files with 232 additions and 35 deletions

View file

@ -0,0 +1,57 @@
<article data-bliss-artifact-editor
data-kind="<%= it.kind %>" data-id="<%= it.artifact.id %>"
data-structure-id="<%= it.artifact.structure_id %>" data-method="<%= it.artifact.verb || '' %>"
class="border-2 border-black bg-white text-black shadow-[4px_4px_0_#000]">
<header class="flex items-center gap-2 border-b-4 border-double border-black p-3">
<strong class="min-w-0 flex-1 truncate"><% if (it.kind === 'route') { %><%= it.artifact.verb %> <%= it.artifact.path %><% } else { %><%= it.artifact.name %><% } %></strong>
<span class="text-xs uppercase"><%= it.kind %></span><span data-status class="text-xs"></span>
</header>
<div class="p-3">
<% if (it.kind === 'route' && it.artifact.verb !== 'GET') { %>
<p class="mb-2 border border-amber-700 bg-amber-100 p-2 text-sm">Saving does not replay this <%= it.artifact.verb %> route.</p>
<% } %>
<label class="block text-xs font-bold uppercase tracking-widest"><%= it.kind === 'route' ? 'Handler' : 'Template' %></label>
<div data-editor="<%= it.kind === 'route' ? 'handler' : 'content' %>" data-mode="<%= it.kind === 'route' ? 'javascript' : 'html' %>" class="h-[min(55vh,34rem)] w-full border-2 border-black"><%= it.kind === 'route' ? it.artifact.handler : it.artifact.content %></div>
<% if (it.kind === 'template') { %>
<details class="mt-3" open>
<summary class="cursor-pointer text-xs font-bold uppercase tracking-widest">Rendering test</summary>
<div data-editor="test_object" data-mode="javascript" class="mt-1 h-32 w-full border-2 border-black"><%= it.artifact.test_object || '' %></div>
<iframe data-template-test title="Template rendering test" src="/workshop/<%= it.artifact.structure_id %>/template/<%= it.artifact.id %>/preview" class="mt-2 h-64 w-full border-2 border-black"></iframe>
</details>
<% } %>
<button data-save type="button" class="mt-3 border-2 border-black bg-white px-3 py-2 font-bold shadow-[3px_3px_0_#000]">Save</button>
</div>
</article>
<script>
(function () {
const card = document.currentScript.previousElementSibling;
if (!card || card.dataset.ready) return;
card.dataset.ready = "true";
const editors = Object.fromEntries([...card.querySelectorAll("[data-editor]")].map((node) => {
const editor = ace.edit(node);
editor.setTheme("ace/theme/monokai");
editor.session.setMode(`ace/mode/${node.dataset.mode}`);
return [node.dataset.editor, editor];
}));
card.querySelector("[data-save]").onclick = async () => {
const status = card.querySelector("[data-status]");
const { kind, id, structureId } = card.dataset;
status.textContent = "Saving…";
const body = kind === "route"
? { handler: editors.handler.getValue() }
: { content: editors.content.getValue(), test_object: editors.test_object.getValue() };
const response = await fetch(`/workshop/${structureId}/${kind}/${id}`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});
if (!response.ok) return void (status.textContent = "Save failed");
status.textContent = "Saved";
if (kind === "template") {
const frame = card.querySelector("[data-template-test]");
frame.src = `${frame.src.split("?")[0]}?t=${Date.now()}`;
}
document.dispatchEvent(new CustomEvent("bliss:source-saved", {
detail: { kind, id, method: kind === "route" ? card.dataset.verb : undefined },
}));
};
})();
</script>

View file

@ -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);

View file

@ -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 });
}

View file

@ -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 });
}

51
db.js
View file

@ -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,

View file

@ -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) {

View file

@ -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 <body> 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();