Phase 3: Client-side routing with SX page registry + routing analyzer demo

Add client-side route matching so pure pages (no IO deps) can render
instantly without a server roundtrip. Page metadata serialized as SX
dict literals (not JSON) in <script type="text/sx-pages"> blocks.

- New router.sx spec: route pattern parsing and matching (6 pure functions)
- boot.sx: process page registry using SX parser at startup
- orchestration.sx: intercept boost links for client routing with
  try-first/fallback — client attempts local eval, falls back to server
- helpers.py: _build_pages_sx() serializes defpage metadata as SX
- Routing analyzer demo page showing per-page client/server classification
- 32 tests for Phase 2 IO detection (scan_io_refs, transitive_io_refs,
  compute_all_io_refs, component_pure?) + fallback/ref parity
- 37 tests for Phase 3 router functions + page registry serialization
- Fix bootstrap_py.py _emit_let cell variable initialization bug
- Fix missing primitive aliases (split, length, merge) in bootstrap_py.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 15:47:56 +00:00
parent 631394989c
commit cf5e767510
16 changed files with 2059 additions and 99 deletions

View File

@@ -631,6 +631,7 @@ details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.
<body class="bg-stone-50 text-stone-900">
<script type="text/sx-styles" data-hash="{styles_hash}">{styles_json}</script>
<script type="text/sx" data-components data-hash="{component_hash}">{component_defs}</script>
<script type="text/sx-pages">{pages_sx}</script>
<script type="text/sx" data-mount="body">{page_sx}</script>
<script src="{asset_url}/scripts/sx-browser.js?v={sx_js_hash}"></script>
<script src="{asset_url}/scripts/body.js?v={body_js_hash}"></script>
@@ -638,6 +639,66 @@ details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.
</html>"""
def _build_pages_sx(service: str) -> str:
"""Build SX page registry for client-side routing.
Returns SX dict literals (one per page) parseable by the client's
``parse`` function. Each dict has keys: name, path, auth, has-data,
content, closure.
"""
from .pages import get_all_pages
from .parser import serialize as sx_serialize
pages = get_all_pages(service)
if not pages:
return ""
entries = []
for page_def in pages.values():
content_src = ""
if page_def.content_expr is not None:
try:
content_src = sx_serialize(page_def.content_expr)
except Exception:
pass
auth = page_def.auth if isinstance(page_def.auth, str) else "custom"
has_data = "true" if page_def.data_expr is not None else "false"
# Build closure as SX dict
closure_parts: list[str] = []
for k, v in page_def.closure.items():
if isinstance(v, (str, int, float, bool)):
closure_parts.append(f":{k} {_sx_literal(v)}")
closure_sx = "{" + " ".join(closure_parts) + "}"
entry = (
"{:name " + _sx_literal(page_def.name)
+ " :path " + _sx_literal(page_def.path)
+ " :auth " + _sx_literal(auth)
+ " :has-data " + has_data
+ " :content " + _sx_literal(content_src)
+ " :closure " + closure_sx + "}"
)
entries.append(entry)
return "\n".join(entries)
def _sx_literal(v: object) -> str:
"""Serialize a Python value as an SX literal."""
if v is None:
return "nil"
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, (int, float)):
return str(v)
if isinstance(v, str):
escaped = v.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
return f'"{escaped}"'
return "nil"
def sx_page(ctx: dict, page_sx: str, *,
meta_html: str = "") -> str:
"""Return a minimal HTML shell that boots the page from sx source.
@@ -692,6 +753,14 @@ def sx_page(ctx: dict, page_sx: str, *,
else:
styles_json = _build_style_dict_json()
# Page registry for client-side routing
pages_sx = ""
try:
from quart import current_app
pages_sx = _build_pages_sx(current_app.name)
except Exception:
pass
return _SX_PAGE_TEMPLATE.format(
title=_html_escape(title),
asset_url=asset_url,
@@ -701,6 +770,7 @@ def sx_page(ctx: dict, page_sx: str, *,
component_defs=component_defs,
styles_hash=styles_hash,
styles_json=styles_json,
pages_sx=pages_sx,
page_sx=page_sx,
sx_css=sx_css,
sx_css_classes=sx_css_classes,

View File

@@ -295,6 +295,33 @@
scripts))))
;; --------------------------------------------------------------------------
;; Page registry for client-side routing
;; --------------------------------------------------------------------------
(define _page-routes (list))
(define process-page-scripts
(fn ()
;; Process <script type="text/sx-pages"> tags.
;; Parses SX page registry and builds route entries with parsed patterns.
(let ((scripts (query-page-scripts)))
(for-each
(fn (s)
(when (not (is-processed? s "pages"))
(mark-processed! s "pages")
(let ((text (dom-text-content s)))
(when (and text (not (empty? (trim text))))
(let ((pages (parse text)))
(for-each
(fn (page)
(append! _page-routes
(merge page
{"parsed" (parse-route-pattern (get page "path"))})))
pages))))))
scripts))))
;; --------------------------------------------------------------------------
;; Full boot sequence
;; --------------------------------------------------------------------------
@@ -305,12 +332,14 @@
;; 1. CSS tracking
;; 2. Style dictionary
;; 3. Process scripts (components + mounts)
;; 4. Hydrate [data-sx] elements
;; 5. Process engine elements
;; 4. Process page registry (client-side routing)
;; 5. Hydrate [data-sx] elements
;; 6. Process engine elements
(do
(init-css-tracking)
(init-style-dict)
(process-sx-scripts nil)
(process-page-scripts)
(sx-hydrate-elements nil)
(process-elements nil))))
@@ -354,6 +383,7 @@
;; === Script queries ===
;; (query-sx-scripts root) → list of <script type="text/sx"> elements
;; (query-style-scripts) → list of <script type="text/sx-styles"> elements
;; (query-page-scripts) → list of <script type="text/sx-pages"> elements
;;
;; === localStorage ===
;; (local-storage-get key) → string or nil

