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

@@ -100,11 +100,13 @@
(dict :label "CSSX" :href "/specs/cssx")
(dict :label "Continuations" :href "/specs/continuations")
(dict :label "call/cc" :href "/specs/callcc")
(dict :label "Deps" :href "/specs/deps")))
(dict :label "Deps" :href "/specs/deps")
(dict :label "Router" :href "/specs/router")))
(define isomorphism-nav-items (list
(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")))
(define plans-nav-items (list
(dict :label "Reader Macros" :href "/plans/reader-macros"
@@ -175,7 +177,10 @@
(define module-spec-items (list
(dict :slug "deps" :filename "deps.sx" :title "Deps"
:desc "Component dependency analysis and IO detection — per-page bundling, transitive closure, CSS scoping, pure/IO classification."
:prose "The deps module analyzes component dependency graphs and classifies components as pure or IO-dependent. Phase 1 (bundling): walks component AST bodies to find transitive ~component references, computes the minimal set needed per page, and collects per-page CSS classes from only the used components. Phase 2 (IO detection): scans component ASTs for references to IO primitive names (from boundary.sx declarations — frag, query, service, current-user, highlight, etc.), computes transitive IO refs through the component graph, and caches the result on each component. Components with no transitive IO refs are pure — they can render anywhere without server data. IO-dependent components must expand server-side. The spec provides the classification; each host's async partial evaluator acts on it (expand IO-dependent server-side, serialize pure for client). All functions are pure — each host bootstraps them to native code via --spec-modules deps. Platform functions (component-deps, component-set-deps!, component-css-classes, component-io-refs, component-set-io-refs!, env-components, regex-find-all, scan-css-classes) are implemented natively per target.")))
:prose "The deps module analyzes component dependency graphs and classifies components as pure or IO-dependent. Phase 1 (bundling): walks component AST bodies to find transitive ~component references, computes the minimal set needed per page, and collects per-page CSS classes from only the used components. Phase 2 (IO detection): scans component ASTs for references to IO primitive names (from boundary.sx declarations — frag, query, service, current-user, highlight, etc.), computes transitive IO refs through the component graph, and caches the result on each component. Components with no transitive IO refs are pure — they can render anywhere without server data. IO-dependent components must expand server-side. The spec provides the classification; each host's async partial evaluator acts on it (expand IO-dependent server-side, serialize pure for client). All functions are pure — each host bootstraps them to native code via --spec-modules deps. Platform functions (component-deps, component-set-deps!, component-css-classes, component-io-refs, component-set-io-refs!, env-components, regex-find-all, scan-css-classes) are implemented natively per target.")
(dict :slug "router" :filename "router.sx" :title "Router"
:desc "Client-side route matching — Flask-style pattern parsing, segment matching, route table search."
:prose "The router module provides pure functions for matching URL paths against Flask-style route patterns (e.g. /docs/<slug>). Used by client-side routing (Phase 3) to determine if a page can be rendered locally without a server roundtrip. split-path-segments breaks a path into segments, parse-route-pattern converts patterns into typed segment descriptors, match-route-segments tests a path against a parsed pattern returning extracted params, and find-matching-route searches a route table for the first match. No platform interface needed — uses only pure string and list primitives. Bootstrapped via --spec-modules deps,router.")))
(define all-spec-items (concat core-spec-items (concat adapter-spec-items (concat browser-spec-items (concat extension-spec-items module-spec-items)))))

View File

@@ -615,7 +615,8 @@
(li (strong "CSS on-demand: ") "CSSX resolves keywords to CSS rules, injects only used rules.")
(li (strong "Boundary enforcement: ") "boundary.sx + SX_BOUNDARY_STRICT=1 validates all primitives/IO/helpers at registration.")
(li (strong "Dependency analysis: ") "deps.sx computes per-page component bundles — only definitions a page actually uses are sent.")
(li (strong "IO detection: ") "deps.sx classifies every component as pure or IO-dependent by scanning for boundary primitive references transitively. The spec provides the classification; each host's async evaluator acts on it — expanding IO-dependent components server-side, serializing pure ones for client rendering.")))
(li (strong "IO detection: ") "deps.sx classifies every component as pure or IO-dependent. Server expands IO components, serializes pure ones for client.")
(li (strong "Client-side routing: ") "router.sx matches URL patterns. Pure pages render instantly without server roundtrips. Pages with :data fall through to server transparently.")))
;; -----------------------------------------------------------------------
;; Phase 1
@@ -747,46 +748,74 @@
(~doc-section :title "Phase 3: Client-Side Routing (SPA Mode)" :id "phase-3"
(div :class "rounded border border-violet-200 bg-violet-50 p-4 mb-4"
(p :class "text-violet-900 font-medium" "What it enables")
(p :class "text-violet-800" "After initial page load, client resolves routes locally using cached components + data. Only hits server for fresh data or unknown routes. Like Next.js client-side navigation."))
(div :class "rounded border border-green-300 bg-green-50 p-4 mb-4"
(div :class "flex items-center gap-2 mb-2"
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete")
(a :href "/specs/router" :class "text-green-700 underline text-sm font-medium" "View canonical spec: router.sx")
(a :href "/isomorphism/routing-analyzer" :class "text-green-700 underline text-sm font-medium" "Live routing analyzer"))
(p :class "text-green-900 font-medium" "What it enables")
(p :class "text-green-800" "After initial page load, pure pages render instantly without server roundtrips. Client matches routes locally, evaluates content expressions with cached components, and only falls back to server for pages with :data dependencies."))
(~doc-subsection :title "Current Mechanism"
(p "All routing is server-side via defpage → Quart routes. Client navigates via sx-boost links doing sx-get + morphing. Every navigation = server roundtrip."))
(~doc-subsection :title "Approach"
(~doc-subsection :title "Architecture"
(p "Three-layer approach: spec defines pure route matching, page registry bridges server metadata to client, orchestration intercepts navigation for try-first/fallback.")
(div :class "space-y-4"
(div
(h4 :class "font-semibold text-stone-700" "1. Client-side page registry")
(p "Serialize defpage routing info to client:")
(~doc-code :code (highlight "(script :type \"text/sx-pages\")\n;; {\"docs-page\": {\"path\": \"/docs/:slug\", \"auth\": \"public\",\n;; \"content\": \"(case slug ...)\", \"data\": null}}" "lisp")))
(h4 :class "font-semibold text-stone-700" "1. Route matching spec (router.sx)")
(p "New spec module with pure functions for Flask-style route pattern matching:")
(~doc-code :code (highlight "(define split-path-segments ;; \"/docs/hello\" → (\"docs\" \"hello\")\n(define parse-route-pattern ;; \"/docs/<slug>\" → segment descriptors\n(define match-route-segments ;; segments + pattern → params dict or nil\n(define find-matching-route ;; path + route table → first match" "lisp"))
(p "No platform interface needed — uses only pure string and list primitives. Bootstrapped to both hosts via " (code "--spec-modules deps,router") "."))
(div
(h4 :class "font-semibold text-stone-700" "2. Client route matcher")
(p "New spec file shared/sx/ref/router.sx — convert /docs/<slug> patterns to matchers. On boost-link click: match URL → if found and pure, evaluate locally. If IO needed: fetch data from server, evaluate content locally. No match: fall through to standard fetch."))
(h4 :class "font-semibold text-stone-700" "2. Page registry")
(p "Server serializes defpage metadata as SX dict literals inside " (code "<script type=\"text/sx-pages\">") ". Each entry carries name, path pattern, auth level, has-data flag, serialized content expression, and closure values.")
(~doc-code :code (highlight "{:name \"docs-page\" :path \"/docs/<slug>\"\n :auth \"public\" :has-data false\n :content \"(case slug ...)\" :closure {}}" "lisp"))
(p "boot.sx processes these at startup using the SX parser — the same " (code "parse") " function from parser.sx — building route entries with parsed patterns into the " (code "_page-routes") " table. No JSON dependency."))
(div
(h4 :class "font-semibold text-stone-700" "3. Data endpoint")
(~doc-code :code (highlight "GET /internal/page-data/<page-name>?<params>\n# Returns JSON with evaluated :data expression\n# Reuses execute_page() logic, stops after :data step" "python")))
(h4 :class "font-semibold text-stone-700" "3. Client-side interception (orchestration.sx)")
(p (code "bind-client-route-link") " replaces " (code "bind-boost-link") " in boost processing. On click:")
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li "Extract pathname from href")
(li "Call " (code "find-matching-route") " against " (code "_page-routes"))
(li "If match found AND no :data: evaluate content expression locally with component env + URL params")
(li "If evaluation succeeds: swap into #main-panel, pushState, log " (code "\"sx:route client /path\""))
(li "If anything fails (no match, has data, eval error): transparent fallback to server fetch"))
(p (code "handle-popstate") " also tries client routing before server fetch on back/forward."))))
(div
(h4 :class "font-semibold text-stone-700" "4. Layout caching")
(p "Layouts depend on auth/fragments, so cache current layout and reuse across navigations. SX-Layout-Hash header tracks staleness."))
(~doc-subsection :title "What becomes client-routable"
(p "Pages WITHOUT " (code ":data") " that have pure content expressions — most of this docs app:")
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li (code "/") ", " (code "/docs/") ", " (code "/docs/<slug>") " (most slugs), " (code "/protocols/") ", " (code "/protocols/<slug>"))
(li (code "/examples/") ", " (code "/examples/<slug>") ", " (code "/essays/") ", " (code "/essays/<slug>"))
(li (code "/plans/") ", " (code "/plans/<slug>") ", " (code "/isomorphism/") ", " (code "/bootstrappers/")))
(p "Pages that fall through to server:")
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li (code "/docs/primitives") " and " (code "/docs/special-forms") " (call " (code "primitives-data") " / " (code "special-forms-data") " helpers)")
(li (code "/reference/<slug>") " (has " (code ":data (reference-data slug)") ")")
(li (code "/bootstrappers/<slug>") " (has " (code ":data (bootstrapper-data slug)") ")")
(li (code "/isomorphism/bundle-analyzer") " (has " (code ":data (bundle-analyzer-data)") ")")))
(div
(h4 :class "font-semibold text-stone-700" "5. Integration with orchestration.sx")
(p "Intercept bind-boost-link to try client-side resolution first."))))
(~doc-subsection :title "Try-first/fallback design"
(p "Client routing uses a try-first approach: attempt local evaluation in a try/catch, fall back to server fetch on any failure. This avoids needing perfect static analysis of content expressions — if a content expression calls a page helper the client doesn't have, the eval throws, and the server handles it transparently.")
(p "Console messages provide visibility: " (code "sx:route client /essays/why-sexps") " vs " (code "sx:route server /specs/eval") "."))
(div :class "rounded border border-amber-200 bg-amber-50 p-3 mt-2"
(p :class "text-amber-800 text-sm" (strong "Depends on: ") "Phase 1 (client knows which components each page needs), Phase 2 (which pages are pure vs IO)."))
(~doc-subsection :title "Files"
(ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm"
(li "shared/sx/ref/router.sx — route pattern matching spec")
(li "shared/sx/ref/boot.sx — process page registry scripts")
(li "shared/sx/ref/orchestration.sx — client route interception")
(li "shared/sx/ref/bootstrap_js.py — router spec module + platform functions")
(li "shared/sx/ref/bootstrap_py.py — router spec module (parity)")
(li "shared/sx/helpers.py — page registry SX serialization")))
(~doc-subsection :title "Verification"
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li "Pure page navigation: zero server requests")
(li "IO page navigation: exactly one data request (not full page fetch)")
(li "Browser back/forward works with client-resolved routes")
(li "Disabling client registry → identical behavior to current"))))
(li "Pure page navigation: zero server requests, console shows \"sx:route client\"")
(li "IO/data page fallback: falls through to server fetch transparently")
(li "Browser back/forward works with client-routed pages")
(li "Disabling page registry → identical behavior to before")
(li "Bootstrap parity: sx_ref.py and sx-ref.js both contain router functions"))))
;; -----------------------------------------------------------------------
;; Phase 4

