(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 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 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" } }); 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); return; } await htmx.ajax("GET", url, { target: element, swap: "outerHTML" }); } 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 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(); }); })();