Add cross-domain SX navigation with OOB swap
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m38s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m38s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -63,8 +63,8 @@ def register():
|
|||||||
src=getattr(item, "feature_image", None),
|
src=getattr(item, "feature_image", None),
|
||||||
label=getattr(item, "label", item.slug))
|
label=getattr(item, "label", item.slug))
|
||||||
item_sexps.append(sexp_call(
|
item_sexps.append(sexp_call(
|
||||||
"blog-nav-item-plain",
|
"blog-nav-item-link",
|
||||||
href=href, selected=selected, nav_cls=nav_cls,
|
href=href, hx_get=href, selected=selected, nav_cls=nav_cls,
|
||||||
img=SexpExpr(img), label=getattr(item, "label", item.slug),
|
img=SexpExpr(img), label=getattr(item, "label", item.slug),
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -74,8 +74,8 @@ def register():
|
|||||||
or "artdag" == app_name) else "false"
|
or "artdag" == app_name) else "false"
|
||||||
img = sexp_call("blog-nav-item-image", src=None, label="art-dag")
|
img = sexp_call("blog-nav-item-image", src=None, label="art-dag")
|
||||||
item_sexps.append(sexp_call(
|
item_sexps.append(sexp_call(
|
||||||
"blog-nav-item-plain",
|
"blog-nav-item-link",
|
||||||
href=href, selected=selected, nav_cls=nav_cls,
|
href=href, hx_get=href, selected=selected, nav_cls=nav_cls,
|
||||||
img=SexpExpr(img), label="art-dag",
|
img=SexpExpr(img), label="art-dag",
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
@@ -295,7 +295,12 @@ def create_base_app(
|
|||||||
if origin.endswith(".rose-ash.com") or origin.endswith(".localhost"):
|
if origin.endswith(".rose-ash.com") or origin.endswith(".localhost"):
|
||||||
response.headers["Access-Control-Allow-Origin"] = origin
|
response.headers["Access-Control-Allow-Origin"] = origin
|
||||||
response.headers["Access-Control-Allow-Credentials"] = "true"
|
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
|
return response
|
||||||
|
|
||||||
@app.after_request
|
@app.after_request
|
||||||
|
|||||||
@@ -278,6 +278,46 @@ def sexp_call(component_name: str, **kwargs: Any) -> str:
|
|||||||
return "(" + " ".join(parts) + ")"
|
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("</script>")
|
||||||
|
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,
|
def sexp_response(source_or_component: str, status: int = 200,
|
||||||
headers: dict | None = None, **kwargs: Any):
|
headers: dict | None = None, **kwargs: Any):
|
||||||
"""Return an s-expression wire-format response.
|
"""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)::
|
Or with a component name + kwargs (builds the sexp call)::
|
||||||
|
|
||||||
return sexp_response("test-row", nodeid="foo", outcome="passed")
|
return sexp_response("test-row", nodeid="foo", outcome="passed")
|
||||||
|
|
||||||
|
For SX requests, missing component definitions are prepended as a
|
||||||
|
``<script type="text/sexp" data-components>`` block so the client
|
||||||
|
can process them before rendering OOB content.
|
||||||
"""
|
"""
|
||||||
from quart import Response
|
from quart import request, Response
|
||||||
if kwargs:
|
if kwargs:
|
||||||
source = sexp_call(source_or_component, **kwargs)
|
source = sexp_call(source_or_component, **kwargs)
|
||||||
else:
|
else:
|
||||||
source = source_or_component
|
source = source_or_component
|
||||||
resp = Response(source, status=status, content_type="text/sexp")
|
|
||||||
|
body = source
|
||||||
|
# For SX requests, prepend missing component definitions
|
||||||
|
if request.headers.get("SX-Request"):
|
||||||
|
comp_defs = components_for_request()
|
||||||
|
if comp_defs:
|
||||||
|
body = (f'<script type="text/sexp" data-components>'
|
||||||
|
f'{comp_defs}</script>\n{body}')
|
||||||
|
|
||||||
|
resp = Response(body, status=status, content_type="text/sexp")
|
||||||
if headers:
|
if headers:
|
||||||
for k, v in headers.items():
|
for k, v in headers.items():
|
||||||
resp.headers[k] = v
|
resp.headers[k] = v
|
||||||
|
|||||||
@@ -1452,6 +1452,12 @@
|
|||||||
var targetSel = el.getAttribute("sx-target");
|
var targetSel = el.getAttribute("sx-target");
|
||||||
if (targetSel) headers["SX-Target"] = targetSel;
|
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
|
// Extra headers from sx-headers
|
||||||
var extraH = el.getAttribute("sx-headers");
|
var extraH = el.getAttribute("sx-headers");
|
||||||
if (extraH) {
|
if (extraH) {
|
||||||
@@ -1545,6 +1551,14 @@
|
|||||||
el.setAttribute("aria-busy", "true");
|
el.setAttribute("aria-busy", "true");
|
||||||
|
|
||||||
var fetchOpts = { method: method, headers: headers, signal: ctrl.signal };
|
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;
|
if (body && method !== "GET") fetchOpts.body = body;
|
||||||
|
|
||||||
return fetch(url, fetchOpts).then(function (resp) {
|
return fetch(url, fetchOpts).then(function (resp) {
|
||||||
@@ -1580,6 +1594,9 @@
|
|||||||
var parser = new DOMParser();
|
var parser = new DOMParser();
|
||||||
var doc = parser.parseFromString(text, "text/html");
|
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
|
// OOB processing: extract elements with sx-swap-oob
|
||||||
var oobs = doc.querySelectorAll("[sx-swap-oob]");
|
var oobs = doc.querySelectorAll("[sx-swap-oob]");
|
||||||
oobs.forEach(function (oob) {
|
oobs.forEach(function (oob) {
|
||||||
@@ -1854,9 +1871,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fetch fresh
|
// Fetch fresh
|
||||||
fetch(url, {
|
var histOpts = {
|
||||||
headers: { "SX-Request": "true", "SX-History-Restore": "true" }
|
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();
|
return resp.text();
|
||||||
}).then(function (text) {
|
}).then(function (text) {
|
||||||
var ct = "";
|
var ct = "";
|
||||||
|
|||||||
Reference in New Issue
Block a user