96
sx/sx/routing-analyzer.sx Normal file
View File

@@ -0,0 +1,96 @@
;; Routing analyzer — live demonstration of client-side routing classification.
;; Shows which pages route client-side (pure, instant) vs server-side (IO/data).
;; @css bg-green-100 text-green-800 bg-violet-600 bg-stone-200 text-violet-600 text-stone-600 text-green-600 rounded-full h-2.5 grid-cols-2 bg-blue-100 text-blue-800 bg-amber-100 text-amber-800 grid-cols-4 marker:text-stone-400 bg-blue-50 bg-amber-50 text-blue-700 text-amber-700 border-blue-200 border-amber-200 bg-blue-500 bg-amber-500 grid-cols-3 border-green-200 bg-green-50 text-green-700
(defcomp ~routing-analyzer-content (&key pages total-pages client-count
server-count registry-sample)
(~doc-page :title "Routing Analyzer"
(p :class "text-stone-600 mb-6"
"Live classification of all " (strong (str total-pages)) " pages by routing mode. "
"Pages without " (code ":data") " dependencies are "
(span :class "text-green-700 font-medium" "client-routable")
" — after initial load they render instantly from the page registry without a server roundtrip. "
"Pages with data dependencies fall back to "
(span :class "text-amber-700 font-medium" "server fetch")
" transparently. Powered by "
(a :href "/specs/router" :class "text-violet-700 underline" "router.sx")
" route matching and "
(a :href "/specs/deps" :class "text-violet-700 underline" "deps.sx")
" IO detection.")
(div :class "mb-8 grid grid-cols-4 gap-4"
(~analyzer-stat :label "Total Pages" :value (str total-pages)
:cls "text-violet-600")
(~analyzer-stat :label "Client-Routable" :value (str client-count)
:cls "text-green-600")
(~analyzer-stat :label "Server-Only" :value (str server-count)
:cls "text-amber-600")
(~analyzer-stat :label "Client Ratio" :value (str (round (* (/ client-count total-pages) 100)) "%")
:cls "text-blue-600"))
;; Route classification bar
(div :class "mb-8"
(div :class "flex items-center gap-2 mb-2"
(span :class "text-sm font-medium text-stone-600" "Client")
(div :class "flex-1")
(span :class "text-sm font-medium text-stone-600" "Server"))
(div :class "w-full bg-amber-200 rounded-full h-4 overflow-hidden"
(div :class "bg-green-500 h-4 rounded-l-full transition-all"
:style (str "width: " (round (* (/ client-count total-pages) 100)) "%"))))
(~doc-section :title "Route Table" :id "routes"
(div :class "space-y-2"
(map (fn (page)
(~routing-row
:name (get page "name")
:path (get page "path")
:mode (get page "mode")
:has-data (get page "has-data")
:content-expr (get page "content-expr")
:reason (get page "reason")))
pages)))
(~doc-section :title "Page Registry Format" :id "registry"
(p :class "text-stone-600 mb-4"
"The server serializes page metadata as SX dict literals inside "
(code "<script type=\"text/sx-pages\">")
". The client's parser reads these at boot, building a route table with parsed URL patterns. "
"No JSON involved — the same SX parser handles everything.")
(when (not (empty? registry-sample))
(div :class "not-prose"
(pre :class "text-xs leading-relaxed whitespace-pre-wrap overflow-x-auto bg-stone-100 rounded border border-stone-200 p-4"
(code (highlight registry-sample "lisp"))))))
(~doc-section :title "How Client Routing Works" :id "how"
(ol :class "list-decimal pl-5 space-y-2 text-stone-700"
(li (strong "Boot: ") "boot.sx finds " (code "<script type=\"text/sx-pages\">") ", calls " (code "parse") " on the SX content, then " (code "parse-route-pattern") " on each page's path to build " (code "_page-routes") ".")
(li (strong "Click: ") "orchestration.sx intercepts boost link clicks via " (code "bind-client-route-link") ". Extracts the pathname from the href.")
(li (strong "Match: ") (code "find-matching-route") " from router.sx tests the pathname against all parsed patterns. Returns the first match with extracted URL params.")
(li (strong "Check: ") "If the matched page has " (code ":has-data true") ", skip to server fetch. Otherwise proceed to client eval.")
(li (strong "Eval: ") (code "try-eval-content") " merges the component env + URL params + closure, then parses and renders the content expression to DOM.")
(li (strong "Swap: ") "On success, the rendered DOM replaces " (code "#main-panel") " contents, " (code "pushState") " updates the URL, and the console logs " (code "sx:route client /path") ".")
(li (strong "Fallback: ") "If anything fails (no match, eval error, missing component), the click falls through to a standard server fetch. Console logs " (code "sx:route server /path") ". The user sees no difference.")))))
(defcomp ~routing-row (&key name path mode has-data content-expr reason)
(div :class (str "rounded border p-3 flex items-center gap-3 "
(if (= mode "client")
"border-green-200 bg-green-50"
"border-amber-200 bg-amber-50"))
;; Mode badge
(span :class (str "inline-block px-2 py-0.5 rounded text-xs font-bold uppercase "
(if (= mode "client")
"bg-green-600 text-white"
"bg-amber-500 text-white"))
mode)
;; Page info
(div :class "flex-1 min-w-0"
(div :class "flex items-center gap-2"
(span :class "font-mono font-semibold text-stone-800 text-sm" name)
(span :class "text-stone-400 text-xs font-mono" path))
(when reason
(div :class "text-xs text-stone-500 mt-0.5" reason)))
;; Content expression
(when content-expr
(div :class "hidden md:block max-w-xs truncate"
(code :class "text-xs text-stone-500" content-expr)))))

