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

@@ -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"]
'''