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/slideout", "slideout.eta"],
|
||||||
["inspector/route_editor", "route-editor.eta"],
|
["inspector/route_editor", "route-editor.eta"],
|
||||||
["inspector/template_editor", "template-editor.eta"],
|
["inspector/template_editor", "template-editor.eta"],
|
||||||
|
["inspector/artifact_editor", "artifact-editor.eta"],
|
||||||
];
|
];
|
||||||
for (const [name, filename] of templates) {
|
for (const [name, filename] of templates) {
|
||||||
let template = state.templates.find((item) => item.name === name);
|
let template = state.templates.find((item) => item.name === name);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
function handler(req, res) {
|
function handler(req, res) {
|
||||||
const sql = require("db")("bliss").sql;
|
const sql = require("db")("bliss").sql;
|
||||||
const sourceRoute = sql
|
const kind = "route";
|
||||||
.prepare("SELECT * FROM routes WHERE id = ?")
|
const artifact = sql.prepare("SELECT * FROM routes WHERE id = ?").get(req.params.id);
|
||||||
.get(req.params.id);
|
if (!artifact) return res.status(404).send("Route not found");
|
||||||
if (!sourceRoute) return res.status(404).send("Route not found");
|
res.render("inspector/artifact_editor", { kind, artifact });
|
||||||
res.render("inspector/route_editor", { sourceRoute });
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
function handler(req, res) {
|
function handler(req, res) {
|
||||||
const sql = require("db")("bliss").sql;
|
const sql = require("db")("bliss").sql;
|
||||||
const sourceTemplate = sql
|
const kind = "template";
|
||||||
.prepare("SELECT * FROM templates WHERE id = ?")
|
const artifact = sql.prepare("SELECT * FROM templates WHERE id = ?").get(req.params.id);
|
||||||
.get(req.params.id);
|
if (!artifact) return res.status(404).send("Template not found");
|
||||||
if (!sourceTemplate) return res.status(404).send("Template not found");
|
res.render("inspector/artifact_editor", { kind, artifact });
|
||||||
res.render("inspector/template_editor", { sourceTemplate });
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
51
db.js
51
db.js
|
|
@ -2,6 +2,7 @@ const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const betterSqlite3 = require("better-sqlite3");
|
const betterSqlite3 = require("better-sqlite3");
|
||||||
const { LRUCache } = require("lru-cache");
|
const { LRUCache } = require("lru-cache");
|
||||||
|
const { randomUUID } = require("node:crypto");
|
||||||
const { Eta } = require("eta");
|
const { Eta } = require("eta");
|
||||||
const cheerio = require("cheerio");
|
const cheerio = require("cheerio");
|
||||||
|
|
||||||
|
|
@ -95,8 +96,37 @@ function getAllRoutes() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const templateCache = new LRUCache({ max: 100 });
|
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) {
|
function annotate($, element) {
|
||||||
const current = $(element).attr("data-bliss-templates");
|
const current = $(element).attr("data-bliss-templates");
|
||||||
let templates = [];
|
let templates = [];
|
||||||
|
|
@ -109,10 +139,20 @@ function annotateTemplateHtml(html, template) {
|
||||||
templates.push({ id: String(template.id), name: template.name });
|
templates.push({ id: String(template.id), name: template.name });
|
||||||
}
|
}
|
||||||
$(element).attr("data-bliss-templates", JSON.stringify(templates));
|
$(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")) {
|
if (!$(element).attr("data-bliss-template-id")) {
|
||||||
$(element).attr({
|
$(element).attr({
|
||||||
"data-bliss-template-id": String(template.id),
|
"data-bliss-template-id": String(template.id),
|
||||||
"data-bliss-template-name": template.name,
|
"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);
|
const html = etaRender.call(this, templateName, data, meta);
|
||||||
if (typeof templateName !== "string") return html;
|
if (typeof templateName !== "string") return html;
|
||||||
const template = getTemplateByName(structId, templateName);
|
const template = getTemplateByName(structId, templateName);
|
||||||
return template ? annotateTemplateHtml(html, template) : html;
|
return template ? annotateTemplateHtml(html, template, data) : html;
|
||||||
};
|
};
|
||||||
templateCache.set(structId, etaInstance);
|
templateCache.set(structId, etaInstance);
|
||||||
}
|
}
|
||||||
|
|
@ -302,6 +342,12 @@ function getTemplate(templateId) {
|
||||||
return db.prepare("SELECT * from templates where id = ?").get(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) {
|
function getTemplateContentByName(structId, name) {
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|
@ -736,6 +782,7 @@ module.exports = {
|
||||||
getTemplater,
|
getTemplater,
|
||||||
getTemplates,
|
getTemplates,
|
||||||
getTemplate,
|
getTemplate,
|
||||||
|
getInspectableTemplateContext,
|
||||||
getTemplateContentByName,
|
getTemplateContentByName,
|
||||||
getTemplateByName,
|
getTemplateByName,
|
||||||
createTemplate,
|
createTemplate,
|
||||||
|
|
|
||||||
23
index.js
23
index.js
|
|
@ -464,6 +464,29 @@ function renderTemplate(structureId, templateName, context) {
|
||||||
return model.getTemplater(structureId).render(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
|
// Provenance for a rendered response: GET routes are re-embeddable, so they
|
||||||
// carry the copy-embed snippet; other verbs don't.
|
// carry the copy-embed snippet; other verbs don't.
|
||||||
function sourceFor(route, req, templateName = null) {
|
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() {
|
function inventory() {
|
||||||
const items = [];
|
const items = [];
|
||||||
for (const owner of routeElements()) {
|
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) {
|
async function refreshElement(element) {
|
||||||
if (element.dataset.blissMethod !== "GET") return;
|
if (element.dataset.blissMethod !== "GET") return;
|
||||||
const url = element.dataset.blissRequestUrl;
|
const url = element.dataset.blissRequestUrl;
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
||||||
if (element === document.body) {
|
if (element === document.body) {
|
||||||
const ui = [...document.body.querySelectorAll(":scope > [data-bliss-ui]")];
|
// A page root must be fetched as a document. Asking for an HTMX
|
||||||
const response = await fetch(url, { headers: { "HX-Request": "true" } });
|
// 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}`);
|
if (!response.ok) throw new Error(`GET ${url} returned ${response.status}`);
|
||||||
const next = new DOMParser().parseFromString(await response.text(), "text/html");
|
await replaceBody(await response.text());
|
||||||
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);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await htmx.ajax("GET", url, { target: element, swap: "outerHTML" });
|
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 }) {
|
async function refreshSources({ kind, id, method }) {
|
||||||
if (method && method !== "GET") return;
|
if (method && method !== "GET") return;
|
||||||
const elements = routeElements().filter((element) => {
|
const elements =
|
||||||
if (kind === "template") {
|
kind === "template"
|
||||||
return templateElements().some(
|
? templateRefreshGroups(id)
|
||||||
(template) =>
|
: outermost(
|
||||||
matches(template, { kind: "template", id }) &&
|
routeElements().filter((element) => matches(element, { kind, id })),
|
||||||
template.closest(ROUTE_SELECTOR) === element,
|
ROUTE_SELECTOR,
|
||||||
);
|
);
|
||||||
}
|
const refresh =
|
||||||
return matches(element, { kind, id });
|
kind === "template"
|
||||||
});
|
? (group) => refreshTemplateElement(group[0], id, group)
|
||||||
const settled = await Promise.allSettled(elements.map(refreshElement));
|
: refreshElement;
|
||||||
|
const settled = await Promise.allSettled(elements.map(refresh));
|
||||||
const failure = settled.find((result) => result.status === "rejected");
|
const failure = settled.find((result) => result.status === "rejected");
|
||||||
if (failure) console.error("Bliss live refresh failed", failure.reason);
|
if (failure) console.error("Bliss live refresh failed", failure.reason);
|
||||||
publishInventory();
|
publishInventory();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue