Phase 4: Client-side rendering of :data pages via abstract resolve-page-data
Spec layer (orchestration.sx): - try-client-route now handles :data pages instead of falling back to server - New abstract primitive resolve-page-data(name, params, callback) — platform decides transport (HTTP, IPC, cache, etc) - Extracted swap-rendered-content and resolve-route-target helpers Platform layer (bootstrap_js.py): - resolvePageData() browser implementation: fetches /sx/data/<name>, parses SX response, calls callback. Other hosts provide their own transport. Server layer (pages.py): - evaluate_page_data() evaluates :data expr, serializes result as SX - auto_mount_page_data() mounts /sx/data/ endpoint with per-page auth - _build_pages_sx now computes component deps for all pages (not just pure) Test page at /isomorphism/data-test exercises the full pipeline. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
// =========================================================================
|
||||
|
||||
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
|
||||
var SX_VERSION = "2026-03-06T23:02:44Z";
|
||||
var SX_VERSION = "2026-03-06T23:36:38Z";
|
||||
|
||||
function isNil(x) { return x === NIL || x === null || x === undefined; }
|
||||
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
||||
@@ -1930,22 +1930,33 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\"
|
||||
})()) : NIL); }, domQueryAll(container, "form"));
|
||||
})(); };
|
||||
|
||||
// swap-rendered-content
|
||||
var swapRenderedContent = function(target, rendered, pathname) { return (domSetTextContent(target, ""), domAppend(target, rendered), hoistHeadElementsFull(target), processElements(target), sxHydrateElements(target), domDispatch(target, "sx:clientRoute", {["pathname"]: pathname}), logInfo((String("sx:route client ") + String(pathname)))); };
|
||||
|
||||
// resolve-route-target
|
||||
var resolveRouteTarget = function(targetSel) { return (isSxTruthy((isSxTruthy(targetSel) && !isSxTruthy((targetSel == "true")))) ? domQuery(targetSel) : NIL); };
|
||||
|
||||
// try-client-route
|
||||
var tryClientRoute = function(pathname, targetSel) { return (function() {
|
||||
var match = findMatchingRoute(pathname, _pageRoutes);
|
||||
return (isSxTruthy(isNil(match)) ? (logInfo((String("sx:route no match (") + String(len(_pageRoutes)) + String(" routes) ") + String(pathname))), false) : (isSxTruthy(get(match, "has-data")) ? (logInfo((String("sx:route server (has data) ") + String(pathname))), false) : (function() {
|
||||
return (isSxTruthy(isNil(match)) ? (logInfo((String("sx:route no match (") + String(len(_pageRoutes)) + String(" routes) ") + String(pathname))), false) : (function() {
|
||||
var contentSrc = get(match, "content");
|
||||
var closure = sxOr(get(match, "closure"), {});
|
||||
var params = get(match, "params");
|
||||
var pageName = get(match, "name");
|
||||
return (isSxTruthy(sxOr(isNil(contentSrc), isEmpty(contentSrc))) ? (logWarn((String("sx:route no content for ") + String(pathname))), false) : (function() {
|
||||
var target = resolveRouteTarget(targetSel);
|
||||
return (isSxTruthy(isNil(target)) ? (logWarn((String("sx:route target not found: ") + String(targetSel))), false) : (isSxTruthy(get(match, "has-data")) ? (logInfo((String("sx:route client+data ") + String(pathname))), resolvePageData(pageName, params, function(data) { return (function() {
|
||||
var env = merge(closure, params, data);
|
||||
var rendered = tryEvalContent(contentSrc, env);
|
||||
return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route data eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname));
|
||||
})(); }), true) : (function() {
|
||||
var env = merge(closure, params);
|
||||
var rendered = tryEvalContent(contentSrc, env);
|
||||
return (isSxTruthy(isNil(rendered)) ? (logInfo((String("sx:route server (eval failed) ") + String(pathname))), false) : (function() {
|
||||
var target = (isSxTruthy((isSxTruthy(targetSel) && !isSxTruthy((targetSel == "true")))) ? domQuery(targetSel) : NIL);
|
||||
return (isSxTruthy(isNil(target)) ? (logWarn((String("sx:route target not found: ") + String(targetSel))), false) : (domSetTextContent(target, ""), domAppend(target, rendered), hoistHeadElementsFull(target), processElements(target), sxHydrateElements(target), domDispatch(target, "sx:clientRoute", {["pathname"]: pathname}), logInfo((String("sx:route client ") + String(pathname))), true));
|
||||
})());
|
||||
})());
|
||||
return (isSxTruthy(isNil(rendered)) ? (logInfo((String("sx:route server (eval failed) ") + String(pathname))), false) : (swapRenderedContent(target, rendered, pathname), true));
|
||||
})()));
|
||||
})());
|
||||
})());
|
||||
})(); };
|
||||
|
||||
// bind-client-route-link
|
||||
@@ -2368,76 +2379,6 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
var bootInit = function() { return (logInfo((String("sx-browser ") + String(SX_VERSION))), initCssTracking(), initStyleDict(), processPageScripts(), processSxScripts(NIL), sxHydrateElements(NIL), processElements(NIL)); };
|
||||
|
||||
|
||||
// === Transpiled from router (client-side route matching) ===
|
||||
|
||||
// split-path-segments
|
||||
var splitPathSegments = function(path) { return (function() {
|
||||
var trimmed = (isSxTruthy(startsWith(path, "/")) ? slice(path, 1) : path);
|
||||
return (function() {
|
||||
var trimmed2 = (isSxTruthy((isSxTruthy(!isSxTruthy(isEmpty(trimmed))) && endsWith(trimmed, "/"))) ? slice(trimmed, 0, (len(trimmed) - 1)) : trimmed);
|
||||
return (isSxTruthy(isEmpty(trimmed2)) ? [] : split(trimmed2, "/"));
|
||||
})();
|
||||
})(); };
|
||||
|
||||
// make-route-segment
|
||||
var makeRouteSegment = function(seg) { return (isSxTruthy((isSxTruthy(startsWith(seg, "<")) && endsWith(seg, ">"))) ? (function() {
|
||||
var paramName = slice(seg, 1, (len(seg) - 1));
|
||||
return (function() {
|
||||
var d = {};
|
||||
d["type"] = "param";
|
||||
d["value"] = paramName;
|
||||
return d;
|
||||
})();
|
||||
})() : (function() {
|
||||
var d = {};
|
||||
d["type"] = "literal";
|
||||
d["value"] = seg;
|
||||
return d;
|
||||
})()); };
|
||||
|
||||
// parse-route-pattern
|
||||
var parseRoutePattern = function(pattern) { return (function() {
|
||||
var segments = splitPathSegments(pattern);
|
||||
return map(makeRouteSegment, segments);
|
||||
})(); };
|
||||
|
||||
// match-route-segments
|
||||
var matchRouteSegments = function(pathSegs, parsedSegs) { return (isSxTruthy(!isSxTruthy((len(pathSegs) == len(parsedSegs)))) ? NIL : (function() {
|
||||
var params = {};
|
||||
var matched = true;
|
||||
forEachIndexed(function(i, parsedSeg) { return (isSxTruthy(matched) ? (function() {
|
||||
var pathSeg = nth(pathSegs, i);
|
||||
var segType = get(parsedSeg, "type");
|
||||
return (isSxTruthy((segType == "literal")) ? (isSxTruthy(!isSxTruthy((pathSeg == get(parsedSeg, "value")))) ? (matched = false) : NIL) : (isSxTruthy((segType == "param")) ? dictSet(params, get(parsedSeg, "value"), pathSeg) : (matched = false)));
|
||||
})() : NIL); }, parsedSegs);
|
||||
return (isSxTruthy(matched) ? params : NIL);
|
||||
})()); };
|
||||
|
||||
// match-route
|
||||
var matchRoute = function(path, pattern) { return (function() {
|
||||
var pathSegs = splitPathSegments(path);
|
||||
var parsedSegs = parseRoutePattern(pattern);
|
||||
return matchRouteSegments(pathSegs, parsedSegs);
|
||||
})(); };
|
||||
|
||||
// find-matching-route
|
||||
var findMatchingRoute = function(path, routes) { return (function() {
|
||||
var pathSegs = splitPathSegments(path);
|
||||
var result = NIL;
|
||||
{ var _c = routes; for (var _i = 0; _i < _c.length; _i++) { var route = _c[_i]; if (isSxTruthy(isNil(result))) {
|
||||
(function() {
|
||||
var params = matchRouteSegments(pathSegs, get(route, "parsed"));
|
||||
return (isSxTruthy(!isSxTruthy(isNil(params))) ? (function() {
|
||||
var matched = merge(route, {});
|
||||
matched["params"] = params;
|
||||
return (result = matched);
|
||||
})() : NIL);
|
||||
})();
|
||||
} } }
|
||||
return result;
|
||||
})(); };
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// Platform interface — DOM adapter (browser-only)
|
||||
// =========================================================================
|
||||
@@ -3116,6 +3057,42 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePageData(pageName, params, callback) {
|
||||
// Platform implementation: fetch page data via HTTP from /sx/data/ endpoint.
|
||||
// The spec only knows about resolve-page-data(name, params, callback) —
|
||||
// this function provides the concrete transport.
|
||||
var url = "/sx/data/" + encodeURIComponent(pageName);
|
||||
if (params && !isNil(params)) {
|
||||
var qs = [];
|
||||
var ks = Object.keys(params);
|
||||
for (var i = 0; i < ks.length; i++) {
|
||||
var v = params[ks[i]];
|
||||
if (v !== null && v !== undefined && v !== NIL) {
|
||||
qs.push(encodeURIComponent(ks[i]) + "=" + encodeURIComponent(v));
|
||||
}
|
||||
}
|
||||
if (qs.length) url += "?" + qs.join("&");
|
||||
}
|
||||
var headers = { "SX-Request": "true" };
|
||||
fetch(url, { headers: headers }).then(function(resp) {
|
||||
if (!resp.ok) {
|
||||
logWarn("sx:data resolve failed " + resp.status + " for " + pageName);
|
||||
return;
|
||||
}
|
||||
return resp.text().then(function(text) {
|
||||
try {
|
||||
var exprs = parse(text);
|
||||
var data = exprs.length === 1 ? exprs[0] : {};
|
||||
callback(data || {});
|
||||
} catch (e) {
|
||||
logWarn("sx:data parse error for " + pageName + ": " + (e && e.message ? e.message : e));
|
||||
}
|
||||
});
|
||||
}).catch(function(err) {
|
||||
logWarn("sx:data resolve error for " + pageName + ": " + (err && err.message ? err.message : err));
|
||||
});
|
||||
}
|
||||
|
||||
function urlPathname(href) {
|
||||
try {
|
||||
return new URL(href, location.href).pathname;
|
||||
@@ -3577,10 +3554,6 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null,
|
||||
getEnv: function() { return componentEnv; },
|
||||
init: typeof bootInit === "function" ? bootInit : null,
|
||||
splitPathSegments: splitPathSegments,
|
||||
parseRoutePattern: parseRoutePattern,
|
||||
matchRoute: matchRoute,
|
||||
findMatchingRoute: findMatchingRoute,
|
||||
_version: "ref-2.0 (boot+cssx+dom+engine+html+orchestration+parser+sx, bootstrap-compiled)"
|
||||
};
|
||||
|
||||
|
||||
@@ -684,8 +684,10 @@ def _build_pages_sx(service: str) -> str:
|
||||
has_data = "true" if page_def.data_expr is not None else "false"
|
||||
|
||||
# Component deps needed to render this page client-side
|
||||
# Compute for all pages (including :data pages) — the client
|
||||
# renders both pure and data pages after fetching data
|
||||
deps: set[str] = set()
|
||||
if content_src and page_def.data_expr is None:
|
||||
if content_src:
|
||||
deps = components_needed(content_src, _COMPONENT_ENV)
|
||||
deps_sx = "(" + " ".join(_sx_literal(d) for d in sorted(deps)) + ")"
|
||||
|
||||
|
||||
@@ -318,12 +318,19 @@ def auto_mount_pages(app: Any, service_name: str) -> None:
|
||||
|
||||
Pages must have absolute paths (from the service URL root).
|
||||
Called once per service in app.py after setup_*_pages().
|
||||
|
||||
Also mounts the /sx/data/ endpoint for client-side data fetching.
|
||||
"""
|
||||
pages = get_all_pages(service_name)
|
||||
for page_def in pages.values():
|
||||
_mount_one_page(app, service_name, page_def)
|
||||
logger.info("Auto-mounted %d defpages for %s", len(pages), service_name)
|
||||
|
||||
# Mount page data endpoint for client-side rendering of :data pages
|
||||
has_data_pages = any(p.data_expr is not None for p in pages.values())
|
||||
if has_data_pages:
|
||||
auto_mount_page_data(app, service_name)
|
||||
|
||||
|
||||
def mount_pages(bp: Any, service_name: str,
|
||||
names: set[str] | list[str] | None = None) -> None:
|
||||
@@ -405,3 +412,126 @@ def _apply_cache(fn: Any, cache: dict) -> Any:
|
||||
tag = cache.get("tag")
|
||||
scope = cache.get("scope", "user")
|
||||
return cache_page(ttl=ttl, tag=tag, scope=scope)(fn)
|
||||
|
||||
|
||||
async def _check_page_auth(auth: str | list) -> Any | None:
|
||||
"""Check auth for the data endpoint. Returns None if OK, or a response."""
|
||||
from quart import g, abort as quart_abort
|
||||
|
||||
if auth == "public":
|
||||
return None
|
||||
user = g.get("user")
|
||||
if auth == "login":
|
||||
if not user:
|
||||
quart_abort(401)
|
||||
elif auth == "admin":
|
||||
if not user or not user.get("rights", {}).get("admin"):
|
||||
quart_abort(403)
|
||||
elif isinstance(auth, list) and auth and auth[0] == "rights":
|
||||
if not user:
|
||||
quart_abort(401)
|
||||
user_rights = set(user.get("rights", {}).keys())
|
||||
required = set(auth[1:])
|
||||
if not required.issubset(user_rights):
|
||||
quart_abort(403)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page data endpoint — evaluate :data expression, return SX
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def evaluate_page_data(
|
||||
page_def: PageDef,
|
||||
service_name: str,
|
||||
url_params: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Evaluate a defpage's :data expression and return result as SX.
|
||||
|
||||
This is the data-only counterpart to execute_page(). The client
|
||||
fetches this when it has all component definitions but needs the
|
||||
data bindings to render a :data page client-side.
|
||||
|
||||
Returns SX wire format (e.g. ``{:posts (list ...) :count 42}``),
|
||||
parsed by the client's SX parser and merged into the eval env.
|
||||
"""
|
||||
from .jinja_bridge import get_component_env, _get_request_context
|
||||
from .async_eval import async_eval
|
||||
from .parser import serialize
|
||||
|
||||
if page_def.data_expr is None:
|
||||
return "nil"
|
||||
|
||||
if url_params is None:
|
||||
url_params = {}
|
||||
|
||||
# Build environment (same as execute_page)
|
||||
env = dict(get_component_env())
|
||||
env.update(get_page_helpers(service_name))
|
||||
env.update(page_def.closure)
|
||||
|
||||
for key, val in url_params.items():
|
||||
kebab = key.replace("_", "-")
|
||||
env[kebab] = val
|
||||
env[key] = val
|
||||
|
||||
ctx = _get_request_context()
|
||||
|
||||
data_result = await async_eval(page_def.data_expr, env, ctx)
|
||||
|
||||
# Kebab-case dict keys (matching execute_page line 214-215)
|
||||
if isinstance(data_result, dict):
|
||||
data_result = {
|
||||
k.replace("_", "-"): v for k, v in data_result.items()
|
||||
}
|
||||
|
||||
# Serialize the result as SX
|
||||
return serialize(data_result)
|
||||
|
||||
|
||||
def auto_mount_page_data(app: Any, service_name: str) -> None:
|
||||
"""Mount a single /sx/data/ endpoint that serves page data as SX.
|
||||
|
||||
For each defpage with :data, the client can GET /sx/data/<page-name>
|
||||
(with URL params as query args) and receive the evaluated :data
|
||||
result serialized as SX wire format (text/sx).
|
||||
|
||||
Auth is enforced per-page: the endpoint looks up the page's auth
|
||||
setting and checks it before evaluating the data expression.
|
||||
"""
|
||||
from quart import make_response, request, abort as quart_abort
|
||||
|
||||
async def page_data_view(page_name: str) -> Any:
|
||||
page_def = get_page(service_name, page_name)
|
||||
if page_def is None:
|
||||
quart_abort(404)
|
||||
|
||||
if page_def.data_expr is None:
|
||||
quart_abort(404)
|
||||
|
||||
# Check auth — same enforcement as the page route itself
|
||||
auth_error = await _check_page_auth(page_def.auth)
|
||||
if auth_error is not None:
|
||||
return auth_error
|
||||
|
||||
# Extract URL params from query string
|
||||
url_params = dict(request.args)
|
||||
|
||||
result_sx = await evaluate_page_data(
|
||||
page_def, service_name, url_params=url_params,
|
||||
)
|
||||
|
||||
resp = await make_response(result_sx, 200)
|
||||
resp.content_type = "text/sx; charset=utf-8"
|
||||
return resp
|
||||
|
||||
page_data_view.__name__ = "sx_page_data"
|
||||
page_data_view.__qualname__ = "sx_page_data"
|
||||
|
||||
app.add_url_rule(
|
||||
"/sx/data/<page_name>",
|
||||
endpoint="sx_page_data",
|
||||
view_func=page_data_view,
|
||||
methods=["GET"],
|
||||
)
|
||||
logger.info("Mounted page data endpoint for %s at /sx/data/<page_name>", service_name)
|
||||
|
||||
@@ -2637,6 +2637,42 @@ PLATFORM_ORCHESTRATION_JS = """
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePageData(pageName, params, callback) {
|
||||
// Platform implementation: fetch page data via HTTP from /sx/data/ endpoint.
|
||||
// The spec only knows about resolve-page-data(name, params, callback) —
|
||||
// this function provides the concrete transport.
|
||||
var url = "/sx/data/" + encodeURIComponent(pageName);
|
||||
if (params && !isNil(params)) {
|
||||
var qs = [];
|
||||
var ks = Object.keys(params);
|
||||
for (var i = 0; i < ks.length; i++) {
|
||||
var v = params[ks[i]];
|
||||
if (v !== null && v !== undefined && v !== NIL) {
|
||||
qs.push(encodeURIComponent(ks[i]) + "=" + encodeURIComponent(v));
|
||||
}
|
||||
}
|
||||
if (qs.length) url += "?" + qs.join("&");
|
||||
}
|
||||
var headers = { "SX-Request": "true" };
|
||||
fetch(url, { headers: headers }).then(function(resp) {
|
||||
if (!resp.ok) {
|
||||
logWarn("sx:data resolve failed " + resp.status + " for " + pageName);
|
||||
return;
|
||||
}
|
||||
return resp.text().then(function(text) {
|
||||
try {
|
||||
var exprs = parse(text);
|
||||
var data = exprs.length === 1 ? exprs[0] : {};
|
||||
callback(data || {});
|
||||
} catch (e) {
|
||||
logWarn("sx:data parse error for " + pageName + ": " + (e && e.message ? e.message : e));
|
||||
}
|
||||
});
|
||||
}).catch(function(err) {
|
||||
logWarn("sx:data resolve error for " + pageName + ": " + (err && err.message ? err.message : err));
|
||||
});
|
||||
}
|
||||
|
||||
function urlPathname(href) {
|
||||
try {
|
||||
return new URL(href, location.href).pathname;
|
||||
|
||||
@@ -552,39 +552,66 @@
|
||||
;; No app-specific nav update here — apps handle sx:clientRoute event.
|
||||
|
||||
|
||||
(define swap-rendered-content
|
||||
(fn (target rendered pathname)
|
||||
;; Swap rendered DOM content into target and run post-processing.
|
||||
;; Shared by pure and data page client routes.
|
||||
(do
|
||||
(dom-set-text-content target "")
|
||||
(dom-append target rendered)
|
||||
(hoist-head-elements-full target)
|
||||
(process-elements target)
|
||||
(sx-hydrate-elements target)
|
||||
(dom-dispatch target "sx:clientRoute"
|
||||
(dict "pathname" pathname))
|
||||
(log-info (str "sx:route client " pathname)))))
|
||||
|
||||
|
||||
(define resolve-route-target
|
||||
(fn (target-sel)
|
||||
;; Resolve a target selector to a DOM element, or nil.
|
||||
(if (and target-sel (not (= target-sel "true")))
|
||||
(dom-query target-sel)
|
||||
nil)))
|
||||
|
||||
|
||||
(define try-client-route
|
||||
(fn (pathname target-sel)
|
||||
;; Try to render a page client-side. Returns true if successful, false otherwise.
|
||||
;; target-sel is the CSS selector for the swap target (from sx-boost value).
|
||||
;; Only works for pages without :data dependencies.
|
||||
;; For pure pages: renders immediately. For :data pages: fetches data then renders.
|
||||
(let ((match (find-matching-route pathname _page-routes)))
|
||||
(if (nil? match)
|
||||
(do (log-info (str "sx:route no match (" (len _page-routes) " routes) " pathname)) false)
|
||||
(if (get match "has-data")
|
||||
(do (log-info (str "sx:route server (has data) " pathname)) false)
|
||||
(let ((content-src (get match "content"))
|
||||
(closure (or (get match "closure") {}))
|
||||
(params (get match "params")))
|
||||
(if (or (nil? content-src) (empty? content-src))
|
||||
(do (log-warn (str "sx:route no content for " pathname)) false)
|
||||
(let ((env (merge closure params))
|
||||
(rendered (try-eval-content content-src env)))
|
||||
(if (nil? rendered)
|
||||
(do (log-info (str "sx:route server (eval failed) " pathname)) false)
|
||||
(let ((target (if (and target-sel (not (= target-sel "true")))
|
||||
(dom-query target-sel)
|
||||
nil)))
|
||||
(if (nil? target)
|
||||
(do (log-warn (str "sx:route target not found: " target-sel)) false)
|
||||
(let ((content-src (get match "content"))
|
||||
(closure (or (get match "closure") {}))
|
||||
(params (get match "params"))
|
||||
(page-name (get match "name")))
|
||||
(if (or (nil? content-src) (empty? content-src))
|
||||
(do (log-warn (str "sx:route no content for " pathname)) false)
|
||||
(let ((target (resolve-route-target target-sel)))
|
||||
(if (nil? target)
|
||||
(do (log-warn (str "sx:route target not found: " target-sel)) false)
|
||||
(if (get match "has-data")
|
||||
;; Data page: resolve data asynchronously, then render client-side
|
||||
(do
|
||||
(log-info (str "sx:route client+data " pathname))
|
||||
(resolve-page-data page-name params
|
||||
(fn (data)
|
||||
;; data is a dict — merge into env and render
|
||||
(let ((env (merge closure params data))
|
||||
(rendered (try-eval-content content-src env)))
|
||||
(if (nil? rendered)
|
||||
(log-warn (str "sx:route data eval failed for " pathname))
|
||||
(swap-rendered-content target rendered pathname)))))
|
||||
true)
|
||||
;; Pure page: render immediately
|
||||
(let ((env (merge closure params))
|
||||
(rendered (try-eval-content content-src env)))
|
||||
(if (nil? rendered)
|
||||
(do (log-info (str "sx:route server (eval failed) " pathname)) false)
|
||||
(do
|
||||
(dom-set-text-content target "")
|
||||
(dom-append target rendered)
|
||||
(hoist-head-elements-full target)
|
||||
(process-elements target)
|
||||
(sx-hydrate-elements target)
|
||||
(dom-dispatch target "sx:clientRoute"
|
||||
(dict "pathname" pathname))
|
||||
(log-info (str "sx:route client " pathname))
|
||||
(swap-rendered-content target rendered pathname)
|
||||
true))))))))))))
|
||||
|
||||
|
||||
@@ -893,6 +920,9 @@
|
||||
;; === Client-side routing ===
|
||||
;; (try-eval-content source env) → DOM node or nil (catches eval errors)
|
||||
;; (url-pathname href) → extract pathname from URL string
|
||||
;; (resolve-page-data name params cb) → void; resolves data for a named page.
|
||||
;; Platform decides transport (HTTP, cache, IPC, etc). Calls (cb data-dict)
|
||||
;; when data is available. params is a dict of URL/route parameters.
|
||||
;;
|
||||
;; From boot.sx:
|
||||
;; _page-routes → list of route entries
|
||||
|
||||
@@ -54,3 +54,8 @@
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "data-test-data"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
52
sx/sx/data-test.sx
Normal file
52
sx/sx/data-test.sx
Normal file
@@ -0,0 +1,52 @@
|
||||
;; Data test page — exercises Phase 4 client-side data rendering.
|
||||
;;
|
||||
;; This page has a :data expression. When navigated to:
|
||||
;; - Full page load: server evaluates data + renders content (normal path)
|
||||
;; - Client route: client fetches /sx/data/data-test, parses SX, renders locally
|
||||
;;
|
||||
;; Open browser console and look for "sx:route client+data" to confirm
|
||||
;; client-side rendering happened.
|
||||
|
||||
(defcomp ~data-test-content (&key server-time items phase transport)
|
||||
(div :class "space-y-8"
|
||||
(div :class "border-b border-stone-200 pb-6"
|
||||
(h1 :class "text-2xl font-bold text-stone-900" "Data Test")
|
||||
(p :class "mt-2 text-stone-600"
|
||||
"This page tests the Phase 4 data endpoint. The content you see was "
|
||||
"rendered using data from the server, but the rendering itself may have "
|
||||
"happened client-side."))
|
||||
|
||||
;; Server-provided metadata
|
||||
(div :class "rounded-lg border border-stone-200 bg-white p-6 space-y-3"
|
||||
(h2 :class "text-lg font-semibold text-stone-800" "Data from server")
|
||||
(dl :class "grid grid-cols-2 gap-2 text-sm"
|
||||
(dt :class "font-medium text-stone-600" "Phase")
|
||||
(dd :class "text-stone-900" phase)
|
||||
(dt :class "font-medium text-stone-600" "Transport")
|
||||
(dd :class "text-stone-900" transport)
|
||||
(dt :class "font-medium text-stone-600" "Server time")
|
||||
(dd :class "font-mono text-stone-900" server-time)))
|
||||
|
||||
;; Pipeline steps from data
|
||||
(div :class "space-y-3"
|
||||
(h2 :class "text-lg font-semibold text-stone-800" "Pipeline steps")
|
||||
(div :class "space-y-2"
|
||||
(map-indexed
|
||||
(fn (i item)
|
||||
(div :class "flex items-start gap-3 rounded border border-stone-100 bg-white p-3"
|
||||
(span :class "flex-none rounded-full bg-violet-100 text-violet-700 w-6 h-6 flex items-center justify-center text-xs font-bold"
|
||||
(str (+ i 1)))
|
||||
(div
|
||||
(div :class "font-medium text-stone-900" (get item "label"))
|
||||
(div :class "text-sm text-stone-500" (get item "detail")))))
|
||||
items)))
|
||||
|
||||
;; How to verify
|
||||
(div :class "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2"
|
||||
(p :class "font-semibold text-amber-800" "How to verify client-side rendering")
|
||||
(ol :class "list-decimal list-inside text-amber-700 space-y-1"
|
||||
(li "Open the browser console (F12)")
|
||||
(li "Navigate to this page from another page using a link")
|
||||
(li "Look for: " (code :class "bg-amber-100 px-1 rounded" "sx:route client+data /isomorphism/data-test"))
|
||||
(li "That log line means the data was fetched and rendered client-side")
|
||||
(li "A full page reload will show server-side rendering instead")))))
|
||||
@@ -106,7 +106,8 @@
|
||||
(define isomorphism-nav-items (list
|
||||
(dict :label "Roadmap" :href "/isomorphism/")
|
||||
(dict :label "Bundle Analyzer" :href "/isomorphism/bundle-analyzer")
|
||||
(dict :label "Routing Analyzer" :href "/isomorphism/routing-analyzer")))
|
||||
(dict :label "Routing Analyzer" :href "/isomorphism/routing-analyzer")
|
||||
(dict :label "Data Test" :href "/isomorphism/data-test")))
|
||||
|
||||
(define plans-nav-items (list
|
||||
(dict :label "Reader Macros" :href "/plans/reader-macros"
|
||||
|
||||
@@ -449,6 +449,20 @@
|
||||
:pages pages :total-pages total-pages :client-count client-count
|
||||
:server-count server-count :registry-sample registry-sample))
|
||||
|
||||
(defpage data-test
|
||||
:path "/isomorphism/data-test"
|
||||
:auth :public
|
||||
:layout (:sx-section
|
||||
:section "Isomorphism"
|
||||
:sub-label "Isomorphism"
|
||||
:sub-href "/isomorphism/"
|
||||
:sub-nav (~section-nav :items isomorphism-nav-items :current "Data Test")
|
||||
:selected "Data Test")
|
||||
:data (data-test-data)
|
||||
:content (~data-test-content
|
||||
:server-time server-time :items items
|
||||
:phase phase :transport transport))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Plans section
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
@@ -23,6 +23,7 @@ def _register_sx_helpers() -> None:
|
||||
"bootstrapper-data": _bootstrapper_data,
|
||||
"bundle-analyzer-data": _bundle_analyzer_data,
|
||||
"routing-analyzer-data": _routing_analyzer_data,
|
||||
"data-test-data": _data_test_data,
|
||||
})
|
||||
|
||||
|
||||
@@ -488,3 +489,26 @@ def _event_detail_data(slug: str) -> dict:
|
||||
"event-example": detail.get("example"),
|
||||
"event-demo": sx_call(demo_name) if demo_name else None,
|
||||
}
|
||||
|
||||
|
||||
def _data_test_data() -> dict:
|
||||
"""Return test data for the client-side data rendering test page.
|
||||
|
||||
This exercises the Phase 4 data endpoint: server evaluates this
|
||||
helper, serializes the result as SX, the client fetches and parses
|
||||
it, then renders the page content with these bindings.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return {
|
||||
"server-time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"items": [
|
||||
{"label": "Eval", "detail": "Server evaluates :data expression"},
|
||||
{"label": "Serialize", "detail": "Result serialized as SX wire format"},
|
||||
{"label": "Fetch", "detail": "Client calls resolve-page-data"},
|
||||
{"label": "Parse", "detail": "Client parses SX response to dict"},
|
||||
{"label": "Render", "detail": "Client merges data into env, renders content"},
|
||||
],
|
||||
"phase": "Phase 4 — Client Async & IO Bridge",
|
||||
"transport": "SX wire format (text/sx)",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user