2db7ed7a3c
req: examples/005 req: htmx_equivalents/002 req: dx/008 req: form/002
618 lines
24 KiB
JavaScript
618 lines
24 KiB
JavaScript
(() => {
|
|
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 sseSources = new WeakMap();
|
|
const atomStores = new WeakMap();
|
|
const dragKeys = 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, eventName) {
|
|
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);
|
|
const dragKey = eventName === "drop" && dragKeys.get(rootOf(el));
|
|
if (dragKey && !data.has("work_id")) data.set("work_id", dragKey);
|
|
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, eventName);
|
|
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" } });
|
|
if (!await applyResponse(response, root) && mode !== "none") {
|
|
location.href = href;
|
|
return;
|
|
}
|
|
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 false;
|
|
}
|
|
if (!compatibleFingerprint(response, root)) {
|
|
location.reload();
|
|
return false;
|
|
}
|
|
const type = response.headers.get("content-type") || "";
|
|
if (type.includes("text/html")) return applyHtml(await response.text(), root, response.headers.get("x-slhx-title"));
|
|
if (type.includes("application/slhx")) {
|
|
applyBatch(await response.arrayBuffer(), root);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
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]");
|
|
const lowered = replaceLoweredSlots(scope, doc);
|
|
const named = replaceSlot(scope, doc, "content", lowered ? undefined : (template ? template.innerHTML : html));
|
|
if (!lowered && !named) {
|
|
emit(scope, "slhx:missing-content-slot", null);
|
|
return false;
|
|
}
|
|
replaceSlot(scope, doc, "nav");
|
|
const nextTitle = title || (doc.querySelector("title") && doc.querySelector("title").textContent);
|
|
if (nextTitle) document.title = nextTitle;
|
|
return true;
|
|
}
|
|
|
|
function replaceLoweredSlots(scope, doc) {
|
|
let changed = false;
|
|
doc.querySelectorAll("[data-sid], [data-slot-id]").forEach((source) => {
|
|
const id = source.getAttribute("data-sid") || source.getAttribute("data-slot-id");
|
|
const target = scope.querySelector(`[data-sid="${cssEscape(id)}"], [data-slot-id="${cssEscape(id)}"]`);
|
|
if (target) {
|
|
target.innerHTML = source.innerHTML;
|
|
changed = true;
|
|
}
|
|
});
|
|
return changed;
|
|
}
|
|
|
|
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", "dragstart", "dragover", "drop"].forEach((name) => {
|
|
root.addEventListener(name, (event) => {
|
|
if (name === "dragstart") {
|
|
const item = closestInRoot(event.target, root, "[data-key]");
|
|
if (item) {
|
|
dragKeys.set(root, item.getAttribute("data-key"));
|
|
if (event.dataTransfer) event.dataTransfer.setData("text/plain", item.getAttribute("data-key"));
|
|
}
|
|
return;
|
|
}
|
|
if (name === "dragover") {
|
|
const drop = closestInRoot(event.target, root, `[${HID}][data-slhx-on="drop"]`);
|
|
if (drop) event.preventDefault();
|
|
return;
|
|
}
|
|
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;
|
|
}
|
|
if (name === "click") {
|
|
const direct = closestInRoot(event.target, root, `[${HID}]`);
|
|
if (direct && defaultEvent(direct) === "click") {
|
|
event.preventDefault();
|
|
schedule(direct, name);
|
|
return;
|
|
}
|
|
}
|
|
if (name === "click") {
|
|
const submitter = closestInRoot(event.target, root, "button[type=submit], input[type=submit], button:not([type])");
|
|
const form = submitter && submitter.closest("form");
|
|
if (form && root.contains(form) && handleId(form)) {
|
|
if (form.reportValidity && !form.reportValidity()) return;
|
|
event.preventDefault();
|
|
schedule(form, "submit");
|
|
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 bindSse(root) {
|
|
const url = root.getAttribute("data-slhx-sse");
|
|
if (!url || sseSources.has(root) || typeof EventSource === "undefined") return;
|
|
const source = new EventSource(new URL(url, location.href).href);
|
|
source.addEventListener("slhx", (event) => applySseMessage(root, event));
|
|
source.addEventListener("message", (event) => applySseMessage(root, event));
|
|
source.addEventListener("error", () => emit(root, "slhx:sse-error", url));
|
|
sseSources.set(root, source);
|
|
}
|
|
|
|
function applySseMessage(root, event) {
|
|
try {
|
|
const bytes = base64UrlBytes(event.data);
|
|
applyBatch(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), root);
|
|
} catch (error) {
|
|
emit(root, "slhx:error", String(error));
|
|
}
|
|
}
|
|
|
|
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);
|
|
bindSse(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 });
|
|
})();
|