(function () { if (window.__blissInspectorLoaded) return; window.__blissInspectorLoaded = true; const ROUTE_SELECTOR = "[data-bliss-structure-id][data-bliss-route-id]"; const TEMPLATE_SELECTOR = "[data-bliss-template-id]"; let root = null; let open = false; function visibleElements(selector) { return [...document.querySelectorAll(selector)].filter( (element) => !element.closest("[data-bliss-ui]"), ); } function routeElements() { return visibleElements(ROUTE_SELECTOR); } function templateElements() { return visibleElements(TEMPLATE_SELECTOR); } function templatesFor(element) { try { const templates = JSON.parse(element.dataset.blissTemplates || "[]"); if (templates.length) return templates; } catch (_) {} return element.dataset.blissTemplateId ? [ { id: element.dataset.blissTemplateId, name: element.dataset.blissTemplateName, }, ] : []; } 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()) { const templates = templateElements().filter( (element) => element.closest(ROUTE_SELECTOR) === owner, ); if (!templates.length) templates.push(null); for (const templateElement of templates) { const renderedTemplates = templateElement ? templatesFor(templateElement) : [null]; for (const template of renderedTemplates) { items.push({ structureId: owner.dataset.blissStructureId, structureName: owner.dataset.blissStructureName, routeId: owner.dataset.blissRouteId, routeName: owner.dataset.blissRouteName, templateId: template?.id || null, templateName: template?.name || null, }); } } } return items; } function publishInventory() { document.dispatchEvent( new CustomEvent("bliss:inventory", { detail: inventory() }), ); } function matches(element, { kind, id }) { if (kind === "template") { return templatesFor(element).some( (template) => String(template.id) === String(id), ); } const field = { structure: "blissStructureId", route: "blissRouteId", }[kind]; return field && element.dataset[field] === String(id); } function highlight(detail) { const candidates = detail.kind === "template" ? templateElements() : routeElements(); for (const element of candidates.filter((el) => matches(el, detail))) { element.classList.toggle("bliss-source-highlight", detail.on); } } 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); } 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) { // 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}`); 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(); // The render-template endpoint re-stamps template provenance but not the // route/structure provenance (that comes from the route pipeline). Carry it // over from the element being replaced so the inspector overlay and tree // stay complete after a live template edit, instead of losing the element's // route/copy/clone identity. const provenance = [...element.attributes] .filter((attribute) => attribute.name.startsWith("data-bliss-")) .map((attribute) => ({ name: attribute.name, value: attribute.value })); const carry = (target) => { for (const { name, value } of provenance) { if (!target.hasAttribute(name)) target.setAttribute(name, value); } }; if (element === document.body) { await replaceBody(html, { morph: true }); carry(document.body); return; } 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(); } for (const root of fragment.content.children) carry(root); 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 = 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(); } function show() { if (open) return; open = true; try { sessionStorage.setItem("blissInspectorOpen", "1"); } catch (_) {} document.documentElement.classList.add("bliss-inspector-open"); root = document.createElement("div"); root.id = "bliss-inspector-root"; root.dataset.blissUi = ""; root.setAttribute("hx-get", "/_bliss/inspector"); root.setAttribute("hx-trigger", "load"); root.setAttribute("hx-swap", "innerHTML"); document.body.appendChild(root); htmx.process(root); } function hide() { open = false; try { sessionStorage.removeItem("blissInspectorOpen"); } catch (_) {} document.documentElement.classList.remove("bliss-inspector-open"); root?.remove(); root = null; for (const element of [...routeElements(), ...templateElements()]) { element.classList.remove("bliss-source-highlight"); } } document.addEventListener("DOMContentLoaded", () => { const style = document.createElement("style"); style.dataset.blissUi = ""; style.textContent = ` html.bliss-inspector-open body > :not([data-bliss-ui]) { transform: translateX(min(28rem, 90vw)); max-width: calc(100vw - min(28rem, 90vw)); } [data-bliss-structure-id], [data-bliss-template-id] { transition: outline-color 80ms linear, background-color 80ms linear; } html.bliss-inspector-open [data-bliss-structure-id], html.bliss-inspector-open [data-bliss-template-id] { outline: 1px dashed rgba(255, 0, 168, .5); outline-offset: -1px; } .bliss-source-highlight { outline: 4px solid #ff00a8 !important; outline-offset: 3px !important; background-color: rgba(255, 0, 168, .08) !important; } `; document.head.appendChild(style); const trigger = document.createElement("button"); trigger.type = "button"; trigger.textContent = "🔍"; trigger.title = "Open Bliss live editor"; trigger.dataset.blissUi = ""; trigger.style.cssText = "position:fixed;right:.5rem;bottom:.5rem;z-index:2147483647;border:0;background:white;border-radius:999px;padding:.45rem;font-size:1.25rem;cursor:pointer;box-shadow:0 2px 8px #0004"; trigger.onclick = () => (open ? hide() : show()); document.body.appendChild(trigger); // A "Save & View" jump navigates the whole page to see a route live; // pick the inspector back up here so that hop doesn't feel like closing // it. slideout.eta separately restores whatever editor pane was open. let wasOpen = false; try { wasOpen = sessionStorage.getItem("blissInspectorOpen") === "1"; } catch (_) {} if (wasOpen) show(); }); document.addEventListener("bliss:highlight", (event) => highlight(event.detail), ); document.addEventListener("bliss:source-saved", (event) => refreshSources(event.detail), ); document.addEventListener("bliss:inventory-request", publishInventory); document.addEventListener("bliss:inspector-close", hide); document.addEventListener("htmx:afterSwap", () => { if (open) publishInventory(); }); })();