refactor!: rename slhx to hemx
Rename the tracked product identity, crate/package names, Rust paths/macros, generated artifacts, runtime files, public attributes, examples, docs, requirements, and tests from slhx to hemx without compatibility shims. Verified with cargo run -p hemx-xtask -- test, cargo test -p hemx-derive --test compile_fail, cargo test -p hemx-js, cargo test -p hemx-axum, cargo test -p hemx-v0-examples, cargo check --workspace, redgate list, redgate refs, redgate health --strict, git diff --check, and git grep/ls-files legacy-name audits. req: misc/001 req: codegen/001 req: component/004 req: runtime/001
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "hemx-js"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
Vendored
+66
@@ -0,0 +1,66 @@
|
||||
// req: ts/001
|
||||
export type ResourceKind = "slot" | "atom" | "handle" | "form";
|
||||
|
||||
export interface ResourceId {
|
||||
kind: ResourceKind;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export type ScopeKey =
|
||||
| { kind: "key"; value: string }
|
||||
| { kind: "field"; value: string };
|
||||
|
||||
export interface ResourceRef {
|
||||
resource: ResourceId;
|
||||
scope: ScopeKey | null;
|
||||
}
|
||||
|
||||
export type Payload =
|
||||
| { kind: "text"; value: string }
|
||||
| { kind: "html"; value: string };
|
||||
|
||||
export type ScrollBehavior =
|
||||
| "preserve"
|
||||
| "top"
|
||||
| { kind: "element"; target: ResourceRef };
|
||||
|
||||
export type Effect =
|
||||
| { kind: "put"; target: ResourceRef; payload: Payload }
|
||||
| { kind: "insert"; target: ResourceRef; key: string; payload: Payload }
|
||||
| { kind: "prepend"; target: ResourceRef; key: string; payload: Payload }
|
||||
| { kind: "remove"; target: ResourceRef; key: string | null }
|
||||
| { kind: "move"; target: ResourceRef; key: string; before: string | null }
|
||||
| { kind: "focus"; target: ResourceRef }
|
||||
| { kind: "navigate"; url: string; mode: "push" | "replace" | "redirect"; scroll: ScrollBehavior; title: string | null }
|
||||
| { kind: "emit"; name: string; payload: string };
|
||||
|
||||
export interface EffectBatch {
|
||||
abiVersion: number;
|
||||
fingerprint: bigint;
|
||||
ops: Effect[];
|
||||
}
|
||||
|
||||
export interface AtomSnapshot {
|
||||
id: number;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
export interface HemxRuntime {
|
||||
readonly runtimeAbiVersion: number;
|
||||
roots(): Element[];
|
||||
rootOf(node: Element | null): Element | null;
|
||||
applyHtml(html: string, root?: ParentNode | null, title?: string | null): boolean;
|
||||
applyBatch(buffer: ArrayBuffer, root?: ParentNode | null): void;
|
||||
decodeBatch(buffer: ArrayBuffer): EffectBatch;
|
||||
atomValue(root: Element | ParentNode | null | undefined, id: number): Uint8Array | undefined;
|
||||
decodeAtomState(encoded: string): AtomSnapshot[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
hemx: HemxRuntime;
|
||||
}
|
||||
}
|
||||
|
||||
declare const hemx: HemxRuntime;
|
||||
export default hemx;
|
||||
@@ -0,0 +1,810 @@
|
||||
(() => {
|
||||
const ROOT = "data-hemx-root";
|
||||
const HID = "data-hid";
|
||||
const SID = "data-sid";
|
||||
const runtimeAbiVersion = 1;
|
||||
const FINGERPRINT = "data-hemx-fp";
|
||||
const STATE = "data-hemx-st";
|
||||
const pending = new WeakMap();
|
||||
const queues = new WeakMap();
|
||||
const timers = new WeakMap();
|
||||
const everyTimers = new WeakMap();
|
||||
const pendingClassStates = new WeakMap();
|
||||
const indicatorStates = new WeakMap();
|
||||
const disabledStates = new WeakMap();
|
||||
const sseSources = new WeakMap();
|
||||
const atomStores = new WeakMap();
|
||||
const dragKeys = new WeakMap();
|
||||
|
||||
function roots() {
|
||||
const found = [];
|
||||
forEachElement(document, (el) => { if (el.hasAttribute(ROOT)) found.push(el); });
|
||||
return found;
|
||||
}
|
||||
|
||||
function rootOf(node) {
|
||||
for (let el = node; el && el !== document; el = el.parentElement) {
|
||||
if (el.hasAttribute && el.hasAttribute(ROOT)) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formOwner(el) {
|
||||
if (!el || el.tagName === "FORM") return el;
|
||||
if (el.getAttribute && el.getAttribute("form")) return elementById(rootOf(el) || document, el.getAttribute("form"));
|
||||
return closestInRoot(el, rootOf(el) || document, (node) => node.tagName === "FORM");
|
||||
}
|
||||
|
||||
function elementById(scope, id) {
|
||||
if (attrEquals(scope, "id", id)) return scope;
|
||||
return firstElement(scope, (el) => attrEquals(el, "id", id));
|
||||
}
|
||||
|
||||
function formHandleId(form) {
|
||||
if (!form) return null;
|
||||
const holder = form.hasAttribute(HID) ? form : firstElement(form, (el) => el.hasAttribute(HID));
|
||||
const raw = holder && holder.getAttribute(HID);
|
||||
return raw && /^\d+$/.test(raw) ? raw : null;
|
||||
}
|
||||
|
||||
function closestInRoot(start, root, predicate) {
|
||||
for (let node = start; node && node !== root.parentNode; node = node.parentElement) {
|
||||
if (predicate(node)) 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 normalizedPolicy(value) {
|
||||
return value === "latest" || value === "queue" || value === "drop" || value === "parallel" ? value : null;
|
||||
}
|
||||
|
||||
function requestPolicy(el, eventName) {
|
||||
const policy = normalizedPolicy(el.getAttribute("data-hemx-policy"));
|
||||
if (policy) return policy;
|
||||
if (el.hasAttribute("data-hemx-debounce") || eventName === "input") return "latest";
|
||||
if (el.tagName === "FORM") return "drop";
|
||||
return "parallel";
|
||||
}
|
||||
|
||||
function showPending(el, on) {
|
||||
const klass = el.getAttribute("data-hemx-pending-class");
|
||||
if (klass) togglePendingClass(el, klass, on);
|
||||
const root = rootOf(el) || document;
|
||||
forEachElement(root, (i) => { if (i.hasAttribute("data-hemx-indicator")) toggleIndicator(i, on); });
|
||||
if (el.hasAttribute("data-hemx-disable-while-pending")) {
|
||||
const controls = [];
|
||||
if (isDisableControl(el)) controls.push(el);
|
||||
forEachElement(el, (child) => { if (isDisableControl(child)) controls.push(child); });
|
||||
controls.forEach((c) => toggleDisabled(c, on));
|
||||
}
|
||||
}
|
||||
|
||||
function togglePendingClass(el, klass, on) {
|
||||
const state = pendingClassStates.get(el);
|
||||
if (on) {
|
||||
if (state) state.count += 1;
|
||||
else pendingClassStates.set(el, { count: 1, className: klass, hadClass: el.classList.contains(klass) });
|
||||
el.classList.add(klass);
|
||||
return;
|
||||
}
|
||||
if (!state) return;
|
||||
state.count -= 1;
|
||||
if (state.count <= 0) {
|
||||
if (state.hadClass) el.classList.add(state.className);
|
||||
else el.classList.remove(state.className);
|
||||
pendingClassStates.delete(el);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleIndicator(indicator, on) {
|
||||
const state = indicatorStates.get(indicator);
|
||||
if (on) {
|
||||
if (state) state.count += 1;
|
||||
else indicatorStates.set(indicator, { count: 1, hidden: indicator.hidden });
|
||||
indicator.hidden = false;
|
||||
return;
|
||||
}
|
||||
if (!state) return;
|
||||
state.count -= 1;
|
||||
if (state.count <= 0) {
|
||||
indicator.hidden = state.hidden;
|
||||
indicatorStates.delete(indicator);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDisabled(control, on) {
|
||||
const state = disabledStates.get(control);
|
||||
if (on) {
|
||||
if (state) state.count += 1;
|
||||
else disabledStates.set(control, { count: 1, disabled: control.disabled });
|
||||
control.disabled = true;
|
||||
return;
|
||||
}
|
||||
if (!state) return;
|
||||
state.count -= 1;
|
||||
if (state.count <= 0) {
|
||||
control.disabled = state.disabled;
|
||||
disabledStates.delete(control);
|
||||
}
|
||||
}
|
||||
|
||||
function formDataFor(el, eventName, source = el) {
|
||||
const form = formOwner(el);
|
||||
const data = form ? new FormData(form) : new FormData();
|
||||
if (form && source && source !== form && source.name && !source.disabled) data.append(source.name, source.value);
|
||||
const id = handleId(el) || formHandleId(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-hemx-") && 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, source = el) {
|
||||
if (el.getAttribute("data-hemx-confirm") && !confirm(el.getAttribute("data-hemx-confirm"))) return;
|
||||
const { form, data, multipart } = formDataFor(el, eventName, source);
|
||||
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();
|
||||
showPending(target, false);
|
||||
}
|
||||
if (active && policy === "queue") {
|
||||
const base = queues.get(target) || active.done;
|
||||
let queued;
|
||||
const next = base.then(() => send(el, eventName, source));
|
||||
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.getAttribute("method")) || "POST").toUpperCase();
|
||||
const body = method === "GET" || method === "HEAD" ? undefined : requestBody(data, multipart);
|
||||
const headers = { "X-HEMX-Partial": "1", "Accept": "application/hemx, 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,
|
||||
credentials: "same-origin",
|
||||
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), "hemx:error", String(error));
|
||||
} finally {
|
||||
if (pending.get(target)?.abort === abort) {
|
||||
pending.delete(target);
|
||||
showPending(target, false);
|
||||
} else if (policy === "parallel") {
|
||||
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-HEMX-Partial": "1", "Accept": "text/html" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!await applyResponse(response, root)) {
|
||||
if (mode === "none") location.reload();
|
||||
else location.href = href;
|
||||
return;
|
||||
}
|
||||
if (mode === "push") history.pushState({ hemx: true }, "", href);
|
||||
else if (mode === "replace") history.replaceState({ hemx: 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-hemx-title"));
|
||||
if (type.includes("application/hemx")) {
|
||||
applyBatch(await response.arrayBuffer(), root);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function compatibleFingerprint(response, root) {
|
||||
const received = response.headers.get("x-hemx-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);
|
||||
if (op.target.scope && op.target.scope.kind === "key" && op.payload.kind === "html") replacePayload(target, op.payload, op.target.scope.value, op.target.resource.id);
|
||||
else 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, op.target.resource.id);
|
||||
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"]({ hemx: 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 generatedTarget(scope, ref.resource.id);
|
||||
}
|
||||
|
||||
function firstElement(scope, predicate) {
|
||||
const stack = [];
|
||||
for (let node = scope && scope.firstElementChild; node; node = node.nextElementSibling) stack.push(node);
|
||||
while (stack.length) {
|
||||
const node = stack.shift();
|
||||
if (predicate(node)) return node;
|
||||
for (let child = node.firstElementChild; child; child = child.nextElementSibling) stack.push(child);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function attrEquals(el, name, value) {
|
||||
return el && el.getAttribute && el.getAttribute(name) === String(value);
|
||||
}
|
||||
|
||||
function generatedResource(el, id) {
|
||||
return attrEquals(el, "data-sid", id) || attrEquals(el, "data-slot-id", id);
|
||||
}
|
||||
|
||||
function generatedForm(el, id) {
|
||||
return attrEquals(el, "data-fid", id) || attrEquals(el, "data-form-id", id);
|
||||
}
|
||||
|
||||
function generatedTarget(scope, id) {
|
||||
if (scope && generatedResource(scope, id)) return scope;
|
||||
return firstElement(scope, (el) => generatedResource(el, id));
|
||||
}
|
||||
|
||||
function withinGeneratedResource(el, scope, id) {
|
||||
for (let node = el; node && node !== scope.parentNode; node = node.parentElement) {
|
||||
if (generatedResource(node, id)) return true;
|
||||
if (node === scope) break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function withinGeneratedForm(el, scope, id) {
|
||||
for (let node = el; node && node !== scope.parentNode; node = node.parentElement) {
|
||||
if (generatedForm(node, id)) return true;
|
||||
if (node === scope) break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isInputControl(el) {
|
||||
return el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT");
|
||||
}
|
||||
|
||||
function isDisableControl(el) {
|
||||
return isInputControl(el) || (el && el.tagName === "BUTTON");
|
||||
}
|
||||
|
||||
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) {
|
||||
return firstElement(scope, (el) => attrEquals(el, "data-key", key) && withinGeneratedResource(el, scope, id));
|
||||
}
|
||||
|
||||
function fieldTarget(scope, id, field) {
|
||||
return firstElement(scope, (el) => attrEquals(el, "name", field) && withinGeneratedForm(el, scope, id));
|
||||
}
|
||||
|
||||
function formErrorTarget(scope, id, field) {
|
||||
return firstElement(scope, (el) => attrEquals(el, "data-hemx-error-for", field) && withinGeneratedForm(el, scope, id)) ||
|
||||
firstElement(scope, (el) => attrEquals(el, "name", field) && withinGeneratedForm(el, scope, id));
|
||||
}
|
||||
|
||||
function putFormError(target, message) {
|
||||
if (isInputControl(target)) target.setCustomValidity(message);
|
||||
else target.textContent = message;
|
||||
}
|
||||
|
||||
function putPayload(target, payload) {
|
||||
if (payload.kind === "html") target.innerHTML = payload.value;
|
||||
else if (isInputControl(target)) target.value = payload.value;
|
||||
else target.textContent = payload.value;
|
||||
}
|
||||
|
||||
function replacePayload(target, payload, key, resourceId) {
|
||||
const nodes = fragmentNodes(payload, key, resourceId);
|
||||
if (nodes.length) target.replaceWith(...nodes);
|
||||
else target.innerHTML = "";
|
||||
}
|
||||
|
||||
function fragmentNodes(payload, key, resourceId) {
|
||||
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);
|
||||
if (firstElement && resourceId != null && !firstElement.hasAttribute("data-sid")) firstElement.setAttribute("data-sid", resourceId);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function handleRuntimeEvent(scope, name, payload) {
|
||||
if (name === "hemx:form-reset") {
|
||||
const form = generatedFormTarget(scope, payload);
|
||||
if (form && form.reset) form.reset();
|
||||
} else if (name === "hemx:form-error") {
|
||||
const [id, field, message] = String(payload).split("\u001f");
|
||||
const target = formErrorTarget(scope, id, field);
|
||||
if (target) putFormError(target, message || "");
|
||||
} else if (name === "hemx:form-disable-while-pending") {
|
||||
const form = generatedFormTarget(scope, payload);
|
||||
if (form) form.setAttribute("data-hemx-disable-while-pending", "");
|
||||
}
|
||||
}
|
||||
|
||||
function missing(root, target) {
|
||||
emit(root, "hemx: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 = firstElement(doc, (el) => el.tagName === "TEMPLATE" && el.hasAttribute("data-hemx"));
|
||||
const lowered = replaceLoweredSlots(scope, doc);
|
||||
const named = replaceSlot(scope, doc, "content", lowered ? undefined : (template ? template.innerHTML : html));
|
||||
if (!lowered && !named) {
|
||||
emit(scope, "hemx:missing-content-slot", null);
|
||||
return false;
|
||||
}
|
||||
replaceSlot(scope, doc, "nav");
|
||||
const titleEl = firstElement(doc, (el) => el.tagName === "TITLE");
|
||||
const nextTitle = title || (titleEl && titleEl.textContent);
|
||||
if (nextTitle) document.title = nextTitle;
|
||||
return true;
|
||||
}
|
||||
|
||||
function replaceLoweredSlots(scope, doc) {
|
||||
let changed = false;
|
||||
forEachElement(doc.body || doc, (source) => {
|
||||
const id = source.getAttribute("data-sid") || source.getAttribute("data-slot-id");
|
||||
if (!id) return;
|
||||
const target = generatedTarget(scope, id);
|
||||
if (target) {
|
||||
target.innerHTML = source.innerHTML;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
function forEachElement(scope, visit) {
|
||||
for (let node = scope && scope.firstElementChild; node; node = node.nextElementSibling) {
|
||||
visit(node);
|
||||
forEachElement(node, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function generatedFormTarget(scope, id) {
|
||||
if (scope && generatedForm(scope, id)) return scope;
|
||||
return firstElement(scope, (el) => generatedForm(el, id));
|
||||
}
|
||||
|
||||
function namedSlot(el, name) {
|
||||
return attrEquals(el, "data-hemx-slot", name) || attrEquals(el, "data-slot", name);
|
||||
}
|
||||
|
||||
function replaceSlot(scope, doc, name, fallback) {
|
||||
const target = firstElement(scope, (el) => namedSlot(el, name));
|
||||
if (!target) return false;
|
||||
const source = firstElement(doc, (el) => namedSlot(el, 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-hemx-on")) return el.getAttribute("data-hemx-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, (el) => el.hasAttribute("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, (el) => el.hasAttribute(HID) && el.getAttribute("data-hemx-on") === "drop");
|
||||
if (drop) event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const nav = closestInRoot(event.target, root, (el) =>
|
||||
(el.tagName === "A" && el.hasAttribute("data-hemx-nav")) ||
|
||||
(el.tagName === "A" && el.hasAttribute("href") && closestInRoot(el.parentElement, root, (node) => node.hasAttribute("data-hemx-boost")))
|
||||
);
|
||||
if (name === "click" && nav && sameOriginNav(event, nav)) {
|
||||
event.preventDefault();
|
||||
navigate(nav);
|
||||
return;
|
||||
}
|
||||
if (name === "click") {
|
||||
const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(HID));
|
||||
if (direct && defaultEvent(direct) === "click") {
|
||||
event.preventDefault();
|
||||
schedule(direct, name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (name === "click") {
|
||||
const submitter = closestInRoot(event.target, root, (el) =>
|
||||
(el.tagName === "BUTTON" && (!el.hasAttribute("type") || el.getAttribute("type") === "submit")) ||
|
||||
(el.tagName === "INPUT" && el.getAttribute("type") === "submit")
|
||||
);
|
||||
const form = formOwner(submitter);
|
||||
if (form && root.contains(form) && formHandleId(form)) {
|
||||
if (form.reportValidity && !form.reportValidity()) return;
|
||||
event.preventDefault();
|
||||
schedule(form, "submit", submitter);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let el = closestInRoot(event.target, root, (node) =>
|
||||
node.hasAttribute(HID) ||
|
||||
(node.tagName === "FORM" && closestInRoot(node.parentElement, root, (parent) => parent.hasAttribute("data-hemx-boost")))
|
||||
);
|
||||
if (name === "submit" && !el && event.target && event.target.tagName === "FORM" && formHandleId(event.target)) el = event.target;
|
||||
if (!el || defaultEvent(el) !== name) return;
|
||||
event.preventDefault();
|
||||
schedule(el, name, event.submitter || el);
|
||||
});
|
||||
});
|
||||
bindPolling(root);
|
||||
}
|
||||
|
||||
function schedule(el, eventName, source = el) {
|
||||
const debounce = duration(el.getAttribute("data-hemx-debounce"));
|
||||
const throttle = duration(el.getAttribute("data-hemx-throttle"));
|
||||
if (debounce) {
|
||||
clearTimeout(timers.get(el));
|
||||
timers.set(el, setTimeout(() => send(el, eventName, source), debounce));
|
||||
} else if (throttle) {
|
||||
if (timers.get(el)) return;
|
||||
send(el, eventName, source).finally(() => setTimeout(() => timers.delete(el), throttle));
|
||||
} else {
|
||||
send(el, eventName, source);
|
||||
}
|
||||
}
|
||||
|
||||
function bindPolling(root) {
|
||||
forEachElement(root, (el) => {
|
||||
if (!el.hasAttribute("data-hemx-every") || everyTimers.has(el)) return;
|
||||
const ms = duration(el.getAttribute("data-hemx-every"));
|
||||
if (!ms) return;
|
||||
everyTimers.set(el, setInterval(() => document.contains(el) ? send(el, "every") : stopPolling(el), ms));
|
||||
});
|
||||
}
|
||||
|
||||
function stopPolling(el) {
|
||||
clearInterval(everyTimers.get(el));
|
||||
everyTimers.delete(el);
|
||||
}
|
||||
|
||||
function bindSse(root) {
|
||||
const url = root.getAttribute("data-hemx-sse");
|
||||
if (!url || sseSources.has(root) || typeof EventSource === "undefined") return;
|
||||
const href = new URL(url, location.href);
|
||||
if (href.origin !== location.origin) {
|
||||
emit(root, "hemx:sse-error", url);
|
||||
return;
|
||||
}
|
||||
const source = new EventSource(href.href);
|
||||
source.addEventListener("hemx", (event) => applySseMessage(root, event));
|
||||
source.addEventListener("message", (event) => applySseMessage(root, event));
|
||||
source.addEventListener("error", () => emit(root, "hemx: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, "hemx: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;
|
||||
try {
|
||||
const store = atomStore(root);
|
||||
for (const atom of decodeAtomState(encoded)) store.set(String(atom.id), atom.bytes);
|
||||
} catch (error) {
|
||||
atomStores.delete(root);
|
||||
emit(root, "hemx:state-error", String(error));
|
||||
}
|
||||
}
|
||||
|
||||
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 hemx 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 hemx 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 hemx 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 hemx 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()) !== "HEMX") throw new Error("bad hemx batch magic");
|
||||
const batch = { abiVersion: d.u32(), fingerprint: d.u64(), ops: d.vec(d.effect) };
|
||||
if (!d.done()) throw new Error("trailing hemx 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 || { hemx: true }, "", location.href);
|
||||
}
|
||||
|
||||
addEventListener("popstate", () => {
|
||||
const root = roots()[0];
|
||||
if (root) navigateUrl(location.href, root, "none").catch((error) => emit(root, "hemx:error", String(error)));
|
||||
});
|
||||
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
|
||||
else start();
|
||||
|
||||
window.hemx = Object.freeze({ runtimeAbiVersion, roots, rootOf, applyHtml, applyBatch, decodeBatch, atomValue, decodeAtomState });
|
||||
})();
|
||||
@@ -0,0 +1,3 @@
|
||||
pub const RUNTIME_ABI_VERSION: u32 = 1;
|
||||
pub const RUNTIME_JS: &str = include_str!("../runtime/hemx.js");
|
||||
pub const RUNTIME_D_TS: &str = include_str!("../runtime/hemx.d.ts");
|
||||
@@ -0,0 +1,340 @@
|
||||
#[test]
|
||||
fn runtime_posts_urlencoded_forms_by_default() {
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("new URLSearchParams()"));
|
||||
assert!(source.contains("form.getAttribute(\"method\")"));
|
||||
assert!(source.contains("multipart/form-data"));
|
||||
assert!(source.contains("const body = method === \"GET\" || method === \"HEAD\" ? undefined : requestBody(data, multipart)"));
|
||||
assert!(source.contains("application/x-www-form-urlencoded;charset=UTF-8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_preserves_multipart_file_upload_fallback_shape() {
|
||||
// req: multipart/003
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains(
|
||||
"multipart: form && String(form.enctype).toLowerCase() === \"multipart/form-data\""
|
||||
));
|
||||
assert!(source.contains("if (value instanceof File) continue"));
|
||||
assert!(source.contains("return multipart ? data : urlEncoded(data)"));
|
||||
assert!(source.contains("if (body instanceof URLSearchParams) headers[\"Content-Type\"] = \"application/x-www-form-urlencoded;charset=UTF-8\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_uses_root_scoped_walks_not_dom_selector_apis() {
|
||||
// req: pitch/002 req: runtime/001 req: target_policy/001
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("function firstElement(scope, predicate)"));
|
||||
assert!(source.contains("function closestInRoot(start, root, predicate)"));
|
||||
assert!(source.contains("root.addEventListener(name, (event) =>"));
|
||||
assert!(!source.contains("querySelector"));
|
||||
assert!(!source.contains("querySelectorAll"));
|
||||
assert!(!source.contains(".closest("));
|
||||
assert!(!source.contains(".matches("));
|
||||
assert!(!source.contains("getElementsBy"));
|
||||
assert!(!source.contains("document.getElementById"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_targets_generated_resources_not_response_selectors() {
|
||||
// req: target_policy/001 req: target_policy/002
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("function targetFor(scope, ref)"));
|
||||
assert!(source.contains("return generatedTarget(scope, ref.resource.id)"));
|
||||
assert!(source.contains("function firstElement(scope, predicate)"));
|
||||
assert!(source.contains("function generatedResource(el, id)"));
|
||||
assert!(source.contains("return firstElement(scope, (el) => attrEquals(el, \"data-key\", key) && withinGeneratedResource(el, scope, id))"));
|
||||
assert!(
|
||||
source.contains("const nodes = fragmentNodes(op.payload, op.key, op.target.resource.id)")
|
||||
);
|
||||
assert!(source.contains("if (op.target.scope && op.target.scope.kind === \"key\" && op.payload.kind === \"html\") replacePayload(target, op.payload, op.target.scope.value, op.target.resource.id)"));
|
||||
assert!(source.contains("function replacePayload(target, payload, key, resourceId)"));
|
||||
assert!(source.contains("target.replaceWith(...nodes)"));
|
||||
assert!(source.contains("firstElement.setAttribute(\"data-sid\", resourceId)"));
|
||||
assert!(source.contains("const target = generatedTarget(scope, id)"));
|
||||
assert!(source.contains("if (!target) return missing(scope, op.target)"));
|
||||
assert!(!source.contains("data-hemx-target"));
|
||||
assert!(!source.contains("data-hemx-select"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_interval_dispatch_avoids_duplicate_timers() {
|
||||
// req: convention/005
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("const everyTimers = new WeakMap()"));
|
||||
assert!(source.contains("forEachElement(root, (el) =>"));
|
||||
assert!(source.contains("!el.hasAttribute(\"data-hemx-every\") || everyTimers.has(el)"));
|
||||
assert!(
|
||||
source.contains("if (!el.hasAttribute(\"data-hemx-every\") || everyTimers.has(el)) return")
|
||||
);
|
||||
assert!(source.contains(
|
||||
"setInterval(() => document.contains(el) ? send(el, \"every\") : stopPolling(el), ms)"
|
||||
));
|
||||
assert!(source.contains("function stopPolling(el)"));
|
||||
assert!(source.contains("clearInterval(everyTimers.get(el))"));
|
||||
assert!(source.contains("everyTimers.delete(el)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_toggles_pending_conventions_around_requests() {
|
||||
// req: convention/007 req: convention/008
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("function showPending(el, on)"));
|
||||
assert!(source.contains("el.getAttribute(\"data-hemx-pending-class\")"));
|
||||
assert!(source.contains("const pendingClassStates = new WeakMap()"));
|
||||
assert!(source.contains("function togglePendingClass(el, klass, on)"));
|
||||
assert!(source.contains("hadClass: el.classList.contains(klass)"));
|
||||
assert!(source.contains("if (state.hadClass) el.classList.add(state.className)"));
|
||||
assert!(source.contains("const indicatorStates = new WeakMap()"));
|
||||
assert!(source.contains("function toggleIndicator(indicator, on)"));
|
||||
assert!(source.contains("toggleIndicator(i, on)"));
|
||||
assert!(source.contains("indicator.hidden = state.hidden"));
|
||||
assert!(source.contains("el.hasAttribute(\"data-hemx-disable-while-pending\")"));
|
||||
assert!(source.contains("if (isDisableControl(el)) controls.push(el)"));
|
||||
assert!(source.contains(
|
||||
"forEachElement(el, (child) => { if (isDisableControl(child)) controls.push(child); })"
|
||||
));
|
||||
assert!(source.contains("const disabledStates = new WeakMap()"));
|
||||
assert!(source.contains("function toggleDisabled(control, on)"));
|
||||
assert!(source
|
||||
.contains("else disabledStates.set(control, { count: 1, disabled: control.disabled })"));
|
||||
assert!(source.contains("control.disabled = state.disabled"));
|
||||
assert!(source.contains("controls.forEach((c) => toggleDisabled(c, on))"));
|
||||
assert!(source.contains("showPending(target, true)"));
|
||||
assert!(source.contains("showPending(target, false)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_confirms_before_handler_dispatch() {
|
||||
// req: convention/004
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("el.getAttribute(\"data-hemx-confirm\") && !confirm(el.getAttribute(\"data-hemx-confirm\"))"));
|
||||
assert!(source.contains("return;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_fetches_with_same_origin_credentials() {
|
||||
// req: auth/005
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert_eq!(source.matches("credentials: \"same-origin\"").count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_handles_get_forms_without_request_body() {
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("function requestUrl(form, data, method)"));
|
||||
assert!(source.contains("method === \"GET\" || method === \"HEAD\" ? undefined : requestBody"));
|
||||
assert!(source.contains("if (method === \"GET\") url.search = urlEncoded(data).toString()"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_clicking_submitter_schedules_form_submit() {
|
||||
// req: convention/004 req: runtime/001
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("function formOwner(el)"));
|
||||
assert!(source.contains("function formHandleId(form)"));
|
||||
assert!(source.contains("function elementById(scope, id)"));
|
||||
assert!(
|
||||
source.contains("return elementById(rootOf(el) || document, el.getAttribute(\"form\"))")
|
||||
);
|
||||
assert!(!source.contains("document.getElementById(el.getAttribute(\"form\"))"));
|
||||
assert!(source.contains(
|
||||
"const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(HID))"
|
||||
));
|
||||
assert!(source.contains("const submitter = closestInRoot(event.target, root, (el) =>"));
|
||||
assert!(source.contains("el.tagName === \"BUTTON\" && (!el.hasAttribute(\"type\") || el.getAttribute(\"type\") === \"submit\")"));
|
||||
assert!(source.contains("form.reportValidity && !form.reportValidity()"));
|
||||
assert!(source.contains("schedule(form, \"submit\", submitter)"));
|
||||
assert!(source.contains("function formDataFor(el, eventName, source = el)"));
|
||||
assert!(source.contains("data.append(source.name, source.value)"));
|
||||
assert!(source.contains("schedule(el, name, event.submitter || el)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_supports_drag_drop_params_without_user_js() {
|
||||
// req: htmx_equivalents/002, req: dx/008
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("const dragKeys = new WeakMap()"));
|
||||
assert!(source.contains("dragstart"));
|
||||
assert!(source.contains("dragover"));
|
||||
assert!(source.contains("eventName === \"drop\""));
|
||||
assert!(source.contains("data.set(\"work_id\", dragKey)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_supports_queued_request_policy() {
|
||||
// req: convention/006
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("function normalizedPolicy(value)"));
|
||||
assert!(source.contains("value === \"latest\" || value === \"queue\" || value === \"drop\" || value === \"parallel\""));
|
||||
assert!(
|
||||
source.contains("const policy = normalizedPolicy(el.getAttribute(\"data-hemx-policy\"))")
|
||||
);
|
||||
assert!(source.contains("const queues = new WeakMap()"));
|
||||
assert!(source.contains("policy === \"queue\""));
|
||||
assert!(source.contains("const base = queues.get(target) || active.done"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_latest_request_policy_releases_superseded_pending_state() {
|
||||
// req: convention/006 req: convention/007
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("if (active && policy === \"latest\") {\n active.abort.abort();\n showPending(target, false);\n }"));
|
||||
assert!(source.contains("if (pending.get(target)?.abort === abort)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_parallel_request_policy_releases_each_pending_state() {
|
||||
// req: convention/006 req: convention/007 req: convention/008
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains(
|
||||
"else if (policy === \"parallel\") {\n showPending(target, false);\n }"
|
||||
));
|
||||
assert!(source.contains("const pendingClassStates = new WeakMap()"));
|
||||
assert!(source.contains("const indicatorStates = new WeakMap()"));
|
||||
assert!(source.contains("const disabledStates = new WeakMap()"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_exposes_page_swap_hooks() {
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("data-hemx-nav"));
|
||||
assert!(source.contains("data-hemx-boost"));
|
||||
assert!(source.contains("history.pushState"));
|
||||
assert!(source.contains("popstate"));
|
||||
assert!(source.contains("x-hemx-title"));
|
||||
assert!(source.contains("x-hemx-fingerprint"));
|
||||
assert!(source.contains("hemx:missing-content-slot"));
|
||||
assert!(source.contains("else location.href = href"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_popstate_failed_partials_reload_instead_of_stale_ui() {
|
||||
// req: page_swap/005 req: page_swap/006 req: runtime/004
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("if (mode === \"none\") location.reload();"));
|
||||
assert!(source.contains("if (root) navigateUrl(location.href, root, \"none\")"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_preserves_native_navigation_escape_hatches() {
|
||||
// req: page_swap/007 req: failure/006
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("function sameOriginNav(event, anchor)"));
|
||||
assert!(source.contains("!event.defaultPrevented"));
|
||||
assert!(source.contains("event.button === 0"));
|
||||
assert!(source.contains("!event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey"));
|
||||
assert!(source.contains("anchor.origin === location.origin"));
|
||||
assert!(source.contains("!anchor.download"));
|
||||
assert!(source.contains("anchor.target !== \"_blank\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_refuses_partial_updates_on_fingerprint_mismatch() {
|
||||
// req: abi/003 req: abi/004 req: runtime/004
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("function compatibleFingerprint(response, root)"));
|
||||
assert!(source.contains("const received = response.headers.get(\"x-hemx-fingerprint\")"));
|
||||
assert!(source.contains("const expected = root && root.getAttribute(FINGERPRINT)"));
|
||||
assert!(source.contains("if (!compatibleFingerprint(response, root))"));
|
||||
assert!(source.contains("location.reload()"));
|
||||
assert!(source.contains("if (expected && String(batch.fingerprint) !== expected)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_malformed_bootstrap_state_reports_and_continues() {
|
||||
// req: state/004 req: state/006 req: runtime/001
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("function bootstrapState(root)"));
|
||||
assert!(source.contains("try {\n const store = atomStore(root);"));
|
||||
assert!(source.contains("atomStores.delete(root)"));
|
||||
assert!(source.contains("emit(root, \"hemx:state-error\", String(error))"));
|
||||
assert!(source.contains("bindRoot(root)"));
|
||||
assert!(source.contains("bindSse(root)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_applies_sse_effect_batches_inside_roots() {
|
||||
// req: push/001 req: push/003 req: push/006 req: runtime/001
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("const sseSources = new WeakMap()"));
|
||||
assert!(source.contains("const url = root.getAttribute(\"data-hemx-sse\")"));
|
||||
assert!(source.contains("const href = new URL(url, location.href)"));
|
||||
assert!(source.contains("if (href.origin !== location.origin)"));
|
||||
assert!(source.contains("emit(root, \"hemx:sse-error\", url)"));
|
||||
assert!(source.contains("new EventSource(href.href)"));
|
||||
assert!(source
|
||||
.contains("source.addEventListener(\"hemx\", (event) => applySseMessage(root, event))"));
|
||||
assert!(source
|
||||
.contains("source.addEventListener(\"message\", (event) => applySseMessage(root, event))"));
|
||||
assert!(source.contains("applyBatch(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), root)"));
|
||||
assert!(source.contains("emit(root, \"hemx:sse-error\", url)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_page_swaps_lowered_slot_ids() {
|
||||
// req: wire/001
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("function replaceLoweredSlots(scope, doc)"));
|
||||
assert!(source.contains("forEachElement(doc.body || doc, (source)"));
|
||||
assert!(source.contains("const target = generatedTarget(scope, id)"));
|
||||
assert!(source.contains("target.innerHTML = source.innerHTML"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_keeps_form_field_targets_separate_from_error_targets() {
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("function fieldTarget(scope, id, field)"));
|
||||
assert!(
|
||||
source.contains("attrEquals(el, \"name\", field) && withinGeneratedForm(el, scope, id)")
|
||||
);
|
||||
assert!(source.contains("function formErrorTarget(scope, id, field)"));
|
||||
assert!(source.contains(
|
||||
"attrEquals(el, \"data-hemx-error-for\", field) && withinGeneratedForm(el, scope, id)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_preflights_batches_before_applying_ops() {
|
||||
let source = hemx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains(
|
||||
"const missingTarget = batch.ops.map((op) => canApplyOp(scope, op)).find(Boolean)"
|
||||
));
|
||||
assert!(source.contains("function canApplyOp(scope, op)"));
|
||||
assert!(source.contains("for (const op of batch.ops) applyOp(scope, op)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_ships_typescript_definitions() {
|
||||
let source = hemx_js::RUNTIME_D_TS;
|
||||
|
||||
assert!(source.contains("req: ts/001"));
|
||||
assert!(source.contains("export interface EffectBatch"));
|
||||
assert!(source.contains("fingerprint: bigint"));
|
||||
assert!(source.contains("export type Effect ="));
|
||||
assert!(source.contains("decodeBatch(buffer: ArrayBuffer): EffectBatch"));
|
||||
assert!(source.contains("interface Window"));
|
||||
}
|
||||
Reference in New Issue
Block a user