(() => { const ROOT = "data-slhx-root"; const HID = "data-hid"; const SID = "data-sid"; const runtimeAbiVersion = 1; const FINGERPRINT = "data-slhx-fp"; const STATE = "data-slhx-st"; const pending = new WeakMap(); const queues = new WeakMap(); const timers = new WeakMap(); const atomStores = new WeakMap(); function roots() { return Array.from(document.querySelectorAll(`[${ROOT}]`)); } function rootOf(node) { return node && node.closest ? node.closest(`[${ROOT}]`) : null; } function closestInRoot(start, root, selector) { for (let node = start; node && node !== root.parentNode; node = node.parentNode) { if (node.matches && node.matches(selector)) return node; if (node === root) break; } return null; } function handleId(el) { const raw = el && el.getAttribute(HID); return raw && /^\d+$/.test(raw) ? raw : null; } function requestPolicy(el, eventName) { const policy = el.getAttribute("data-slhx-policy"); if (policy) return policy; if (el.hasAttribute("data-slhx-debounce") || eventName === "input") return "latest"; if (el.tagName === "FORM") return "drop"; return "parallel"; } function showPending(el, on) { const klass = el.getAttribute("data-slhx-pending-class"); if (klass) el.classList.toggle(klass, on); const root = rootOf(el) || document; root.querySelectorAll("[data-slhx-indicator]").forEach((i) => { i.hidden = !on; }); if (el.hasAttribute("data-slhx-disable-while-pending")) { const controls = el.matches("button,input,select,textarea") ? [el] : el.querySelectorAll("button,input,select,textarea"); controls.forEach((c) => { c.disabled = on; }); } } function formDataFor(el) { const form = el.tagName === "FORM" ? el : el.closest("form"); const data = form ? new FormData(form) : new FormData(); const id = handleId(el) || (form && handleId(form)); if (id && !data.has("__h")) data.set("__h", id); for (const { name, value } of Array.from(el.attributes || [])) { if (name.startsWith("data-") && !name.startsWith("data-slhx-") && name !== HID && name !== SID) { data.set(name.slice(5).replace(/-/g, "_"), value); } } return { form, data, multipart: form && String(form.enctype).toLowerCase() === "multipart/form-data" }; } function urlEncoded(data) { const encoded = new URLSearchParams(); for (const [name, value] of data.entries()) { if (value instanceof File) continue; encoded.append(name, value); } return encoded; } function requestBody(data, multipart) { return multipart ? data : urlEncoded(data); } function requestUrl(form, data, method) { const url = new URL((form && form.action) || location.href, location.href); if (method === "GET") url.search = urlEncoded(data).toString(); return url.href; } async function send(el, eventName) { if (el.getAttribute("data-slhx-confirm") && !confirm(el.getAttribute("data-slhx-confirm"))) return; const { form, data, multipart } = formDataFor(el); const target = form || el; const policy = requestPolicy(target, eventName); const active = pending.get(target); if (active && policy === "drop") return; if (active && policy === "latest") active.abort.abort(); if (active && policy === "queue") { const base = queues.get(target) || active.done; let queued; const next = base.then(() => send(el, eventName)); queued = next.catch(() => {}).finally(() => { if (queues.get(target) === queued) queues.delete(target); }); queues.set(target, queued); return; } const abort = new AbortController(); let finish; const done = new Promise((resolve) => { finish = resolve; }); const method = String((form && form.method) || "POST").toUpperCase(); const body = method === "GET" || method === "HEAD" ? undefined : requestBody(data, multipart); const headers = { "X-SLHX-Partial": "1", "Accept": "application/slhx, text/html" }; if (body instanceof URLSearchParams) headers["Content-Type"] = "application/x-www-form-urlencoded;charset=UTF-8"; pending.set(target, { abort, done }); showPending(target, true); try { const response = await fetch(requestUrl(form, data, method), { method, body, headers, signal: abort.signal, }); if (pending.get(target)?.abort !== abort && policy === "latest") return; await applyResponse(response, rootOf(target)); } catch (error) { if (error.name !== "AbortError") emit(rootOf(target), "slhx:error", String(error)); } finally { if (pending.get(target)?.abort === abort) { pending.delete(target); showPending(target, false); } finish(); } } async function navigate(anchor, mode = "push") { const href = anchor.href; const root = rootOf(anchor); showPending(anchor, true); try { await navigateUrl(href, root, mode); } finally { showPending(anchor, false); } } async function navigateUrl(href, root, mode = "replace") { const response = await fetch(href, { headers: { "X-SLHX-Partial": "1", "Accept": "text/html" } }); await applyResponse(response, root); if (mode === "push") history.pushState({ slhx: true }, "", href); else if (mode === "replace") history.replaceState({ slhx: true }, "", href); } async function applyResponse(response, root) { if (response.redirected) { location.href = response.url; return; } if (!compatibleFingerprint(response, root)) { location.reload(); return; } const type = response.headers.get("content-type") || ""; if (type.includes("text/html")) applyHtml(await response.text(), root, response.headers.get("x-slhx-title")); else if (type.includes("application/slhx")) applyBatch(await response.arrayBuffer(), root); } function compatibleFingerprint(response, root) { const received = response.headers.get("x-slhx-fingerprint"); const expected = root && root.getAttribute(FINGERPRINT); return !received || !expected || received === expected; } function applyBatch(buffer, root) { const batch = decodeBatch(buffer); if (batch.abiVersion !== runtimeAbiVersion) { location.reload(); return; } const expected = root && root.getAttribute(FINGERPRINT); if (expected && String(batch.fingerprint) !== expected) { location.reload(); return; } const scope = root || document; const missingTarget = batch.ops.map((op) => canApplyOp(scope, op)).find(Boolean); if (missingTarget) { missing(scope, missingTarget); return; } for (const op of batch.ops) applyOp(scope, op); } function canApplyOp(scope, op) { if (op.kind === "put" && isAtom(op.target)) return null; if (op.kind === "put" || op.kind === "focus") return targetFor(scope, op.target) ? null : op.target; if (op.kind === "insert" || op.kind === "prepend") return targetFor(scope, op.target) ? null : op.target; if (op.kind === "remove") return (op.key ? keyedTarget(scope, op.target.resource.id, op.key) : targetFor(scope, op.target)) ? null : op.target; if (op.kind === "move") return targetFor(scope, op.target) && keyedTarget(scope, op.target.resource.id, op.key) ? null : op.target; if (op.kind === "navigate" && op.scroll && op.scroll.kind === "element") return targetFor(scope, op.scroll.target) ? null : op.scroll.target; return null; } function applyOp(scope, op) { if (op.kind === "put") { if (isAtom(op.target)) { atomStore(scope).set(String(op.target.resource.id), op.payload.value); return true; } const target = targetFor(scope, op.target); if (!target) return missing(scope, op.target); putPayload(target, op.payload); } else if (op.kind === "insert" || op.kind === "prepend") { const target = targetFor(scope, op.target); if (!target) return missing(scope, op.target); const nodes = fragmentNodes(op.payload, op.key); target[op.kind === "prepend" ? "prepend" : "append"](...nodes); } else if (op.kind === "remove") { const target = op.key ? keyedTarget(scope, op.target.resource.id, op.key) : targetFor(scope, op.target); if (!target) return missing(scope, op.target); target.remove(); } else if (op.kind === "move") { const target = targetFor(scope, op.target); const item = keyedTarget(scope, op.target.resource.id, op.key); if (!target || !item) return missing(scope, op.target); const before = op.before && keyedTarget(scope, op.target.resource.id, op.before); target.insertBefore(item, before || null); } else if (op.kind === "focus") { const target = targetFor(scope, op.target); if (target && target.focus) target.focus(); else return missing(scope, op.target); } else if (op.kind === "navigate") { if (op.mode === "redirect") location.href = op.url; else { history[op.mode === "replace" ? "replaceState" : "pushState"]({ slhx: true }, "", op.url); if (op.scroll === "top") scrollTo(0, 0); else if (op.scroll && op.scroll.kind === "element") { const target = targetFor(scope, op.scroll.target); if (target) target.scrollIntoView(); } if (op.title) document.title = op.title; } } else if (op.kind === "emit") { handleRuntimeEvent(scope, op.name, op.payload); emit(scope, op.name, op.payload); } return true; } function targetFor(scope, ref) { if (isAtom(ref)) return null; if (ref.scope && ref.scope.kind === "key") return keyedTarget(scope, ref.resource.id, ref.scope.value); if (ref.scope && ref.scope.kind === "field") return fieldTarget(scope, ref.resource.id, ref.scope.value); return scope.querySelector(`[data-sid="${ref.resource.id}"], [data-slot-id="${ref.resource.id}"]`); } function isAtom(ref) { return ref && ref.resource && ref.resource.kind === "atom"; } function atomStore(root) { const owner = root && root.nodeType === 1 ? root : document.documentElement; let store = atomStores.get(owner); if (!store) { store = new Map(); atomStores.set(owner, store); } return store; } function atomValue(root, id) { return atomStore(rootOf(root) || root || roots()[0]).get(String(id)); } function keyedTarget(scope, id, key) { const escapedKey = cssEscape(key); return scope.querySelector(`[data-sid="${id}"][data-key="${escapedKey}"], [data-sid="${id}"] [data-key="${escapedKey}"], [data-slot-id="${id}"][data-key="${escapedKey}"], [data-slot-id="${id}"] [data-key="${escapedKey}"]`); } function fieldTarget(scope, id, field) { const escapedField = cssEscape(field); return scope.querySelector(`[data-fid="${id}"] [name="${escapedField}"], [data-form-id="${id}"] [name="${escapedField}"]`); } function formErrorTarget(scope, id, field) { const escapedField = cssEscape(field); return scope.querySelector(`[data-fid="${id}"] [data-slhx-error-for="${escapedField}"], [data-form-id="${id}"] [data-slhx-error-for="${escapedField}"]`) || scope.querySelector(`[data-fid="${id}"] [name="${escapedField}"], [data-form-id="${id}"] [name="${escapedField}"]`); } function putFormError(target, message) { if (target.matches && target.matches("input,textarea,select")) target.setCustomValidity(message); else target.textContent = message; } function putPayload(target, payload) { if (payload.kind === "html") target.innerHTML = payload.value; else if (target.matches && target.matches("input,textarea,select")) target.value = payload.value; else target.textContent = payload.value; } function fragmentNodes(payload, key) { const template = document.createElement("template"); if (payload.kind === "html") template.innerHTML = payload.value; else template.textContent = payload.value; const nodes = Array.from(template.content.childNodes); const firstElement = nodes.find((node) => node.nodeType === 1); if (firstElement && key != null && !firstElement.hasAttribute("data-key")) firstElement.setAttribute("data-key", key); return nodes; } function handleRuntimeEvent(scope, name, payload) { if (name === "slhx:form-reset") { const form = scope.querySelector(`[data-fid="${payload}"], [data-form-id="${payload}"]`); if (form && form.reset) form.reset(); } else if (name === "slhx:form-error") { const [id, field, message] = String(payload).split("\u001f"); const target = formErrorTarget(scope, id, field); if (target) putFormError(target, message || ""); } else if (name === "slhx:form-disable-while-pending") { const form = scope.querySelector(`[data-fid="${payload}"], [data-form-id="${payload}"]`); if (form) form.setAttribute("data-slhx-disable-while-pending", ""); } } function missing(root, target) { emit(root, "slhx:missing-target", target); return false; } function cssEscape(value) { return String(value).replace(/\\/g, "\\\\").replace(/"/g, "\\\""); } function applyHtml(html, root, title) { const scope = root || document; const doc = new DOMParser().parseFromString(html, "text/html"); const template = doc.querySelector("template[data-slhx]"); if (!replaceSlot(scope, doc, "content", template ? template.innerHTML : html)) { location.reload(); return; } replaceSlot(scope, doc, "nav"); const nextTitle = title || (doc.querySelector("title") && doc.querySelector("title").textContent); if (nextTitle) document.title = nextTitle; } function replaceSlot(scope, doc, name, fallback) { const target = scope.querySelector(`[data-slhx-slot="${name}"], [data-slot="${name}"]`); if (!target) return false; const source = doc.querySelector(`[data-slhx-slot="${name}"], [data-slot="${name}"]`); if (!source && fallback === undefined) return false; target.innerHTML = source ? source.innerHTML : fallback; return true; } function emit(root, name, detail) { (root || document).dispatchEvent(new CustomEvent(name, { bubbles: true, detail })); } function defaultEvent(el) { if (el.getAttribute("data-slhx-on")) return el.getAttribute("data-slhx-on"); if (el.tagName === "FORM") return "submit"; return "click"; } function bindRoot(root) { ["click", "submit", "input", "change"].forEach((name) => { root.addEventListener(name, (event) => { const nav = closestInRoot(event.target, root, "a[data-slhx-nav], [data-slhx-boost] a[href]"); if (name === "click" && nav && sameOriginNav(event, nav)) { event.preventDefault(); navigate(nav); return; } const el = closestInRoot(event.target, root, `[${HID}], [data-slhx-boost] form`); if (!el || defaultEvent(el) !== name) return; event.preventDefault(); schedule(el, name); }); }); bindPolling(root); } function schedule(el, eventName) { const debounce = duration(el.getAttribute("data-slhx-debounce")); const throttle = duration(el.getAttribute("data-slhx-throttle")); if (debounce) { clearTimeout(timers.get(el)); timers.set(el, setTimeout(() => send(el, eventName), debounce)); } else if (throttle) { if (timers.get(el)) return; send(el, eventName).finally(() => setTimeout(() => timers.delete(el), throttle)); } else { send(el, eventName); } } function bindPolling(root) { root.querySelectorAll("[data-slhx-every]").forEach((el) => { if (timers.has(el)) return; const ms = duration(el.getAttribute("data-slhx-every")); if (!ms) return; timers.set(el, setInterval(() => document.contains(el) ? send(el, "every") : clearInterval(timers.get(el)), ms)); }); } function duration(value) { if (!value) return 0; const match = String(value).trim().match(/^(\d+)(ms|s)?$/); if (!match) return 0; return Number(match[1]) * (match[2] === "s" ? 1000 : 1); } function bootstrapState(root) { const encoded = root.getAttribute(STATE); if (!encoded) return; const store = atomStore(root); for (const atom of decodeAtomState(encoded)) store.set(String(atom.id), atom.bytes); } function decodeAtomState(encoded) { const bytes = base64UrlBytes(encoded); const d = postcardDecoder(bytes); const atoms = d.vec(() => ({ id: d.varint(), bytes: d.bytes() })); if (!d.done()) throw new Error("trailing slhx state bytes"); return atoms; } function base64UrlBytes(encoded) { const normalized = String(encoded).replace(/-/g, "+").replace(/_/g, "/"); const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4); return Uint8Array.from(atob(padded), (ch) => ch.charCodeAt(0)); } function postcardDecoder(bytes) { let offset = 0; const need = (len) => { const end = offset + len; if (end > bytes.length) throw new Error("truncated slhx state"); const slice = bytes.subarray(offset, end); offset = end; return slice; }; const varint = () => { let shift = 0; let value = 0; for (;;) { const byte = need(1)[0]; value |= (byte & 0x7f) << shift; if ((byte & 0x80) === 0) return value >>> 0; shift += 7; } }; const bytesField = () => need(varint()); const vec = (read) => Array.from({ length: varint() }, read); return { varint, bytes: bytesField, vec, done: () => offset === bytes.length }; } function decoder(buffer) { const bytes = new Uint8Array(buffer); let offset = 0; const need = (len) => { const end = offset + len; if (end > bytes.length) throw new Error("truncated slhx batch"); const slice = bytes.subarray(offset, end); offset = end; return slice; }; const u8 = () => need(1)[0]; const u32 = () => { const b = need(4); return (b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 24)) >>> 0; }; const u64 = () => { const lo = BigInt(u32()); const hi = BigInt(u32()); return lo | (hi << 32n); }; const str = () => new TextDecoder().decode(need(u32())); const option = (read) => u8() === 0 ? null : read(); const resource = () => ({ kind: ["slot", "atom", "handle", "form"][u8()], id: u32() }); const scope = () => { const kind = u8(); if (kind === 0) return null; return { kind: kind === 1 ? "key" : "field", value: str() }; }; const ref = () => ({ resource: resource(), scope: scope() }); const payload = () => ({ kind: u8() === 0 ? "text" : "html", value: str() }); const scroll = () => { const kind = u8(); if (kind === 0) return "preserve"; if (kind === 1) return "top"; return { kind: "element", target: ref() }; }; const effect = () => { const kind = u8(); if (kind === 0) return { kind: "put", target: ref(), payload: payload() }; if (kind === 1) return { kind: "insert", target: ref(), key: str(), payload: payload() }; if (kind === 2) return { kind: "prepend", target: ref(), key: str(), payload: payload() }; if (kind === 3) return { kind: "remove", target: ref(), key: option(str) }; if (kind === 4) return { kind: "move", target: ref(), key: str(), before: option(str) }; if (kind === 5) return { kind: "focus", target: ref() }; if (kind === 6) return { kind: "navigate", url: str(), mode: ["push", "replace", "redirect"][u8()], scroll: scroll(), title: option(str) }; if (kind === 7) return { kind: "emit", name: str(), payload: str() }; throw new Error(`unknown slhx effect ${kind}`); }; const vec = (read) => Array.from({ length: u32() }, read); return { u8, u32, u64, vec, effect, done: () => offset === bytes.length }; } function decodeBatch(buffer) { const d = decoder(buffer); if (String.fromCharCode(d.u8(), d.u8(), d.u8(), d.u8()) !== "SLHX") throw new Error("bad slhx batch magic"); const batch = { abiVersion: d.u32(), fingerprint: d.u64(), ops: d.vec(d.effect) }; if (!d.done()) throw new Error("trailing slhx batch bytes"); return batch; } function sameOriginNav(event, anchor) { return !event.defaultPrevented && event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey && anchor.origin === location.origin && !anchor.download && anchor.target !== "_blank"; } function start() { roots().forEach((root) => { bootstrapState(root); bindRoot(root); }); history.replaceState(history.state || { slhx: true }, "", location.href); } addEventListener("popstate", () => { const root = roots()[0]; if (root) navigateUrl(location.href, root, "none").catch((error) => emit(root, "slhx:error", String(error))); }); if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start(); window.slhx = Object.freeze({ runtimeAbiVersion, roots, rootOf, applyHtml, applyBatch, decodeBatch, atomValue, decodeAtomState }); })();