53 Commits

Author SHA1 Message Date
09d06a4c87 Filter data page deps by IO purity: only bundle pure component trees
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m43s
Pages whose component trees reference IO primitives (e.g. highlight)
cannot render client-side, so exclude their deps from the client bundle.
This prevents "Undefined symbol: highlight" errors on pages like
bundle-analyzer while still allowing pure data pages like data-test
to render client-side with caching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:48:47 +00:00
6655f638b9 Optimize evaluator hot path: prototype-chain envs, imperative kwarg parsing
Three key optimizations to the JS evaluator platform layer:

1. envMerge uses Object.create() instead of copying all keys — O(own) vs O(all)
2. renderDomComponent/renderDomElement override: imperative kwarg/attr
   parsing replaces reduce+assoc pattern (no per-arg dict allocation)
3. callComponent/parseKeywordArgs override: same imperative pattern
   for the eval path (not just DOM rendering)

Wire format and spec semantics unchanged — these are host-level
performance overrides in the platform JS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:42:09 +00:00
2c56d3e14b Include all :data page component deps in every page's client bundle
Per-page bundling now unions deps from all :data pages in the service,
so navigating between data pages uses client-side rendering + cache
instead of expensive server fetch + SX parse.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:26:39 +00:00
fa295acfe3 Remove debug logs from client routing, Phase 4 confirmed working
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:23:32 +00:00
28ee441d9a Debug: log fallback path when client route fails
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:20:58 +00:00
1387d97c82 Clean up debug logs from try-client-route, keep deps check
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:16:24 +00:00
b90cc59029 Check component deps before attempting client-side route render
Pages whose components aren't loaded client-side now fall through
to server fetch instead of silently failing in the async callback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:13:09 +00:00
59c935e394 Fix route order: specific routes before wildcard <slug> catch-all
Client router uses first-match, so /isomorphism/data-test was matching
the /isomorphism/<slug> wildcard instead of the specific data-test route.
Moved bundle-analyzer, routing-analyzer, data-test before the wildcard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:07:02 +00:00
c15dbc3242 Debug: log has-data type and cache status in try-client-route
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:03:48 +00:00
ece2aa225d Fix popstate and client routing when no [sx-boost] container exists
handle-popstate falls back to #main-panel when no [sx-boost] element
is found, fixing back button for apps using explicit sx-target attrs.
bindClientRouteClick also checks sx-target on the link itself.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:53:08 +00:00
ac1dc34dad Fix: pass target selector to tryClientRoute from link clicks
bindClientRouteClick was calling tryClientRoute(pathname) without the
target-sel argument. This caused resolve-route-target to return nil,
so client routing ALWAYS fell back to server fetch on link clicks.
Now finds the sx-boost ancestor and passes its target selector.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:46:25 +00:00
9278be9fe2 Mark Phase 4 complete in sx-docs, link to data-test demo
- Phase 4 section: green "Complete" badge with live data test link
- Documents architecture: resolve-page-data, server endpoint, data cache
- Lists files, 30 unit tests, verification steps
- Renumber: Phase 5 = async continuations, Phase 6 = streaming, Phase 7 = full iso
- Update Phase 3 to note :data pages now also client-routable
- Add data-test to "pages that fall through" list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:41:15 +00:00
f36583b620 Fix: register append!/dict-set! in PRIMITIVES after it is defined
The registrations were in the platform eval block which emits before
var PRIMITIVES = {}. Moved to core.list and core.dict primitive sections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:33:49 +00:00
6772f1141f Register append! and dict-set! as proper primitives
Previously these mutating operations were internal helpers in the JS
bootstrapper but not declared in primitives.sx or registered in the
Python evaluator. Now properly specced and available in both hosts.

Removes mock injections from cache tests — they use real primitives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:21:17 +00:00
60b58fdff7 Add cache unit tests (10) and update data-test demo for TTL
- 10 new tests: cache key generation, set/get, TTL expiry, overwrite,
  key independence, complex nested data
- Update data-test.sx with cache verification instructions:
  navigate away+back within 30s → client+cache, after 30s → new fetch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:18:11 +00:00
d3617ab7f3 Phase 4 complete: client data cache + plan update
- Add page data cache in orchestration.sx (30s TTL, keyed by page-name+params)
- Cache hit path: sx:route client+cache (instant render, no fetch)
- Cache miss path: sx:route client+data (fetch, cache, render)
- Fix HTMX response dep computation to include :data pages
- Update isomorphic-sx-plan.md: Phases 1-4 marked done with details,
  reorder remaining phases (continuations→Phase 5, suspense→Phase 6,
  optimistic updates→Phase 7)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:06:22 +00:00
732923a7ef Fix: auto-include router spec module when boot adapter is present
boot.sx uses parse-route-pattern from router.sx, but router was only
included as an opt-in spec module. Now auto-included when boot is in
the adapter set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:53:55 +00:00
b1f9e41027 Add unit tests for Phase 4 page data pipeline (20 tests)
Tests cover: SX wire format roundtrip for data dicts (11 tests),
kebab-case key conversion (4 tests), component dep computation for
:data pages (2 tests), and full pipeline simulation — serialize on
server, parse on client, merge into env, eval content (3 tests).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:49:08 +00:00
a657d0831c Phase 4: Client-side rendering of :data pages via abstract resolve-page-data
Spec layer (orchestration.sx):
- try-client-route now handles :data pages instead of falling back to server
- New abstract primitive resolve-page-data(name, params, callback) — platform
  decides transport (HTTP, IPC, cache, etc)
- Extracted swap-rendered-content and resolve-route-target helpers

Platform layer (bootstrap_js.py):
- resolvePageData() browser implementation: fetches /sx/data/<name>, parses
  SX response, calls callback. Other hosts provide their own transport.

Server layer (pages.py):
- evaluate_page_data() evaluates :data expr, serializes result as SX
- auto_mount_page_data() mounts /sx/data/ endpoint with per-page auth
- _build_pages_sx now computes component deps for all pages (not just pure)

Test page at /isomorphism/data-test exercises the full pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:46:30 +00:00
9d0cffb84d Fix special-forms.sx path resolution in container
Three levels of ../ overshot from /app/sxc/pages/ to /. Use same
two-level pattern with /app/shared fallback as _read_spec_file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:16:21 +00:00
eee2954559 Update reference docs: fix event names, add demos, document sx-boost target
- Remove sx:afterSettle (not dispatched), rename sx:sendError → sx:requestError
- Add sx:clientRoute event (Phase 3 client-side routing)
- Add working demos for all 10 events (afterRequest, afterSwap, requestError,
  clientRoute, sseOpen, sseMessage, sseError were missing demos)
