diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index a3e64f2..07d007c 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -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)" }; diff --git a/shared/sx/helpers.py b/shared/sx/helpers.py index 6a331b0..922dc7c 100644 --- a/shared/sx/helpers.py +++ b/shared/sx/helpers.py @@ -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)) + ")" diff --git a/shared/sx/pages.py b/shared/sx/pages.py index 2c1ee1a..0f39e7e 100644 --- a/shared/sx/pages.py +++ b/shared/sx/pages.py @@ -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/ + (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/", + endpoint="sx_page_data", + view_func=page_data_view, + methods=["GET"], + ) + logger.info("Mounted page data endpoint for %s at /sx/data/", service_name) diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index 9d54d58..8936396 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -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; diff --git a/shared/sx/ref/orchestration.sx b/shared/sx/ref/orchestration.sx index c52c6f7..8496904 100644 --- a/shared/sx/ref/orchestration.sx +++ b/shared/sx/ref/orchestration.sx @@ -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 diff --git a/sx/sx/boundary.sx b/sx/sx/boundary.sx index f22395f..3f0f5e0 100644 --- a/sx/sx/boundary.sx +++ b/sx/sx/boundary.sx @@ -54,3 +54,8 @@ :params () :returns "dict" :service "sx") + +(define-page-helper "data-test-data" + :params () + :returns "dict" + :service "sx") diff --git a/sx/sx/data-test.sx b/sx/sx/data-test.sx new file mode 100644 index 0000000..b890cdd --- /dev/null +++ b/sx/sx/data-test.sx @@ -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"))))) diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index 874ac09..88af17f 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -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" diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx index 582a639..15a8bd6 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -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 ;; --------------------------------------------------------------------------- diff --git a/sx/sxc/pages/helpers.py b/sx/sxc/pages/helpers.py index 2bd80fe..3c40544 100644 --- a/sx/sxc/pages/helpers.py +++ b/sx/sxc/pages/helpers.py @@ -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)", + }