View File

@@ -376,6 +376,11 @@ class JSEmitter:
"event-source-listen": "eventSourceListen",
"bind-boost-link": "bindBoostLink",
"bind-boost-form": "bindBoostForm",
"bind-client-route-link": "bindClientRouteLink",
"bind-client-route-click": "bindClientRouteClick",
"try-client-route": "tryClientRoute",
"try-eval-content": "tryEvalContent",
"url-pathname": "urlPathname",
"bind-inline-handler": "bindInlineHandler",
"bind-preload": "bindPreload",
"mark-processed!": "markProcessed",
@@ -490,6 +495,9 @@ class JSEmitter:
"log-info": "logInfo",
"log-parse-error": "logParseError",
"parse-and-load-style-dict": "parseAndLoadStyleDict",
"_page-routes": "_pageRoutes",
"process-page-scripts": "processPageScripts",
"query-page-scripts": "queryPageScripts",
# deps.sx
"scan-refs": "scanRefs",
"scan-refs-walk": "scanRefsWalk",
@@ -513,6 +521,14 @@ class JSEmitter:
"transitive-io-refs": "transitiveIoRefs",
"compute-all-io-refs": "computeAllIoRefs",
"component-pure?": "componentPure_p",
# router.sx
"split-path-segments": "splitPathSegments",
"make-route-segment": "makeRouteSegment",
"parse-route-pattern": "parseRoutePattern",
"match-route-segments": "matchRouteSegments",
"match-route": "matchRoute",
"find-matching-route": "findMatchingRoute",
"for-each-indexed": "forEachIndexed",
}
if name in RENAMES:
return RENAMES[name]
@@ -1026,6 +1042,7 @@ ADAPTER_DEPS = {
SPEC_MODULES = {
"deps": ("deps.sx", "deps (component dependency analysis)"),
"router": ("router.sx", "router (client-side route matching)"),
}
@@ -1170,6 +1187,7 @@ def compile_ref_to_js(
raise ValueError(f"Unknown spec module: {sm!r}. Valid: {', '.join(SPEC_MODULES)}")
spec_mod_set.add(sm)
has_deps = "deps" in spec_mod_set
has_router = "router" in spec_mod_set
# Core files always included, then selected adapters, then spec modules
sx_files = [
@@ -1256,7 +1274,7 @@ def compile_ref_to_js(
parts.append(fixups_js(has_html, has_sx, has_dom))
if has_continuations:
parts.append(CONTINUATIONS_JS)
parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label, has_deps))
parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label, has_deps, has_router))
parts.append(EPILOGUE)
return "\n".join(parts)
@@ -2589,6 +2607,50 @@ PLATFORM_ORCHESTRATION_JS = """
});
}
// --- Client-side route bindings ---
function bindClientRouteClick(link, href, fallbackFn) {
link.addEventListener("click", function(e) {
e.preventDefault();
var pathname = urlPathname(href);
if (tryClientRoute(pathname)) {
try { history.pushState({ sxUrl: href, scrollY: window.scrollY }, "", href); } catch (err) {}
if (typeof window !== "undefined") window.scrollTo(0, 0);
} else {
logInfo("sx:route server " + pathname);
executeRequest(link, { method: "GET", url: href }).then(function() {
try { history.pushState({ sxUrl: href, scrollY: window.scrollY }, "", href); } catch (err) {}
});
}
});
}
function tryEvalContent(source, env) {
try {
var merged = merge(componentEnv);
if (env && !isNil(env)) {
var ks = Object.keys(env);
for (var i = 0; i < ks.length; i++) merged[ks[i]] = env[ks[i]];
}
return sxRenderWithEnv(source, merged);
} catch (e) {
return NIL;
}
}
function urlPathname(href) {
try {
return new URL(href, location.href).pathname;
} catch (e) {
// Fallback: strip query/hash
var idx = href.indexOf("?");
if (idx >= 0) href = href.substring(0, idx);
idx = href.indexOf("#");
if (idx >= 0) href = href.substring(0, idx);
return href;
}
}
// --- Inline handlers ---
function bindInlineHandler(el, eventName, body) {
@@ -2861,6 +2923,12 @@ PLATFORM_BOOT_JS = """
document.querySelectorAll('script[type="text/sx-styles"]'));
}
function queryPageScripts() {
if (!_hasDom) return [];
return Array.prototype.slice.call(
document.querySelectorAll('script[type="text/sx-pages"]'));
}
// --- localStorage ---
function localStorageGet(key) {
@@ -2968,7 +3036,7 @@ def fixups_js(has_html, has_sx, has_dom):
return "\n".join(lines)
def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label, has_deps=False):
def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label, has_deps=False, has_router=False):
# Parser: use compiled sxParse from parser.sx, or inline a minimal fallback
if has_parser:
parser = '''
@@ -3101,6 +3169,11 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has
api_lines.append(' transitiveIoRefs: transitiveIoRefs,')
api_lines.append(' computeAllIoRefs: computeAllIoRefs,')
api_lines.append(' componentPure_p: componentPure_p,')
if has_router:
api_lines.append(' splitPathSegments: splitPathSegments,')
api_lines.append(' parseRoutePattern: parseRoutePattern,')
api_lines.append(' matchRoute: matchRoute,')
api_lines.append(' findMatchingRoute: findMatchingRoute,')
api_lines.append(f' _version: "{version}"')
api_lines.append(' };')

View File

@@ -258,6 +258,13 @@ class PyEmitter:
"transitive-io-refs": "transitive_io_refs",
"compute-all-io-refs": "compute_all_io_refs",
"component-pure?": "component_pure_p",
# router.sx
"split-path-segments": "split_path_segments",
"make-route-segment": "make_route_segment",
"parse-route-pattern": "parse_route_pattern",
"match-route-segments": "match_route_segments",
"match-route": "match_route",
"find-matching-route": "find_matching_route",
}
if name in RENAMES:
return RENAMES[name]
@@ -391,6 +398,9 @@ class PyEmitter:
assignments.append((self._mangle(vname), self.emit(bindings[i + 1])))
# Nested IIFE for sequential let (each binding can see previous ones):
# (lambda a: (lambda b: body)(val_b))(val_a)
# Cell variables (mutated by nested set!) are initialized in _cells dict
# instead of lambda params, since the body reads _cells[name].
cell_vars = getattr(self, '_current_cell_vars', set())
body_parts = [self.emit(b) for b in body]
if len(body) == 1:
body_str = body_parts[0]
@@ -399,7 +409,11 @@ class PyEmitter:
# Build from inside out
result = body_str
for name, val in reversed(assignments):
result = f"(lambda {name}: {result})({val})"
if name in cell_vars:
# Cell var: initialize in _cells dict, not as lambda param
result = f"_sx_begin(_sx_cell_set(_cells, {self._py_string(name)}, {val}), {result})"
else:
result = f"(lambda {name}: {result})({val})"
return result
def _emit_if(self, expr) -> str:
@@ -828,6 +842,7 @@ ADAPTER_FILES = {
SPEC_MODULES = {
"deps": ("deps.sx", "deps (component dependency analysis)"),
"router": ("router.sx", "router (client-side route matching)"),
}
@@ -1947,6 +1962,9 @@ range = PRIMITIVES["range"]
apply = lambda f, args: f(*args)
assoc = PRIMITIVES["assoc"]
concat = PRIMITIVES["concat"]
split = PRIMITIVES["split"]
length = PRIMITIVES["len"]
merge = PRIMITIVES["merge"]
'''