View File

@@ -182,7 +182,8 @@ continuations.sx depends on: eval (optional)
callcc.sx depends on: eval (optional)
;; Spec modules (optional — loaded via --spec-modules)
deps.sx depends on: eval (optional)")))
deps.sx depends on: eval (optional)
router.sx (standalone — pure string/list ops)")))
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Extensions")

View File

@@ -416,6 +416,9 @@
"bundle-analyzer" (~bundle-analyzer-content
:pages pages :total-components total-components :total-macros total-macros
:pure-count pure-count :io-count io-count)
"routing-analyzer" (~routing-analyzer-content
:pages pages :total-pages total-pages :client-count client-count
:server-count server-count :registry-sample registry-sample)
:else (~plan-isomorphic-content)))
(defpage bundle-analyzer
@@ -432,6 +435,20 @@
:pages pages :total-components total-components :total-macros total-macros
:pure-count pure-count :io-count io-count))
(defpage routing-analyzer
:path "/isomorphism/routing-analyzer"
:auth :public
:layout (:sx-section
:section "Isomorphism"
:sub-label "Isomorphism"
:sub-href "/isomorphism/"
:sub-nav (~section-nav :items isomorphism-nav-items :current "Routing Analyzer")
:selected "Routing Analyzer")
:data (routing-analyzer-data)
:content (~routing-analyzer-content
:pages pages :total-pages total-pages :client-count client-count
:server-count server-count :registry-sample registry-sample))
;; ---------------------------------------------------------------------------
;; Plans section
;; ---------------------------------------------------------------------------

