Compare commits
53 Commits
0385be0a0d
...
09d06a4c87
| Author | SHA1 | Date | |
|---|---|---|---|
| 09d06a4c87 | |||
| 6655f638b9 | |||
| 2c56d3e14b | |||
| fa295acfe3 | |||
| 28ee441d9a | |||
| 1387d97c82 | |||
| b90cc59029 | |||
| 59c935e394 | |||
| c15dbc3242 | |||
| ece2aa225d | |||
| ac1dc34dad | |||
| 9278be9fe2 | |||
| f36583b620 | |||
| 6772f1141f | |||
| 60b58fdff7 | |||
| d3617ab7f3 | |||
| 732923a7ef | |||
| b1f9e41027 | |||
| a657d0831c | |||
| 9d0cffb84d | |||
| eee2954559 | |||
| b9003eacb2 | |||
| 7229335d22 | |||
| e38534a898 | |||
| daf76c3e5b | |||
| 093050059d | |||
| 6a5cb31123 | |||
| bcb58d340f | |||
| b98a8f8c41 | |||
| 14c5316d17 | |||
| 3b00a7095a | |||
| 719dfbf732 | |||
| 5ea0f5c546 | |||
| 74428cc433 | |||
| d1a47e1e52 | |||
| 3d191099e0 | |||
| 70cf501c49 | |||
| 2a978e6e9f | |||
| 3a8ee0dbd6 | |||
| c346f525d2 | |||
| 79ee3bc46e | |||
| c80b5d674f | |||
| f08bd403de | |||
| 227444a026 | |||
| 2660d37f9e | |||
| d850f7c9c1 | |||
| bc9d9e51c9 | |||
| eb70e7237e | |||
| a7d09291b8 | |||
| 2d5096be6c | |||
| f70861c175 | |||
| 78c3ff30dd | |||
| 756162b63f |
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -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))))
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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¶m2=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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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, =
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
423
shared/sx/tests/test_page_data.py
Normal file
423
shared/sx/tests/test_page_data.py
Normal 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"
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
56
sx/sx/data-test.sx
Normal 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")))))
|
||||
@@ -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/")
|
||||
|
||||
782
sx/sx/plans.sx
782
sx/sx/plans.sx
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)",
|
||||
}
|
||||
|
||||
@@ -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.")))
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user