View File

@@ -505,7 +505,7 @@
(dom-set-attr link "sx-swap" "innerHTML"))
(when (not (dom-has-attr? link "sx-push-url"))
(dom-set-attr link "sx-push-url" "true"))
(bind-boost-link link (dom-get-attr link "href"))))
(bind-client-route-link link (dom-get-attr link "href"))))
(dom-query-all container "a[href]"))
(for-each
(fn (form)
@@ -523,6 +523,52 @@
(dom-query-all container "form"))))
;; --------------------------------------------------------------------------
;; Client-side routing
;; --------------------------------------------------------------------------
(define try-client-route
(fn (pathname)
;; Try to render a page client-side. Returns true if successful, false otherwise.
;; Only works for pages without :data dependencies.
(let ((match (find-matching-route pathname _page-routes)))
(if (nil? match)
false
(if (get match "has-data")
false
(let ((content-src (get match "content"))
(closure (or (get match "closure") {}))
(params (get match "params")))
(if (or (nil? content-src) (empty? content-src))
false
(let ((env (merge closure params))
(rendered (try-eval-content content-src env)))
(if (nil? rendered)
false
(let ((target (dom-query-by-id "main-panel")))
(if (nil? target)
false
(do
(dom-set-text-content target "")
(dom-append target rendered)
(hoist-head-elements-full target)
(process-elements target)
(sx-hydrate-elements target)
(log-info (str "sx:route client " pathname))
true))))))))))))
(define bind-client-route-link
(fn (link href)
;; Bind a boost link with client-side routing. If the route can be
;; rendered client-side (pure page, no :data), do so. Otherwise
;; fall back to standard server fetch via bind-boost-link.
(bind-client-route-click link href
(fn ()
;; Fallback: use standard boost link binding
(bind-boost-link link href)))))
;; --------------------------------------------------------------------------
;; SSE processing
;; --------------------------------------------------------------------------
@@ -668,13 +714,17 @@
(define handle-popstate
(fn (scrollY)
;; Handle browser back/forward navigation
;; Handle browser back/forward navigation.
;; Try client-side route first, fall back to server fetch.
(let ((main (dom-query-by-id "main-panel"))
(url (browser-location-href)))
(when main
(let ((headers (build-request-headers main
(loaded-component-names) _css-hash)))
(fetch-and-restore main url headers scrollY))))))
(let ((pathname (url-pathname url)))
(if (try-client-route pathname)
(browser-scroll-to 0 scrollY)
(let ((headers (build-request-headers main
(loaded-component-names) _css-hash)))
(fetch-and-restore main url headers scrollY))))))))
;; --------------------------------------------------------------------------
@@ -773,6 +823,7 @@
;; === Boost bindings ===
;; (bind-boost-link el href) → void (click handler + pushState)
;; (bind-boost-form form method action) → void (submit handler)
;; (bind-client-route-click link href fallback-fn) → void (client route click handler)
;;
;; === Inline handlers ===
;; (bind-inline-handler el event-name body) → void (new Function)
@@ -803,10 +854,22 @@
;; === Parsing ===
;; (try-parse-json s) → parsed value or nil
;;
;; === Client-side routing ===
;; (try-eval-content source env) → DOM node or nil (catches eval errors)
;; (url-pathname href) → extract pathname from URL string
;;
;; From boot.sx:
;; _page-routes → list of route entries
;;
;; From router.sx:
;; (find-matching-route path routes) → matching entry with params, or nil
;; (parse-route-pattern pattern) → parsed pattern segments
;;
;; === Browser (via engine.sx) ===
;; (browser-location-href) → current URL string
;; (browser-navigate url) → void
;; (browser-reload) → void
;; (browser-scroll-to x y) → void
;; (browser-media-matches? query) → boolean
;; (browser-confirm msg) → boolean
;; (browser-prompt msg) → string or nil

126
shared/sx/ref/router.sx Normal file
View File

