(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); } } 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) { // 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(); 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 = 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; 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; 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; } .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); }); 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(); }); })();