- Update sx-boost docs: configurable target selector, client routing behavior
- Remove app-specific nav logic from orchestration.sx, use sx:clientRoute event
- Pass page content deps to sx_response for component loading after server fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:38 +00:00
b9003eacb2 Fix unclosed paren in content-addressed components plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:07:42 +00:00
7229335d22 Add content-addressed components plan to sx-docs
7-phase plan: canonical serialization, CID computation, component
manifests, IPFS storage & resolution cascade, security model (purity
verification, content verification, eval limits, trust tiers),
wire format integration with prefetch system, and federated sharing
via AP component registry actors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:55:13 +00:00
e38534a898 Expand prefetch plan: full strategy spectrum and components+data split
Add prefetch strategies section covering the full timing spectrum:
eager bundle (initial load), idle timer (requestIdleCallback), viewport
(IntersectionObserver), mouse trajectory prediction, hover, mousedown,
and the components+data hybrid for :data pages. Add declarative
configuration via defpage :prefetch metadata and sx-prefetch attributes.
Update rollout to 7 incremental steps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:43:52 +00:00
daf76c3e5b Add predictive component prefetching plan to sx-docs
4-phase design: server endpoint for on-demand component defs,
SX-specced client prefetch logic (hover/viewport triggers),
boundary declarations, and bootstrap integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:30:26 +00:00
093050059d Remove debug env logging from tryClientRoute
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:18:59 +00:00
6a5cb31123 Debug: log env keys and params in tryClientRoute
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:15:20 +00:00
bcb58d340f Unknown components throw instead of rendering error box
render-dom-unknown-component now calls (error ...) instead of
creating a styled div. This lets tryEvalContent catch the error
and fall back to server fetch, instead of rendering "Unknown
component: ~name" into the page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:12:59 +00:00
b98a8f8c41 Try-first routing: attempt eval, fall back on failure
Remove strict deps check — for case expressions like essay pages,
deps includes ALL branches but only one is taken. Instead, just
try to eval the content. If a component is missing, tryEvalContent
catches the error and we transparently fall back to server fetch.
deps field remains in registry for future prefetching use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:10:35 +00:00
14c5316d17 Add component deps to page registry, check before client routing
Each page entry now includes a deps list of component names needed.
Client checks all deps are loaded before attempting eval — if any
are missing, falls through to server fetch with a clear log message.
No bundle bloat: server sends components for the current page only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:06:28 +00:00
3b00a7095a Fix (not) compilation: use isSxTruthy for NIL-safe negation
NIL is a frozen sentinel object ({_nil:true}) which is truthy in JS.
(not expr) compiled to !expr, so (not nil) returned false instead of
true. Fixed to compile as !isSxTruthy(expr) which correctly handles
NIL. This was preventing client-side routing from activating.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:01:39 +00:00
719dfbf732 Debug: log each isGetLink condition individually
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:59:58 +00:00
5ea0f5c546 Debug: log mods.delay and isGetLink result
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:59:17 +00:00
74428cc433 Debug: log verbInfo method and url in click handler
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:58:43 +00:00
d1a47e1e52 Restore click handler logging + use logInfo for errors (SES-safe)
SES lockdown may suppress console.error. Use logInfo for error
reporting since we know it works ([sx-ref] prefix visible).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:57:44 +00:00
3d191099e0 Surface all errors: wrap event handlers in try/catch with console.error
- domAddListener wraps callbacks so exceptions always log to console
- Add "sx:route trying <url>" log before tryClientRoute call
- Remove redundant bind-event fired log (replaced with route-specific logs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:53:54 +00:00
70cf501c49 Debug: log every bind-event click handler firing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:51:04 +00:00
2a978e6e9f Add explicit logging for route decisions in bind-event
- Log "sx:route server fetch <url>" when falling back to network
- Use console.error for eval errors (not console.warn)
- Restructure bind-event to separate client route check from &&-chain

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:44:55 +00:00
3a8ee0dbd6 Fix router.sx: use len not length (correct SX primitive name)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:38:23 +00:00
c346f525d2 Include router spec module in sx-browser.js bootstrap
parseRoutePattern was undefined because the router module
wasn't included in the build. Now passing --spec-modules router.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:37:41 +00:00
79ee3bc46e Fix page registry: process page scripts before mount scripts
The data-mount="body" script replaces the entire body content,
destroying the <script type="text/sx-pages"> tag. Moving
processPageScripts before processSxScripts ensures the page
registry is read before the body is replaced.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:35:59 +00:00
c80b5d674f Add debug logging to page registry pipeline
Server-side: log page count, output size, and first 200 chars in _build_pages_sx.
Client-side: log script tag count, text length, parsed entry count in processPageScripts.
Helps diagnose why pages: 0 routes loaded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:31:53 +00:00
f08bd403de Remove wrong brace escaping from pages_sx
str.format() doesn't re-process braces inside substituted values,
so the escaping was producing literal doubled braces {{:name...}}
in the output, which the SX parser couldn't parse.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:26:38 +00:00
227444a026 Tighten exception handling in helpers.py
- Remove silent except around content serialization (was hiding bugs)
- Narrow cookie/request-context catches to RuntimeError
- Narrow script hash to OSError
- Log warnings for pretty-print failures instead of silent pass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:19:26 +00:00
2660d37f9e Remove try/except around page registry build
This was silently masking the str.format() braces bug. If page
registry building fails, it should crash visibly, not serve a
broken page with 0 routes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:17:37 +00:00
d850f7c9c1 Fix page registry: escape braces for str.format()
pages_sx contains SX dict literals with {} (empty closures) which
Python's str.format() interprets as positional placeholders, causing
a KeyError that was silently caught. Escape braces before formatting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:16:35 +00:00
bc9d9e51c9 Log page registry build errors instead of silently swallowing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:14:19 +00:00
eb70e7237e Log route count on boot and no-match on route attempts
Shows "pages: N routes loaded" at startup and
"sx:route no match (N routes) /path" when no route matches,
so we can see if routes loaded and why matching fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:10:24 +00:00
a7d09291b8 Add version logging and route decision logging to sx-browser
boot-init prints SX_VERSION (build timestamp) to console on startup.
tryClientRoute logs why it falls through: has-data, no content, eval
failed, #main-panel not found. tryEvalContent logs the actual error.
Added logWarn platform function.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:05:39 +00:00
2d5096be6c Add console logging for client-side routing decisions
tryClientRoute now logs why it falls through: has-data, no content,
eval failed, or #main-panel not found. tryEvalContent logs the actual
error on catch. Added logWarn platform function (console.warn).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 20:43:27 +00:00
f70861c175 Try client-side routing for all sx-get link clicks, not just boost links
bind-event now checks tryClientRoute before executeRequest for GET
clicks on links. Previously only boost links (inside [sx-boost]
containers) attempted client routing — explicit sx-get links like
~nav-link always hit the network. Now essay/doc nav links render
client-side when possible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 20:31:23 +00:00
78c3ff30dd Wrap index SX response in #main-panel section
All nav links use sx-select="#main-panel" to extract content from
responses. The index partial must include this wrapper so the select
finds it, matching the pattern used by test-detail-section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:59:10 +00:00
756162b63f Fix test dashboard SX swap targets for partial responses
Filter cards: target #test-results (the actual response container)
instead of sx-select #main-panel (not present in partial response).
Back link: use innerHTML swap into #main-panel (no sx-select needed).
Results route: use sx_response() for correct content-type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:49:27 +00:00
24 changed files with 2706 additions and 466 deletions

View File

@@ -20,136 +20,147 @@ The key insight: **s-expressions can partially unfold on the server after IO, th
---
### Phase 1: Component Distribution & Dependency Analysis
### Phase 1: Component Distribution & Dependency Analysis — DONE
**What it enables:** Per-page component bundles instead of sending every definition to every page. Smaller payloads, faster boot, better cache hit rates.
**The problem:** `client_components_tag()` in `shared/sx/jinja_bridge.py` serializes ALL entries in `_COMPONENT_ENV`. The `sx_page()` template sends everything or nothing based on a single global hash. No mechanism determines which components a page actually needs.
**Implemented:**
**Approach:**
1. **Transitive closure analyzer** — new module `shared/sx/deps.py`
- Walk `Component.body` AST, collect all `Symbol` refs starting with `~`
1. **Transitive closure analyzer**`shared/sx/deps.py` (now `shared/sx/ref/deps.sx`, spec-level)
- Walk component body AST, collect all `~name` refs
- Recursively follow into their bodies
- Handle control forms (`if`/`when`/`cond`/`case`) — include ALL branches
- Handle macros — expand during walk using limited eval
- Function: `transitive_deps(name: str, env: dict) -> set[str]`
- Cache result on `Component` object (invalidate on hot-reload)
- `components_needed(source, env) -> set[str]`
2. **Runtime component scanning** — after `_aser` serializes page content, scan the SX string for `(~name` patterns (parallel to existing `scan_classes_from_sx` for CSS). Then compute transitive closure to get sub-components.
2. **IO reference analysis**`deps.sx` also tracks IO primitive usage
- `scan-io-refs` / `transitive-io-refs` / `component-pure?`
- Used by Phase 2 for automatic server/client boundary
3. **Per-page component block** in `sx_page()` — replace all-or-nothing with page-specific bundle. Hash changes per page, localStorage cache keyed by route pattern.
3. **Per-page component block** `_build_pages_sx()` in `helpers.py`
- Each page entry includes `:deps` list of required components
- Client page registry carries dep info for prefetching
4. **SX partial responses**`components_for_request()` already diffs against `SX-Components` header. Enhance with transitive closure so only truly needed missing components are sent.
4. **SX partial responses**`components_for_request()` diffs against `SX-Components` header, sends only missing components
**Files:**
- New: `shared/sx/deps.py` — dependency analysis
- `shared/sx/jinja_bridge.py` — per-page bundle generation, cache deps on Component
- `shared/sx/helpers.py` — modify `sx_page()` and `sx_response()` for page-specific bundles
- `shared/sx/types.py` — add `deps: set[str]` to Component
- `shared/sx/ref/boot.sx` — per-page component caching alongside global cache
**Verification:**
- Page using 5/50 components → `data-components` block contains only those 5 + transitive deps
- No "Unknown component" errors after bundle reduction
- Payload size reduction measurable
**Files:** `shared/sx/ref/deps.sx`, `shared/sx/deps.py`, `shared/sx/helpers.py`, `shared/sx/jinja_bridge.py`
---
### Phase 2: Smart Server/Client Boundary
### Phase 2: Smart Server/Client Boundary — DONE
**What it enables:** Formalized partial evaluation model. Server evaluates IO, serializes pure subtrees. The system automatically knows "this component needs server data" vs "this component is pure and can render anywhere."
**Current mechanism:** `_aser` in `async_eval.py` already does partial evaluation — IO primitives are awaited and substituted, HTML tags and component calls serialize as SX. The `_expand_components` context var controls expansion. But this is a global toggle, not per-component.
**Implemented:**
**Approach:**
1. **Automatic IO detection**`deps.sx` walks component bodies for IO primitive refs
- `compute-all-io-refs` computes transitive IO analysis for all components
- `component-pure?` returns true if no IO refs transitively
1. **Automatic IO detection** — extend Phase 1 AST walker to check for references to `IO_PRIMITIVES` names (`frag`, `query`, `service`, `current-user`, etc.)
- `has_io_deps(name: str, env: dict) -> bool`
- Computed at registration time, cached on Component
2. **Selective expansion**`_aser` expands known components server-side via `_aser_component`
- IO-dependent components expand server-side (IO must resolve)
- Unknown components serialize for client rendering
- `_expand_components` context var controls override
2. **Component metadata**enrich Component with analysis results:
```python
ComponentMeta:
deps: set[str] # transitive component deps (Phase 1)
io_refs: set[str] # IO primitive names referenced
is_pure: bool # True if io_refs empty (transitively)
```
3. **Component metadata**computed at registration, cached on Component objects
3. **Selective expansion** — refine `_aser` (line ~1335): instead of checking a global `_expand_components` flag, check the component's `is_pure` metadata:
- IO-dependent → expand server-side (IO must resolve)
- Pure → serialize for client (let client render)
- Explicit override: `:server true` on defcomp forces server expansion
4. **Data manifest** for pages — `PageDef` produces a declaration of what IO the page needs, enabling Phase 3 (client can prefetch data) and Phase 5 (streaming).
**Files:**
- `shared/sx/deps.py` — add IO analysis
- `shared/sx/types.py` — add metadata fields to Component
- `shared/sx/async_eval.py` — refine `_aser` component expansion logic
- `shared/sx/jinja_bridge.py` — compute IO metadata at registration
- `shared/sx/pages.py` — data manifest on PageDef
**Verification:**
- Components calling `(query ...)` classified IO-dependent; pure components classified pure
- Existing pages produce identical output (regression)
**Files:** `shared/sx/ref/deps.sx`, `shared/sx/async_eval.py`, `shared/sx/jinja_bridge.py`
---
### Phase 3: Client-Side Routing (SPA Mode)
### Phase 3: Client-Side Routing (SPA Mode) — DONE
**What it enables:** 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.
**What it enables:** After initial page load, client resolves routes locally using cached components. Only hits server for fresh data or unknown routes.
**Current mechanism:** All routing is server-side via `defpage` → Quart routes. Client navigates via `sx-boost` links doing `sx-get` + morphing. Every navigation = server roundtrip.
**Implemented:**
**Approach:**
1. **Client-side page registry**`_build_pages_sx()` serializes defpage routing info
- `<script type="text/sx-pages">` with name, path, auth, content, deps, closure, has-data
- Processed by `boot.sx``_page-routes` list
1. **Client-side page registry** — serialize defpage routing info to client as `<script type="text/sx-pages">`:
```json
{"docs-page": {"path": "/docs/:slug", "auth": "public",
"content": "(case slug ...)", "data": null}}
```
Pure pages (no `:data`) can be evaluated entirely client-side.
2. **Client route matcher**`shared/sx/ref/router.sx`
- `parse-route-pattern` converts Flask-style `/docs/<slug>` to matchers
- `find-matching-route` matches URL against registered routes
- `match-route-segments` handles literal and param segments
2. **Client route matcher** — 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 (existing behavior)
3. **Client-side route intercept**`orchestration.sx`
- `try-client-route` — match URL, eval content locally, swap DOM
- `bind-client-route-link` — intercept boost link clicks
- Pure pages render immediately, no server roundtrip
- Falls through to server fetch on miss
3. **Data endpoint** — `GET /internal/page-data/<page-name>?<params>` returns JSON with evaluated `:data` expression. Reuses `execute_page()` logic but stops after `:data` step.
4. **Integration with engine**boost link clicks try client route first, fall back to standard fetch
4. **Layout caching** — layouts depend on auth/fragments, so cache current layout and reuse across navigations. `SX-Layout-Hash` header tracks staleness.
5. **Integration with orchestration.sx** — intercept `bind-boost-link` to try client-side resolution first.
**Files:**
- `shared/sx/pages.py` — `serialize_for_client()`, data-only execution path
- `shared/sx/helpers.py` — include page registry in `sx_page()`
- New: `shared/sx/ref/router.sx` — client-side route matching
- `shared/sx/ref/boot.sx` — process `<script type="text/sx-pages">`
- `shared/sx/ref/orchestration.sx` — client-side route intercept
- Service blueprints — `/internal/page-data/` endpoint
**Depends on:** Phase 1 (client knows which components each page needs), Phase 2 (which pages are pure vs IO)
**Verification:**
- Pure page navigation: zero server requests
- IO page navigation: exactly one data request (not full page fetch)
- Browser back/forward works with client-resolved routes
- Disabling client registry → identical behavior to current
**Files:** `shared/sx/ref/router.sx`, `shared/sx/ref/boot.sx`, `shared/sx/ref/orchestration.sx`, `shared/sx/helpers.py`, `shared/sx/pages.py`
---
### Phase 4: Client Async & IO Bridge
### Phase 4: Client Async & IO Bridge — DONE
**What it enables:** Client evaluates IO primitives by mapping them to server REST calls. Same SX code, different transport. `(query "market" "products" :ids "1,2,3")` on server → DB; on client → `fetch("/internal/data/products?ids=1,2,3")`.
**What it enables:** Client fetches server-evaluated data and renders `:data` pages locally. Data cached to avoid redundant fetches on back/forward navigation.
**The approach:** Separate IO from rendering. Server evaluates `:data` expression (async, with DB/service access), serializes result as SX wire format. Client fetches this pre-evaluated data, parses it, merges into env, renders pure `:content` client-side. No continuations needed — all IO happens server-side.
**Implemented:**
1. **Abstract `resolve-page-data`** — spec-level primitive in `orchestration.sx`
- `(resolve-page-data name params callback)` — platform decides transport
- Spec says "I need data for this page"; platform provides concrete implementation
- Browser platform: HTTP fetch to `/sx/data/` endpoint
2. **Server data endpoint**`pages.py`
- `evaluate_page_data()` — evaluates `:data` expression, kebab-cases dict keys, serializes as SX
- `auto_mount_page_data()` — mounts `GET /sx/data/<page_name>` endpoint
- Per-page auth enforcement via `_check_page_auth()`
- Response content type: `text/sx; charset=utf-8`
3. **Client-side data rendering**`orchestration.sx`
- `try-client-route` handles `:data` pages: fetch data → parse SX → merge into env → render content
- Console log: `sx:route client+data <pathname>` confirms client-side rendering
- Component deps computed for `:data` pages too (not just pure pages)
4. **Client data cache**`orchestration.sx`
- `_page-data-cache` dict keyed by `page-name:param=value`
- 30s TTL (configurable via `_page-data-cache-ttl`)
- Cache hit: `sx:route client+cache <pathname>` — renders instantly
- Cache miss: fetches, caches, renders
- Stale entries evicted on next access
5. **Test page**`sx/sx/data-test.sx`
- Exercises full data pipeline: server time, pipeline steps, phase/transport metadata
- Navigate from another page → console shows `sx:route client+data`
- Navigate back → console shows `sx:route client+cache`
6. **Unit tests**`shared/sx/tests/test_page_data.py` (20 tests)
- Serialize roundtrip for all data types
- Kebab-case key conversion
- Component deps for `:data` pages
- Full pipeline simulation (serialize → parse → merge → eval)
**Files:**
- `shared/sx/ref/orchestration.sx``resolve-page-data` spec, data cache
- `shared/sx/ref/bootstrap_js.py` — platform `resolvePageData` implementation
- `shared/sx/pages.py``evaluate_page_data()`, `auto_mount_page_data()`
- `shared/sx/helpers.py` — deps for `:data` pages
- `sx/sx/data-test.sx` — test component
- `sx/sxc/pages/docs.sx` — test page defpage
- `sx/sxc/pages/helpers.py``data-test-data` helper
- `sx/sx/boundary.sx` — helper declaration
- `shared/sx/tests/test_page_data.py` — unit tests
---
### Phase 5: Async Continuations & Inline IO
**What it enables:** Components call IO primitives directly in their body (e.g. `(query ...)`). The evaluator suspends mid-evaluation, fetches data, resumes. Same component source works on both server (Python async/await) and client (continuation-based suspension).
**The problem:** The existing `shift/reset` continuations extension is synchronous (throw/catch). Client-side IO via `fetch()` returns a Promise, and you can't throw-catch across an async boundary. The evaluator needs Promise-aware continuations.
**Approach:**
1. **Async client evaluator** — two possible mechanisms:
- **Promise-based:** `evalExpr` returns value or Promise; rendering awaits
- **Continuation-based:** use existing `shift/reset` to suspend on IO, resume when data arrives (architecturally cleaner, leverages existing spec)
1. **Async-aware shift/reset**extend the continuations extension:
- `sfShift` captures the continuation and returns a Promise
- `sfReset` awaits Promise results in the trampoline
- Continuation resume feeds the fetched value back into the evaluation
2. **IO primitive bridge** — register async IO primitives in client `PRIMITIVES`:
- `query` → fetch to `/internal/data/`
@@ -157,27 +168,22 @@ The key insight: **s-expressions can partially unfold on the server after IO, th
- `frag` → fetch fragment HTML
- `current-user` → cached from initial page load
3. **Client data cache** — keyed by `(service, query, params-hash)`, configurable TTL, server can invalidate via `SX-Invalidate` header.
3. **CPS transform option**alternative to Promise-aware shift/reset:
- Transform the evaluator to continuation-passing style
- Every eval step takes a continuation argument
- IO primitives call the continuation after fetch resolves
- Architecturally cleaner but requires deeper changes
4. **Optimistic updates** — extend existing `apply-optimistic`/`revert-optimistic` in `engine.sx` from DOM-level to data-level.
**Files:**
- `shared/sx/ref/eval.sx` — async dispatch path (or new `async-eval.sx`)
- New: `shared/sx/ref/io-bridge.sx` — client IO implementations
- `shared/sx/ref/boot.sx` — register IO bridge at init
- `shared/sx/ref/bootstrap_js.py` — emit async-aware code
- `/internal/data/` endpoints — ensure client-accessible (CORS, auth)
**Depends on:** Phase 2 (IO affinity), Phase 3 (routing for when to trigger IO)
**Depends on:** Phase 4 (data endpoint infrastructure)
**Verification:**
- Client `(query ...)` returns identical data to server-side
- Data cache prevents redundant fetches
- Same component source → identical output on either side
- Component calling `(query ...)` on client fetches data and renders
- Same component source → identical output on server and client
- Suspension visible: placeholder → resolved content
---
### Phase 5: Streaming & Suspense
### Phase 6: Streaming & Suspense
**What it enables:** Server streams partially-evaluated SX as IO resolves. Client renders available subtrees immediately, fills in suspended parts. Like React Suspense but built on delimited continuations.
@@ -203,11 +209,11 @@ The key insight: **s-expressions can partially unfold on the server after IO, th
- New: `shared/sx/ref/suspense.sx` — client suspension rendering
- `shared/sx/ref/boot.sx` — handle resolution scripts
**Depends on:** Phase 4 (client async for filling suspended subtrees), Phase 2 (IO analysis for priority)
**Depends on:** Phase 5 (async continuations for filling suspended subtrees), Phase 2 (IO analysis for priority)
---
### Phase 6: Full Isomorphism
### Phase 7: Full Isomorphism
**What it enables:** Same SX code runs on either side. Runtime chooses optimal split. Offline-first with cached data + client eval.
@@ -226,11 +232,16 @@ The key insight: **s-expressions can partially unfold on the server after IO, th
```
Default: auto (runtime decides from IO analysis).
3. **Offline data layer** — Service Worker intercepts `/internal/data/` requests, serves from IndexedDB when offline, syncs when back online.
3. **Optimistic data updates** — extend existing `apply-optimistic`/`revert-optimistic` in `engine.sx` from DOM-level to data-level:
- Client updates cached data optimistically (e.g., like button increments count)
- Sends mutation to server
- If server confirms, keep; if rejects, revert cached data and re-render
4. **Isomorphic testing** — evaluate same component on Python and JS, compare output. Extends existing `test_sx_ref.py` cross-evaluator comparison.
4. **Offline data layer** — Service Worker intercepts `/internal/data/` requests, serves from IndexedDB when offline, syncs when back online.
5. **Universal page descriptor** — `defpage` is portable: server executes via `execute_page()`, client executes via route match → fetch data → eval content → render DOM. Same descriptor, different execution environment.
5. **Isomorphic testing** — evaluate same component on Python and JS, compare output. Extends existing `test_sx_ref.py` cross-evaluator comparison.
6. **Universal page descriptor** — `defpage` is portable: server executes via `execute_page()`, client executes via route match → fetch data → eval content → render DOM. Same descriptor, different execution environment.
**Depends on:** All previous phases.
@@ -259,15 +270,15 @@ All new behavior specified in `.sx` files under `shared/sx/ref/` before implemen
| File | Role | Phases |
|------|------|--------|
| `shared/sx/async_eval.py` | Core evaluator, `_aser`, server/client boundary | 2, 5 |
| `shared/sx/helpers.py` | `sx_page()`, `sx_response()`, output pipeline | 1, 3 |
| `shared/sx/async_eval.py` | Core evaluator, `_aser`, server/client boundary | 2, 6 |
| `shared/sx/helpers.py` | `sx_page()`, `sx_response()`, output pipeline | 1, 3, 4 |
| `shared/sx/jinja_bridge.py` | `_COMPONENT_ENV`, component registry | 1, 2 |
| `shared/sx/pages.py` | `defpage`, `execute_page()`, page lifecycle | 2, 3 |
| `shared/sx/ref/boot.sx` | Client boot, component caching | 1, 3, 4 |
| `shared/sx/ref/orchestration.sx` | Client fetch/swap/morph | 3, 4 |
| `shared/sx/ref/eval.sx` | Evaluator spec | 4 |
| `shared/sx/pages.py` | `defpage`, `execute_page()`, page lifecycle, data endpoint | 2, 3, 4 |
| `shared/sx/ref/boot.sx` | Client boot, component caching, page registry | 1, 3, 4 |
| `shared/sx/ref/orchestration.sx` | Client fetch/swap/morph, routing, data cache | 3, 4, 5 |
| `shared/sx/ref/eval.sx` | Evaluator spec | 5 |
| `shared/sx/ref/engine.sx` | Morph, swaps, triggers | 3 |
| New: `shared/sx/deps.py` | Dependency analysis | 1, 2 |
| New: `shared/sx/ref/router.sx` | Client-side routing | 3 |
| New: `shared/sx/ref/io-bridge.sx` | Client IO primitives | 4 |
| New: `shared/sx/ref/suspense.sx` | Streaming/suspension | 5 |
| `shared/sx/ref/deps.sx` | Dependency + IO analysis (spec) | 1, 2 |
| `shared/sx/ref/router.sx` | Client-side route matching | 3 |
| `shared/sx/ref/bootstrap_js.py` | JS bootstrapper, platform implementations | 4, 5 |
| New: `shared/sx/ref/suspense.sx` | Streaming/suspension | 6 |

View File

@@ -737,6 +737,26 @@ document.body.addEventListener('click', function (e) {
// ============================================================================
// Client-side route nav selection
// - Updates aria-selected on sub-nav links after client-side routing
// - Scoped to <nav> elements (top-level section links are outside <nav>)
// ============================================================================
document.body.addEventListener('sx:clientRoute', function (e) {
var pathname = e.detail && e.detail.pathname;
if (!pathname) return;
// Deselect all sub-nav links (inside <nav> elements)
document.querySelectorAll('nav a[aria-selected]').forEach(function (a) {
a.setAttribute('aria-selected', 'false');
});
// Select the matching link
document.querySelectorAll('nav a[href="' + pathname + '"]').forEach(function (a) {
a.setAttribute('aria-selected', 'true');
});
});
// ============================================================================
// Scrolling menu arrow visibility (replaces hyperscript scroll/load handlers)
// Elements with data-scroll-arrows="arrow-class" show/hide arrows on overflow.

View File

@@ -14,6 +14,7 @@
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-07T01:41:53Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -173,10 +174,14 @@
function envHas(env, name) { return name in env; }
function envGet(env, name) { return env[name]; }
function envSet(env, name, val) { env[name] = val; }
function envExtend(env) { return merge(env); }
function envMerge(base, overlay) { return merge(base, overlay); }
function envExtend(env) { return Object.create(env); }
function envMerge(base, overlay) {
var child = Object.create(base);
if (overlay) for (var k in overlay) if (overlay.hasOwnProperty(k)) child[k] = overlay[k];
return child;
}
function dictSet(d, k, v) { d[k] = v; }
function dictSet(d, k, v) { d[k] = v; return v; }
function dictGet(d, k) { var v = d[k]; return v !== undefined ? v : NIL; }
// Render-expression detection — lets the evaluator delegate to the active adapter.
@@ -314,6 +319,7 @@
PRIMITIVES["nth"] = function(c, n) { return c && n >= 0 && n < c.length ? c[n] : NIL; };
PRIMITIVES["cons"] = function(x, c) { return [x].concat(c || []); };
PRIMITIVES["append"] = function(c, x) { return (c || []).concat([x]); };
PRIMITIVES["append!"] = function(arr, x) { arr.push(x); return arr; };
PRIMITIVES["chunk-every"] = function(c, n) {
var r = []; for (var i = 0; i < c.length; i += n) r.push(c.slice(i, i + n)); return r;
};
@@ -340,6 +346,7 @@
for (var i = 1; i < arguments.length; i++) delete out[arguments[i]];
return out;
};
PRIMITIVES["dict-set!"] = function(d, k, v) { d[k] = v; return v; };
PRIMITIVES["into"] = function(target, coll) {
if (Array.isArray(target)) return Array.isArray(coll) ? coll.slice() : Object.entries(coll);
var r = {}; for (var i = 0; i < coll.length; i++) { var p = coll[i]; if (Array.isArray(p) && p.length >= 2) r[p[0]] = p[1]; }
@@ -512,6 +519,52 @@
return NIL;
}
// =========================================================================
// Performance overrides — evaluator hot path
// =========================================================================
// Override parseKeywordArgs: imperative loop instead of reduce+assoc
parseKeywordArgs = function(rawArgs, env) {
var kwargs = {};
var children = [];
for (var i = 0; i < rawArgs.length; i++) {
var arg = rawArgs[i];
if (arg && arg._kw && (i + 1) < rawArgs.length) {
kwargs[arg.name] = trampoline(evalExpr(rawArgs[i + 1], env));
i++;
} else {
children.push(trampoline(evalExpr(arg, env)));
}
}
return [kwargs, children];
};
// Override callComponent: use prototype chain env, imperative kwarg binding
callComponent = function(comp, rawArgs, env) {
var kwargs = {};
var children = [];
for (var i = 0; i < rawArgs.length; i++) {
var arg = rawArgs[i];
if (arg && arg._kw && (i + 1) < rawArgs.length) {
kwargs[arg.name] = trampoline(evalExpr(rawArgs[i + 1], env));
i++;
} else {
children.push(trampoline(evalExpr(arg, env)));
}
}
var local = Object.create(componentClosure(comp));
for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k];
var params = componentParams(comp);
for (var j = 0; j < params.length; j++) {
var p = params[j];
local[p] = p in kwargs ? kwargs[p] : NIL;
}
if (componentHasChildren(comp)) {
local["children"] = children;
}
return makeThunk(componentBody(comp), local);
};
// =========================================================================
// Platform: deps module — component dependency analysis
// =========================================================================
@@ -635,7 +688,7 @@
var evalList = function(expr, env) { return (function() {
var head = first(expr);
var args = rest(expr);
return (isSxTruthy(!sxOr((typeOf(head) == "symbol"), (typeOf(head) == "lambda"), (typeOf(head) == "list"))) ? map(function(x) { return trampoline(evalExpr(x, env)); }, expr) : (isSxTruthy((typeOf(head) == "symbol")) ? (function() {
return (isSxTruthy(!isSxTruthy(sxOr((typeOf(head) == "symbol"), (typeOf(head) == "lambda"), (typeOf(head) == "list")))) ? map(function(x) { return trampoline(evalExpr(x, env)); }, expr) : (isSxTruthy((typeOf(head) == "symbol")) ? (function() {
var name = symbolName(head);
return (isSxTruthy((name == "if")) ? sfIf(args, env) : (isSxTruthy((name == "when")) ? sfWhen(args, env) : (isSxTruthy((name == "cond")) ? sfCond(args, env) : (isSxTruthy((name == "case")) ? sfCase(args, env) : (isSxTruthy((name == "and")) ? sfAnd(args, env) : (isSxTruthy((name == "or")) ? sfOr(args, env) : (isSxTruthy((name == "let")) ? sfLet(args, env) : (isSxTruthy((name == "let*")) ? sfLet(args, env) : (isSxTruthy((name == "letrec")) ? sfLetrec(args, env) : (isSxTruthy((name == "lambda")) ? sfLambda(args, env) : (isSxTruthy((name == "fn")) ? sfLambda(args, env) : (isSxTruthy((name == "define")) ? sfDefine(args, env) : (isSxTruthy((name == "defcomp")) ? sfDefcomp(args, env) : (isSxTruthy((name == "defmacro")) ? sfDefmacro(args, env) : (isSxTruthy((name == "defstyle")) ? sfDefstyle(args, env) : (isSxTruthy((name == "defkeyframes")) ? sfDefkeyframes(args, env) : (isSxTruthy((name == "defhandler")) ? sfDefhandler(args, env) : (isSxTruthy((name == "defpage")) ? sfDefpage(args, env) : (isSxTruthy((name == "defquery")) ? sfDefquery(args, env) : (isSxTruthy((name == "defaction")) ? sfDefaction(args, env) : (isSxTruthy((name == "begin")) ? sfBegin(args, env) : (isSxTruthy((name == "do")) ? sfBegin(args, env) : (isSxTruthy((name == "quote")) ? sfQuote(args, env) : (isSxTruthy((name == "quasiquote")) ? sfQuasiquote(args, env) : (isSxTruthy((name == "->")) ? sfThreadFirst(args, env) : (isSxTruthy((name == "set!")) ? sfSetBang(args, env) : (isSxTruthy((name == "reset")) ? sfReset(args, env) : (isSxTruthy((name == "shift")) ? sfShift(args, env) : (isSxTruthy((name == "dynamic-wind")) ? sfDynamicWind(args, env) : (isSxTruthy((name == "map")) ? hoMap(args, env) : (isSxTruthy((name == "map-indexed")) ? hoMapIndexed(args, env) : (isSxTruthy((name == "filter")) ? hoFilter(args, env) : (isSxTruthy((name == "reduce")) ? hoReduce(args, env) : (isSxTruthy((name == "some")) ? hoSome(args, env) : (isSxTruthy((name == "every?")) ? hoEvery(args, env) : (isSxTruthy((name == "for-each")) ? hoForEach(args, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? (function() {
var mac = envGet(env, name);
@@ -648,7 +701,7 @@
var evalCall = function(head, args, env) { return (function() {
var f = trampoline(evalExpr(head, env));
var evaluatedArgs = map(function(a) { return trampoline(evalExpr(a, env)); }, args);
return (isSxTruthy((isSxTruthy(isCallable(f)) && isSxTruthy(!isLambda(f)) && !isComponent(f))) ? apply(f, evaluatedArgs) : (isSxTruthy(isLambda(f)) ? callLambda(f, evaluatedArgs, env) : (isSxTruthy(isComponent(f)) ? callComponent(f, args, env) : error((String("Not callable: ") + String(inspect(f)))))));
return (isSxTruthy((isSxTruthy(isCallable(f)) && isSxTruthy(!isSxTruthy(isLambda(f))) && !isSxTruthy(isComponent(f)))) ? apply(f, evaluatedArgs) : (isSxTruthy(isLambda(f)) ? callLambda(f, evaluatedArgs, env) : (isSxTruthy(isComponent(f)) ? callComponent(f, args, env) : error((String("Not callable: ") + String(inspect(f)))))));
})(); };
// call-lambda
@@ -687,13 +740,13 @@
// sf-if
var sfIf = function(args, env) { return (function() {
var condition = trampoline(evalExpr(first(args), env));
return (isSxTruthy((isSxTruthy(condition) && !isNil(condition))) ? makeThunk(nth(args, 1), env) : (isSxTruthy((len(args) > 2)) ? makeThunk(nth(args, 2), env) : NIL));
return (isSxTruthy((isSxTruthy(condition) && !isSxTruthy(isNil(condition)))) ? makeThunk(nth(args, 1), env) : (isSxTruthy((len(args) > 2)) ? makeThunk(nth(args, 2), env) : NIL));
})(); };
// sf-when
var sfWhen = function(args, env) { return (function() {
var condition = trampoline(evalExpr(first(args), env));
return (isSxTruthy((isSxTruthy(condition) && !isNil(condition))) ? (forEach(function(e) { return trampoline(evalExpr(e, env)); }, slice(args, 1, (len(args) - 1))), makeThunk(last(args), env)) : NIL);
return (isSxTruthy((isSxTruthy(condition) && !isSxTruthy(isNil(condition)))) ? (forEach(function(e) { return trampoline(evalExpr(e, env)); }, slice(args, 1, (len(args) - 1))), makeThunk(last(args), env)) : NIL);
})(); };
// sf-cond
@@ -731,7 +784,7 @@
// sf-and
var sfAnd = function(args, env) { return (isSxTruthy(isEmpty(args)) ? true : (function() {
var val = trampoline(evalExpr(first(args), env));
return (isSxTruthy(!val) ? val : (isSxTruthy((len(args) == 1)) ? val : sfAnd(rest(args), env)));
return (isSxTruthy(!isSxTruthy(val)) ? val : (isSxTruthy((len(args) == 1)) ? val : sfAnd(rest(args), env)));
})()); };
// sf-or
@@ -878,7 +931,7 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai
var sfQuasiquote = function(args, env) { return qqExpand(first(args), env); };
// qq-expand
var qqExpand = function(template, env) { return (isSxTruthy(!(typeOf(template) == "list")) ? template : (isSxTruthy(isEmpty(template)) ? [] : (function() {
var qqExpand = function(template, env) { return (isSxTruthy(!isSxTruthy((typeOf(template) == "list"))) ? template : (isSxTruthy(isEmpty(template)) ? [] : (function() {
var head = first(template);
return (isSxTruthy((isSxTruthy((typeOf(head) == "symbol")) && (symbolName(head) == "unquote"))) ? trampoline(evalExpr(nth(template, 1), env)) : reduce(function(result, item) { return (isSxTruthy((isSxTruthy((typeOf(item) == "list")) && isSxTruthy((len(item) == 2)) && isSxTruthy((typeOf(first(item)) == "symbol")) && (symbolName(first(item)) == "splice-unquote"))) ? (function() {
var spliced = trampoline(evalExpr(nth(item, 1), env));
@@ -893,10 +946,10 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai
var f = trampoline(evalExpr(first(form), env));
var restArgs = map(function(a) { return trampoline(evalExpr(a, env)); }, rest(form));
var allArgs = cons(result, restArgs);
return (isSxTruthy((isSxTruthy(isCallable(f)) && !isLambda(f))) ? apply(f, allArgs) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, allArgs, env)) : error((String("-> form not callable: ") + String(inspect(f))))));
return (isSxTruthy((isSxTruthy(isCallable(f)) && !isSxTruthy(isLambda(f)))) ? apply(f, allArgs) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, allArgs, env)) : error((String("-> form not callable: ") + String(inspect(f))))));
})() : (function() {
var f = trampoline(evalExpr(form, env));
return (isSxTruthy((isSxTruthy(isCallable(f)) && !isLambda(f))) ? f(result) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, [result], env)) : error((String("-> form not callable: ") + String(inspect(f))))));
return (isSxTruthy((isSxTruthy(isCallable(f)) && !isSxTruthy(isLambda(f)))) ? f(result) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, [result], env)) : error((String("-> form not callable: ") + String(inspect(f))))));
})()); }, val, rest(args));
})(); };
@@ -1047,11 +1100,11 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai
// render-attrs
var renderAttrs = function(attrs) { return join("", map(function(key) { return (function() {
var val = dictGet(attrs, key);
return (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && val)) ? (String(" ") + String(key)) : (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && !val)) ? "" : (isSxTruthy(isNil(val)) ? "" : (isSxTruthy((isSxTruthy((key == "style")) && isStyleValue(val))) ? (String(" class=\"") + String(styleValueClass(val)) + String("\"")) : (String(" ") + String(key) + String("=\"") + String(escapeAttr((String(val)))) + String("\""))))));
return (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && val)) ? (String(" ") + String(key)) : (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && !isSxTruthy(val))) ? "" : (isSxTruthy(isNil(val)) ? "" : (isSxTruthy((isSxTruthy((key == "style")) && isStyleValue(val))) ? (String(" class=\"") + String(styleValueClass(val)) + String("\"")) : (String(" ") + String(key) + String("=\"") + String(escapeAttr((String(val)))) + String("\""))))));
})(); }, keys(attrs))); };
// eval-cond
var evalCond = function(clauses, env) { return (isSxTruthy((isSxTruthy(!isEmpty(clauses)) && isSxTruthy((typeOf(first(clauses)) == "list")) && (len(first(clauses)) == 2))) ? evalCondScheme(clauses, env) : evalCondClojure(clauses, env)); };
var evalCond = function(clauses, env) { return (isSxTruthy((isSxTruthy(!isSxTruthy(isEmpty(clauses))) && isSxTruthy((typeOf(first(clauses)) == "list")) && (len(first(clauses)) == 2))) ? evalCondScheme(clauses, env) : evalCondClojure(clauses, env)); };
// eval-cond-scheme
var evalCondScheme = function(clauses, env) { return (isSxTruthy(isEmpty(clauses)) ? NIL : (function() {
@@ -1087,7 +1140,7 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai
var sxParse = function(source) { return (function() {
var pos = 0;
var lenSrc = len(source);
var skipComment = function() { while(true) { if (isSxTruthy((isSxTruthy((pos < lenSrc)) && !(nth(source, pos) == "\n")))) { pos = (pos + 1);
var skipComment = function() { while(true) { if (isSxTruthy((isSxTruthy((pos < lenSrc)) && !isSxTruthy((nth(source, pos) == "\n"))))) { pos = (pos + 1);
continue; } else { return NIL; } } };
var skipWs = function() { while(true) { if (isSxTruthy((pos < lenSrc))) { { var ch = nth(source, pos);
if (isSxTruthy(sxOr((ch == " "), (ch == "\t"), (ch == "\n"), (ch == "\\r")))) { pos = (pos + 1);
@@ -1209,7 +1262,7 @@ continue; } else { return NIL; } } };
// render-list-to-html
var renderListToHtml = function(expr, env) { return (isSxTruthy(isEmpty(expr)) ? "" : (function() {
var head = first(expr);
return (isSxTruthy(!(typeOf(head) == "symbol")) ? join("", map(function(x) { return renderValueToHtml(x, env); }, expr)) : (function() {
return (isSxTruthy(!isSxTruthy((typeOf(head) == "symbol"))) ? join("", map(function(x) { return renderValueToHtml(x, env); }, expr)) : (function() {
var name = symbolName(head);
var args = rest(expr);
return (isSxTruthy((name == "<>")) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy((name == "raw!")) ? join("", map(function(x) { return (String(trampoline(evalExpr(x, env)))); }, args)) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderHtmlElement(name, args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() {
@@ -1223,7 +1276,7 @@ continue; } else { return NIL; } } };
var dispatchHtmlForm = function(name, expr, env) { return (isSxTruthy((name == "if")) ? (function() {
var condVal = trampoline(evalExpr(nth(expr, 1), env));
return (isSxTruthy(condVal) ? renderToHtml(nth(expr, 2), env) : (isSxTruthy((len(expr) > 3)) ? renderToHtml(nth(expr, 3), env) : ""));
})() : (isSxTruthy((name == "when")) ? (isSxTruthy(!trampoline(evalExpr(nth(expr, 1), env))) ? "" : join("", map(function(i) { return renderToHtml(nth(expr, i), env); }, range(2, len(expr))))) : (isSxTruthy((name == "cond")) ? (function() {
})() : (isSxTruthy((name == "when")) ? (isSxTruthy(!isSxTruthy(trampoline(evalExpr(nth(expr, 1), env)))) ? "" : join("", map(function(i) { return renderToHtml(nth(expr, i), env); }, range(2, len(expr))))) : (isSxTruthy((name == "cond")) ? (function() {
var branch = evalCond(rest(expr), env);
return (isSxTruthy(branch) ? renderToHtml(branch, env) : "");
})() : (isSxTruthy((name == "case")) ? renderToHtml(trampoline(evalExpr(expr, env)), env) : (isSxTruthy(sxOr((name == "let"), (name == "let*"))) ? (function() {
@@ -1300,19 +1353,19 @@ continue; } else { return NIL; } } };
var aserList = function(expr, env) { return (function() {
var head = first(expr);
var args = rest(expr);
return (isSxTruthy(!(typeOf(head) == "symbol")) ? map(function(x) { return aser(x, env); }, expr) : (function() {
return (isSxTruthy(!isSxTruthy((typeOf(head) == "symbol"))) ? map(function(x) { return aser(x, env); }, expr) : (function() {
var name = symbolName(head);
return (isSxTruthy((name == "<>")) ? aserFragment(args, env) : (isSxTruthy(startsWith(name, "~")) ? aserCall(name, args, env) : (isSxTruthy(contains(HTML_TAGS, name)) ? aserCall(name, args, env) : (isSxTruthy(sxOr(isSpecialForm(name), isHoForm(name))) ? aserSpecial(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? aser(expandMacro(envGet(env, name), args, env), env) : (function() {
var f = trampoline(evalExpr(head, env));
var evaledArgs = map(function(a) { return trampoline(evalExpr(a, env)); }, args);
return (isSxTruthy((isSxTruthy(isCallable(f)) && isSxTruthy(!isLambda(f)) && !isComponent(f))) ? apply(f, evaledArgs) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, evaledArgs, env)) : (isSxTruthy(isComponent(f)) ? aserCall((String("~") + String(componentName(f))), args, env) : error((String("Not callable: ") + String(inspect(f)))))));
return (isSxTruthy((isSxTruthy(isCallable(f)) && isSxTruthy(!isSxTruthy(isLambda(f))) && !isSxTruthy(isComponent(f)))) ? apply(f, evaledArgs) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, evaledArgs, env)) : (isSxTruthy(isComponent(f)) ? aserCall((String("~") + String(componentName(f))), args, env) : error((String("Not callable: ") + String(inspect(f)))))));
})())))));
})());
})(); };
// aser-fragment
var aserFragment = function(children, env) { return (function() {
var parts = filter(function(x) { return !isNil(x); }, map(function(c) { return aser(c, env); }, children));
var parts = filter(function(x) { return !isSxTruthy(isNil(x)); }, map(function(c) { return aser(c, env); }, children));
return (isSxTruthy(isEmpty(parts)) ? "" : (String("(<> ") + String(join(" ", map(serialize, parts))) + String(")")));
})(); };
@@ -1323,14 +1376,14 @@ continue; } else { return NIL; } } };
var skip = get(state, "skip");
return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() {
var val = aser(nth(args, (get(state, "i") + 1)), env);
if (isSxTruthy(!isNil(val))) {
if (isSxTruthy(!isSxTruthy(isNil(val)))) {
parts.push((String(":") + String(keywordName(arg))));
parts.push(serialize(val));
}
return assoc(state, "skip", true, "i", (get(state, "i") + 1));
})() : (function() {
var val = aser(arg, env);
if (isSxTruthy(!isNil(val))) {
if (isSxTruthy(!isSxTruthy(isNil(val)))) {
parts.push(serialize(val));
}
return assoc(state, "i", (get(state, "i") + 1));
@@ -1380,7 +1433,7 @@ continue; } else { return NIL; } } };
var attrVal = trampoline(evalExpr(nth(args, (get(state, "i") + 1)), env));
(isSxTruthy(sxOr(isNil(attrVal), (attrVal == false))) ? NIL : (isSxTruthy((isSxTruthy((attrName == "style")) && isStyleValue(attrVal))) ? (extraClass = styleValueClass(attrVal)) : (isSxTruthy(contains(BOOLEAN_ATTRS, attrName)) ? (isSxTruthy(attrVal) ? domSetAttr(el, attrName, "") : NIL) : (isSxTruthy((attrVal == true)) ? domSetAttr(el, attrName, "") : domSetAttr(el, attrName, (String(attrVal)))))));
return assoc(state, "skip", true, "i", (get(state, "i") + 1));
})() : ((isSxTruthy(!contains(VOID_ELEMENTS, tag)) ? domAppend(el, renderToDom(arg, env, newNs)) : NIL), assoc(state, "i", (get(state, "i") + 1)))));
})() : ((isSxTruthy(!isSxTruthy(contains(VOID_ELEMENTS, tag))) ? domAppend(el, renderToDom(arg, env, newNs)) : NIL), assoc(state, "i", (get(state, "i") + 1)))));
})(); }, {["i"]: 0, ["skip"]: false}, args);
if (isSxTruthy(extraClass)) {
(function() {
@@ -1429,18 +1482,13 @@ continue; } else { return NIL; } } };
var frag = createFragment();
{ var _c = args; for (var _i = 0; _i < _c.length; _i++) { var arg = _c[_i]; (function() {
var val = trampoline(evalExpr(arg, env));
return (isSxTruthy((typeOf(val) == "string")) ? domAppend(frag, domParseHtml(val)) : (isSxTruthy((typeOf(val) == "dom-node")) ? domAppend(frag, domClone(val)) : (isSxTruthy(!isNil(val)) ? domAppend(frag, createTextNode((String(val)))) : NIL)));
return (isSxTruthy((typeOf(val) == "string")) ? domAppend(frag, domParseHtml(val)) : (isSxTruthy((typeOf(val) == "dom-node")) ? domAppend(frag, domClone(val)) : (isSxTruthy(!isSxTruthy(isNil(val))) ? domAppend(frag, createTextNode((String(val)))) : NIL)));
})(); } }
return frag;
})(); };
// render-dom-unknown-component
var renderDomUnknownComponent = function(name) { return (function() {
var el = domCreateElement("div", NIL);
domSetAttr(el, "style", "background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;padding:4px 8px;margin:2px;border-radius:4px;font-size:12px;font-family:monospace");
domAppend(el, createTextNode((String("Unknown component: ") + String(name))));
return el;
})(); };
var renderDomUnknownComponent = function(name) { return error((String("Unknown component: ") + String(name))); };
// RENDER_DOM_FORMS
var RENDER_DOM_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defmacro", "defstyle", "defkeyframes", "defhandler", "map", "map-indexed", "filter", "for-each"];
@@ -1452,7 +1500,7 @@ continue; } else { return NIL; } } };
var dispatchRenderForm = function(name, expr, env, ns) { return (isSxTruthy((name == "if")) ? (function() {
var condVal = trampoline(evalExpr(nth(expr, 1), env));
return (isSxTruthy(condVal) ? renderToDom(nth(expr, 2), env, ns) : (isSxTruthy((len(expr) > 3)) ? renderToDom(nth(expr, 3), env, ns) : createFragment()));
})() : (isSxTruthy((name == "when")) ? (isSxTruthy(!trampoline(evalExpr(nth(expr, 1), env))) ? createFragment() : (function() {
})() : (isSxTruthy((name == "when")) ? (isSxTruthy(!isSxTruthy(trampoline(evalExpr(nth(expr, 1), env)))) ? createFragment() : (function() {
var frag = createFragment();
{ var _c = range(2, len(expr)); for (var _i = 0; _i < _c.length; _i++) { var i = _c[_i]; domAppend(frag, renderToDom(nth(expr, i), env, ns)); } }
return frag;
@@ -1519,7 +1567,7 @@ continue; } else { return NIL; } } };
// parse-trigger-spec
var parseTriggerSpec = function(spec) { return (isSxTruthy(isNil(spec)) ? NIL : (function() {
var rawParts = split(spec, ",");
return filter(function(x) { return !isNil(x); }, map(function(part) { return (function() {
return filter(function(x) { return !isSxTruthy(isNil(x)); }, map(function(part) { return (function() {
var tokens = split(trim(part), " ");
return (isSxTruthy(isEmpty(tokens)) ? NIL : (isSxTruthy((isSxTruthy((first(tokens) == "every")) && (len(tokens) >= 2))) ? {["event"]: "every", ["modifiers"]: {["interval"]: parseTime(nth(tokens, 1))}} : (function() {
var mods = {};
@@ -1545,7 +1593,7 @@ continue; } else { return NIL; } } };
var targetSel = domGetAttr(el, "sx-target");
return (isSxTruthy(targetSel) ? dictSet(headers, "SX-Target", targetSel) : NIL);
})();
if (isSxTruthy(!isEmpty(loadedComponents))) {
if (isSxTruthy(!isSxTruthy(isEmpty(loadedComponents)))) {
headers["SX-Components"] = join(",", loadedComponents);
}
if (isSxTruthy(cssHash)) {
@@ -1585,7 +1633,7 @@ continue; } else { return NIL; } } };
// filter-params
var filterParams = function(paramsSpec, allParams) { return (isSxTruthy(isNil(paramsSpec)) ? allParams : (isSxTruthy((paramsSpec == "none")) ? [] : (isSxTruthy((paramsSpec == "*")) ? allParams : (isSxTruthy(startsWith(paramsSpec, "not ")) ? (function() {
var excluded = map(trim, split(slice(paramsSpec, 4), ","));
return filter(function(p) { return !contains(excluded, first(p)); }, allParams);
return filter(function(p) { return !isSxTruthy(contains(excluded, first(p))); }, allParams);
})() : (function() {
var allowed = map(trim, split(paramsSpec, ","));
return filter(function(p) { return contains(allowed, first(p)); }, allParams);
@@ -1635,15 +1683,15 @@ continue; } else { return NIL; } } };
})(); };
// morph-node
var morphNode = function(oldNode, newNode) { return (isSxTruthy(sxOr(domHasAttr(oldNode, "sx-preserve"), domHasAttr(oldNode, "sx-ignore"))) ? NIL : (isSxTruthy(sxOr(!(domNodeType(oldNode) == domNodeType(newNode)), !(domNodeName(oldNode) == domNodeName(newNode)))) ? domReplaceChild(domParent(oldNode), domClone(newNode), oldNode) : (isSxTruthy(sxOr((domNodeType(oldNode) == 3), (domNodeType(oldNode) == 8))) ? (isSxTruthy(!(domTextContent(oldNode) == domTextContent(newNode))) ? domSetTextContent(oldNode, domTextContent(newNode)) : NIL) : (isSxTruthy((domNodeType(oldNode) == 1)) ? (syncAttrs(oldNode, newNode), (isSxTruthy(!(isSxTruthy(domIsActiveElement(oldNode)) && domIsInputElement(oldNode))) ? morphChildren(oldNode, newNode) : NIL)) : NIL)))); };
var morphNode = function(oldNode, newNode) { return (isSxTruthy(sxOr(domHasAttr(oldNode, "sx-preserve"), domHasAttr(oldNode, "sx-ignore"))) ? NIL : (isSxTruthy(sxOr(!isSxTruthy((domNodeType(oldNode) == domNodeType(newNode))), !isSxTruthy((domNodeName(oldNode) == domNodeName(newNode))))) ? domReplaceChild(domParent(oldNode), domClone(newNode), oldNode) : (isSxTruthy(sxOr((domNodeType(oldNode) == 3), (domNodeType(oldNode) == 8))) ? (isSxTruthy(!isSxTruthy((domTextContent(oldNode) == domTextContent(newNode)))) ? domSetTextContent(oldNode, domTextContent(newNode)) : NIL) : (isSxTruthy((domNodeType(oldNode) == 1)) ? (syncAttrs(oldNode, newNode), (isSxTruthy(!isSxTruthy((isSxTruthy(domIsActiveElement(oldNode)) && domIsInputElement(oldNode)))) ? morphChildren(oldNode, newNode) : NIL)) : NIL)))); };
// sync-attrs
var syncAttrs = function(oldEl, newEl) { { var _c = domAttrList(newEl); for (var _i = 0; _i < _c.length; _i++) { var attr = _c[_i]; (function() {
var name = first(attr);
var val = nth(attr, 1);
return (isSxTruthy(!(domGetAttr(oldEl, name) == val)) ? domSetAttr(oldEl, name, val) : NIL);
return (isSxTruthy(!isSxTruthy((domGetAttr(oldEl, name) == val))) ? domSetAttr(oldEl, name, val) : NIL);
})(); } }
return forEach(function(attr) { return (isSxTruthy(!domHasAttr(newEl, first(attr))) ? domRemoveAttr(oldEl, first(attr)) : NIL); }, domAttrList(oldEl)); };
return forEach(function(attr) { return (isSxTruthy(!isSxTruthy(domHasAttr(newEl, first(attr)))) ? domRemoveAttr(oldEl, first(attr)) : NIL); }, domAttrList(oldEl)); };
// morph-children
var morphChildren = function(oldParent, newParent) { return (function() {
@@ -1657,14 +1705,14 @@ return forEach(function(attr) { return (isSxTruthy(!domHasAttr(newEl, first(attr
{ var _c = newKids; for (var _i = 0; _i < _c.length; _i++) { var newChild = _c[_i]; (function() {
var matchId = domId(newChild);
var matchById = (isSxTruthy(matchId) ? dictGet(oldById, matchId) : NIL);
return (isSxTruthy((isSxTruthy(matchById) && !isNil(matchById))) ? ((isSxTruthy((isSxTruthy((oi < len(oldKids))) && !(matchById == nth(oldKids, oi)))) ? domInsertBefore(oldParent, matchById, (isSxTruthy((oi < len(oldKids))) ? nth(oldKids, oi) : NIL)) : NIL), morphNode(matchById, newChild), (oi = (oi + 1))) : (isSxTruthy((oi < len(oldKids))) ? (function() {
return (isSxTruthy((isSxTruthy(matchById) && !isSxTruthy(isNil(matchById)))) ? ((isSxTruthy((isSxTruthy((oi < len(oldKids))) && !isSxTruthy((matchById == nth(oldKids, oi))))) ? domInsertBefore(oldParent, matchById, (isSxTruthy((oi < len(oldKids))) ? nth(oldKids, oi) : NIL)) : NIL), morphNode(matchById, newChild), (oi = (oi + 1))) : (isSxTruthy((oi < len(oldKids))) ? (function() {
var oldChild = nth(oldKids, oi);
return (isSxTruthy((isSxTruthy(domId(oldChild)) && !matchId)) ? domInsertBefore(oldParent, domClone(newChild), oldChild) : (morphNode(oldChild, newChild), (oi = (oi + 1))));
return (isSxTruthy((isSxTruthy(domId(oldChild)) && !isSxTruthy(matchId))) ? domInsertBefore(oldParent, domClone(newChild), oldChild) : (morphNode(oldChild, newChild), (oi = (oi + 1))));
})() : domAppend(oldParent, domClone(newChild))));
})(); } }
return forEach(function(i) { return (isSxTruthy((i >= oi)) ? (function() {
var leftover = nth(oldKids, i);
return (isSxTruthy((isSxTruthy(domIsChildOf(leftover, oldParent)) && isSxTruthy(!domHasAttr(leftover, "sx-preserve")) && !domHasAttr(leftover, "sx-ignore"))) ? domRemoveChild(oldParent, leftover) : NIL);
return (isSxTruthy((isSxTruthy(domIsChildOf(leftover, oldParent)) && isSxTruthy(!isSxTruthy(domHasAttr(leftover, "sx-preserve"))) && !isSxTruthy(domHasAttr(leftover, "sx-ignore")))) ? domRemoveChild(oldParent, leftover) : NIL);
})() : NIL); }, range(oi, len(oldKids)));
})(); };
@@ -1709,7 +1757,7 @@ return forEach(function(attr) { return (isSxTruthy(!domHasAttr(newEl, first(attr
var pushUrl = domGetAttr(el, "sx-push-url");
var replaceUrl = domGetAttr(el, "sx-replace-url");
var hdrReplace = get(respHeaders, "replace-url");
return (isSxTruthy(hdrReplace) ? browserReplaceState(hdrReplace) : (isSxTruthy((isSxTruthy(pushUrl) && !(pushUrl == "false"))) ? browserPushState((isSxTruthy((pushUrl == "true")) ? url : pushUrl)) : (isSxTruthy((isSxTruthy(replaceUrl) && !(replaceUrl == "false"))) ? browserReplaceState((isSxTruthy((replaceUrl == "true")) ? url : replaceUrl)) : NIL)));
return (isSxTruthy(hdrReplace) ? browserReplaceState(hdrReplace) : (isSxTruthy((isSxTruthy(pushUrl) && !isSxTruthy((pushUrl == "false")))) ? browserPushState((isSxTruthy((pushUrl == "true")) ? url : pushUrl)) : (isSxTruthy((isSxTruthy(replaceUrl) && !isSxTruthy((replaceUrl == "false")))) ? browserReplaceState((isSxTruthy((replaceUrl == "true")) ? url : replaceUrl)) : NIL)));
})(); };
// PRELOAD_TTL
@@ -1733,11 +1781,11 @@ return forEach(function(attr) { return (isSxTruthy(!domHasAttr(newEl, first(attr
// should-boost-link?
var shouldBoostLink = function(link) { return (function() {
var href = domGetAttr(link, "href");
return (isSxTruthy(href) && isSxTruthy(!startsWith(href, "#")) && isSxTruthy(!startsWith(href, "javascript:")) && isSxTruthy(!startsWith(href, "mailto:")) && isSxTruthy(browserSameOrigin(href)) && isSxTruthy(!domHasAttr(link, "sx-get")) && isSxTruthy(!domHasAttr(link, "sx-post")) && !domHasAttr(link, "sx-disable"));
return (isSxTruthy(href) && isSxTruthy(!isSxTruthy(startsWith(href, "#"))) && isSxTruthy(!isSxTruthy(startsWith(href, "javascript:"))) && isSxTruthy(!isSxTruthy(startsWith(href, "mailto:"))) && isSxTruthy(browserSameOrigin(href)) && isSxTruthy(!isSxTruthy(domHasAttr(link, "sx-get"))) && isSxTruthy(!isSxTruthy(domHasAttr(link, "sx-post"))) && !isSxTruthy(domHasAttr(link, "sx-disable")));
})(); };
// should-boost-form?
var shouldBoostForm = function(form) { return (isSxTruthy(!domHasAttr(form, "sx-get")) && isSxTruthy(!domHasAttr(form, "sx-post")) && !domHasAttr(form, "sx-disable")); };
var shouldBoostForm = function(form) { return (isSxTruthy(!isSxTruthy(domHasAttr(form, "sx-get"))) && isSxTruthy(!isSxTruthy(domHasAttr(form, "sx-post"))) && !isSxTruthy(domHasAttr(form, "sx-disable"))); };
// parse-sse-swap
var parseSseSwap = function(el) { return sxOr(domGetAttr(el, "sx-sse-swap"), "message"); };
@@ -1756,7 +1804,7 @@ return forEach(function(attr) { return (isSxTruthy(!domHasAttr(newEl, first(attr
var parsed = tryParseJson(headerVal);
return (isSxTruthy(parsed) ? forEach(function(key) { return domDispatch(el, key, get(parsed, key)); }, keys(parsed)) : forEach(function(name) { return (function() {
var trimmed = trim(name);
return (isSxTruthy(!isEmpty(trimmed)) ? domDispatch(el, trimmed, {}) : NIL);
return (isSxTruthy(!isSxTruthy(isEmpty(trimmed))) ? domDispatch(el, trimmed, {}) : NIL);
})(); }, split(headerVal, ",")));
})() : NIL); };
@@ -1777,14 +1825,14 @@ return forEach(function(attr) { return (isSxTruthy(!domHasAttr(newEl, first(attr
var url = get(info, "url");
return (isSxTruthy((function() {
var media = domGetAttr(el, "sx-media");
return (isSxTruthy(media) && !browserMediaMatches(media));
return (isSxTruthy(media) && !isSxTruthy(browserMediaMatches(media)));
})()) ? promiseResolve(NIL) : (isSxTruthy((function() {
var confirmMsg = domGetAttr(el, "sx-confirm");
return (isSxTruthy(confirmMsg) && !browserConfirm(confirmMsg));
return (isSxTruthy(confirmMsg) && !isSxTruthy(browserConfirm(confirmMsg)));
})()) ? promiseResolve(NIL) : (function() {
var promptMsg = domGetAttr(el, "sx-prompt");
var promptVal = (isSxTruthy(promptMsg) ? browserPrompt(promptMsg) : NIL);
return (isSxTruthy((isSxTruthy(promptMsg) && isNil(promptVal))) ? promiseResolve(NIL) : (isSxTruthy(!validateForRequest(el)) ? promiseResolve(NIL) : doFetch(el, verb, verb, url, (isSxTruthy(promptVal) ? assoc(sxOr(extraParams, {}), "SX-Prompt", promptVal) : extraParams))));
return (isSxTruthy((isSxTruthy(promptMsg) && isNil(promptVal))) ? promiseResolve(NIL) : (isSxTruthy(!isSxTruthy(validateForRequest(el))) ? promiseResolve(NIL) : doFetch(el, verb, verb, url, (isSxTruthy(promptVal) ? assoc(sxOr(extraParams, {}), "SX-Prompt", promptVal) : extraParams))));
})()));
})());
})(); };
@@ -1822,7 +1870,7 @@ return forEach(function(attr) { return (isSxTruthy(!domHasAttr(newEl, first(attr
domAddClass(el, "sx-request");
domSetAttr(el, "aria-busy", "true");
domDispatch(el, "sx:beforeRequest", {["url"]: finalUrl, ["method"]: method});
return fetchRequest({["url"]: finalUrl, ["method"]: method, ["headers"]: headers, ["body"]: body, ["signal"]: controllerSignal(ctrl), ["cross-origin"]: isCrossOrigin(finalUrl), ["preloaded"]: cached}, function(respOk, status, getHeader, text) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!respOk) ? (domDispatch(el, "sx:responseError", {["status"]: status, ["text"]: text}), handleRetry(el, verb, method, finalUrl, extraParams)) : (domDispatch(el, "sx:afterRequest", {["status"]: status}), handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text)))); }, function(err) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isAbortError(err)) ? domDispatch(el, "sx:requestError", {["error"]: err}) : NIL)); });
return fetchRequest({["url"]: finalUrl, ["method"]: method, ["headers"]: headers, ["body"]: body, ["signal"]: controllerSignal(ctrl), ["cross-origin"]: isCrossOrigin(finalUrl), ["preloaded"]: cached}, function(respOk, status, getHeader, text) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isSxTruthy(respOk)) ? (domDispatch(el, "sx:responseError", {["status"]: status, ["text"]: text}), handleRetry(el, verb, method, finalUrl, extraParams)) : (domDispatch(el, "sx:afterRequest", {["status"]: status}), handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text)))); }, function(err) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isSxTruthy(isAbortError(err))) ? domDispatch(el, "sx:requestError", {["error"]: err}) : NIL)); });
})();
})();
})();
@@ -1859,7 +1907,7 @@ return forEach(function(attr) { return (isSxTruthy(!domHasAttr(newEl, first(attr
var final = extractResponseCss(cleaned);
return (function() {
var trimmed = trim(final);
return (isSxTruthy(!isEmpty(trimmed)) ? (function() {
return (isSxTruthy(!isSxTruthy(isEmpty(trimmed))) ? (function() {
var rendered = sxRender(trimmed);
var container = domCreateElement("div", NIL);
domAppend(container, rendered);
@@ -1935,7 +1983,15 @@ return postSwap(target); });
return (isSxTruthy((val == lastVal)) ? (shouldFire = false) : (lastVal = val));
})();
}
return (isSxTruthy(shouldFire) ? ((isSxTruthy(sxOr((eventName == "submit"), (isSxTruthy((eventName == "click")) && domHasAttr(el, "href")))) ? preventDefault_(e) : NIL), (isSxTruthy(get(mods, "delay")) ? (clearTimeout_(timer), (timer = setTimeout_(function() { return executeRequest(el, verbInfo, NIL); }, get(mods, "delay")))) : executeRequest(el, verbInfo, NIL))) : NIL);
return (isSxTruthy(shouldFire) ? ((isSxTruthy(sxOr((eventName == "submit"), (isSxTruthy((eventName == "click")) && domHasAttr(el, "href")))) ? preventDefault_(e) : NIL), (function() {
var isGetLink = (isSxTruthy((eventName == "click")) && isSxTruthy((get(verbInfo, "method") == "GET")) && isSxTruthy(domHasAttr(el, "href")) && !isSxTruthy(get(mods, "delay")));
var clientRouted = false;
if (isSxTruthy(isGetLink)) {
logInfo((String("sx:route trying ") + String(get(verbInfo, "url"))));
clientRouted = tryClientRoute(urlPathname(get(verbInfo, "url")), domGetAttr(el, "sx-target"));
}
return (isSxTruthy(clientRouted) ? (browserPushState(get(verbInfo, "url")), browserScrollTo(0, 0)) : ((isSxTruthy(isGetLink) ? logInfo((String("sx:route server fetch ") + String(get(verbInfo, "url")))) : NIL), (isSxTruthy(get(mods, "delay")) ? (clearTimeout_(timer), (timer = setTimeout_(function() { return executeRequest(el, verbInfo, NIL); }, get(mods, "delay")))) : executeRequest(el, verbInfo, NIL))));
})()) : NIL);
})(); }, (isSxTruthy(get(mods, "once")) ? {["once"]: true} : NIL)) : NIL);
})(); };
@@ -1948,7 +2004,7 @@ return processElements(root); };
// activate-scripts
var activateScripts = function(root) { return (isSxTruthy(root) ? (function() {
var scripts = domQueryAll(root, "script");
return forEach(function(dead) { return (isSxTruthy((isSxTruthy(!domHasAttr(dead, "data-components")) && !domHasAttr(dead, "data-sx-activated"))) ? (function() {
return forEach(function(dead) { return (isSxTruthy((isSxTruthy(!isSxTruthy(domHasAttr(dead, "data-components"))) && !isSxTruthy(domHasAttr(dead, "data-sx-activated")))) ? (function() {
var live = createScriptClone(dead);
domSetAttr(live, "data-sx-activated", "true");
return domReplaceChild(domParent(dead), live, dead);
@@ -1984,54 +2040,109 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\"
var processBoosted = function(root) { return forEach(function(container) { return boostDescendants(container); }, domQueryAll(sxOr(root, domBody()), "[sx-boost]")); };
// boost-descendants
var boostDescendants = function(container) { { var _c = domQueryAll(container, "a[href]"); for (var _i = 0; _i < _c.length; _i++) { var link = _c[_i]; if (isSxTruthy((isSxTruthy(!isProcessed(link, "boost")) && shouldBoostLink(link)))) {
var boostDescendants = function(container) { return (function() {
var boostTarget = domGetAttr(container, "sx-boost");
{ var _c = domQueryAll(container, "a[href]"); for (var _i = 0; _i < _c.length; _i++) { var link = _c[_i]; if (isSxTruthy((isSxTruthy(!isSxTruthy(isProcessed(link, "boost"))) && shouldBoostLink(link)))) {
markProcessed(link, "boost");
if (isSxTruthy(!domHasAttr(link, "sx-target"))) {
domSetAttr(link, "sx-target", "#main-panel");
if (isSxTruthy((isSxTruthy(!isSxTruthy(domHasAttr(link, "sx-target"))) && isSxTruthy(boostTarget) && !isSxTruthy((boostTarget == "true"))))) {
domSetAttr(link, "sx-target", boostTarget);
}
if (isSxTruthy(!domHasAttr(link, "sx-swap"))) {
if (isSxTruthy(!isSxTruthy(domHasAttr(link, "sx-swap")))) {
domSetAttr(link, "sx-swap", "innerHTML");
}
if (isSxTruthy(!domHasAttr(link, "sx-push-url"))) {
if (isSxTruthy(!isSxTruthy(domHasAttr(link, "sx-push-url")))) {
domSetAttr(link, "sx-push-url", "true");
}
bindClientRouteLink(link, domGetAttr(link, "href"));
} } }
return forEach(function(form) { return (isSxTruthy((isSxTruthy(!isProcessed(form, "boost")) && shouldBoostForm(form))) ? (markProcessed(form, "boost"), (function() {
return forEach(function(form) { return (isSxTruthy((isSxTruthy(!isSxTruthy(isProcessed(form, "boost"))) && shouldBoostForm(form))) ? (markProcessed(form, "boost"), (function() {
var method = upper(sxOr(domGetAttr(form, "method"), "GET"));
var action = sxOr(domGetAttr(form, "action"), browserLocationHref());
if (isSxTruthy(!domHasAttr(form, "sx-target"))) {
domSetAttr(form, "sx-target", "#main-panel");
if (isSxTruthy((isSxTruthy(!isSxTruthy(domHasAttr(form, "sx-target"))) && isSxTruthy(boostTarget) && !isSxTruthy((boostTarget == "true"))))) {
domSetAttr(form, "sx-target", boostTarget);
}
if (isSxTruthy(!domHasAttr(form, "sx-swap"))) {
if (isSxTruthy(!isSxTruthy(domHasAttr(form, "sx-swap")))) {
domSetAttr(form, "sx-swap", "innerHTML");
}
return bindBoostForm(form, method, action);
})()) : NIL); }, domQueryAll(container, "form")); };
})()) : NIL); }, domQueryAll(container, "form"));
})(); };
// _page-data-cache
var _pageDataCache = {};
// _page-data-cache-ttl
var _pageDataCacheTtl = 30000;
// page-data-cache-key
var pageDataCacheKey = function(pageName, params) { return (function() {
var base = pageName;
return (isSxTruthy(sxOr(isNil(params), isEmpty(keys(params)))) ? base : (function() {
var parts = [];
{ var _c = keys(params); for (var _i = 0; _i < _c.length; _i++) { var k = _c[_i]; parts.push((String(k) + String("=") + String(get(params, k)))); } }
return (String(base) + String(":") + String(join("&", parts)));
})());
})(); };
// page-data-cache-get
var pageDataCacheGet = function(cacheKey) { return (function() {
var entry = get(_pageDataCache, cacheKey);
return (isSxTruthy(isNil(entry)) ? NIL : (isSxTruthy(((nowMs() - get(entry, "ts")) > _pageDataCacheTtl)) ? (dictSet(_pageDataCache, cacheKey, NIL), NIL) : get(entry, "data")));
})(); };
// page-data-cache-set
var pageDataCacheSet = function(cacheKey, data) { return dictSet(_pageDataCache, cacheKey, {"data": data, "ts": nowMs()}); };
// 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); };
// deps-satisfied?
var depsSatisfied_p = function(match) { return (function() {
var deps = get(match, "deps");
var loaded = loadedComponentNames();
return (isSxTruthy(sxOr(isNil(deps), isEmpty(deps))) ? true : isEvery(function(dep) { return contains(loaded, dep); }, deps));
})(); };
// try-client-route
var tryClientRoute = function(pathname) { return (function() {
var tryClientRoute = function(pathname, targetSel) { return (function() {
var match = findMatchingRoute(pathname, _pageRoutes);
return (isSxTruthy(isNil(match)) ? false : (isSxTruthy(get(match, "has-data")) ? 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");
return (isSxTruthy(sxOr(isNil(contentSrc), isEmpty(contentSrc))) ? false : (function() {
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(!isSxTruthy(depsSatisfied_p(match))) ? (logInfo((String("sx:route deps miss for ") + String(pageName))), false) : (isSxTruthy(get(match, "has-data")) ? (function() {
var cacheKey = pageDataCacheKey(pageName, params);
var cached = pageDataCacheGet(cacheKey);
return (isSxTruthy(cached) ? (function() {
var env = merge(closure, params, cached);
var rendered = tryEvalContent(contentSrc, env);
return (isSxTruthy(isNil(rendered)) ? (logWarn((String("sx:route cached eval failed for ") + String(pathname))), false) : (logInfo((String("sx:route client+cache ") + String(pathname))), swapRenderedContent(target, rendered, pathname), true));
})() : (logInfo((String("sx:route client+data ") + String(pathname))), resolvePageData(pageName, params, function(data) { pageDataCacheSet(cacheKey, 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)) ? false : (function() {
var target = domQueryById("main-panel");
return (isSxTruthy(isNil(target)) ? false : (domSetTextContent(target, ""), domAppend(target, rendered), hoistHeadElementsFull(target), processElements(target), sxHydrateElements(target), 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
var bindClientRouteLink = function(link, href) { return bindClientRouteClick(link, href, function() { return bindBoostLink(link, href); }); };
// process-sse
var processSse = function(root) { return forEach(function(el) { return (isSxTruthy(!isProcessed(el, "sse")) ? (markProcessed(el, "sse"), bindSse(el)) : NIL); }, domQueryAll(sxOr(root, domBody()), "[sx-sse]")); };
var processSse = function(root) { return forEach(function(el) { return (isSxTruthy(!isSxTruthy(isProcessed(el, "sse"))) ? (markProcessed(el, "sse"), bindSse(el)) : NIL); }, domQueryAll(sxOr(root, domBody()), "[sx-sse]")); };
// bind-sse
var bindSse = function(el) { return (function() {
@@ -2050,7 +2161,7 @@ return forEach(function(form) { return (isSxTruthy((isSxTruthy(!isProcessed(form
var swapStyle = get(swapSpec, "style");
var useTransition = get(swapSpec, "transition");
var trimmed = trim(data);
return (isSxTruthy(!isEmpty(trimmed)) ? (isSxTruthy(startsWith(trimmed, "(")) ? (function() {
return (isSxTruthy(!isSxTruthy(isEmpty(trimmed))) ? (isSxTruthy(startsWith(trimmed, "(")) ? (function() {
var rendered = sxRender(trimmed);
var container = domCreateElement("div", NIL);
domAppend(container, rendered);
@@ -2066,7 +2177,7 @@ return postSwap(target); })) : NIL);
var body = nth(attr, 1);
return (isSxTruthy(startsWith(name, "sx-on:")) ? (function() {
var eventName = slice(name, 6);
return (isSxTruthy(!isProcessed(el, (String("on:") + String(eventName)))) ? (markProcessed(el, (String("on:") + String(eventName))), bindInlineHandler(el, eventName, body)) : NIL);
return (isSxTruthy(!isSxTruthy(isProcessed(el, (String("on:") + String(eventName))))) ? (markProcessed(el, (String("on:") + String(eventName))), bindInlineHandler(el, eventName, body)) : NIL);
})() : NIL);
})(); }, domAttrList(el)); }, domQueryAll(sxOr(root, domBody()), "[sx-on\\:beforeRequest],[sx-on\\:afterRequest],[sx-on\\:afterSwap],[sx-on\\:afterSettle],[sx-on\\:load]")); };
@@ -2094,7 +2205,7 @@ return postSwap(target); })) : NIL);
// process-elements
var processElements = function(root) { (function() {
var els = domQueryAll(sxOr(root, domBody()), VERB_SELECTOR);
return forEach(function(el) { return (isSxTruthy(!isProcessed(el, "verb")) ? (markProcessed(el, "verb"), processOne(el)) : NIL); }, els);
return forEach(function(el) { return (isSxTruthy(!isSxTruthy(isProcessed(el, "verb"))) ? (markProcessed(el, "verb"), processOne(el)) : NIL); }, els);
})();
processBoosted(root);
processSse(root);
@@ -2103,20 +2214,24 @@ return bindInlineHandlers(root); };
// process-one
var processOne = function(el) { return (function() {
var verbInfo = getVerbInfo(el);
return (isSxTruthy(verbInfo) ? (isSxTruthy(!domHasAttr(el, "sx-disable")) ? (bindTriggers(el, verbInfo), bindPreloadFor(el)) : NIL) : NIL);
return (isSxTruthy(verbInfo) ? (isSxTruthy(!isSxTruthy(domHasAttr(el, "sx-disable"))) ? (bindTriggers(el, verbInfo), bindPreloadFor(el)) : NIL) : NIL);
})(); };
// handle-popstate
var handlePopstate = function(scrollY) { return (function() {
var main = domQueryById("main-panel");
var url = browserLocationHref();
return (isSxTruthy(main) ? (function() {
var pathname = urlPathname(url);
return (isSxTruthy(tryClientRoute(pathname)) ? browserScrollTo(0, scrollY) : (function() {
var headers = buildRequestHeaders(main, loadedComponentNames(), _cssHash);
return fetchAndRestore(main, url, headers, scrollY);
})());
var boostEl = domQuery("[sx-boost]");
var targetSel = (isSxTruthy(boostEl) ? (function() {
var attr = domGetAttr(boostEl, "sx-boost");
return (isSxTruthy((isSxTruthy(attr) && !isSxTruthy((attr == "true")))) ? attr : NIL);
})() : NIL);
var targetSel = sxOr(targetSel, "#main-panel");
var target = domQuery(targetSel);
var pathname = urlPathname(url);
return (isSxTruthy(target) ? (isSxTruthy(tryClientRoute(pathname, targetSel)) ? browserScrollTo(0, scrollY) : (function() {
var headers = buildRequestHeaders(target, loadedComponentNames(), _cssHash);
return fetchAndRestore(target, url, headers, scrollY);
})()) : NIL);
})(); };
// engine-init
@@ -2193,7 +2308,7 @@ return (_styleCache = {}); };
// resolve-atom
var resolveAtom = function(atom) { return (function() {
var decls = dictGet(_styleAtoms, atom);
return (isSxTruthy(!isNil(decls)) ? decls : (isSxTruthy(startsWith(atom, "animate-")) ? (function() {
return (isSxTruthy(!isSxTruthy(isNil(decls))) ? decls : (isSxTruthy(startsWith(atom, "animate-")) ? (function() {
var kfName = slice(atom, 8);
return (isSxTruthy(dictHas(_styleKeyframes, kfName)) ? (String("animation-name:") + String(kfName)) : NIL);
})() : (function() {
@@ -2219,7 +2334,7 @@ return (_styleCache = {}); };
var key = join("\\0", atoms);
return (function() {
var cached = dictGet(_styleCache, key);
return (isSxTruthy(!isNil(cached)) ? cached : (function() {
return (isSxTruthy(!isSxTruthy(isNil(cached))) ? cached : (function() {
var baseDecls = [];
var mediaRules = [];
var pseudoRules = [];
@@ -2334,7 +2449,7 @@ allKf = concat(allKf, styleValueKeyframes_(sv)); } }
// sx-hydrate-elements
var sxHydrateElements = function(root) { return (function() {
var els = domQueryAll(sxOr(root, domBody()), "[data-sx]");
return forEach(function(el) { return (isSxTruthy(!isProcessed(el, "hydrated")) ? (markProcessed(el, "hydrated"), sxUpdateElement(el, NIL)) : NIL); }, els);
return forEach(function(el) { return (isSxTruthy(!isSxTruthy(isProcessed(el, "hydrated"))) ? (markProcessed(el, "hydrated"), sxUpdateElement(el, NIL)) : NIL); }, els);
})(); };
// sx-update-element
@@ -2361,7 +2476,7 @@ allKf = concat(allKf, styleValueKeyframes_(sv)); } }
return (function() {
var env = getRenderEnv(extraEnv);
var comp = envGet(env, fullName);
return (isSxTruthy(!isComponent(comp)) ? error((String("Unknown component: ") + String(fullName))) : (function() {
return (isSxTruthy(!isSxTruthy(isComponent(comp))) ? error((String("Unknown component: ") + String(fullName))) : (function() {
var callExpr = [makeSymbol(fullName)];
{ var _c = keys(kwargs); for (var _i = 0; _i < _c.length; _i++) { var k = _c[_i]; callExpr.push(makeKeyword(toKebab(k)));
callExpr.push(dictGet(kwargs, k)); } }
@@ -2373,7 +2488,7 @@ callExpr.push(dictGet(kwargs, k)); } }
// process-sx-scripts
var processSxScripts = function(root) { return (function() {
var scripts = querySxScripts(root);
return forEach(function(s) { return (isSxTruthy(!isProcessed(s, "script")) ? (markProcessed(s, "script"), (function() {
return forEach(function(s) { return (isSxTruthy(!isSxTruthy(isProcessed(s, "script"))) ? (markProcessed(s, "script"), (function() {
var text = domTextContent(s);
return (isSxTruthy(domHasAttr(s, "data-components")) ? processComponentScript(s, text) : (isSxTruthy(sxOr(isNil(text), isEmpty(trim(text)))) ? NIL : (isSxTruthy(domHasAttr(s, "data-mount")) ? (function() {
var mountSel = domGetAttr(s, "data-mount");
@@ -2386,8 +2501,8 @@ callExpr.push(dictGet(kwargs, k)); } }
// process-component-script
var processComponentScript = function(script, text) { return (function() {
var hash = domGetAttr(script, "data-hash");
return (isSxTruthy(isNil(hash)) ? (isSxTruthy((isSxTruthy(text) && !isEmpty(trim(text)))) ? sxLoadComponents(text) : NIL) : (function() {
var hasInline = (isSxTruthy(text) && !isEmpty(trim(text)));
return (isSxTruthy(isNil(hash)) ? (isSxTruthy((isSxTruthy(text) && !isSxTruthy(isEmpty(trim(text))))) ? sxLoadComponents(text) : NIL) : (function() {
var hasInline = (isSxTruthy(text) && !isSxTruthy(isEmpty(trim(text))));
(function() {
var cachedHash = localStorageGet("sx-components-hash");
return (isSxTruthy((cachedHash == hash)) ? (isSxTruthy(hasInline) ? (localStorageSet("sx-components-hash", hash), localStorageSet("sx-components-src", text), sxLoadComponents(text), logInfo("components: downloaded (cookie stale)")) : (function() {
@@ -2402,11 +2517,11 @@ callExpr.push(dictGet(kwargs, k)); } }
// init-style-dict
var initStyleDict = function() { return (function() {
var scripts = queryStyleScripts();
return forEach(function(s) { return (isSxTruthy(!isProcessed(s, "styles")) ? (markProcessed(s, "styles"), (function() {
return forEach(function(s) { return (isSxTruthy(!isSxTruthy(isProcessed(s, "styles"))) ? (markProcessed(s, "styles"), (function() {
var text = domTextContent(s);
var hash = domGetAttr(s, "data-hash");
return (isSxTruthy(isNil(hash)) ? (isSxTruthy((isSxTruthy(text) && !isEmpty(trim(text)))) ? parseAndLoadStyleDict(text) : NIL) : (function() {
var hasInline = (isSxTruthy(text) && !isEmpty(trim(text)));
return (isSxTruthy(isNil(hash)) ? (isSxTruthy((isSxTruthy(text) && !isSxTruthy(isEmpty(trim(text))))) ? parseAndLoadStyleDict(text) : NIL) : (function() {
var hasInline = (isSxTruthy(text) && !isSxTruthy(isEmpty(trim(text))));
(function() {
var cachedHash = localStorageGet("sx-styles-hash");
return (isSxTruthy((cachedHash == hash)) ? (isSxTruthy(hasInline) ? (localStorageSet("sx-styles-src", text), parseAndLoadStyleDict(text), logInfo("styles: downloaded (cookie stale)")) : (function() {
@@ -2425,17 +2540,24 @@ callExpr.push(dictGet(kwargs, k)); } }
// process-page-scripts
var processPageScripts = function() { return (function() {
var scripts = queryPageScripts();
return forEach(function(s) { return (isSxTruthy(!isProcessed(s, "pages")) ? (markProcessed(s, "pages"), (function() {
logInfo((String("pages: found ") + String(len(scripts)) + String(" script tags")));
{ var _c = scripts; for (var _i = 0; _i < _c.length; _i++) { var s = _c[_i]; if (isSxTruthy(!isSxTruthy(isProcessed(s, "pages")))) {
markProcessed(s, "pages");
(function() {
var text = domTextContent(s);
return (isSxTruthy((isSxTruthy(text) && !isEmpty(trim(text)))) ? (function() {
logInfo((String("pages: script text length=") + String((isSxTruthy(text) ? len(text) : 0))));
return (isSxTruthy((isSxTruthy(text) && !isSxTruthy(isEmpty(trim(text))))) ? (function() {
var pages = parse(text);
logInfo((String("pages: parsed ") + String(len(pages)) + String(" entries")));
return forEach(function(page) { return append_b(_pageRoutes, merge(page, {"parsed": parseRoutePattern(get(page, "path"))})); }, pages);
})() : NIL);
})()) : NIL); }, scripts);
})() : logWarn("pages: script tag is empty"));
})();
} } }
return logInfo((String("pages: ") + String(len(_pageRoutes)) + String(" routes loaded")));
})(); };
// boot-init
var bootInit = function() { return (initCssTracking(), initStyleDict(), processSxScripts(NIL), processPageScripts(), sxHydrateElements(NIL), processElements(NIL)); };
var bootInit = function() { return (logInfo((String("sx-browser ") + String(SX_VERSION))), initCssTracking(), initStyleDict(), processPageScripts(), processSxScripts(NIL), sxHydrateElements(NIL), processElements(NIL)); };
// === Transpiled from deps (component dependency analysis) ===
@@ -2450,11 +2572,11 @@ callExpr.push(dictGet(kwargs, k)); } }
// scan-refs-walk
var scanRefsWalk = function(node, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() {
var name = symbolName(node);
return (isSxTruthy(startsWith(name, "~")) ? (isSxTruthy(!contains(refs, name)) ? append_b(refs, name) : NIL) : NIL);
return (isSxTruthy(startsWith(name, "~")) ? (isSxTruthy(!isSxTruthy(contains(refs, name))) ? append_b(refs, name) : NIL) : NIL);
})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanRefsWalk(item, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanRefsWalk(dictGet(node, key), refs); }, keys(node)) : NIL))); };
// transitive-deps-walk
var transitiveDepsWalk = function(n, seen, env) { return (isSxTruthy(!contains(seen, n)) ? (append_b(seen, n), (function() {
var transitiveDepsWalk = function(n, seen, env) { return (isSxTruthy(!isSxTruthy(contains(seen, n))) ? (append_b(seen, n), (function() {
var val = envGet(env, n);
return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(componentBody(val))) : (isSxTruthy((typeOf(val) == "macro")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(macroBody(val))) : NIL));
})()) : NIL); };
@@ -2464,7 +2586,7 @@ callExpr.push(dictGet(kwargs, k)); } }
var seen = [];
var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name)));
transitiveDepsWalk(key, seen, env);
return filter(function(x) { return !(x == key); }, seen);
return filter(function(x) { return !isSxTruthy((x == key)); }, seen);
})(); };
// compute-all-deps
@@ -2483,14 +2605,14 @@ callExpr.push(dictGet(kwargs, k)); } }
var componentsNeeded = function(pageSource, env) { return (function() {
var direct = scanComponentsFromSource(pageSource);
var allNeeded = [];
{ var _c = direct; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; if (isSxTruthy(!contains(allNeeded, name))) {
{ var _c = direct; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; if (isSxTruthy(!isSxTruthy(contains(allNeeded, name)))) {
allNeeded.push(name);
}
(function() {
var val = envGet(env, name);
return (function() {
var deps = (isSxTruthy((isSxTruthy((typeOf(val) == "component")) && !isEmpty(componentDeps(val)))) ? componentDeps(val) : transitiveDeps(name, env));
return forEach(function(dep) { return (isSxTruthy(!contains(allNeeded, dep)) ? append_b(allNeeded, dep) : NIL); }, deps);
var deps = (isSxTruthy((isSxTruthy((typeOf(val) == "component")) && !isSxTruthy(isEmpty(componentDeps(val))))) ? componentDeps(val) : transitiveDeps(name, env));
return forEach(function(dep) { return (isSxTruthy(!isSxTruthy(contains(allNeeded, dep))) ? append_b(allNeeded, dep) : NIL); }, deps);
})();
})(); } }
return allNeeded;
@@ -2505,9 +2627,9 @@ callExpr.push(dictGet(kwargs, k)); } }
var classes = [];
{ var _c = needed; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; (function() {
var val = envGet(env, name);
return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(cls) { return (isSxTruthy(!contains(classes, cls)) ? append_b(classes, cls) : NIL); }, componentCssClasses(val)) : NIL);
return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(cls) { return (isSxTruthy(!isSxTruthy(contains(classes, cls))) ? append_b(classes, cls) : NIL); }, componentCssClasses(val)) : NIL);
})(); } }
{ var _c = scanCssClasses(pageSource); for (var _i = 0; _i < _c.length; _i++) { var cls = _c[_i]; if (isSxTruthy(!contains(classes, cls))) {
{ var _c = scanCssClasses(pageSource); for (var _i = 0; _i < _c.length; _i++) { var cls = _c[_i]; if (isSxTruthy(!isSxTruthy(contains(classes, cls)))) {
classes.push(cls);
} } }
return classes;
@@ -2516,7 +2638,7 @@ callExpr.push(dictGet(kwargs, k)); } }
// scan-io-refs-walk
var scanIoRefsWalk = function(node, ioNames, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() {
var name = symbolName(node);
return (isSxTruthy(contains(ioNames, name)) ? (isSxTruthy(!contains(refs, name)) ? append_b(refs, name) : NIL) : NIL);
return (isSxTruthy(contains(ioNames, name)) ? (isSxTruthy(!isSxTruthy(contains(refs, name))) ? append_b(refs, name) : NIL) : NIL);
})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanIoRefsWalk(item, ioNames, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanIoRefsWalk(dictGet(node, key), ioNames, refs); }, keys(node)) : NIL))); };
// scan-io-refs
@@ -2527,9 +2649,9 @@ callExpr.push(dictGet(kwargs, k)); } }
})(); };
// transitive-io-refs-walk
var transitiveIoRefsWalk = function(n, seen, allRefs, env, ioNames) { return (isSxTruthy(!contains(seen, n)) ? (append_b(seen, n), (function() {
var transitiveIoRefsWalk = function(n, seen, allRefs, env, ioNames) { return (isSxTruthy(!isSxTruthy(contains(seen, n))) ? (append_b(seen, n), (function() {
var val = envGet(env, n);
return (isSxTruthy((typeOf(val) == "component")) ? (forEach(function(ref) { return (isSxTruthy(!contains(allRefs, ref)) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(componentBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(componentBody(val)))) : (isSxTruthy((typeOf(val) == "macro")) ? (forEach(function(ref) { return (isSxTruthy(!contains(allRefs, ref)) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(macroBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(macroBody(val)))) : NIL));
return (isSxTruthy((typeOf(val) == "component")) ? (forEach(function(ref) { return (isSxTruthy(!isSxTruthy(contains(allRefs, ref))) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(componentBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(componentBody(val)))) : (isSxTruthy((typeOf(val) == "macro")) ? (forEach(function(ref) { return (isSxTruthy(!isSxTruthy(contains(allRefs, ref))) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(macroBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(macroBody(val)))) : NIL));
})()) : NIL); };
// transitive-io-refs
@@ -2557,14 +2679,14 @@ callExpr.push(dictGet(kwargs, k)); } }
var splitPathSegments = function(path) { return (function() {
var trimmed = (isSxTruthy(startsWith(path, "/")) ? slice(path, 1) : path);
return (function() {
var trimmed2 = (isSxTruthy((isSxTruthy(!isEmpty(trimmed)) && endsWith(trimmed, "/"))) ? slice(trimmed, 0, (length(trimmed) - 1)) : trimmed);
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, (length(seg) - 1));
var paramName = slice(seg, 1, (len(seg) - 1));
return (function() {
var d = {};
d["type"] = "param";
@@ -2585,13 +2707,13 @@ callExpr.push(dictGet(kwargs, k)); } }
})(); };
// match-route-segments
var matchRouteSegments = function(pathSegs, parsedSegs) { return (isSxTruthy(!(length(pathSegs) == length(parsedSegs))) ? NIL : (function() {
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(!(pathSeg == get(parsedSeg, "value"))) ? (matched = false) : NIL) : (isSxTruthy((segType == "param")) ? dictSet(params, get(parsedSeg, "value"), pathSeg) : (matched = false)));
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);
})()); };
@@ -2610,7 +2732,7 @@ callExpr.push(dictGet(kwargs, k)); } }
{ 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(!isNil(params)) ? (function() {
return (isSxTruthy(!isSxTruthy(isNil(params))) ? (function() {
var matched = merge(route, {});
matched["params"] = params;
return (result = matched);
@@ -2777,6 +2899,84 @@ callExpr.push(dictGet(kwargs, k)); } }
function domTagName(el) { return el && el.tagName ? el.tagName : ""; }
// =========================================================================
// Performance overrides — replace transpiled spec with imperative JS
// =========================================================================
// Override renderDomComponent: imperative kwarg parsing, no reduce/assoc
renderDomComponent = function(comp, args, env, ns) {
// Parse keyword args imperatively
var kwargs = {};
var children = [];
for (var i = 0; i < args.length; i++) {
var arg = args[i];
if (arg && arg._kw && (i + 1) < args.length) {
kwargs[arg.name] = trampoline(evalExpr(args[i + 1], env));
i++; // skip value
} else {
children.push(arg);
}
}
// Build local env via prototype chain
var local = Object.create(componentClosure(comp));
// Copy caller env own properties
for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k];
// Bind params
var params = componentParams(comp);
for (var j = 0; j < params.length; j++) {
var p = params[j];
local[p] = p in kwargs ? kwargs[p] : NIL;
}
// Bind children
if (componentHasChildren(comp)) {
var childFrag = document.createDocumentFragment();
for (var c = 0; c < children.length; c++) {
var rendered = renderToDom(children[c], env, ns);
if (rendered) childFrag.appendChild(rendered);
}
local["children"] = childFrag;
}
return renderToDom(componentBody(comp), local, ns);
};
// Override renderDomElement: imperative attr parsing, no reduce/assoc
renderDomElement = function(tag, args, env, ns) {
var newNs = tag === "svg" ? SVG_NS : tag === "math" ? MATH_NS : ns;
var el = domCreateElement(tag, newNs);
var extraClasses = [];
var isVoid = contains(VOID_ELEMENTS, tag);
for (var i = 0; i < args.length; i++) {
var arg = args[i];
if (arg && arg._kw && (i + 1) < args.length) {
var attrName = arg.name;
var attrVal = trampoline(evalExpr(args[i + 1], env));
i++; // skip value
if (isNil(attrVal) || attrVal === false) continue;
if (attrName === "class" && attrVal && attrVal._styleValue) {
extraClasses.push(attrVal.className);
} else if (attrName === "style" && attrVal && attrVal._styleValue) {
extraClasses.push(attrVal.className);
} else if (contains(BOOLEAN_ATTRS, attrName)) {
if (isSxTruthy(attrVal)) el.setAttribute(attrName, "");
} else if (attrVal === true) {
el.setAttribute(attrName, "");
} else {
el.setAttribute(attrName, String(attrVal));
}
} else {
if (!isVoid) {
var child = renderToDom(arg, env, newNs);
if (child) el.appendChild(child);
}
}
}
if (extraClasses.length) {
var existing = el.getAttribute("class") || "";
el.setAttribute("class", (existing ? existing + " " : "") + extraClasses.join(" "));
}
return el;
};
// =========================================================================
// Platform interface — Engine pure logic (browser + node compatible)
@@ -3176,7 +3376,9 @@ callExpr.push(dictGet(kwargs, k)); } }
if (opts && !isNil(opts)) {
if (opts.once || opts["once"]) o.once = true;
}
el.addEventListener(event, fn, o);
el.addEventListener(event, function(e) {
try { fn(e); } catch (err) { logInfo("EVENT ERROR: " + event + " " + (err && err.message ? err.message : err)); console.error("[sx-ref] event handler error:", event, err); }
}, o);
}
// --- Validation ---
@@ -3271,7 +3473,13 @@ callExpr.push(dictGet(kwargs, k)); } }
link.addEventListener("click", function(e) {
e.preventDefault();
var pathname = urlPathname(href);
if (tryClientRoute(pathname)) {
// Find target selector: sx-boost ancestor, explicit sx-target, or #main-panel
var boostEl = link.closest("[sx-boost]");
var targetSel = boostEl ? boostEl.getAttribute("sx-boost") : null;
if (!targetSel || targetSel === "true") {
targetSel = link.getAttribute("sx-target") || "#main-panel";
}
if (tryClientRoute(pathname, targetSel)) {
try { history.pushState({ sxUrl: href, scrollY: window.scrollY }, "", href); } catch (err) {}
if (typeof window !== "undefined") window.scrollTo(0, 0);
} else {
@@ -3292,10 +3500,47 @@ callExpr.push(dictGet(kwargs, k)); } }
}
return sxRenderWithEnv(source, merged);
} catch (e) {
logInfo("sx:route eval miss: " + (e && e.message ? e.message : e));
return NIL;
}
}
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;
@@ -3639,6 +3884,10 @@ callExpr.push(dictGet(kwargs, k)); } }
if (typeof console !== "undefined") console.log("[sx-ref] " + msg);
}
function logWarn(msg) {
if (typeof console !== "undefined") console.warn("[sx-ref] " + msg);
}
function logParseError(label, text, err) {
if (typeof console === "undefined") return;
var msg = err && err.message ? err.message : String(err);

View File

@@ -456,14 +456,18 @@ def sx_call(component_name: str, **kwargs: Any) -> str:
def components_for_request(source: str = "") -> str:
def components_for_request(source: str = "",
extra_names: set[str] | None = None) -> str:
"""Return defcomp/defmacro source for definitions the client doesn't have yet.
Reads the ``SX-Components`` header (comma-separated component names
like ``~card,~nav-item``) and returns only the definitions the client
is missing. If *source* is provided, only sends components needed
for that source (plus transitive deps). If the header is absent,
returns all needed defs.
for that source (plus transitive deps).
*extra_names* — additional component names to include beyond what
*source* references. Used by defpage to send components the page's
content expression needs for client-side routing.
"""
from quart import request
from .jinja_bridge import _COMPONENT_ENV
@@ -477,6 +481,12 @@ def components_for_request(source: str = "") -> str:
else:
needed = None # all
# Merge in extra names (e.g. from page content expression deps)
if extra_names and needed is not None:
needed = needed | extra_names
elif extra_names:
needed = extra_names
loaded_raw = request.headers.get("SX-Components", "")
loaded = set(loaded_raw.split(",")) if loaded_raw else set()
@@ -510,7 +520,8 @@ def components_for_request(source: str = "") -> str:
def sx_response(source: str, status: int = 200,
headers: dict | None = None):
headers: dict | None = None,
extra_component_names: set[str] | None = None):
"""Return an s-expression wire-format response.
Takes a raw sx string::
@@ -520,6 +531,10 @@ def sx_response(source: str, status: int = 200,
For SX requests, missing component definitions are prepended as a
``<script type="text/sx" data-components>`` block so the client
can process them before rendering OOB content.
*extra_component_names* — additional component names to include beyond
what *source* references. Used by defpage to send components the page's
content expression needs for client-side routing.
"""
from quart import request, Response
@@ -535,7 +550,7 @@ def sx_response(source: str, status: int = 200,
# For SX requests, prepend missing component definitions
comp_defs = ""
if request.headers.get("SX-Request"):
comp_defs = components_for_request(source)
comp_defs = components_for_request(source, extra_names=extra_component_names)
if comp_defs:
body = (f'<script type="text/sx" data-components>'
f'{comp_defs}</script>\n{body}')
@@ -644,27 +659,38 @@ def _build_pages_sx(service: str) -> str:
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.
content, closure, deps.
"""
import logging
_log = logging.getLogger("sx.pages")
from .pages import get_all_pages
from .parser import serialize as sx_serialize
from .deps import components_needed
from .jinja_bridge import _COMPONENT_ENV
pages = get_all_pages(service)
_log.debug("_build_pages_sx(%s): %d pages in registry", service, len(pages))
if not pages:
_log.warning("_build_pages_sx(%s): no pages found — page registry will be empty", service)
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
content_src = sx_serialize(page_def.content_expr)
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"
# 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:
deps = components_needed(content_src, _COMPONENT_ENV)
deps_sx = "(" + " ".join(_sx_literal(d) for d in sorted(deps)) + ")"
# Build closure as SX dict
closure_parts: list[str] = []
for k, v in page_def.closure.items():
@@ -678,11 +704,14 @@ def _build_pages_sx(service: str) -> str:
+ " :auth " + _sx_literal(auth)
+ " :has-data " + has_data
+ " :content " + _sx_literal(content_src)
+ " :deps " + deps_sx
+ " :closure " + closure_sx + "}"
)
entries.append(entry)
return "\n".join(entries)
result = "\n".join(entries)
_log.debug("_build_pages_sx(%s): built %d entries, %d bytes", service, len(entries), len(result))
return result
def _sx_literal(v: object) -> str:
@@ -699,6 +728,7 @@ def _sx_literal(v: object) -> str:
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.
@@ -710,8 +740,9 @@ def sx_page(ctx: dict, page_sx: str, *,
from .jinja_bridge import components_for_page, css_classes_for_page
from .css_registry import lookup_rules, get_preamble, registry_loaded, store_css_hash
# Per-page component bundle: only definitions this page needs
component_defs, component_hash = components_for_page(page_sx)
# Per-page component bundle: this page's deps + all :data page deps
from quart import current_app as _ca
component_defs, component_hash = components_for_page(page_sx, service=_ca.name)
# Check if client already has this version cached (via cookie)
# In dev mode, always send full source so edits are visible immediately
@@ -725,7 +756,7 @@ def sx_page(ctx: dict, page_sx: str, *,
sx_css_classes = ""
sx_css_hash = ""
if registry_loaded():
classes = css_classes_for_page(page_sx)
classes = css_classes_for_page(page_sx, service=_ca.name)
# Always include body classes
classes.update(["bg-stone-50", "text-stone-900"])
rules = lookup_rules(classes)
@@ -742,8 +773,9 @@ def sx_page(ctx: dict, page_sx: str, *,
from .parser import parse as _parse, serialize as _serialize
try:
page_sx = _serialize(_parse(page_sx), pretty=True)
except Exception:
pass
except Exception as e:
import logging
logging.getLogger("sx").warning("Pretty-print page_sx failed: %s", e)
# Style dictionary for client-side css primitive
styles_hash = _get_style_dict_hash()
@@ -754,12 +786,13 @@ def sx_page(ctx: dict, page_sx: str, *,
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
import logging
_plog = logging.getLogger("sx.pages")
from quart import current_app
pages_sx = _build_pages_sx(current_app.name)
_plog.debug("sx_page: pages_sx %d bytes for service %s", len(pages_sx), current_app.name)
if pages_sx:
_plog.debug("sx_page: pages_sx first 200 chars: %s", pages_sx[:200])
return _SX_PAGE_TEMPLATE.format(
title=_html_escape(title),
@@ -830,7 +863,7 @@ def _get_sx_styles_cookie() -> str:
try:
from quart import request
return request.cookies.get("sx-styles-hash", "")
except Exception:
except RuntimeError:
return ""
@@ -840,7 +873,7 @@ def _script_hash(filename: str) -> str:
try:
data = (Path("static") / "scripts" / filename).read_bytes()
_SCRIPT_HASH_CACHE[filename] = hashlib.md5(data).hexdigest()[:8]
except Exception:
except OSError:
_SCRIPT_HASH_CACHE[filename] = "dev"
return _SCRIPT_HASH_CACHE[filename]
@@ -850,7 +883,7 @@ def _get_csrf_token() -> str:
try:
from quart import g
return getattr(g, "csrf_token", "")
except Exception:
except RuntimeError:
return ""
@@ -859,7 +892,7 @@ def _get_sx_comp_cookie() -> str:
try:
from quart import request
return request.cookies.get("sx-comp-hash", "")
except Exception:
except RuntimeError:
return ""
@@ -903,7 +936,9 @@ def _pretty_print_sx_body(body: str) -> str:
pretty_parts = [_serialize(expr, pretty=True) for expr in exprs]
parts.append("\n\n".join(pretty_parts))
return "\n\n".join(parts)
except Exception:
except Exception as e:
import logging
logging.getLogger("sx").warning("Pretty-print sx body failed: %s", e)
return body

View File

@@ -332,17 +332,41 @@ def client_components_tag(*names: str) -> str:
return f'<script type="text/sx" data-components>{source}</script>'
def components_for_page(page_sx: str) -> tuple[str, str]:
def components_for_page(page_sx: str, service: str | None = None) -> tuple[str, str]:
"""Return (component_defs_source, page_hash) for a page.
Scans *page_sx* for component references, computes the transitive
closure, and returns only the definitions needed for this page.
When *service* is given, also includes deps for all :data pages
in that service so the client can render them without a server
roundtrip on navigation.
The hash is computed from the page-specific bundle for caching.
"""
from .deps import components_needed
from .parser import serialize
needed = components_needed(page_sx, _COMPONENT_ENV)
# Include deps for :data pages whose component trees are fully pure
# (no IO refs). Pages with IO deps must render server-side.
if service:
from .pages import get_all_pages
for page_def in get_all_pages(service).values():
if page_def.data_expr is not None and page_def.content_expr is not None:
content_src = serialize(page_def.content_expr)
data_deps = components_needed(content_src, _COMPONENT_ENV)
# Check if any dep component has IO refs
has_io = False
for dep_name in data_deps:
comp = _COMPONENT_ENV.get(dep_name)
if isinstance(comp, Component) and comp.io_refs:
has_io = True
break
if not has_io:
needed |= data_deps
if not needed:
return "", ""
@@ -375,16 +399,30 @@ def components_for_page(page_sx: str) -> tuple[str, str]:
return source, digest
def css_classes_for_page(page_sx: str) -> set[str]:
def css_classes_for_page(page_sx: str, service: str | None = None) -> set[str]:
"""Return CSS classes needed for a page's component bundle + page source.
Instead of unioning ALL component CSS classes, only includes classes
from components the page actually uses.
from components the page actually uses (plus all :data page deps).
"""
from .deps import components_needed
from .css_registry import scan_classes_from_sx
from .parser import serialize
needed = components_needed(page_sx, _COMPONENT_ENV)
if service:
from .pages import get_all_pages
for page_def in get_all_pages(service).values():
if page_def.data_expr is not None and page_def.content_expr is not None:
content_src = serialize(page_def.content_expr)
data_deps = components_needed(content_src, _COMPONENT_ENV)
has_io = any(
isinstance(_COMPONENT_ENV.get(d), Component) and _COMPONENT_ENV.get(d).io_refs
for d in data_deps
)
if not has_io:
needed |= data_deps
classes: set[str] = set()
for key, val in _COMPONENT_ENV.items():

View File

@@ -279,13 +279,25 @@ async def execute_page(
is_htmx = is_htmx_request()
if is_htmx:
# Compute content expression deps so the server sends component
# definitions the client needs for future client-side routing
extra_deps: set[str] | None = None
if page_def.content_expr is not None:
from .deps import components_needed
from .parser import serialize
try:
content_src = serialize(page_def.content_expr)
extra_deps = components_needed(content_src, get_component_env())
except Exception:
pass # non-critical — client will just fall back to server
return sx_response(await oob_page_sx(
oobs=oob_headers if oob_headers else "",
filter=filter_sx,
aside=aside_sx,
content=content_sx,
menu=menu_sx,
))
), extra_component_names=extra_deps)
else:
return await full_page_sx(
tctx,
@@ -306,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:
@@ -393,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/<page-name>
(with URL params as query args) and receive the evaluated :data
result serialized as SX wire format (text/sx).
Auth is enforced per-page: the endpoint looks up the page's auth
setting and checks it before evaluating the data expression.
"""
from quart import make_response, request, abort as quart_abort
async def page_data_view(page_name: str) -> Any:
page_def = get_page(service_name, page_name)
if page_def is None:
quart_abort(404)
if page_def.data_expr is None:
quart_abort(404)
# Check auth — same enforcement as the page route itself
auth_error = await _check_page_auth(page_def.auth)
if auth_error is not None:
return auth_error
# Extract URL params from query string
url_params = dict(request.args)
result_sx = await evaluate_page_data(
page_def, service_name, url_params=url_params,
)
resp = await make_response(result_sx, 200)
resp.content_type = "text/sx; charset=utf-8"
return resp
page_data_view.__name__ = "sx_page_data"
page_data_view.__qualname__ = "sx_page_data"
app.add_url_rule(
"/sx/data/<page_name>",
endpoint="sx_page_data",
view_func=page_data_view,
methods=["GET"],
)
logger.info("Mounted page data endpoint for %s at /sx/data/<page_name>", service_name)

View File

@@ -386,6 +386,11 @@ def prim_cons(x: Any, coll: Any) -> list:
def prim_append(coll: Any, x: Any) -> list:
return list(coll) + [x] if coll else [x]
@register_primitive("append!")
def prim_append_mut(coll: Any, x: Any) -> list:
coll.append(x)
return coll
@register_primitive("chunk-every")
def prim_chunk_every(coll: Any, n: Any) -> list:
n = int(n)
@@ -439,6 +444,13 @@ def prim_dissoc(d: Any, *keys_to_remove: Any) -> dict:
result.pop(key, None)
return result
@register_primitive("dict-set!")
def prim_dict_set_mut(d: Any, key: Any, val: Any) -> Any:
if isinstance(key, Keyword):
key = key.name
d[key] = val
return val
@register_primitive("into")
def prim_into(target: Any, coll: Any) -> Any:
if isinstance(target, list):

View File

@@ -286,11 +286,7 @@
(define render-dom-unknown-component
(fn (name)
(let ((el (dom-create-element "div" nil)))
(dom-set-attr el "style"
"background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;padding:4px 8px;margin:2px;border-radius:4px;font-size:12px;font-family:monospace")
(dom-append el (create-text-node (str "Unknown component: " name)))
el)))
(error (str "Unknown component: " name))))
;; --------------------------------------------------------------------------

View File

@@ -306,20 +306,25 @@
;; Process <script type="text/sx-pages"> tags.
;; Parses SX page registry and builds route entries with parsed patterns.
(let ((scripts (query-page-scripts)))
(log-info (str "pages: found " (len scripts) " script tags"))
(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))))
(log-info (str "pages: script text length=" (if text (len text) 0)))
(if (and text (not (empty? (trim text))))
(let ((pages (parse text)))
(log-info (str "pages: parsed " (len pages) " entries"))
(for-each
(fn (page)
(append! _page-routes
(merge page
{"parsed" (parse-route-pattern (get page "path"))})))
pages))))))
scripts))))
pages))
(log-warn "pages: script tag is empty")))))
scripts)
(log-info (str "pages: " (len _page-routes) " routes loaded")))))
;; --------------------------------------------------------------------------
@@ -336,10 +341,11 @@
;; 5. Hydrate [data-sx] elements
;; 6. Process engine elements
(do
(log-info (str "sx-browser " SX_VERSION))
(init-css-tracking)
(init-style-dict)
(process-sx-scripts nil)
(process-page-scripts)
(process-sx-scripts nil)
(sx-hydrate-elements nil)
(process-elements nil))))

View File

@@ -476,6 +476,7 @@ class JSEmitter:
"process-sx-scripts": "processSxScripts",
"process-component-script": "processComponentScript",
"init-style-dict": "initStyleDict",
"SX_VERSION": "SX_VERSION",
"boot-init": "bootInit",
"resolve-mount-target": "resolveMountTarget",
"sx-render-with-env": "sxRenderWithEnv",
@@ -497,6 +498,7 @@ class JSEmitter:
"store-env-attr": "storeEnvAttr",
"to-kebab": "toKebab",
"log-info": "logInfo",
"log-warn": "logWarn",
"log-parse-error": "logParseError",
"parse-and-load-style-dict": "parseAndLoadStyleDict",
"_page-routes": "_pageRoutes",
@@ -579,7 +581,7 @@ class JSEmitter:
if name == "or":
return self._emit_or(expr)
if name == "not":
return f"!{self.emit(expr[1])}"
return f"!isSxTruthy({self.emit(expr[1])})"
if name == "do" or name == "begin":
return self._emit_do(expr)
if name == "list":
@@ -1197,6 +1199,9 @@ def compile_ref_to_js(
if sm not in SPEC_MODULES:
raise ValueError(f"Unknown spec module: {sm!r}. Valid: {', '.join(SPEC_MODULES)}")
spec_mod_set.add(sm)
# boot.sx uses parse-route-pattern from router.sx
if "boot" in adapter_set:
spec_mod_set.add("router")
has_deps = "deps" in spec_mod_set
has_router = "router" in spec_mod_set
@@ -1287,7 +1292,9 @@ def compile_ref_to_js(
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, has_router))
parts.append(EPILOGUE)
return "\n".join(parts)
from datetime import datetime, timezone
build_ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
return "\n".join(parts).replace("BUILD_TIMESTAMP", build_ts)
# ---------------------------------------------------------------------------
@@ -1311,6 +1318,7 @@ PREAMBLE = '''\
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "BUILD_TIMESTAMP";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -1492,6 +1500,7 @@ PRIMITIVES_JS_MODULES: dict[str, str] = {
PRIMITIVES["nth"] = function(c, n) { return c && n >= 0 && n < c.length ? c[n] : NIL; };
PRIMITIVES["cons"] = function(x, c) { return [x].concat(c || []); };
PRIMITIVES["append"] = function(c, x) { return (c || []).concat([x]); };
PRIMITIVES["append!"] = function(arr, x) { arr.push(x); return arr; };
PRIMITIVES["chunk-every"] = function(c, n) {
var r = []; for (var i = 0; i < c.length; i += n) r.push(c.slice(i, i + n)); return r;
};
@@ -1519,6 +1528,7 @@ PRIMITIVES_JS_MODULES: dict[str, str] = {
for (var i = 1; i < arguments.length; i++) delete out[arguments[i]];
return out;
};
PRIMITIVES["dict-set!"] = function(d, k, v) { d[k] = v; return v; };
PRIMITIVES["into"] = function(target, coll) {
if (Array.isArray(target)) return Array.isArray(coll) ? coll.slice() : Object.entries(coll);
var r = {}; for (var i = 0; i < coll.length; i++) { var p = coll[i]; if (Array.isArray(p) && p.length >= 2) r[p[0]] = p[1]; }
@@ -1700,10 +1710,14 @@ PLATFORM_JS_PRE = '''
function envHas(env, name) { return name in env; }
function envGet(env, name) { return env[name]; }
function envSet(env, name, val) { env[name] = val; }
function envExtend(env) { return merge(env); }
function envMerge(base, overlay) { return merge(base, overlay); }
function envExtend(env) { return Object.create(env); }
function envMerge(base, overlay) {
var child = Object.create(base);
if (overlay) for (var k in overlay) if (overlay.hasOwnProperty(k)) child[k] = overlay[k];
return child;
}
function dictSet(d, k, v) { d[k] = v; }
function dictSet(d, k, v) { d[k] = v; return v; }
function dictGet(d, k) { var v = d[k]; return v !== undefined ? v : NIL; }
// Render-expression detection — lets the evaluator delegate to the active adapter.
@@ -1842,7 +1856,53 @@ PLATFORM_JS_POST = '''
function forEachIndexed(fn, coll) {
for (var i = 0; i < coll.length; i++) fn(i, coll[i]);
return NIL;
}'''
}
// =========================================================================
// Performance overrides — evaluator hot path
// =========================================================================
// Override parseKeywordArgs: imperative loop instead of reduce+assoc
parseKeywordArgs = function(rawArgs, env) {
var kwargs = {};
var children = [];
for (var i = 0; i < rawArgs.length; i++) {
var arg = rawArgs[i];
if (arg && arg._kw && (i + 1) < rawArgs.length) {
kwargs[arg.name] = trampoline(evalExpr(rawArgs[i + 1], env));
i++;
} else {
children.push(trampoline(evalExpr(arg, env)));
}
}
return [kwargs, children];
};
// Override callComponent: use prototype chain env, imperative kwarg binding
callComponent = function(comp, rawArgs, env) {
var kwargs = {};
var children = [];
for (var i = 0; i < rawArgs.length; i++) {
var arg = rawArgs[i];
if (arg && arg._kw && (i + 1) < rawArgs.length) {
kwargs[arg.name] = trampoline(evalExpr(rawArgs[i + 1], env));
i++;
} else {
children.push(trampoline(evalExpr(arg, env)));
}
}
var local = Object.create(componentClosure(comp));
for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k];
var params = componentParams(comp);
for (var j = 0; j < params.length; j++) {
var p = params[j];
local[p] = p in kwargs ? kwargs[p] : NIL;
}
if (componentHasChildren(comp)) {
local["children"] = children;
}
return makeThunk(componentBody(comp), local);
};'''
PLATFORM_DEPS_JS = '''
// =========================================================================
@@ -2107,6 +2167,84 @@ PLATFORM_DOM_JS = """
}
function domTagName(el) { return el && el.tagName ? el.tagName : ""; }
// =========================================================================
// Performance overrides — replace transpiled spec with imperative JS
// =========================================================================
// Override renderDomComponent: imperative kwarg parsing, no reduce/assoc
renderDomComponent = function(comp, args, env, ns) {
// Parse keyword args imperatively
var kwargs = {};
var children = [];
for (var i = 0; i < args.length; i++) {
var arg = args[i];
if (arg && arg._kw && (i + 1) < args.length) {
kwargs[arg.name] = trampoline(evalExpr(args[i + 1], env));
i++; // skip value
} else {
children.push(arg);
}
}
// Build local env via prototype chain
var local = Object.create(componentClosure(comp));
// Copy caller env own properties
for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k];
// Bind params
var params = componentParams(comp);
for (var j = 0; j < params.length; j++) {
var p = params[j];
local[p] = p in kwargs ? kwargs[p] : NIL;
}
// Bind children
if (componentHasChildren(comp)) {
var childFrag = document.createDocumentFragment();
for (var c = 0; c < children.length; c++) {
var rendered = renderToDom(children[c], env, ns);
if (rendered) childFrag.appendChild(rendered);
}
local["children"] = childFrag;
}
return renderToDom(componentBody(comp), local, ns);
};
// Override renderDomElement: imperative attr parsing, no reduce/assoc
renderDomElement = function(tag, args, env, ns) {
var newNs = tag === "svg" ? SVG_NS : tag === "math" ? MATH_NS : ns;
var el = domCreateElement(tag, newNs);
var extraClasses = [];
var isVoid = contains(VOID_ELEMENTS, tag);
for (var i = 0; i < args.length; i++) {
var arg = args[i];
if (arg && arg._kw && (i + 1) < args.length) {
var attrName = arg.name;
var attrVal = trampoline(evalExpr(args[i + 1], env));
i++; // skip value
if (isNil(attrVal) || attrVal === false) continue;
if (attrName === "class" && attrVal && attrVal._styleValue) {
extraClasses.push(attrVal.className);
} else if (attrName === "style" && attrVal && attrVal._styleValue) {
extraClasses.push(attrVal.className);
} else if (contains(BOOLEAN_ATTRS, attrName)) {
if (isSxTruthy(attrVal)) el.setAttribute(attrName, "");
} else if (attrVal === true) {
el.setAttribute(attrName, "");
} else {
el.setAttribute(attrName, String(attrVal));
}
} else {
if (!isVoid) {
var child = renderToDom(arg, env, newNs);
if (child) el.appendChild(child);
}
}
}
if (extraClasses.length) {
var existing = el.getAttribute("class") || "";
el.setAttribute("class", (existing ? existing + " " : "") + extraClasses.join(" "));
}
return el;
};
"""
PLATFORM_ENGINE_PURE_JS = """
@@ -2509,7 +2647,9 @@ PLATFORM_ORCHESTRATION_JS = """
if (opts && !isNil(opts)) {
if (opts.once || opts["once"]) o.once = true;
}
el.addEventListener(event, fn, o);
el.addEventListener(event, function(e) {
try { fn(e); } catch (err) { logInfo("EVENT ERROR: " + event + " " + (err && err.message ? err.message : err)); console.error("[sx-ref] event handler error:", event, err); }
}, o);
}
// --- Validation ---
@@ -2604,7 +2744,13 @@ PLATFORM_ORCHESTRATION_JS = """
link.addEventListener("click", function(e) {
e.preventDefault();
var pathname = urlPathname(href);
if (tryClientRoute(pathname)) {
// Find target selector: sx-boost ancestor, explicit sx-target, or #main-panel
var boostEl = link.closest("[sx-boost]");
var targetSel = boostEl ? boostEl.getAttribute("sx-boost") : null;
if (!targetSel || targetSel === "true") {
targetSel = link.getAttribute("sx-target") || "#main-panel";
}
if (tryClientRoute(pathname, targetSel)) {
try { history.pushState({ sxUrl: href, scrollY: window.scrollY }, "", href); } catch (err) {}
if (typeof window !== "undefined") window.scrollTo(0, 0);
} else {
@@ -2625,10 +2771,47 @@ PLATFORM_ORCHESTRATION_JS = """
}
return sxRenderWithEnv(source, merged);
} catch (e) {
logInfo("sx:route eval miss: " + (e && e.message ? e.message : e));
return NIL;
}
}
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;
@@ -2974,6 +3157,10 @@ PLATFORM_BOOT_JS = """
if (typeof console !== "undefined") console.log("[sx-ref] " + msg);
}
function logWarn(msg) {
if (typeof console !== "undefined") console.warn("[sx-ref] " + msg);
}
function logParseError(label, text, err) {
if (typeof console === "undefined") return;
var msg = err && err.message ? err.message : String(err);

View File

@@ -388,15 +388,33 @@
(dom-has-attr? el "href")))
(prevent-default e))
;; Delay modifier
(if (get mods "delay")
(do
(clear-timeout timer)
(set! timer
(set-timeout
(fn () (execute-request el verbInfo nil))
(get mods "delay"))))
(execute-request el verbInfo nil)))))
;; For GET clicks on links, try client-side routing first
(let ((is-get-link (and (= event-name "click")
(= (get verbInfo "method") "GET")
(dom-has-attr? el "href")
(not (get mods "delay"))))
(client-routed false))
(when is-get-link
(log-info (str "sx:route trying " (get verbInfo "url")))
(set! client-routed
(try-client-route
(url-pathname (get verbInfo "url"))
(dom-get-attr el "sx-target"))))
(if client-routed
(do
(browser-push-state (get verbInfo "url"))
(browser-scroll-to 0 0))
(do
(when is-get-link
(log-info (str "sx:route server fetch " (get verbInfo "url"))))
(if (get mods "delay")
(do
(clear-timeout timer)
(set! timer
(set-timeout
(fn () (execute-request el verbInfo nil))
(get mods "delay"))))
(execute-request el verbInfo nil))))))))
(if (get mods "once") (dict "once" true) nil))))))
@@ -491,71 +509,179 @@
(define boost-descendants
(fn (container)
;; Boost links and forms within a container
;; Links get sx-get, forms get sx-post/sx-get
(for-each
(fn (link)
(when (and (not (is-processed? link "boost"))
(should-boost-link? link))
(mark-processed! link "boost")
;; Set default sx-target if not specified
(when (not (dom-has-attr? link "sx-target"))
(dom-set-attr link "sx-target" "#main-panel"))
(when (not (dom-has-attr? link "sx-swap"))
(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-client-route-link link (dom-get-attr link "href"))))
(dom-query-all container "a[href]"))
(for-each
(fn (form)
(when (and (not (is-processed? form "boost"))
(should-boost-form? form))
(mark-processed! form "boost")
(let ((method (upper (or (dom-get-attr form "method") "GET")))
(action (or (dom-get-attr form "action")
(browser-location-href))))
(when (not (dom-has-attr? form "sx-target"))
(dom-set-attr form "sx-target" "#main-panel"))
(when (not (dom-has-attr? form "sx-swap"))
(dom-set-attr form "sx-swap" "innerHTML"))
(bind-boost-form form method action))))
(dom-query-all container "form"))))
;; Boost links and forms within a container.
;; The sx-boost attribute value is the default target selector
;; for boosted descendants (e.g. sx-boost="#main-panel").
(let ((boost-target (dom-get-attr container "sx-boost")))
(for-each
(fn (link)
(when (and (not (is-processed? link "boost"))
(should-boost-link? link))
(mark-processed! link "boost")
;; Inherit target from boost container if not specified
(when (and (not (dom-has-attr? link "sx-target"))
boost-target (not (= boost-target "true")))
(dom-set-attr link "sx-target" boost-target))
(when (not (dom-has-attr? link "sx-swap"))
(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-client-route-link link (dom-get-attr link "href"))))
(dom-query-all container "a[href]"))
(for-each
(fn (form)
(when (and (not (is-processed? form "boost"))
(should-boost-form? form))
(mark-processed! form "boost")
(let ((method (upper (or (dom-get-attr form "method") "GET")))
(action (or (dom-get-attr form "action")
(browser-location-href))))
(when (and (not (dom-has-attr? form "sx-target"))
boost-target (not (= boost-target "true")))
(dom-set-attr form "sx-target" boost-target))
(when (not (dom-has-attr? form "sx-swap"))
(dom-set-attr form "sx-swap" "innerHTML"))
(bind-boost-form form method action))))
(dom-query-all container "form")))))
;; --------------------------------------------------------------------------
;; Client-side routing — data cache
;; --------------------------------------------------------------------------
;; Cache for page data resolved via resolve-page-data.
;; Keyed by "page-name:param1=val1&param2=val2", value is {data, ts}.
;; Default TTL: 30s. Prevents redundant fetches on back/forward navigation.
(define _page-data-cache (dict))
(define _page-data-cache-ttl 30000) ;; 30 seconds in ms
(define page-data-cache-key
(fn (page-name params)
;; Build a cache key from page name + params.
;; Params are from route matching so order is deterministic.
(let ((base page-name))
(if (or (nil? params) (empty? (keys params)))
base
(let ((parts (list)))
(for-each
(fn (k)
(append! parts (str k "=" (get params k))))
(keys params))
(str base ":" (join "&" parts)))))))
(define page-data-cache-get
(fn (cache-key)
;; Return cached data if fresh, else nil.
(let ((entry (get _page-data-cache cache-key)))
(if (nil? entry)
nil
(if (> (- (now-ms) (get entry "ts")) _page-data-cache-ttl)
(do
(dict-set! _page-data-cache cache-key nil)
nil)
(get entry "data"))))))
(define page-data-cache-set
(fn (cache-key data)
;; Store data with current timestamp.
(dict-set! _page-data-cache cache-key
{"data" data "ts" (now-ms)})))
;; --------------------------------------------------------------------------
;; Client-side routing
;; --------------------------------------------------------------------------
;; 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 deps-satisfied?
(fn (match)
;; Check if all component deps for a page are loaded client-side.
(let ((deps (get match "deps"))
(loaded (loaded-component-names)))
(if (or (nil? deps) (empty? deps))
true
(every? (fn (dep) (contains? loaded dep)) deps)))))
(define try-client-route
(fn (pathname)
(fn (pathname target-sel)
;; Try to render a page client-side. Returns true if successful, false otherwise.
;; Only works for pages without :data dependencies.
;; target-sel is the CSS selector for the swap target (from sx-boost value).
;; For pure pages: renders immediately. For :data pages: fetches data then renders.
(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))))))))))))
(do (log-info (str "sx:route no match (" (len _page-routes) " routes) " pathname)) 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 (not (deps-satisfied? match))
(do (log-info (str "sx:route deps miss for " page-name)) false)
(if (get match "has-data")
;; Data page: check cache, else resolve asynchronously
(let ((cache-key (page-data-cache-key page-name params))
(cached (page-data-cache-get cache-key)))
(if cached
;; Cache hit: render immediately
(let ((env (merge closure params cached))
(rendered (try-eval-content content-src env)))
(if (nil? rendered)
(do (log-warn (str "sx:route cached eval failed for " pathname)) false)
(do
(log-info (str "sx:route client+cache " pathname))
(swap-rendered-content target rendered pathname)
true)))
;; Cache miss: fetch, cache, render
(do
(log-info (str "sx:route client+data " pathname))
(resolve-page-data page-name params
(fn (data)
(page-data-cache-set cache-key data)
(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
(swap-rendered-content target rendered pathname)
true)))))))))))))
(define bind-client-route-link
@@ -715,16 +841,24 @@
(define handle-popstate
(fn (scrollY)
;; Handle browser back/forward navigation.
;; Derive target from [sx-boost] container or fall back to #main-panel.
;; 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 ((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))))))))
(let ((url (browser-location-href))
(boost-el (dom-query "[sx-boost]"))
(target-sel (if boost-el
(let ((attr (dom-get-attr boost-el "sx-boost")))
(if (and attr (not (= attr "true"))) attr nil))
nil))
;; Fall back to #main-panel if no sx-boost target
(target-sel (or target-sel "#main-panel"))
(target (dom-query target-sel))
(pathname (url-pathname url)))
(when target
(if (try-client-route pathname target-sel)
(browser-scroll-to 0 scrollY)
(let ((headers (build-request-headers target
(loaded-component-names) _css-hash)))
(fetch-and-restore target url headers scrollY)))))))
;; --------------------------------------------------------------------------
@@ -777,7 +911,7 @@
;; cross-origin
;; success-fn: (fn (resp-ok status get-header text) ...)
;; error-fn: (fn (err) ...)
;; (fetch-location url) → fetch URL and swap to #main-panel
;; (fetch-location url) → fetch URL and swap to boost target
;; (fetch-and-restore main url headers scroll-y) → popstate fetch+swap
;; (fetch-preload url headers cache) → preload into cache
;;
@@ -857,6 +991,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

View File

@@ -384,6 +384,11 @@
:returns "list"
:doc "Append x to end of coll (returns new list).")
(define-primitive "append!"
:params (coll x)
:returns "list"
:doc "Mutate coll by appending x in-place. Returns coll.")
(define-primitive "chunk-every"
:params (coll n)
:returns "list"
@@ -426,6 +431,11 @@
:returns "dict"
:doc "Return new dict with keys removed.")
(define-primitive "dict-set!"
:params (d key val)
:returns "any"
:doc "Mutate dict d by setting key to val in-place. Returns val.")
(define-primitive "into"
:params (target coll)
:returns "any"

View File

@@ -22,7 +22,7 @@
(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))
(slice trimmed 0 (- (len trimmed) 1))
trimmed)))
(if (empty? trimmed2)
(list)
@@ -38,7 +38,7 @@
(define make-route-segment
(fn (seg)
(if (and (starts-with? seg "<") (ends-with? seg ">"))
(let ((param-name (slice seg 1 (- (length seg) 1))))
(let ((param-name (slice seg 1 (- (len seg) 1))))
(let ((d {}))
(dict-set! d "type" "param")
(dict-set! d "value" param-name)
@@ -61,7 +61,7 @@
(define match-route-segments
(fn (path-segs parsed-segs)
(if (not (= (length path-segs) (length parsed-segs)))
(if (not (= (len path-segs) (len parsed-segs)))
nil
(let ((params {})
(matched true))
@@ -120,7 +120,7 @@
;; Platform interface — none required
;; --------------------------------------------------------------------------
;; All functions use only pure primitives:
;; split, slice, starts-with?, ends-with?, length, empty?,
;; split, slice, starts-with?, ends-with?, len, empty?,
;; map, for-each, for-each-indexed, nth, get, dict-set!, merge,
;; list, nil?, not, =
;; --------------------------------------------------------------------------

View File

@@ -0,0 +1,423 @@
"""Tests for Phase 4 page data pipeline.
Tests the serialize→parse roundtrip for data dicts (SX wire format),
the kebab-case key conversion, component dep computation for
:data pages, and the client data cache logic.
"""
import pytest
from shared.sx.parser import parse, parse_all, serialize
from shared.sx.types import Symbol, Keyword, NIL
# ---------------------------------------------------------------------------
# SX wire format roundtrip — data dicts
# ---------------------------------------------------------------------------
class TestDataSerializeRoundtrip:
"""Data dicts must survive serialize → parse as SX wire format."""
def test_simple_dict(self):
data = {"name": "hello", "count": 42}
sx = serialize(data)
parsed = parse_all(sx)
assert len(parsed) == 1
d = parsed[0]
assert d["name"] == "hello"
assert d["count"] == 42
def test_nested_list(self):
data = {"items": [1, 2, 3]}
sx = serialize(data)
parsed = parse_all(sx)
d = parsed[0]
assert d["items"] == [1, 2, 3]
def test_nested_dict(self):
data = {"user": {"name": "alice", "active": True}}
sx = serialize(data)
parsed = parse_all(sx)
d = parsed[0]
assert d["user"]["name"] == "alice"
assert d["user"]["active"] is True
def test_nil_value(self):
data = {"value": None}
sx = serialize(data)
parsed = parse_all(sx)
d = parsed[0]
assert d["value"] is NIL or d["value"] is None
def test_boolean_values(self):
data = {"yes": True, "no": False}
sx = serialize(data)
parsed = parse_all(sx)
d = parsed[0]
assert d["yes"] is True
assert d["no"] is False
def test_string_with_special_chars(self):
data = {"msg": 'He said "hello"\nNew line'}
sx = serialize(data)
parsed = parse_all(sx)
d = parsed[0]
assert d["msg"] == 'He said "hello"\nNew line'
def test_empty_dict(self):
data = {}
sx = serialize(data)
parsed = parse_all(sx)
d = parsed[0]
assert d == {}
def test_list_of_dicts(self):
"""Data helpers often return lists of dicts (e.g. items)."""
data = {"items": [
{"label": "A", "value": 1},
{"label": "B", "value": 2},
]}
sx = serialize(data)
parsed = parse_all(sx)
d = parsed[0]
items = d["items"]
assert len(items) == 2
assert items[0]["label"] == "A"
assert items[1]["value"] == 2
def test_float_values(self):
data = {"pi": 3.14, "neg": -0.5}
sx = serialize(data)
parsed = parse_all(sx)
d = parsed[0]
assert d["pi"] == 3.14
assert d["neg"] == -0.5
def test_empty_string(self):
data = {"empty": ""}
sx = serialize(data)
parsed = parse_all(sx)
d = parsed[0]
assert d["empty"] == ""
def test_empty_list(self):
data = {"items": []}
sx = serialize(data)
parsed = parse_all(sx)
d = parsed[0]
assert d["items"] == []
# ---------------------------------------------------------------------------
# Kebab-case key conversion
# ---------------------------------------------------------------------------
class TestKebabCaseKeys:
"""evaluate_page_data converts underscore keys to kebab-case."""
def _kebab(self, d):
"""Same logic as evaluate_page_data."""
return {k.replace("_", "-"): v for k, v in d.items()}
def test_underscores_to_kebab(self):
d = {"total_count": 5, "is_active": True}
result = self._kebab(d)
assert "total-count" in result
assert "is-active" in result
assert result["total-count"] == 5
def test_no_underscores_unchanged(self):
d = {"name": "hello", "count": 3}
result = self._kebab(d)
assert result == d
def test_already_kebab_unchanged(self):
d = {"my-key": "val"}
result = self._kebab(d)
assert result == {"my-key": "val"}
def test_kebab_then_serialize_roundtrip(self):
"""Full pipeline: kebab-case → serialize → parse."""
data = {"total_count": 5, "page_title": "Test"}
kebab = self._kebab(data)
sx = serialize(kebab)
parsed = parse_all(sx)
d = parsed[0]
assert d["total-count"] == 5
assert d["page-title"] == "Test"
# ---------------------------------------------------------------------------
# Component deps for :data pages
# ---------------------------------------------------------------------------
class TestDataPageDeps:
"""_build_pages_sx should compute deps for :data pages too."""
def test_deps_computed_for_data_page(self):
from shared.sx.deps import components_needed
from shared.sx.parser import parse_all as pa
from shared.sx.evaluator import _eval, _trampoline
# Define a component
env = {}
for expr in pa('(defcomp ~card (&key title) (div title))'):
_trampoline(_eval(expr, env))
# Content that uses ~card — this is what a :data page's content looks like
content_src = '(~card :title page-title)'
deps = components_needed(content_src, env)
assert "~card" in deps
def test_deps_transitive_for_data_page(self):
from shared.sx.deps import components_needed
from shared.sx.parser import parse_all as pa
from shared.sx.evaluator import _eval, _trampoline
env = {}
source = """
(defcomp ~inner (&key text) (span text))
(defcomp ~outer (&key title) (div (~inner :text title)))
"""
for expr in pa(source):
_trampoline(_eval(expr, env))
content_src = '(~outer :title page-title)'
deps = components_needed(content_src, env)
assert "~outer" in deps
assert "~inner" in deps
# ---------------------------------------------------------------------------
# Full data pipeline simulation
# ---------------------------------------------------------------------------
class TestDataPipelineSimulation:
"""Simulate the full data page pipeline without Quart context.
Server: data_helper() → dict → kebab-case → serialize → SX text
Client: SX text → parse → dict → merge into env → eval content
Note: uses str/list ops instead of HTML tags since the bare evaluator
doesn't have the HTML tag registry. The real client uses renderToDom.
"""
def test_full_pipeline(self):
from shared.sx.parser import parse_all as pa
from shared.sx.evaluator import _eval, _trampoline
# 1. Define a component that uses only pure primitives
env = {}
for expr in pa('(defcomp ~greeting (&key name time) (str "Hello " name " at " time))'):
_trampoline(_eval(expr, env))
# 2. Server: data helper returns a dict
data_result = {"user_name": "Alice", "server_time": "12:00"}
# 3. Server: kebab-case + serialize
kebab = {k.replace("_", "-"): v for k, v in data_result.items()}
sx_wire = serialize(kebab)
# 4. Client: parse SX wire format
parsed = pa(sx_wire)
assert len(parsed) == 1
data_dict = parsed[0]
# 5. Client: merge data into env
env.update(data_dict)
# 6. Client: eval content expression
content_src = '(~greeting :name user-name :time server-time)'
for expr in pa(content_src):
result = _trampoline(_eval(expr, env))
assert result == "Hello Alice at 12:00"
def test_pipeline_with_list_data(self):
from shared.sx.parser import parse_all as pa
from shared.sx.evaluator import _eval, _trampoline
env = {}
for expr in pa('''
(defcomp ~item-list (&key items)
(map (fn (item) (get item "label")) items))
'''):
_trampoline(_eval(expr, env))
# Server data
data_result = {"items": [{"label": "One"}, {"label": "Two"}]}
sx_wire = serialize(data_result)
# Client parse + merge + eval
data_dict = pa(sx_wire)[0]
env.update(data_dict)
result = None
for expr in pa('(~item-list :items items)'):
result = _trampoline(_eval(expr, env))
assert result == ["One", "Two"]
def test_pipeline_data_isolation(self):
"""Different data for the same content produces different results."""
from shared.sx.parser import parse_all as pa
from shared.sx.evaluator import _eval, _trampoline
env = {}
for expr in pa('(defcomp ~page (&key title count) (str title ": " count))'):
_trampoline(_eval(expr, env))
# Two different data payloads
for title, count, expected in [
("Posts", 42, "Posts: 42"),
("Users", 7, "Users: 7"),
]:
data = {"title": title, "count": count}
sx_wire = serialize(data)
data_dict = pa(sx_wire)[0]
page_env = dict(env)
page_env.update(data_dict)
for expr in pa('(~page :title title :count count)'):
result = _trampoline(_eval(expr, page_env))
assert result == expected
# ---------------------------------------------------------------------------
# Client data cache
# ---------------------------------------------------------------------------
class TestDataCache:
"""Test the page data cache logic from orchestration.sx.
The cache functions are pure SX evaluated with a mock now-ms primitive.
"""
def _make_env(self, current_time_ms=1000):
"""Create an env with cache functions and a controllable now-ms."""
from shared.sx.parser import parse_all as pa
from shared.sx.evaluator import _eval, _trampoline
env = {}
# Mock now-ms as a callable that returns current_time_ms
self._time = current_time_ms
env["now-ms"] = lambda: self._time
# Define the cache functions from orchestration.sx
cache_src = """
(define _page-data-cache (dict))
(define _page-data-cache-ttl 30000)
(define page-data-cache-key
(fn (page-name params)
(let ((base page-name))
(if (or (nil? params) (empty? (keys params)))
base
(let ((parts (list)))
(for-each
(fn (k)
(append! parts (str k "=" (get params k))))
(keys params))
(str base ":" (join "&" parts)))))))
(define page-data-cache-get
(fn (cache-key)
(let ((entry (get _page-data-cache cache-key)))
(if (nil? entry)
nil
(if (> (- (now-ms) (get entry "ts")) _page-data-cache-ttl)
(do
(dict-set! _page-data-cache cache-key nil)
nil)
(get entry "data"))))))
(define page-data-cache-set
(fn (cache-key data)
(dict-set! _page-data-cache cache-key
{"data" data "ts" (now-ms)})))
"""
for expr in pa(cache_src):
_trampoline(_eval(expr, env))
return env
def _eval(self, src, env):
from shared.sx.parser import parse_all as pa
from shared.sx.evaluator import _eval, _trampoline
result = None
for expr in pa(src):
result = _trampoline(_eval(expr, env))
return result
def test_cache_key_no_params(self):
env = self._make_env()
result = self._eval('(page-data-cache-key "data-test" {})', env)
assert result == "data-test"
def test_cache_key_with_params(self):
env = self._make_env()
result = self._eval('(page-data-cache-key "reference" {"slug" "div"})', env)
assert result == "reference:slug=div"
def test_cache_key_nil_params(self):
env = self._make_env()
result = self._eval('(page-data-cache-key "data-test" nil)', env)
assert result == "data-test"
def test_cache_miss_returns_nil(self):
env = self._make_env()
result = self._eval('(page-data-cache-get "nonexistent")', env)
assert result is NIL or result is None
def test_cache_set_then_get(self):
env = self._make_env(current_time_ms=1000)
self._eval('(page-data-cache-set "test-page" {"title" "Hello"})', env)
result = self._eval('(page-data-cache-get "test-page")', env)
assert result["title"] == "Hello"
def test_cache_hit_within_ttl(self):
env = self._make_env(current_time_ms=1000)
self._eval('(page-data-cache-set "test-page" {"val" 42})', env)
# Advance time by 10 seconds (within 30s TTL)
self._time = 11000
result = self._eval('(page-data-cache-get "test-page")', env)
assert result["val"] == 42
def test_cache_expired_returns_nil(self):
env = self._make_env(current_time_ms=1000)
self._eval('(page-data-cache-set "test-page" {"val" 42})', env)
# Advance time by 31 seconds (past 30s TTL)
self._time = 32000
result = self._eval('(page-data-cache-get "test-page")', env)
assert result is NIL or result is None
def test_cache_overwrite(self):
env = self._make_env(current_time_ms=1000)
self._eval('(page-data-cache-set "p" {"v" 1})', env)
self._time = 2000
self._eval('(page-data-cache-set "p" {"v" 2})', env)
result = self._eval('(page-data-cache-get "p")', env)
assert result["v"] == 2
def test_cache_different_keys_independent(self):
env = self._make_env(current_time_ms=1000)
self._eval('(page-data-cache-set "a" {"x" 1})', env)
self._eval('(page-data-cache-set "b" {"x" 2})', env)
a = self._eval('(page-data-cache-get "a")', env)
b = self._eval('(page-data-cache-get "b")', env)
assert a["x"] == 1
assert b["x"] == 2
def test_cache_complex_data(self):
"""Cache preserves nested dicts and lists."""
env = self._make_env(current_time_ms=1000)
self._eval("""
(page-data-cache-set "complex"
{"items" (list {"label" "A"} {"label" "B"})
"count" 2})
""", env)
result = self._eval('(page-data-cache-get "complex")', env)
assert result["count"] == 2
assert len(result["items"]) == 2
assert result["items"][0]["label"] == "A"

View File

@@ -113,7 +113,7 @@ BEHAVIOR_ATTRS = [
("sx-media", "Only enable this element when the media query matches", True),
("sx-disable", "Disable sx processing on this element and its children", True),
("sx-on:*", "Inline event handler — e.g. sx-on:click runs JavaScript on event", True),
("sx-boost", "Progressively enhance all links and forms in a container with AJAX navigation", True),
("sx-boost", "Progressively enhance all links and forms in a container with AJAX navigation. Value can be a target selector.", True),
("sx-preload", "Preload content on hover/focus for instant response on click", True),
("sx-preserve", "Preserve element across swaps — keeps DOM state, event listeners, and scroll position", True),
("sx-indicator", "CSS selector for a loading indicator element to show/hide during requests", True),
@@ -171,10 +171,10 @@ EVENTS = [
("sx:beforeRequest", "Fired before an sx request is issued. Call preventDefault() to cancel."),
("sx:afterRequest", "Fired after a successful sx response is received."),
("sx:afterSwap", "Fired after the response has been swapped into the DOM."),
("sx:afterSettle", "Fired after the DOM has settled (scripts executed, etc)."),
("sx:responseError", "Fired on HTTP error responses (4xx, 5xx)."),
("sx:sendError", "Fired when the request fails to send (network error)."),
("sx:requestError", "Fired when the request fails to send (network error, abort)."),
("sx:validationFailed", "Fired when sx-validate blocks a request due to invalid form data."),
("sx:clientRoute", "Fired after successful client-side routing (no server request)."),
("sx:sseOpen", "Fired when an SSE connection is established."),
("sx:sseMessage", "Fired when an SSE message is received and swapped."),
("sx:sseError", "Fired when an SSE connection encounters an error."),
@@ -585,7 +585,7 @@ EVENT_DETAILS: dict[str, dict] = {
"sx:afterRequest": {
"description": (
"Fired on the triggering element after a successful sx response is received, "
"before the swap happens. The response data is available on event.detail. "
"before the swap happens. event.detail contains the response status. "
"Use this for logging, analytics, or pre-swap side effects."
),
"example": (
@@ -595,42 +595,27 @@ EVENT_DETAILS: dict[str, dict] = {
' :sx-on:sx:afterRequest "console.log(\'Response received\', event.detail)"\n'
' "Load data")'
),
"demo": "ref-event-after-request-demo",
},
"sx:afterSwap": {
"description": (
"Fired after the response content has been swapped into the DOM. "
"The new content is in place but scripts may not have executed yet. "
"Use this to initialize UI on newly inserted content."
"Fired on the triggering element after the response content has been "
"swapped into the DOM. event.detail contains the target element and swap "
"style. Use this to initialize UI on newly inserted content."
),
"example": (
';; Initialize tooltips on new content\n'
'(div :sx-on:sx:afterSwap "initTooltips(this)"\n'
' (button :sx-get "/api/items"\n'
' :sx-target "#item-list"\n'
' "Load items")\n'
' (div :id "item-list"))'
';; Run code after content is swapped in\n'
'(button :sx-get "/api/items"\n'
' :sx-target "#item-list"\n'
' :sx-on:sx:afterSwap "console.log(\'Swapped into\', event.detail.target)"\n'
' "Load items")'
),
},
"sx:afterSettle": {
"description": (
"Fired after the DOM has fully settled — all scripts executed, transitions "
"complete. This is the safest point to run code that depends on the final "
"state of the DOM after a swap."
),
"example": (
';; Scroll to new content after settle\n'
'(div :sx-on:sx:afterSettle "document.getElementById(\'new-item\').scrollIntoView()"\n'
' (button :sx-get "/api/append"\n'
' :sx-target "#list" :sx-swap "beforeend"\n'
' "Add item")\n'
' (div :id "list"))'
),
"demo": "ref-event-after-settle-demo",
"demo": "ref-event-after-swap-demo",
},
"sx:responseError": {
"description": (
"Fired when the server responds with an HTTP error (4xx or 5xx). "
"event.detail contains the status code and response. "
"event.detail contains the status code and response text. "
"Use this for error handling, showing notifications, or retry logic."
),
"example": (
@@ -643,21 +628,22 @@ EVENT_DETAILS: dict[str, dict] = {
),
"demo": "ref-event-response-error-demo",
},
"sx:sendError": {
"sx:requestError": {
"description": (
"Fired when the request fails to send — typically a network error, "
"DNS failure, or CORS issue. Unlike sx:responseError, no HTTP response "
"was received at all."
"was received at all. Aborted requests (e.g. from sx-sync) do not fire this event."
),
"example": (
';; Handle network failures\n'
'(div :sx-on:sx:sendError "this.querySelector(\'.status\').textContent = \'Offline\'"\n'
'(div :sx-on:sx:requestError "this.querySelector(\'.status\').textContent = \'Offline\'"\n'
' (button :sx-get "/api/data"\n'
' :sx-target "#result"\n'
' "Load")\n'
' (span :class "status")\n'
' (div :id "result"))'
),
"demo": "ref-event-request-error-demo",
},
"sx:validationFailed": {
"description": (
@@ -676,6 +662,29 @@ EVENT_DETAILS: dict[str, dict] = {
),
"demo": "ref-event-validation-failed-demo",
},
"sx:clientRoute": {
"description": (
"Fired on the swap target after successful client-side routing. "
"No server request was made — the page was rendered entirely in the browser "
"from component definitions the client already has. "
"event.detail contains the pathname. Use this to update navigation state, "
"analytics, or other side effects that should run on client-only navigation. "
"The event bubbles, so you can listen on document.body."
),
"example": (
';; Pages with no :data are client-routable.\n'
';; sx-boost containers try client routing first.\n'
';; On success, sx:clientRoute fires on the swap target.\n'
'(nav :sx-boost "#main-panel"\n'
' (a :href "/essays/" "Essays")\n'
' (a :href "/plans/" "Plans"))\n'
'\n'
';; Listen in body.js:\n'
';; document.body.addEventListener("sx:clientRoute",\n'
';; function(e) { updateNav(e.detail.pathname); })'
),
"demo": "ref-event-client-route-demo",
},
"sx:sseOpen": {
"description": (
"Fired when a Server-Sent Events connection is successfully established. "
@@ -688,6 +697,7 @@ EVENT_DETAILS: dict[str, dict] = {
' (span :class "status" "Connecting...")\n'
' (div :id "messages"))'
),
"demo": "ref-event-sse-open-demo",
},
"sx:sseMessage": {
"description": (
@@ -698,10 +708,10 @@ EVENT_DETAILS: dict[str, dict] = {
';; Count received messages\n'
'(div :sx-sse "/api/stream"\n'
' :sx-sse-swap "update"\n'
' :sx-on:sx:sseMessage "this.dataset.count = (parseInt(this.dataset.count||0)+1); this.querySelector(\'.count\').textContent = this.dataset.count"\n'
' (span :class "count" "0") " messages received"\n'
' (div :id "stream-content"))'
' :sx-on:sx:sseMessage "this.dataset.count = (parseInt(this.dataset.count||0)+1)"\n'
' (span :class "count" "0") " messages received")'
),
"demo": "ref-event-sse-message-demo",
},
"sx:sseError": {
"description": (
@@ -715,6 +725,7 @@ EVENT_DETAILS: dict[str, dict] = {
' (span :class "status" "Connecting...")\n'
' (div :id "messages"))'
),
"demo": "ref-event-sse-error-demo",
},
}
@@ -1200,14 +1211,22 @@ ATTR_DETAILS: dict[str, dict] = {
"description": (
"Progressively enhance all descendant links and forms with AJAX navigation. "
"Links become sx-get requests with pushState, forms become sx-post/sx-get requests. "
"No explicit sx-* attributes needed on each link or form — just place sx-boost on a container."
"No explicit sx-* attributes needed on each link or form — just place sx-boost on a container. "
'The attribute value can be a CSS selector (e.g. sx-boost="#main-panel") to set '
"the default swap target for all boosted descendants. If set to \"true\", "
"each link/form must specify its own sx-target. "
"Pure pages (no server data dependencies) are rendered client-side without a server request."
),
"demo": "ref-boost-demo",
"example": (
'(nav :sx-boost "true"\n'
';; Boost with configurable target\n'
'(nav :sx-boost "#main-panel"\n'
' (a :href "/docs/introduction" "Introduction")\n'
' (a :href "/docs/components" "Components")\n'
' (a :href "/docs/evaluator" "Evaluator"))'
' (a :href "/docs/evaluator" "Evaluator"))\n'
'\n'
';; All links swap into #main-panel automatically.\n'
';; Pure pages render client-side (no server request).'
),
},
"sx-preload": {

View File

@@ -54,3 +54,8 @@
:params ()
:returns "dict"
:service "sx")
(define-page-helper "data-test-data"
:params ()
:returns "dict"
:service "sx")

56
sx/sx/data-test.sx Normal file
View File

@@ -0,0 +1,56 @@
;; Data test page — exercises Phase 4 client-side data rendering + caching.
;;
;; This page has a :data expression. When navigated to:
;; - Full page load: server evaluates data + renders content (normal path)
;; - Client route (1st): client fetches /sx/data/data-test, caches, renders
;; - Client route (2nd within 30s): client uses cached data, renders instantly
;;
;; Open browser console and look for:
;; "sx:route client+data" — cache miss, fetched from server
;; "sx:route client+cache" — cache hit, rendered from cached data
(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 and client-side data cache. "
"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 — updated with cache instructions
(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 + caching")
(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 "Navigate away, then back within 30 seconds")
(li "Look for: " (code :class "bg-amber-100 px-1 rounded" "sx:route client+cache /isomorphism/data-test"))
(li "The server-time value should be the same (cached data)")
(li "Wait 30+ seconds, navigate back again — new fetch, updated time")))))

View File

@@ -106,13 +106,18 @@
(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"
:summary "Extensible parse-time transformations via # dispatch — datum comments, raw strings, and quote shorthand.")
(dict :label "SX-Activity" :href "/plans/sx-activity"
:summary "A new web built on SX — executable content, shared components, parsers, and logic on IPFS, provenance on Bitcoin, all running within your own security context.")))
:summary "A new web built on SX — executable content, shared components, parsers, and logic on IPFS, provenance on Bitcoin, all running within your own security context.")
(dict :label "Predictive Prefetching" :href "/plans/predictive-prefetch"
:summary "Prefetch missing component definitions before the user clicks — hover a link, fetch its deps, navigate client-side.")
(dict :label "Content-Addressed Components" :href "/plans/content-addressed-components"
:summary "Components identified by CID, stored on IPFS, fetched from anywhere. Canonical serialization, content verification, federated sharing.")))
(define bootstrappers-nav-items (list
(dict :label "Overview" :href "/bootstrappers/")

View File

@@ -594,6 +594,677 @@
(td :class "px-3 py-2 text-stone-700" "Content addressing — shared with component CIDs")
(td :class "px-3 py-2 text-stone-600" "2, 3"))))))))
;; ---------------------------------------------------------------------------
;; Content-Addressed Components
;; ---------------------------------------------------------------------------
(defcomp ~plan-content-addressed-components-content ()
(~doc-page :title "Content-Addressed Components"
(~doc-section :title "The Premise" :id "premise"
(p "SX components are pure functions. Boundary enforcement guarantees it — a component cannot call IO primitives, make network requests, access cookies, or touch the filesystem. " (code "Component.is_pure") " is a structural property, verified at registration time by scanning the transitive closure of IO references via " (code "deps.sx") ".")
(p "Pure functions have a remarkable property: " (strong "their identity is their content.") " Two components that produce the same serialized form are the same component, regardless of who wrote them or where they're hosted. This means we can content-address them — compute a cryptographic hash of the canonical serialized form, and that hash " (em "is") " the component's identity.")
(p "Content addressing turns components into shared infrastructure. Define " (code "~card") " once, pin it to IPFS, and every SX application on the planet can use it by CID. No package registry, no npm install, no version conflicts. The CID " (em "is") " the version. The hash " (em "is") " the trust. Boundary enforcement " (em "is") " the sandbox.")
(p "This plan details how to get from the current name-based, per-server component model to a content-addressed, globally-shared one."))
;; -----------------------------------------------------------------------
;; Current State
;; -----------------------------------------------------------------------
(~doc-section :title "Current State" :id "current-state"
(p "What already exists and what's missing.")
(div :class "overflow-x-auto rounded border border-stone-200 mb-4"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "Capability")
(th :class "px-3 py-2 font-medium text-stone-600" "Status")
(th :class "px-3 py-2 font-medium text-stone-600" "Where")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Deterministic serialization")
(td :class "px-3 py-2 text-stone-700" "Partial — " (code "serialize(body, pretty=True)") " from AST, but no canonical normalization")
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "parser.py:296-427"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Component identity")
(td :class "px-3 py-2 text-stone-700" "By name (" (code "~card") ") — names are mutable, server-local")
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "types.py:157-180"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Bundle hashing")
(td :class "px-3 py-2 text-stone-700" "SHA256 of all defs concatenated — per-bundle, not per-component")
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "jinja_bridge.py:60-86"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Purity verification")
(td :class "px-3 py-2 text-stone-700" (span :class "text-green-700 font-medium" "Complete") " — " (code "is_pure") " via transitive IO ref analysis")
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "deps.sx, boundary.py"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Dependency graph")
(td :class "px-3 py-2 text-stone-700" (span :class "text-green-700 font-medium" "Complete") " — " (code "Component.deps") " transitive closure")
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "deps.sx"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "IPFS infrastructure")
(td :class "px-3 py-2 text-stone-700" (span :class "text-green-700 font-medium" "Exists") " — IPFSPin model, async upload tasks, CID tracking")
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "models/federation.py, artdag/l1/tasks/"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Client component caching")
(td :class "px-3 py-2 text-stone-700" "Hash-based localStorage — but keyed by bundle hash, not individual CID")
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "boot.sx, helpers.py"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Content-addressed components")
(td :class "px-3 py-2 text-stone-700" (span :class "text-red-700 font-medium" "Not yet") " — no per-component CID, no IPFS resolution")
(td :class "px-3 py-2 text-stone-600" "—"))))))
;; -----------------------------------------------------------------------
;; Canonical Serialization
;; -----------------------------------------------------------------------
(~doc-section :title "Phase 1: Canonical Serialization" :id "canonical-serialization"
(div :class "rounded border border-violet-200 bg-violet-50 p-4 mb-4"
(p :class "text-violet-900 font-medium" "The foundation")
(p :class "text-violet-800" "Same component must always produce the same bytes, regardless of original formatting, whitespace, or comment placement. Without this, content addressing is meaningless."))
(~doc-subsection :title "The Problem"
(p "Currently " (code "serialize(body, pretty=True)") " produces readable SX source from the parsed AST. But serialization isn't fully canonical — it depends on the internal representation order, and there's no normalization pass. Two semantically identical components formatted differently would produce different hashes.")
(p "We need a " (strong "canonical form") " that strips all variance:"))
(~doc-subsection :title "Canonical Form Rules"
(ol :class "list-decimal pl-5 text-stone-700 space-y-2"
(li (strong "Strip comments.") " Comments are parsing artifacts, not part of the AST. The serializer already ignores them (it works from the parsed tree), but any future comment-preserving parser must not affect canonical output.")
(li (strong "Normalize whitespace.") " Single space between tokens, newline before each top-level form in a body. No trailing whitespace. No blank lines.")
(li (strong "Sort keyword arguments alphabetically.") " In component calls: " (code "(~card :class \"x\" :title \"y\")") " not " (code "(~card :title \"y\" :class \"x\")") ". In dict literals: " (code "{:a 1 :b 2}") " not " (code "{:b 2 :a 1}") ".")
(li (strong "Normalize string escapes.") " Use " (code "\\n") " not literal newlines in strings. Escape only what must be escaped.")
(li (strong "Normalize numbers.") " " (code "1.0") " not " (code "1.00") " or " (code "1.") ". " (code "42") " not " (code "042") ".")
(li (strong "Include the full definition form.") " Hash the complete " (code "(defcomp ~name (params) body)") ", not just the body. The name and parameter signature are part of the component's identity.")))
(~doc-subsection :title "Implementation"
(p "New spec function in a " (code "canonical.sx") " module:")
(~doc-code :code (highlight "(define canonical-serialize\n (fn (node)\n ;; Produce a canonical s-expression string from an AST node.\n ;; Deterministic: same AST always produces same output.\n ;; Used for CID computation — NOT for human-readable output.\n (case (type-of node)\n \"list\"\n (str \"(\" (join \" \" (map canonical-serialize node)) \")\")\n \"dict\"\n (let ((sorted-keys (sort (keys node))))\n (str \"{\" (join \" \"\n (map (fn (k)\n (str \":\" k \" \" (canonical-serialize (get node k))))\n sorted-keys)) \"}\"))\n \"string\"\n (str '\"' (escape-canonical node) '\"')\n \"number\"\n (canonical-number node)\n \"symbol\"\n (symbol-name node)\n \"keyword\"\n (str \":\" (keyword-name node))\n \"boolean\"\n (if node \"true\" \"false\")\n \"nil\"\n \"nil\")))" "lisp"))
(p "This function must be bootstrapped to both Python and JS — the server computes CIDs at registration time, the client verifies them on fetch.")
(p "The canonical serializer is distinct from " (code "serialize()") " for display. " (code "serialize(pretty=True)") " remains for human-readable output. " (code "canonical-serialize") " is for hashing only.")))
;; -----------------------------------------------------------------------
;; CID Computation
;; -----------------------------------------------------------------------
(~doc-section :title "Phase 2: CID Computation" :id "cid-computation"
(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" "Every component gets a stable, unique content identifier. Same source → same CID, always. Different source → different CID, always."))
(~doc-subsection :title "CID Format"
(p "Use " (a :href "https://github.com/multiformats/cid" :class "text-violet-700 underline" "CIDv1") " with:")
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li (strong "Hash function:") " SHA3-256 (already used by artdag for content addressing)")
(li (strong "Codec:") " raw (the content is the canonical SX source bytes, not a DAG-PB wrapper)")
(li (strong "Base encoding:") " base32lower for URL-safe representation (" (code "bafy...") " prefix)"))
(~doc-code :code (highlight ";; CID computation pipeline\n(define component-cid\n (fn (component)\n ;; 1. Reconstruct full defcomp form\n ;; 2. Canonical serialize\n ;; 3. SHA3-256 hash\n ;; 4. Wrap as CIDv1\n (let ((source (canonical-serialize\n (list 'defcomp\n (symbol (str \"~\" (component-name component)))\n (component-params-list component)\n (component-body component)))))\n (cid-v1 :sha3-256 :raw (encode-utf8 source)))))" "lisp")))
(~doc-subsection :title "Where CIDs Live"
(p "Each " (code "Component") " object gains a " (code "cid") " field, computed at registration time:")
(~doc-code :code (highlight ";; types.py extension\n@dataclass\nclass Component:\n name: str\n params: list[str]\n has_children: bool\n body: Any\n closure: dict[str, Any]\n css_classes: set[str]\n deps: set[str] # by name\n io_refs: set[str]\n cid: str | None = None # computed after registration\n dep_cids: dict[str, str] | None = None # name → CID" "python"))
(p "After " (code "compute_all_deps()") " runs, a new " (code "compute_all_cids()") " pass fills in CIDs for every component. Dependency CIDs are also recorded — when a component references " (code "~card") ", we store both the name and card's CID."))
(~doc-subsection :title "CID Stability"
(p "A component's CID changes when and only when its " (strong "semantics") " change:")
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li "Reformatting the " (code ".sx") " source file → same AST → same canonical form → " (strong "same CID"))
(li "Adding a comment → stripped by parser → same AST → " (strong "same CID"))
(li "Changing a class name in the body → different AST → " (strong "different CID"))
(li "Renaming the component → different defcomp form → " (strong "different CID") " (name is part of identity)"))
(p "This means CIDs are " (em "immutable versions") ". There's no " (code "~card@1.2.3") " — there's " (code "~card") " at CID " (code "bafy...abc") " and " (code "~card") " at CID " (code "bafy...def") ". The name is a human-friendly alias; the CID is the truth.")))
;; -----------------------------------------------------------------------
;; Component Manifest
;; -----------------------------------------------------------------------
(~doc-section :title "Phase 3: Component Manifest" :id "manifest"
(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" "Metadata that travels with a CID — what a component needs, what it provides, whether it's safe to run. Enough information to resolve, validate, and render without fetching the source first."))
(~doc-subsection :title "Manifest Structure"
(~doc-code :code (highlight ";; Component manifest — published alongside the source\n(SxComponent\n :name \"~product-card\"\n :cid \"bafy...productcard\"\n :source-bytes 847\n :params (:title :price :image-url)\n :has-children true\n :pure true\n :deps (\n {:name \"~card\" :cid \"bafy...card\"}\n {:name \"~price-tag\" :cid \"bafy...pricetag\"}\n {:name \"~lazy-image\" :cid \"bafy...lazyimg\"})\n :css-atoms (:border :rounded :p-4 :text-sm :font-bold\n :text-green-700 :line-through :text-stone-400)\n :author \"https://rose-ash.com/apps/market\"\n :published \"2026-03-06T14:30:00Z\")" "lisp"))
(p "Key fields:")
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li (code ":cid") " — content address of the canonical serialized source")
(li (code ":deps") " — dependency CIDs, not just names. A consumer can recursively resolve the entire tree by CID without name ambiguity")
(li (code ":pure") " — pre-computed purity flag. The consumer " (em "re-verifies") " this after fetching (never trust the manifest alone), but it enables fast rejection of IO-dependent components before downloading")
(li (code ":css-atoms") " — CSSX class names the component uses. The consumer can pre-resolve CSS rules without parsing the source")
(li (code ":params") " — parameter signature for tooling, documentation, IDE support")
(li (code ":author") " — who published this. AP actor URL, verifiable via HTTP Signatures")))
(~doc-subsection :title "Manifest CID"
(p "The manifest itself is content-addressed. But the manifest CID is " (em "not") " the component CID — they're separate objects. The component CID is derived from the source alone (pure content). The manifest CID includes metadata that could change (author, publication date) without changing the component.")
(p "Resolution order: manifest CID → manifest → component CID → component source. Or shortcut: component CID → source directly, if you already know what you need.")))
;; -----------------------------------------------------------------------
;; IPFS Storage & Resolution
;; -----------------------------------------------------------------------
(~doc-section :title "Phase 4: IPFS Storage & Resolution" :id "ipfs"
(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" "Components live on IPFS. Any browser can fetch them by CID. No origin server needed. No CDN. No DNS. The content network IS the distribution network."))
(~doc-subsection :title "Server-Side: Publication"
(p "On component registration (startup or hot-reload), the server:")
(ol :class "list-decimal pl-5 text-stone-700 space-y-1"
(li "Computes canonical form and CID")
(li "Checks " (code "IPFSPin") " — if CID already pinned, skip (content can't have changed)")
(li "Pins canonical source to IPFS (async Celery task, same pattern as artdag)")
(li "Creates/updates " (code "IPFSPin") " record with " (code "pin_type=\"component\""))
(li "Publishes manifest to IPFS (separate CID)")
(li "Optionally announces via AP outbox for federated discovery"))
(~doc-code :code (highlight ";; IPFSPin usage for components\nIPFSPin(\n content_hash=\"sha3-256:abcdef...\",\n ipfs_cid=\"bafy...productcard\",\n pin_type=\"component\",\n source_type=\"market\", # which service defined it\n metadata={\n \"name\": \"~product-card\",\n \"manifest_cid\": \"bafy...manifest\",\n \"deps\": [\"bafy...card\", \"bafy...pricetag\"],\n \"pure\": True\n }\n)" "python")))
(~doc-subsection :title "Client-Side: Resolution"
(p "New spec module " (code "resolve.sx") " — the client-side component resolution pipeline:")
(~doc-code :code (highlight "(define resolve-component-by-cid\n (fn (cid callback)\n ;; Resolution cascade:\n ;; 1. Check component env (already loaded?)\n ;; 2. Check localStorage (keyed by CID = cache-forever)\n ;; 3. Check origin server (/sx/components?cid=bafy...)\n ;; 4. Fetch from IPFS gateway\n ;; 5. Verify hash matches CID\n ;; 6. Parse, validate purity, register, callback\n (let ((cached (local-storage-get (str \"sx-cid:\" cid))))\n (if cached\n (do\n (register-component-source cached)\n (callback true))\n (fetch-component-by-cid cid\n (fn (source)\n (if (verify-cid cid source)\n (do\n (local-storage-set (str \"sx-cid:\" cid) source)\n (register-component-source source)\n (callback true))\n (do\n (log-warn (str \"sx:cid verification failed \" cid))\n (callback false)))))))))" "lisp"))
(p "The cache-forever semantics are the key insight: because CIDs are content-addressed, a cached component " (strong "can never be stale") ". If the source changes, it gets a new CID. Old CIDs remain valid forever. There is no cache invalidation problem."))
(~doc-subsection :title "Resolution Cascade"
(div :class "overflow-x-auto rounded border border-stone-200 mb-4"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "Layer")
(th :class "px-3 py-2 font-medium text-stone-600" "Lookup")
(th :class "px-3 py-2 font-medium text-stone-600" "Latency")
(th :class "px-3 py-2 font-medium text-stone-600" "When")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "1. Component env")
(td :class "px-3 py-2 font-mono text-sm text-stone-700" "(env-has? env cid)")
(td :class "px-3 py-2 text-stone-600" "0ms")
(td :class "px-3 py-2 text-stone-600" "Already loaded this session"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "2. localStorage")
(td :class "px-3 py-2 font-mono text-sm text-stone-700" "localStorage[\"sx-cid:\" + cid]")
(td :class "px-3 py-2 text-stone-600" "<1ms")
(td :class "px-3 py-2 text-stone-600" "Previously fetched, persists across sessions"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "3. Origin server")
(td :class "px-3 py-2 font-mono text-sm text-stone-700" "GET /sx/components?cid=bafy...")
(td :class "px-3 py-2 text-stone-600" "~20ms")
(td :class "px-3 py-2 text-stone-600" "Same-origin component, not yet cached"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "4. IPFS gateway")
(td :class "px-3 py-2 font-mono text-sm text-stone-700" "GET https://gateway/ipfs/{cid}")
(td :class "px-3 py-2 text-stone-600" "~200ms")
(td :class "px-3 py-2 text-stone-600" "Foreign component, federated content"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "5. Local IPFS node")
(td :class "px-3 py-2 font-mono text-sm text-stone-700" "ipfs cat {cid}")
(td :class "px-3 py-2 text-stone-600" "~5ms")
(td :class "px-3 py-2 text-stone-600" "User runs own IPFS node (power users)")))))
(p "Layer 5 is optional — checked between 2 and 3 if " (code "window.ipfs") " or a local gateway is detected. For most users, layers 1-4 cover all cases.")))
;; -----------------------------------------------------------------------
;; Security Model
;; -----------------------------------------------------------------------
(~doc-section :title "Phase 5: Security Model" :id "security"
(div :class "rounded border border-violet-200 bg-violet-50 p-4 mb-4"
(p :class "text-violet-900 font-medium" "The hard part")
(p :class "text-violet-800" "Loading code from the network is the web's original sin. Content-addressed components are safe because of three structural guarantees — not policies, not trust, not sandboxes that can be escaped."))
(~doc-subsection :title "Guarantee 1: Purity is Structural"
(p "SX boundary enforcement isn't a runtime sandbox — it's a registration-time structural check. When a component is loaded from IPFS and parsed, " (code "compute_all_io_refs()") " walks its entire AST and transitive dependencies. If " (em "any") " node references an IO primitive, the component is classified as IO-dependent and " (strong "rejected for untrusted registration."))
(p "This means the evaluator literally doesn't have IO primitives in scope when running an IPFS-loaded component. It's not that we catch IO calls — the names don't resolve. There's nothing to catch.")
(~doc-code :code (highlight "(define register-untrusted-component\n (fn (source origin)\n ;; Parse the defcomp from source\n ;; Run compute-all-io-refs on the parsed component\n ;; If io_refs is non-empty → REJECT\n ;; If pure → register in env with :origin metadata\n (let ((comp (parse-component source)))\n (if (not (component-pure? comp))\n (do\n (log-warn (str \"sx:reject IO component from \" origin))\n nil)\n (do\n (register-component comp)\n (log-info (str \"sx:registered \" (component-name comp)\n \" from \" origin))\n comp)))))" "lisp")))
(~doc-subsection :title "Guarantee 2: Content Verification"
(p "The CID IS the hash. When you fetch " (code "bafy...abc") " from any source — IPFS gateway, origin server, peer — you hash the response and compare. If it doesn't match, you reject it. No MITM attack can alter the content without changing the CID.")
(p "This is stronger than HTTPS. HTTPS trusts the certificate authority, the DNS resolver, and the server operator. Content addressing trusts " (em "mathematics") ". The hash either matches or it doesn't."))
(~doc-subsection :title "Guarantee 3: Evaluation Limits"
(p "Pure doesn't mean terminating. A component could contain an infinite loop or exponential recursion. SX evaluators enforce step limits:")
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li (strong "Max eval steps:") " configurable per context. Untrusted components get a lower limit than local ones.")
(li (strong "Max recursion depth:") " prevents stack exhaustion.")
(li (strong "Max output size:") " prevents a component from producing gigabytes of DOM nodes."))
(p "Exceeding any limit halts evaluation and returns an error node. The worst case is wasted CPU — never data exfiltration, never unauthorized IO."))
(~doc-subsection :title "Trust Tiers"
(div :class "overflow-x-auto rounded border border-stone-200 mb-4"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "Tier")
(th :class "px-3 py-2 font-medium text-stone-600" "Source")
(th :class "px-3 py-2 font-medium text-stone-600" "Allowed")
(th :class "px-3 py-2 font-medium text-stone-600" "Eval limits")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-semibold text-stone-800" "Local")
(td :class "px-3 py-2 text-stone-700" "Server's own " (code ".sx") " files")
(td :class "px-3 py-2 text-stone-700" "Pure + IO primitives + page helpers")
(td :class "px-3 py-2 text-stone-600" "None (trusted)"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-semibold text-stone-800" "Followed")
(td :class "px-3 py-2 text-stone-700" "Components from followed AP actors")
(td :class "px-3 py-2 text-stone-700" "Pure only (IO rejected)")
(td :class "px-3 py-2 text-stone-600" "Standard limits"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-semibold text-stone-800" "Federated")
(td :class "px-3 py-2 text-stone-700" "Components from any IPFS source")
(td :class "px-3 py-2 text-stone-700" "Pure only (IO rejected)")
(td :class "px-3 py-2 text-stone-600" "Strict limits"))))))
(~doc-subsection :title "What Can Go Wrong"
(p "Honest accounting of the attack surface:")
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li (strong "Visual spoofing:") " A malicious component could render UI that looks like a login form. Mitigation: untrusted components render inside a visually distinct container with origin attribution.")
(li (strong "CSS abuse:") " A component's CSS atoms could interfere with page layout. Mitigation: scoped CSS — untrusted components' classes are namespaced.")
(li (strong "Resource exhaustion:") " A component could be expensive to evaluate. Mitigation: step limits, timeout, lazy rendering for off-screen components.")
(li (strong "Privacy leak via CSS:") " Background-image URLs could phone home. Mitigation: CSP restrictions on untrusted component rendering contexts.")
(li (strong "Dependency confusion:") " A malicious manifest could claim deps that are different components with the same name. Mitigation: deps are referenced by CID, not name. Name is informational only."))))
;; -----------------------------------------------------------------------
;; Wire Format & Prefetch Integration
;; -----------------------------------------------------------------------
(~doc-section :title "Phase 6: Wire Format & Prefetch Integration" :id "wire-format"
(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" "Pages and SX responses reference components by CID. The prefetch system resolves them from the most efficient source. Components become location-independent."))
(~doc-subsection :title "CID References in Page Registry"
(p "The page registry (shipped to the client as " (code "<script type=\"text/sx-pages\">") ") currently lists deps by name. Extend to include CIDs:")
(~doc-code :code (highlight "{:name \"docs-page\" :path \"/docs/<slug>\"\n :auth \"public\" :has-data false\n :deps ({:name \"~essay-foo\" :cid \"bafy...essay\"}\n {:name \"~doc-code\" :cid \"bafy...doccode\"})\n :content \"(case slug ...)\" :closure {}}" "lisp"))
(p "The " (a :href "/plans/predictive-prefetch" :class "text-violet-700 underline" "predictive prefetch system") " uses these CIDs to fetch components from the resolution cascade rather than only from the origin server's " (code "/sx/components") " endpoint."))
(~doc-subsection :title "SX Response Component Headers"
(p "Currently, " (code "SX-Components") " header lists loaded component names. Extend to support CIDs:")
(~doc-code :code (highlight "Request:\nSX-Components: ~card:bafy...card,~nav:bafy...nav\n\nResponse:\nSX-Component-CIDs: ~essay-foo:bafy...essay,~doc-code:bafy...doccode\n\n;; Response body only includes defs the client doesn't have\n(defcomp ~essay-foo ...)" "http"))
(p "The client can then verify received components match their declared CIDs. If the origin server is compromised, CID verification catches the tampered response."))
(~doc-subsection :title "Federated Content"
(p "When an ActivityPub activity arrives with SX content, it declares component requirements by CID:")
(~doc-code :code (highlight "(Create\n :actor \"https://other-instance.com/users/bob\"\n :object (Note\n :content (~product-card :title \"Bob's Widget\" :price 29.99)\n :requires (list\n {:name \"~product-card\" :cid \"bafy...prodcard\"}\n {:name \"~price-tag\" :cid \"bafy...pricetag\"})))" "lisp"))
(p "The receiving browser resolves required components through the cascade. If Bob's instance is down, the components are still fetchable from IPFS. The content is self-describing and self-resolving.")))
;; -----------------------------------------------------------------------
;; Component Sharing & Discovery
;; -----------------------------------------------------------------------
(~doc-section :title "Phase 7: Sharing & Discovery" :id "sharing"
(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" "Servers publish component collections via AP. Other servers follow them. Like npm, but federated, content-addressed, and structurally safe."))
(~doc-subsection :title "Component Registry as AP Actor"
(p "Each server exposes a component registry actor:")
(~doc-code :code (highlight "(Service\n :id \"https://rose-ash.com/sx-registry\"\n :type \"SxComponentRegistry\"\n :name \"Rose Ash Components\"\n :outbox \"https://rose-ash.com/sx-registry/outbox\"\n :followers \"https://rose-ash.com/sx-registry/followers\")" "lisp"))
(p "Follow the registry to receive component updates. The outbox is a chronological feed of Create/Update/Delete activities for components. 'Update' means a new CID for the same name — consumers decide whether to adopt it."))
(~doc-subsection :title "Discovery Protocol"
(p "Webfinger-style lookup for components by name:")
(~doc-code :code (highlight "GET /.well-known/sx-component?name=~product-card\n\n{\n \"name\": \"~product-card\",\n \"cid\": \"bafy...prodcard\",\n \"manifest_cid\": \"bafy...manifest\",\n \"gateway\": \"https://rose-ash.com/ipfs/\",\n \"author\": \"https://rose-ash.com/apps/market\"\n}" "http"))
(p "This is an optional convenience — any consumer that knows the CID can skip discovery and fetch directly from IPFS. Discovery answers the question: " (em "\"what's the current version of ~product-card on rose-ash.com?\""))
)
(~doc-subsection :title "Name Resolution"
(p "Names are human-friendly aliases for CIDs. The same name on different servers can refer to different components (different CIDs). Conflict resolution is simple:")
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li (strong "Local wins:") " If the server defines " (code "~card") ", that definition takes precedence over any federated " (code "~card") ".")
(li (strong "CID pinning:") " When referencing a federated component, pin the CID. " (code "(:name \"~card\" :cid \"bafy...abc\")") " — the name is informational, the CID is authoritative.")
(li (strong "No global namespace:") " There is no \"npm\" that owns " (code "~card") ". Names are scoped to the server that defines them. CIDs are global."))))
;; -----------------------------------------------------------------------
;; Spec modules
;; -----------------------------------------------------------------------
(~doc-section :title "Spec Modules" :id "spec-modules"
(p "Per the SX host architecture principle, all content-addressing logic is specced in " (code ".sx") " files and bootstrapped:")
(div :class "overflow-x-auto rounded border border-stone-200 mb-4"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "Spec module")
(th :class "px-3 py-2 font-medium text-stone-600" "Functions")
(th :class "px-3 py-2 font-medium text-stone-600" "Platform obligations")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "canonical.sx")
(td :class "px-3 py-2 text-stone-700" (code "canonical-serialize") ", " (code "canonical-number") ", " (code "escape-canonical"))
(td :class "px-3 py-2 text-stone-600" "None — pure string operations"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "cid.sx")
(td :class "px-3 py-2 text-stone-700" (code "component-cid") ", " (code "verify-cid") ", " (code "cid-to-string") ", " (code "parse-cid"))
(td :class "px-3 py-2 text-stone-600" (code "sha3-256") ", " (code "encode-base32") ", " (code "encode-utf8")))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "resolve.sx")
(td :class "px-3 py-2 text-stone-700" (code "resolve-component-by-cid") ", " (code "resolve-deps-recursive") ", " (code "register-untrusted-component"))
(td :class "px-3 py-2 text-stone-600" (code "local-storage-get/set") ", " (code "fetch-cid") ", " (code "register-component-source"))))))
;; -----------------------------------------------------------------------
;; Critical files
;; -----------------------------------------------------------------------
(~doc-section :title "Critical Files" :id "critical-files"
(div :class "overflow-x-auto rounded border border-stone-200"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "File")
(th :class "px-3 py-2 font-medium text-stone-600" "Role")
(th :class "px-3 py-2 font-medium text-stone-600" "Phase")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/canonical.sx")
(td :class "px-3 py-2 text-stone-700" "Canonical serialization spec (new)")
(td :class "px-3 py-2 text-stone-600" "1"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/cid.sx")
(td :class "px-3 py-2 text-stone-700" "CID computation and verification spec (new)")
(td :class "px-3 py-2 text-stone-600" "2"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/types.py")
(td :class "px-3 py-2 text-stone-700" "Add " (code "cid") " and " (code "dep_cids") " to Component")
(td :class "px-3 py-2 text-stone-600" "2"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/jinja_bridge.py")
(td :class "px-3 py-2 text-stone-700" "Add " (code "compute_all_cids()") " to registration lifecycle")
(td :class "px-3 py-2 text-stone-600" "2"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/models/federation.py")
(td :class "px-3 py-2 text-stone-700" "IPFSPin records for component CIDs")
(td :class "px-3 py-2 text-stone-600" "4"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/resolve.sx")
(td :class "px-3 py-2 text-stone-700" "Client-side CID resolution cascade (new)")
(td :class "px-3 py-2 text-stone-600" "4"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/helpers.py")
(td :class "px-3 py-2 text-stone-700" "CIDs in page registry, " (code "/sx/components?cid=") " endpoint")
(td :class "px-3 py-2 text-stone-600" "6"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/orchestration.sx")
(td :class "px-3 py-2 text-stone-700" "CID-aware prefetch in resolution cascade")
(td :class "px-3 py-2 text-stone-600" "6"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/infrastructure/activitypub.py")
(td :class "px-3 py-2 text-stone-700" "Component registry actor, Webfinger extension")
(td :class "px-3 py-2 text-stone-600" "7"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/boundary.py")
(td :class "px-3 py-2 text-stone-700" "Trust tier enforcement for untrusted components")
(td :class "px-3 py-2 text-stone-600" "5"))))))
;; -----------------------------------------------------------------------
;; Relationship
;; -----------------------------------------------------------------------
(~doc-section :title "Relationships" :id "relationships"
(p "This plan is the foundation for several other plans and roadmaps:")
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li (a :href "/plans/sx-activity" :class "text-violet-700 underline" "SX-Activity") " Phase 2 (content-addressed components on IPFS) is a summary of this plan. This plan supersedes that section with full detail.")
(li (a :href "/plans/predictive-prefetch" :class "text-violet-700 underline" "Predictive prefetching") " gains CID-based resolution — the " (code "/sx/components") " endpoint and IPFS gateway become alternative resolution paths in the prefetch cascade.")
(li (a :href "/plans/isomorphic-architecture" :class "text-violet-700 underline" "Isomorphic architecture") " Phase 1 (component distribution) is enhanced — CIDs make per-page bundles verifiable and cross-server shareable.")
(li "The SX-Activity vision of " (strong "serverless applications on IPFS") " depends entirely on this plan. Without content-addressed components, applications can't be pinned to IPFS as self-contained artifacts."))
(div :class "rounded border border-amber-200 bg-amber-50 p-3 mt-2"
(p :class "text-amber-800 text-sm" (strong "Depends on: ") "deps.sx (complete), boundary enforcement (complete), IPFS infrastructure (exists in artdag, needs wiring to web platform)."))))))
;; ---------------------------------------------------------------------------
;; Predictive Component Prefetching
;; ---------------------------------------------------------------------------
(defcomp ~plan-predictive-prefetch-content ()
(~doc-page :title "Predictive Component Prefetching"
(~doc-section :title "Context" :id "context"
(p "Phase 3 of the isomorphic roadmap added client-side routing with component dependency checking. When a user clicks a link, " (code "try-client-route") " checks " (code "has-all-deps?") " — if the target page needs components not yet loaded, the client falls back to a server fetch. This works correctly but misses an opportunity: " (strong "we can prefetch those missing components before the click happens."))
(p "The page registry already carries " (code ":deps") " metadata for every page. The client already knows which components are loaded via " (code "loaded-component-names") ". The gap is a mechanism to " (em "proactively") " resolve the difference — fetching missing component definitions so that by the time the user clicks, client-side routing succeeds.")
(p "But this goes beyond just hover-to-prefetch. The full spectrum includes: bundling linked routes' components with the initial page load, batch-prefetching after idle, predicting mouse trajectory toward links, and even splitting the component/data fetch so that " (code ":data") " pages can prefetch their components and only fetch data on click. Each strategy trades bandwidth for latency, and pages should be able to declare which tradeoff they want."))
(~doc-section :title "Current State" :id "current-state"
(div :class "overflow-x-auto rounded border border-stone-200 mb-4"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "Layer")
(th :class "px-3 py-2 font-medium text-stone-600" "What exists")
(th :class "px-3 py-2 font-medium text-stone-600" "Where")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Page registry")
(td :class "px-3 py-2 text-stone-700" "Each page carries " (code ":deps (\"~card\" \"~essay-foo\" ...)"))
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "helpers.py → <script type=\"text/sx-pages\">"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Dep check")
(td :class "px-3 py-2 text-stone-700" (code "has-all-deps?") " gates client routing")
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "orchestration.sx:546-559"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Component bundle")
(td :class "px-3 py-2 text-stone-700" "Per-page inline " (code "<script type=\"text/sx\" data-components>"))
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "helpers.py:715, jinja_bridge.py"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Incremental defs")
(td :class "px-3 py-2 text-stone-700" (code "components_for_request()") " sends only missing defs in SX responses")
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "helpers.py:459-509"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Preload cache")
(td :class "px-3 py-2 text-stone-700" (code "sx-preload") " prefetches full responses on hover/mousedown")
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "orchestration.sx:686-708"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Route matching")
(td :class "px-3 py-2 text-stone-700" (code "find-matching-route") " matches pathname to page entry")
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "router.sx"))))))
;; -----------------------------------------------------------------------
;; Prefetch strategies
;; -----------------------------------------------------------------------
(~doc-section :title "Prefetch Strategies" :id "strategies"
(p "Prefetching is a spectrum from conservative to aggressive. The system should support all of these, configured declaratively per link or per page via " (code "defpage") " metadata and " (code "sx-prefetch") " attributes.")
(div :class "overflow-x-auto rounded border border-stone-200 mb-4"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "Strategy")
(th :class "px-3 py-2 font-medium text-stone-600" "Trigger")
(th :class "px-3 py-2 font-medium text-stone-600" "What prefetches")
(th :class "px-3 py-2 font-medium text-stone-600" "Latency on click")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-semibold text-stone-800" "Eager bundle")
(td :class "px-3 py-2 text-stone-700" "Initial page load")
(td :class "px-3 py-2 text-stone-700" "Components for linked routes included in " (code "<script data-components>"))
(td :class "px-3 py-2 text-stone-600" "Zero — already in memory"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-semibold text-stone-800" "Idle timer")
(td :class "px-3 py-2 text-stone-700" "After page settles (requestIdleCallback or setTimeout)")
(td :class "px-3 py-2 text-stone-700" "Components for visible nav links, batched in one request")
(td :class "px-3 py-2 text-stone-600" "Zero if idle fetch completed"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-semibold text-stone-800" "Viewport")
(td :class "px-3 py-2 text-stone-700" "Link scrolls into view (IntersectionObserver)")
(td :class "px-3 py-2 text-stone-700" "Components for that link's route")
(td :class "px-3 py-2 text-stone-600" "Zero if user scrolled before clicking"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-semibold text-stone-800" "Mouse approach")
(td :class "px-3 py-2 text-stone-700" "Cursor moving toward link (trajectory prediction)")
(td :class "px-3 py-2 text-stone-700" "Components for predicted target")
(td :class "px-3 py-2 text-stone-600" "Near-zero — fetch starts ~200ms before hover"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-semibold text-stone-800" "Hover")
(td :class "px-3 py-2 text-stone-700" "mouseover (150ms debounce)")
(td :class "px-3 py-2 text-stone-700" "Components for hovered link's route")
(td :class "px-3 py-2 text-stone-600" "Low — typical hover-to-click is 300-500ms"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-semibold text-stone-800" "Mousedown")
(td :class "px-3 py-2 text-stone-700" "mousedown (0ms debounce)")
(td :class "px-3 py-2 text-stone-700" "Components for clicked link's route")
(td :class "px-3 py-2 text-stone-600" "~80ms — mousedown-to-click gap"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-semibold text-stone-800" "Components + data")
(td :class "px-3 py-2 text-stone-700" "Any of the above")
(td :class "px-3 py-2 text-stone-700" "Components " (em "and") " page data for " (code ":data") " pages")
(td :class "px-3 py-2 text-stone-600" "Zero for components; data fetch may still be in flight")))))
(~doc-subsection :title "Eager Bundle"
(p "The server already computes per-page component bundles. For key navigation paths — the main nav bar, section nav — the server can include " (em "linked routes' components") " in the initial bundle, not just the current page's.")
(~doc-code :code (highlight ";; defpage metadata declares eager prefetch targets\n(defpage docs-page\n :path \"/docs/<slug>\"\n :auth :public\n :prefetch :eager ;; bundle deps for all linked pure routes\n :content (case slug ...))" "lisp"))
(p "Implementation: " (code "components_for_page()") " already scans the page SX for component refs. Extend it to also scan for " (code "href") " attributes, match them against the page registry, and include those pages' deps in the bundle. The cost is a larger initial payload; the benefit is zero-latency navigation within a section."))
(~doc-subsection :title "Idle Timer"
(p "After page load and initial render, use " (code "requestIdleCallback") " (or a fallback " (code "setTimeout") ") to scan visible nav links and batch-prefetch their missing components in a single request.")
(~doc-code :code (highlight "(define prefetch-visible-links-on-idle\n (fn ()\n (request-idle-callback\n (fn ()\n (let ((links (dom-query-all \"a[href][sx-get]\"))\n (all-missing (list)))\n (for-each\n (fn (link)\n (let ((missing (compute-missing-deps\n (url-pathname (dom-get-attr link \"href\")))))\n (when missing\n (for-each (fn (d) (append! all-missing d))\n missing))))\n links)\n (when (not (empty? all-missing))\n (prefetch-components (dedupe all-missing))))))))" "lisp"))
(p "Called once from " (code "boot-init") " after initial processing. Batches all missing deps into one network request. Low priority — browser handles it when idle."))
(~doc-subsection :title "Mouse Approach (Trajectory Prediction)"
(p "Don't wait for the cursor to reach the link — predict where it's heading. Track the last few " (code "mousemove") " events, extrapolate the trajectory, and if it points toward a link, start prefetching before the hover event fires.")
(~doc-code :code (highlight "(define bind-approach-prefetch\n (fn (container)\n ;; Track mouse trajectory within a nav container.\n ;; On each mousemove, extrapolate position ~200ms ahead.\n ;; If projected point intersects a link's bounding box,\n ;; prefetch that link's route deps.\n (let ((last-x 0) (last-y 0) (last-t 0)\n (prefetched (dict)))\n (dom-add-listener container \"mousemove\"\n (fn (e)\n (let ((now (timestamp))\n (dt (- now last-t)))\n (when (> dt 16) ;; ~60fps throttle\n (let ((vx (/ (- (event-x e) last-x) dt))\n (vy (/ (- (event-y e) last-y) dt))\n (px (+ (event-x e) (* vx 200)))\n (py (+ (event-y e) (* vy 200)))\n (target (dom-element-at-point px py)))\n (when (and target (dom-has-attr? target \"href\")\n (not (get prefetched\n (dom-get-attr target \"href\"))))\n (let ((href (dom-get-attr target \"href\")))\n (set! prefetched\n (merge prefetched {href true}))\n (prefetch-route-deps\n (url-pathname href)))))\n (set! last-x (event-x e))\n (set! last-y (event-y e))\n (set! last-t now))))))))" "lisp"))
(p "This is the most speculative strategy — best suited for dense navigation areas (section sidebars, nav bars) where the cursor trajectory is a strong predictor. The " (code "prefetched") " dict prevents duplicate fetches within the same container interaction."))
(~doc-subsection :title "Components + Data (Hybrid Prefetch)"
(p "The most interesting strategy. For pages with " (code ":data") " dependencies, current behavior is full server fallback. But the page's " (em "components") " are still pure and prefetchable. If we prefetch components ahead of time, the click only needs to fetch " (em "data") " — a much smaller, faster response.")
(p "This creates a new rendering path:")
(ol :class "list-decimal pl-5 text-stone-700 space-y-1"
(li "Prefetch: hover/idle/viewport triggers " (code "prefetch-components") " for the target page")
(li "Click: client has components, but page has " (code ":data") " — fetch data from server")
(li "Server returns " (em "only data") " (JSON or SX bindings), not the full rendered page")
(li "Client evaluates the content expression with prefetched components + fetched data")
(li "Result: faster than full server render, no redundant component transfer"))
(~doc-code :code (highlight ";; Declarative: prefetch components, fetch data on click\n(defpage reference-page\n :path \"/reference/<slug>\"\n :auth :public\n :prefetch :components ;; prefetch components, data stays server-fetched\n :data (reference-data slug)\n :content (~reference-attrs-content :attrs attrs))\n\n;; On click, client-side flow:\n;; 1. Components already prefetched (from hover/idle)\n;; 2. GET /reference/attributes → server returns data bindings\n;; 3. Client evals (reference-data slug) result + content expr\n;; 4. Renders locally with cached components" "lisp"))
(p "This is a stepping stone toward full Phase 4 (client IO bridge) of the isomorphic roadmap — it achieves partial client rendering for data pages without needing a general-purpose client async evaluator. The server is a data service, the client is the renderer."))
(~doc-subsection :title "Declarative Configuration"
(p "All strategies configured via " (code "defpage") " metadata and " (code "sx-prefetch") " attributes on links/containers:")
(~doc-code :code (highlight ";; Page-level: what to prefetch for routes linking TO this page\n(defpage docs-page\n :path \"/docs/<slug>\"\n :prefetch :eager) ;; bundle with linking page\n\n(defpage reference-page\n :path \"/reference/<slug>\"\n :prefetch :components) ;; prefetch components, data on click\n\n;; Link-level: override per-link\n(a :href \"/docs/components\"\n :sx-prefetch \"idle\") ;; prefetch after page idle\n\n;; Container-level: approach prediction for nav areas\n(nav :sx-prefetch \"approach\"\n (a :href \"/docs/\") (a :href \"/reference/\") ...)" "lisp"))
(p "Priority cascade: explicit " (code "sx-prefetch") " on link > " (code ":prefetch") " on target defpage > default (hover). The system never prefetches the same components twice — " (code "_prefetch-pending") " and " (code "loaded-component-names") " handle dedup.")))
;; -----------------------------------------------------------------------
;; Design
;; -----------------------------------------------------------------------
(~doc-section :title "Implementation Design" :id "design"
(p "Per the SX host architecture principle: all SX-specific logic goes in " (code ".sx") " spec files and gets bootstrapped. The prefetch logic — scanning links, computing missing deps, managing the component cache — must be specced in " (code ".sx") ", not written directly in JS or Python.")
(~doc-subsection :title "Phase 1: Component Fetch Endpoint (Python)"
(p "A new " (strong "public") " endpoint (not " (code "/internal/") " — the client's browser calls it) that returns component definitions by name.")
(~doc-code :code (highlight "GET /<service-prefix>/sx/components?names=~card,~essay-foo\n\nResponse (text/sx):\n(defcomp ~card (&key title &rest children)\n (div :class \"border rounded p-4\" (h2 title) children))\n(defcomp ~essay-foo (&key id)\n (div (~card :title id)))" "http"))
(p "The server resolves transitive deps via " (code "deps.py") ", subtracts anything listed in the " (code "SX-Components") " request header (already loaded), serializes and returns. This is essentially " (code "components_for_request()") " driven by an explicit " (code "?names=") " param.")
(p "Cache-friendly: the response is a pure function of component hash + requested names. " (code "Cache-Control: public, max-age=3600") " with the component hash as ETag."))
(~doc-subsection :title "Phase 2: Client Prefetch Logic (SX spec)"
(p "New functions in " (code "orchestration.sx") " (or a new " (code "prefetch.sx") " if scope warrants):")
(div :class "space-y-4"
(div
(h4 :class "font-semibold text-stone-700" "1. compute-missing-deps")
(p "Given a pathname, find the page, return dep names not in " (code "loaded-component-names") ". Returns nil if page not found or has data (can't client-route anyway).")
(~doc-code :code (highlight "(define compute-missing-deps\n (fn (pathname)\n (let ((match (find-matching-route pathname _page-routes)))\n (when (and match (not (get match \"has-data\")))\n (let ((deps (or (get match \"deps\") (list)))\n (loaded (loaded-component-names)))\n (filter (fn (d) (not (contains? loaded d))) deps))))))" "lisp")))
(div
(h4 :class "font-semibold text-stone-700" "2. prefetch-components")
(p "Fetch component definitions from the server for a list of names. Deduplicates in-flight requests. On success, parses and registers the returned definitions into the component env.")
(~doc-code :code (highlight "(define _prefetch-pending (dict))\n\n(define prefetch-components\n (fn (names)\n (let ((key (join \",\" (sort names))))\n (when (not (get _prefetch-pending key))\n (set! _prefetch-pending\n (merge _prefetch-pending {key true}))\n (fetch-components-from-server names\n (fn (sx-text)\n (sx-process-component-text sx-text)\n (dict-remove! _prefetch-pending key)))))))" "lisp")))
(div
(h4 :class "font-semibold text-stone-700" "3. prefetch-route-deps")
(p "High-level composition: compute missing deps for a route, fetch if any.")
(~doc-code :code (highlight "(define prefetch-route-deps\n (fn (pathname)\n (let ((missing (compute-missing-deps pathname)))\n (when (and missing (not (empty? missing)))\n (log-info (str \"sx:prefetch \"\n (len missing) \" components for \" pathname))\n (prefetch-components missing)))))" "lisp")))
(div
(h4 :class "font-semibold text-stone-700" "4. Trigger: link hover")
(p "On mouseover of a boosted link, prefetch its route's missing components. Debounced 150ms to avoid fetching on quick mouse-throughs.")
(~doc-code :code (highlight "(define bind-prefetch-on-hover\n (fn (link)\n (let ((timer nil))\n (dom-add-listener link \"mouseover\"\n (fn (e)\n (clear-timeout timer)\n (set! timer (set-timeout\n (fn () (prefetch-route-deps\n (url-pathname (dom-get-attr link \"href\"))))\n 150))))\n (dom-add-listener link \"mouseout\"\n (fn (e) (clear-timeout timer))))))" "lisp")))
(div
(h4 :class "font-semibold text-stone-700" "5. Trigger: viewport intersection (opt-in)")
(p "More aggressive strategy: when a link scrolls into view, prefetch its route's deps. Opt-in via " (code "sx-prefetch=\"visible\"") " attribute.")
(~doc-code :code (highlight "(define bind-prefetch-on-visible\n (fn (link)\n (observe-intersection link\n (fn () (prefetch-route-deps\n (url-pathname (dom-get-attr link \"href\"))))\n true 0)))" "lisp")))
(div
(h4 :class "font-semibold text-stone-700" "6. Integration into process-elements")
(p "During the existing hydration pass, for each boosted link:")
(~doc-code :code (highlight ";; In process-elements, after binding boost behavior:\n(when (and (should-boost-link? link)\n (dom-get-attr link \"href\"))\n (bind-prefetch-on-hover link))\n\n;; Explicit viewport prefetch:\n(when (dom-has-attr? link \"sx-prefetch\")\n (bind-prefetch-on-visible link))" "lisp")))))
(~doc-subsection :title "Phase 3: Boundary Declaration"
(p "Two new IO primitives in " (code "boundary.sx") " (browser-only):")
(~doc-code :code (highlight ";; IO primitives (browser-only)\n(io fetch-components-from-server (names callback) -> void)\n(io sx-process-component-text (sx-text) -> void)" "lisp"))
(p "These are thin wrappers around " (code "fetch()") " + the existing component script processing logic already in the boundary adapter."))
(~doc-subsection :title "Phase 4: Bootstrap"
(p (code "bootstrap_js.py") " picks up the new functions from the spec and emits them into " (code "sx-browser.js") ". The two new boundary IO functions get implemented in the JS boundary adapter — the hand-written glue code that the bootstrapper doesn't generate.")
(~doc-code :code (highlight "// fetch-components-from-server: calls the endpoint\nfunction fetchComponentsFromServer(names, callback) {\n const url = `${routePrefix}/sx/components?names=${names.join(\",\")}`;\n const headers = {\n \"SX-Components\": loadedComponentNames().join(\",\")\n };\n fetch(url, { headers })\n .then(r => r.ok ? r.text() : \"\")\n .then(text => callback(text))\n .catch(() => {}); // silent fail — prefetch is best-effort\n}\n\n// sx-process-component-text: parse defcomp/defmacro into env\nfunction sxProcessComponentText(sxText) {\n if (!sxText) return;\n const frag = document.createElement(\"div\");\n frag.innerHTML =\n `<script type=\"text/sx\" data-components>${sxText}<\\/script>`;\n Sx.processScripts(frag);\n}" "javascript"))))
;; -----------------------------------------------------------------------
;; Request flow
;; -----------------------------------------------------------------------
(~doc-section :title "Request Flow" :id "request-flow"
(p "End-to-end example: user hovers a link, components prefetch, click goes client-side.")
(~doc-code :code (highlight "User hovers link \"/docs/sx-manifesto\"\n |\n +-- bind-prefetch-on-hover fires (150ms debounce)\n |\n +-- compute-missing-deps(\"/docs/sx-manifesto\")\n | +-- find-matching-route -> page with deps:\n | | [\"~essay-sx-manifesto\", \"~doc-code\"]\n | +-- loaded-component-names -> [\"~nav\", \"~footer\", \"~doc-code\"]\n | +-- missing: [\"~essay-sx-manifesto\"]\n |\n +-- prefetch-components([\"~essay-sx-manifesto\"])\n | +-- GET /sx/components?names=~essay-sx-manifesto\n | | Headers: SX-Components: ~nav,~footer,~doc-code\n | +-- Server resolves transitive deps\n | | (also needs ~rich-text, subtracts already-loaded)\n | +-- Response:\n | (defcomp ~essay-sx-manifesto ...) \n | (defcomp ~rich-text ...)\n |\n +-- sx-process-component-text registers defcomps in env\n |\n +-- User clicks link\n +-- try-client-route(\"/docs/sx-manifesto\")\n +-- has-all-deps? -> true (prefetched!)\n +-- eval content -> DOM\n +-- Client-side render, no server roundtrip" "text")))
;; -----------------------------------------------------------------------
;; File changes
;; -----------------------------------------------------------------------
(~doc-section :title "File Changes" :id "file-changes"
(div :class "overflow-x-auto rounded border border-stone-200"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "File")
(th :class "px-3 py-2 font-medium text-stone-600" "Change")
(th :class "px-3 py-2 font-medium text-stone-600" "Phase")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/helpers.py")
(td :class "px-3 py-2 text-stone-700" "New " (code "sx_components_endpoint()") " route handler")
(td :class "px-3 py-2 text-stone-600" "1"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/infrastructure/factory.py")
(td :class "px-3 py-2 text-stone-700" "Register " (code "/sx/components") " route on all SX apps")
(td :class "px-3 py-2 text-stone-600" "1"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/orchestration.sx")
(td :class "px-3 py-2 text-stone-700" "Prefetch functions: compute-missing-deps, prefetch-components, prefetch-route-deps, bind-prefetch-on-hover, bind-prefetch-on-visible")
(td :class "px-3 py-2 text-stone-600" "2"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/boundary.sx")
(td :class "px-3 py-2 text-stone-700" "Declare " (code "fetch-components-from-server") ", " (code "sx-process-component-text"))
(td :class "px-3 py-2 text-stone-600" "3"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/bootstrap_js.py")
(td :class "px-3 py-2 text-stone-700" "Emit new spec functions, boundary adapter stubs")
(td :class "px-3 py-2 text-stone-600" "4"))))))
;; -----------------------------------------------------------------------
;; Non-goals & rollout
;; -----------------------------------------------------------------------
(~doc-section :title "Non-Goals (This Phase)" :id "non-goals"
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li (strong "Analytics-driven prediction") " — no ML models or click-frequency heuristics. Trajectory prediction uses geometry, not statistics.")
(li (strong "Cross-service prefetch") " — components are per-service. A link to a different service domain is always a server navigation.")
(li (strong "Service worker caching") " — could layer on later, but basic fetch + in-memory registration is sufficient.")
(li (strong "Full client-side data evaluation") " — the components+data strategy fetches data from the server, it doesn't replicate server IO on the client. That's Phase 4 of the isomorphic roadmap.")))
(~doc-section :title "Rollout" :id "rollout"
(p "Incremental, each step independently valuable:")
(ol :class "list-decimal pl-5 text-stone-700 space-y-2"
(li (strong "Component endpoint") " — purely additive. Refactor " (code "components_for_request()") " to accept explicit " (code "?names=") " param.")
(li (strong "Core spec functions") " — " (code "compute-missing-deps") ", " (code "prefetch-components") ", " (code "prefetch-route-deps") " in orchestration.sx. Testable in isolation.")
(li (strong "Hover prefetch") " — wire " (code "bind-prefetch-on-hover") " into " (code "process-elements") ". All boosted links get it automatically. Console logs show activity.")
(li (strong "Idle batch prefetch") " — call " (code "prefetch-visible-links-on-idle") " from " (code "boot-init") ". One request prefetches all visible nav deps after page settles.")
(li (strong "Viewport + approach") " — opt-in via " (code "sx-prefetch") " attributes. Trajectory prediction for dense nav areas.")
(li (strong "Eager bundles") " — extend " (code "components_for_page()") " to include linked routes' deps. Heavier initial payload, zero-latency nav.")
(li (strong "Components + data split") " — new server response mode returning data bindings only. Client renders with prefetched components. Bridges toward Phase 4.")))
(~doc-section :title "Relationship to Isomorphic Roadmap" :id "relationship"
(p "This plan sits between Phase 3 (client-side routing) and Phase 4 (client async & IO bridge) of the "
(a :href "/plans/isomorphic-architecture" :class "text-violet-700 underline" "isomorphic architecture roadmap")
". It extends Phase 3 by making more navigations go client-side without needing any IO bridge — purely by ensuring component definitions are available before they're needed.")
(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 3 (client-side routing with deps checking). No dependency on Phase 4.")))))
;; ---------------------------------------------------------------------------
;; Isomorphic Architecture Roadmap
;; ---------------------------------------------------------------------------
@@ -784,7 +1455,7 @@
(p (code "handle-popstate") " also tries client routing before server fetch on back/forward."))))
(~doc-subsection :title "What becomes client-routable"
(p "Pages WITHOUT " (code ":data") " that have pure content expressions — most of this docs app:")
(p "All pages with content expressions — most of this docs app. Pure pages render instantly; :data pages fetch data then render client-side (Phase 4):")
(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>"))
@@ -794,7 +1465,8 @@
(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)") ")")))
(li (code "/isomorphism/bundle-analyzer") " (has " (code ":data (bundle-analyzer-data)") ")")
(li (code "/isomorphism/data-test") " (has " (code ":data (data-test-data)") " — " (a :href "/isomorphism/data-test" :class "text-violet-700 underline" "Phase 4 demo") ")")))
(~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.")
@@ -823,19 +1495,71 @@
(~doc-section :title "Phase 4: Client Async & IO Bridge" :id "phase-4"
(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" "Client evaluates IO primitives by mapping them to server REST calls. Same SX code, different transport. (query \"market\" \"products\" :ids \"1,2,3\") on server → DB; on client → fetch(\"/internal/data/products?ids=1,2,3\")."))
(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 "/isomorphism/data-test" :class "text-green-700 underline text-sm font-medium" "Live data test page"))
(p :class "text-green-900 font-medium" "What it enables")
(p :class "text-green-800" "Client fetches server-evaluated data and renders :data pages locally. Data cached with TTL to avoid redundant fetches on back/forward navigation. All IO stays server-side — no continuations needed."))
(~doc-subsection :title "Approach"
(~doc-subsection :title "Architecture"
(p "Separates IO from rendering. Server evaluates :data expression (async, with DB/service access), serializes result as SX wire format. Client fetches pre-evaluated data, parses it, merges into env, renders pure :content client-side.")
(div :class "space-y-4"
(div
(h4 :class "font-semibold text-stone-700" "1. Async client evaluator")
(p "Two possible mechanisms:")
(h4 :class "font-semibold text-stone-700" "1. Abstract resolve-page-data")
(p "Spec-level primitive in orchestration.sx. The spec says \"I need data for this page\" — platform provides transport:")
(~doc-code :code (highlight "(resolve-page-data page-name params\n (fn (data)\n ;; data is a dict — merge into env and render\n (let ((env (merge closure params data))\n (rendered (try-eval-content content-src env)))\n (swap-rendered-content target rendered pathname))))" "lisp"))
(p "Browser platform: HTTP fetch to " (code "/sx/data/<page-name>") ". Future platforms could use IPC, cache, WebSocket, etc."))
(div
(h4 :class "font-semibold text-stone-700" "2. Server data endpoint")
(p (code "evaluate_page_data()") " evaluates the :data expression, kebab-cases dict keys (Python " (code "total_count") " → SX " (code "total-count") "), serializes as SX wire format.")
(p "Response content type: " (code "text/sx; charset=utf-8") ". Per-page auth enforcement via " (code "_check_page_auth()") "."))
(div
(h4 :class "font-semibold text-stone-700" "3. Client data cache")
(p "In-memory cache in orchestration.sx, keyed by " (code "page-name:param=value") ". 30-second TTL prevents redundant fetches on back/forward navigation:")
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li (strong "Promise-based: ") "evalExpr returns value or Promise; rendering awaits")
(li (strong "Continuation-based: ") "use existing shift/reset to suspend on IO, resume when data arrives (architecturally cleaner, leverages existing spec)")))
(li "Cache miss: " (code "sx:route client+data /path") " — fetches from server, caches, renders")
(li "Cache hit: " (code "sx:route client+cache /path") " — instant render from cached data")
(li "After TTL: stale entry evicted, fresh fetch on next visit"))
(p "Try it: navigate to the " (a :href "/isomorphism/data-test" :class "text-violet-700 underline" "data test page") ", go back, return within 30s — the server-time stays the same (cached). Wait 30s+ and return — new time (fresh fetch)."))))
(~doc-subsection :title "Files"
(ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm"
(li "shared/sx/ref/orchestration.sx — resolve-page-data spec, data cache")
(li "shared/sx/ref/bootstrap_js.py — platform resolvePageData (HTTP fetch)")
(li "shared/sx/pages.py — evaluate_page_data(), auto_mount_page_data()")
(li "shared/sx/helpers.py — deps for :data pages in page registry")
(li "sx/sx/data-test.sx — test component")
(li "shared/sx/tests/test_page_data.py — 30 unit tests")))
(~doc-subsection :title "Verification"
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li "30 unit tests: serialize roundtrip, kebab-case, deps, full pipeline simulation, cache TTL")
(li "Console: " (code "sx:route client+data") " on first visit, " (code "sx:route client+cache") " on return within 30s")
(li (a :href "/isomorphism/data-test" :class "text-violet-700 underline" "Live data test page") " exercises the full pipeline with server time + pipeline steps")
(li "append! and dict-set! registered as proper primitives in spec + both hosts"))))
;; -----------------------------------------------------------------------
;; Phase 5
;; -----------------------------------------------------------------------
(~doc-section :title "Phase 5: Async Continuations & Inline IO" :id "phase-5"
(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" "Components call IO primitives directly in their body. The evaluator suspends mid-evaluation via async-aware continuations, fetches data, resumes. Same component source works on both server (Python async/await) and client (continuation-based suspension)."))
(~doc-subsection :title "The Problem"
(p "The existing shift/reset continuations extension is synchronous (throw/catch). Client-side IO via fetch() returns a Promise — you can't throw-catch across an async boundary. The evaluator needs Promise-aware continuations or a CPS transform."))
(~doc-subsection :title "Approach"
(div :class "space-y-4"
(div
(h4 :class "font-semibold text-stone-700" "1. Async-aware shift/reset")
(p "Extend the continuations extension: sfShift captures the continuation and returns a Promise, sfReset awaits Promise results in the trampoline. Continuation resume feeds the fetched value back into evaluation."))
(div
(h4 :class "font-semibold text-stone-700" "2. IO primitive bridge")
@@ -847,27 +1571,17 @@
(li "current-user → cached from initial page load")))
(div
(h4 :class "font-semibold text-stone-700" "3. Client data cache")
(p "Keyed by (service, query, params-hash), configurable TTL, server can invalidate via SX-Invalidate header."))
(div
(h4 :class "font-semibold text-stone-700" "4. Optimistic updates")
(p "Extend existing apply-optimistic/revert-optimistic in engine.sx from DOM-level to data-level."))))
(h4 :class "font-semibold text-stone-700" "3. CPS transform option")
(p "Alternative: transform the evaluator to continuation-passing style. Every eval step takes a continuation argument. IO primitives call the continuation after fetch resolves. Architecturally cleaner but requires deeper changes."))))
(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 2 (IO affinity), Phase 3 (routing for when to trigger IO)."))
(~doc-subsection :title "Verification"
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li "Client (query ...) returns identical data to server-side")
(li "Data cache prevents redundant fetches")
(li "Same component source → identical output on either side"))))
(p :class "text-amber-800 text-sm" (strong "Depends on: ") "Phase 4 (data endpoint infrastructure).")))
;; -----------------------------------------------------------------------
;; Phase 5
;; Phase 6
;; -----------------------------------------------------------------------
(~doc-section :title "Phase 5: Streaming & Suspense" :id "phase-5"
(~doc-section :title "Phase 6: Streaming & Suspense" :id "phase-6"
(div :class "rounded border border-violet-200 bg-violet-50 p-4 mb-4"
(p :class "text-violet-900 font-medium" "What it enables")
@@ -897,13 +1611,13 @@
(p "Above-fold content resolves first. All IO starts concurrently (asyncio.create_task), results flushed in priority order."))))
(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 4 (client async for filling suspended subtrees), Phase 2 (IO analysis for priority).")))
(p :class "text-amber-800 text-sm" (strong "Depends on: ") "Phase 5 (async continuations for filling suspended subtrees), Phase 2 (IO analysis for priority).")))
;; -----------------------------------------------------------------------
;; Phase 6
;; Phase 7
;; -----------------------------------------------------------------------
(~doc-section :title "Phase 6: Full Isomorphism" :id "phase-6"
(~doc-section :title "Phase 7: Full Isomorphism" :id "phase-7"
(div :class "rounded border border-violet-200 bg-violet-50 p-4 mb-4"
(p :class "text-violet-900 font-medium" "What it enables")
@@ -922,15 +1636,19 @@
(p "Default: auto (runtime decides from IO analysis)."))
(div
(h4 :class "font-semibold text-stone-700" "3. Offline data layer")
(h4 :class "font-semibold text-stone-700" "3. Optimistic data updates")
(p "Extend existing apply-optimistic/revert-optimistic in engine.sx from DOM-level to data-level. Client updates cached data optimistically, sends mutation to server, reverts on rejection."))
(div
(h4 :class "font-semibold text-stone-700" "4. Offline data layer")
(p "Service Worker intercepts /internal/data/ requests, serves from IndexedDB when offline, syncs when back online."))
(div
(h4 :class "font-semibold text-stone-700" "4. Isomorphic testing")
(h4 :class "font-semibold text-stone-700" "5. Isomorphic testing")
(p "Evaluate same component on Python and JS, compare output. Extends existing test_sx_ref.py cross-evaluator comparison."))
(div
(h4 :class "font-semibold text-stone-700" "5. Universal page descriptor")
(h4 :class "font-semibold text-stone-700" "6. Universal page descriptor")
(p "defpage is portable: server executes via execute_page(), client executes via route match → fetch data → eval content → render DOM. Same descriptor, different execution environment."))))
(div :class "rounded border border-amber-200 bg-amber-50 p-3 mt-2"
@@ -947,7 +1665,7 @@
(li "Phase 1: \"Unknown component\" includes which page expected it and what bundle was sent")
(li "Phase 2: Server logs which components expanded server-side vs sent to client")
(li "Phase 3: Client route failures include unmatched path and available routes")
(li "Phase 4: Client IO errors include query name, params, server response")
(li "Phase 4: Client data errors include page name, params, server response status")
(li "Source location tracking in parser → propagate through eval → include in error messages")))
(~doc-subsection :title "Backward Compatibility"

View File

@@ -20,6 +20,10 @@
(defcomp ~reference-events-content (&key table)
(~doc-page :title "Events"
(p :class "text-stone-600 mb-6"
"sx fires custom DOM events at various points in the request lifecycle. "
"Listen for them with sx-on:* attributes or addEventListener. "
"Client-side routing fires sx:clientRoute instead of request lifecycle events.")
table))
(defcomp ~reference-js-api-content (&key table)

View File

@@ -402,25 +402,6 @@
:selected "Roadmap")
:content (~plan-isomorphic-content))
(defpage isomorphism-page
:path "/isomorphism/<slug>"
:auth :public
:layout (:sx-section
:section "Isomorphism"
:sub-label "Isomorphism"
:sub-href "/isomorphism/"
:sub-nav (~section-nav :items isomorphism-nav-items
:current (find-current isomorphism-nav-items slug))
:selected (or (find-current isomorphism-nav-items slug) ""))
:content (case slug
"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
:path "/isomorphism/bundle-analyzer"
:auth :public
@@ -449,6 +430,40 @@
: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))
;; Wildcard must come AFTER specific routes (first-match routing)
(defpage isomorphism-page
:path "/isomorphism/<slug>"
:auth :public
:layout (:sx-section
:section "Isomorphism"
:sub-label "Isomorphism"
:sub-href "/isomorphism/"
:sub-nav (~section-nav :items isomorphism-nav-items
:current (find-current isomorphism-nav-items slug))
:selected (or (find-current isomorphism-nav-items slug) ""))
:content (case slug
"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)))
;; ---------------------------------------------------------------------------
;; Plans section
;; ---------------------------------------------------------------------------
@@ -477,4 +492,6 @@
:content (case slug
"reader-macros" (~plan-reader-macros-content)
"sx-activity" (~plan-sx-activity-content)
"predictive-prefetch" (~plan-predictive-prefetch-content)
"content-addressed-components" (~plan-content-addressed-components-content)
:else (~plans-index-content)))

View File

@@ -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,
})
@@ -42,10 +43,10 @@ def _special_forms_data() -> dict:
from shared.sx.parser import parse_all, serialize
from shared.sx.types import Symbol, Keyword
spec_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..", "..", "..", "shared", "sx", "ref", "special-forms.sx",
)
ref_dir = os.path.join(os.path.dirname(__file__), "..", "..", "shared", "sx", "ref")
if not os.path.isdir(ref_dir):
ref_dir = "/app/shared/sx/ref"
spec_path = os.path.join(ref_dir, "special-forms.sx")
with open(spec_path) as f:
exprs = parse_all(f.read())
@@ -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)",
}

