392 lines
13 KiB
JavaScript
392 lines
13 KiB
JavaScript
(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 <body> 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;
|
|
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;
|
|
}
|
|
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);
|
|
});
|
|
|
|
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();
|
|
});
|
|
})();
|