diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 03af793..417bca8 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -1252,6 +1252,354 @@ // parse-sse-swap var parseSseSwap = function(el) { return sxOr(domGetAttr(el, "sx-sse-swap"), "message"); }; + // _preload-cache + var _preloadCache = {}; + + // _css-hash + var _cssHash = ""; + + // dispatch-trigger-events + var dispatchTriggerEvents = function(el, headerVal) { return (isSxTruthy(headerVal) ? (function() { + var parsed = tryParseJson(headerVal); + return (isSxTruthy((isSxTruthy(parsed) && isDict(parsed))) ? forEach(function(key) { return domDispatch(el, key, dictGet(parsed, key)); }, keys(parsed)) : forEach(function(name) { return (function() { + var n = trim(name); + return (isSxTruthy(!(n == "")) ? domDispatch(el, n, {}) : NIL); +})(); }, split(headerVal, ","))); +})() : NIL); }; + + // init-css-tracking + var initCssTracking = function() { return (function() { + var meta = domQuery("meta[name=\"sx-css-classes\"]"); + return (isSxTruthy(meta) ? (function() { + var content = domGetAttr(meta, "content"); + return (isSxTruthy(content) ? (_cssHash = content) : NIL); +})() : NIL); +})(); }; + + // execute-request + var executeRequest = function(el, verbInfo, extraParams) { return (function() { + var currentVerb = getVerbInfo(el); + var verb = (isSxTruthy(currentVerb) ? currentVerb : verbInfo); + var method = get(verb, "method"); + var url = get(verb, "url"); + if (isSxTruthy(!domHasClass(el, "sx-error"))) { + domRemoveAttr(el, "data-sx-retry-ms"); +} + return (isSxTruthy((function() { + var media = domGetAttr(el, "sx-media"); + return (isSxTruthy(media) && !browserMediaMatches(media)); +})()) ? promiseResolve(NIL) : (isSxTruthy((function() { + var msg = domGetAttr(el, "sx-confirm"); + return (isSxTruthy(msg) && !browserConfirm(msg)); +})()) ? promiseResolve(NIL) : (function() { + var promptMsg = domGetAttr(el, "sx-prompt"); + var params = extraParams; + return (isSxTruthy(promptMsg) ? (function() { + var promptVal = browserPrompt(promptMsg); + return (isSxTruthy(isNil(promptVal)) ? promiseResolve(NIL) : ((params = sxOr(params, {})), dictSet(params, "promptValue", promptVal), doFetch(el, verb, method, url, params))); +})() : doFetch(el, verb, method, url, params)); +})())); +})(); }; + + // do-fetch + var doFetch = function(el, verb, method, url, extraParams) { return (function() { + var syncAttr = domGetAttr(el, "sx-sync"); + if (isSxTruthy((isSxTruthy(syncAttr) && contains(syncAttr, "replace")))) { + abortPrevious(el); +} + return (function() { + var ctrl = newAbortController(); + trackController(el, ctrl); + return (function() { + var headers = buildRequestHeaders(el, loadedComponentNames(), _cssHash); + if (isSxTruthy((isSxTruthy(extraParams) && dictHas(extraParams, "promptValue")))) { + headers["SX-Prompt"] = get(extraParams, "promptValue"); +} + if (isSxTruthy((isSxTruthy(!(method == "GET")) && browserSameOrigin(url)))) { + (function() { + var csrf = csrfToken(); + return (isSxTruthy(csrf) ? dictSet(headers, "X-CSRFToken", csrf) : NIL); +})(); +} + return (function() { + var bodyInfo = buildRequestBody(el, method, url); + return (function() { + var body = get(bodyInfo, "body"); + var finalUrl = get(bodyInfo, "url"); + var ct = get(bodyInfo, "content-type"); + if (isSxTruthy(ct)) { + headers["Content-Type"] = ct; +} + return (isSxTruthy(!domDispatch(el, "sx:beforeRequest", {["method"]: method, ["url"]: finalUrl})) ? promiseResolve(NIL) : (domAddClass(el, "sx-request"), domSetAttr(el, "aria-busy", "true"), (function() { + var indicator = showIndicator(el); + var disabledElts = disableElements(el); + var preloaded = (isSxTruthy((method == "GET")) ? preloadCacheGet(_preloadCache, finalUrl) : NIL); + return fetchRequest({["url"]: finalUrl, ["method"]: method, ["headers"]: headers, ["body"]: body, ["signal"]: controllerSignal(ctrl), ["preloaded"]: preloaded, ["cross-origin"]: isCrossOrigin(finalUrl)}, function(respOk, status, getHeader, text) { return (clearLoadingState(el, indicator, disabledElts), (isSxTruthy(!respOk) ? (domDispatch(el, "sx:responseError", {["status"]: status}), handleRetry(el, verb, extraParams)) : (domDispatch(el, "sx:afterRequest", {}), handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text)))); }, function(err) { return (clearLoadingState(el, indicator, disabledElts), (isSxTruthy(!isAbortError(err)) ? (domDispatch(el, "sx:sendError", {["error"]: err}), handleRetry(el, verb, extraParams)) : NIL)); }); +})())); +})(); +})(); +})(); +})(); +})(); }; + + // handle-fetch-success + var handleFetchSuccess = function(el, url, verb, extraParams, getHeader, text) { return (function() { + var headers = processResponseHeaders(getHeader); + return (isSxTruthy(get(headers, "redirect")) ? browserNavigate(get(headers, "redirect")) : (isSxTruthy((get(headers, "refresh") == "true")) ? browserReload() : (dispatchTriggerEvents(el, get(headers, "trigger")), (function() { + var rawSwap = sxOr(domGetAttr(el, "sx-swap"), DEFAULT_SWAP); + var target = resolveTarget(el); + var selectSel = domGetAttr(el, "sx-select"); + if (isSxTruthy(get(headers, "retarget"))) { + target = sxOr(domQuery(get(headers, "retarget")), target); +} + if (isSxTruthy(get(headers, "reswap"))) { + rawSwap = get(headers, "reswap"); +} + return (function() { + var swap = parseSwapSpec(rawSwap, false); + var ct = sxOr(get(headers, "content-type"), ""); + (isSxTruthy(contains(ct, "text/sx")) ? handleSxResponse(el, target, swap, selectSel, text) : handleHtmlResponse(el, target, swap, selectSel, text)); + if (isSxTruthy(get(headers, "location"))) { + fetchLocation(get(headers, "location")); +} + handleHistory(el, url, headers); + domDispatch(el, "sx:afterSwap", {["target"]: target}); + dispatchTriggerEvents(el, get(headers, "trigger-swap")); + return requestAnimationFrame_(function() { return (domDispatch(el, "sx:afterSettle", {["target"]: target}), dispatchTriggerEvents(el, get(headers, "trigger-settle"))); }); +})(); +})()))); +})(); }; + + // handle-sx-response + var handleSxResponse = function(el, target, swap, selectSel, text) { return (function() { + var cleaned = stripComponentScripts(text); + var cleaned2 = extractResponseCss(cleaned); + return (function() { + var source = trim(cleaned2); + return (isSxTruthy((isSxTruthy(source) && !(source == ""))) ? (function() { + var dom = sxRender(source); + var container = domCreateElement("div", NIL); + domAppend(container, dom); + processOobSwaps(container, function(t, oob, s) { return swapDomNodes(t, oob, s); }); + return (function() { + var selected = (isSxTruthy(selectSel) ? selectFromContainer(container, selectSel) : childrenToFragment(container)); + return (isSxTruthy((isSxTruthy(!(get(swap, "style") == "none")) && target)) ? withTransition(get(swap, "transition"), function() { return (swapDomNodes(target, selected, get(swap, "style")), hoistHeadElements(target)); }) : NIL); +})(); +})() : NIL); +})(); +})(); }; + + // handle-html-response + var handleHtmlResponse = function(el, target, swap, selectSel, text) { return (function() { + var doc = domParseHtmlDocument(text); + sxProcessScripts(doc); + processOobSwaps(doc, function(t, oob, s) { return swapHtmlString(t, domOuterHtml(oob), s); }); + return (function() { + var content = (isSxTruthy(selectSel) ? selectHtmlFromDoc(doc, selectSel) : sxOr(domBodyInnerHtml(doc), text)); + return (isSxTruthy((isSxTruthy(!(get(swap, "style") == "none")) && target)) ? withTransition(get(swap, "transition"), function() { return (swapHtmlString(target, content, get(swap, "style")), hoistHeadElements(target)); }) : NIL); +})(); +})(); }; + + // handle-retry + var handleRetry = function(el, verbInfo, extraParams) { return (function() { + var retryAttr = domGetAttr(el, "sx-retry"); + return (isSxTruthy(retryAttr) ? (function() { + var spec = parseRetrySpec(retryAttr); + var currentMs = sxOr(parseInt_(domGetAttr(el, "data-sx-retry-ms"), 0), get(spec, "start-ms")); + domAddClass(el, "sx-error"); + domRemoveClass(el, "sx-loading"); + return setTimeout_(function() { return (domRemoveClass(el, "sx-error"), domAddClass(el, "sx-loading"), domSetAttr(el, "data-sx-retry-ms", (String(nextRetryMs(currentMs, get(spec, "cap-ms"))))), executeRequest(el, verbInfo, extraParams)); }, currentMs); +})() : NIL); +})(); }; + + // bind-triggers + var bindTriggers = function(el, verbInfo) { return (function() { + var triggerSpec = domGetAttr(el, "sx-trigger"); + var triggers = (isSxTruthy(triggerSpec) ? parseTriggerSpec(triggerSpec) : defaultTrigger(domTagName(el))); + return forEach(function(trig) { return (function() { + var kind = classifyTrigger(trig); + return (isSxTruthy((kind == "poll")) ? setInterval_(function() { return executeRequest(el, verbInfo, NIL); }, sxOr(get(get(trig, "modifiers"), "interval"), 1000)) : (isSxTruthy((kind == "intersect")) ? observeIntersection(el, function() { return executeRequest(el, verbInfo, NIL); }, get(get(trig, "modifiers"), "once"), get(get(trig, "modifiers"), "delay")) : (isSxTruthy((kind == "load")) ? setTimeout_(function() { return executeRequest(el, verbInfo, NIL); }, 0) : (isSxTruthy((kind == "revealed")) ? observeIntersection(el, function() { return executeRequest(el, verbInfo, NIL); }, true, NIL) : bindEvent(el, verbInfo, trig))))); +})(); }, triggers); +})(); }; + + // bind-event + var bindEvent = function(el, verbInfo, trig) { return (function() { + var eventName = get(trig, "event"); + var mods = get(trig, "modifiers"); + var listenTarget = (isSxTruthy(get(mods, "from")) ? sxOr(domQuery(get(mods, "from")), el) : el); + var timer = NIL; + var lastVal = NIL; + return domAddListener(listenTarget, eventName, function(e) { return ((isSxTruthy((eventName == "submit")) ? preventDefault_(e) : NIL), (isSxTruthy((isSxTruthy((eventName == "click")) && (domTagName(el) == "A"))) ? preventDefault_(e) : NIL), (isSxTruthy(!validateForRequest(el)) ? domDispatch(el, "sx:validationFailed", {}) : (isSxTruthy((isSxTruthy(get(mods, "changed")) && isSxTruthy(!isNil(elementValue(el))) && (elementValue(el) == lastVal))) ? NIL : ((isSxTruthy(get(mods, "changed")) ? (lastVal = elementValue(el)) : NIL), (function() { + var optState = applyOptimistic(el); + var execFn = function() { return (function() { + var p = executeRequest(el, verbInfo, NIL); + return (isSxTruthy((isSxTruthy(optState) && p)) ? promiseCatch(p, function(_) { return revertOptimistic(optState); }) : NIL); +})(); }; + return (isSxTruthy(get(mods, "delay")) ? (clearTimeout_(timer), (timer = setTimeout_(execFn, get(mods, "delay")))) : execFn()); +})())))); }, {["once"]: get(mods, "once")}); +})(); }; + + // post-swap + var postSwap = function(root) { return (activateScripts(root), sxProcessScripts(root), sxHydrate(root), processElements(root)); }; + + // activate-scripts + var activateScripts = function(root) { return (function() { + var dead = domQueryAll(root, "script:not([type]), script[type='text/javascript']"); + return forEach(function(d) { return (function() { + var live = createScriptClone(d); + return domReplaceChild(domParent(d), live, d); +})(); }, dead); +})(); }; + + // process-oob-swaps + var processOobSwaps = function(container, swapFn) { return forEach(function(attr) { return (function() { + var oobEls = domQueryAll(container, (String("[") + String(attr) + String("]"))); + return forEach(function(oob) { return (function() { + var swapType = sxOr(domGetAttr(oob, attr), "outerHTML"); + var targetId = domId(oob); + domRemoveAttr(oob, attr); + if (isSxTruthy(domParent(oob))) { + domRemoveChild(domParent(oob), oob); +} + return (isSxTruthy(targetId) ? (function() { + var target = domQueryById(targetId); + return (isSxTruthy(target) ? swapFn(target, oob, swapType) : NIL); +})() : NIL); +})(); }, oobEls); +})(); }, ["sx-swap-oob", "hx-swap-oob"]); }; + + // hoist-head-elements + var hoistHeadElements = function(root) { return (function() { + var styles = domQueryAll(root, "style[data-sx-css]"); + var links = domQueryAll(root, "link[rel='stylesheet']"); + { var _c = styles; for (var _i = 0; _i < _c.length; _i++) { var el = _c[_i]; if (isSxTruthy(domParent(el))) { + domRemoveChild(domParent(el), el); +} } } + return forEach(function(el) { return (isSxTruthy(domParent(el)) ? domRemoveChild(domParent(el), el) : NIL); }, links); +})(); }; + + // process-boosted + var processBoosted = function(root) { return (function() { + var containers = domQueryAll(root, "[sx-boost]"); + if (isSxTruthy(domMatches(root, "[sx-boost]"))) { + boostDescendants(root); +} + return forEach(boostDescendants, containers); +})(); }; + + // boost-descendants + var boostDescendants = function(container) { return ((function() { + var links = domQueryAll(container, "a[href]"); + return forEach(function(link) { return (isSxTruthy((isSxTruthy(!isProcessed(link, "boost")) && shouldBoostLink(link))) ? (markProcessed(link, "boost"), bindBoostLink(link, domGetAttr(link, "href")), (isSxTruthy(!domHasAttr(link, "sx-target")) ? domSetAttr(link, "sx-target", "#main-panel") : NIL), (isSxTruthy(!domHasAttr(link, "sx-swap")) ? domSetAttr(link, "sx-swap", "innerHTML") : NIL), (isSxTruthy(!domHasAttr(link, "sx-select")) ? domSetAttr(link, "sx-select", "#main-panel") : NIL)) : NIL); }, links); +})(), (function() { + var forms = domQueryAll(container, "form"); + return forEach(function(form) { return (isSxTruthy((isSxTruthy(!isProcessed(form, "boost")) && shouldBoostForm(form))) ? (markProcessed(form, "boost"), bindBoostForm(form, sxOr(upper(domGetAttr(form, "method")), "GET"), sxOr(domGetAttr(form, "action"), browserLocationHref())), (isSxTruthy(!domHasAttr(form, "sx-target")) ? domSetAttr(form, "sx-target", "#main-panel") : NIL), (isSxTruthy(!domHasAttr(form, "sx-swap")) ? domSetAttr(form, "sx-swap", "innerHTML") : NIL)) : NIL); }, forms); +})()); }; + + // process-sse + var processSse = function(root) { return (function() { + var sseEls = domQueryAll(root, "[sx-sse]"); + if (isSxTruthy(domMatches(root, "[sx-sse]"))) { + bindSse(root); +} + return forEach(bindSse, sseEls); +})(); }; + + // bind-sse + var bindSse = function(el) { return (isSxTruthy(!isProcessed(el, "sse")) ? (markProcessed(el, "sse"), (function() { + var url = domGetAttr(el, "sx-sse"); + return (isSxTruthy(url) ? (function() { + var source = eventSourceConnect(url, el); + return (function() { + var swapEls = domQueryAll(el, "[sx-sse-swap]"); + if (isSxTruthy(domHasAttr(el, "sx-sse-swap"))) { + bindSseSwap(el, source); +} + return forEach(function(child) { return bindSseSwap(child, source); }, swapEls); +})(); +})() : NIL); +})()) : NIL); }; + + // bind-sse-swap + var bindSseSwap = function(el, source) { return (function() { + var eventName = parseSseSwap(el); + return eventSourceListen(source, eventName, function(data) { return (function() { + var target = sxOr(resolveTarget(el), el); + var swapStyle = sxOr(domGetAttr(el, "sx-swap"), "innerHTML"); + (isSxTruthy(startsWith(trim(data), "(")) ? (function() { + var dom = sxRender(data); + return swapDomNodes(target, dom, swapStyle); +})() : swapHtmlString(target, data, swapStyle)); + postSwap(target); + return domDispatch(el, "sx:sseMessage", {["data"]: data, ["event"]: eventName}); +})(); }); +})(); }; + + // bind-inline-handlers + var bindInlineHandlers = function(el) { return (isSxTruthy(!isProcessed(el, "on")) ? (markProcessed(el, "on"), (function() { + var attrs = domAttrList(el); + return forEach(function(attr) { return (function() { + var name = first(attr); + var val = nth(attr, 1); + return (isSxTruthy(startsWith(name, "sx-on:")) ? bindInlineHandler(el, slice(name, 6), val) : NIL); +})(); }, attrs); +})()) : NIL); }; + + // bind-preload-for + var bindPreloadFor = function(el) { return (isSxTruthy(domHasAttr(el, "sx-preload")) ? (function() { + var mode = sxOr(domGetAttr(el, "sx-preload"), "mousedown"); + var events = (isSxTruthy((mode == "mouseover")) ? ["mouseenter", "focusin"] : ["mousedown", "focusin"]); + var debounceMs = (isSxTruthy((mode == "mouseover")) ? 100 : 0); + return bindPreload(el, events, debounceMs, function() { return (function() { + var verb = getVerbInfo(el); + return (isSxTruthy(verb) ? (function() { + var url = get(verb, "url"); + return (isSxTruthy(isNil(preloadCacheGet(_preloadCache, url))) ? doPreload(url) : NIL); +})() : NIL); +})(); }); +})() : NIL); }; + + // do-preload + var doPreload = function(url) { return (function() { + var headers = buildRequestHeaders(NIL, loadedComponentNames(), _cssHash); + return fetchPreload(url, headers, _preloadCache); +})(); }; + + // VERB_SELECTOR + var VERB_SELECTOR = "[sx-get],[sx-post],[sx-put],[sx-delete],[sx-patch]"; + + // process-elements + var processElements = function(root) { return (function() { + var root = sxOr(root, domBody()); + return (isSxTruthy(root) ? ((isSxTruthy(domMatches(root, VERB_SELECTOR)) ? processOne(root) : NIL), (function() { + var elements = domQueryAll(root, VERB_SELECTOR); + return forEach(processOne, elements); +})(), processBoosted(root), processSse(root), (function() { + var onEls = domQueryAll(root, "[sx-on\\:beforeRequest],[sx-on\\:afterRequest],[sx-on\\:afterSwap],[sx-on\\:afterSettle],[sx-on\\:responseError]"); + return forEach(bindInlineHandlers, onEls); +})()) : NIL); +})(); }; + + // process-one + var processOne = function(el) { return (isSxTruthy(!isProcessed(el, "bound")) ? (isSxTruthy(!sxOr(domHasAttr(el, "sx-disable"), domClosest(el, "[sx-disable]"))) ? (markProcessed(el, "bound"), (function() { + var verbInfo = getVerbInfo(el); + return (isSxTruthy(verbInfo) ? (bindTriggers(el, verbInfo), bindPreloadFor(el)) : NIL); +})()) : NIL) : NIL); }; + + // handle-popstate + var handlePopstate = function(scrollY) { return (function() { + var url = browserLocationHref(); + var main = domQueryById("main-panel"); + return (isSxTruthy(!main) ? browserReload() : (function() { + var headers = buildRequestHeaders(NIL, loadedComponentNames(), _cssHash); + headers["SX-History-Restore"] = "true"; + return fetchAndRestore(main, url, headers, scrollY); +})()); +})(); }; + + // engine-init + var engineInit = function() { return (initCssTracking(), sxProcessScripts(NIL), sxHydrate(NIL), processElements(NIL)); }; + // ========================================================================= // Platform interface — DOM adapter (browser-only) @@ -1411,6 +1759,8 @@ // Platform interface — Engine (browser-only) // ========================================================================= + // --- Browser/Network --- + function browserLocationHref() { return typeof location !== "undefined" ? location.href : ""; } @@ -1472,6 +1822,531 @@ } catch (e) { return null; } } + function csrfToken() { + if (!_hasDom) return NIL; + var m = document.querySelector('meta[name="csrf-token"]'); + return m ? m.getAttribute("content") : NIL; + } + + function isCrossOrigin(url) { + try { + var h = new URL(url, location.href).hostname; + return h !== location.hostname && + (h.indexOf(".rose-ash.com") >= 0 || h.indexOf(".localhost") >= 0); + } catch (e) { return false; } + } + + // --- Promises --- + + function promiseResolve(val) { return Promise.resolve(val); } + + function promiseCatch(p, fn) { return p && p.catch ? p.catch(fn) : p; } + + // --- Abort controllers --- + + var _controllers = typeof WeakMap !== "undefined" ? new WeakMap() : null; + + function abortPrevious(el) { + if (_controllers) { + var prev = _controllers.get(el); + if (prev) prev.abort(); + } + } + + function trackController(el, ctrl) { + if (_controllers) _controllers.set(el, ctrl); + } + + function newAbortController() { + return typeof AbortController !== "undefined" ? new AbortController() : { signal: null, abort: function() {} }; + } + + function controllerSignal(ctrl) { return ctrl ? ctrl.signal : null; } + + function isAbortError(err) { return err && err.name === "AbortError"; } + + // --- Timers --- + + function setTimeout_(fn, ms) { return setTimeout(fn, ms || 0); } + function setInterval_(fn, ms) { return setInterval(fn, ms || 1000); } + function clearTimeout_(id) { clearTimeout(id); } + function requestAnimationFrame_(fn) { + if (typeof requestAnimationFrame !== "undefined") requestAnimationFrame(fn); + else setTimeout(fn, 16); + } + + // --- Fetch --- + + function fetchRequest(config, successFn, errorFn) { + var opts = { method: config.method, headers: config.headers }; + if (config.signal) opts.signal = config.signal; + if (config.body && config.method !== "GET") opts.body = config.body; + if (config["cross-origin"]) opts.credentials = "include"; + + var p = config.preloaded + ? Promise.resolve({ + ok: true, status: 200, + headers: new Headers({ "Content-Type": config.preloaded["content-type"] || "" }), + text: function() { return Promise.resolve(config.preloaded.text); } + }) + : fetch(config.url, opts); + + return p.then(function(resp) { + return resp.text().then(function(text) { + var getHeader = function(name) { + var v = resp.headers.get(name); + return v === null ? NIL : v; + }; + return successFn(resp.ok, resp.status, getHeader, text); + }); + }).catch(function(err) { + return errorFn(err); + }); + } + + function fetchLocation(headerVal) { + if (!_hasDom) return; + var locUrl = headerVal; + try { var obj = JSON.parse(headerVal); locUrl = obj.path || obj; } catch (e) {} + fetch(locUrl, { headers: { "SX-Request": "true" } }).then(function(r) { + return r.text().then(function(t) { + var main = document.getElementById("main-panel"); + if (main) { + main.innerHTML = t; + postSwap(main); + try { history.pushState({ sxUrl: locUrl }, "", locUrl); } catch (e) {} + } + }); + }); + } + + function fetchAndRestore(main, url, headers, scrollY) { + var opts = { headers: headers }; + try { + var h = new URL(url, location.href).hostname; + if (h !== location.hostname && + (h.indexOf(".rose-ash.com") >= 0 || h.indexOf(".localhost") >= 0)) { + opts.credentials = "include"; + } + } catch (e) {} + + fetch(url, opts).then(function(resp) { + return resp.text().then(function(text) { + text = stripComponentScripts(text); + text = extractResponseCss(text); + text = text.trim(); + if (text.charAt(0) === "(") { + try { + var dom = sxRender(text); + var container = document.createElement("div"); + container.appendChild(dom); + processOobSwaps(container, function(t, oob, s) { + swapDomNodes(t, oob, s); + sxHydrate(t); + processElements(t); + }); + var newMain = container.querySelector("#main-panel"); + morphChildren(main, newMain || container); + postSwap(main); + if (typeof window !== "undefined") window.scrollTo(0, scrollY || 0); + } catch (err) { + console.error("sx-ref popstate error:", err); + location.reload(); + } + } else { + var parser = new DOMParser(); + var doc = parser.parseFromString(text, "text/html"); + var newMain = doc.getElementById("main-panel"); + if (newMain) { + morphChildren(main, newMain); + postSwap(main); + if (typeof window !== "undefined") window.scrollTo(0, scrollY || 0); + } else { + location.reload(); + } + } + }); + }).catch(function() { location.reload(); }); + } + + function fetchPreload(url, headers, cache) { + fetch(url, { headers: headers }).then(function(resp) { + if (!resp.ok) return; + var ct = resp.headers.get("Content-Type") || ""; + return resp.text().then(function(text) { + preloadCacheSet(cache, url, text, ct); + }); + }).catch(function() { /* ignore */ }); + } + + // --- Request body building --- + + function buildRequestBody(el, method, url) { + if (!_hasDom) return { body: null, url: url, "content-type": NIL }; + var body = null; + var ct = NIL; + var finalUrl = url; + var isJson = el.getAttribute("sx-encoding") === "json"; + + if (method !== "GET") { + var form = el.closest("form") || (el.tagName === "FORM" ? el : null); + if (form) { + if (isJson) { + var fd = new FormData(form); + var obj = {}; + fd.forEach(function(v, k) { + if (obj[k] !== undefined) { + if (!Array.isArray(obj[k])) obj[k] = [obj[k]]; + obj[k].push(v); + } else { obj[k] = v; } + }); + body = JSON.stringify(obj); + ct = "application/json"; + } else { + body = new URLSearchParams(new FormData(form)); + ct = "application/x-www-form-urlencoded"; + } + } + } + + // sx-params + var paramsSpec = el.getAttribute("sx-params"); + if (paramsSpec && body instanceof URLSearchParams) { + if (paramsSpec === "none") { + body = new URLSearchParams(); + } else if (paramsSpec.indexOf("not ") === 0) { + paramsSpec.substring(4).split(",").forEach(function(k) { body.delete(k.trim()); }); + } else if (paramsSpec !== "*") { + var allowed = paramsSpec.split(",").map(function(s) { return s.trim(); }); + var filtered = new URLSearchParams(); + allowed.forEach(function(k) { + body.getAll(k).forEach(function(v) { filtered.append(k, v); }); + }); + body = filtered; + } + } + + // sx-include + var includeSel = el.getAttribute("sx-include"); + if (includeSel && method !== "GET") { + if (!body) body = new URLSearchParams(); + document.querySelectorAll(includeSel).forEach(function(inp) { + if (inp.name) body.append(inp.name, inp.value); + }); + } + + // sx-vals + var valsAttr = el.getAttribute("sx-vals"); + if (valsAttr) { + try { + var vals = valsAttr.charAt(0) === "{" && valsAttr.charAt(1) === ":" ? parse(valsAttr) : JSON.parse(valsAttr); + if (method === "GET") { + for (var vk in vals) finalUrl += (finalUrl.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(vk) + "=" + encodeURIComponent(vals[vk]); + } else if (body instanceof URLSearchParams) { + for (var vk2 in vals) body.append(vk2, vals[vk2]); + } else if (!body) { + body = new URLSearchParams(); + for (var vk3 in vals) body.append(vk3, vals[vk3]); + ct = "application/x-www-form-urlencoded"; + } + } catch (e) {} + } + + // GET form data → URL + if (method === "GET") { + var form2 = el.closest("form") || (el.tagName === "FORM" ? el : null); + if (form2) { + var qs = new URLSearchParams(new FormData(form2)).toString(); + if (qs) finalUrl += (finalUrl.indexOf("?") >= 0 ? "&" : "?") + qs; + } + if ((el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA") && el.name) { + finalUrl += (finalUrl.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(el.name) + "=" + encodeURIComponent(el.value); + } + } + + return { body: body, url: finalUrl, "content-type": ct }; + } + + // --- Loading state --- + + function showIndicator(el) { + if (!_hasDom) return NIL; + var sel = el.getAttribute("sx-indicator"); + var ind = sel ? (document.querySelector(sel) || el.closest(sel)) : null; + if (ind) { ind.classList.add("sx-request"); ind.style.display = ""; } + return ind || NIL; + } + + function disableElements(el) { + if (!_hasDom) return []; + var sel = el.getAttribute("sx-disabled-elt"); + if (!sel) return []; + var elts = Array.prototype.slice.call(document.querySelectorAll(sel)); + elts.forEach(function(e) { e.disabled = true; }); + return elts; + } + + function clearLoadingState(el, indicator, disabledElts) { + el.classList.remove("sx-request"); + el.removeAttribute("aria-busy"); + if (indicator && !isNil(indicator)) { + indicator.classList.remove("sx-request"); + indicator.style.display = "none"; + } + if (disabledElts) { + for (var i = 0; i < disabledElts.length; i++) disabledElts[i].disabled = false; + } + } + + // --- DOM extras --- + + function domQueryById(id) { + return _hasDom ? document.getElementById(id) : null; + } + + function domMatches(el, sel) { + return el && el.matches ? el.matches(sel) : false; + } + + function domClosest(el, sel) { + return el && el.closest ? el.closest(sel) : null; + } + + function domBody() { + return _hasDom ? document.body : null; + } + + function domHasClass(el, cls) { + return el && el.classList ? el.classList.contains(cls) : false; + } + + function domAppendToHead(el) { + if (_hasDom && document.head) document.head.appendChild(el); + } + + function domParseHtmlDocument(text) { + if (!_hasDom) return null; + return new DOMParser().parseFromString(text, "text/html"); + } + + function domOuterHtml(el) { + return el ? el.outerHTML : ""; + } + + function domBodyInnerHtml(doc) { + return doc && doc.body ? doc.body.innerHTML : ""; + } + + // --- Events --- + + function preventDefault_(e) { if (e && e.preventDefault) e.preventDefault(); } + function elementValue(el) { return el && el.value !== undefined ? el.value : NIL; } + + function domAddListener(el, event, fn, opts) { + if (!el || !el.addEventListener) return; + var o = {}; + if (opts && !isNil(opts)) { + if (opts.once || opts["once"]) o.once = true; + } + el.addEventListener(event, fn, o); + } + + // --- Validation --- + + function validateForRequest(el) { + if (!_hasDom) return true; + var attr = el.getAttribute("sx-validate"); + if (attr === null) { + var vForm = el.closest("[sx-validate]"); + if (vForm) attr = vForm.getAttribute("sx-validate"); + } + if (attr === null) return true; // no validation configured + var form = el.tagName === "FORM" ? el : el.closest("form"); + if (form && !form.reportValidity()) return false; + if (attr && attr !== "true" && attr !== "") { + var fn = window[attr]; + if (typeof fn === "function" && !fn(el)) return false; + } + return true; + } + + // --- View Transitions --- + + function withTransition(enabled, fn) { + if (enabled && _hasDom && document.startViewTransition) { + document.startViewTransition(fn); + } else { + fn(); + } + } + + // --- IntersectionObserver --- + + function observeIntersection(el, fn, once, delay) { + if (!_hasDom || !("IntersectionObserver" in window)) { fn(); return; } + var fired = false; + var d = isNil(delay) ? 0 : delay; + var obs = new IntersectionObserver(function(entries) { + entries.forEach(function(entry) { + if (!entry.isIntersecting) return; + if (once && fired) return; + fired = true; + if (once) obs.unobserve(el); + if (d) setTimeout(fn, d); else fn(); + }); + }); + obs.observe(el); + } + + // --- EventSource --- + + function eventSourceConnect(url, el) { + var source = new EventSource(url); + source.addEventListener("error", function() { domDispatch(el, "sx:sseError", {}); }); + source.addEventListener("open", function() { domDispatch(el, "sx:sseOpen", {}); }); + if (typeof MutationObserver !== "undefined") { + var obs = new MutationObserver(function() { + if (!document.body.contains(el)) { source.close(); obs.disconnect(); } + }); + obs.observe(document.body, { childList: true, subtree: true }); + } + return source; + } + + function eventSourceListen(source, event, fn) { + source.addEventListener(event, function(e) { fn(e.data); }); + } + + // --- Boost bindings --- + + function bindBoostLink(el, href) { + el.addEventListener("click", function(e) { + e.preventDefault(); + executeRequest(el, { method: "GET", url: href }).then(function() { + try { history.pushState({ sxUrl: href, scrollY: window.scrollY }, "", href); } catch (err) {} + }); + }); + } + + function bindBoostForm(form, method, action) { + form.addEventListener("submit", function(e) { + e.preventDefault(); + executeRequest(form, { method: method, url: action }).then(function() { + try { history.pushState({ sxUrl: action, scrollY: window.scrollY }, "", action); } catch (err) {} + }); + }); + } + + // --- Inline handlers --- + + function bindInlineHandler(el, eventName, body) { + el.addEventListener(eventName, new Function("event", body)); + } + + // --- Preload binding --- + + function bindPreload(el, events, debounceMs, fn) { + var timer = null; + events.forEach(function(evt) { + el.addEventListener(evt, function() { + if (debounceMs) { + clearTimeout(timer); + timer = setTimeout(fn, debounceMs); + } else { + fn(); + } + }); + }); + } + + // --- Processing markers --- + + var PROCESSED = "_sxBound"; + + function markProcessed(el, key) { el[PROCESSED + key] = true; } + function isProcessed(el, key) { return !!el[PROCESSED + key]; } + + // --- Script cloning --- + + function createScriptClone(dead) { + var live = document.createElement("script"); + for (var i = 0; i < dead.attributes.length; i++) + live.setAttribute(dead.attributes[i].name, dead.attributes[i].value); + live.textContent = dead.textContent; + return live; + } + + // --- SX API references --- + + function sxRender(source) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (SxObj && SxObj.render) return SxObj.render(source); + throw new Error("No SX renderer available"); + } + + function sxProcessScripts(root) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (SxObj && SxObj.processScripts) SxObj.processScripts(root || undefined); + } + + function sxHydrate(root) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (SxObj && SxObj.hydrate) SxObj.hydrate(root || undefined); + } + + function loadedComponentNames() { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (!SxObj) return []; + var env = SxObj.componentEnv || (SxObj.getEnv ? SxObj.getEnv() : {}); + return Object.keys(env).filter(function(k) { return k.charAt(0) === "~"; }); + } + + // --- Response processing --- + + function stripComponentScripts(text) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + return text.replace(/]*type="text\/sx"[^>]*data-components[^>]*>([\s\S]*?)<\/script>/gi, + function(_, defs) { if (SxObj && SxObj.loadComponents) SxObj.loadComponents(defs); return ""; }); + } + + function extractResponseCss(text) { + if (!_hasDom) return text; + var target = document.getElementById("sx-css"); + if (!target) return text; + return text.replace(/]*data-sx-css[^>]*>([\s\S]*?)<\/style>/gi, + function(_, css) { target.textContent += css; return ""; }); + } + + function selectFromContainer(container, sel) { + var frag = document.createDocumentFragment(); + sel.split(",").forEach(function(s) { + container.querySelectorAll(s.trim()).forEach(function(m) { frag.appendChild(m); }); + }); + return frag; + } + + function childrenToFragment(container) { + var frag = document.createDocumentFragment(); + while (container.firstChild) frag.appendChild(container.firstChild); + return frag; + } + + function selectHtmlFromDoc(doc, sel) { + var parts = sel.split(",").map(function(s) { return s.trim(); }); + var frags = []; + parts.forEach(function(s) { + doc.querySelectorAll(s).forEach(function(m) { frags.push(m.outerHTML); }); + }); + return frags.join(""); + } + + // --- Parsing --- + + function tryParseJson(s) { + if (!s) return NIL; + try { return JSON.parse(s); } catch (e) { return NIL; } + } + // ========================================================================= // Post-transpilation fixups @@ -1625,9 +2500,30 @@ morphNode: typeof morphNode === "function" ? morphNode : null, morphChildren: typeof morphChildren === "function" ? morphChildren : null, swapDomNodes: typeof swapDomNodes === "function" ? swapDomNodes : null, + process: typeof processElements === "function" ? processElements : null, + executeRequest: typeof executeRequest === "function" ? executeRequest : null, + postSwap: typeof postSwap === "function" ? postSwap : null, + init: typeof engineInit === "function" ? engineInit : null, _version: "ref-2.0 (dom+engine, bootstrap-compiled)" }; + + // --- Popstate listener --- + if (typeof window !== "undefined") { + window.addEventListener("popstate", function(e) { + handlePopstate(e && e.state ? e.state.scrollY || 0 : 0); + }); + } + + // --- Auto-init --- + if (typeof document !== "undefined") { + var _sxRefInit = function() { engineInit(); }; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", _sxRefInit); + } else { + _sxRefInit(); + } + } if (typeof module !== "undefined" && module.exports) module.exports = SxRef; else global.SxRef = SxRef; diff --git a/shared/static/scripts/sx-ref.js b/shared/static/scripts/sx-ref.js index c7aff82..76c06bf 100644 --- a/shared/static/scripts/sx-ref.js +++ b/shared/static/scripts/sx-ref.js @@ -1400,6 +1400,354 @@ // parse-sse-swap var parseSseSwap = function(el) { return sxOr(domGetAttr(el, "sx-sse-swap"), "message"); }; + // _preload-cache + var _preloadCache = {}; + + // _css-hash + var _cssHash = ""; + + // dispatch-trigger-events + var dispatchTriggerEvents = function(el, headerVal) { return (isSxTruthy(headerVal) ? (function() { + var parsed = tryParseJson(headerVal); + return (isSxTruthy((isSxTruthy(parsed) && isDict(parsed))) ? forEach(function(key) { return domDispatch(el, key, dictGet(parsed, key)); }, keys(parsed)) : forEach(function(name) { return (function() { + var n = trim(name); + return (isSxTruthy(!(n == "")) ? domDispatch(el, n, {}) : NIL); +})(); }, split(headerVal, ","))); +})() : NIL); }; + + // init-css-tracking + var initCssTracking = function() { return (function() { + var meta = domQuery("meta[name=\"sx-css-classes\"]"); + return (isSxTruthy(meta) ? (function() { + var content = domGetAttr(meta, "content"); + return (isSxTruthy(content) ? (_cssHash = content) : NIL); +})() : NIL); +})(); }; + + // execute-request + var executeRequest = function(el, verbInfo, extraParams) { return (function() { + var currentVerb = getVerbInfo(el); + var verb = (isSxTruthy(currentVerb) ? currentVerb : verbInfo); + var method = get(verb, "method"); + var url = get(verb, "url"); + if (isSxTruthy(!domHasClass(el, "sx-error"))) { + domRemoveAttr(el, "data-sx-retry-ms"); +} + return (isSxTruthy((function() { + var media = domGetAttr(el, "sx-media"); + return (isSxTruthy(media) && !browserMediaMatches(media)); +})()) ? promiseResolve(NIL) : (isSxTruthy((function() { + var msg = domGetAttr(el, "sx-confirm"); + return (isSxTruthy(msg) && !browserConfirm(msg)); +})()) ? promiseResolve(NIL) : (function() { + var promptMsg = domGetAttr(el, "sx-prompt"); + var params = extraParams; + return (isSxTruthy(promptMsg) ? (function() { + var promptVal = browserPrompt(promptMsg); + return (isSxTruthy(isNil(promptVal)) ? promiseResolve(NIL) : ((params = sxOr(params, {})), dictSet(params, "promptValue", promptVal), doFetch(el, verb, method, url, params))); +})() : doFetch(el, verb, method, url, params)); +})())); +})(); }; + + // do-fetch + var doFetch = function(el, verb, method, url, extraParams) { return (function() { + var syncAttr = domGetAttr(el, "sx-sync"); + if (isSxTruthy((isSxTruthy(syncAttr) && contains(syncAttr, "replace")))) { + abortPrevious(el); +} + return (function() { + var ctrl = newAbortController(); + trackController(el, ctrl); + return (function() { + var headers = buildRequestHeaders(el, loadedComponentNames(), _cssHash); + if (isSxTruthy((isSxTruthy(extraParams) && dictHas(extraParams, "promptValue")))) { + headers["SX-Prompt"] = get(extraParams, "promptValue"); +} + if (isSxTruthy((isSxTruthy(!(method == "GET")) && browserSameOrigin(url)))) { + (function() { + var csrf = csrfToken(); + return (isSxTruthy(csrf) ? dictSet(headers, "X-CSRFToken", csrf) : NIL); +})(); +} + return (function() { + var bodyInfo = buildRequestBody(el, method, url); + return (function() { + var body = get(bodyInfo, "body"); + var finalUrl = get(bodyInfo, "url"); + var ct = get(bodyInfo, "content-type"); + if (isSxTruthy(ct)) { + headers["Content-Type"] = ct; +} + return (isSxTruthy(!domDispatch(el, "sx:beforeRequest", {["method"]: method, ["url"]: finalUrl})) ? promiseResolve(NIL) : (domAddClass(el, "sx-request"), domSetAttr(el, "aria-busy", "true"), (function() { + var indicator = showIndicator(el); + var disabledElts = disableElements(el); + var preloaded = (isSxTruthy((method == "GET")) ? preloadCacheGet(_preloadCache, finalUrl) : NIL); + return fetchRequest({["url"]: finalUrl, ["method"]: method, ["headers"]: headers, ["body"]: body, ["signal"]: controllerSignal(ctrl), ["preloaded"]: preloaded, ["cross-origin"]: isCrossOrigin(finalUrl)}, function(respOk, status, getHeader, text) { return (clearLoadingState(el, indicator, disabledElts), (isSxTruthy(!respOk) ? (domDispatch(el, "sx:responseError", {["status"]: status}), handleRetry(el, verb, extraParams)) : (domDispatch(el, "sx:afterRequest", {}), handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text)))); }, function(err) { return (clearLoadingState(el, indicator, disabledElts), (isSxTruthy(!isAbortError(err)) ? (domDispatch(el, "sx:sendError", {["error"]: err}), handleRetry(el, verb, extraParams)) : NIL)); }); +})())); +})(); +})(); +})(); +})(); +})(); }; + + // handle-fetch-success + var handleFetchSuccess = function(el, url, verb, extraParams, getHeader, text) { return (function() { + var headers = processResponseHeaders(getHeader); + return (isSxTruthy(get(headers, "redirect")) ? browserNavigate(get(headers, "redirect")) : (isSxTruthy((get(headers, "refresh") == "true")) ? browserReload() : (dispatchTriggerEvents(el, get(headers, "trigger")), (function() { + var rawSwap = sxOr(domGetAttr(el, "sx-swap"), DEFAULT_SWAP); + var target = resolveTarget(el); + var selectSel = domGetAttr(el, "sx-select"); + if (isSxTruthy(get(headers, "retarget"))) { + target = sxOr(domQuery(get(headers, "retarget")), target); +} + if (isSxTruthy(get(headers, "reswap"))) { + rawSwap = get(headers, "reswap"); +} + return (function() { + var swap = parseSwapSpec(rawSwap, false); + var ct = sxOr(get(headers, "content-type"), ""); + (isSxTruthy(contains(ct, "text/sx")) ? handleSxResponse(el, target, swap, selectSel, text) : handleHtmlResponse(el, target, swap, selectSel, text)); + if (isSxTruthy(get(headers, "location"))) { + fetchLocation(get(headers, "location")); +} + handleHistory(el, url, headers); + domDispatch(el, "sx:afterSwap", {["target"]: target}); + dispatchTriggerEvents(el, get(headers, "trigger-swap")); + return requestAnimationFrame_(function() { return (domDispatch(el, "sx:afterSettle", {["target"]: target}), dispatchTriggerEvents(el, get(headers, "trigger-settle"))); }); +})(); +})()))); +})(); }; + + // handle-sx-response + var handleSxResponse = function(el, target, swap, selectSel, text) { return (function() { + var cleaned = stripComponentScripts(text); + var cleaned2 = extractResponseCss(cleaned); + return (function() { + var source = trim(cleaned2); + return (isSxTruthy((isSxTruthy(source) && !(source == ""))) ? (function() { + var dom = sxRender(source); + var container = domCreateElement("div", NIL); + domAppend(container, dom); + processOobSwaps(container, function(t, oob, s) { return swapDomNodes(t, oob, s); }); + return (function() { + var selected = (isSxTruthy(selectSel) ? selectFromContainer(container, selectSel) : childrenToFragment(container)); + return (isSxTruthy((isSxTruthy(!(get(swap, "style") == "none")) && target)) ? withTransition(get(swap, "transition"), function() { return (swapDomNodes(target, selected, get(swap, "style")), hoistHeadElements(target)); }) : NIL); +})(); +})() : NIL); +})(); +})(); }; + + // handle-html-response + var handleHtmlResponse = function(el, target, swap, selectSel, text) { return (function() { + var doc = domParseHtmlDocument(text); + sxProcessScripts(doc); + processOobSwaps(doc, function(t, oob, s) { return swapHtmlString(t, domOuterHtml(oob), s); }); + return (function() { + var content = (isSxTruthy(selectSel) ? selectHtmlFromDoc(doc, selectSel) : sxOr(domBodyInnerHtml(doc), text)); + return (isSxTruthy((isSxTruthy(!(get(swap, "style") == "none")) && target)) ? withTransition(get(swap, "transition"), function() { return (swapHtmlString(target, content, get(swap, "style")), hoistHeadElements(target)); }) : NIL); +})(); +})(); }; + + // handle-retry + var handleRetry = function(el, verbInfo, extraParams) { return (function() { + var retryAttr = domGetAttr(el, "sx-retry"); + return (isSxTruthy(retryAttr) ? (function() { + var spec = parseRetrySpec(retryAttr); + var currentMs = sxOr(parseInt_(domGetAttr(el, "data-sx-retry-ms"), 0), get(spec, "start-ms")); + domAddClass(el, "sx-error"); + domRemoveClass(el, "sx-loading"); + return setTimeout_(function() { return (domRemoveClass(el, "sx-error"), domAddClass(el, "sx-loading"), domSetAttr(el, "data-sx-retry-ms", (String(nextRetryMs(currentMs, get(spec, "cap-ms"))))), executeRequest(el, verbInfo, extraParams)); }, currentMs); +})() : NIL); +})(); }; + + // bind-triggers + var bindTriggers = function(el, verbInfo) { return (function() { + var triggerSpec = domGetAttr(el, "sx-trigger"); + var triggers = (isSxTruthy(triggerSpec) ? parseTriggerSpec(triggerSpec) : defaultTrigger(domTagName(el))); + return forEach(function(trig) { return (function() { + var kind = classifyTrigger(trig); + return (isSxTruthy((kind == "poll")) ? setInterval_(function() { return executeRequest(el, verbInfo, NIL); }, sxOr(get(get(trig, "modifiers"), "interval"), 1000)) : (isSxTruthy((kind == "intersect")) ? observeIntersection(el, function() { return executeRequest(el, verbInfo, NIL); }, get(get(trig, "modifiers"), "once"), get(get(trig, "modifiers"), "delay")) : (isSxTruthy((kind == "load")) ? setTimeout_(function() { return executeRequest(el, verbInfo, NIL); }, 0) : (isSxTruthy((kind == "revealed")) ? observeIntersection(el, function() { return executeRequest(el, verbInfo, NIL); }, true, NIL) : bindEvent(el, verbInfo, trig))))); +})(); }, triggers); +})(); }; + + // bind-event + var bindEvent = function(el, verbInfo, trig) { return (function() { + var eventName = get(trig, "event"); + var mods = get(trig, "modifiers"); + var listenTarget = (isSxTruthy(get(mods, "from")) ? sxOr(domQuery(get(mods, "from")), el) : el); + var timer = NIL; + var lastVal = NIL; + return domAddListener(listenTarget, eventName, function(e) { return ((isSxTruthy((eventName == "submit")) ? preventDefault_(e) : NIL), (isSxTruthy((isSxTruthy((eventName == "click")) && (domTagName(el) == "A"))) ? preventDefault_(e) : NIL), (isSxTruthy(!validateForRequest(el)) ? domDispatch(el, "sx:validationFailed", {}) : (isSxTruthy((isSxTruthy(get(mods, "changed")) && isSxTruthy(!isNil(elementValue(el))) && (elementValue(el) == lastVal))) ? NIL : ((isSxTruthy(get(mods, "changed")) ? (lastVal = elementValue(el)) : NIL), (function() { + var optState = applyOptimistic(el); + var execFn = function() { return (function() { + var p = executeRequest(el, verbInfo, NIL); + return (isSxTruthy((isSxTruthy(optState) && p)) ? promiseCatch(p, function(_) { return revertOptimistic(optState); }) : NIL); +})(); }; + return (isSxTruthy(get(mods, "delay")) ? (clearTimeout_(timer), (timer = setTimeout_(execFn, get(mods, "delay")))) : execFn()); +})())))); }, {["once"]: get(mods, "once")}); +})(); }; + + // post-swap + var postSwap = function(root) { return (activateScripts(root), sxProcessScripts(root), sxHydrate(root), processElements(root)); }; + + // activate-scripts + var activateScripts = function(root) { return (function() { + var dead = domQueryAll(root, "script:not([type]), script[type='text/javascript']"); + return forEach(function(d) { return (function() { + var live = createScriptClone(d); + return domReplaceChild(domParent(d), live, d); +})(); }, dead); +})(); }; + + // process-oob-swaps + var processOobSwaps = function(container, swapFn) { return forEach(function(attr) { return (function() { + var oobEls = domQueryAll(container, (String("[") + String(attr) + String("]"))); + return forEach(function(oob) { return (function() { + var swapType = sxOr(domGetAttr(oob, attr), "outerHTML"); + var targetId = domId(oob); + domRemoveAttr(oob, attr); + if (isSxTruthy(domParent(oob))) { + domRemoveChild(domParent(oob), oob); +} + return (isSxTruthy(targetId) ? (function() { + var target = domQueryById(targetId); + return (isSxTruthy(target) ? swapFn(target, oob, swapType) : NIL); +})() : NIL); +})(); }, oobEls); +})(); }, ["sx-swap-oob", "hx-swap-oob"]); }; + + // hoist-head-elements + var hoistHeadElements = function(root) { return (function() { + var styles = domQueryAll(root, "style[data-sx-css]"); + var links = domQueryAll(root, "link[rel='stylesheet']"); + { var _c = styles; for (var _i = 0; _i < _c.length; _i++) { var el = _c[_i]; if (isSxTruthy(domParent(el))) { + domRemoveChild(domParent(el), el); +} } } + return forEach(function(el) { return (isSxTruthy(domParent(el)) ? domRemoveChild(domParent(el), el) : NIL); }, links); +})(); }; + + // process-boosted + var processBoosted = function(root) { return (function() { + var containers = domQueryAll(root, "[sx-boost]"); + if (isSxTruthy(domMatches(root, "[sx-boost]"))) { + boostDescendants(root); +} + return forEach(boostDescendants, containers); +})(); }; + + // boost-descendants + var boostDescendants = function(container) { return ((function() { + var links = domQueryAll(container, "a[href]"); + return forEach(function(link) { return (isSxTruthy((isSxTruthy(!isProcessed(link, "boost")) && shouldBoostLink(link))) ? (markProcessed(link, "boost"), bindBoostLink(link, domGetAttr(link, "href")), (isSxTruthy(!domHasAttr(link, "sx-target")) ? domSetAttr(link, "sx-target", "#main-panel") : NIL), (isSxTruthy(!domHasAttr(link, "sx-swap")) ? domSetAttr(link, "sx-swap", "innerHTML") : NIL), (isSxTruthy(!domHasAttr(link, "sx-select")) ? domSetAttr(link, "sx-select", "#main-panel") : NIL)) : NIL); }, links); +})(), (function() { + var forms = domQueryAll(container, "form"); + return forEach(function(form) { return (isSxTruthy((isSxTruthy(!isProcessed(form, "boost")) && shouldBoostForm(form))) ? (markProcessed(form, "boost"), bindBoostForm(form, sxOr(upper(domGetAttr(form, "method")), "GET"), sxOr(domGetAttr(form, "action"), browserLocationHref())), (isSxTruthy(!domHasAttr(form, "sx-target")) ? domSetAttr(form, "sx-target", "#main-panel") : NIL), (isSxTruthy(!domHasAttr(form, "sx-swap")) ? domSetAttr(form, "sx-swap", "innerHTML") : NIL)) : NIL); }, forms); +})()); }; + + // process-sse + var processSse = function(root) { return (function() { + var sseEls = domQueryAll(root, "[sx-sse]"); + if (isSxTruthy(domMatches(root, "[sx-sse]"))) { + bindSse(root); +} + return forEach(bindSse, sseEls); +})(); }; + + // bind-sse + var bindSse = function(el) { return (isSxTruthy(!isProcessed(el, "sse")) ? (markProcessed(el, "sse"), (function() { + var url = domGetAttr(el, "sx-sse"); + return (isSxTruthy(url) ? (function() { + var source = eventSourceConnect(url, el); + return (function() { + var swapEls = domQueryAll(el, "[sx-sse-swap]"); + if (isSxTruthy(domHasAttr(el, "sx-sse-swap"))) { + bindSseSwap(el, source); +} + return forEach(function(child) { return bindSseSwap(child, source); }, swapEls); +})(); +})() : NIL); +})()) : NIL); }; + + // bind-sse-swap + var bindSseSwap = function(el, source) { return (function() { + var eventName = parseSseSwap(el); + return eventSourceListen(source, eventName, function(data) { return (function() { + var target = sxOr(resolveTarget(el), el); + var swapStyle = sxOr(domGetAttr(el, "sx-swap"), "innerHTML"); + (isSxTruthy(startsWith(trim(data), "(")) ? (function() { + var dom = sxRender(data); + return swapDomNodes(target, dom, swapStyle); +})() : swapHtmlString(target, data, swapStyle)); + postSwap(target); + return domDispatch(el, "sx:sseMessage", {["data"]: data, ["event"]: eventName}); +})(); }); +})(); }; + + // bind-inline-handlers + var bindInlineHandlers = function(el) { return (isSxTruthy(!isProcessed(el, "on")) ? (markProcessed(el, "on"), (function() { + var attrs = domAttrList(el); + return forEach(function(attr) { return (function() { + var name = first(attr); + var val = nth(attr, 1); + return (isSxTruthy(startsWith(name, "sx-on:")) ? bindInlineHandler(el, slice(name, 6), val) : NIL); +})(); }, attrs); +})()) : NIL); }; + + // bind-preload-for + var bindPreloadFor = function(el) { return (isSxTruthy(domHasAttr(el, "sx-preload")) ? (function() { + var mode = sxOr(domGetAttr(el, "sx-preload"), "mousedown"); + var events = (isSxTruthy((mode == "mouseover")) ? ["mouseenter", "focusin"] : ["mousedown", "focusin"]); + var debounceMs = (isSxTruthy((mode == "mouseover")) ? 100 : 0); + return bindPreload(el, events, debounceMs, function() { return (function() { + var verb = getVerbInfo(el); + return (isSxTruthy(verb) ? (function() { + var url = get(verb, "url"); + return (isSxTruthy(isNil(preloadCacheGet(_preloadCache, url))) ? doPreload(url) : NIL); +})() : NIL); +})(); }); +})() : NIL); }; + + // do-preload + var doPreload = function(url) { return (function() { + var headers = buildRequestHeaders(NIL, loadedComponentNames(), _cssHash); + return fetchPreload(url, headers, _preloadCache); +})(); }; + + // VERB_SELECTOR + var VERB_SELECTOR = "[sx-get],[sx-post],[sx-put],[sx-delete],[sx-patch]"; + + // process-elements + var processElements = function(root) { return (function() { + var root = sxOr(root, domBody()); + return (isSxTruthy(root) ? ((isSxTruthy(domMatches(root, VERB_SELECTOR)) ? processOne(root) : NIL), (function() { + var elements = domQueryAll(root, VERB_SELECTOR); + return forEach(processOne, elements); +})(), processBoosted(root), processSse(root), (function() { + var onEls = domQueryAll(root, "[sx-on\\:beforeRequest],[sx-on\\:afterRequest],[sx-on\\:afterSwap],[sx-on\\:afterSettle],[sx-on\\:responseError]"); + return forEach(bindInlineHandlers, onEls); +})()) : NIL); +})(); }; + + // process-one + var processOne = function(el) { return (isSxTruthy(!isProcessed(el, "bound")) ? (isSxTruthy(!sxOr(domHasAttr(el, "sx-disable"), domClosest(el, "[sx-disable]"))) ? (markProcessed(el, "bound"), (function() { + var verbInfo = getVerbInfo(el); + return (isSxTruthy(verbInfo) ? (bindTriggers(el, verbInfo), bindPreloadFor(el)) : NIL); +})()) : NIL) : NIL); }; + + // handle-popstate + var handlePopstate = function(scrollY) { return (function() { + var url = browserLocationHref(); + var main = domQueryById("main-panel"); + return (isSxTruthy(!main) ? browserReload() : (function() { + var headers = buildRequestHeaders(NIL, loadedComponentNames(), _cssHash); + headers["SX-History-Restore"] = "true"; + return fetchAndRestore(main, url, headers, scrollY); +})()); +})(); }; + + // engine-init + var engineInit = function() { return (initCssTracking(), sxProcessScripts(NIL), sxHydrate(NIL), processElements(NIL)); }; + // ========================================================================= // Platform interface — DOM adapter (browser-only) @@ -1559,6 +1907,8 @@ // Platform interface — Engine (browser-only) // ========================================================================= + // --- Browser/Network --- + function browserLocationHref() { return typeof location !== "undefined" ? location.href : ""; } @@ -1620,6 +1970,531 @@ } catch (e) { return null; } } + function csrfToken() { + if (!_hasDom) return NIL; + var m = document.querySelector('meta[name="csrf-token"]'); + return m ? m.getAttribute("content") : NIL; + } + + function isCrossOrigin(url) { + try { + var h = new URL(url, location.href).hostname; + return h !== location.hostname && + (h.indexOf(".rose-ash.com") >= 0 || h.indexOf(".localhost") >= 0); + } catch (e) { return false; } + } + + // --- Promises --- + + function promiseResolve(val) { return Promise.resolve(val); } + + function promiseCatch(p, fn) { return p && p.catch ? p.catch(fn) : p; } + + // --- Abort controllers --- + + var _controllers = typeof WeakMap !== "undefined" ? new WeakMap() : null; + + function abortPrevious(el) { + if (_controllers) { + var prev = _controllers.get(el); + if (prev) prev.abort(); + } + } + + function trackController(el, ctrl) { + if (_controllers) _controllers.set(el, ctrl); + } + + function newAbortController() { + return typeof AbortController !== "undefined" ? new AbortController() : { signal: null, abort: function() {} }; + } + + function controllerSignal(ctrl) { return ctrl ? ctrl.signal : null; } + + function isAbortError(err) { return err && err.name === "AbortError"; } + + // --- Timers --- + + function setTimeout_(fn, ms) { return setTimeout(fn, ms || 0); } + function setInterval_(fn, ms) { return setInterval(fn, ms || 1000); } + function clearTimeout_(id) { clearTimeout(id); } + function requestAnimationFrame_(fn) { + if (typeof requestAnimationFrame !== "undefined") requestAnimationFrame(fn); + else setTimeout(fn, 16); + } + + // --- Fetch --- + + function fetchRequest(config, successFn, errorFn) { + var opts = { method: config.method, headers: config.headers }; + if (config.signal) opts.signal = config.signal; + if (config.body && config.method !== "GET") opts.body = config.body; + if (config["cross-origin"]) opts.credentials = "include"; + + var p = config.preloaded + ? Promise.resolve({ + ok: true, status: 200, + headers: new Headers({ "Content-Type": config.preloaded["content-type"] || "" }), + text: function() { return Promise.resolve(config.preloaded.text); } + }) + : fetch(config.url, opts); + + return p.then(function(resp) { + return resp.text().then(function(text) { + var getHeader = function(name) { + var v = resp.headers.get(name); + return v === null ? NIL : v; + }; + return successFn(resp.ok, resp.status, getHeader, text); + }); + }).catch(function(err) { + return errorFn(err); + }); + } + + function fetchLocation(headerVal) { + if (!_hasDom) return; + var locUrl = headerVal; + try { var obj = JSON.parse(headerVal); locUrl = obj.path || obj; } catch (e) {} + fetch(locUrl, { headers: { "SX-Request": "true" } }).then(function(r) { + return r.text().then(function(t) { + var main = document.getElementById("main-panel"); + if (main) { + main.innerHTML = t; + postSwap(main); + try { history.pushState({ sxUrl: locUrl }, "", locUrl); } catch (e) {} + } + }); + }); + } + + function fetchAndRestore(main, url, headers, scrollY) { + var opts = { headers: headers }; + try { + var h = new URL(url, location.href).hostname; + if (h !== location.hostname && + (h.indexOf(".rose-ash.com") >= 0 || h.indexOf(".localhost") >= 0)) { + opts.credentials = "include"; + } + } catch (e) {} + + fetch(url, opts).then(function(resp) { + return resp.text().then(function(text) { + text = stripComponentScripts(text); + text = extractResponseCss(text); + text = text.trim(); + if (text.charAt(0) === "(") { + try { + var dom = sxRender(text); + var container = document.createElement("div"); + container.appendChild(dom); + processOobSwaps(container, function(t, oob, s) { + swapDomNodes(t, oob, s); + sxHydrate(t); + processElements(t); + }); + var newMain = container.querySelector("#main-panel"); + morphChildren(main, newMain || container); + postSwap(main); + if (typeof window !== "undefined") window.scrollTo(0, scrollY || 0); + } catch (err) { + console.error("sx-ref popstate error:", err); + location.reload(); + } + } else { + var parser = new DOMParser(); + var doc = parser.parseFromString(text, "text/html"); + var newMain = doc.getElementById("main-panel"); + if (newMain) { + morphChildren(main, newMain); + postSwap(main); + if (typeof window !== "undefined") window.scrollTo(0, scrollY || 0); + } else { + location.reload(); + } + } + }); + }).catch(function() { location.reload(); }); + } + + function fetchPreload(url, headers, cache) { + fetch(url, { headers: headers }).then(function(resp) { + if (!resp.ok) return; + var ct = resp.headers.get("Content-Type") || ""; + return resp.text().then(function(text) { + preloadCacheSet(cache, url, text, ct); + }); + }).catch(function() { /* ignore */ }); + } + + // --- Request body building --- + + function buildRequestBody(el, method, url) { + if (!_hasDom) return { body: null, url: url, "content-type": NIL }; + var body = null; + var ct = NIL; + var finalUrl = url; + var isJson = el.getAttribute("sx-encoding") === "json"; + + if (method !== "GET") { + var form = el.closest("form") || (el.tagName === "FORM" ? el : null); + if (form) { + if (isJson) { + var fd = new FormData(form); + var obj = {}; + fd.forEach(function(v, k) { + if (obj[k] !== undefined) { + if (!Array.isArray(obj[k])) obj[k] = [obj[k]]; + obj[k].push(v); + } else { obj[k] = v; } + }); + body = JSON.stringify(obj); + ct = "application/json"; + } else { + body = new URLSearchParams(new FormData(form)); + ct = "application/x-www-form-urlencoded"; + } + } + } + + // sx-params + var paramsSpec = el.getAttribute("sx-params"); + if (paramsSpec && body instanceof URLSearchParams) { + if (paramsSpec === "none") { + body = new URLSearchParams(); + } else if (paramsSpec.indexOf("not ") === 0) { + paramsSpec.substring(4).split(",").forEach(function(k) { body.delete(k.trim()); }); + } else if (paramsSpec !== "*") { + var allowed = paramsSpec.split(",").map(function(s) { return s.trim(); }); + var filtered = new URLSearchParams(); + allowed.forEach(function(k) { + body.getAll(k).forEach(function(v) { filtered.append(k, v); }); + }); + body = filtered; + } + } + + // sx-include + var includeSel = el.getAttribute("sx-include"); + if (includeSel && method !== "GET") { + if (!body) body = new URLSearchParams(); + document.querySelectorAll(includeSel).forEach(function(inp) { + if (inp.name) body.append(inp.name, inp.value); + }); + } + + // sx-vals + var valsAttr = el.getAttribute("sx-vals"); + if (valsAttr) { + try { + var vals = valsAttr.charAt(0) === "{" && valsAttr.charAt(1) === ":" ? parse(valsAttr) : JSON.parse(valsAttr); + if (method === "GET") { + for (var vk in vals) finalUrl += (finalUrl.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(vk) + "=" + encodeURIComponent(vals[vk]); + } else if (body instanceof URLSearchParams) { + for (var vk2 in vals) body.append(vk2, vals[vk2]); + } else if (!body) { + body = new URLSearchParams(); + for (var vk3 in vals) body.append(vk3, vals[vk3]); + ct = "application/x-www-form-urlencoded"; + } + } catch (e) {} + } + + // GET form data → URL + if (method === "GET") { + var form2 = el.closest("form") || (el.tagName === "FORM" ? el : null); + if (form2) { + var qs = new URLSearchParams(new FormData(form2)).toString(); + if (qs) finalUrl += (finalUrl.indexOf("?") >= 0 ? "&" : "?") + qs; + } + if ((el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA") && el.name) { + finalUrl += (finalUrl.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(el.name) + "=" + encodeURIComponent(el.value); + } + } + + return { body: body, url: finalUrl, "content-type": ct }; + } + + // --- Loading state --- + + function showIndicator(el) { + if (!_hasDom) return NIL; + var sel = el.getAttribute("sx-indicator"); + var ind = sel ? (document.querySelector(sel) || el.closest(sel)) : null; + if (ind) { ind.classList.add("sx-request"); ind.style.display = ""; } + return ind || NIL; + } + + function disableElements(el) { + if (!_hasDom) return []; + var sel = el.getAttribute("sx-disabled-elt"); + if (!sel) return []; + var elts = Array.prototype.slice.call(document.querySelectorAll(sel)); + elts.forEach(function(e) { e.disabled = true; }); + return elts; + } + + function clearLoadingState(el, indicator, disabledElts) { + el.classList.remove("sx-request"); + el.removeAttribute("aria-busy"); + if (indicator && !isNil(indicator)) { + indicator.classList.remove("sx-request"); + indicator.style.display = "none"; + } + if (disabledElts) { + for (var i = 0; i < disabledElts.length; i++) disabledElts[i].disabled = false; + } + } + + // --- DOM extras --- + + function domQueryById(id) { + return _hasDom ? document.getElementById(id) : null; + } + + function domMatches(el, sel) { + return el && el.matches ? el.matches(sel) : false; + } + + function domClosest(el, sel) { + return el && el.closest ? el.closest(sel) : null; + } + + function domBody() { + return _hasDom ? document.body : null; + } + + function domHasClass(el, cls) { + return el && el.classList ? el.classList.contains(cls) : false; + } + + function domAppendToHead(el) { + if (_hasDom && document.head) document.head.appendChild(el); + } + + function domParseHtmlDocument(text) { + if (!_hasDom) return null; + return new DOMParser().parseFromString(text, "text/html"); + } + + function domOuterHtml(el) { + return el ? el.outerHTML : ""; + } + + function domBodyInnerHtml(doc) { + return doc && doc.body ? doc.body.innerHTML : ""; + } + + // --- Events --- + + function preventDefault_(e) { if (e && e.preventDefault) e.preventDefault(); } + function elementValue(el) { return el && el.value !== undefined ? el.value : NIL; } + + function domAddListener(el, event, fn, opts) { + if (!el || !el.addEventListener) return; + var o = {}; + if (opts && !isNil(opts)) { + if (opts.once || opts["once"]) o.once = true; + } + el.addEventListener(event, fn, o); + } + + // --- Validation --- + + function validateForRequest(el) { + if (!_hasDom) return true; + var attr = el.getAttribute("sx-validate"); + if (attr === null) { + var vForm = el.closest("[sx-validate]"); + if (vForm) attr = vForm.getAttribute("sx-validate"); + } + if (attr === null) return true; // no validation configured + var form = el.tagName === "FORM" ? el : el.closest("form"); + if (form && !form.reportValidity()) return false; + if (attr && attr !== "true" && attr !== "") { + var fn = window[attr]; + if (typeof fn === "function" && !fn(el)) return false; + } + return true; + } + + // --- View Transitions --- + + function withTransition(enabled, fn) { + if (enabled && _hasDom && document.startViewTransition) { + document.startViewTransition(fn); + } else { + fn(); + } + } + + // --- IntersectionObserver --- + + function observeIntersection(el, fn, once, delay) { + if (!_hasDom || !("IntersectionObserver" in window)) { fn(); return; } + var fired = false; + var d = isNil(delay) ? 0 : delay; + var obs = new IntersectionObserver(function(entries) { + entries.forEach(function(entry) { + if (!entry.isIntersecting) return; + if (once && fired) return; + fired = true; + if (once) obs.unobserve(el); + if (d) setTimeout(fn, d); else fn(); + }); + }); + obs.observe(el); + } + + // --- EventSource --- + + function eventSourceConnect(url, el) { + var source = new EventSource(url); + source.addEventListener("error", function() { domDispatch(el, "sx:sseError", {}); }); + source.addEventListener("open", function() { domDispatch(el, "sx:sseOpen", {}); }); + if (typeof MutationObserver !== "undefined") { + var obs = new MutationObserver(function() { + if (!document.body.contains(el)) { source.close(); obs.disconnect(); } + }); + obs.observe(document.body, { childList: true, subtree: true }); + } + return source; + } + + function eventSourceListen(source, event, fn) { + source.addEventListener(event, function(e) { fn(e.data); }); + } + + // --- Boost bindings --- + + function bindBoostLink(el, href) { + el.addEventListener("click", function(e) { + e.preventDefault(); + executeRequest(el, { method: "GET", url: href }).then(function() { + try { history.pushState({ sxUrl: href, scrollY: window.scrollY }, "", href); } catch (err) {} + }); + }); + } + + function bindBoostForm(form, method, action) { + form.addEventListener("submit", function(e) { + e.preventDefault(); + executeRequest(form, { method: method, url: action }).then(function() { + try { history.pushState({ sxUrl: action, scrollY: window.scrollY }, "", action); } catch (err) {} + }); + }); + } + + // --- Inline handlers --- + + function bindInlineHandler(el, eventName, body) { + el.addEventListener(eventName, new Function("event", body)); + } + + // --- Preload binding --- + + function bindPreload(el, events, debounceMs, fn) { + var timer = null; + events.forEach(function(evt) { + el.addEventListener(evt, function() { + if (debounceMs) { + clearTimeout(timer); + timer = setTimeout(fn, debounceMs); + } else { + fn(); + } + }); + }); + } + + // --- Processing markers --- + + var PROCESSED = "_sxBound"; + + function markProcessed(el, key) { el[PROCESSED + key] = true; } + function isProcessed(el, key) { return !!el[PROCESSED + key]; } + + // --- Script cloning --- + + function createScriptClone(dead) { + var live = document.createElement("script"); + for (var i = 0; i < dead.attributes.length; i++) + live.setAttribute(dead.attributes[i].name, dead.attributes[i].value); + live.textContent = dead.textContent; + return live; + } + + // --- SX API references --- + + function sxRender(source) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (SxObj && SxObj.render) return SxObj.render(source); + throw new Error("No SX renderer available"); + } + + function sxProcessScripts(root) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (SxObj && SxObj.processScripts) SxObj.processScripts(root || undefined); + } + + function sxHydrate(root) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (SxObj && SxObj.hydrate) SxObj.hydrate(root || undefined); + } + + function loadedComponentNames() { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (!SxObj) return []; + var env = SxObj.componentEnv || (SxObj.getEnv ? SxObj.getEnv() : {}); + return Object.keys(env).filter(function(k) { return k.charAt(0) === "~"; }); + } + + // --- Response processing --- + + function stripComponentScripts(text) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + return text.replace(/]*type="text\/sx"[^>]*data-components[^>]*>([\s\S]*?)<\/script>/gi, + function(_, defs) { if (SxObj && SxObj.loadComponents) SxObj.loadComponents(defs); return ""; }); + } + + function extractResponseCss(text) { + if (!_hasDom) return text; + var target = document.getElementById("sx-css"); + if (!target) return text; + return text.replace(/]*data-sx-css[^>]*>([\s\S]*?)<\/style>/gi, + function(_, css) { target.textContent += css; return ""; }); + } + + function selectFromContainer(container, sel) { + var frag = document.createDocumentFragment(); + sel.split(",").forEach(function(s) { + container.querySelectorAll(s.trim()).forEach(function(m) { frag.appendChild(m); }); + }); + return frag; + } + + function childrenToFragment(container) { + var frag = document.createDocumentFragment(); + while (container.firstChild) frag.appendChild(container.firstChild); + return frag; + } + + function selectHtmlFromDoc(doc, sel) { + var parts = sel.split(",").map(function(s) { return s.trim(); }); + var frags = []; + parts.forEach(function(s) { + doc.querySelectorAll(s).forEach(function(m) { frags.push(m.outerHTML); }); + }); + return frags.join(""); + } + + // --- Parsing --- + + function tryParseJson(s) { + if (!s) return NIL; + try { return JSON.parse(s); } catch (e) { return NIL; } + } + // ========================================================================= // Post-transpilation fixups @@ -1791,9 +2666,30 @@ morphNode: typeof morphNode === "function" ? morphNode : null, morphChildren: typeof morphChildren === "function" ? morphChildren : null, swapDomNodes: typeof swapDomNodes === "function" ? swapDomNodes : null, + process: typeof processElements === "function" ? processElements : null, + executeRequest: typeof executeRequest === "function" ? executeRequest : null, + postSwap: typeof postSwap === "function" ? postSwap : null, + init: typeof engineInit === "function" ? engineInit : null, _version: "ref-2.0 (dom+engine+html+sx, bootstrap-compiled)" }; + + // --- Popstate listener --- + if (typeof window !== "undefined") { + window.addEventListener("popstate", function(e) { + handlePopstate(e && e.state ? e.state.scrollY || 0 : 0); + }); + } + + // --- Auto-init --- + if (typeof document !== "undefined") { + var _sxRefInit = function() { engineInit(); }; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", _sxRefInit); + } else { + _sxRefInit(); + } + } if (typeof module !== "undefined" && module.exports) module.exports = SxRef; else global.SxRef = SxRef; diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index 5c9ac5f..473375b 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -297,6 +297,92 @@ class JSEmitter: "should-boost-link?": "shouldBoostLink", "should-boost-form?": "shouldBoostForm", "parse-sse-swap": "parseSseSwap", + # engine.sx orchestration + "_preload-cache": "_preloadCache", + "_css-hash": "_cssHash", + "dispatch-trigger-events": "dispatchTriggerEvents", + "init-css-tracking": "initCssTracking", + "execute-request": "executeRequest", + "do-fetch": "doFetch", + "handle-fetch-success": "handleFetchSuccess", + "handle-sx-response": "handleSxResponse", + "handle-html-response": "handleHtmlResponse", + "handle-retry": "handleRetry", + "bind-triggers": "bindTriggers", + "bind-event": "bindEvent", + "post-swap": "postSwap", + "activate-scripts": "activateScripts", + "process-oob-swaps": "processOobSwaps", + "hoist-head-elements": "hoistHeadElements", + "process-boosted": "processBoosted", + "boost-descendants": "boostDescendants", + "process-sse": "processSse", + "bind-sse": "bindSse", + "bind-sse-swap": "bindSseSwap", + "bind-inline-handlers": "bindInlineHandlers", + "bind-preload-for": "bindPreloadFor", + "do-preload": "doPreload", + "VERB_SELECTOR": "VERB_SELECTOR", + "process-elements": "processElements", + "process-one": "processOne", + "handle-popstate": "handlePopstate", + "engine-init": "engineInit", + # engine orchestration platform + "promise-resolve": "promiseResolve", + "promise-catch": "promiseCatch", + "abort-previous": "abortPrevious", + "track-controller": "trackController", + "new-abort-controller": "newAbortController", + "controller-signal": "controllerSignal", + "abort-error?": "isAbortError", + "set-timeout": "setTimeout_", + "set-interval": "setInterval_", + "clear-timeout": "clearTimeout_", + "request-animation-frame": "requestAnimationFrame_", + "csrf-token": "csrfToken", + "cross-origin?": "isCrossOrigin", + "loaded-component-names": "loadedComponentNames", + "build-request-body": "buildRequestBody", + "show-indicator": "showIndicator", + "disable-elements": "disableElements", + "clear-loading-state": "clearLoadingState", + "fetch-request": "fetchRequest", + "fetch-location": "fetchLocation", + "fetch-and-restore": "fetchAndRestore", + "fetch-preload": "fetchPreload", + "dom-query-by-id": "domQueryById", + "dom-matches?": "domMatches", + "dom-closest": "domClosest", + "dom-body": "domBody", + "dom-has-class?": "domHasClass", + "dom-append-to-head": "domAppendToHead", + "dom-parse-html-document": "domParseHtmlDocument", + "dom-outer-html": "domOuterHtml", + "dom-body-inner-html": "domBodyInnerHtml", + "prevent-default": "preventDefault_", + "element-value": "elementValue", + "validate-for-request": "validateForRequest", + "with-transition": "withTransition", + "observe-intersection": "observeIntersection", + "event-source-connect": "eventSourceConnect", + "event-source-listen": "eventSourceListen", + "bind-boost-link": "bindBoostLink", + "bind-boost-form": "bindBoostForm", + "bind-inline-handler": "bindInlineHandler", + "bind-preload": "bindPreload", + "mark-processed!": "markProcessed", + "is-processed?": "isProcessed", + "create-script-clone": "createScriptClone", + "sx-render": "sxRender", + "sx-process-scripts": "sxProcessScripts", + "sx-hydrate": "sxHydrate", + "strip-component-scripts": "stripComponentScripts", + "extract-response-css": "extractResponseCss", + "select-from-container": "selectFromContainer", + "children-to-fragment": "childrenToFragment", + "select-html-from-doc": "selectHtmlFromDoc", + "try-parse-json": "tryParseJson", + "process-css-response": "processCssResponse", "browser-location-href": "browserLocationHref", "browser-same-origin?": "browserSameOrigin", "browser-push-state": "browserPushState", @@ -1360,6 +1446,8 @@ PLATFORM_ENGINE_JS = """ // Platform interface — Engine (browser-only) // ========================================================================= + // --- Browser/Network --- + function browserLocationHref() { return typeof location !== "undefined" ? location.href : ""; } @@ -1420,6 +1508,531 @@ PLATFORM_ENGINE_JS = """ return JSON.parse(s); } catch (e) { return null; } } + + function csrfToken() { + if (!_hasDom) return NIL; + var m = document.querySelector('meta[name="csrf-token"]'); + return m ? m.getAttribute("content") : NIL; + } + + function isCrossOrigin(url) { + try { + var h = new URL(url, location.href).hostname; + return h !== location.hostname && + (h.indexOf(".rose-ash.com") >= 0 || h.indexOf(".localhost") >= 0); + } catch (e) { return false; } + } + + // --- Promises --- + + function promiseResolve(val) { return Promise.resolve(val); } + + function promiseCatch(p, fn) { return p && p.catch ? p.catch(fn) : p; } + + // --- Abort controllers --- + + var _controllers = typeof WeakMap !== "undefined" ? new WeakMap() : null; + + function abortPrevious(el) { + if (_controllers) { + var prev = _controllers.get(el); + if (prev) prev.abort(); + } + } + + function trackController(el, ctrl) { + if (_controllers) _controllers.set(el, ctrl); + } + + function newAbortController() { + return typeof AbortController !== "undefined" ? new AbortController() : { signal: null, abort: function() {} }; + } + + function controllerSignal(ctrl) { return ctrl ? ctrl.signal : null; } + + function isAbortError(err) { return err && err.name === "AbortError"; } + + // --- Timers --- + + function setTimeout_(fn, ms) { return setTimeout(fn, ms || 0); } + function setInterval_(fn, ms) { return setInterval(fn, ms || 1000); } + function clearTimeout_(id) { clearTimeout(id); } + function requestAnimationFrame_(fn) { + if (typeof requestAnimationFrame !== "undefined") requestAnimationFrame(fn); + else setTimeout(fn, 16); + } + + // --- Fetch --- + + function fetchRequest(config, successFn, errorFn) { + var opts = { method: config.method, headers: config.headers }; + if (config.signal) opts.signal = config.signal; + if (config.body && config.method !== "GET") opts.body = config.body; + if (config["cross-origin"]) opts.credentials = "include"; + + var p = config.preloaded + ? Promise.resolve({ + ok: true, status: 200, + headers: new Headers({ "Content-Type": config.preloaded["content-type"] || "" }), + text: function() { return Promise.resolve(config.preloaded.text); } + }) + : fetch(config.url, opts); + + return p.then(function(resp) { + return resp.text().then(function(text) { + var getHeader = function(name) { + var v = resp.headers.get(name); + return v === null ? NIL : v; + }; + return successFn(resp.ok, resp.status, getHeader, text); + }); + }).catch(function(err) { + return errorFn(err); + }); + } + + function fetchLocation(headerVal) { + if (!_hasDom) return; + var locUrl = headerVal; + try { var obj = JSON.parse(headerVal); locUrl = obj.path || obj; } catch (e) {} + fetch(locUrl, { headers: { "SX-Request": "true" } }).then(function(r) { + return r.text().then(function(t) { + var main = document.getElementById("main-panel"); + if (main) { + main.innerHTML = t; + postSwap(main); + try { history.pushState({ sxUrl: locUrl }, "", locUrl); } catch (e) {} + } + }); + }); + } + + function fetchAndRestore(main, url, headers, scrollY) { + var opts = { headers: headers }; + try { + var h = new URL(url, location.href).hostname; + if (h !== location.hostname && + (h.indexOf(".rose-ash.com") >= 0 || h.indexOf(".localhost") >= 0)) { + opts.credentials = "include"; + } + } catch (e) {} + + fetch(url, opts).then(function(resp) { + return resp.text().then(function(text) { + text = stripComponentScripts(text); + text = extractResponseCss(text); + text = text.trim(); + if (text.charAt(0) === "(") { + try { + var dom = sxRender(text); + var container = document.createElement("div"); + container.appendChild(dom); + processOobSwaps(container, function(t, oob, s) { + swapDomNodes(t, oob, s); + sxHydrate(t); + processElements(t); + }); + var newMain = container.querySelector("#main-panel"); + morphChildren(main, newMain || container); + postSwap(main); + if (typeof window !== "undefined") window.scrollTo(0, scrollY || 0); + } catch (err) { + console.error("sx-ref popstate error:", err); + location.reload(); + } + } else { + var parser = new DOMParser(); + var doc = parser.parseFromString(text, "text/html"); + var newMain = doc.getElementById("main-panel"); + if (newMain) { + morphChildren(main, newMain); + postSwap(main); + if (typeof window !== "undefined") window.scrollTo(0, scrollY || 0); + } else { + location.reload(); + } + } + }); + }).catch(function() { location.reload(); }); + } + + function fetchPreload(url, headers, cache) { + fetch(url, { headers: headers }).then(function(resp) { + if (!resp.ok) return; + var ct = resp.headers.get("Content-Type") || ""; + return resp.text().then(function(text) { + preloadCacheSet(cache, url, text, ct); + }); + }).catch(function() { /* ignore */ }); + } + + // --- Request body building --- + + function buildRequestBody(el, method, url) { + if (!_hasDom) return { body: null, url: url, "content-type": NIL }; + var body = null; + var ct = NIL; + var finalUrl = url; + var isJson = el.getAttribute("sx-encoding") === "json"; + + if (method !== "GET") { + var form = el.closest("form") || (el.tagName === "FORM" ? el : null); + if (form) { + if (isJson) { + var fd = new FormData(form); + var obj = {}; + fd.forEach(function(v, k) { + if (obj[k] !== undefined) { + if (!Array.isArray(obj[k])) obj[k] = [obj[k]]; + obj[k].push(v); + } else { obj[k] = v; } + }); + body = JSON.stringify(obj); + ct = "application/json"; + } else { + body = new URLSearchParams(new FormData(form)); + ct = "application/x-www-form-urlencoded"; + } + } + } + + // sx-params + var paramsSpec = el.getAttribute("sx-params"); + if (paramsSpec && body instanceof URLSearchParams) { + if (paramsSpec === "none") { + body = new URLSearchParams(); + } else if (paramsSpec.indexOf("not ") === 0) { + paramsSpec.substring(4).split(",").forEach(function(k) { body.delete(k.trim()); }); + } else if (paramsSpec !== "*") { + var allowed = paramsSpec.split(",").map(function(s) { return s.trim(); }); + var filtered = new URLSearchParams(); + allowed.forEach(function(k) { + body.getAll(k).forEach(function(v) { filtered.append(k, v); }); + }); + body = filtered; + } + } + + // sx-include + var includeSel = el.getAttribute("sx-include"); + if (includeSel && method !== "GET") { + if (!body) body = new URLSearchParams(); + document.querySelectorAll(includeSel).forEach(function(inp) { + if (inp.name) body.append(inp.name, inp.value); + }); + } + + // sx-vals + var valsAttr = el.getAttribute("sx-vals"); + if (valsAttr) { + try { + var vals = valsAttr.charAt(0) === "{" && valsAttr.charAt(1) === ":" ? parse(valsAttr) : JSON.parse(valsAttr); + if (method === "GET") { + for (var vk in vals) finalUrl += (finalUrl.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(vk) + "=" + encodeURIComponent(vals[vk]); + } else if (body instanceof URLSearchParams) { + for (var vk2 in vals) body.append(vk2, vals[vk2]); + } else if (!body) { + body = new URLSearchParams(); + for (var vk3 in vals) body.append(vk3, vals[vk3]); + ct = "application/x-www-form-urlencoded"; + } + } catch (e) {} + } + + // GET form data → URL + if (method === "GET") { + var form2 = el.closest("form") || (el.tagName === "FORM" ? el : null); + if (form2) { + var qs = new URLSearchParams(new FormData(form2)).toString(); + if (qs) finalUrl += (finalUrl.indexOf("?") >= 0 ? "&" : "?") + qs; + } + if ((el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA") && el.name) { + finalUrl += (finalUrl.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(el.name) + "=" + encodeURIComponent(el.value); + } + } + + return { body: body, url: finalUrl, "content-type": ct }; + } + + // --- Loading state --- + + function showIndicator(el) { + if (!_hasDom) return NIL; + var sel = el.getAttribute("sx-indicator"); + var ind = sel ? (document.querySelector(sel) || el.closest(sel)) : null; + if (ind) { ind.classList.add("sx-request"); ind.style.display = ""; } + return ind || NIL; + } + + function disableElements(el) { + if (!_hasDom) return []; + var sel = el.getAttribute("sx-disabled-elt"); + if (!sel) return []; + var elts = Array.prototype.slice.call(document.querySelectorAll(sel)); + elts.forEach(function(e) { e.disabled = true; }); + return elts; + } + + function clearLoadingState(el, indicator, disabledElts) { + el.classList.remove("sx-request"); + el.removeAttribute("aria-busy"); + if (indicator && !isNil(indicator)) { + indicator.classList.remove("sx-request"); + indicator.style.display = "none"; + } + if (disabledElts) { + for (var i = 0; i < disabledElts.length; i++) disabledElts[i].disabled = false; + } + } + + // --- DOM extras --- + + function domQueryById(id) { + return _hasDom ? document.getElementById(id) : null; + } + + function domMatches(el, sel) { + return el && el.matches ? el.matches(sel) : false; + } + + function domClosest(el, sel) { + return el && el.closest ? el.closest(sel) : null; + } + + function domBody() { + return _hasDom ? document.body : null; + } + + function domHasClass(el, cls) { + return el && el.classList ? el.classList.contains(cls) : false; + } + + function domAppendToHead(el) { + if (_hasDom && document.head) document.head.appendChild(el); + } + + function domParseHtmlDocument(text) { + if (!_hasDom) return null; + return new DOMParser().parseFromString(text, "text/html"); + } + + function domOuterHtml(el) { + return el ? el.outerHTML : ""; + } + + function domBodyInnerHtml(doc) { + return doc && doc.body ? doc.body.innerHTML : ""; + } + + // --- Events --- + + function preventDefault_(e) { if (e && e.preventDefault) e.preventDefault(); } + function elementValue(el) { return el && el.value !== undefined ? el.value : NIL; } + + function domAddListener(el, event, fn, opts) { + if (!el || !el.addEventListener) return; + var o = {}; + if (opts && !isNil(opts)) { + if (opts.once || opts["once"]) o.once = true; + } + el.addEventListener(event, fn, o); + } + + // --- Validation --- + + function validateForRequest(el) { + if (!_hasDom) return true; + var attr = el.getAttribute("sx-validate"); + if (attr === null) { + var vForm = el.closest("[sx-validate]"); + if (vForm) attr = vForm.getAttribute("sx-validate"); + } + if (attr === null) return true; // no validation configured + var form = el.tagName === "FORM" ? el : el.closest("form"); + if (form && !form.reportValidity()) return false; + if (attr && attr !== "true" && attr !== "") { + var fn = window[attr]; + if (typeof fn === "function" && !fn(el)) return false; + } + return true; + } + + // --- View Transitions --- + + function withTransition(enabled, fn) { + if (enabled && _hasDom && document.startViewTransition) { + document.startViewTransition(fn); + } else { + fn(); + } + } + + // --- IntersectionObserver --- + + function observeIntersection(el, fn, once, delay) { + if (!_hasDom || !("IntersectionObserver" in window)) { fn(); return; } + var fired = false; + var d = isNil(delay) ? 0 : delay; + var obs = new IntersectionObserver(function(entries) { + entries.forEach(function(entry) { + if (!entry.isIntersecting) return; + if (once && fired) return; + fired = true; + if (once) obs.unobserve(el); + if (d) setTimeout(fn, d); else fn(); + }); + }); + obs.observe(el); + } + + // --- EventSource --- + + function eventSourceConnect(url, el) { + var source = new EventSource(url); + source.addEventListener("error", function() { domDispatch(el, "sx:sseError", {}); }); + source.addEventListener("open", function() { domDispatch(el, "sx:sseOpen", {}); }); + if (typeof MutationObserver !== "undefined") { + var obs = new MutationObserver(function() { + if (!document.body.contains(el)) { source.close(); obs.disconnect(); } + }); + obs.observe(document.body, { childList: true, subtree: true }); + } + return source; + } + + function eventSourceListen(source, event, fn) { + source.addEventListener(event, function(e) { fn(e.data); }); + } + + // --- Boost bindings --- + + function bindBoostLink(el, href) { + el.addEventListener("click", function(e) { + e.preventDefault(); + executeRequest(el, { method: "GET", url: href }).then(function() { + try { history.pushState({ sxUrl: href, scrollY: window.scrollY }, "", href); } catch (err) {} + }); + }); + } + + function bindBoostForm(form, method, action) { + form.addEventListener("submit", function(e) { + e.preventDefault(); + executeRequest(form, { method: method, url: action }).then(function() { + try { history.pushState({ sxUrl: action, scrollY: window.scrollY }, "", action); } catch (err) {} + }); + }); + } + + // --- Inline handlers --- + + function bindInlineHandler(el, eventName, body) { + el.addEventListener(eventName, new Function("event", body)); + } + + // --- Preload binding --- + + function bindPreload(el, events, debounceMs, fn) { + var timer = null; + events.forEach(function(evt) { + el.addEventListener(evt, function() { + if (debounceMs) { + clearTimeout(timer); + timer = setTimeout(fn, debounceMs); + } else { + fn(); + } + }); + }); + } + + // --- Processing markers --- + + var PROCESSED = "_sxBound"; + + function markProcessed(el, key) { el[PROCESSED + key] = true; } + function isProcessed(el, key) { return !!el[PROCESSED + key]; } + + // --- Script cloning --- + + function createScriptClone(dead) { + var live = document.createElement("script"); + for (var i = 0; i < dead.attributes.length; i++) + live.setAttribute(dead.attributes[i].name, dead.attributes[i].value); + live.textContent = dead.textContent; + return live; + } + + // --- SX API references --- + + function sxRender(source) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (SxObj && SxObj.render) return SxObj.render(source); + throw new Error("No SX renderer available"); + } + + function sxProcessScripts(root) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (SxObj && SxObj.processScripts) SxObj.processScripts(root || undefined); + } + + function sxHydrate(root) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (SxObj && SxObj.hydrate) SxObj.hydrate(root || undefined); + } + + function loadedComponentNames() { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (!SxObj) return []; + var env = SxObj.componentEnv || (SxObj.getEnv ? SxObj.getEnv() : {}); + return Object.keys(env).filter(function(k) { return k.charAt(0) === "~"; }); + } + + // --- Response processing --- + + function stripComponentScripts(text) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + return text.replace(/]*type="text\\/sx"[^>]*data-components[^>]*>([\\s\\S]*?)<\\/script>/gi, + function(_, defs) { if (SxObj && SxObj.loadComponents) SxObj.loadComponents(defs); return ""; }); + } + + function extractResponseCss(text) { + if (!_hasDom) return text; + var target = document.getElementById("sx-css"); + if (!target) return text; + return text.replace(/]*data-sx-css[^>]*>([\\s\\S]*?)<\\/style>/gi, + function(_, css) { target.textContent += css; return ""; }); + } + + function selectFromContainer(container, sel) { + var frag = document.createDocumentFragment(); + sel.split(",").forEach(function(s) { + container.querySelectorAll(s.trim()).forEach(function(m) { frag.appendChild(m); }); + }); + return frag; + } + + function childrenToFragment(container) { + var frag = document.createDocumentFragment(); + while (container.firstChild) frag.appendChild(container.firstChild); + return frag; + } + + function selectHtmlFromDoc(doc, sel) { + var parts = sel.split(",").map(function(s) { return s.trim(); }); + var frags = []; + parts.forEach(function(s) { + doc.querySelectorAll(s).forEach(function(m) { frags.push(m.outerHTML); }); + }); + return frags.join(""); + } + + // --- Parsing --- + + function tryParseJson(s) { + if (!s) return NIL; + try { return JSON.parse(s); } catch (e) { return NIL; } + } """ def fixups_js(has_html, has_sx, has_dom): @@ -1642,10 +2255,33 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, adapter_label): api_lines.append(' morphNode: typeof morphNode === "function" ? morphNode : null,') api_lines.append(' morphChildren: typeof morphChildren === "function" ? morphChildren : null,') api_lines.append(' swapDomNodes: typeof swapDomNodes === "function" ? swapDomNodes : null,') + api_lines.append(' process: typeof processElements === "function" ? processElements : null,') + api_lines.append(' executeRequest: typeof executeRequest === "function" ? executeRequest : null,') + api_lines.append(' postSwap: typeof postSwap === "function" ? postSwap : null,') + api_lines.append(' init: typeof engineInit === "function" ? engineInit : null,') api_lines.append(f' _version: "{version}"') api_lines.append(' };') api_lines.append('') + if has_engine: + api_lines.append(''' + // --- Popstate listener --- + if (typeof window !== "undefined") { + window.addEventListener("popstate", function(e) { + handlePopstate(e && e.state ? e.state.scrollY || 0 : 0); + }); + } + + // --- Auto-init --- + if (typeof document !== "undefined") { + var _sxRefInit = function() { engineInit(); }; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", _sxRefInit); + } else { + _sxRefInit(); + } + }''') + api_lines.append(' if (typeof module !== "undefined" && module.exports) module.exports = SxRef;') api_lines.append(' else global.SxRef = SxRef;') diff --git a/shared/sx/ref/engine.sx b/shared/sx/ref/engine.sx index d4e9a56..8e84d9f 100644 --- a/shared/sx/ref/engine.sx +++ b/shared/sx/ref/engine.sx @@ -662,11 +662,684 @@ (or (dom-get-attr el "sx-sse-swap") "message"))) +;; ========================================================================== +;; Engine orchestration +;; +;; The following functions define the runtime behavior of the engine: +;; request execution, trigger binding, post-swap lifecycle, boost, SSE, +;; and main processing. Browser-specific mechanics (fetch, addEventListener, +;; IntersectionObserver, EventSource, etc.) are declared as platform +;; interface at the bottom. +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; Engine state +;; -------------------------------------------------------------------------- + +(define _preload-cache (dict)) +(define _css-hash "") + + +;; -------------------------------------------------------------------------- +;; Event dispatch helpers +;; -------------------------------------------------------------------------- + +(define dispatch-trigger-events + (fn (el header-val) + ;; Parse and dispatch SX-Trigger header events. + ;; Value: JSON object, JSON string, or comma-separated names. + (when header-val + (let ((parsed (try-parse-json header-val))) + (if (and parsed (dict? parsed)) + (for-each + (fn (key) (dom-dispatch el key (dict-get parsed key))) + (keys parsed)) + (for-each + (fn (name) + (let ((n (trim name))) + (when (not (= n "")) + (dom-dispatch el n (dict))))) + (split header-val ","))))))) + + +;; -------------------------------------------------------------------------- +;; CSS tracking +;; -------------------------------------------------------------------------- + +(define init-css-tracking + (fn () + ;; Read CSS hash from + (let ((meta (dom-query "meta[name=\"sx-css-classes\"]"))) + (when meta + (let ((content (dom-get-attr meta "content"))) + (when content + (set! _css-hash content))))))) + + +;; -------------------------------------------------------------------------- +;; Request execution +;; -------------------------------------------------------------------------- + +(define execute-request + (fn (el verb-info extra-params) + ;; Pre-flight gate logic: media, confirm, prompt. + ;; Returns a promise. + (let ((current-verb (get-verb-info el)) + (verb (if current-verb current-verb verb-info)) + (method (get verb "method")) + (url (get verb "url"))) + ;; Reset retry backoff on fresh requests + (when (not (dom-has-class? el "sx-error")) + (dom-remove-attr el "data-sx-retry-ms")) + ;; Gate: media query + (if (let ((media (dom-get-attr el "sx-media"))) + (and media (not (browser-media-matches? media)))) + (promise-resolve nil) + ;; Gate: confirm dialog + (if (let ((msg (dom-get-attr el "sx-confirm"))) + (and msg (not (browser-confirm msg)))) + (promise-resolve nil) + ;; Gate: prompt dialog + (let ((prompt-msg (dom-get-attr el "sx-prompt")) + (params extra-params)) + (if prompt-msg + (let ((prompt-val (browser-prompt prompt-msg))) + (if (nil? prompt-val) + (promise-resolve nil) + (do + (set! params (or params (dict))) + (dict-set! params "promptValue" prompt-val) + (do-fetch el verb method url params)))) + (do-fetch el verb method url params)))))))) + + +;; -------------------------------------------------------------------------- +;; Fetch pipeline +;; -------------------------------------------------------------------------- + +(define do-fetch + (fn (el verb method url extra-params) + ;; Build request, execute fetch, handle response. + ;; Returns a promise. + (let ((sync-attr (dom-get-attr el "sx-sync"))) + (when (and sync-attr (contains? sync-attr "replace")) + (abort-previous el)) + (let ((ctrl (new-abort-controller))) + (track-controller el ctrl) + (let ((headers (build-request-headers el + (loaded-component-names) _css-hash))) + ;; Prompt header + (when (and extra-params (dict-has? extra-params "promptValue")) + (dict-set! headers "SX-Prompt" + (get extra-params "promptValue"))) + ;; CSRF for mutating same-origin + (when (and (not (= method "GET")) (browser-same-origin? url)) + (let ((csrf (csrf-token))) + (when csrf + (dict-set! headers "X-CSRFToken" csrf)))) + ;; Build request body + (let ((body-info (build-request-body el method url))) + (let ((body (get body-info "body")) + (final-url (get body-info "url")) + (ct (get body-info "content-type"))) + (when ct (dict-set! headers "Content-Type" ct)) + ;; Lifecycle: beforeRequest + (if (not (dom-dispatch el "sx:beforeRequest" + (dict "method" method "url" final-url))) + (promise-resolve nil) + (do + ;; Loading state + (dom-add-class el "sx-request") + (dom-set-attr el "aria-busy" "true") + (let ((indicator (show-indicator el)) + (disabled-elts (disable-elements el)) + (preloaded (if (= method "GET") + (preload-cache-get _preload-cache final-url) + nil))) + ;; Platform fetch with callbacks + (fetch-request + (dict "url" final-url "method" method + "headers" headers "body" body + "signal" (controller-signal ctrl) + "preloaded" preloaded + "cross-origin" (cross-origin? final-url)) + ;; Success: (fn (resp-ok status get-header text) ...) + (fn (resp-ok status get-header text) + (do + (clear-loading-state el indicator disabled-elts) + (if (not resp-ok) + (do + (dom-dispatch el "sx:responseError" + (dict "status" status)) + (handle-retry el verb extra-params)) + (do + (dom-dispatch el "sx:afterRequest" (dict)) + (handle-fetch-success el final-url verb + extra-params get-header text))))) + ;; Error: (fn (err) ...) + (fn (err) + (do + (clear-loading-state el indicator disabled-elts) + (when (not (abort-error? err)) + (do + (dom-dispatch el "sx:sendError" + (dict "error" err)) + (handle-retry el verb extra-params)))))))))))))))) + + +;; -------------------------------------------------------------------------- +;; Response handling +;; -------------------------------------------------------------------------- + +(define handle-fetch-success + (fn (el url verb extra-params get-header text) + ;; Process a successful fetch response. + (let ((headers (process-response-headers get-header))) + ;; Redirect — skip swap + (if (get headers "redirect") + (browser-navigate (get headers "redirect")) + ;; Refresh — skip swap + (if (= (get headers "refresh") "true") + (browser-reload) + (do + ;; Trigger events from header + (dispatch-trigger-events el (get headers "trigger")) + ;; Determine swap target and strategy + (let ((raw-swap (or (dom-get-attr el "sx-swap") DEFAULT_SWAP)) + (target (resolve-target el)) + (select-sel (dom-get-attr el "sx-select"))) + ;; Server overrides + (when (get headers "retarget") + (set! target (or (dom-query (get headers "retarget")) target))) + (when (get headers "reswap") + (set! raw-swap (get headers "reswap"))) + ;; Parse swap spec + (let ((swap (parse-swap-spec raw-swap false)) + (ct (or (get headers "content-type") ""))) + ;; Dispatch by content type + (if (contains? ct "text/sx") + (handle-sx-response el target swap select-sel text) + (handle-html-response el target swap select-sel text)) + ;; SX-Location + (when (get headers "location") + (fetch-location (get headers "location"))) + ;; History + (handle-history el url headers) + ;; After-swap lifecycle + (dom-dispatch el "sx:afterSwap" (dict "target" target)) + (dispatch-trigger-events el (get headers "trigger-swap")) + (request-animation-frame + (fn () + (do + (dom-dispatch el "sx:afterSettle" + (dict "target" target)) + (dispatch-trigger-events el + (get headers "trigger-settle"))))))))))))) + + +;; -------------------------------------------------------------------------- +;; SX response handler +;; -------------------------------------------------------------------------- + +(define handle-sx-response + (fn (el target swap select-sel text) + ;; Process text/sx response: extract components, CSS, render, swap. + (let ((cleaned (strip-component-scripts text)) + (cleaned2 (extract-response-css cleaned))) + (let ((source (trim cleaned2))) + (when (and source (not (= source ""))) + (let ((dom (sx-render source)) + (container (dom-create-element "div" nil))) + (dom-append container dom) + ;; OOB processing on live DOM nodes + (process-oob-swaps container + (fn (t oob s) (swap-dom-nodes t oob s))) + ;; Select filtering + (let ((selected (if select-sel + (select-from-container container select-sel) + (children-to-fragment container)))) + ;; Main swap + (when (and (not (= (get swap "style") "none")) target) + (with-transition (get swap "transition") + (fn () + (do + (swap-dom-nodes target selected (get swap "style")) + (hoist-head-elements target)))))))))))) + + +;; -------------------------------------------------------------------------- +;; HTML response handler +;; -------------------------------------------------------------------------- + +(define handle-html-response + (fn (el target swap select-sel text) + ;; Process HTML response: parse, scripts, OOB, swap. + (let ((doc (dom-parse-html-document text))) + ;; Process sx scripts + (sx-process-scripts doc) + ;; OOB processing + (process-oob-swaps doc + (fn (t oob s) + (swap-html-string t (dom-outer-html oob) s))) + ;; Build content + (let ((content (if select-sel + (select-html-from-doc doc select-sel) + (or (dom-body-inner-html doc) text)))) + ;; Main swap + (when (and (not (= (get swap "style") "none")) target) + (with-transition (get swap "transition") + (fn () + (do + (swap-html-string target content (get swap "style")) + (hoist-head-elements target))))))))) + + +;; -------------------------------------------------------------------------- +;; Retry handling +;; -------------------------------------------------------------------------- + +(define handle-retry + (fn (el verb-info extra-params) + ;; Retry failed request with exponential backoff. + (let ((retry-attr (dom-get-attr el "sx-retry"))) + (when retry-attr + (let ((spec (parse-retry-spec retry-attr)) + (current-ms (or (parse-int + (dom-get-attr el "data-sx-retry-ms") 0) + (get spec "start-ms")))) + (dom-add-class el "sx-error") + (dom-remove-class el "sx-loading") + (set-timeout + (fn () + (do + (dom-remove-class el "sx-error") + (dom-add-class el "sx-loading") + (dom-set-attr el "data-sx-retry-ms" + (str (next-retry-ms current-ms (get spec "cap-ms")))) + (execute-request el verb-info extra-params))) + current-ms)))))) + + +;; -------------------------------------------------------------------------- +;; Trigger binding +;; -------------------------------------------------------------------------- + +(define bind-triggers + (fn (el verb-info) + ;; Parse triggers and bind event handlers. + (let ((trigger-spec (dom-get-attr el "sx-trigger")) + (triggers (if trigger-spec + (parse-trigger-spec trigger-spec) + (default-trigger (dom-tag-name el))))) + (for-each + (fn (trig) + (let ((kind (classify-trigger trig))) + (cond + (= kind "poll") + (set-interval + (fn () (execute-request el verb-info nil)) + (or (get (get trig "modifiers") "interval") 1000)) + (= kind "intersect") + (observe-intersection el + (fn () (execute-request el verb-info nil)) + (get (get trig "modifiers") "once") + (get (get trig "modifiers") "delay")) + (= kind "load") + (set-timeout + (fn () (execute-request el verb-info nil)) 0) + (= kind "revealed") + (observe-intersection el + (fn () (execute-request el verb-info nil)) + true nil) + :else + (bind-event el verb-info trig)))) + triggers)))) + + +;; -------------------------------------------------------------------------- +;; Event binding with modifiers +;; -------------------------------------------------------------------------- + +(define bind-event + (fn (el verb-info trig) + ;; Bind a single event with modifiers (once, delay, changed, from). + (let ((event-name (get trig "event")) + (mods (get trig "modifiers")) + (listen-target (if (get mods "from") + (or (dom-query (get mods "from")) el) + el)) + (timer nil) + (last-val nil)) + (dom-add-listener listen-target event-name + (fn (e) + (do + ;; Prevent defaults + (when (= event-name "submit") (prevent-default e)) + (when (and (= event-name "click") (= (dom-tag-name el) "A")) + (prevent-default e)) + ;; Validation gate + (if (not (validate-for-request el)) + (dom-dispatch el "sx:validationFailed" (dict)) + ;; Changed modifier gate + (if (and (get mods "changed") + (not (nil? (element-value el))) + (= (element-value el) last-val)) + nil + (do + (when (get mods "changed") + (set! last-val (element-value el))) + ;; Apply optimistic update + (let ((opt-state (apply-optimistic el)) + (exec-fn + (fn () + (let ((p (execute-request el verb-info nil))) + (when (and opt-state p) + (promise-catch p + (fn (_) (revert-optimistic opt-state)))))))) + ;; Delay modifier + (if (get mods "delay") + (do + (clear-timeout timer) + (set! timer + (set-timeout exec-fn (get mods "delay")))) + (exec-fn)))))))) + (dict "once" (get mods "once")))))) + + +;; -------------------------------------------------------------------------- +;; Post-swap lifecycle +;; -------------------------------------------------------------------------- + +(define post-swap + (fn (root) + ;; Post-swap: activate scripts, load components, hydrate, bind engine. + (do + (activate-scripts root) + (sx-process-scripts root) + (sx-hydrate root) + (process-elements root)))) + +(define activate-scripts + (fn (root) + ;; Scripts inserted via innerHTML don't execute. + ;; Replace dead scripts with live clones so the browser runs them. + (let ((dead (dom-query-all root + "script:not([type]), script[type='text/javascript']"))) + (for-each + (fn (d) + (let ((live (create-script-clone d))) + (dom-replace-child (dom-parent d) live d))) + dead)))) + + +;; -------------------------------------------------------------------------- +;; Out-of-band swap processing (orchestration variant) +;; -------------------------------------------------------------------------- + +(define process-oob-swaps + (fn (container swap-fn) + ;; Find elements with sx-swap-oob/hx-swap-oob and swap to targets. + (for-each + (fn (attr) + (let ((oob-els (dom-query-all container (str "[" attr "]")))) + (for-each + (fn (oob) + (let ((swap-type (or (dom-get-attr oob attr) "outerHTML")) + (target-id (dom-id oob))) + (dom-remove-attr oob attr) + (when (dom-parent oob) + (dom-remove-child (dom-parent oob) oob)) + (when target-id + (let ((target (dom-query-by-id target-id))) + (when target + (swap-fn target oob swap-type)))))) + oob-els))) + (list "sx-swap-oob" "hx-swap-oob")))) + + +;; -------------------------------------------------------------------------- +;; Head element hoisting +;; -------------------------------------------------------------------------- + +(define hoist-head-elements + (fn (root) + ;; Move