View File

@@ -424,7 +424,8 @@
:class "text-violet-600 hover:text-violet-800 underline text-sm"
"sx-target"))
(p :class "text-xs text-stone-400"
"These links use AJAX navigation via sx-boost — no sx-get needed on each link.")))
"These links use AJAX navigation via sx-boost — no sx-get needed on each link. "
"Set the value to a CSS selector (e.g. sx-boost=\"#main-panel\") to configure the default swap target for all descendants.")))
;; ---------------------------------------------------------------------------
;; sx-preload
@@ -727,19 +728,42 @@
"Request is cancelled via preventDefault() if the input is empty.")))
;; ---------------------------------------------------------------------------
;; sx:afterSettle event demo
;; sx:afterRequest event demo
;; ---------------------------------------------------------------------------
(defcomp ~ref-event-after-settle-demo ()
(defcomp ~ref-event-after-request-demo ()
(div :class "space-y-3"
(button
:sx-get "/reference/api/time"
:sx-target "#ref-evt-ar-result"
:sx-swap "innerHTML"
:sx-on:sx:afterRequest "document.getElementById('ref-evt-ar-log').textContent = 'Response status: ' + (event.detail ? event.detail.status : '?')"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Load (logs after response)")
(div :id "ref-evt-ar-log"
:class "p-2 rounded bg-emerald-50 text-emerald-700 text-sm"
"Event log will appear here.")
(div :id "ref-evt-ar-result"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Click to load — afterRequest fires before the swap.")))
;; ---------------------------------------------------------------------------
;; sx:afterSwap event demo
;; ---------------------------------------------------------------------------
(defcomp ~ref-event-after-swap-demo ()
(div :class "space-y-3"
(button
:sx-get "/reference/api/swap-item"
:sx-target "#ref-evt-settle-list"
:sx-target "#ref-evt-as-list"
:sx-swap "beforeend"
:sx-on:sx:afterSettle "var items = document.querySelectorAll('#ref-evt-settle-list > div'); if (items.length) items[items.length-1].scrollIntoView({behavior:'smooth'})"
:sx-on:sx:afterSwap "var items = document.querySelectorAll('#ref-evt-as-list > div'); if (items.length) items[items.length-1].scrollIntoView({behavior:'smooth'}); document.getElementById('ref-evt-as-count').textContent = items.length + ' items'"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Add item (scrolls after settle)")
(div :id "ref-evt-settle-list"
"Add item (scrolls after swap)")
(div :id "ref-evt-as-count"
:class "text-sm text-emerald-700"
"1 items")
(div :id "ref-evt-as-list"
:class "p-3 rounded border border-stone-200 space-y-1 max-h-32 overflow-y-auto"
(div :class "text-sm text-stone-500" "Items will be appended and scrolled into view."))))
@@ -791,3 +815,102 @@
(div :id "ref-evt-vf-result"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Submit with empty/invalid email to trigger the event.")))
;; ---------------------------------------------------------------------------
;; sx:requestError event demo
;; ---------------------------------------------------------------------------
(defcomp ~ref-event-request-error-demo ()
(div :class "space-y-3"
(button
:sx-get "https://this-domain-does-not-exist.invalid/api"
:sx-target "#ref-evt-re-result"
:sx-swap "innerHTML"
:sx-on:sx:requestError "document.getElementById('ref-evt-re-status').style.display = 'block'; document.getElementById('ref-evt-re-status').textContent = 'Network error — request never reached a server'"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Request invalid domain")
(div :id "ref-evt-re-status"
:class "p-2 rounded bg-red-50 text-red-600 text-sm"
:style "display: none"
"")
(div :id "ref-evt-re-result"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Click to trigger a network error — sx:requestError fires.")))
;; ---------------------------------------------------------------------------
;; sx:clientRoute event demo
;; ---------------------------------------------------------------------------
(defcomp ~ref-event-client-route-demo ()
(div :class "space-y-3"
(p :class "text-sm text-stone-600"
"Open DevTools console, then navigate to a pure page (no :data expression). "
"You'll see \"sx:route client /path\" in the console — no network request is made.")
(div :class "flex gap-2 flex-wrap"
(a :href "/essays/"
:class "px-3 py-1 bg-violet-100 text-violet-700 rounded text-sm no-underline hover:bg-violet-200"
"Essays")
(a :href "/plans/"
:class "px-3 py-1 bg-violet-100 text-violet-700 rounded text-sm no-underline hover:bg-violet-200"
"Plans")
(a :href "/protocols/"
:class "px-3 py-1 bg-violet-100 text-violet-700 rounded text-sm no-underline hover:bg-violet-200"
"Protocols"))
(p :class "text-xs text-stone-400"
"The sx:clientRoute event fires on the swap target and bubbles to document.body. "
"Apps use it to update nav selection, analytics, or other post-navigation state.")))
;; ---------------------------------------------------------------------------
;; sx:sseOpen event demo
;; ---------------------------------------------------------------------------
(defcomp ~ref-event-sse-open-demo ()
(div :class "space-y-3"
(div :sx-sse "/reference/api/sse-time"
:sx-sse-swap "time"
:sx-swap "innerHTML"
:sx-on:sx:sseOpen "document.getElementById('ref-evt-sseopen-status').textContent = 'Connected'; document.getElementById('ref-evt-sseopen-status').className = 'inline-block px-2 py-0.5 rounded text-xs bg-emerald-100 text-emerald-700'"
(div :class "flex items-center gap-3"
(span :id "ref-evt-sseopen-status"
:class "inline-block px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-700"
"Connecting...")
(span :class "text-sm text-stone-500" "SSE stream")))
(p :class "text-xs text-stone-400"
"The status badge turns green when the SSE connection opens.")))
;; ---------------------------------------------------------------------------
;; sx:sseMessage event demo
;; ---------------------------------------------------------------------------
(defcomp ~ref-event-sse-message-demo ()
(div :class "space-y-3"
(div :sx-sse "/reference/api/sse-time"
:sx-sse-swap "time"
:sx-swap "innerHTML"
:sx-on:sx:sseMessage "var c = parseInt(document.getElementById('ref-evt-ssemsg-count').dataset.count || '0') + 1; document.getElementById('ref-evt-ssemsg-count').dataset.count = c; document.getElementById('ref-evt-ssemsg-count').textContent = c + ' messages received'"
(div :id "ref-evt-ssemsg-output"
:class "p-3 rounded bg-stone-100 text-stone-600 text-sm font-mono"
"Waiting for SSE messages..."))
(div :id "ref-evt-ssemsg-count"
:class "text-sm text-emerald-700"
:data-count "0"
"0 messages received")))
;; ---------------------------------------------------------------------------
;; sx:sseError event demo
;; ---------------------------------------------------------------------------
(defcomp ~ref-event-sse-error-demo ()
(div :class "space-y-3"
(div :sx-sse "/reference/api/sse-time"
:sx-sse-swap "time"
:sx-swap "innerHTML"
:sx-on:sx:sseError "document.getElementById('ref-evt-sseerr-status').textContent = 'Disconnected'; document.getElementById('ref-evt-sseerr-status').className = 'inline-block px-2 py-0.5 rounded text-xs bg-red-100 text-red-700'"
:sx-on:sx:sseOpen "document.getElementById('ref-evt-sseerr-status').textContent = 'Connected'; document.getElementById('ref-evt-sseerr-status').className = 'inline-block px-2 py-0.5 rounded text-xs bg-emerald-100 text-emerald-700'"
(div :class "flex items-center gap-3"
(span :id "ref-evt-sseerr-status"
:class "inline-block px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-700"
"Connecting...")
(span :class "text-sm text-stone-500" "SSE stream")))
(p :class "text-xs text-stone-400"
"If the SSE connection drops, the badge turns red via sx:sseError.")))

View File

@@ -26,12 +26,15 @@ def register(url_prefix: str = "/") -> Blueprint:
if is_sx:
from shared.sx.helpers import sx_response
html = await render_results_partial_sx(
inner = await render_results_partial_sx(
result, running, csrf,
active_filter=active_filter,
active_service=active_service,
)
return sx_response(html)
# Wrap in #main-panel so sx-select="#main-panel" works
sx = (f'(section :id "main-panel" :class "flex-1 md:h-full md:min-h-0'
f' overflow-y-auto overscroll-contain js-grid-viewport" {inner})')
return sx_response(sx)
from shared.sx.page import get_template_context
ctx = await get_template_context()