@@ -0,0 +1,126 @@
;; ==========================================================================
;; router.sx — Client-side route matching specification
;;
;; Pure functions for matching URL paths against Flask-style route patterns.
;; Used by client-side routing to determine if a page can be rendered
;; locally without a server roundtrip.
;;
;; All functions are pure — no IO, no platform-specific operations.
;; Uses only primitives from primitives.sx (string ops, list ops).
;; ==========================================================================
;; --------------------------------------------------------------------------
;; 1. Split path into segments
;; --------------------------------------------------------------------------
;; "/docs/hello" → ("docs" "hello")
;; "/" → ()
;; "/docs/" → ("docs")
(define split-path-segments
(fn (path)
(let ((trimmed (if (starts-with? path "/") (slice path 1) path)))
(let ((trimmed2 (if (and (not (empty? trimmed))
(ends-with? trimmed "/"))
(slice trimmed 0 (- (length trimmed) 1))
trimmed)))
(if (empty? trimmed2)
(list)
(split trimmed2 "/"))))))
;; --------------------------------------------------------------------------
;; 2. Parse Flask-style route pattern into segment descriptors
;; --------------------------------------------------------------------------
;; "/docs/<slug>" → ({"type" "literal" "value" "docs"}
;; {"type" "param" "value" "slug"})
(define make-route-segment
(fn (seg)
(if (and (starts-with? seg "<") (ends-with? seg ">"))
(let ((param-name (slice seg 1 (- (length seg) 1))))
(let ((d {}))
(dict-set! d "type" "param")
(dict-set! d "value" param-name)
d))
(let ((d {}))
(dict-set! d "type" "literal")
(dict-set! d "value" seg)
d))))
(define parse-route-pattern
(fn (pattern)
(let ((segments (split-path-segments pattern)))
(map make-route-segment segments))))
;; --------------------------------------------------------------------------
;; 3. Match path segments against parsed pattern
;; --------------------------------------------------------------------------
;; Returns params dict if match, nil if no match.
(define match-route-segments
(fn (path-segs parsed-segs)
(if (not (= (length path-segs) (length parsed-segs)))
nil
(let ((params {})
(matched true))
(for-each-indexed
(fn (i parsed-seg)
(when matched
(let ((path-seg (nth path-segs i))
(seg-type (get parsed-seg "type")))
(cond
(= seg-type "literal")
(when (not (= path-seg (get parsed-seg "value")))
(set! matched false))
(= seg-type "param")
(dict-set! params (get parsed-seg "value") path-seg)
:else
(set! matched false)))))
parsed-segs)
(if matched params nil)))))
;; --------------------------------------------------------------------------
;; 4. Public API: match a URL path against a pattern string
;; --------------------------------------------------------------------------
;; Returns params dict (may be empty for exact matches) or nil.
(define match-route
(fn (path pattern)
(let ((path-segs (split-path-segments path))
(parsed-segs (parse-route-pattern pattern)))
(match-route-segments path-segs parsed-segs))))
;; --------------------------------------------------------------------------
;; 5. Search a list of route entries for first match
;; --------------------------------------------------------------------------
;; Each entry: {"pattern" "/docs/<slug>" "parsed" [...] "name" "docs-page" ...}
;; Returns matching entry with "params" added, or nil.
(define find-matching-route
(fn (path routes)
(let ((path-segs (split-path-segments path))
(result nil))
(for-each
(fn (route)
(when (nil? result)
(let ((params (match-route-segments path-segs (get route "parsed"))))
(when (not (nil? params))
(let ((matched (merge route {})))
(dict-set! matched "params" params)
(set! result matched))))))
routes)
result)))
;; --------------------------------------------------------------------------
;; Platform interface — none required
;; --------------------------------------------------------------------------
;; All functions use only pure primitives:
;; split, slice, starts-with?, ends-with?, length, empty?,
;; map, for-each, for-each-indexed, nth, get, dict-set!, merge,
;; list, nil?, not, =
;; --------------------------------------------------------------------------

View File

