From 82b411f25a92cbaebfde4997e25f168396a2bd3c Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 1 Mar 2026 10:33:12 +0000 Subject: [PATCH] Add cross-domain SX navigation with OOB swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable instant cross-subdomain navigation (blog → market, etc.) via sx-get instead of full page reloads. The server prepends missing component definitions to OOB responses so the client can render components from other domains. - sexp.js: send SX-Components header, add credentials for cross-origin fetches to .rose-ash.com/.localhost, process sexp scripts in response before OOB swap - helpers.py: add components_for_request() to diff client/server component sets, update sexp_response() to prepend missing defs - factory.py: add SX-Components to CORS allowed headers, add Access-Control-Allow-Methods - fragments/routes.py: switch nav items from ~blog-nav-item-plain to ~blog-nav-item-link (sx-get enabled) Co-Authored-By: Claude Opus 4.6 --- blog/bp/fragments/routes.py | 8 ++--- shared/infrastructure/factory.py | 7 +++- shared/sexp/helpers.py | 57 ++++++++++++++++++++++++++++++-- shared/static/scripts/sexp.js | 29 ++++++++++++++-- 4 files changed, 92 insertions(+), 9 deletions(-) diff --git a/blog/bp/fragments/routes.py b/blog/bp/fragments/routes.py index 76a297c..44269dd 100644 --- a/blog/bp/fragments/routes.py +++ b/blog/bp/fragments/routes.py @@ -63,8 +63,8 @@ def register(): src=getattr(item, "feature_image", None), label=getattr(item, "label", item.slug)) item_sexps.append(sexp_call( - "blog-nav-item-plain", - href=href, selected=selected, nav_cls=nav_cls, + "blog-nav-item-link", + href=href, hx_get=href, selected=selected, nav_cls=nav_cls, img=SexpExpr(img), label=getattr(item, "label", item.slug), )) @@ -74,8 +74,8 @@ def register(): or "artdag" == app_name) else "false" img = sexp_call("blog-nav-item-image", src=None, label="art-dag") item_sexps.append(sexp_call( - "blog-nav-item-plain", - href=href, selected=selected, nav_cls=nav_cls, + "blog-nav-item-link", + href=href, hx_get=href, selected=selected, nav_cls=nav_cls, img=SexpExpr(img), label="art-dag", )) diff --git a/shared/infrastructure/factory.py b/shared/infrastructure/factory.py index 64c91c6..323b6cc 100644 --- a/shared/infrastructure/factory.py +++ b/shared/infrastructure/factory.py @@ -295,7 +295,12 @@ def create_base_app( if origin.endswith(".rose-ash.com") or origin.endswith(".localhost"): response.headers["Access-Control-Allow-Origin"] = origin response.headers["Access-Control-Allow-Credentials"] = "true" - response.headers["Access-Control-Allow-Headers"] = "SX-Request, SX-Target, SX-Current-URL, HX-Request, HX-Target, HX-Current-URL, HX-Trigger, Content-Type, X-CSRFToken" + response.headers["Access-Control-Allow-Headers"] = ( + "SX-Request, SX-Target, SX-Current-URL, SX-Components, " + "HX-Request, HX-Target, HX-Current-URL, HX-Trigger, " + "Content-Type, X-CSRFToken" + ) + response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" return response @app.after_request diff --git a/shared/sexp/helpers.py b/shared/sexp/helpers.py index 0a7bd3b..58098d2 100644 --- a/shared/sexp/helpers.py +++ b/shared/sexp/helpers.py @@ -278,6 +278,46 @@ def sexp_call(component_name: str, **kwargs: Any) -> str: return "(" + " ".join(parts) + ")" +def components_for_request() -> str: + """Return defcomp source for components the client doesn't have yet. + + Reads the ``SX-Components`` header (comma-separated component names + like ``~card,~nav-item``) and returns only the definitions the client + is missing. If the header is absent, returns all component defs. + """ + from quart import request + from .jinja_bridge import client_components_tag, _COMPONENT_ENV + from .types import Component + from .parser import serialize + + loaded_raw = request.headers.get("SX-Components", "") + if not loaded_raw: + # Client has nothing — send all + tag = client_components_tag() + if not tag: + return "" + start = tag.find(">") + 1 + end = tag.rfind("") + return tag[start:end] if start > 0 and end > start else "" + + loaded = set(loaded_raw.split(",")) + parts = [] + for key, val in _COMPONENT_ENV.items(): + if not isinstance(val, Component): + continue + # Skip components the client already has + if f"~{val.name}" in loaded or val.name in loaded: + continue + # Reconstruct defcomp source + param_strs = ["&key"] + list(val.params) + if val.has_children: + param_strs.extend(["&rest", "children"]) + params_sexp = "(" + " ".join(param_strs) + ")" + body_sexp = serialize(val.body, pretty=True) + parts.append(f"(defcomp ~{val.name} {params_sexp} {body_sexp})") + return "\n".join(parts) + + def sexp_response(source_or_component: str, status: int = 200, headers: dict | None = None, **kwargs: Any): """Return an s-expression wire-format response. @@ -289,13 +329,26 @@ def sexp_response(source_or_component: str, status: int = 200, Or with a component name + kwargs (builds the sexp call):: return sexp_response("test-row", nodeid="foo", outcome="passed") + + For SX requests, missing component definitions are prepended as a + ``\n{body}') + + resp = Response(body, status=status, content_type="text/sexp") if headers: for k, v in headers.items(): resp.headers[k] = v diff --git a/shared/static/scripts/sexp.js b/shared/static/scripts/sexp.js index 3741e8f..d1d2e56 100644 --- a/shared/static/scripts/sexp.js +++ b/shared/static/scripts/sexp.js @@ -1452,6 +1452,12 @@ var targetSel = el.getAttribute("sx-target"); if (targetSel) headers["SX-Target"] = targetSel; + // Send loaded component names so cross-domain responses can prepend missing defs + var loadedNames = Object.keys(_componentEnv).filter(function (k) { + return k.charAt(0) === "~"; + }); + if (loadedNames.length) headers["SX-Components"] = loadedNames.join(","); + // Extra headers from sx-headers var extraH = el.getAttribute("sx-headers"); if (extraH) { @@ -1545,6 +1551,14 @@ el.setAttribute("aria-busy", "true"); var fetchOpts = { method: method, headers: headers, signal: ctrl.signal }; + // Cross-origin credentials for known subdomains + try { + var urlHost = new URL(url, location.href).hostname; + if (urlHost !== location.hostname && + (urlHost.endsWith(".rose-ash.com") || urlHost.endsWith(".localhost"))) { + fetchOpts.credentials = "include"; + } + } catch (e) {} if (body && method !== "GET") fetchOpts.body = body; return fetch(url, fetchOpts).then(function (resp) { @@ -1580,6 +1594,9 @@ var parser = new DOMParser(); var doc = parser.parseFromString(text, "text/html"); + // Process any sexp script blocks in the response (e.g. cross-domain component defs) + Sexp.processScripts(doc); + // OOB processing: extract elements with sx-swap-oob var oobs = doc.querySelectorAll("[sx-swap-oob]"); oobs.forEach(function (oob) { @@ -1854,9 +1871,17 @@ } } // Fetch fresh - fetch(url, { + var histOpts = { headers: { "SX-Request": "true", "SX-History-Restore": "true" } - }).then(function (resp) { + }; + try { + var hHost = new URL(url, location.href).hostname; + if (hHost !== location.hostname && + (hHost.endsWith(".rose-ash.com") || hHost.endsWith(".localhost"))) { + histOpts.credentials = "include"; + } + } catch (e) {} + fetch(url, histOpts).then(function (resp) { return resp.text(); }).then(function (text) { var ct = "";