View File

@@ -22,6 +22,7 @@ def _register_sx_helpers() -> None:
"read-spec-file": _read_spec_file,
"bootstrapper-data": _bootstrapper_data,
"bundle-analyzer-data": _bundle_analyzer_data,
"routing-analyzer-data": _routing_analyzer_data,
})
@@ -342,6 +343,82 @@ def _bundle_analyzer_data() -> dict:
}
def _routing_analyzer_data() -> dict:
"""Compute per-page routing classification for the sx-docs app."""
from shared.sx.pages import get_all_pages
from shared.sx.parser import serialize as sx_serialize
from shared.sx.helpers import _sx_literal
pages_data = []
full_content: list[tuple[str, str, bool]] = [] # (name, full_content, has_data)
client_count = 0
server_count = 0
for name, page_def in sorted(get_all_pages("sx").items()):
has_data = page_def.data_expr is not None
content_src = ""
if page_def.content_expr is not None:
try:
content_src = sx_serialize(page_def.content_expr)
except Exception:
pass
full_content.append((name, content_src, has_data))
# Determine routing mode and reason
if has_data:
mode = "server"
reason = "Has :data expression — needs server IO"
server_count += 1
elif not content_src:
mode = "server"
reason = "No content expression"
server_count += 1
else:
mode = "client"
reason = ""
client_count += 1
pages_data.append({
"name": name,
"path": page_def.path,
"mode": mode,
"has-data": has_data,
"content-expr": content_src[:80] + ("..." if len(content_src) > 80 else ""),
"reason": reason,
})
# Sort: client pages first, then server
pages_data.sort(key=lambda p: (0 if p["mode"] == "client" else 1, p["name"]))
# Build a sample of the SX page registry format (use full content, first 3)
total = client_count + server_count
sample_entries = []
sorted_full = sorted(full_content, key=lambda x: x[0])
for name, csrc, hd in sorted_full[:3]:
page_def = get_all_pages("sx").get(name)
if not page_def:
continue
entry = (
"{:name " + _sx_literal(name)
+ "\n :path " + _sx_literal(page_def.path)
+ "\n :auth " + _sx_literal("public")
+ " :has-data " + ("true" if hd else "false")
+ "\n :content " + _sx_literal(csrc)
+ "\n :closure {}}"
)
sample_entries.append(entry)
registry_sample = "\n\n".join(sample_entries)
return {
"pages": pages_data,
"total-pages": total,
"client-count": client_count,
"server-count": server_count,
"registry-sample": registry_sample,
}
def _attr_detail_data(slug: str) -> dict:
"""Return attribute detail data for a specific attribute slug.