@@ -876,6 +876,9 @@ range = PRIMITIVES["range"]
apply = lambda f, args: f(*args)
assoc = PRIMITIVES["assoc"]
concat = PRIMITIVES["concat"]
split = PRIMITIVES["split"]
length = PRIMITIVES["len"]
merge = PRIMITIVES["merge"]
# =========================================================================
@@ -1237,6 +1240,40 @@ compute_all_io_refs = lambda env, io_names: for_each(lambda name: (lambda val: (
component_pure_p = lambda name, env, io_names: empty_p(transitive_io_refs(name, env, io_names))
# === Transpiled from router (client-side route matching) ===
# split-path-segments
split_path_segments = lambda path: (lambda trimmed: (lambda trimmed2: ([] if sx_truthy(empty_p(trimmed2)) else split(trimmed2, '/')))((slice(trimmed, 0, (length(trimmed) - 1)) if sx_truthy(((not sx_truthy(empty_p(trimmed))) if not sx_truthy((not sx_truthy(empty_p(trimmed)))) else ends_with_p(trimmed, '/'))) else trimmed)))((slice(path, 1) if sx_truthy(starts_with_p(path, '/')) else path))
# make-route-segment
make_route_segment = lambda seg: ((lambda param_name: (lambda d: _sx_begin(_sx_dict_set(d, 'type', 'param'), _sx_dict_set(d, 'value', param_name), d))({}))(slice(seg, 1, (length(seg) - 1))) if sx_truthy((starts_with_p(seg, '<') if not sx_truthy(starts_with_p(seg, '<')) else ends_with_p(seg, '>'))) else (lambda d: _sx_begin(_sx_dict_set(d, 'type', 'literal'), _sx_dict_set(d, 'value', seg), d))({}))
# parse-route-pattern
parse_route_pattern = lambda pattern: (lambda segments: map(make_route_segment, segments))(split_path_segments(pattern))
# match-route-segments
def match_route_segments(path_segs, parsed_segs):
_cells = {}
return (NIL if sx_truthy((not sx_truthy((length(path_segs) == length(parsed_segs))))) else (lambda params: _sx_begin(_sx_cell_set(_cells, 'matched', True), _sx_begin(for_each_indexed(lambda i, parsed_seg: ((lambda path_seg: (lambda seg_type: ((_sx_cell_set(_cells, 'matched', False) if sx_truthy((not sx_truthy((path_seg == get(parsed_seg, 'value'))))) else NIL) if sx_truthy((seg_type == 'literal')) else (_sx_dict_set(params, get(parsed_seg, 'value'), path_seg) if sx_truthy((seg_type == 'param')) else _sx_cell_set(_cells, 'matched', False))))(get(parsed_seg, 'type')))(nth(path_segs, i)) if sx_truthy(_cells['matched']) else NIL), parsed_segs), (params if sx_truthy(_cells['matched']) else NIL))))({}))
# match-route
match_route = lambda path, pattern: (lambda path_segs: (lambda parsed_segs: match_route_segments(path_segs, parsed_segs))(parse_route_pattern(pattern)))(split_path_segments(path))
# find-matching-route
def find_matching_route(path, routes):
_cells = {}
path_segs = split_path_segments(path)
_cells['result'] = NIL
for route in routes:
if sx_truthy(is_nil(_cells['result'])):
params = match_route_segments(path_segs, get(route, 'parsed'))
if sx_truthy((not sx_truthy(is_nil(params)))):
matched = merge(route, {})
matched['params'] = params
_cells['result'] = matched
return _cells['result']
# =========================================================================
# Fixups -- wire up render adapter dispatch
# =========================================================================

View File

@@ -0,0 +1,392 @@
"""Tests for Phase 2 IO detection — component purity analysis.
Tests both the hand-written fallback (deps.py) and the bootstrapped
sx_ref.py implementation of IO reference scanning and transitive
IO classification.
"""
import os
import pytest
from shared.sx.parser import parse_all
from shared.sx.types import Component, Macro, Symbol
from shared.sx.deps import (
_scan_io_refs_fallback,
_transitive_io_refs_fallback,
_compute_all_io_refs_fallback,
compute_all_io_refs,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_env(*sx_sources: str) -> dict:
"""Parse and evaluate component definitions into an env dict."""
from shared.sx.evaluator import _eval, _trampoline
env: dict = {}
for source in sx_sources:
exprs = parse_all(source)
for expr in exprs:
_trampoline(_eval(expr, env))
return env
IO_NAMES = {"fetch-data", "call-action", "app-url", "config", "db-query"}
# ---------------------------------------------------------------------------
# _scan_io_refs_fallback — scan single AST for IO primitives
# ---------------------------------------------------------------------------
class TestScanIoRefs:
def test_no_io_refs(self):
env = make_env('(defcomp ~card (&key title) (div :class "p-4" title))')
comp = env["~card"]
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
assert refs == set()
def test_direct_io_ref(self):
env = make_env('(defcomp ~page (&key) (div (fetch-data "posts")))')
comp = env["~page"]
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
assert refs == {"fetch-data"}
def test_multiple_io_refs(self):
env = make_env(
'(defcomp ~page (&key) (div (fetch-data "x") (config "y")))'
)
comp = env["~page"]
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
assert refs == {"fetch-data", "config"}
def test_io_in_nested_control_flow(self):
env = make_env(
'(defcomp ~page (&key show) '
' (if show (div (app-url "/")) (span "none")))'
)
comp = env["~page"]
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
assert refs == {"app-url"}
def test_io_in_dict_value(self):
env = make_env(
'(defcomp ~wrap (&key) (div {:data (db-query "x")}))'
)
comp = env["~wrap"]
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
assert refs == {"db-query"}
def test_non_io_symbol_ignored(self):
"""Symbols that aren't in the IO set should not be detected."""
env = make_env(
'(defcomp ~card (&key) (div (str "hello") (len "world")))'
)
comp = env["~card"]
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
assert refs == set()
def test_component_ref_not_io(self):
"""Component references (~name) should not appear as IO refs."""
env = make_env(
'(defcomp ~page (&key) (div (~card :title "hi")))',
'(defcomp ~card (&key title) (div title))',
)
comp = env["~page"]
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
assert refs == set()
# ---------------------------------------------------------------------------
# _transitive_io_refs_fallback — follow deps to find all IO refs
# ---------------------------------------------------------------------------
class TestTransitiveIoRefs:
def test_pure_component(self):
env = make_env(
'(defcomp ~card (&key title) (div title))',
)
refs = _transitive_io_refs_fallback("~card", env, IO_NAMES)
assert refs == set()
def test_direct_io(self):
env = make_env(
'(defcomp ~page (&key) (div (fetch-data "posts")))',
)
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
assert refs == {"fetch-data"}
def test_transitive_io_through_dep(self):
"""IO ref in a dependency should propagate to the parent."""
env = make_env(
'(defcomp ~page (&key) (div (~nav)))',
'(defcomp ~nav (&key) (nav (app-url "/home")))',
)
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
assert refs == {"app-url"}
def test_multiple_transitive_io(self):
"""IO refs from multiple deps should be unioned."""
env = make_env(
'(defcomp ~page (&key) (div (~header) (~footer)))',
'(defcomp ~header (&key) (nav (app-url "/")))',
'(defcomp ~footer (&key) (footer (config "site-name")))',
)
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
assert refs == {"app-url", "config"}
def test_deep_transitive_io(self):
"""IO refs should propagate through multiple levels."""
env = make_env(
'(defcomp ~page (&key) (div (~layout)))',
'(defcomp ~layout (&key) (div (~sidebar)))',
'(defcomp ~sidebar (&key) (nav (fetch-data "menu")))',
)
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
assert refs == {"fetch-data"}
def test_circular_deps_no_infinite_loop(self):
"""Circular component references should not cause infinite recursion."""
env = make_env(
'(defcomp ~a (&key) (div (~b) (app-url "/")))',
'(defcomp ~b (&key) (div (~a)))',
)
refs = _transitive_io_refs_fallback("~a", env, IO_NAMES)
assert refs == {"app-url"}
def test_without_tilde_prefix(self):
"""Should auto-add ~ prefix when not provided."""
env = make_env(
'(defcomp ~nav (&key) (nav (app-url "/")))',
)
refs = _transitive_io_refs_fallback("nav", env, IO_NAMES)
assert refs == {"app-url"}
def test_missing_dep_component(self):
"""Referencing a component not in env should not crash."""
env = make_env(
'(defcomp ~page (&key) (div (~unknown) (fetch-data "x")))',
)
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
assert refs == {"fetch-data"}
def test_macro_io_detection(self):
"""IO refs in macros should be detected too."""
env = make_env(
'(defmacro ~with-data (body) (list (quote div) (list (quote fetch-data) "x") body))',
'(defcomp ~page (&key) (div (~with-data (span "hi"))))',
)
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
assert "fetch-data" in refs
# ---------------------------------------------------------------------------
# _compute_all_io_refs_fallback — batch computation
# ---------------------------------------------------------------------------
class TestComputeAllIoRefs:
def test_sets_io_refs_on_components(self):
env = make_env(
'(defcomp ~page (&key) (div (~nav) (fetch-data "x")))',
'(defcomp ~nav (&key) (nav (app-url "/")))',
'(defcomp ~card (&key title) (div title))',
)
_compute_all_io_refs_fallback(env, IO_NAMES)
assert env["~page"].io_refs == {"fetch-data", "app-url"}
assert env["~nav"].io_refs == {"app-url"}
assert env["~card"].io_refs == set()
def test_pure_components_get_empty_set(self):
env = make_env(
'(defcomp ~a (&key) (div "hello"))',
'(defcomp ~b (&key) (span "world"))',
)
_compute_all_io_refs_fallback(env, IO_NAMES)
assert env["~a"].io_refs == set()
assert env["~b"].io_refs == set()
def test_transitive_io_via_compute_all(self):
"""Transitive IO refs should be cached on the parent component."""
env = make_env(
'(defcomp ~page (&key) (div (~child)))',
'(defcomp ~child (&key) (div (config "key")))',
)
_compute_all_io_refs_fallback(env, IO_NAMES)
assert env["~page"].io_refs == {"config"}
assert env["~child"].io_refs == {"config"}
# ---------------------------------------------------------------------------
# Public API dispatch — compute_all_io_refs
# ---------------------------------------------------------------------------
class TestPublicApiIoRefs:
def test_fallback_mode(self):
"""Public API should work in fallback mode (SX_USE_REF != 1)."""
env = make_env(
'(defcomp ~page (&key) (div (fetch-data "x")))',
'(defcomp ~leaf (&key) (span "pure"))',
)
old_val = os.environ.get("SX_USE_REF")
try:
os.environ.pop("SX_USE_REF", None)
compute_all_io_refs(env, IO_NAMES)
assert env["~page"].io_refs == {"fetch-data"}
assert env["~leaf"].io_refs == set()
finally:
if old_val is not None:
os.environ["SX_USE_REF"] = old_val
def test_ref_mode(self):
"""Public API should work with bootstrapped sx_ref.py (SX_USE_REF=1)."""
env = make_env(
'(defcomp ~page (&key) (div (fetch-data "x")))',
'(defcomp ~leaf (&key) (span "pure"))',
)
old_val = os.environ.get("SX_USE_REF")
try:
os.environ["SX_USE_REF"] = "1"
compute_all_io_refs(env, IO_NAMES)
# sx_ref returns lists, compute_all_io_refs converts as needed
page_refs = env["~page"].io_refs
leaf_refs = env["~leaf"].io_refs
# May be list or set depending on backend
assert "fetch-data" in page_refs
assert len(leaf_refs) == 0
finally:
if old_val is not None:
os.environ["SX_USE_REF"] = old_val
else:
os.environ.pop("SX_USE_REF", None)
# ---------------------------------------------------------------------------
# Bootstrapped sx_ref.py IO functions — direct testing
# ---------------------------------------------------------------------------
class TestSxRefIoFunctions:
"""Test the bootstrapped sx_ref.py IO functions directly."""
def test_scan_io_refs(self):
from shared.sx.ref.sx_ref import scan_io_refs
env = make_env('(defcomp ~page (&key) (div (fetch-data "x") (config "y")))')
comp = env["~page"]
refs = scan_io_refs(comp.body, list(IO_NAMES))
assert set(refs) == {"fetch-data", "config"}
def test_scan_io_refs_no_match(self):
from shared.sx.ref.sx_ref import scan_io_refs
env = make_env('(defcomp ~card (&key title) (div title))')
comp = env["~card"]
refs = scan_io_refs(comp.body, list(IO_NAMES))
assert refs == []
def test_transitive_io_refs(self):
from shared.sx.ref.sx_ref import transitive_io_refs
env = make_env(
'(defcomp ~page (&key) (div (~nav)))',
'(defcomp ~nav (&key) (nav (app-url "/")))',
)
refs = transitive_io_refs("~page", env, list(IO_NAMES))
assert set(refs) == {"app-url"}
def test_transitive_io_refs_pure(self):
from shared.sx.ref.sx_ref import transitive_io_refs
env = make_env('(defcomp ~card (&key) (div "hi"))')
refs = transitive_io_refs("~card", env, list(IO_NAMES))
assert refs == []
def test_compute_all_io_refs(self):
from shared.sx.ref.sx_ref import compute_all_io_refs as ref_compute
env = make_env(
'(defcomp ~page (&key) (div (~nav) (fetch-data "x")))',
'(defcomp ~nav (&key) (nav (app-url "/")))',
'(defcomp ~card (&key) (div "pure"))',
)
ref_compute(env, list(IO_NAMES))
page_refs = env["~page"].io_refs
nav_refs = env["~nav"].io_refs
card_refs = env["~card"].io_refs
assert "fetch-data" in page_refs
assert "app-url" in page_refs
assert "app-url" in nav_refs
assert len(card_refs) == 0
def test_component_pure_p(self):
from shared.sx.ref.sx_ref import component_pure_p
env = make_env(
'(defcomp ~pure-card (&key) (div "hello"))',
'(defcomp ~io-card (&key) (div (fetch-data "x")))',
)
io_list = list(IO_NAMES)
assert component_pure_p("~pure-card", env, io_list) is True
assert component_pure_p("~io-card", env, io_list) is False
def test_component_pure_p_transitive(self):
"""A component is impure if any transitive dep uses IO."""
from shared.sx.ref.sx_ref import component_pure_p
env = make_env(
'(defcomp ~page (&key) (div (~child)))',
'(defcomp ~child (&key) (div (config "key")))',
)
io_list = list(IO_NAMES)
assert component_pure_p("~page", env, io_list) is False
assert component_pure_p("~child", env, io_list) is False
# ---------------------------------------------------------------------------
# Parity: fallback vs bootstrapped produce same results
# ---------------------------------------------------------------------------
class TestFallbackVsRefParity:
"""Ensure fallback Python and bootstrapped sx_ref.py agree."""
def _check_parity(self, *sx_sources: str):
"""Run both implementations and verify io_refs match."""
from shared.sx.ref.sx_ref import compute_all_io_refs as ref_compute
# Run fallback
env_fb = make_env(*sx_sources)
_compute_all_io_refs_fallback(env_fb, IO_NAMES)
# Run bootstrapped
env_ref = make_env(*sx_sources)
ref_compute(env_ref, list(IO_NAMES))
# Compare all components
for key in env_fb:
if isinstance(env_fb[key], Component):
fb_refs = env_fb[key].io_refs or set()
ref_refs = env_ref[key].io_refs
# Normalize: fallback returns set, ref returns list/set
assert set(fb_refs) == set(ref_refs), (
f"Mismatch for {key}: fallback={fb_refs}, ref={set(ref_refs)}"
)
def test_parity_pure_components(self):
self._check_parity(
'(defcomp ~a (&key) (div "hello"))',
'(defcomp ~b (&key) (span (~a)))',
)
def test_parity_io_components(self):
self._check_parity(
'(defcomp ~page (&key) (div (~header) (fetch-data "x")))',
'(defcomp ~header (&key) (nav (app-url "/")))',
'(defcomp ~footer (&key) (footer "static"))',
)
def test_parity_deep_chain(self):
self._check_parity(
'(defcomp ~a (&key) (div (~b)))',
'(defcomp ~b (&key) (div (~c)))',
'(defcomp ~c (&key) (div (config "x")))',
)
def test_parity_mixed(self):
self._check_parity(
'(defcomp ~layout (&key) (div (~nav) (~content) (~footer)))',
'(defcomp ~nav (&key) (nav (app-url "/")))',
'(defcomp ~content (&key) (main "pure content"))',
'(defcomp ~footer (&key) (footer (config "name")))',
)

View File

@@ -0,0 +1,300 @@
"""Tests for the router.sx spec — client-side route matching.
Tests the bootstrapped Python router functions (from sx_ref.py) and
the SX page registry serialization (from helpers.py).
"""
import pytest
from shared.sx.ref import sx_ref
# ---------------------------------------------------------------------------
# split-path-segments
# ---------------------------------------------------------------------------
class TestSplitPathSegments:
def test_simple(self):
assert sx_ref.split_path_segments("/docs/hello") == ["docs", "hello"]
def test_root(self):
assert sx_ref.split_path_segments("/") == []
def test_trailing_slash(self):
assert sx_ref.split_path_segments("/docs/") == ["docs"]
def test_no_leading_slash(self):
assert sx_ref.split_path_segments("docs/hello") == ["docs", "hello"]
def test_single_segment(self):
assert sx_ref.split_path_segments("/about") == ["about"]
def test_deep_path(self):
assert sx_ref.split_path_segments("/a/b/c/d") == ["a", "b", "c", "d"]
def test_empty(self):
assert sx_ref.split_path_segments("") == []
# ---------------------------------------------------------------------------
# parse-route-pattern
# ---------------------------------------------------------------------------
class TestParseRoutePattern:
def test_literal_only(self):
result = sx_ref.parse_route_pattern("/docs/")
assert len(result) == 1
assert result[0]["type"] == "literal"
assert result[0]["value"] == "docs"
def test_param(self):
result = sx_ref.parse_route_pattern("/docs/<slug>")
assert len(result) == 2
assert result[0] == {"type": "literal", "value": "docs"}
assert result[1] == {"type": "param", "value": "slug"}
def test_multiple_params(self):
result = sx_ref.parse_route_pattern("/users/<uid>/posts/<pid>")
assert len(result) == 4
assert result[0]["type"] == "literal"
assert result[1] == {"type": "param", "value": "uid"}
assert result[2]["type"] == "literal"
assert result[3] == {"type": "param", "value": "pid"}
def test_root_pattern(self):
result = sx_ref.parse_route_pattern("/")
assert result == []
# ---------------------------------------------------------------------------
# match-route
# ---------------------------------------------------------------------------
class TestMatchRoute:
def test_exact_match(self):
params = sx_ref.match_route("/docs/", "/docs/")
assert params is not None
assert params == {}
def test_param_match(self):
params = sx_ref.match_route("/docs/components", "/docs/<slug>")
assert params is not None
assert params == {"slug": "components"}
def test_no_match_different_length(self):
result = sx_ref.match_route("/docs/a/b", "/docs/<slug>")
assert result is sx_ref.NIL or result is None
def test_no_match_literal_mismatch(self):
result = sx_ref.match_route("/api/hello", "/docs/<slug>")
assert result is sx_ref.NIL or result is None
def test_root_match(self):
params = sx_ref.match_route("/", "/")
assert params is not None
assert params == {}
def test_multiple_params(self):
params = sx_ref.match_route("/users/42/posts/7", "/users/<uid>/posts/<pid>")
assert params is not None
assert params == {"uid": "42", "pid": "7"}
# ---------------------------------------------------------------------------
# find-matching-route
# ---------------------------------------------------------------------------
class TestFindMatchingRoute:
def _make_routes(self, patterns):
"""Build route entries like boot.sx does — with parsed patterns."""
routes = []
for name, pattern in patterns:
route = {
"name": name,
"path": pattern,
"parsed": sx_ref.parse_route_pattern(pattern),
"has-data": False,
"content": "(div \"test\")",
}
routes.append(route)
return routes
def test_first_match(self):
routes = self._make_routes([
("home", "/"),
("docs-index", "/docs/"),
("docs-page", "/docs/<slug>"),
])
match = sx_ref.find_matching_route("/docs/components", routes)
assert match is not None
assert match["name"] == "docs-page"
assert match["params"] == {"slug": "components"}
def test_exact_before_param(self):
routes = self._make_routes([
("docs-index", "/docs/"),
("docs-page", "/docs/<slug>"),
])
match = sx_ref.find_matching_route("/docs/", routes)
assert match is not None
assert match["name"] == "docs-index"
def test_no_match(self):
routes = self._make_routes([
("home", "/"),
("docs-page", "/docs/<slug>"),
])
result = sx_ref.find_matching_route("/unknown/path", routes)
assert result is sx_ref.NIL or result is None
def test_root_match(self):
routes = self._make_routes([
("home", "/"),
("about", "/about"),
])
match = sx_ref.find_matching_route("/", routes)
assert match is not None
assert match["name"] == "home"
def test_params_not_on_original(self):
"""find-matching-route should not mutate the original route entry."""
routes = self._make_routes([("page", "/docs/<slug>")])
match = sx_ref.find_matching_route("/docs/test", routes)
assert match["params"] == {"slug": "test"}
# Original should not have params key
assert "params" not in routes[0]
# ---------------------------------------------------------------------------
# Page registry SX serialization
# ---------------------------------------------------------------------------
class TestBuildPagesSx:
"""Test the SX page registry format — serialize + parse round-trip."""
def test_round_trip_simple(self):
"""SX dict literal round-trips through serialize → parse."""
from shared.sx.helpers import _sx_literal
from shared.sx.parser import parse_all
# Build an SX dict literal like _build_pages_sx does
entry = (
"{:name " + _sx_literal("home")
+ " :path " + _sx_literal("/")
+ " :auth " + _sx_literal("public")
+ " :has-data false"
+ " :content " + _sx_literal("(~home-content)")
+ " :closure {}}"
)
parsed = parse_all(entry)
assert len(parsed) == 1
d = parsed[0]
assert d["name"] == "home"
assert d["path"] == "/"
assert d["auth"] == "public"
assert d["has-data"] is False
assert d["content"] == "(~home-content)"
assert d["closure"] == {}
def test_round_trip_multiple(self):
"""Multiple SX dict literals parse as a list."""
from shared.sx.helpers import _sx_literal
from shared.sx.parser import parse_all
entries = []
for name, path in [("home", "/"), ("docs", "/docs/<slug>")]:
entry = (
"{:name " + _sx_literal(name)
+ " :path " + _sx_literal(path)
+ " :has-data false"
+ " :content " + _sx_literal("(div)")
+ " :closure {}}"
)
entries.append(entry)
text = "\n".join(entries)
parsed = parse_all(text)
assert len(parsed) == 2
assert parsed[0]["name"] == "home"
assert parsed[1]["name"] == "docs"
assert parsed[1]["path"] == "/docs/<slug>"
def test_content_with_quotes(self):
"""Content expressions with quotes survive serialization."""
from shared.sx.helpers import _sx_literal
from shared.sx.parser import parse_all
content = '(~doc-page :title "Hello \\"World\\"")'
entry = (
"{:name " + _sx_literal("test")
+ " :content " + _sx_literal(content)
+ " :closure {}}"
)
parsed = parse_all(entry)
assert parsed[0]["content"] == content
def test_closure_with_values(self):
"""Closure dict with various value types."""
from shared.sx.helpers import _sx_literal
from shared.sx.parser import parse_all
entry = '{:name "test" :closure {:label "hello" :count 42 :active true}}'
parsed = parse_all(entry)
closure = parsed[0]["closure"]
assert closure["label"] == "hello"
assert closure["count"] == 42
assert closure["active"] is True
def test_has_data_true(self):
"""has-data true marks server-only pages."""
from shared.sx.parser import parse_all
entry = '{:name "analyzer" :path "/data" :has-data true :content "" :closure {}}'
parsed = parse_all(entry)
assert parsed[0]["has-data"] is True
# ---------------------------------------------------------------------------
# _sx_literal helper
# ---------------------------------------------------------------------------
class TestSxLiteral:
def test_string(self):
from shared.sx.helpers import _sx_literal
assert _sx_literal("hello") == '"hello"'
def test_string_with_quotes(self):
from shared.sx.helpers import _sx_literal
assert _sx_literal('say "hi"') == '"say \\"hi\\""'
def test_string_with_newline(self):
from shared.sx.helpers import _sx_literal
assert _sx_literal("line1\nline2") == '"line1\\nline2"'
def test_string_with_backslash(self):
from shared.sx.helpers import _sx_literal
assert _sx_literal("a\\b") == '"a\\\\b"'
def test_int(self):
from shared.sx.helpers import _sx_literal
assert _sx_literal(42) == "42"
def test_float(self):
from shared.sx.helpers import _sx_literal
assert _sx_literal(3.14) == "3.14"
def test_bool_true(self):
from shared.sx.helpers import _sx_literal
assert _sx_literal(True) == "true"
def test_bool_false(self):
from shared.sx.helpers import _sx_literal
assert _sx_literal(False) == "false"
def test_none(self):
from shared.sx.helpers import _sx_literal
assert _sx_literal(None) == "nil"
def test_empty_string(self):
from shared.sx.helpers import _sx_literal
assert _sx_literal("") == '""'