fix: restore inspector template hot reload
This commit is contained in:
parent
cbbb87cd2e
commit
ffba038b35
7 changed files with 232 additions and 35 deletions
57
bliss-cli/live-editor/artifact-editor.eta
Normal file
57
bliss-cli/live-editor/artifact-editor.eta
Normal 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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
51
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,
|
||||
|
|
|
|||
23
index.js
23
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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue