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 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 isNil(x) { return x === NIL || x === null || x === undefined; }
|
||||||
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
||||||
@@ -1930,22 +1930,33 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\"
|
|||||||
})()) : NIL); }, domQueryAll(container, "form"));
|
})()) : 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
|
// try-client-route
|
||||||
var tryClientRoute = function(pathname, targetSel) { return (function() {
|
var tryClientRoute = function(pathname, targetSel) { return (function() {
|
||||||
var match = findMatchingRoute(pathname, _pageRoutes);
|
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 contentSrc = get(match, "content");
|
||||||
var closure = sxOr(get(match, "closure"), {});
|
var closure = sxOr(get(match, "closure"), {});
|
||||||
var params = get(match, "params");
|
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() {
|
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 env = merge(closure, params);
|
||||||
var rendered = tryEvalContent(contentSrc, env);
|
var rendered = tryEvalContent(contentSrc, env);
|
||||||
return (isSxTruthy(isNil(rendered)) ? (logInfo((String("sx:route server (eval failed) ") + String(pathname))), false) : (function() {
|
return (isSxTruthy(isNil(rendered)) ? (logInfo((String("sx:route server (eval failed) ") + String(pathname))), false) : (swapRenderedContent(target, rendered, pathname), true));
|
||||||
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));
|
|
||||||
})());
|
|
||||||
})());
|
|
||||||
})()));
|
})()));
|
||||||
|
})());
|
||||||
|
})());
|
||||||
})(); };
|
})(); };
|
||||||
|
|
||||||
// bind-client-route-link
|
// 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)); };
|
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)
|
// 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) {
|
function urlPathname(href) {
|
||||||
try {
|
try {
|
||||||
return new URL(href, location.href).pathname;
|
return new URL(href, location.href).pathname;
|
||||||
@@ -3577,10 +3554,6 @@ callExpr.push(dictGet(kwargs, k)); } }
|
|||||||
renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null,
|
renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null,
|
||||||
getEnv: function() { return componentEnv; },
|
getEnv: function() { return componentEnv; },
|
||||||
init: typeof bootInit === "function" ? bootInit : null,
|
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)"
|
_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"
|
has_data = "true" if page_def.data_expr is not None else "false"
|
||||||
|
|
||||||
# Component deps needed to render this page client-side
|
# 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()
|
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 = components_needed(content_src, _COMPONENT_ENV)
|
||||||
deps_sx = "(" + " ".join(_sx_literal(d) for d in sorted(deps)) + ")"
|
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).
|
Pages must have absolute paths (from the service URL root).
|
||||||
Called once per service in app.py after setup_*_pages().
|
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)
|
pages = get_all_pages(service_name)
|
||||||
for page_def in pages.values():
|
for page_def in pages.values():
|
||||||
_mount_one_page(app, service_name, page_def)
|
_mount_one_page(app, service_name, page_def)
|
||||||
logger.info("Auto-mounted %d defpages for %s", len(pages), service_name)
|
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,
|
def mount_pages(bp: Any, service_name: str,
|
||||||
names: set[str] | list[str] | None = None) -> None:
|
names: set[str] | list[str] | None = None) -> None:
|
||||||
@@ -405,3 +412,126 @@ def _apply_cache(fn: Any, cache: dict) -> Any:
|
|||||||
tag = cache.get("tag")
|
tag = cache.get("tag")
|
||||||
scope = cache.get("scope", "user")
|
scope = cache.get("scope", "user")
|
||||||
return cache_page(ttl=ttl, tag=tag, scope=scope)(fn)
|
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) {
|
function urlPathname(href) {
|
||||||
try {
|
try {
|
||||||
return new URL(href, location.href).pathname;
|
return new URL(href, location.href).pathname;
|
||||||
|
|||||||
@@ -552,39 +552,66 @@
|
|||||||
;; No app-specific nav update here — apps handle sx:clientRoute event.
|
;; 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
|
(define try-client-route
|
||||||
(fn (pathname target-sel)
|
(fn (pathname target-sel)
|
||||||
;; Try to render a page client-side. Returns true if successful, false otherwise.
|
;; 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).
|
;; 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)))
|
(let ((match (find-matching-route pathname _page-routes)))
|
||||||
(if (nil? match)
|
(if (nil? match)
|
||||||
(do (log-info (str "sx:route no match (" (len _page-routes) " routes) " pathname)) false)
|
(do (log-info (str "sx:route no match (" (len _page-routes) " routes) " pathname)) false)
|
||||||
(if (get match "has-data")
|
(let ((content-src (get match "content"))
|
||||||
(do (log-info (str "sx:route server (has data) " pathname)) false)
|
(closure (or (get match "closure") {}))
|
||||||
(let ((content-src (get match "content"))
|
(params (get match "params"))
|
||||||
(closure (or (get match "closure") {}))
|
(page-name (get match "name")))
|
||||||
(params (get match "params")))
|
(if (or (nil? content-src) (empty? content-src))
|
||||||
(if (or (nil? content-src) (empty? content-src))
|
(do (log-warn (str "sx:route no content for " pathname)) false)
|
||||||
(do (log-warn (str "sx:route no content for " pathname)) false)
|
(let ((target (resolve-route-target target-sel)))
|
||||||
(let ((env (merge closure params))
|
(if (nil? target)
|
||||||
(rendered (try-eval-content content-src env)))
|
(do (log-warn (str "sx:route target not found: " target-sel)) false)
|
||||||
(if (nil? rendered)
|
(if (get match "has-data")
|
||||||
(do (log-info (str "sx:route server (eval failed) " pathname)) false)
|
;; Data page: resolve data asynchronously, then render client-side
|
||||||
(let ((target (if (and target-sel (not (= target-sel "true")))
|
(do
|
||||||
(dom-query target-sel)
|
(log-info (str "sx:route client+data " pathname))
|
||||||
nil)))
|
(resolve-page-data page-name params
|
||||||
(if (nil? target)
|
(fn (data)
|
||||||
(do (log-warn (str "sx:route target not found: " target-sel)) false)
|
;; 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
|
(do
|
||||||
(dom-set-text-content target "")
|
(swap-rendered-content target rendered pathname)
|
||||||
(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))
|
|
||||||
true))))))))))))
|
true))))))))))))
|
||||||
|
|
||||||
|
|
||||||
@@ -893,6 +920,9 @@
|
|||||||
;; === Client-side routing ===
|
;; === Client-side routing ===
|
||||||
;; (try-eval-content source env) → DOM node or nil (catches eval errors)
|
;; (try-eval-content source env) → DOM node or nil (catches eval errors)
|
||||||
;; (url-pathname href) → extract pathname from URL string
|
;; (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:
|
;; From boot.sx:
|
||||||
;; _page-routes → list of route entries
|
;; _page-routes → list of route entries
|
||||||
|
|||||||
@@ -54,3 +54,8 @@
|
|||||||
:params ()
|
:params ()
|
||||||
:returns "dict"
|
:returns "dict"
|
||||||
:service "sx")
|
: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
|
(define isomorphism-nav-items (list
|
||||||
(dict :label "Roadmap" :href "/isomorphism/")
|
(dict :label "Roadmap" :href "/isomorphism/")
|
||||||
(dict :label "Bundle Analyzer" :href "/isomorphism/bundle-analyzer")
|
(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
|
(define plans-nav-items (list
|
||||||
(dict :label "Reader Macros" :href "/plans/reader-macros"
|
(dict :label "Reader Macros" :href "/plans/reader-macros"
|
||||||
|
|||||||
@@ -449,6 +449,20 @@
|
|||||||
:pages pages :total-pages total-pages :client-count client-count
|
:pages pages :total-pages total-pages :client-count client-count
|
||||||
:server-count server-count :registry-sample registry-sample))
|
: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
|
;; Plans section
|
||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ def _register_sx_helpers() -> None:
|
|||||||
"bootstrapper-data": _bootstrapper_data,
|
"bootstrapper-data": _bootstrapper_data,
|
||||||
"bundle-analyzer-data": _bundle_analyzer_data,
|
"bundle-analyzer-data": _bundle_analyzer_data,
|
||||||
"routing-analyzer-data": _routing_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-example": detail.get("example"),
|
||||||
"event-demo": sx_call(demo_name) if demo_name else None,
|
"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