From 48ac1249c8228c32430b8f0f9231702583ae0fb7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 3 Aug 2026 01:24:54 -0400 Subject: [PATCH 01/10] feat: slideout editor new new new! --- bliss-cli/live-editor/inspector.js | 3 + bliss-cli/live-editor/install.js | 79 ++++++ bliss-cli/live-editor/route-editor.eta | 28 +++ bliss-cli/live-editor/route-editor.js | 8 + bliss-cli/live-editor/slideout.eta | 76 ++++++ bliss-cli/live-editor/template-editor.eta | 25 ++ bliss-cli/live-editor/template-editor.js | 8 + db.js | 47 ++++ index.js | 27 +- public/js/bliss_inspector.js | 293 ++++++++++++++-------- 10 files changed, 482 insertions(+), 112 deletions(-) create mode 100644 bliss-cli/live-editor/inspector.js create mode 100644 bliss-cli/live-editor/install.js create mode 100644 bliss-cli/live-editor/route-editor.eta create mode 100644 bliss-cli/live-editor/route-editor.js create mode 100644 bliss-cli/live-editor/slideout.eta create mode 100644 bliss-cli/live-editor/template-editor.eta create mode 100644 bliss-cli/live-editor/template-editor.js diff --git a/bliss-cli/live-editor/inspector.js b/bliss-cli/live-editor/inspector.js new file mode 100644 index 0000000..b4f228d --- /dev/null +++ b/bliss-cli/live-editor/inspector.js @@ -0,0 +1,3 @@ +function handler(req, res) { + res.render("inspector/slideout", {}); +} diff --git a/bliss-cli/live-editor/install.js b/bliss-cli/live-editor/install.js new file mode 100644 index 0000000..d7eebcd --- /dev/null +++ b/bliss-cli/live-editor/install.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node + +// Install/update the live editor using Bliss's public CLI workflow. This file +// intentionally does not open the Bliss database directly. +const { spawnSync } = require("node:child_process"); +const path = require("node:path"); + +const repo = path.resolve(__dirname, "../.."); +const cli = path.join(repo, "bliss-cli/bliss"); + +function bliss(...args) { + const result = spawnSync(process.execPath, [cli, ...args], { + cwd: repo, + env: process.env, + encoding: "utf8", + }); + if (result.status !== 0) throw new Error(result.stderr || result.stdout); + return result.stdout.trim(); +} + +function json(...args) { + return JSON.parse(bliss(...args)); +} + +const structures = json("structures"); +let structure = structures.find((item) => item.name === "__bliss_live_editor"); +if (!structure) { + const created = json("create-structure", "__bliss_live_editor"); + structure = { id: created.id }; +} + +const structureId = String(structure.id); +bliss("update-settings", structureId, "--route-prefix", "/_bliss"); +let state = json("structure", structureId); +if (!state.dbs.some((db) => String(db.id) === "0" && db.alias === "bliss")) { + bliss("attach-db", structureId, "0", "bliss"); +} + +const templates = [ + ["inspector/slideout", "slideout.eta"], + ["inspector/route_editor", "route-editor.eta"], + ["inspector/template_editor", "template-editor.eta"], +]; +for (const [name, filename] of templates) { + let template = state.templates.find((item) => item.name === name); + if (!template) { + template = json("create-template", structureId, name); + } + bliss( + "update-template", + structureId, + String(template.id), + "--content-file", + path.join(__dirname, filename), + ); +} + +const routes = [ + ["/inspector", "inspector.js"], + ["/editor/route/:id", "route-editor.js"], + ["/editor/template/:id", "template-editor.js"], +]; +for (const [routePath, filename] of routes) { + let route = state.routes.find( + (item) => item.verb === "GET" && item.path === routePath, + ); + if (!route) { + route = json("create-route", structureId, "GET", routePath); + } + bliss( + "update-route", + structureId, + String(route.id), + "--handler-file", + path.join(__dirname, filename), + ); +} + +console.log(`live editor installed as Structure ${structureId}`); diff --git a/bliss-cli/live-editor/route-editor.eta b/bliss-cli/live-editor/route-editor.eta new file mode 100644 index 0000000..212859e --- /dev/null +++ b/bliss-cli/live-editor/route-editor.eta @@ -0,0 +1,28 @@ +
+
<%= it.sourceRoute.verb %> <%= it.sourceRoute.path %>
+ <% if (it.sourceRoute.verb !== "GET") { %> +
Rerunning this <%= it.sourceRoute.verb %> request can repeat writes or external side effects. Bliss will not rerun it automatically.
+ <% } %> +
<%= it.sourceRoute.handler %>
+ +
+ diff --git a/bliss-cli/live-editor/route-editor.js b/bliss-cli/live-editor/route-editor.js new file mode 100644 index 0000000..7b1a13d --- /dev/null +++ b/bliss-cli/live-editor/route-editor.js @@ -0,0 +1,8 @@ +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 }); +} diff --git a/bliss-cli/live-editor/slideout.eta b/bliss-cli/live-editor/slideout.eta new file mode 100644 index 0000000..4acf7b3 --- /dev/null +++ b/bliss-cli/live-editor/slideout.eta @@ -0,0 +1,76 @@ + + diff --git a/bliss-cli/live-editor/template-editor.eta b/bliss-cli/live-editor/template-editor.eta new file mode 100644 index 0000000..f2aa49d --- /dev/null +++ b/bliss-cli/live-editor/template-editor.eta @@ -0,0 +1,25 @@ +
+
<%= it.sourceTemplate.name %>
+
<%= it.sourceTemplate.content %>
+ +
+ diff --git a/bliss-cli/live-editor/template-editor.js b/bliss-cli/live-editor/template-editor.js new file mode 100644 index 0000000..e97f348 --- /dev/null +++ b/bliss-cli/live-editor/template-editor.js @@ -0,0 +1,8 @@ +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 }); +} diff --git a/db.js b/db.js index 83f7ce0..0bcf33e 100644 --- a/db.js +++ b/db.js @@ -3,6 +3,7 @@ const path = require("path"); const betterSqlite3 = require("better-sqlite3"); const { LRUCache } = require("lru-cache"); const { Eta } = require("eta"); +const cheerio = require("cheerio"); const db = betterSqlite3("./dbs/0.sqlite"); @@ -91,6 +92,37 @@ function getAllRoutes() { const templateCache = new LRUCache({ max: 100 }); +function annotateTemplateHtml(html, template) { + function annotate($, element) { + const current = $(element).attr("data-bliss-templates"); + let templates = []; + if (current) { + try { + templates = JSON.parse(current); + } catch (_) {} + } + if (!templates.some((item) => String(item.id) === String(template.id))) { + templates.push({ id: String(template.id), name: template.name }); + } + $(element).attr("data-bliss-templates", JSON.stringify(templates)); + if (!$(element).attr("data-bliss-template-id")) { + $(element).attr({ + "data-bliss-template-id": String(template.id), + "data-bliss-template-name": template.name, + }); + } + } + const lower = html.trimStart().toLowerCase(); + if (lower.startsWith(" + 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(); From e9f235505f05c928b14a199fb8513b1ca09b3d1b Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 6 Aug 2026 16:46:48 -0400 Subject: [PATCH 05/10] fix: inspector reload hoepfully? --- public/js/bliss_inspector.js | 96 ++++++++++++++++++++++++++++++++++-- 1 file changed, 93 insertions(+), 3 deletions(-) diff --git a/public/js/bliss_inspector.js b/public/js/bliss_inspector.js index 2ce5a76..7f68b6c 100644 --- a/public/js/bliss_inspector.js +++ b/public/js/bliss_inspector.js @@ -97,9 +97,99 @@ } } - async function replaceBody(html) { - const ui = [...document.body.querySelectorAll(":scope > [data-bliss-ui]")]; + function nodeKey(node) { + if (node.nodeType !== Node.ELEMENT_NODE) return null; + if (node.id) return `id:${node.id}`; + if (node.dataset.messageId) return `message:${node.dataset.messageId}`; + if (node.dataset.blissRouteId) { + return `route:${node.dataset.blissRouteId}:${node.dataset.blissRequestUrl || ""}`; + } + return null; + } + + function sameNodeType(left, right) { + return ( + left.nodeType === right.nodeType && + (left.nodeType !== Node.ELEMENT_NODE || left.tagName === right.tagName) + ); + } + + function syncAttributes(element, next) { + for (const attribute of [...element.attributes]) { + if (!next.hasAttribute(attribute.name)) element.removeAttribute(attribute.name); + } + for (const attribute of [...next.attributes]) { + if (element.getAttribute(attribute.name) !== attribute.value) { + element.setAttribute(attribute.name, attribute.value); + } + } + } + + // Live regions can contain data that arrived after the template was first + // rendered (a WebSocket chat message is the important case). Those nodes + // are not present in the saved render context, so retain them during a + // template patch instead of deleting them with the old tree. + function keepLiveNode(node) { + return ( + node.nodeType === Node.ELEMENT_NODE && + (node.matches("[data-bliss-ui]") || + node.hasAttribute("data-message-id") || + node.hasAttribute("data-bliss-route-id")) + ); + } + + function morphNode(current, next) { + if (!sameNodeType(current, next)) { + current.replaceWith(next.cloneNode(true)); + return; + } + if (current.nodeType !== Node.ELEMENT_NODE) { + if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue; + return; + } + + syncAttributes(current, next); + const currentChildren = [...current.childNodes]; + const keyed = new Map(); + for (const child of currentChildren) { + const key = nodeKey(child); + if (key && !keyed.has(key)) keyed.set(key, child); + } + + const used = new Set(); + let cursor = current.firstChild; + for (const nextChild of [...next.childNodes]) { + const key = nodeKey(nextChild); + let child = key ? keyed.get(key) : null; + if (child && used.has(child)) child = null; + if (!child) { + child = currentChildren.find( + (candidate) => + !used.has(candidate) && + !nodeKey(candidate) && + sameNodeType(candidate, nextChild), + ); + } + if (!child) child = nextChild.cloneNode(false); + if (child !== cursor) current.insertBefore(child, cursor); + used.add(child); + morphNode(child, nextChild); + cursor = child.nextSibling; + } + + for (const child of currentChildren) { + if (!used.has(child) && child.isConnected && !keepLiveNode(child)) child.remove(); + } + } + + async function replaceBody(html, { morph = false } = {}) { const next = new DOMParser().parseFromString(html, "text/html"); + if (morph) { + morphNode(document.body, next.body); + htmx.process(document.body); + return; + } + const ui = [...document.body.querySelectorAll(":scope > [data-bliss-ui]")]; for (const attribute of [...document.body.attributes]) { document.body.removeAttribute(attribute.name); } @@ -154,7 +244,7 @@ throw new Error(`Template ${templateId} returned ${response.status}`); } const html = await response.text(); - if (element === document.body) return replaceBody(html); + if (element === document.body) return replaceBody(html, { morph: true }); const fragment = document.createElement("template"); fragment.innerHTML = html; // A template may legally have several top-level nodes. They share one From c8e50cb6295413cd716dc3bab6760a8427a2354c Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 6 Aug 2026 17:08:17 -0400 Subject: [PATCH 06/10] fix: retain child provenance in htmx page swaps --- .gitignore | 1 + index.js | 29 +++++++++-- tests/test_inspector_sheepgpt.py | 89 ++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 tests/test_inspector_sheepgpt.py diff --git a/.gitignore b/.gitignore index 4923d39..e5dc11d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules +.venv .DS_Store dbs .env diff --git a/index.js b/index.js index 670d039..0b701cd 100644 --- a/index.js +++ b/index.js @@ -441,6 +441,28 @@ function decorateFragment(html, { headInjection, source } = {}) { ); } +// `res.render()` commonly returns a complete document even when it is serving +// an HTMX request. HTMX swaps that document's body into the current page, so +// preserve the template provenance from its body on every swapped root before +// reducing it to a fragment. Without this, templates from an hx-get child +// appear to belong to the outer page; saving `message-raw` in /sheepgpt then +// incorrectly refreshes /sheepgpt instead of the embedded /chat instance. +function fragmentFromPage(html) { + const $ = cheerio.load(html); + const body = $("body"); + const templateAttrs = Object.fromEntries( + Object.entries(body[0]?.attribs || {}).filter(([name]) => + name.startsWith("data-bliss-template"), + ), + ); + if (Object.keys(templateAttrs).length) { + for (const child of body.children().toArray()) { + Object.assign(child.attribs, templateAttrs); + } + } + return body.html(); +} + // Single entry point for turning rendered HTML into a response body. A full // document (or any non-htmx request) becomes a decorated page; an htmx partial // carrying provenance becomes a decorated fragment; anything else passes through. @@ -448,11 +470,12 @@ function decorate(html, { headInjection, source, fragment } = {}) { const lower = html.toLowerCase(); const isFullDoc = lower.startsWith("") || lower.startsWith(" None: + self.requests.append((request.method, urlparse(request.url).path)) + + def after(self, start: int) -> list[tuple[str, str]]: + return self.requests[start:] + + +class BlissInspector: + """Small page-object API for the inspector's normal user workflow.""" + + def __init__(self, page: Page) -> None: + self.page = page + + def open_slideout(self) -> None: + self.page.get_by_role("button", name="πŸ”").click() + self.page.locator("#bliss-live-editor").wait_for() + + def open_template(self, name: str, template_id: str) -> None: + self.page.get_by_role("button", name=re.compile(rf"^{re.escape(name)}(?: |$)")).click() + self.page.locator( + f'[data-bliss-artifact-editor][data-kind="template"][data-id="{template_id}"]' + ).wait_for() + + def save_template(self) -> None: + self.page.locator("[data-bliss-artifact-editor] [data-save]").click() + + +def main() -> None: + with sync_playwright() as playwright: + browser = playwright.chromium.launch() + page = browser.new_page() + trace = NetworkTrace() + page.on("request", trace.record) + + page.goto(SHEEPGPT_URL, wait_until="networkidle") + inspector = BlissInspector(page) + inspector.open_slideout() + inspector.open_template("message-raw", MESSAGE_RAW_TEMPLATE_ID) + + chat_before = page.locator("#chat").element_handle() + assert chat_before is not None, "the embedded chat did not render" + start = len(trace.requests) + with page.expect_response(re.compile(r"/_bliss/render-template$")) as rendered: + inspector.save_template() + render_response = rendered.value + print(f"template render response: {render_response.status} {render_response.url}") + print(f"template render body: {render_response.request.post_data}") + print("requests after message-raw save:") + for method, path in trace.after(start): + print(f" {method} {path}") + assert render_response.status == 200, render_response.text() + page.wait_for_timeout(200) + + requests = trace.after(start) + # Saving a child template must never replay the outer sheepgpt route. + assert ("GET", "/sheepgpt/") not in requests, requests + # The embedded chat itself must stay mounted; only matching template + # instances should be patched. + assert page.locator("#chat").element_handle() == chat_before + assert page.locator("#bliss-live-editor").is_visible() + browser.close() + + +if __name__ == "__main__": + main() From cda04f48ed3b9d1bc91a2d899a805640b617fc3c Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 6 Aug 2026 17:41:16 -0400 Subject: [PATCH 07/10] Preserve child head content in HTMX embeds --- index.js | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/index.js b/index.js index 0b701cd..43cdbd3 100644 --- a/index.js +++ b/index.js @@ -416,15 +416,32 @@ function decoratePage(html, { headInjection, source } = {}) { return $.html(); } -// HTMX partial: stamp provenance on the swapped-in elements (an oob swap loses -// its own attrs, so we descend into its children) and append an out-of-band -// so head injection still reaches the page. +// Carry rendered head content through HTMX with a neutral OOB wrapper. A +// literal in an HTMX response is discarded by the browser's fragment +// parser, and HTMX does not discover a top-level