Compare commits
96 Commits
631394989c
...
3ab26635ce
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ab26635ce | |||
| 9b3b2ea224 | |||
| 3a12368c9d | |||
| bec881acb3 | |||
| e89c496dc8 | |||
| 7eb158c79f | |||
| e9d86d628b | |||
| 754e7557f5 | |||
| f674a5edcc | |||
| e09bc3b601 | |||
| 43f2547de8 | |||
| 8366088ee1 | |||
| fd20811afa | |||
| 84ea5d4c16 | |||
| 51990d9445 | |||
| 0d6b959045 | |||
| 847d5d1f31 | |||
| ff2ef29d8a | |||
| ab27491157 | |||
| aa67b036c7 | |||
| 9ac90a787d | |||
| cb0990feb3 | |||
| 8c89311182 | |||
| a745de7e35 | |||
| a5f5373a63 | |||
| c2a85ed026 | |||
| 69ced865db | |||
| 2b0a45b337 | |||
| feb368f7fb | |||
| 6215d3573b | |||
| 79fa1411dc | |||
| 04ff03f5d4 | |||
| b85a46bb62 | |||
| 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 | |||
| 0385be0a0d | |||
| 1e52bb33a6 | |||
| a8e61dd0ea | |||
| 20ac0fe948 | |||
| 2aa0f1d010 | |||
| a2d0a8a0fa | |||
| b8d3e46a9b | |||
| 3749fe9625 | |||
| dd1c1c9a3c | |||
| cf5e767510 |
@@ -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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
96
shared/static/scripts/sx-test-runner.js
Normal file
96
shared/static/scripts/sx-test-runner.js
Normal file
@@ -0,0 +1,96 @@
|
||||
// sx-test-runner.js — Run test.sx in the browser using sx-browser.js.
|
||||
// Loaded on the /specs/testing page. Uses the Sx global.
|
||||
(function() {
|
||||
var NIL = Sx.NIL;
|
||||
function isNil(x) { return x === NIL || x === null || x === undefined; }
|
||||
function deepEqual(a, b) {
|
||||
if (a === b) return true;
|
||||
if (isNil(a) && isNil(b)) return true;
|
||||
if (typeof a !== typeof b) return false;
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false;
|
||||
return true;
|
||||
}
|
||||
if (a && typeof a === "object" && b && typeof b === "object") {
|
||||
var ka = Object.keys(a), kb = Object.keys(b);
|
||||
if (ka.length !== kb.length) return false;
|
||||
for (var j = 0; j < ka.length; j++) if (!deepEqual(a[ka[j]], b[ka[j]])) return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
window.sxRunTests = function(srcId, outId, btnId) {
|
||||
var src = document.getElementById(srcId).textContent;
|
||||
var out = document.getElementById(outId);
|
||||
var btn = document.getElementById(btnId);
|
||||
|
||||
var stack = [], passed = 0, failed = 0, num = 0, lines = [];
|
||||
|
||||
var env = {
|
||||
"try-call": function(thunk) {
|
||||
try {
|
||||
Sx.eval([thunk], env);
|
||||
return { ok: true };
|
||||
} catch(e) {
|
||||
return { ok: false, error: e.message || String(e) };
|
||||
}
|
||||
},
|
||||
"report-pass": function(name) {
|
||||
num++; passed++;
|
||||
lines.push("ok " + num + " - " + stack.concat([name]).join(" > "));
|
||||
},
|
||||
"report-fail": function(name, error) {
|
||||
num++; failed++;
|
||||
lines.push("not ok " + num + " - " + stack.concat([name]).join(" > "));
|
||||
lines.push(" # " + error);
|
||||
},
|
||||
"push-suite": function(name) { stack.push(name); },
|
||||
"pop-suite": function() { stack.pop(); },
|
||||
|
||||
"equal?": function(a, b) { return deepEqual(a, b); },
|
||||
"eq?": function(a, b) { return a === b; },
|
||||
"boolean?": function(x) { return typeof x === "boolean"; },
|
||||
"string-length": function(s) { return String(s).length; },
|
||||
"substring": function(s, start, end) { return String(s).slice(start, end); },
|
||||
"string-contains?": function(s, n) { return String(s).indexOf(n) !== -1; },
|
||||
"upcase": function(s) { return String(s).toUpperCase(); },
|
||||
"downcase": function(s) { return String(s).toLowerCase(); },
|
||||
"reverse": function(c) { return c ? c.slice().reverse() : []; },
|
||||
"flatten": function(c) {
|
||||
var r = [];
|
||||
for (var i = 0; i < (c||[]).length; i++) {
|
||||
if (Array.isArray(c[i])) for (var j = 0; j < c[i].length; j++) r.push(c[i][j]);
|
||||
else r.push(c[i]);
|
||||
}
|
||||
return r;
|
||||
},
|
||||
"has-key?": function(d, k) { return d && typeof d === "object" && k in d; },
|
||||
"append": function(c, x) { return Array.isArray(x) ? (c||[]).concat(x) : (c||[]).concat([x]); },
|
||||
};
|
||||
|
||||
try {
|
||||
var t0 = performance.now();
|
||||
var exprs = Sx.parseAll(src);
|
||||
for (var i = 0; i < exprs.length; i++) Sx.eval(exprs[i], env);
|
||||
var elapsed = Math.round(performance.now() - t0);
|
||||
lines.push("");
|
||||
lines.push("1.." + num);
|
||||
lines.push("# tests " + (passed + failed));
|
||||
lines.push("# pass " + passed);
|
||||
if (failed > 0) lines.push("# fail " + failed);
|
||||
lines.push("# time " + elapsed + "ms");
|
||||
} catch(e) {
|
||||
lines.push("");
|
||||
lines.push("FATAL: " + (e.message || String(e)));
|
||||
}
|
||||
|
||||
out.textContent = lines.join("\n");
|
||||
out.style.display = "block";
|
||||
btn.textContent = passed + "/" + (passed + failed) + " passed" + (failed === 0 ? "" : " (" + failed + " failed)");
|
||||
btn.className = failed > 0
|
||||
? "px-4 py-2 rounded-md bg-red-600 text-white font-medium text-sm cursor-default"
|
||||
: "px-4 py-2 rounded-md bg-green-600 text-white font-medium text-sm cursor-default";
|
||||
};
|
||||
})();
|
||||
@@ -456,7 +456,8 @@ 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
|
||||
@@ -464,6 +465,10 @@ def components_for_request(source: str = "") -> str:
|
||||
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.
|
||||
|
||||
*extra_names* — additional component names (``~foo``) to include
|
||||
beyond what *source* references. Used by ``execute_page`` 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 +482,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 +521,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 +532,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 +551,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}')
|
||||
@@ -631,6 +647,7 @@ details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.
|
||||
<body class="bg-stone-50 text-stone-900">
|
||||
<script type="text/sx-styles" data-hash="{styles_hash}">{styles_json}</script>
|
||||
<script type="text/sx" data-components data-hash="{component_hash}">{component_defs}</script>
|
||||
<script type="text/sx-pages">{pages_sx}</script>
|
||||
<script type="text/sx" data-mount="body">{page_sx}</script>
|
||||
<script src="{asset_url}/scripts/sx-browser.js?v={sx_js_hash}"></script>
|
||||
<script src="{asset_url}/scripts/body.js?v={body_js_hash}"></script>
|
||||
@@ -638,6 +655,94 @@ details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.
|
||||
</html>"""
|
||||
|
||||
|
||||
def _build_pages_sx(service: str) -> str:
|
||||
"""Build SX page registry for client-side routing.
|
||||
|
||||
Returns SX dict literals (one per page) parseable by the client's
|
||||
``parse`` function. Each dict has keys: name, path, auth, has-data,
|
||||
content, closure, 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:
|
||||
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)) + ")"
|
||||
|
||||
# Collect IO primitive names referenced by dep components
|
||||
from .types import Component as _Comp
|
||||
io_deps: set[str] = set()
|
||||
for dep_name in deps:
|
||||
comp = _COMPONENT_ENV.get(dep_name)
|
||||
if isinstance(comp, _Comp) and comp.io_refs:
|
||||
io_deps.update(comp.io_refs)
|
||||
io_deps_sx = (
|
||||
"(" + " ".join(_sx_literal(n) for n in sorted(io_deps)) + ")"
|
||||
if io_deps else "()"
|
||||
)
|
||||
|
||||
# Build closure as SX dict
|
||||
closure_parts: list[str] = []
|
||||
for k, v in page_def.closure.items():
|
||||
if isinstance(v, (str, int, float, bool)):
|
||||
closure_parts.append(f":{k} {_sx_literal(v)}")
|
||||
closure_sx = "{" + " ".join(closure_parts) + "}"
|
||||
|
||||
entry = (
|
||||
"{:name " + _sx_literal(page_def.name)
|
||||
+ " :path " + _sx_literal(page_def.path)
|
||||
+ " :auth " + _sx_literal(auth)
|
||||
+ " :has-data " + has_data
|
||||
+ " :io-deps " + io_deps_sx
|
||||
+ " :content " + _sx_literal(content_src)
|
||||
+ " :deps " + deps_sx
|
||||
+ " :closure " + closure_sx + "}"
|
||||
)
|
||||
entries.append(entry)
|
||||
|
||||
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:
|
||||
"""Serialize a Python value as an SX literal."""
|
||||
if v is None:
|
||||
return "nil"
|
||||
if isinstance(v, bool):
|
||||
return "true" if v else "false"
|
||||
if isinstance(v, (int, float)):
|
||||
return str(v)
|
||||
if isinstance(v, str):
|
||||
escaped = v.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
||||
return f'"{escaped}"'
|
||||
return "nil"
|
||||
|
||||
|
||||
|
||||
def sx_page(ctx: dict, page_sx: str, *,
|
||||
meta_html: str = "") -> str:
|
||||
"""Return a minimal HTML shell that boots the page from sx source.
|
||||
@@ -649,8 +754,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
|
||||
@@ -664,7 +770,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)
|
||||
@@ -681,8 +787,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()
|
||||
@@ -692,6 +799,15 @@ def sx_page(ctx: dict, page_sx: str, *,
|
||||
else:
|
||||
styles_json = _build_style_dict_json()
|
||||
|
||||
# Page registry for client-side routing
|
||||
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),
|
||||
asset_url=asset_url,
|
||||
@@ -701,6 +817,7 @@ def sx_page(ctx: dict, page_sx: str, *,
|
||||
component_defs=component_defs,
|
||||
styles_hash=styles_hash,
|
||||
styles_json=styles_json,
|
||||
pages_sx=pages_sx,
|
||||
page_sx=page_sx,
|
||||
sx_css=sx_css,
|
||||
sx_css_classes=sx_css_classes,
|
||||
@@ -760,7 +877,7 @@ def _get_sx_styles_cookie() -> str:
|
||||
try:
|
||||
from quart import request
|
||||
return request.cookies.get("sx-styles-hash", "")
|
||||
except Exception:
|
||||
except RuntimeError:
|
||||
return ""
|
||||
|
||||
|
||||
@@ -770,7 +887,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]
|
||||
|
||||
@@ -780,7 +897,7 @@ def _get_csrf_token() -> str:
|
||||
try:
|
||||
from quart import g
|
||||
return getattr(g, "csrf_token", "")
|
||||
except Exception:
|
||||
except RuntimeError:
|
||||
return ""
|
||||
|
||||
|
||||
@@ -789,7 +906,7 @@ def _get_sx_comp_cookie() -> str:
|
||||
try:
|
||||
from quart import request
|
||||
return request.cookies.get("sx-comp-hash", "")
|
||||
except Exception:
|
||||
except RuntimeError:
|
||||
return ""
|
||||
|
||||
|
||||
@@ -833,7 +950,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,33 @@ 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 all :data pages so the client can render them.
|
||||
# Pages with IO deps use the async render path (Phase 5) — the IO
|
||||
# primitives are proxied via /sx/io/<name>.
|
||||
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)
|
||||
needed |= components_needed(content_src, _COMPONENT_ENV)
|
||||
|
||||
if not needed:
|
||||
return "", ""
|
||||
|
||||
@@ -375,16 +391,24 @@ 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)
|
||||
needed |= components_needed(content_src, _COMPONENT_ENV)
|
||||
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 and page_def.data_expr is 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,22 @@ 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)
|
||||
|
||||
# Mount IO proxy endpoint for Phase 5: client-side IO primitives
|
||||
mount_io_endpoint(app, service_name)
|
||||
|
||||
|
||||
def mount_pages(bp: Any, service_name: str,
|
||||
names: set[str] | list[str] | None = None) -> None:
|
||||
@@ -393,3 +415,202 @@ 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)
|
||||
|
||||
|
||||
def mount_io_endpoint(app: Any, service_name: str) -> None:
|
||||
"""Mount /sx/io/<name> endpoint for client-side IO primitive calls.
|
||||
|
||||
The client can call any allowed IO primitive or page helper via GET/POST.
|
||||
Result is returned as SX wire format (text/sx).
|
||||
|
||||
Falls back to page helpers when the name isn't a global IO primitive,
|
||||
so service-specific functions like ``highlight`` work via the proxy.
|
||||
"""
|
||||
import asyncio as _asyncio
|
||||
from quart import make_response, request, abort as quart_abort
|
||||
from .primitives_io import IO_PRIMITIVES, execute_io
|
||||
from .jinja_bridge import _get_request_context
|
||||
from .parser import serialize
|
||||
|
||||
# Build allowlist from all component IO refs across this service
|
||||
from .jinja_bridge import _COMPONENT_ENV
|
||||
from .types import Component as _Comp
|
||||
_ALLOWED_IO: set[str] = set()
|
||||
for _val in _COMPONENT_ENV.values():
|
||||
if isinstance(_val, _Comp) and _val.io_refs:
|
||||
_ALLOWED_IO.update(_val.io_refs)
|
||||
|
||||
from shared.browser.app.csrf import csrf_exempt
|
||||
|
||||
@csrf_exempt
|
||||
async def io_proxy(name: str) -> Any:
|
||||
if name not in _ALLOWED_IO:
|
||||
quart_abort(403)
|
||||
|
||||
# Parse args from query string or JSON body
|
||||
args: list = []
|
||||
kwargs: dict = {}
|
||||
if request.method == "GET":
|
||||
for k, v in request.args.items():
|
||||
if k.startswith("_arg"):
|
||||
args.append(v)
|
||||
else:
|
||||
kwargs[k] = v
|
||||
else:
|
||||
data = await request.get_json(silent=True) or {}
|
||||
args = data.get("args", [])
|
||||
kwargs = data.get("kwargs", {})
|
||||
|
||||
# Try global IO primitives first
|
||||
if name in IO_PRIMITIVES:
|
||||
ctx = _get_request_context()
|
||||
result = await execute_io(name, args, kwargs, ctx)
|
||||
else:
|
||||
# Fall back to page helpers (service-specific functions)
|
||||
helpers = get_page_helpers(service_name)
|
||||
helper_fn = helpers.get(name)
|
||||
if helper_fn is None:
|
||||
quart_abort(404)
|
||||
result = helper_fn(*args, **kwargs) if kwargs else helper_fn(*args)
|
||||
if _asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
|
||||
result_sx = serialize(result) if result is not None else "nil"
|
||||
resp = await make_response(result_sx, 200)
|
||||
resp.content_type = "text/sx; charset=utf-8"
|
||||
resp.headers["Cache-Control"] = "public, max-age=300"
|
||||
return resp
|
||||
|
||||
io_proxy.__name__ = "sx_io_proxy"
|
||||
io_proxy.__qualname__ = "sx_io_proxy"
|
||||
|
||||
app.add_url_rule(
|
||||
"/sx/io/<name>",
|
||||
endpoint="sx_io_proxy",
|
||||
view_func=io_proxy,
|
||||
methods=["GET", "POST"],
|
||||
)
|
||||
logger.info("Mounted IO proxy for %s: %s", service_name, sorted(_ALLOWED_IO))
|
||||
|
||||
@@ -192,9 +192,13 @@ def prim_is_zero(n: Any) -> bool:
|
||||
def prim_is_nil(x: Any) -> bool:
|
||||
return x is None or x is NIL
|
||||
|
||||
@register_primitive("boolean?")
|
||||
def prim_is_boolean(x: Any) -> bool:
|
||||
return isinstance(x, bool)
|
||||
|
||||
@register_primitive("number?")
|
||||
def prim_is_number(x: Any) -> bool:
|
||||
return isinstance(x, (int, float))
|
||||
return isinstance(x, (int, float)) and not isinstance(x, bool)
|
||||
|
||||
@register_primitive("string?")
|
||||
def prim_is_string(x: Any) -> bool:
|
||||
@@ -268,13 +272,27 @@ def prim_concat(*colls: Any) -> list:
|
||||
return result
|
||||
|
||||
@register_primitive("upper")
|
||||
@register_primitive("upcase")
|
||||
def prim_upper(s: str) -> str:
|
||||
return s.upper()
|
||||
|
||||
@register_primitive("lower")
|
||||
@register_primitive("downcase")
|
||||
def prim_lower(s: str) -> str:
|
||||
return s.lower()
|
||||
|
||||
@register_primitive("string-length")
|
||||
def prim_string_length(s: str) -> int:
|
||||
return len(s)
|
||||
|
||||
@register_primitive("substring")
|
||||
def prim_substring(s: str, start: int, end: int) -> str:
|
||||
return s[int(start):int(end)]
|
||||
|
||||
@register_primitive("string-contains?")
|
||||
def prim_string_contains(s: str, needle: str) -> bool:
|
||||
return needle in s
|
||||
|
||||
@register_primitive("trim")
|
||||
def prim_trim(s: str) -> str:
|
||||
return s.strip()
|
||||
@@ -384,8 +402,36 @@ def prim_cons(x: Any, coll: Any) -> list:
|
||||
|
||||
@register_primitive("append")
|
||||
def prim_append(coll: Any, x: Any) -> list:
|
||||
if isinstance(x, list):
|
||||
return list(coll) + x if coll else list(x)
|
||||
return list(coll) + [x] if coll else [x]
|
||||
|
||||
@register_primitive("reverse")
|
||||
def prim_reverse(coll: Any) -> list:
|
||||
return list(reversed(coll)) if coll else []
|
||||
|
||||
@register_primitive("flatten")
|
||||
def prim_flatten(coll: Any) -> list:
|
||||
result = []
|
||||
for item in (coll or []):
|
||||
if isinstance(item, list):
|
||||
result.extend(item)
|
||||
else:
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@register_primitive("has-key?")
|
||||
def prim_has_key(d: Any, key: Any) -> bool:
|
||||
if not isinstance(d, dict):
|
||||
return False
|
||||
k = key.name if isinstance(key, Keyword) else key
|
||||
return k in d
|
||||
|
||||
@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 +485,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):
|
||||
|
||||
@@ -377,7 +377,10 @@ async def _io_asset_url(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> str:
|
||||
"""``(asset-url "/img/logo.png")`` → versioned static URL."""
|
||||
from shared.infrastructure.urls import asset_url
|
||||
from quart import current_app
|
||||
asset_url = current_app.jinja_env.globals.get("asset_url")
|
||||
if asset_url is None:
|
||||
raise RuntimeError("asset_url Jinja global not registered")
|
||||
path = str(args[0]) if args else ""
|
||||
return asset_url(path)
|
||||
|
||||
@@ -458,7 +461,10 @@ def _bridge_app_url(service, *path_parts):
|
||||
return app_url(str(service), path)
|
||||
|
||||
def _bridge_asset_url(*path_parts):
|
||||
from shared.infrastructure.urls import asset_url
|
||||
from quart import current_app
|
||||
asset_url = current_app.jinja_env.globals.get("asset_url")
|
||||
if asset_url is None:
|
||||
raise RuntimeError("asset_url Jinja global not registered")
|
||||
path = str(path_parts[0]) if path_parts else ""
|
||||
return asset_url(path)
|
||||
|
||||
|
||||
@@ -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))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -295,6 +295,38 @@
|
||||
scripts))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Page registry for client-side routing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define _page-routes (list))
|
||||
|
||||
(define process-page-scripts
|
||||
(fn ()
|
||||
;; Process <script type="text/sx-pages"> tags.
|
||||
;; Parses SX page registry and builds route entries with parsed patterns.
|
||||
(let ((scripts (query-page-scripts)))
|
||||
(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)))
|
||||
(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))
|
||||
(log-warn "pages: script tag is empty")))))
|
||||
scripts)
|
||||
(log-info (str "pages: " (len _page-routes) " routes loaded")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Full boot sequence
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -305,11 +337,14 @@
|
||||
;; 1. CSS tracking
|
||||
;; 2. Style dictionary
|
||||
;; 3. Process scripts (components + mounts)
|
||||
;; 4. Hydrate [data-sx] elements
|
||||
;; 5. Process engine elements
|
||||
;; 4. Process page registry (client-side routing)
|
||||
;; 5. Hydrate [data-sx] elements
|
||||
;; 6. Process engine elements
|
||||
(do
|
||||
(log-info (str "sx-browser " SX_VERSION))
|
||||
(init-css-tracking)
|
||||
(init-style-dict)
|
||||
(process-page-scripts)
|
||||
(process-sx-scripts nil)
|
||||
(sx-hydrate-elements nil)
|
||||
(process-elements nil))))
|
||||
@@ -354,6 +389,7 @@
|
||||
;; === Script queries ===
|
||||
;; (query-sx-scripts root) → list of <script type="text/sx"> elements
|
||||
;; (query-style-scripts) → list of <script type="text/sx-styles"> elements
|
||||
;; (query-page-scripts) → list of <script type="text/sx-pages"> elements
|
||||
;;
|
||||
;; === localStorage ===
|
||||
;; (local-storage-get key) → string or nil
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,8 @@ class PyEmitter:
|
||||
return self._emit_symbol(expr.name)
|
||||
if isinstance(expr, Keyword):
|
||||
return self._py_string(expr.name)
|
||||
if isinstance(expr, dict):
|
||||
return self._emit_native_dict(expr)
|
||||
if isinstance(expr, list):
|
||||
return self._emit_list(expr)
|
||||
return str(expr)
|
||||
@@ -234,6 +236,8 @@ class PyEmitter:
|
||||
"map-indexed": "map_indexed",
|
||||
"map-dict": "map_dict",
|
||||
"eval-cond": "eval_cond",
|
||||
"eval-cond-scheme": "eval_cond_scheme",
|
||||
"eval-cond-clojure": "eval_cond_clojure",
|
||||
"process-bindings": "process_bindings",
|
||||
# deps.sx
|
||||
"scan-refs": "scan_refs",
|
||||
@@ -258,6 +262,13 @@ class PyEmitter:
|
||||
"transitive-io-refs": "transitive_io_refs",
|
||||
"compute-all-io-refs": "compute_all_io_refs",
|
||||
"component-pure?": "component_pure_p",
|
||||
# router.sx
|
||||
"split-path-segments": "split_path_segments",
|
||||
"make-route-segment": "make_route_segment",
|
||||
"parse-route-pattern": "parse_route_pattern",
|
||||
"match-route-segments": "match_route_segments",
|
||||
"match-route": "match_route",
|
||||
"find-matching-route": "find_matching_route",
|
||||
}
|
||||
if name in RENAMES:
|
||||
return RENAMES[name]
|
||||
@@ -391,6 +402,9 @@ class PyEmitter:
|
||||
assignments.append((self._mangle(vname), self.emit(bindings[i + 1])))
|
||||
# Nested IIFE for sequential let (each binding can see previous ones):
|
||||
# (lambda a: (lambda b: body)(val_b))(val_a)
|
||||
# Cell variables (mutated by nested set!) are initialized in _cells dict
|
||||
# instead of lambda params, since the body reads _cells[name].
|
||||
cell_vars = getattr(self, '_current_cell_vars', set())
|
||||
body_parts = [self.emit(b) for b in body]
|
||||
if len(body) == 1:
|
||||
body_str = body_parts[0]
|
||||
@@ -399,6 +413,10 @@ class PyEmitter:
|
||||
# Build from inside out
|
||||
result = body_str
|
||||
for name, val in reversed(assignments):
|
||||
if name in cell_vars:
|
||||
# Cell var: initialize in _cells dict, not as lambda param
|
||||
result = f"_sx_begin(_sx_cell_set(_cells, {self._py_string(name)}, {val}), {result})"
|
||||
else:
|
||||
result = f"(lambda {name}: {result})({val})"
|
||||
return result
|
||||
|
||||
@@ -512,6 +530,13 @@ class PyEmitter:
|
||||
parts = [self.emit(e) for e in exprs]
|
||||
return "_sx_begin(" + ", ".join(parts) + ")"
|
||||
|
||||
def _emit_native_dict(self, expr: dict) -> str:
|
||||
"""Emit a native Python dict (from parser's {:key val} syntax)."""
|
||||
parts = []
|
||||
for key, val in expr.items():
|
||||
parts.append(f"{self._py_string(key)}: {self.emit(val)}")
|
||||
return "{" + ", ".join(parts) + "}"
|
||||
|
||||
def _emit_dict_literal(self, expr) -> str:
|
||||
pairs = expr[1:]
|
||||
parts = []
|
||||
@@ -828,6 +853,7 @@ ADAPTER_FILES = {
|
||||
|
||||
SPEC_MODULES = {
|
||||
"deps": ("deps.sx", "deps (component dependency analysis)"),
|
||||
"router": ("router.sx", "router (client-side route matching)"),
|
||||
}
|
||||
|
||||
|
||||
@@ -1947,6 +1973,9 @@ range = PRIMITIVES["range"]
|
||||
apply = lambda f, args: f(*args)
|
||||
assoc = PRIMITIVES["assoc"]
|
||||
concat = PRIMITIVES["concat"]
|
||||
split = PRIMITIVES["split"]
|
||||
length = PRIMITIVES["len"]
|
||||
merge = PRIMITIVES["merge"]
|
||||
'''
|
||||
|
||||
|
||||
|
||||
245
shared/sx/ref/bootstrap_test.py
Normal file
245
shared/sx/ref/bootstrap_test.py
Normal file
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Bootstrap compiler: test.sx -> pytest test module.
|
||||
|
||||
Reads test.sx and emits a Python test file that runs each deftest
|
||||
as a pytest test case, grouped into classes by defsuite.
|
||||
|
||||
The emitted tests use the SX evaluator to run SX test bodies,
|
||||
verifying that the Python implementation matches the spec.
|
||||
|
||||
Usage:
|
||||
python bootstrap_test.py --output shared/sx/tests/test_sx_spec.py
|
||||
pytest shared/sx/tests/test_sx_spec.py -v
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", ".."))
|
||||
sys.path.insert(0, _PROJECT)
|
||||
|
||||
from shared.sx.parser import parse_all
|
||||
from shared.sx.types import Symbol, Keyword, NIL as SX_NIL
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
"""Convert a test/suite name to a valid Python identifier."""
|
||||
s = name.lower().strip()
|
||||
s = re.sub(r'[^a-z0-9]+', '_', s)
|
||||
s = s.strip('_')
|
||||
return s
|
||||
|
||||
|
||||
def _sx_to_source(expr) -> str:
|
||||
"""Convert an SX AST node back to SX source string."""
|
||||
if isinstance(expr, bool):
|
||||
return "true" if expr else "false"
|
||||
if isinstance(expr, (int, float)):
|
||||
return str(expr)
|
||||
if isinstance(expr, str):
|
||||
escaped = expr.replace('\\', '\\\\').replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
if expr is None or expr is SX_NIL:
|
||||
return "nil"
|
||||
if isinstance(expr, Symbol):
|
||||
return expr.name
|
||||
if isinstance(expr, Keyword):
|
||||
return f":{expr.name}"
|
||||
if isinstance(expr, dict):
|
||||
pairs = []
|
||||
for k, v in expr.items():
|
||||
pairs.append(f":{k} {_sx_to_source(v)}")
|
||||
return "{" + " ".join(pairs) + "}"
|
||||
if isinstance(expr, list):
|
||||
if not expr:
|
||||
return "()"
|
||||
return "(" + " ".join(_sx_to_source(e) for e in expr) + ")"
|
||||
return str(expr)
|
||||
|
||||
|
||||
def _parse_test_sx(path: str) -> tuple[list[dict], list]:
|
||||
"""Parse test.sx and return (suites, preamble_exprs).
|
||||
|
||||
Preamble exprs are define forms (assertion helpers) that must be
|
||||
evaluated before tests run. Suites contain the actual test cases.
|
||||
"""
|
||||
with open(path) as f:
|
||||
content = f.read()
|
||||
|
||||
exprs = parse_all(content)
|
||||
suites = []
|
||||
preamble = []
|
||||
|
||||
for expr in exprs:
|
||||
if not isinstance(expr, list) or not expr:
|
||||
continue
|
||||
head = expr[0]
|
||||
if isinstance(head, Symbol) and head.name == "defsuite":
|
||||
suite = _parse_suite(expr)
|
||||
if suite:
|
||||
suites.append(suite)
|
||||
elif isinstance(head, Symbol) and head.name == "define":
|
||||
preamble.append(expr)
|
||||
|
||||
return suites, preamble
|
||||
|
||||
|
||||
def _parse_suite(expr: list) -> dict | None:
|
||||
"""Parse a (defsuite "name" ...) form."""
|
||||
if len(expr) < 2:
|
||||
return None
|
||||
|
||||
name = expr[1]
|
||||
if not isinstance(name, str):
|
||||
return None
|
||||
|
||||
tests = []
|
||||
for child in expr[2:]:
|
||||
if not isinstance(child, list) or not child:
|
||||
continue
|
||||
head = child[0]
|
||||
if isinstance(head, Symbol):
|
||||
if head.name == "deftest":
|
||||
test = _parse_test(child)
|
||||
if test:
|
||||
tests.append(test)
|
||||
elif head.name == "defsuite":
|
||||
sub = _parse_suite(child)
|
||||
if sub:
|
||||
tests.append(sub)
|
||||
|
||||
return {"type": "suite", "name": name, "tests": tests}
|
||||
|
||||
|
||||
def _parse_test(expr: list) -> dict | None:
|
||||
"""Parse a (deftest "name" body ...) form."""
|
||||
if len(expr) < 3:
|
||||
return None
|
||||
name = expr[1]
|
||||
if not isinstance(name, str):
|
||||
return None
|
||||
body = expr[2:]
|
||||
return {"type": "test", "name": name, "body": body}
|
||||
|
||||
|
||||
def _emit_py(suites: list[dict], preamble: list) -> str:
|
||||
"""Emit a pytest module from parsed suites."""
|
||||
# Serialize preamble (assertion helpers) as SX source
|
||||
preamble_sx = "\n".join(_sx_to_source(expr) for expr in preamble)
|
||||
preamble_escaped = preamble_sx.replace('\\', '\\\\').replace("'", "\\'")
|
||||
|
||||
lines = []
|
||||
lines.append('"""Auto-generated from test.sx — SX spec self-tests.')
|
||||
lines.append('')
|
||||
lines.append('DO NOT EDIT. Regenerate with:')
|
||||
lines.append(' python shared/sx/ref/bootstrap_test.py --output shared/sx/tests/test_sx_spec.py')
|
||||
lines.append('"""')
|
||||
lines.append('from __future__ import annotations')
|
||||
lines.append('')
|
||||
lines.append('import pytest')
|
||||
lines.append('from shared.sx.parser import parse_all')
|
||||
lines.append('from shared.sx.evaluator import _eval, _trampoline')
|
||||
lines.append('')
|
||||
lines.append('')
|
||||
lines.append(f"_PREAMBLE = '''{preamble_escaped}'''")
|
||||
lines.append('')
|
||||
lines.append('')
|
||||
lines.append('def _make_env() -> dict:')
|
||||
lines.append(' """Create a fresh env with assertion helpers loaded."""')
|
||||
lines.append(' env = {}')
|
||||
lines.append(' for expr in parse_all(_PREAMBLE):')
|
||||
lines.append(' _trampoline(_eval(expr, env))')
|
||||
lines.append(' return env')
|
||||
lines.append('')
|
||||
lines.append('')
|
||||
lines.append('def _run(sx_source: str, env: dict | None = None) -> object:')
|
||||
lines.append(' """Evaluate SX source and return the result."""')
|
||||
lines.append(' if env is None:')
|
||||
lines.append(' env = _make_env()')
|
||||
lines.append(' exprs = parse_all(sx_source)')
|
||||
lines.append(' result = None')
|
||||
lines.append(' for expr in exprs:')
|
||||
lines.append(' result = _trampoline(_eval(expr, env))')
|
||||
lines.append(' return result')
|
||||
lines.append('')
|
||||
|
||||
for suite in suites:
|
||||
_emit_suite(suite, lines, indent=0)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _emit_suite(suite: dict, lines: list[str], indent: int):
|
||||
"""Emit a pytest class for a suite."""
|
||||
class_name = f"TestSpec{_slugify(suite['name']).title().replace('_', '')}"
|
||||
pad = " " * indent
|
||||
lines.append(f'{pad}class {class_name}:')
|
||||
lines.append(f'{pad} """test.sx suite: {suite["name"]}"""')
|
||||
lines.append('')
|
||||
|
||||
for item in suite["tests"]:
|
||||
if item["type"] == "test":
|
||||
_emit_test(item, lines, indent + 1)
|
||||
elif item["type"] == "suite":
|
||||
_emit_suite(item, lines, indent + 1)
|
||||
|
||||
lines.append('')
|
||||
|
||||
|
||||
def _emit_test(test: dict, lines: list[str], indent: int):
|
||||
"""Emit a pytest test method."""
|
||||
method_name = f"test_{_slugify(test['name'])}"
|
||||
pad = " " * indent
|
||||
|
||||
# Convert body expressions to SX source
|
||||
body_parts = []
|
||||
for expr in test["body"]:
|
||||
body_parts.append(_sx_to_source(expr))
|
||||
|
||||
# Wrap in (do ...) if multiple expressions, or use single
|
||||
if len(body_parts) == 1:
|
||||
sx_source = body_parts[0]
|
||||
else:
|
||||
sx_source = "(do " + " ".join(body_parts) + ")"
|
||||
|
||||
# Escape for Python string
|
||||
sx_escaped = sx_source.replace('\\', '\\\\').replace("'", "\\'")
|
||||
|
||||
lines.append(f"{pad}def {method_name}(self):")
|
||||
lines.append(f"{pad} _run('{sx_escaped}')")
|
||||
lines.append('')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Bootstrap test.sx to pytest")
|
||||
parser.add_argument("--output", "-o", help="Output file path")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print to stdout")
|
||||
args = parser.parse_args()
|
||||
|
||||
test_sx = os.path.join(_HERE, "test.sx")
|
||||
suites, preamble = _parse_test_sx(test_sx)
|
||||
|
||||
print(f"Parsed {len(suites)} suites, {len(preamble)} preamble defines from test.sx", file=sys.stderr)
|
||||
total_tests = sum(
|
||||
sum(1 for t in s["tests"] if t["type"] == "test")
|
||||
for s in suites
|
||||
)
|
||||
print(f"Total test cases: {total_tests}", file=sys.stderr)
|
||||
|
||||
output = _emit_py(suites, preamble)
|
||||
|
||||
if args.output and not args.dry_run:
|
||||
with open(args.output, "w") as f:
|
||||
f.write(output)
|
||||
print(f"Wrote {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -388,15 +388,33 @@
|
||||
(dom-has-attr? el "href")))
|
||||
(prevent-default e))
|
||||
|
||||
;; Delay modifier
|
||||
;; Re-read verb info from element at click time (not closed-over)
|
||||
(let ((live-info (or (get-verb-info el) verbInfo))
|
||||
(is-get-link (and (= event-name "click")
|
||||
(= (get live-info "method") "GET")
|
||||
(dom-has-attr? el "href")
|
||||
(not (get mods "delay"))))
|
||||
(client-routed false))
|
||||
(when is-get-link
|
||||
(set! client-routed
|
||||
(try-client-route
|
||||
(url-pathname (get live-info "url"))
|
||||
(dom-get-attr el "sx-target"))))
|
||||
(if client-routed
|
||||
(do
|
||||
(browser-push-state (get live-info "url"))
|
||||
(browser-scroll-to 0 0))
|
||||
(do
|
||||
(when is-get-link
|
||||
(log-info (str "sx:route server fetch " (get live-info "url"))))
|
||||
(if (get mods "delay")
|
||||
(do
|
||||
(clear-timeout timer)
|
||||
(set! timer
|
||||
(set-timeout
|
||||
(fn () (execute-request el verbInfo nil))
|
||||
(fn () (execute-request el nil nil))
|
||||
(get mods "delay"))))
|
||||
(execute-request el verbInfo nil)))))
|
||||
(execute-request el nil nil))))))))
|
||||
(if (get mods "once") (dict "once" true) nil))))))
|
||||
|
||||
|
||||
@@ -491,21 +509,24 @@
|
||||
|
||||
(define boost-descendants
|
||||
(fn (container)
|
||||
;; Boost links and forms within a container
|
||||
;; Links get sx-get, forms get sx-post/sx-get
|
||||
;; 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")
|
||||
;; Set default sx-target if not specified
|
||||
(when (not (dom-has-attr? link "sx-target"))
|
||||
(dom-set-attr link "sx-target" "#main-panel"))
|
||||
;; 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-boost-link link (dom-get-attr link "href"))))
|
||||
(bind-client-route-link link (dom-get-attr link "href"))))
|
||||
(dom-query-all container "a[href]"))
|
||||
(for-each
|
||||
(fn (form)
|
||||
@@ -515,12 +536,197 @@
|
||||
(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 (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"))))
|
||||
(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 target-sel)
|
||||
;; Try to render a page client-side. Returns true if successful, false otherwise.
|
||||
;; 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)
|
||||
(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)
|
||||
(let ((io-deps (get match "io-deps"))
|
||||
(has-io (and io-deps (not (empty? io-deps)))))
|
||||
;; Ensure IO deps are registered as proxied primitives
|
||||
(when has-io (register-io-deps io-deps))
|
||||
(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
|
||||
(let ((env (merge closure params cached)))
|
||||
(if has-io
|
||||
;; Async render (data+IO)
|
||||
(do
|
||||
(log-info (str "sx:route client+cache+async " pathname))
|
||||
(try-async-eval-content content-src env
|
||||
(fn (rendered)
|
||||
(if (nil? rendered)
|
||||
(log-warn (str "sx:route async eval failed for " pathname))
|
||||
(swap-rendered-content target rendered pathname))))
|
||||
true)
|
||||
;; Sync render (data only)
|
||||
(let ((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)))
|
||||
(if has-io
|
||||
;; Async render (data+IO)
|
||||
(try-async-eval-content content-src env
|
||||
(fn (rendered)
|
||||
(if (nil? rendered)
|
||||
(log-warn (str "sx:route data+async eval failed for " pathname))
|
||||
(swap-rendered-content target rendered pathname))))
|
||||
;; Sync render (data only)
|
||||
(let ((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)))
|
||||
;; Non-data page
|
||||
(if has-io
|
||||
;; Async render (IO only, no data)
|
||||
(do
|
||||
(log-info (str "sx:route client+async " pathname))
|
||||
(try-async-eval-content content-src (merge closure params)
|
||||
(fn (rendered)
|
||||
(if (nil? rendered)
|
||||
(log-warn (str "sx:route async 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
|
||||
(fn (link href)
|
||||
;; Bind a boost link with client-side routing. If the route can be
|
||||
;; rendered client-side (pure page, no :data), do so. Otherwise
|
||||
;; fall back to standard server fetch via bind-boost-link.
|
||||
(bind-client-route-click link href
|
||||
(fn ()
|
||||
;; Fallback: use standard boost link binding
|
||||
(bind-boost-link link href)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -608,17 +814,18 @@
|
||||
;; Bind preload event listeners based on sx-preload attribute
|
||||
(let ((preload-attr (dom-get-attr el "sx-preload")))
|
||||
(when preload-attr
|
||||
(let ((info (get-verb-info el)))
|
||||
(when info
|
||||
(let ((url (get info "url"))
|
||||
(headers (build-request-headers el
|
||||
(loaded-component-names) _css-hash))
|
||||
(events (if (= preload-attr "mousedown")
|
||||
(let ((events (if (= preload-attr "mousedown")
|
||||
(list "mousedown" "touchstart")
|
||||
(list "mouseover")))
|
||||
(debounce-ms (if (= preload-attr "mousedown") 0 100)))
|
||||
;; Re-read verb info and headers at preload time, not bind time
|
||||
(bind-preload el events debounce-ms
|
||||
(fn () (do-preload url headers))))))))))
|
||||
(fn ()
|
||||
(let ((info (get-verb-info el)))
|
||||
(when info
|
||||
(do-preload (get info "url")
|
||||
(build-request-headers el
|
||||
(loaded-component-names) _css-hash)))))))))))
|
||||
|
||||
|
||||
(define do-preload
|
||||
@@ -668,13 +875,25 @@
|
||||
|
||||
(define handle-popstate
|
||||
(fn (scrollY)
|
||||
;; Handle browser back/forward navigation
|
||||
(let ((main (dom-query-by-id "main-panel"))
|
||||
(url (browser-location-href)))
|
||||
(when main
|
||||
(let ((headers (build-request-headers main
|
||||
;; 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 ((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 main url headers scrollY))))))
|
||||
(fetch-and-restore target url headers scrollY)))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -727,7 +946,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
|
||||
;;
|
||||
@@ -773,6 +992,7 @@
|
||||
;; === Boost bindings ===
|
||||
;; (bind-boost-link el href) → void (click handler + pushState)
|
||||
;; (bind-boost-form form method action) → void (submit handler)
|
||||
;; (bind-client-route-click link href fallback-fn) → void (client route click handler)
|
||||
;;
|
||||
;; === Inline handlers ===
|
||||
;; (bind-inline-handler el event-name body) → void (new Function)
|
||||
@@ -803,10 +1023,29 @@
|
||||
;; === Parsing ===
|
||||
;; (try-parse-json s) → parsed value or nil
|
||||
;;
|
||||
;; === Client-side routing ===
|
||||
;; (try-eval-content source env) → DOM node or nil (catches eval errors)
|
||||
;; (try-async-eval-content source env callback) → void; async render,
|
||||
;; calls (callback rendered-or-nil). Used for pages with IO deps.
|
||||
;; (register-io-deps names) → void; ensure each IO name is registered
|
||||
;; as a proxied IO primitive on the client. Idempotent.
|
||||
;; (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
|
||||
;;
|
||||
;; From router.sx:
|
||||
;; (find-matching-route path routes) → matching entry with params, or nil
|
||||
;; (parse-route-pattern pattern) → parsed pattern segments
|
||||
;;
|
||||
;; === Browser (via engine.sx) ===
|
||||
;; (browser-location-href) → current URL string
|
||||
;; (browser-navigate url) → void
|
||||
;; (browser-reload) → void
|
||||
;; (browser-scroll-to x y) → void
|
||||
;; (browser-media-matches? query) → boolean
|
||||
;; (browser-confirm msg) → boolean
|
||||
;; (browser-prompt msg) → string or nil
|
||||
|
||||
@@ -208,10 +208,16 @@
|
||||
:returns "boolean"
|
||||
:doc "True if x is nil/null/None.")
|
||||
|
||||
(define-primitive "boolean?"
|
||||
:params (x)
|
||||
:returns "boolean"
|
||||
:doc "True if x is a boolean (true or false). Must be checked before
|
||||
number? on platforms where booleans are numeric subtypes.")
|
||||
|
||||
(define-primitive "number?"
|
||||
:params (x)
|
||||
:returns "boolean"
|
||||
:doc "True if x is a number (int or float).")
|
||||
:doc "True if x is a number (int or float). Excludes booleans.")
|
||||
|
||||
(define-primitive "string?"
|
||||
:params (x)
|
||||
@@ -277,11 +283,36 @@
|
||||
:returns "string"
|
||||
:doc "Uppercase string.")
|
||||
|
||||
(define-primitive "upcase"
|
||||
:params (s)
|
||||
:returns "string"
|
||||
:doc "Alias for upper. Uppercase string.")
|
||||
|
||||
(define-primitive "lower"
|
||||
:params (s)
|
||||
:returns "string"
|
||||
:doc "Lowercase string.")
|
||||
|
||||
(define-primitive "downcase"
|
||||
:params (s)
|
||||
:returns "string"
|
||||
:doc "Alias for lower. Lowercase string.")
|
||||
|
||||
(define-primitive "string-length"
|
||||
:params (s)
|
||||
:returns "number"
|
||||
:doc "Length of string in characters.")
|
||||
|
||||
(define-primitive "substring"
|
||||
:params (s start end)
|
||||
:returns "string"
|
||||
:doc "Extract substring from start (inclusive) to end (exclusive).")
|
||||
|
||||
(define-primitive "string-contains?"
|
||||
:params (s needle)
|
||||
:returns "boolean"
|
||||
:doc "True if string s contains substring needle.")
|
||||
|
||||
(define-primitive "trim"
|
||||
:params (s)
|
||||
:returns "string"
|
||||
@@ -382,7 +413,22 @@
|
||||
(define-primitive "append"
|
||||
:params (coll x)
|
||||
:returns "list"
|
||||
:doc "Append x to end of coll (returns new list).")
|
||||
:doc "If x is a list, concatenate. Otherwise append x as single element.")
|
||||
|
||||
(define-primitive "append!"
|
||||
:params (coll x)
|
||||
:returns "list"
|
||||
:doc "Mutate coll by appending x in-place. Returns coll.")
|
||||
|
||||
(define-primitive "reverse"
|
||||
:params (coll)
|
||||
:returns "list"
|
||||
:doc "Return coll in reverse order.")
|
||||
|
||||
(define-primitive "flatten"
|
||||
:params (coll)
|
||||
:returns "list"
|
||||
:doc "Flatten one level of nesting. Nested lists become top-level elements.")
|
||||
|
||||
(define-primitive "chunk-every"
|
||||
:params (coll n)
|
||||
@@ -416,6 +462,11 @@
|
||||
:returns "dict"
|
||||
:doc "Merge dicts left to right. Later keys win. Skips nil.")
|
||||
|
||||
(define-primitive "has-key?"
|
||||
:params (d key)
|
||||
:returns "boolean"
|
||||
:doc "True if dict d contains key.")
|
||||
|
||||
(define-primitive "assoc"
|
||||
:params (d &rest pairs)
|
||||
:returns "dict"
|
||||
@@ -426,6 +477,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"
|
||||
|
||||
@@ -124,6 +124,75 @@
|
||||
(keys attrs)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Render adapter helpers
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Shared by HTML and DOM adapters for evaluating control forms during
|
||||
;; rendering. Unlike sf-cond (eval.sx) which returns a thunk for TCO,
|
||||
;; eval-cond returns the unevaluated body expression so the adapter
|
||||
;; can render it in its own mode (HTML string vs DOM nodes).
|
||||
|
||||
;; eval-cond: find matching cond branch, return unevaluated body expr.
|
||||
;; Handles both scheme-style ((test body) ...) and clojure-style
|
||||
;; (test body test body ...).
|
||||
(define eval-cond
|
||||
(fn (clauses env)
|
||||
(if (and (not (empty? clauses))
|
||||
(= (type-of (first clauses)) "list")
|
||||
(= (len (first clauses)) 2))
|
||||
;; Scheme-style
|
||||
(eval-cond-scheme clauses env)
|
||||
;; Clojure-style
|
||||
(eval-cond-clojure clauses env))))
|
||||
|
||||
(define eval-cond-scheme
|
||||
(fn (clauses env)
|
||||
(if (empty? clauses)
|
||||
nil
|
||||
(let ((clause (first clauses))
|
||||
(test (first clause))
|
||||
(body (nth clause 1)))
|
||||
(if (or (and (= (type-of test) "symbol")
|
||||
(or (= (symbol-name test) "else")
|
||||
(= (symbol-name test) ":else")))
|
||||
(and (= (type-of test) "keyword")
|
||||
(= (keyword-name test) "else")))
|
||||
body
|
||||
(if (trampoline (eval-expr test env))
|
||||
body
|
||||
(eval-cond-scheme (rest clauses) env)))))))
|
||||
|
||||
(define eval-cond-clojure
|
||||
(fn (clauses env)
|
||||
(if (< (len clauses) 2)
|
||||
nil
|
||||
(let ((test (first clauses))
|
||||
(body (nth clauses 1)))
|
||||
(if (or (and (= (type-of test) "keyword") (= (keyword-name test) "else"))
|
||||
(and (= (type-of test) "symbol")
|
||||
(or (= (symbol-name test) "else")
|
||||
(= (symbol-name test) ":else"))))
|
||||
body
|
||||
(if (trampoline (eval-expr test env))
|
||||
body
|
||||
(eval-cond-clojure (slice clauses 2) env)))))))
|
||||
|
||||
;; process-bindings: evaluate let-binding pairs, return extended env.
|
||||
;; bindings = ((name1 expr1) (name2 expr2) ...)
|
||||
(define process-bindings
|
||||
(fn (bindings env)
|
||||
(let ((local (merge env)))
|
||||
(for-each
|
||||
(fn (pair)
|
||||
(when (and (= (type-of pair) "list") (>= (len pair) 2))
|
||||
(let ((name (if (= (type-of (first pair)) "symbol")
|
||||
(symbol-name (first pair))
|
||||
(str (first pair)))))
|
||||
(env-set! local name (trampoline (eval-expr (nth pair 1) local))))))
|
||||
bindings)
|
||||
local)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Platform interface (shared across adapters)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
126
shared/sx/ref/router.sx
Normal file
126
shared/sx/ref/router.sx
Normal file
@@ -0,0 +1,126 @@
|
||||
;; ==========================================================================
|
||||
;; router.sx — Client-side route matching specification
|
||||
;;
|
||||
;; Pure functions for matching URL paths against Flask-style route patterns.
|
||||
;; Used by client-side routing to determine if a page can be rendered
|
||||
;; locally without a server roundtrip.
|
||||
;;
|
||||
;; All functions are pure — no IO, no platform-specific operations.
|
||||
;; Uses only primitives from primitives.sx (string ops, list ops).
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. Split path into segments
|
||||
;; --------------------------------------------------------------------------
|
||||
;; "/docs/hello" → ("docs" "hello")
|
||||
;; "/" → ()
|
||||
;; "/docs/" → ("docs")
|
||||
|
||||
(define split-path-segments
|
||||
(fn (path)
|
||||
(let ((trimmed (if (starts-with? path "/") (slice path 1) path)))
|
||||
(let ((trimmed2 (if (and (not (empty? trimmed))
|
||||
(ends-with? trimmed "/"))
|
||||
(slice trimmed 0 (- (len trimmed) 1))
|
||||
trimmed)))
|
||||
(if (empty? trimmed2)
|
||||
(list)
|
||||
(split trimmed2 "/"))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. Parse Flask-style route pattern into segment descriptors
|
||||
;; --------------------------------------------------------------------------
|
||||
;; "/docs/<slug>" → ({"type" "literal" "value" "docs"}
|
||||
;; {"type" "param" "value" "slug"})
|
||||
|
||||
(define make-route-segment
|
||||
(fn (seg)
|
||||
(if (and (starts-with? seg "<") (ends-with? seg ">"))
|
||||
(let ((param-name (slice seg 1 (- (len seg) 1))))
|
||||
(let ((d {}))
|
||||
(dict-set! d "type" "param")
|
||||
(dict-set! d "value" param-name)
|
||||
d))
|
||||
(let ((d {}))
|
||||
(dict-set! d "type" "literal")
|
||||
(dict-set! d "value" seg)
|
||||
d))))
|
||||
|
||||
(define parse-route-pattern
|
||||
(fn (pattern)
|
||||
(let ((segments (split-path-segments pattern)))
|
||||
(map make-route-segment segments))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. Match path segments against parsed pattern
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Returns params dict if match, nil if no match.
|
||||
|
||||
(define match-route-segments
|
||||
(fn (path-segs parsed-segs)
|
||||
(if (not (= (len path-segs) (len parsed-segs)))
|
||||
nil
|
||||
(let ((params {})
|
||||
(matched true))
|
||||
(for-each-indexed
|
||||
(fn (i parsed-seg)
|
||||
(when matched
|
||||
(let ((path-seg (nth path-segs i))
|
||||
(seg-type (get parsed-seg "type")))
|
||||
(cond
|
||||
(= seg-type "literal")
|
||||
(when (not (= path-seg (get parsed-seg "value")))
|
||||
(set! matched false))
|
||||
(= seg-type "param")
|
||||
(dict-set! params (get parsed-seg "value") path-seg)
|
||||
:else
|
||||
(set! matched false)))))
|
||||
parsed-segs)
|
||||
(if matched params nil)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. Public API: match a URL path against a pattern string
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Returns params dict (may be empty for exact matches) or nil.
|
||||
|
||||
(define match-route
|
||||
(fn (path pattern)
|
||||
(let ((path-segs (split-path-segments path))
|
||||
(parsed-segs (parse-route-pattern pattern)))
|
||||
(match-route-segments path-segs parsed-segs))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. Search a list of route entries for first match
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Each entry: {"pattern" "/docs/<slug>" "parsed" [...] "name" "docs-page" ...}
|
||||
;; Returns matching entry with "params" added, or nil.
|
||||
|
||||
(define find-matching-route
|
||||
(fn (path routes)
|
||||
(let ((path-segs (split-path-segments path))
|
||||
(result nil))
|
||||
(for-each
|
||||
(fn (route)
|
||||
(when (nil? result)
|
||||
(let ((params (match-route-segments path-segs (get route "parsed"))))
|
||||
(when (not (nil? params))
|
||||
(let ((matched (merge route {})))
|
||||
(dict-set! matched "params" params)
|
||||
(set! result matched))))))
|
||||
routes)
|
||||
result)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Platform interface — none required
|
||||
;; --------------------------------------------------------------------------
|
||||
;; All functions use only pure primitives:
|
||||
;; split, slice, starts-with?, ends-with?, len, empty?,
|
||||
;; map, for-each, for-each-indexed, nth, get, dict-set!, merge,
|
||||
;; list, nil?, not, =
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -876,6 +876,9 @@ range = PRIMITIVES["range"]
|
||||
apply = lambda f, args: f(*args)
|
||||
assoc = PRIMITIVES["assoc"]
|
||||
concat = PRIMITIVES["concat"]
|
||||
split = PRIMITIVES["split"]
|
||||
length = PRIMITIVES["len"]
|
||||
merge = PRIMITIVES["merge"]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
@@ -1137,6 +1140,18 @@ parse_element_args = lambda args, env: (lambda attrs: (lambda children: _sx_begi
|
||||
# render-attrs
|
||||
render_attrs = lambda attrs: join('', map(lambda key: (lambda val: (sx_str(' ', key) if sx_truthy((contains_p(BOOLEAN_ATTRS, key) if not sx_truthy(contains_p(BOOLEAN_ATTRS, key)) else val)) else ('' if sx_truthy((contains_p(BOOLEAN_ATTRS, key) if not sx_truthy(contains_p(BOOLEAN_ATTRS, key)) else (not sx_truthy(val)))) else ('' if sx_truthy(is_nil(val)) else (sx_str(' class="', style_value_class(val), '"') if sx_truthy(((key == 'style') if not sx_truthy((key == 'style')) else is_style_value(val))) else sx_str(' ', key, '="', escape_attr(sx_str(val)), '"'))))))(dict_get(attrs, key)), keys(attrs)))
|
||||
|
||||
# eval-cond
|
||||
eval_cond = lambda clauses, env: (eval_cond_scheme(clauses, env) if sx_truthy(((not sx_truthy(empty_p(clauses))) if not sx_truthy((not sx_truthy(empty_p(clauses)))) else ((type_of(first(clauses)) == 'list') if not sx_truthy((type_of(first(clauses)) == 'list')) else (len(first(clauses)) == 2)))) else eval_cond_clojure(clauses, env))
|
||||
|
||||
# eval-cond-scheme
|
||||
eval_cond_scheme = lambda clauses, env: (NIL if sx_truthy(empty_p(clauses)) else (lambda clause: (lambda test: (lambda body: (body if sx_truthy((((type_of(test) == 'symbol') if not sx_truthy((type_of(test) == 'symbol')) else ((symbol_name(test) == 'else') if sx_truthy((symbol_name(test) == 'else')) else (symbol_name(test) == ':else'))) if sx_truthy(((type_of(test) == 'symbol') if not sx_truthy((type_of(test) == 'symbol')) else ((symbol_name(test) == 'else') if sx_truthy((symbol_name(test) == 'else')) else (symbol_name(test) == ':else')))) else ((type_of(test) == 'keyword') if not sx_truthy((type_of(test) == 'keyword')) else (keyword_name(test) == 'else')))) else (body if sx_truthy(trampoline(eval_expr(test, env))) else eval_cond_scheme(rest(clauses), env))))(nth(clause, 1)))(first(clause)))(first(clauses)))
|
||||
|
||||
# eval-cond-clojure
|
||||
eval_cond_clojure = lambda clauses, env: (NIL if sx_truthy((len(clauses) < 2)) else (lambda test: (lambda body: (body if sx_truthy((((type_of(test) == 'keyword') if not sx_truthy((type_of(test) == 'keyword')) else (keyword_name(test) == 'else')) if sx_truthy(((type_of(test) == 'keyword') if not sx_truthy((type_of(test) == 'keyword')) else (keyword_name(test) == 'else'))) else ((type_of(test) == 'symbol') if not sx_truthy((type_of(test) == 'symbol')) else ((symbol_name(test) == 'else') if sx_truthy((symbol_name(test) == 'else')) else (symbol_name(test) == ':else'))))) else (body if sx_truthy(trampoline(eval_expr(test, env))) else eval_cond_clojure(slice(clauses, 2), env))))(nth(clauses, 1)))(first(clauses)))
|
||||
|
||||
# process-bindings
|
||||
process_bindings = lambda bindings, env: (lambda local: _sx_begin(for_each(lambda pair: ((lambda name: _sx_dict_set(local, name, trampoline(eval_expr(nth(pair, 1), local))))((symbol_name(first(pair)) if sx_truthy((type_of(first(pair)) == 'symbol')) else sx_str(first(pair)))) if sx_truthy(((type_of(pair) == 'list') if not sx_truthy((type_of(pair) == 'list')) else (len(pair) >= 2))) else NIL), bindings), local))(merge(env))
|
||||
|
||||
|
||||
# === Transpiled from adapter-html ===
|
||||
|
||||
@@ -1237,6 +1252,40 @@ compute_all_io_refs = lambda env, io_names: for_each(lambda name: (lambda val: (
|
||||
component_pure_p = lambda name, env, io_names: empty_p(transitive_io_refs(name, env, io_names))
|
||||
|
||||
|
||||
# === Transpiled from router (client-side route matching) ===
|
||||
|
||||
# split-path-segments
|
||||
split_path_segments = lambda path: (lambda trimmed: (lambda trimmed2: ([] if sx_truthy(empty_p(trimmed2)) else split(trimmed2, '/')))((slice(trimmed, 0, (length(trimmed) - 1)) if sx_truthy(((not sx_truthy(empty_p(trimmed))) if not sx_truthy((not sx_truthy(empty_p(trimmed)))) else ends_with_p(trimmed, '/'))) else trimmed)))((slice(path, 1) if sx_truthy(starts_with_p(path, '/')) else path))
|
||||
|
||||
# make-route-segment
|
||||
make_route_segment = lambda seg: ((lambda param_name: (lambda d: _sx_begin(_sx_dict_set(d, 'type', 'param'), _sx_dict_set(d, 'value', param_name), d))({}))(slice(seg, 1, (length(seg) - 1))) if sx_truthy((starts_with_p(seg, '<') if not sx_truthy(starts_with_p(seg, '<')) else ends_with_p(seg, '>'))) else (lambda d: _sx_begin(_sx_dict_set(d, 'type', 'literal'), _sx_dict_set(d, 'value', seg), d))({}))
|
||||
|
||||
# parse-route-pattern
|
||||
parse_route_pattern = lambda pattern: (lambda segments: map(make_route_segment, segments))(split_path_segments(pattern))
|
||||
|
||||
# match-route-segments
|
||||
def match_route_segments(path_segs, parsed_segs):
|
||||
_cells = {}
|
||||
return (NIL if sx_truthy((not sx_truthy((length(path_segs) == length(parsed_segs))))) else (lambda params: _sx_begin(_sx_cell_set(_cells, 'matched', True), _sx_begin(for_each_indexed(lambda i, parsed_seg: ((lambda path_seg: (lambda seg_type: ((_sx_cell_set(_cells, 'matched', False) if sx_truthy((not sx_truthy((path_seg == get(parsed_seg, 'value'))))) else NIL) if sx_truthy((seg_type == 'literal')) else (_sx_dict_set(params, get(parsed_seg, 'value'), path_seg) if sx_truthy((seg_type == 'param')) else _sx_cell_set(_cells, 'matched', False))))(get(parsed_seg, 'type')))(nth(path_segs, i)) if sx_truthy(_cells['matched']) else NIL), parsed_segs), (params if sx_truthy(_cells['matched']) else NIL))))({}))
|
||||
|
||||
# match-route
|
||||
match_route = lambda path, pattern: (lambda path_segs: (lambda parsed_segs: match_route_segments(path_segs, parsed_segs))(parse_route_pattern(pattern)))(split_path_segments(path))
|
||||
|
||||
# find-matching-route
|
||||
def find_matching_route(path, routes):
|
||||
_cells = {}
|
||||
path_segs = split_path_segments(path)
|
||||
_cells['result'] = NIL
|
||||
for route in routes:
|
||||
if sx_truthy(is_nil(_cells['result'])):
|
||||
params = match_route_segments(path_segs, get(route, 'parsed'))
|
||||
if sx_truthy((not sx_truthy(is_nil(params)))):
|
||||
matched = merge(route, {})
|
||||
matched['params'] = params
|
||||
_cells['result'] = matched
|
||||
return _cells['result']
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Fixups -- wire up render adapter dispatch
|
||||
# =========================================================================
|
||||
|
||||
597
shared/sx/ref/test.sx
Normal file
597
shared/sx/ref/test.sx
Normal file
@@ -0,0 +1,597 @@
|
||||
;; ==========================================================================
|
||||
;; test.sx — Self-hosting SX test framework
|
||||
;;
|
||||
;; Defines a minimal test framework in SX that tests SX — the language
|
||||
;; proves its own correctness. The framework is self-executing: any host
|
||||
;; that provides 5 platform functions can evaluate this file directly.
|
||||
;;
|
||||
;; Platform functions required:
|
||||
;; try-call (thunk) → {:ok true} | {:ok false :error "msg"}
|
||||
;; report-pass (name) → platform-specific pass output
|
||||
;; report-fail (name error) → platform-specific fail output
|
||||
;; push-suite (name) → push suite name onto context stack
|
||||
;; pop-suite () → pop suite name from context stack
|
||||
;;
|
||||
;; Usage:
|
||||
;; ;; Host injects platform functions into env, then:
|
||||
;; (eval-file "test.sx" env)
|
||||
;;
|
||||
;; The same test.sx runs on every host — Python, JavaScript, etc.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. Test framework macros
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; deftest and defsuite are macros that make test.sx directly executable.
|
||||
;; The host provides try-call (error catching), reporting, and suite
|
||||
;; context — everything else is pure SX.
|
||||
|
||||
(defmacro deftest (name &rest body)
|
||||
`(let ((result (try-call (fn () ,@body))))
|
||||
(if (get result "ok")
|
||||
(report-pass ,name)
|
||||
(report-fail ,name (get result "error")))))
|
||||
|
||||
(defmacro defsuite (name &rest items)
|
||||
`(do (push-suite ,name)
|
||||
,@items
|
||||
(pop-suite)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. Assertion helpers — defined in SX, available in test bodies
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; These are regular functions (not special forms). They use the `assert`
|
||||
;; primitive underneath but provide better error messages.
|
||||
|
||||
(define assert-equal
|
||||
(fn (expected actual)
|
||||
(assert (equal? expected actual)
|
||||
(str "Expected " (str expected) " but got " (str actual)))))
|
||||
|
||||
(define assert-not-equal
|
||||
(fn (a b)
|
||||
(assert (not (equal? a b))
|
||||
(str "Expected values to differ but both are " (str a)))))
|
||||
|
||||
(define assert-true
|
||||
(fn (val)
|
||||
(assert val (str "Expected truthy but got " (str val)))))
|
||||
|
||||
(define assert-false
|
||||
(fn (val)
|
||||
(assert (not val) (str "Expected falsy but got " (str val)))))
|
||||
|
||||
(define assert-nil
|
||||
(fn (val)
|
||||
(assert (nil? val) (str "Expected nil but got " (str val)))))
|
||||
|
||||
(define assert-type
|
||||
(fn (expected-type val)
|
||||
;; Implemented via predicate dispatch since type-of is a platform
|
||||
;; function not available in all hosts. Uses nested if to avoid
|
||||
;; Scheme-style cond detection for 2-element predicate calls.
|
||||
;; Boolean checked before number (subtypes on some platforms).
|
||||
(let ((actual-type
|
||||
(if (nil? val) "nil"
|
||||
(if (boolean? val) "boolean"
|
||||
(if (number? val) "number"
|
||||
(if (string? val) "string"
|
||||
(if (list? val) "list"
|
||||
(if (dict? val) "dict"
|
||||
"unknown"))))))))
|
||||
(assert (= expected-type actual-type)
|
||||
(str "Expected type " expected-type " but got " actual-type)))))
|
||||
|
||||
(define assert-length
|
||||
(fn (expected-len col)
|
||||
(assert (= (len col) expected-len)
|
||||
(str "Expected length " expected-len " but got " (len col)))))
|
||||
|
||||
(define assert-contains
|
||||
(fn (item col)
|
||||
(assert (some (fn (x) (equal? x item)) col)
|
||||
(str "Expected collection to contain " (str item)))))
|
||||
|
||||
(define assert-throws
|
||||
(fn (thunk)
|
||||
(let ((result (try-call thunk)))
|
||||
(assert (not (get result "ok"))
|
||||
"Expected an error to be thrown but none was"))))
|
||||
|
||||
|
||||
;; ==========================================================================
|
||||
;; 3. Test suites — SX testing SX
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3a. Literals and types
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "literals"
|
||||
(deftest "numbers are numbers"
|
||||
(assert-type "number" 42)
|
||||
(assert-type "number" 3.14)
|
||||
(assert-type "number" -1))
|
||||
|
||||
(deftest "strings are strings"
|
||||
(assert-type "string" "hello")
|
||||
(assert-type "string" ""))
|
||||
|
||||
(deftest "booleans are booleans"
|
||||
(assert-type "boolean" true)
|
||||
(assert-type "boolean" false))
|
||||
|
||||
(deftest "nil is nil"
|
||||
(assert-type "nil" nil)
|
||||
(assert-nil nil))
|
||||
|
||||
(deftest "lists are lists"
|
||||
(assert-type "list" (list 1 2 3))
|
||||
(assert-type "list" (list)))
|
||||
|
||||
(deftest "dicts are dicts"
|
||||
(assert-type "dict" {:a 1 :b 2})))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3b. Arithmetic
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "arithmetic"
|
||||
(deftest "addition"
|
||||
(assert-equal 3 (+ 1 2))
|
||||
(assert-equal 0 (+ 0 0))
|
||||
(assert-equal -1 (+ 1 -2))
|
||||
(assert-equal 10 (+ 1 2 3 4)))
|
||||
|
||||
(deftest "subtraction"
|
||||
(assert-equal 1 (- 3 2))
|
||||
(assert-equal -1 (- 2 3)))
|
||||
|
||||
(deftest "multiplication"
|
||||
(assert-equal 6 (* 2 3))
|
||||
(assert-equal 0 (* 0 100))
|
||||
(assert-equal 24 (* 1 2 3 4)))
|
||||
|
||||
(deftest "division"
|
||||
(assert-equal 2 (/ 6 3))
|
||||
(assert-equal 2.5 (/ 5 2)))
|
||||
|
||||
(deftest "modulo"
|
||||
(assert-equal 1 (mod 7 3))
|
||||
(assert-equal 0 (mod 6 3))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3c. Comparison
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "comparison"
|
||||
(deftest "equality"
|
||||
(assert-true (= 1 1))
|
||||
(assert-false (= 1 2))
|
||||
(assert-true (= "a" "a"))
|
||||
(assert-false (= "a" "b")))
|
||||
|
||||
(deftest "deep equality"
|
||||
(assert-true (equal? (list 1 2 3) (list 1 2 3)))
|
||||
(assert-false (equal? (list 1 2) (list 1 3)))
|
||||
(assert-true (equal? {:a 1} {:a 1}))
|
||||
(assert-false (equal? {:a 1} {:a 2})))
|
||||
|
||||
(deftest "ordering"
|
||||
(assert-true (< 1 2))
|
||||
(assert-false (< 2 1))
|
||||
(assert-true (> 2 1))
|
||||
(assert-true (<= 1 1))
|
||||
(assert-true (<= 1 2))
|
||||
(assert-true (>= 2 2))
|
||||
(assert-true (>= 3 2))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3d. String operations
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "strings"
|
||||
(deftest "str concatenation"
|
||||
(assert-equal "abc" (str "a" "b" "c"))
|
||||
(assert-equal "hello world" (str "hello" " " "world"))
|
||||
(assert-equal "42" (str 42))
|
||||
(assert-equal "" (str)))
|
||||
|
||||
(deftest "string-length"
|
||||
(assert-equal 5 (string-length "hello"))
|
||||
(assert-equal 0 (string-length "")))
|
||||
|
||||
(deftest "substring"
|
||||
(assert-equal "ell" (substring "hello" 1 4))
|
||||
(assert-equal "hello" (substring "hello" 0 5)))
|
||||
|
||||
(deftest "string-contains?"
|
||||
(assert-true (string-contains? "hello world" "world"))
|
||||
(assert-false (string-contains? "hello" "xyz")))
|
||||
|
||||
(deftest "upcase and downcase"
|
||||
(assert-equal "HELLO" (upcase "hello"))
|
||||
(assert-equal "hello" (downcase "HELLO")))
|
||||
|
||||
(deftest "trim"
|
||||
(assert-equal "hello" (trim " hello "))
|
||||
(assert-equal "hello" (trim "hello")))
|
||||
|
||||
(deftest "split and join"
|
||||
(assert-equal (list "a" "b" "c") (split "a,b,c" ","))
|
||||
(assert-equal "a-b-c" (join "-" (list "a" "b" "c")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3e. List operations
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "lists"
|
||||
(deftest "constructors"
|
||||
(assert-equal (list 1 2 3) (list 1 2 3))
|
||||
(assert-equal (list) (list))
|
||||
(assert-length 3 (list 1 2 3)))
|
||||
|
||||
(deftest "first and rest"
|
||||
(assert-equal 1 (first (list 1 2 3)))
|
||||
(assert-equal (list 2 3) (rest (list 1 2 3)))
|
||||
(assert-nil (first (list)))
|
||||
(assert-equal (list) (rest (list))))
|
||||
|
||||
(deftest "nth"
|
||||
(assert-equal 1 (nth (list 1 2 3) 0))
|
||||
(assert-equal 2 (nth (list 1 2 3) 1))
|
||||
(assert-equal 3 (nth (list 1 2 3) 2)))
|
||||
|
||||
(deftest "last"
|
||||
(assert-equal 3 (last (list 1 2 3)))
|
||||
(assert-nil (last (list))))
|
||||
|
||||
(deftest "cons and append"
|
||||
(assert-equal (list 0 1 2) (cons 0 (list 1 2)))
|
||||
(assert-equal (list 1 2 3 4) (append (list 1 2) (list 3 4))))
|
||||
|
||||
(deftest "reverse"
|
||||
(assert-equal (list 3 2 1) (reverse (list 1 2 3)))
|
||||
(assert-equal (list) (reverse (list))))
|
||||
|
||||
(deftest "empty?"
|
||||
(assert-true (empty? (list)))
|
||||
(assert-false (empty? (list 1))))
|
||||
|
||||
(deftest "len"
|
||||
(assert-equal 0 (len (list)))
|
||||
(assert-equal 3 (len (list 1 2 3))))
|
||||
|
||||
(deftest "contains?"
|
||||
(assert-true (contains? (list 1 2 3) 2))
|
||||
(assert-false (contains? (list 1 2 3) 4)))
|
||||
|
||||
(deftest "flatten"
|
||||
(assert-equal (list 1 2 3 4) (flatten (list (list 1 2) (list 3 4))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3f. Dict operations
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "dicts"
|
||||
(deftest "dict literal"
|
||||
(assert-type "dict" {:a 1 :b 2})
|
||||
(assert-equal 1 (get {:a 1} "a"))
|
||||
(assert-equal 2 (get {:a 1 :b 2} "b")))
|
||||
|
||||
(deftest "assoc"
|
||||
(assert-equal {:a 1 :b 2} (assoc {:a 1} "b" 2))
|
||||
(assert-equal {:a 99} (assoc {:a 1} "a" 99)))
|
||||
|
||||
(deftest "dissoc"
|
||||
(assert-equal {:b 2} (dissoc {:a 1 :b 2} "a")))
|
||||
|
||||
(deftest "keys and vals"
|
||||
(let ((d {:a 1 :b 2}))
|
||||
(assert-length 2 (keys d))
|
||||
(assert-length 2 (vals d))
|
||||
(assert-contains "a" (keys d))
|
||||
(assert-contains "b" (keys d))))
|
||||
|
||||
(deftest "has-key?"
|
||||
(assert-true (has-key? {:a 1} "a"))
|
||||
(assert-false (has-key? {:a 1} "b")))
|
||||
|
||||
(deftest "merge"
|
||||
(assert-equal {:a 1 :b 2 :c 3}
|
||||
(merge {:a 1 :b 2} {:c 3}))
|
||||
(assert-equal {:a 99 :b 2}
|
||||
(merge {:a 1 :b 2} {:a 99}))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3g. Predicates
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "predicates"
|
||||
(deftest "nil?"
|
||||
(assert-true (nil? nil))
|
||||
(assert-false (nil? 0))
|
||||
(assert-false (nil? false))
|
||||
(assert-false (nil? "")))
|
||||
|
||||
(deftest "number?"
|
||||
(assert-true (number? 42))
|
||||
(assert-true (number? 3.14))
|
||||
(assert-false (number? "42")))
|
||||
|
||||
(deftest "string?"
|
||||
(assert-true (string? "hello"))
|
||||
(assert-false (string? 42)))
|
||||
|
||||
(deftest "list?"
|
||||
(assert-true (list? (list 1 2)))
|
||||
(assert-false (list? "not a list")))
|
||||
|
||||
(deftest "dict?"
|
||||
(assert-true (dict? {:a 1}))
|
||||
(assert-false (dict? (list 1))))
|
||||
|
||||
(deftest "boolean?"
|
||||
(assert-true (boolean? true))
|
||||
(assert-true (boolean? false))
|
||||
(assert-false (boolean? nil))
|
||||
(assert-false (boolean? 0)))
|
||||
|
||||
(deftest "not"
|
||||
(assert-true (not false))
|
||||
(assert-true (not nil))
|
||||
(assert-false (not true))
|
||||
(assert-false (not 1))
|
||||
(assert-false (not "x"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3h. Special forms
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "special-forms"
|
||||
(deftest "if"
|
||||
(assert-equal "yes" (if true "yes" "no"))
|
||||
(assert-equal "no" (if false "yes" "no"))
|
||||
(assert-equal "no" (if nil "yes" "no"))
|
||||
(assert-nil (if false "yes")))
|
||||
|
||||
(deftest "when"
|
||||
(assert-equal "yes" (when true "yes"))
|
||||
(assert-nil (when false "yes")))
|
||||
|
||||
(deftest "cond"
|
||||
(assert-equal "a" (cond true "a" :else "b"))
|
||||
(assert-equal "b" (cond false "a" :else "b"))
|
||||
(assert-equal "c" (cond
|
||||
false "a"
|
||||
false "b"
|
||||
:else "c")))
|
||||
|
||||
(deftest "and"
|
||||
(assert-true (and true true))
|
||||
(assert-false (and true false))
|
||||
(assert-false (and false true))
|
||||
(assert-equal 3 (and 1 2 3)))
|
||||
|
||||
(deftest "or"
|
||||
(assert-equal 1 (or 1 2))
|
||||
(assert-equal 2 (or false 2))
|
||||
(assert-equal "fallback" (or nil false "fallback"))
|
||||
(assert-false (or false false)))
|
||||
|
||||
(deftest "let"
|
||||
(assert-equal 3 (let ((x 1) (y 2)) (+ x y)))
|
||||
(assert-equal "hello world"
|
||||
(let ((a "hello") (b " world")) (str a b))))
|
||||
|
||||
(deftest "let clojure-style"
|
||||
(assert-equal 3 (let (x 1 y 2) (+ x y))))
|
||||
|
||||
(deftest "do / begin"
|
||||
(assert-equal 3 (do 1 2 3))
|
||||
(assert-equal "last" (begin "first" "middle" "last")))
|
||||
|
||||
(deftest "define"
|
||||
(define x 42)
|
||||
(assert-equal 42 x))
|
||||
|
||||
(deftest "set!"
|
||||
(define x 1)
|
||||
(set! x 2)
|
||||
(assert-equal 2 x)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3i. Lambda and closures
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "lambdas"
|
||||
(deftest "basic lambda"
|
||||
(let ((add (fn (a b) (+ a b))))
|
||||
(assert-equal 3 (add 1 2))))
|
||||
|
||||
(deftest "closure captures env"
|
||||
(let ((x 10))
|
||||
(let ((add-x (fn (y) (+ x y))))
|
||||
(assert-equal 15 (add-x 5)))))
|
||||
|
||||
(deftest "lambda as argument"
|
||||
(assert-equal (list 2 4 6)
|
||||
(map (fn (x) (* x 2)) (list 1 2 3))))
|
||||
|
||||
(deftest "recursive lambda via define"
|
||||
(define factorial
|
||||
(fn (n) (if (<= n 1) 1 (* n (factorial (- n 1))))))
|
||||
(assert-equal 120 (factorial 5)))
|
||||
|
||||
(deftest "higher-order returns lambda"
|
||||
(let ((make-adder (fn (n) (fn (x) (+ n x)))))
|
||||
(let ((add5 (make-adder 5)))
|
||||
(assert-equal 8 (add5 3))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3j. Higher-order forms
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "higher-order"
|
||||
(deftest "map"
|
||||
(assert-equal (list 2 4 6)
|
||||
(map (fn (x) (* x 2)) (list 1 2 3)))
|
||||
(assert-equal (list) (map (fn (x) x) (list))))
|
||||
|
||||
(deftest "filter"
|
||||
(assert-equal (list 2 4)
|
||||
(filter (fn (x) (= (mod x 2) 0)) (list 1 2 3 4)))
|
||||
(assert-equal (list)
|
||||
(filter (fn (x) false) (list 1 2 3))))
|
||||
|
||||
(deftest "reduce"
|
||||
(assert-equal 10 (reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3 4)))
|
||||
(assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list))))
|
||||
|
||||
(deftest "some"
|
||||
(assert-true (some (fn (x) (> x 3)) (list 1 2 3 4 5)))
|
||||
(assert-false (some (fn (x) (> x 10)) (list 1 2 3))))
|
||||
|
||||
(deftest "every?"
|
||||
(assert-true (every? (fn (x) (> x 0)) (list 1 2 3)))
|
||||
(assert-false (every? (fn (x) (> x 2)) (list 1 2 3))))
|
||||
|
||||
(deftest "map-indexed"
|
||||
(assert-equal (list "0:a" "1:b" "2:c")
|
||||
(map-indexed (fn (i x) (str i ":" x)) (list "a" "b" "c")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3k. Components
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "components"
|
||||
(deftest "defcomp creates component"
|
||||
(defcomp ~test-comp (&key title)
|
||||
(div title))
|
||||
;; Component is bound and not nil
|
||||
(assert-true (not (nil? ~test-comp))))
|
||||
|
||||
(deftest "component renders with keyword args"
|
||||
(defcomp ~greeting (&key name)
|
||||
(span (str "Hello, " name "!")))
|
||||
(assert-true (not (nil? ~greeting))))
|
||||
|
||||
(deftest "component with children"
|
||||
(defcomp ~box (&key &rest children)
|
||||
(div :class "box" children))
|
||||
(assert-true (not (nil? ~box))))
|
||||
|
||||
(deftest "component with default via or"
|
||||
(defcomp ~label (&key text)
|
||||
(span (or text "default")))
|
||||
(assert-true (not (nil? ~label)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3l. Macros
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "macros"
|
||||
(deftest "defmacro creates macro"
|
||||
(defmacro unless (cond &rest body)
|
||||
`(if (not ,cond) (do ,@body)))
|
||||
(assert-equal "yes" (unless false "yes"))
|
||||
(assert-nil (unless true "no")))
|
||||
|
||||
(deftest "quasiquote and unquote"
|
||||
(let ((x 42))
|
||||
(assert-equal (list 1 42 3) `(1 ,x 3))))
|
||||
|
||||
(deftest "splice-unquote"
|
||||
(let ((xs (list 2 3 4)))
|
||||
(assert-equal (list 1 2 3 4 5) `(1 ,@xs 5)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3m. Threading macro
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "threading"
|
||||
(deftest "thread-first"
|
||||
(assert-equal 8 (-> 5 (+ 1) (+ 2)))
|
||||
(assert-equal "HELLO" (-> "hello" upcase))
|
||||
(assert-equal "HELLO WORLD"
|
||||
(-> "hello"
|
||||
(str " world")
|
||||
upcase))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3n. Truthiness
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "truthiness"
|
||||
(deftest "truthy values"
|
||||
(assert-true (if 1 true false))
|
||||
(assert-true (if "x" true false))
|
||||
(assert-true (if (list 1) true false))
|
||||
(assert-true (if true true false)))
|
||||
|
||||
(deftest "falsy values"
|
||||
(assert-false (if false true false))
|
||||
(assert-false (if nil true false)))
|
||||
|
||||
;; NOTE: empty list, zero, and empty string truthiness is
|
||||
;; platform-dependent. Python treats all three as falsy.
|
||||
;; JavaScript treats [] as truthy but 0 and "" as falsy.
|
||||
;; These tests are omitted — each bootstrapper should emit
|
||||
;; platform-specific truthiness tests instead.
|
||||
)
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3o. Edge cases and regression tests
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "edge-cases"
|
||||
(deftest "nested let scoping"
|
||||
(let ((x 1))
|
||||
(let ((x 2))
|
||||
(assert-equal 2 x))
|
||||
;; outer x should be unchanged by inner let
|
||||
;; (this tests that let creates a new scope)
|
||||
))
|
||||
|
||||
(deftest "recursive map"
|
||||
(assert-equal (list (list 2 4) (list 6 8))
|
||||
(map (fn (sub) (map (fn (x) (* x 2)) sub))
|
||||
(list (list 1 2) (list 3 4)))))
|
||||
|
||||
(deftest "keyword as value"
|
||||
(assert-equal "class" :class)
|
||||
(assert-equal "id" :id))
|
||||
|
||||
(deftest "dict with evaluated values"
|
||||
(let ((x 42))
|
||||
(assert-equal 42 (get {:val x} "val"))))
|
||||
|
||||
(deftest "nil propagation"
|
||||
(assert-nil (get {:a 1} "missing"))
|
||||
(assert-equal "default" (or (get {:a 1} "missing") "default")))
|
||||
|
||||
(deftest "empty operations"
|
||||
(assert-equal (list) (map (fn (x) x) (list)))
|
||||
(assert-equal (list) (filter (fn (x) true) (list)))
|
||||
(assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list)))
|
||||
(assert-equal 0 (len (list)))
|
||||
(assert-equal "" (str))))
|
||||
108
shared/sx/tests/run.js
Normal file
108
shared/sx/tests/run.js
Normal file
@@ -0,0 +1,108 @@
|
||||
// Run test.sx directly against sx-browser.js.
|
||||
//
|
||||
// sx-browser.js parses and evaluates test.sx — SX tests itself.
|
||||
// This script provides only platform functions (error catching, reporting).
|
||||
//
|
||||
// Usage: node shared/sx/tests/run.js
|
||||
|
||||
Object.defineProperty(globalThis, "document", { value: undefined, writable: true });
|
||||
var path = require("path");
|
||||
var fs = require("fs");
|
||||
var Sx = require(path.resolve(__dirname, "../../static/scripts/sx-browser.js"));
|
||||
|
||||
// --- Test state ---
|
||||
var suiteStack = [];
|
||||
var passed = 0, failed = 0, testNum = 0;
|
||||
|
||||
// --- Helpers ---
|
||||
function isNil(x) { return x === Sx.NIL || x === null || x === undefined; }
|
||||
|
||||
function deepEqual(a, b) {
|
||||
if (a === b) return true;
|
||||
if (isNil(a) && isNil(b)) return true;
|
||||
if (typeof a !== typeof b) return false;
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false;
|
||||
return true;
|
||||
}
|
||||
if (a && typeof a === "object" && b && typeof b === "object") {
|
||||
var ka = Object.keys(a), kb = Object.keys(b);
|
||||
if (ka.length !== kb.length) return false;
|
||||
for (var j = 0; j < ka.length; j++) if (!deepEqual(a[ka[j]], b[ka[j]])) return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Platform functions injected into the SX env ---
|
||||
var env = {
|
||||
// Error catching — calls an SX thunk, returns result dict
|
||||
"try-call": function(thunk) {
|
||||
try {
|
||||
Sx.eval([thunk], env);
|
||||
return { ok: true };
|
||||
} catch(e) {
|
||||
return { ok: false, error: e.message || String(e) };
|
||||
}
|
||||
},
|
||||
|
||||
// Test reporting
|
||||
"report-pass": function(name) {
|
||||
testNum++;
|
||||
passed++;
|
||||
var fullName = suiteStack.concat([name]).join(" > ");
|
||||
console.log("ok " + testNum + " - " + fullName);
|
||||
},
|
||||
"report-fail": function(name, error) {
|
||||
testNum++;
|
||||
failed++;
|
||||
var fullName = suiteStack.concat([name]).join(" > ");
|
||||
console.log("not ok " + testNum + " - " + fullName);
|
||||
console.log(" # " + error);
|
||||
},
|
||||
|
||||
// Suite context
|
||||
"push-suite": function(name) { suiteStack.push(name); },
|
||||
"pop-suite": function() { suiteStack.pop(); },
|
||||
|
||||
// Primitives that sx-browser.js has internally but doesn't expose through env
|
||||
"equal?": function(a, b) { return deepEqual(a, b); },
|
||||
"eq?": function(a, b) { return a === b; },
|
||||
"boolean?": function(x) { return typeof x === "boolean"; },
|
||||
"string-length": function(s) { return String(s).length; },
|
||||
"substring": function(s, start, end) { return String(s).slice(start, end); },
|
||||
"string-contains?": function(s, needle) { return String(s).indexOf(needle) !== -1; },
|
||||
"upcase": function(s) { return String(s).toUpperCase(); },
|
||||
"downcase": function(s) { return String(s).toLowerCase(); },
|
||||
"reverse": function(c) { return c ? c.slice().reverse() : []; },
|
||||
"flatten": function(c) {
|
||||
var r = [];
|
||||
for (var i = 0; i < (c||[]).length; i++) {
|
||||
if (Array.isArray(c[i])) for (var j = 0; j < c[i].length; j++) r.push(c[i][j]);
|
||||
else r.push(c[i]);
|
||||
}
|
||||
return r;
|
||||
},
|
||||
"has-key?": function(d, k) { return d && typeof d === "object" && k in d; },
|
||||
"append": function(c, x) { return Array.isArray(x) ? (c||[]).concat(x) : (c||[]).concat([x]); },
|
||||
};
|
||||
|
||||
// --- Read and evaluate test.sx ---
|
||||
var src = fs.readFileSync(path.resolve(__dirname, "../ref/test.sx"), "utf8");
|
||||
var exprs = Sx.parseAll(src);
|
||||
|
||||
console.log("TAP version 13");
|
||||
for (var i = 0; i < exprs.length; i++) {
|
||||
Sx.eval(exprs[i], env);
|
||||
}
|
||||
|
||||
// --- Summary ---
|
||||
console.log("");
|
||||
console.log("1.." + testNum);
|
||||
console.log("# tests " + (passed + failed));
|
||||
console.log("# pass " + passed);
|
||||
if (failed > 0) {
|
||||
console.log("# fail " + failed);
|
||||
process.exit(1);
|
||||
}
|
||||
92
shared/sx/tests/run.py
Normal file
92
shared/sx/tests/run.py
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run test.sx directly against the Python SX evaluator.
|
||||
|
||||
The Python evaluator parses and evaluates test.sx — SX tests itself.
|
||||
This script provides only platform functions (error catching, reporting).
|
||||
|
||||
Usage: python shared/sx/tests/run.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", ".."))
|
||||
sys.path.insert(0, _PROJECT)
|
||||
|
||||
from shared.sx.parser import parse_all
|
||||
from shared.sx.evaluator import _eval, _trampoline
|
||||
|
||||
# --- Test state ---
|
||||
suite_stack: list[str] = []
|
||||
passed = 0
|
||||
failed = 0
|
||||
test_num = 0
|
||||
|
||||
|
||||
def try_call(thunk):
|
||||
"""Call an SX thunk, catching errors."""
|
||||
try:
|
||||
_trampoline(_eval([thunk], {}))
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def report_pass(name):
|
||||
global passed, test_num
|
||||
test_num += 1
|
||||
passed += 1
|
||||
full_name = " > ".join(suite_stack + [name])
|
||||
print(f"ok {test_num} - {full_name}")
|
||||
|
||||
|
||||
def report_fail(name, error):
|
||||
global failed, test_num
|
||||
test_num += 1
|
||||
failed += 1
|
||||
full_name = " > ".join(suite_stack + [name])
|
||||
print(f"not ok {test_num} - {full_name}")
|
||||
print(f" # {error}")
|
||||
|
||||
|
||||
def push_suite(name):
|
||||
suite_stack.append(name)
|
||||
|
||||
|
||||
def pop_suite():
|
||||
suite_stack.pop()
|
||||
|
||||
|
||||
def main():
|
||||
env = {
|
||||
"try-call": try_call,
|
||||
"report-pass": report_pass,
|
||||
"report-fail": report_fail,
|
||||
"push-suite": push_suite,
|
||||
"pop-suite": pop_suite,
|
||||
}
|
||||
|
||||
test_sx = os.path.join(_HERE, "..", "ref", "test.sx")
|
||||
with open(test_sx) as f:
|
||||
src = f.read()
|
||||
|
||||
exprs = parse_all(src)
|
||||
|
||||
print("TAP version 13")
|
||||
for expr in exprs:
|
||||
_trampoline(_eval(expr, env))
|
||||
|
||||
print()
|
||||
print(f"1..{test_num}")
|
||||
print(f"# tests {passed + failed}")
|
||||
print(f"# pass {passed}")
|
||||
if failed > 0:
|
||||
print(f"# fail {failed}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
63
shared/sx/tests/test_bootstrapper.py
Normal file
63
shared/sx/tests/test_bootstrapper.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Test bootstrapper transpilation: JSEmitter and PyEmitter."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from shared.sx.parser import parse
|
||||
from shared.sx.ref.bootstrap_js import JSEmitter
|
||||
from shared.sx.ref.bootstrap_py import PyEmitter
|
||||
|
||||
|
||||
class TestJSEmitterNativeDict:
|
||||
"""JS bootstrapper must handle native Python dicts from {:key val} syntax."""
|
||||
|
||||
def test_simple_string_values(self):
|
||||
expr = parse('{"name" "hello"}')
|
||||
assert isinstance(expr, dict)
|
||||
js = JSEmitter().emit(expr)
|
||||
assert js == '{"name": "hello"}'
|
||||
|
||||
def test_function_call_value(self):
|
||||
"""Dict value containing a function call must emit the call, not raw AST."""
|
||||
expr = parse('{"parsed" (parse-route-pattern (get page "path"))}')
|
||||
js = JSEmitter().emit(expr)
|
||||
assert "parseRoutePattern" in js
|
||||
assert "Symbol" not in js
|
||||
assert js == '{"parsed": parseRoutePattern(get(page, "path"))}'
|
||||
|
||||
def test_multiple_keys(self):
|
||||
expr = parse('{"a" 1 "b" (+ x 2)}')
|
||||
js = JSEmitter().emit(expr)
|
||||
assert '"a": 1' in js
|
||||
assert '"b": (x + 2)' in js
|
||||
|
||||
def test_nested_dict(self):
|
||||
expr = parse('{"outer" {"inner" 42}}')
|
||||
js = JSEmitter().emit(expr)
|
||||
assert '{"outer": {"inner": 42}}' == js
|
||||
|
||||
def test_nil_value(self):
|
||||
expr = parse('{"key" nil}')
|
||||
js = JSEmitter().emit(expr)
|
||||
assert '"key": NIL' in js
|
||||
|
||||
|
||||
class TestPyEmitterNativeDict:
|
||||
"""Python bootstrapper must handle native Python dicts from {:key val} syntax."""
|
||||
|
||||
def test_simple_string_values(self):
|
||||
expr = parse('{"name" "hello"}')
|
||||
py = PyEmitter().emit(expr)
|
||||
assert py == "{'name': 'hello'}"
|
||||
|
||||
def test_function_call_value(self):
|
||||
"""Dict value containing a function call must emit the call, not raw AST."""
|
||||
expr = parse('{"parsed" (parse-route-pattern (get page "path"))}')
|
||||
py = PyEmitter().emit(expr)
|
||||
assert "parse_route_pattern" in py
|
||||
assert "Symbol" not in py
|
||||
|
||||
def test_multiple_keys(self):
|
||||
expr = parse('{"a" 1 "b" (+ x 2)}')
|
||||
py = PyEmitter().emit(expr)
|
||||
assert "'a': 1" in py
|
||||
assert "'b': (x + 2)" in py
|
||||
392
shared/sx/tests/test_io_detection.py
Normal file
392
shared/sx/tests/test_io_detection.py
Normal file
@@ -0,0 +1,392 @@
|
||||
"""Tests for Phase 2 IO detection — component purity analysis.
|
||||
|
||||
Tests both the hand-written fallback (deps.py) and the bootstrapped
|
||||
sx_ref.py implementation of IO reference scanning and transitive
|
||||
IO classification.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from shared.sx.parser import parse_all
|
||||
from shared.sx.types import Component, Macro, Symbol
|
||||
from shared.sx.deps import (
|
||||
_scan_io_refs_fallback,
|
||||
_transitive_io_refs_fallback,
|
||||
_compute_all_io_refs_fallback,
|
||||
compute_all_io_refs,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_env(*sx_sources: str) -> dict:
|
||||
"""Parse and evaluate component definitions into an env dict."""
|
||||
from shared.sx.evaluator import _eval, _trampoline
|
||||
env: dict = {}
|
||||
for source in sx_sources:
|
||||
exprs = parse_all(source)
|
||||
for expr in exprs:
|
||||
_trampoline(_eval(expr, env))
|
||||
return env
|
||||
|
||||
|
||||
IO_NAMES = {"fetch-data", "call-action", "app-url", "config", "db-query"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _scan_io_refs_fallback — scan single AST for IO primitives
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestScanIoRefs:
|
||||
def test_no_io_refs(self):
|
||||
env = make_env('(defcomp ~card (&key title) (div :class "p-4" title))')
|
||||
comp = env["~card"]
|
||||
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
|
||||
assert refs == set()
|
||||
|
||||
def test_direct_io_ref(self):
|
||||
env = make_env('(defcomp ~page (&key) (div (fetch-data "posts")))')
|
||||
comp = env["~page"]
|
||||
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
|
||||
assert refs == {"fetch-data"}
|
||||
|
||||
def test_multiple_io_refs(self):
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (fetch-data "x") (config "y")))'
|
||||
)
|
||||
comp = env["~page"]
|
||||
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
|
||||
assert refs == {"fetch-data", "config"}
|
||||
|
||||
def test_io_in_nested_control_flow(self):
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key show) '
|
||||
' (if show (div (app-url "/")) (span "none")))'
|
||||
)
|
||||
comp = env["~page"]
|
||||
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
|
||||
assert refs == {"app-url"}
|
||||
|
||||
def test_io_in_dict_value(self):
|
||||
env = make_env(
|
||||
'(defcomp ~wrap (&key) (div {:data (db-query "x")}))'
|
||||
)
|
||||
comp = env["~wrap"]
|
||||
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
|
||||
assert refs == {"db-query"}
|
||||
|
||||
def test_non_io_symbol_ignored(self):
|
||||
"""Symbols that aren't in the IO set should not be detected."""
|
||||
env = make_env(
|
||||
'(defcomp ~card (&key) (div (str "hello") (len "world")))'
|
||||
)
|
||||
comp = env["~card"]
|
||||
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
|
||||
assert refs == set()
|
||||
|
||||
def test_component_ref_not_io(self):
|
||||
"""Component references (~name) should not appear as IO refs."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~card :title "hi")))',
|
||||
'(defcomp ~card (&key title) (div title))',
|
||||
)
|
||||
comp = env["~page"]
|
||||
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
|
||||
assert refs == set()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _transitive_io_refs_fallback — follow deps to find all IO refs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTransitiveIoRefs:
|
||||
def test_pure_component(self):
|
||||
env = make_env(
|
||||
'(defcomp ~card (&key title) (div title))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~card", env, IO_NAMES)
|
||||
assert refs == set()
|
||||
|
||||
def test_direct_io(self):
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (fetch-data "posts")))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
|
||||
assert refs == {"fetch-data"}
|
||||
|
||||
def test_transitive_io_through_dep(self):
|
||||
"""IO ref in a dependency should propagate to the parent."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~nav)))',
|
||||
'(defcomp ~nav (&key) (nav (app-url "/home")))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
|
||||
assert refs == {"app-url"}
|
||||
|
||||
def test_multiple_transitive_io(self):
|
||||
"""IO refs from multiple deps should be unioned."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~header) (~footer)))',
|
||||
'(defcomp ~header (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~footer (&key) (footer (config "site-name")))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
|
||||
assert refs == {"app-url", "config"}
|
||||
|
||||
def test_deep_transitive_io(self):
|
||||
"""IO refs should propagate through multiple levels."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~layout)))',
|
||||
'(defcomp ~layout (&key) (div (~sidebar)))',
|
||||
'(defcomp ~sidebar (&key) (nav (fetch-data "menu")))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
|
||||
assert refs == {"fetch-data"}
|
||||
|
||||
def test_circular_deps_no_infinite_loop(self):
|
||||
"""Circular component references should not cause infinite recursion."""
|
||||
env = make_env(
|
||||
'(defcomp ~a (&key) (div (~b) (app-url "/")))',
|
||||
'(defcomp ~b (&key) (div (~a)))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~a", env, IO_NAMES)
|
||||
assert refs == {"app-url"}
|
||||
|
||||
def test_without_tilde_prefix(self):
|
||||
"""Should auto-add ~ prefix when not provided."""
|
||||
env = make_env(
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("nav", env, IO_NAMES)
|
||||
assert refs == {"app-url"}
|
||||
|
||||
def test_missing_dep_component(self):
|
||||
"""Referencing a component not in env should not crash."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~unknown) (fetch-data "x")))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
|
||||
assert refs == {"fetch-data"}
|
||||
|
||||
def test_macro_io_detection(self):
|
||||
"""IO refs in macros should be detected too."""
|
||||
env = make_env(
|
||||
'(defmacro ~with-data (body) (list (quote div) (list (quote fetch-data) "x") body))',
|
||||
'(defcomp ~page (&key) (div (~with-data (span "hi"))))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
|
||||
assert "fetch-data" in refs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _compute_all_io_refs_fallback — batch computation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComputeAllIoRefs:
|
||||
def test_sets_io_refs_on_components(self):
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~nav) (fetch-data "x")))',
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~card (&key title) (div title))',
|
||||
)
|
||||
_compute_all_io_refs_fallback(env, IO_NAMES)
|
||||
assert env["~page"].io_refs == {"fetch-data", "app-url"}
|
||||
assert env["~nav"].io_refs == {"app-url"}
|
||||
assert env["~card"].io_refs == set()
|
||||
|
||||
def test_pure_components_get_empty_set(self):
|
||||
env = make_env(
|
||||
'(defcomp ~a (&key) (div "hello"))',
|
||||
'(defcomp ~b (&key) (span "world"))',
|
||||
)
|
||||
_compute_all_io_refs_fallback(env, IO_NAMES)
|
||||
assert env["~a"].io_refs == set()
|
||||
assert env["~b"].io_refs == set()
|
||||
|
||||
def test_transitive_io_via_compute_all(self):
|
||||
"""Transitive IO refs should be cached on the parent component."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~child)))',
|
||||
'(defcomp ~child (&key) (div (config "key")))',
|
||||
)
|
||||
_compute_all_io_refs_fallback(env, IO_NAMES)
|
||||
assert env["~page"].io_refs == {"config"}
|
||||
assert env["~child"].io_refs == {"config"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API dispatch — compute_all_io_refs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPublicApiIoRefs:
|
||||
def test_fallback_mode(self):
|
||||
"""Public API should work in fallback mode (SX_USE_REF != 1)."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (fetch-data "x")))',
|
||||
'(defcomp ~leaf (&key) (span "pure"))',
|
||||
)
|
||||
old_val = os.environ.get("SX_USE_REF")
|
||||
try:
|
||||
os.environ.pop("SX_USE_REF", None)
|
||||
compute_all_io_refs(env, IO_NAMES)
|
||||
assert env["~page"].io_refs == {"fetch-data"}
|
||||
assert env["~leaf"].io_refs == set()
|
||||
finally:
|
||||
if old_val is not None:
|
||||
os.environ["SX_USE_REF"] = old_val
|
||||
|
||||
def test_ref_mode(self):
|
||||
"""Public API should work with bootstrapped sx_ref.py (SX_USE_REF=1)."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (fetch-data "x")))',
|
||||
'(defcomp ~leaf (&key) (span "pure"))',
|
||||
)
|
||||
old_val = os.environ.get("SX_USE_REF")
|
||||
try:
|
||||
os.environ["SX_USE_REF"] = "1"
|
||||
compute_all_io_refs(env, IO_NAMES)
|
||||
# sx_ref returns lists, compute_all_io_refs converts as needed
|
||||
page_refs = env["~page"].io_refs
|
||||
leaf_refs = env["~leaf"].io_refs
|
||||
# May be list or set depending on backend
|
||||
assert "fetch-data" in page_refs
|
||||
assert len(leaf_refs) == 0
|
||||
finally:
|
||||
if old_val is not None:
|
||||
os.environ["SX_USE_REF"] = old_val
|
||||
else:
|
||||
os.environ.pop("SX_USE_REF", None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bootstrapped sx_ref.py IO functions — direct testing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSxRefIoFunctions:
|
||||
"""Test the bootstrapped sx_ref.py IO functions directly."""
|
||||
|
||||
def test_scan_io_refs(self):
|
||||
from shared.sx.ref.sx_ref import scan_io_refs
|
||||
env = make_env('(defcomp ~page (&key) (div (fetch-data "x") (config "y")))')
|
||||
comp = env["~page"]
|
||||
refs = scan_io_refs(comp.body, list(IO_NAMES))
|
||||
assert set(refs) == {"fetch-data", "config"}
|
||||
|
||||
def test_scan_io_refs_no_match(self):
|
||||
from shared.sx.ref.sx_ref import scan_io_refs
|
||||
env = make_env('(defcomp ~card (&key title) (div title))')
|
||||
comp = env["~card"]
|
||||
refs = scan_io_refs(comp.body, list(IO_NAMES))
|
||||
assert refs == []
|
||||
|
||||
def test_transitive_io_refs(self):
|
||||
from shared.sx.ref.sx_ref import transitive_io_refs
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~nav)))',
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
)
|
||||
refs = transitive_io_refs("~page", env, list(IO_NAMES))
|
||||
assert set(refs) == {"app-url"}
|
||||
|
||||
def test_transitive_io_refs_pure(self):
|
||||
from shared.sx.ref.sx_ref import transitive_io_refs
|
||||
env = make_env('(defcomp ~card (&key) (div "hi"))')
|
||||
refs = transitive_io_refs("~card", env, list(IO_NAMES))
|
||||
assert refs == []
|
||||
|
||||
def test_compute_all_io_refs(self):
|
||||
from shared.sx.ref.sx_ref import compute_all_io_refs as ref_compute
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~nav) (fetch-data "x")))',
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~card (&key) (div "pure"))',
|
||||
)
|
||||
ref_compute(env, list(IO_NAMES))
|
||||
page_refs = env["~page"].io_refs
|
||||
nav_refs = env["~nav"].io_refs
|
||||
card_refs = env["~card"].io_refs
|
||||
assert "fetch-data" in page_refs
|
||||
assert "app-url" in page_refs
|
||||
assert "app-url" in nav_refs
|
||||
assert len(card_refs) == 0
|
||||
|
||||
def test_component_pure_p(self):
|
||||
from shared.sx.ref.sx_ref import component_pure_p
|
||||
env = make_env(
|
||||
'(defcomp ~pure-card (&key) (div "hello"))',
|
||||
'(defcomp ~io-card (&key) (div (fetch-data "x")))',
|
||||
)
|
||||
io_list = list(IO_NAMES)
|
||||
assert component_pure_p("~pure-card", env, io_list) is True
|
||||
assert component_pure_p("~io-card", env, io_list) is False
|
||||
|
||||
def test_component_pure_p_transitive(self):
|
||||
"""A component is impure if any transitive dep uses IO."""
|
||||
from shared.sx.ref.sx_ref import component_pure_p
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~child)))',
|
||||
'(defcomp ~child (&key) (div (config "key")))',
|
||||
)
|
||||
io_list = list(IO_NAMES)
|
||||
assert component_pure_p("~page", env, io_list) is False
|
||||
assert component_pure_p("~child", env, io_list) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parity: fallback vs bootstrapped produce same results
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFallbackVsRefParity:
|
||||
"""Ensure fallback Python and bootstrapped sx_ref.py agree."""
|
||||
|
||||
def _check_parity(self, *sx_sources: str):
|
||||
"""Run both implementations and verify io_refs match."""
|
||||
from shared.sx.ref.sx_ref import compute_all_io_refs as ref_compute
|
||||
|
||||
# Run fallback
|
||||
env_fb = make_env(*sx_sources)
|
||||
_compute_all_io_refs_fallback(env_fb, IO_NAMES)
|
||||
|
||||
# Run bootstrapped
|
||||
env_ref = make_env(*sx_sources)
|
||||
ref_compute(env_ref, list(IO_NAMES))
|
||||
|
||||
# Compare all components
|
||||
for key in env_fb:
|
||||
if isinstance(env_fb[key], Component):
|
||||
fb_refs = env_fb[key].io_refs or set()
|
||||
ref_refs = env_ref[key].io_refs
|
||||
# Normalize: fallback returns set, ref returns list/set
|
||||
assert set(fb_refs) == set(ref_refs), (
|
||||
f"Mismatch for {key}: fallback={fb_refs}, ref={set(ref_refs)}"
|
||||
)
|
||||
|
||||
def test_parity_pure_components(self):
|
||||
self._check_parity(
|
||||
'(defcomp ~a (&key) (div "hello"))',
|
||||
'(defcomp ~b (&key) (span (~a)))',
|
||||
)
|
||||
|
||||
def test_parity_io_components(self):
|
||||
self._check_parity(
|
||||
'(defcomp ~page (&key) (div (~header) (fetch-data "x")))',
|
||||
'(defcomp ~header (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~footer (&key) (footer "static"))',
|
||||
)
|
||||
|
||||
def test_parity_deep_chain(self):
|
||||
self._check_parity(
|
||||
'(defcomp ~a (&key) (div (~b)))',
|
||||
'(defcomp ~b (&key) (div (~c)))',
|
||||
'(defcomp ~c (&key) (div (config "x")))',
|
||||
)
|
||||
|
||||
def test_parity_mixed(self):
|
||||
self._check_parity(
|
||||
'(defcomp ~layout (&key) (div (~nav) (~content) (~footer)))',
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~content (&key) (main "pure content"))',
|
||||
'(defcomp ~footer (&key) (footer (config "name")))',
|
||||
)
|
||||
387
shared/sx/tests/test_io_proxy.py
Normal file
387
shared/sx/tests/test_io_proxy.py
Normal file
@@ -0,0 +1,387 @@
|
||||
"""Tests for Phase 5 async IO proxy infrastructure.
|
||||
|
||||
Tests the io-deps page registry field, SxExpr serialization through
|
||||
the IO proxy pipeline, dynamic allowlist construction, and the
|
||||
orchestration.sx routing logic for IO-dependent pages.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from shared.sx.parser import parse_all, serialize, SxExpr
|
||||
from shared.sx.types import Component, Macro, Symbol, Keyword, NIL
|
||||
from shared.sx.deps import (
|
||||
_compute_all_io_refs_fallback,
|
||||
components_needed,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_env(*sx_sources: str) -> dict:
|
||||
"""Parse and evaluate component definitions into an env dict."""
|
||||
from shared.sx.evaluator import _eval, _trampoline
|
||||
env: dict = {}
|
||||
for source in sx_sources:
|
||||
exprs = parse_all(source)
|
||||
for expr in exprs:
|
||||
_trampoline(_eval(expr, env))
|
||||
return env
|
||||
|
||||
|
||||
IO_NAMES = {"highlight", "current-user", "app-url", "config", "fetch-data"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# io-deps in page registry entries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIoDepsSerialization:
|
||||
"""The page registry should emit :io-deps as a list of IO primitive names."""
|
||||
|
||||
def test_pure_page_gets_empty_io_deps(self):
|
||||
"""Pages with no IO-dependent components get :io-deps ()."""
|
||||
env = make_env(
|
||||
'(defcomp ~card (&key title) (div title))',
|
||||
)
|
||||
_compute_all_io_refs_fallback(env, IO_NAMES)
|
||||
|
||||
deps = {"~card"}
|
||||
io_deps: set[str] = set()
|
||||
for dep_name in deps:
|
||||
comp = env.get(dep_name)
|
||||
if isinstance(comp, Component) and comp.io_refs:
|
||||
io_deps.update(comp.io_refs)
|
||||
|
||||
assert io_deps == set()
|
||||
|
||||
def test_io_page_gets_io_dep_names(self):
|
||||
"""Pages with IO-dependent components get :io-deps ("highlight" ...)."""
|
||||
env = make_env(
|
||||
'(defcomp ~code-block (&key src) (pre (highlight src "lisp")))',
|
||||
)
|
||||
_compute_all_io_refs_fallback(env, IO_NAMES)
|
||||
|
||||
deps = {"~code-block"}
|
||||
io_deps: set[str] = set()
|
||||
for dep_name in deps:
|
||||
comp = env.get(dep_name)
|
||||
if isinstance(comp, Component) and comp.io_refs:
|
||||
io_deps.update(comp.io_refs)
|
||||
|
||||
assert io_deps == {"highlight"}
|
||||
|
||||
def test_multiple_io_deps_collected(self):
|
||||
"""Multiple distinct IO primitives from different components are unioned."""
|
||||
env = make_env(
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~page (&key) (div (~nav) (config "key")))',
|
||||
)
|
||||
_compute_all_io_refs_fallback(env, IO_NAMES)
|
||||
|
||||
deps = {"~nav", "~page"}
|
||||
io_deps: set[str] = set()
|
||||
for dep_name in deps:
|
||||
comp = env.get(dep_name)
|
||||
if isinstance(comp, Component) and comp.io_refs:
|
||||
io_deps.update(comp.io_refs)
|
||||
|
||||
assert io_deps == {"app-url", "config"}
|
||||
|
||||
def test_transitive_io_deps_included(self):
|
||||
"""IO deps from transitive component dependencies are included."""
|
||||
env = make_env(
|
||||
'(defcomp ~inner (&key) (div (highlight "code" "lisp")))',
|
||||
'(defcomp ~outer (&key) (div (~inner)))',
|
||||
)
|
||||
_compute_all_io_refs_fallback(env, IO_NAMES)
|
||||
|
||||
deps = {"~inner", "~outer"}
|
||||
io_deps: set[str] = set()
|
||||
for dep_name in deps:
|
||||
comp = env.get(dep_name)
|
||||
if isinstance(comp, Component) and comp.io_refs:
|
||||
io_deps.update(comp.io_refs)
|
||||
|
||||
# Both components transitively depend on highlight
|
||||
assert "highlight" in io_deps
|
||||
|
||||
def test_io_deps_sx_format(self):
|
||||
"""io-deps serializes as a proper SX list of strings."""
|
||||
from shared.sx.helpers import _sx_literal
|
||||
|
||||
io_deps = {"highlight", "config"}
|
||||
io_deps_sx = (
|
||||
"(" + " ".join(_sx_literal(n) for n in sorted(io_deps)) + ")"
|
||||
)
|
||||
assert io_deps_sx == '("config" "highlight")'
|
||||
|
||||
# Parse it back
|
||||
parsed = parse_all(io_deps_sx)
|
||||
assert len(parsed) == 1
|
||||
assert parsed[0] == ["config", "highlight"]
|
||||
|
||||
def test_empty_io_deps_sx_format(self):
|
||||
io_deps_sx = "()"
|
||||
parsed = parse_all(io_deps_sx)
|
||||
assert len(parsed) == 1
|
||||
assert parsed[0] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dynamic IO allowlist from component IO refs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDynamicAllowlist:
|
||||
"""The IO proxy allowlist should be built from component IO refs."""
|
||||
|
||||
def test_allowlist_from_env(self):
|
||||
"""Union of all component io_refs gives the allowlist."""
|
||||
env = make_env(
|
||||
'(defcomp ~a (&key) (div (highlight "x" "lisp")))',
|
||||
'(defcomp ~b (&key) (div (config "key")))',
|
||||
'(defcomp ~c (&key) (div "pure"))',
|
||||
)
|
||||
_compute_all_io_refs_fallback(env, IO_NAMES)
|
||||
|
||||
allowed: set[str] = set()
|
||||
for val in env.values():
|
||||
if isinstance(val, Component) and val.io_refs:
|
||||
allowed.update(val.io_refs)
|
||||
|
||||
assert "highlight" in allowed
|
||||
assert "config" in allowed
|
||||
assert len(allowed) == 2 # only these two
|
||||
|
||||
def test_pure_env_has_empty_allowlist(self):
|
||||
"""An env with only pure components yields empty allowlist."""
|
||||
env = make_env(
|
||||
'(defcomp ~a (&key) (div "hello"))',
|
||||
'(defcomp ~b (&key) (span "world"))',
|
||||
)
|
||||
_compute_all_io_refs_fallback(env, IO_NAMES)
|
||||
|
||||
allowed: set[str] = set()
|
||||
for val in env.values():
|
||||
if isinstance(val, Component) and val.io_refs:
|
||||
allowed.update(val.io_refs)
|
||||
|
||||
assert allowed == set()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SxExpr serialization through IO proxy pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSxExprIoRoundtrip:
|
||||
"""SxExpr (from highlight etc.) must survive serialize → parse."""
|
||||
|
||||
def test_sxexpr_serializes_unquoted(self):
|
||||
"""SxExpr is emitted as raw SX source, not as a quoted string."""
|
||||
expr = SxExpr('(span :class "text-red-500" "hello")')
|
||||
sx = serialize(expr)
|
||||
assert sx == '(span :class "text-red-500" "hello")'
|
||||
assert not sx.startswith('"')
|
||||
|
||||
def test_sxexpr_roundtrip(self):
|
||||
"""SxExpr → serialize → parse → yields an AST list."""
|
||||
expr = SxExpr('(span :class "text-violet-600" "keyword")')
|
||||
sx = serialize(expr)
|
||||
parsed = parse_all(sx)
|
||||
assert len(parsed) == 1
|
||||
# Should be a list: [Symbol("span"), Keyword("class"), "text-violet-600", "keyword"]
|
||||
node = parsed[0]
|
||||
assert isinstance(node, list)
|
||||
assert isinstance(node[0], Symbol)
|
||||
assert node[0].name == "span"
|
||||
|
||||
def test_fragment_sxexpr_roundtrip(self):
|
||||
"""Fragment SxExpr with multiple children."""
|
||||
expr = SxExpr(
|
||||
'(<> (span :class "text-red-500" "if") '
|
||||
'(span " ") '
|
||||
'(span :class "text-green-500" "true"))'
|
||||
)
|
||||
sx = serialize(expr)
|
||||
parsed = parse_all(sx)
|
||||
assert len(parsed) == 1
|
||||
node = parsed[0]
|
||||
assert isinstance(node, list)
|
||||
assert node[0].name == "<>"
|
||||
|
||||
def test_nil_serializes_as_nil(self):
|
||||
"""None result from IO proxy serializes as 'nil'."""
|
||||
sx = serialize(None)
|
||||
assert sx == "nil"
|
||||
parsed = parse_all(sx)
|
||||
assert parsed[0] is NIL or parsed[0] is None
|
||||
|
||||
def test_sxexpr_in_dict_value(self):
|
||||
"""SxExpr as a dict value serializes inline (not quoted)."""
|
||||
expr = SxExpr('(span "hello")')
|
||||
data = {"code": expr}
|
||||
sx = serialize(data)
|
||||
# Should be {:code (span "hello")} not {:code "(span \"hello\")"}
|
||||
assert '(span "hello")' in sx
|
||||
parsed = parse_all(sx)
|
||||
d = parsed[0]
|
||||
# The value should be a list (AST), not a string
|
||||
assert isinstance(d["code"], list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IO proxy arg parsing (GET query string vs POST JSON body)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIoProxyArgParsing:
|
||||
"""Test the arg extraction logic used by the IO proxy."""
|
||||
|
||||
def test_get_args_from_query_string(self):
|
||||
"""GET: _arg0, _arg1, ... become positional args."""
|
||||
query = {"_arg0": "(defcomp ~card ...)", "_arg1": "lisp"}
|
||||
args = []
|
||||
kwargs = {}
|
||||
for k, v in query.items():
|
||||
if k.startswith("_arg"):
|
||||
args.append(v)
|
||||
else:
|
||||
kwargs[k] = v
|
||||
assert args == ["(defcomp ~card ...)", "lisp"]
|
||||
assert kwargs == {}
|
||||
|
||||
def test_get_kwargs_from_query_string(self):
|
||||
"""GET: non-_arg keys become kwargs."""
|
||||
query = {"_arg0": "code", "language": "python"}
|
||||
args = []
|
||||
kwargs = {}
|
||||
for k, v in query.items():
|
||||
if k.startswith("_arg"):
|
||||
args.append(v)
|
||||
else:
|
||||
kwargs[k] = v
|
||||
assert args == ["code"]
|
||||
assert kwargs == {"language": "python"}
|
||||
|
||||
def test_post_json_body(self):
|
||||
"""POST: args and kwargs from JSON body."""
|
||||
body = {"args": ["(defcomp ~card ...)", "lisp"], "kwargs": {}}
|
||||
args = body.get("args", [])
|
||||
kwargs = body.get("kwargs", {})
|
||||
assert args == ["(defcomp ~card ...)", "lisp"]
|
||||
assert kwargs == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IO-aware client routing logic (orchestration.sx)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIoRoutingLogic:
|
||||
"""Test the orchestration.sx routing decisions for IO pages.
|
||||
|
||||
Uses the SX evaluator to run the actual routing logic.
|
||||
"""
|
||||
|
||||
def _eval(self, src, env):
|
||||
from shared.sx.evaluator import _eval, _trampoline
|
||||
result = None
|
||||
for expr in parse_all(src):
|
||||
result = _trampoline(_eval(expr, env))
|
||||
return result
|
||||
|
||||
def test_io_deps_list_truthiness(self):
|
||||
"""A non-empty io-deps list is truthy, empty is falsy."""
|
||||
env = make_env()
|
||||
# Non-empty list — (and io-deps (not (empty? io-deps))) is truthy
|
||||
result = self._eval(
|
||||
'(let ((io-deps (list "highlight")))'
|
||||
' (if (and io-deps (not (empty? io-deps))) true false))',
|
||||
env,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
# Empty list — (and io-deps (not (empty? io-deps))) is falsy
|
||||
# (and short-circuits: empty list is falsy, returns [])
|
||||
result = self._eval(
|
||||
'(let ((io-deps (list)))'
|
||||
' (if (and io-deps (not (empty? io-deps))) true false))',
|
||||
env,
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_io_deps_from_parsed_page_entry(self):
|
||||
"""io-deps field round-trips through serialize → parse correctly."""
|
||||
entry_sx = '{:name "test" :io-deps ("highlight" "config")}'
|
||||
parsed = parse_all(entry_sx)
|
||||
entry = parsed[0]
|
||||
|
||||
env = make_env()
|
||||
env["entry"] = entry
|
||||
io_deps = self._eval('(get entry "io-deps")', env)
|
||||
assert io_deps == ["highlight", "config"]
|
||||
|
||||
has_io = self._eval(
|
||||
'(let ((d (get entry "io-deps")))'
|
||||
' (and d (not (empty? d))))',
|
||||
env,
|
||||
)
|
||||
assert has_io is True
|
||||
|
||||
def test_empty_io_deps_from_parsed_page_entry(self):
|
||||
"""Empty io-deps list means page is pure."""
|
||||
entry_sx = '{:name "test" :io-deps ()}'
|
||||
parsed = parse_all(entry_sx)
|
||||
entry = parsed[0]
|
||||
|
||||
env = make_env()
|
||||
env["entry"] = entry
|
||||
has_io = self._eval(
|
||||
'(let ((d (get entry "io-deps")))'
|
||||
' (if (and d (not (empty? d))) true false))',
|
||||
env,
|
||||
)
|
||||
assert has_io is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache key determinism for IO proxy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIoCacheKey:
|
||||
"""The client-side IO cache keys by name + args. Verify determinism."""
|
||||
|
||||
def test_same_args_same_key(self):
|
||||
"""Identical calls produce identical cache keys."""
|
||||
def make_key(name, args, kwargs=None):
|
||||
key = name
|
||||
for a in args:
|
||||
key += "\0" + str(a)
|
||||
if kwargs:
|
||||
for k, v in sorted(kwargs.items()):
|
||||
key += "\0" + k + "=" + str(v)
|
||||
return key
|
||||
|
||||
k1 = make_key("highlight", ["(div 1)", "lisp"])
|
||||
k2 = make_key("highlight", ["(div 1)", "lisp"])
|
||||
assert k1 == k2
|
||||
|
||||
def test_different_args_different_key(self):
|
||||
def make_key(name, args):
|
||||
key = name
|
||||
for a in args:
|
||||
key += "\0" + str(a)
|
||||
return key
|
||||
|
||||
k1 = make_key("highlight", ["(div 1)", "lisp"])
|
||||
k2 = make_key("highlight", ["(div 2)", "lisp"])
|
||||
assert k1 != k2
|
||||
|
||||
def test_different_name_different_key(self):
|
||||
def make_key(name, args):
|
||||
key = name
|
||||
for a in args:
|
||||
key += "\0" + str(a)
|
||||
return key
|
||||
|
||||
k1 = make_key("highlight", ["code"])
|
||||
k2 = make_key("config", ["code"])
|
||||
assert k1 != k2
|
||||
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"
|
||||
300
shared/sx/tests/test_router.py
Normal file
300
shared/sx/tests/test_router.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""Tests for the router.sx spec — client-side route matching.
|
||||
|
||||
Tests the bootstrapped Python router functions (from sx_ref.py) and
|
||||
the SX page registry serialization (from helpers.py).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from shared.sx.ref import sx_ref
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# split-path-segments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSplitPathSegments:
|
||||
def test_simple(self):
|
||||
assert sx_ref.split_path_segments("/docs/hello") == ["docs", "hello"]
|
||||
|
||||
def test_root(self):
|
||||
assert sx_ref.split_path_segments("/") == []
|
||||
|
||||
def test_trailing_slash(self):
|
||||
assert sx_ref.split_path_segments("/docs/") == ["docs"]
|
||||
|
||||
def test_no_leading_slash(self):
|
||||
assert sx_ref.split_path_segments("docs/hello") == ["docs", "hello"]
|
||||
|
||||
def test_single_segment(self):
|
||||
assert sx_ref.split_path_segments("/about") == ["about"]
|
||||
|
||||
def test_deep_path(self):
|
||||
assert sx_ref.split_path_segments("/a/b/c/d") == ["a", "b", "c", "d"]
|
||||
|
||||
def test_empty(self):
|
||||
assert sx_ref.split_path_segments("") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse-route-pattern
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseRoutePattern:
|
||||
def test_literal_only(self):
|
||||
result = sx_ref.parse_route_pattern("/docs/")
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "literal"
|
||||
assert result[0]["value"] == "docs"
|
||||
|
||||
def test_param(self):
|
||||
result = sx_ref.parse_route_pattern("/docs/<slug>")
|
||||
assert len(result) == 2
|
||||
assert result[0] == {"type": "literal", "value": "docs"}
|
||||
assert result[1] == {"type": "param", "value": "slug"}
|
||||
|
||||
def test_multiple_params(self):
|
||||
result = sx_ref.parse_route_pattern("/users/<uid>/posts/<pid>")
|
||||
assert len(result) == 4
|
||||
assert result[0]["type"] == "literal"
|
||||
assert result[1] == {"type": "param", "value": "uid"}
|
||||
assert result[2]["type"] == "literal"
|
||||
assert result[3] == {"type": "param", "value": "pid"}
|
||||
|
||||
def test_root_pattern(self):
|
||||
result = sx_ref.parse_route_pattern("/")
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# match-route
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatchRoute:
|
||||
def test_exact_match(self):
|
||||
params = sx_ref.match_route("/docs/", "/docs/")
|
||||
assert params is not None
|
||||
assert params == {}
|
||||
|
||||
def test_param_match(self):
|
||||
params = sx_ref.match_route("/docs/components", "/docs/<slug>")
|
||||
assert params is not None
|
||||
assert params == {"slug": "components"}
|
||||
|
||||
def test_no_match_different_length(self):
|
||||
result = sx_ref.match_route("/docs/a/b", "/docs/<slug>")
|
||||
assert result is sx_ref.NIL or result is None
|
||||
|
||||
def test_no_match_literal_mismatch(self):
|
||||
result = sx_ref.match_route("/api/hello", "/docs/<slug>")
|
||||
assert result is sx_ref.NIL or result is None
|
||||
|
||||
def test_root_match(self):
|
||||
params = sx_ref.match_route("/", "/")
|
||||
assert params is not None
|
||||
assert params == {}
|
||||
|
||||
def test_multiple_params(self):
|
||||
params = sx_ref.match_route("/users/42/posts/7", "/users/<uid>/posts/<pid>")
|
||||
assert params is not None
|
||||
assert params == {"uid": "42", "pid": "7"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find-matching-route
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFindMatchingRoute:
|
||||
def _make_routes(self, patterns):
|
||||
"""Build route entries like boot.sx does — with parsed patterns."""
|
||||
routes = []
|
||||
for name, pattern in patterns:
|
||||
route = {
|
||||
"name": name,
|
||||
"path": pattern,
|
||||
"parsed": sx_ref.parse_route_pattern(pattern),
|
||||
"has-data": False,
|
||||
"content": "(div \"test\")",
|
||||
}
|
||||
routes.append(route)
|
||||
return routes
|
||||
|
||||
def test_first_match(self):
|
||||
routes = self._make_routes([
|
||||
("home", "/"),
|
||||
("docs-index", "/docs/"),
|
||||
("docs-page", "/docs/<slug>"),
|
||||
])
|
||||
match = sx_ref.find_matching_route("/docs/components", routes)
|
||||
assert match is not None
|
||||
assert match["name"] == "docs-page"
|
||||
assert match["params"] == {"slug": "components"}
|
||||
|
||||
def test_exact_before_param(self):
|
||||
routes = self._make_routes([
|
||||
("docs-index", "/docs/"),
|
||||
("docs-page", "/docs/<slug>"),
|
||||
])
|
||||
match = sx_ref.find_matching_route("/docs/", routes)
|
||||
assert match is not None
|
||||
assert match["name"] == "docs-index"
|
||||
|
||||
def test_no_match(self):
|
||||
routes = self._make_routes([
|
||||
("home", "/"),
|
||||
("docs-page", "/docs/<slug>"),
|
||||
])
|
||||
result = sx_ref.find_matching_route("/unknown/path", routes)
|
||||
assert result is sx_ref.NIL or result is None
|
||||
|
||||
def test_root_match(self):
|
||||
routes = self._make_routes([
|
||||
("home", "/"),
|
||||
("about", "/about"),
|
||||
])
|
||||
match = sx_ref.find_matching_route("/", routes)
|
||||
assert match is not None
|
||||
assert match["name"] == "home"
|
||||
|
||||
def test_params_not_on_original(self):
|
||||
"""find-matching-route should not mutate the original route entry."""
|
||||
routes = self._make_routes([("page", "/docs/<slug>")])
|
||||
match = sx_ref.find_matching_route("/docs/test", routes)
|
||||
assert match["params"] == {"slug": "test"}
|
||||
# Original should not have params key
|
||||
assert "params" not in routes[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page registry SX serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildPagesSx:
|
||||
"""Test the SX page registry format — serialize + parse round-trip."""
|
||||
|
||||
def test_round_trip_simple(self):
|
||||
"""SX dict literal round-trips through serialize → parse."""
|
||||
from shared.sx.helpers import _sx_literal
|
||||
from shared.sx.parser import parse_all
|
||||
|
||||
# Build an SX dict literal like _build_pages_sx does
|
||||
entry = (
|
||||
"{:name " + _sx_literal("home")
|
||||
+ " :path " + _sx_literal("/")
|
||||
+ " :auth " + _sx_literal("public")
|
||||
+ " :has-data false"
|
||||
+ " :content " + _sx_literal("(~home-content)")
|
||||
+ " :closure {}}"
|
||||
)
|
||||
|
||||
parsed = parse_all(entry)
|
||||
assert len(parsed) == 1
|
||||
d = parsed[0]
|
||||
assert d["name"] == "home"
|
||||
assert d["path"] == "/"
|
||||
assert d["auth"] == "public"
|
||||
assert d["has-data"] is False
|
||||
assert d["content"] == "(~home-content)"
|
||||
assert d["closure"] == {}
|
||||
|
||||
def test_round_trip_multiple(self):
|
||||
"""Multiple SX dict literals parse as a list."""
|
||||
from shared.sx.helpers import _sx_literal
|
||||
from shared.sx.parser import parse_all
|
||||
|
||||
entries = []
|
||||
for name, path in [("home", "/"), ("docs", "/docs/<slug>")]:
|
||||
entry = (
|
||||
"{:name " + _sx_literal(name)
|
||||
+ " :path " + _sx_literal(path)
|
||||
+ " :has-data false"
|
||||
+ " :content " + _sx_literal("(div)")
|
||||
+ " :closure {}}"
|
||||
)
|
||||
entries.append(entry)
|
||||
|
||||
text = "\n".join(entries)
|
||||
parsed = parse_all(text)
|
||||
assert len(parsed) == 2
|
||||
assert parsed[0]["name"] == "home"
|
||||
assert parsed[1]["name"] == "docs"
|
||||
assert parsed[1]["path"] == "/docs/<slug>"
|
||||
|
||||
def test_content_with_quotes(self):
|
||||
"""Content expressions with quotes survive serialization."""
|
||||
from shared.sx.helpers import _sx_literal
|
||||
from shared.sx.parser import parse_all
|
||||
|
||||
content = '(~doc-page :title "Hello \\"World\\"")'
|
||||
entry = (
|
||||
"{:name " + _sx_literal("test")
|
||||
+ " :content " + _sx_literal(content)
|
||||
+ " :closure {}}"
|
||||
)
|
||||
parsed = parse_all(entry)
|
||||
assert parsed[0]["content"] == content
|
||||
|
||||
def test_closure_with_values(self):
|
||||
"""Closure dict with various value types."""
|
||||
from shared.sx.helpers import _sx_literal
|
||||
from shared.sx.parser import parse_all
|
||||
|
||||
entry = '{:name "test" :closure {:label "hello" :count 42 :active true}}'
|
||||
parsed = parse_all(entry)
|
||||
closure = parsed[0]["closure"]
|
||||
assert closure["label"] == "hello"
|
||||
assert closure["count"] == 42
|
||||
assert closure["active"] is True
|
||||
|
||||
def test_has_data_true(self):
|
||||
"""has-data true marks server-only pages."""
|
||||
from shared.sx.parser import parse_all
|
||||
|
||||
entry = '{:name "analyzer" :path "/data" :has-data true :content "" :closure {}}'
|
||||
parsed = parse_all(entry)
|
||||
assert parsed[0]["has-data"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _sx_literal helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSxLiteral:
|
||||
def test_string(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal("hello") == '"hello"'
|
||||
|
||||
def test_string_with_quotes(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal('say "hi"') == '"say \\"hi\\""'
|
||||
|
||||
def test_string_with_newline(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal("line1\nline2") == '"line1\\nline2"'
|
||||
|
||||
def test_string_with_backslash(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal("a\\b") == '"a\\\\b"'
|
||||
|
||||
def test_int(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal(42) == "42"
|
||||
|
||||
def test_float(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal(3.14) == "3.14"
|
||||
|
||||
def test_bool_true(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal(True) == "true"
|
||||
|
||||
def test_bool_false(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal(False) == "false"
|
||||
|
||||
def test_none(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal(None) == "nil"
|
||||
|
||||
def test_empty_string(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal("") == '""'
|
||||
@@ -20,19 +20,39 @@ SX_TEST_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sx-te
|
||||
|
||||
def _js_render(sx_text: str, components_text: str = "") -> str:
|
||||
"""Run sx.js + sx-test.js in Node and return the renderToString result."""
|
||||
# Build a small Node script
|
||||
import tempfile, os
|
||||
# Build a small Node script that requires the source files
|
||||
script = f"""
|
||||
global.document = undefined; // no DOM needed for string render
|
||||
globalThis.document = undefined; // no DOM needed for string render
|
||||
// sx.js IIFE uses (typeof window !== "undefined" ? window : this).
|
||||
// In Node file mode, `this` is module.exports, not globalThis.
|
||||
// Patch: make the IIFE target globalThis so Sx is accessible.
|
||||
var _origThis = this;
|
||||
Object.defineProperty(globalThis, 'document', {{ value: undefined, writable: true }});
|
||||
(function() {{
|
||||
var _savedThis = globalThis;
|
||||
{SX_JS.read_text()}
|
||||
// Hoist Sx from module.exports to globalThis if needed
|
||||
if (typeof Sx === 'undefined' && typeof module !== 'undefined' && module.exports && module.exports.Sx) {{
|
||||
globalThis.Sx = module.exports.Sx;
|
||||
}}
|
||||
}}).call(globalThis);
|
||||
{SX_TEST_JS.read_text()}
|
||||
if ({json.dumps(components_text)}) Sx.loadComponents({json.dumps(components_text)});
|
||||
var result = Sx.renderToString({json.dumps(sx_text)});
|
||||
process.stdout.write(result);
|
||||
"""
|
||||
# Write to temp file to avoid OS arg length limits
|
||||
fd, tmp = tempfile.mkstemp(suffix=".js")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(script)
|
||||
result = subprocess.run(
|
||||
["node", "-e", script],
|
||||
["node", tmp],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
if result.returncode != 0:
|
||||
pytest.fail(f"Node.js error:\n{result.stderr}")
|
||||
return result.stdout
|
||||
|
||||
343
shared/sx/tests/test_sx_spec.py
Normal file
343
shared/sx/tests/test_sx_spec.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""Auto-generated from test.sx — SX spec self-tests.
|
||||
|
||||
DO NOT EDIT. Regenerate with:
|
||||
python shared/sx/ref/bootstrap_test.py --output shared/sx/tests/test_sx_spec.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from shared.sx.parser import parse_all
|
||||
from shared.sx.evaluator import _eval, _trampoline
|
||||
|
||||
|
||||
_PREAMBLE = '''(define assert-equal (fn (expected actual) (assert (equal? expected actual) (str "Expected " (str expected) " but got " (str actual)))))
|
||||
(define assert-not-equal (fn (a b) (assert (not (equal? a b)) (str "Expected values to differ but both are " (str a)))))
|
||||
(define assert-true (fn (val) (assert val (str "Expected truthy but got " (str val)))))
|
||||
(define assert-false (fn (val) (assert (not val) (str "Expected falsy but got " (str val)))))
|
||||
(define assert-nil (fn (val) (assert (nil? val) (str "Expected nil but got " (str val)))))
|
||||
(define assert-type (fn (expected-type val) (let ((actual-type (if (nil? val) "nil" (if (boolean? val) "boolean" (if (number? val) "number" (if (string? val) "string" (if (list? val) "list" (if (dict? val) "dict" "unknown")))))))) (assert (= expected-type actual-type) (str "Expected type " expected-type " but got " actual-type)))))
|
||||
(define assert-length (fn (expected-len col) (assert (= (len col) expected-len) (str "Expected length " expected-len " but got " (len col)))))
|
||||
(define assert-contains (fn (item col) (assert (some (fn (x) (equal? x item)) col) (str "Expected collection to contain " (str item)))))
|
||||
(define assert-throws (fn (thunk) (let ((result (try-call thunk))) (assert (not (get result "ok")) "Expected an error to be thrown but none was"))))'''
|
||||
|
||||
|
||||
def _make_env() -> dict:
|
||||
"""Create a fresh env with assertion helpers loaded."""
|
||||
env = {}
|
||||
for expr in parse_all(_PREAMBLE):
|
||||
_trampoline(_eval(expr, env))
|
||||
return env
|
||||
|
||||
|
||||
def _run(sx_source: str, env: dict | None = None) -> object:
|
||||
"""Evaluate SX source and return the result."""
|
||||
if env is None:
|
||||
env = _make_env()
|
||||
exprs = parse_all(sx_source)
|
||||
result = None
|
||||
for expr in exprs:
|
||||
result = _trampoline(_eval(expr, env))
|
||||
return result
|
||||
|
||||
class TestSpecLiterals:
|
||||
"""test.sx suite: literals"""
|
||||
|
||||
def test_numbers_are_numbers(self):
|
||||
_run('(do (assert-type "number" 42) (assert-type "number" 3.14) (assert-type "number" -1))')
|
||||
|
||||
def test_strings_are_strings(self):
|
||||
_run('(do (assert-type "string" "hello") (assert-type "string" ""))')
|
||||
|
||||
def test_booleans_are_booleans(self):
|
||||
_run('(do (assert-type "boolean" true) (assert-type "boolean" false))')
|
||||
|
||||
def test_nil_is_nil(self):
|
||||
_run('(do (assert-type "nil" nil) (assert-nil nil))')
|
||||
|
||||
def test_lists_are_lists(self):
|
||||
_run('(do (assert-type "list" (list 1 2 3)) (assert-type "list" (list)))')
|
||||
|
||||
def test_dicts_are_dicts(self):
|
||||
_run('(assert-type "dict" {:a 1 :b 2})')
|
||||
|
||||
|
||||
class TestSpecArithmetic:
|
||||
"""test.sx suite: arithmetic"""
|
||||
|
||||
def test_addition(self):
|
||||
_run('(do (assert-equal 3 (+ 1 2)) (assert-equal 0 (+ 0 0)) (assert-equal -1 (+ 1 -2)) (assert-equal 10 (+ 1 2 3 4)))')
|
||||
|
||||
def test_subtraction(self):
|
||||
_run('(do (assert-equal 1 (- 3 2)) (assert-equal -1 (- 2 3)))')
|
||||
|
||||
def test_multiplication(self):
|
||||
_run('(do (assert-equal 6 (* 2 3)) (assert-equal 0 (* 0 100)) (assert-equal 24 (* 1 2 3 4)))')
|
||||
|
||||
def test_division(self):
|
||||
_run('(do (assert-equal 2 (/ 6 3)) (assert-equal 2.5 (/ 5 2)))')
|
||||
|
||||
def test_modulo(self):
|
||||
_run('(do (assert-equal 1 (mod 7 3)) (assert-equal 0 (mod 6 3)))')
|
||||
|
||||
|
||||
class TestSpecComparison:
|
||||
"""test.sx suite: comparison"""
|
||||
|
||||
def test_equality(self):
|
||||
_run('(do (assert-true (= 1 1)) (assert-false (= 1 2)) (assert-true (= "a" "a")) (assert-false (= "a" "b")))')
|
||||
|
||||
def test_deep_equality(self):
|
||||
_run('(do (assert-true (equal? (list 1 2 3) (list 1 2 3))) (assert-false (equal? (list 1 2) (list 1 3))) (assert-true (equal? {:a 1} {:a 1})) (assert-false (equal? {:a 1} {:a 2})))')
|
||||
|
||||
def test_ordering(self):
|
||||
_run('(do (assert-true (< 1 2)) (assert-false (< 2 1)) (assert-true (> 2 1)) (assert-true (<= 1 1)) (assert-true (<= 1 2)) (assert-true (>= 2 2)) (assert-true (>= 3 2)))')
|
||||
|
||||
|
||||
class TestSpecStrings:
|
||||
"""test.sx suite: strings"""
|
||||
|
||||
def test_str_concatenation(self):
|
||||
_run('(do (assert-equal "abc" (str "a" "b" "c")) (assert-equal "hello world" (str "hello" " " "world")) (assert-equal "42" (str 42)) (assert-equal "" (str)))')
|
||||
|
||||
def test_string_length(self):
|
||||
_run('(do (assert-equal 5 (string-length "hello")) (assert-equal 0 (string-length "")))')
|
||||
|
||||
def test_substring(self):
|
||||
_run('(do (assert-equal "ell" (substring "hello" 1 4)) (assert-equal "hello" (substring "hello" 0 5)))')
|
||||
|
||||
def test_string_contains(self):
|
||||
_run('(do (assert-true (string-contains? "hello world" "world")) (assert-false (string-contains? "hello" "xyz")))')
|
||||
|
||||
def test_upcase_and_downcase(self):
|
||||
_run('(do (assert-equal "HELLO" (upcase "hello")) (assert-equal "hello" (downcase "HELLO")))')
|
||||
|
||||
def test_trim(self):
|
||||
_run('(do (assert-equal "hello" (trim " hello ")) (assert-equal "hello" (trim "hello")))')
|
||||
|
||||
def test_split_and_join(self):
|
||||
_run('(do (assert-equal (list "a" "b" "c") (split "a,b,c" ",")) (assert-equal "a-b-c" (join "-" (list "a" "b" "c"))))')
|
||||
|
||||
|
||||
class TestSpecLists:
|
||||
"""test.sx suite: lists"""
|
||||
|
||||
def test_constructors(self):
|
||||
_run('(do (assert-equal (list 1 2 3) (list 1 2 3)) (assert-equal (list) (list)) (assert-length 3 (list 1 2 3)))')
|
||||
|
||||
def test_first_and_rest(self):
|
||||
_run('(do (assert-equal 1 (first (list 1 2 3))) (assert-equal (list 2 3) (rest (list 1 2 3))) (assert-nil (first (list))) (assert-equal (list) (rest (list))))')
|
||||
|
||||
def test_nth(self):
|
||||
_run('(do (assert-equal 1 (nth (list 1 2 3) 0)) (assert-equal 2 (nth (list 1 2 3) 1)) (assert-equal 3 (nth (list 1 2 3) 2)))')
|
||||
|
||||
def test_last(self):
|
||||
_run('(do (assert-equal 3 (last (list 1 2 3))) (assert-nil (last (list))))')
|
||||
|
||||
def test_cons_and_append(self):
|
||||
_run('(do (assert-equal (list 0 1 2) (cons 0 (list 1 2))) (assert-equal (list 1 2 3 4) (append (list 1 2) (list 3 4))))')
|
||||
|
||||
def test_reverse(self):
|
||||
_run('(do (assert-equal (list 3 2 1) (reverse (list 1 2 3))) (assert-equal (list) (reverse (list))))')
|
||||
|
||||
def test_empty(self):
|
||||
_run('(do (assert-true (empty? (list))) (assert-false (empty? (list 1))))')
|
||||
|
||||
def test_len(self):
|
||||
_run('(do (assert-equal 0 (len (list))) (assert-equal 3 (len (list 1 2 3))))')
|
||||
|
||||
def test_contains(self):
|
||||
_run('(do (assert-true (contains? (list 1 2 3) 2)) (assert-false (contains? (list 1 2 3) 4)))')
|
||||
|
||||
def test_flatten(self):
|
||||
_run('(assert-equal (list 1 2 3 4) (flatten (list (list 1 2) (list 3 4))))')
|
||||
|
||||
|
||||
class TestSpecDicts:
|
||||
"""test.sx suite: dicts"""
|
||||
|
||||
def test_dict_literal(self):
|
||||
_run('(do (assert-type "dict" {:a 1 :b 2}) (assert-equal 1 (get {:a 1} "a")) (assert-equal 2 (get {:a 1 :b 2} "b")))')
|
||||
|
||||
def test_assoc(self):
|
||||
_run('(do (assert-equal {:a 1 :b 2} (assoc {:a 1} "b" 2)) (assert-equal {:a 99} (assoc {:a 1} "a" 99)))')
|
||||
|
||||
def test_dissoc(self):
|
||||
_run('(assert-equal {:b 2} (dissoc {:a 1 :b 2} "a"))')
|
||||
|
||||
def test_keys_and_vals(self):
|
||||
_run('(let ((d {:a 1 :b 2})) (assert-length 2 (keys d)) (assert-length 2 (vals d)) (assert-contains "a" (keys d)) (assert-contains "b" (keys d)))')
|
||||
|
||||
def test_has_key(self):
|
||||
_run('(do (assert-true (has-key? {:a 1} "a")) (assert-false (has-key? {:a 1} "b")))')
|
||||
|
||||
def test_merge(self):
|
||||
_run('(do (assert-equal {:a 1 :b 2 :c 3} (merge {:a 1 :b 2} {:c 3})) (assert-equal {:a 99 :b 2} (merge {:a 1 :b 2} {:a 99})))')
|
||||
|
||||
|
||||
class TestSpecPredicates:
|
||||
"""test.sx suite: predicates"""
|
||||
|
||||
def test_nil(self):
|
||||
_run('(do (assert-true (nil? nil)) (assert-false (nil? 0)) (assert-false (nil? false)) (assert-false (nil? "")))')
|
||||
|
||||
def test_number(self):
|
||||
_run('(do (assert-true (number? 42)) (assert-true (number? 3.14)) (assert-false (number? "42")))')
|
||||
|
||||
def test_string(self):
|
||||
_run('(do (assert-true (string? "hello")) (assert-false (string? 42)))')
|
||||
|
||||
def test_list(self):
|
||||
_run('(do (assert-true (list? (list 1 2))) (assert-false (list? "not a list")))')
|
||||
|
||||
def test_dict(self):
|
||||
_run('(do (assert-true (dict? {:a 1})) (assert-false (dict? (list 1))))')
|
||||
|
||||
def test_boolean(self):
|
||||
_run('(do (assert-true (boolean? true)) (assert-true (boolean? false)) (assert-false (boolean? nil)) (assert-false (boolean? 0)))')
|
||||
|
||||
def test_not(self):
|
||||
_run('(do (assert-true (not false)) (assert-true (not nil)) (assert-false (not true)) (assert-false (not 1)) (assert-false (not "x")))')
|
||||
|
||||
|
||||
class TestSpecSpecialForms:
|
||||
"""test.sx suite: special-forms"""
|
||||
|
||||
def test_if(self):
|
||||
_run('(do (assert-equal "yes" (if true "yes" "no")) (assert-equal "no" (if false "yes" "no")) (assert-equal "no" (if nil "yes" "no")) (assert-nil (if false "yes")))')
|
||||
|
||||
def test_when(self):
|
||||
_run('(do (assert-equal "yes" (when true "yes")) (assert-nil (when false "yes")))')
|
||||
|
||||
def test_cond(self):
|
||||
_run('(do (assert-equal "a" (cond true "a" :else "b")) (assert-equal "b" (cond false "a" :else "b")) (assert-equal "c" (cond false "a" false "b" :else "c")))')
|
||||
|
||||
def test_and(self):
|
||||
_run('(do (assert-true (and true true)) (assert-false (and true false)) (assert-false (and false true)) (assert-equal 3 (and 1 2 3)))')
|
||||
|
||||
def test_or(self):
|
||||
_run('(do (assert-equal 1 (or 1 2)) (assert-equal 2 (or false 2)) (assert-equal "fallback" (or nil false "fallback")) (assert-false (or false false)))')
|
||||
|
||||
def test_let(self):
|
||||
_run('(do (assert-equal 3 (let ((x 1) (y 2)) (+ x y))) (assert-equal "hello world" (let ((a "hello") (b " world")) (str a b))))')
|
||||
|
||||
def test_let_clojure_style(self):
|
||||
_run('(assert-equal 3 (let (x 1 y 2) (+ x y)))')
|
||||
|
||||
def test_do_begin(self):
|
||||
_run('(do (assert-equal 3 (do 1 2 3)) (assert-equal "last" (begin "first" "middle" "last")))')
|
||||
|
||||
def test_define(self):
|
||||
_run('(do (define x 42) (assert-equal 42 x))')
|
||||
|
||||
def test_set(self):
|
||||
_run('(do (define x 1) (set! x 2) (assert-equal 2 x))')
|
||||
|
||||
|
||||
class TestSpecLambdas:
|
||||
"""test.sx suite: lambdas"""
|
||||
|
||||
def test_basic_lambda(self):
|
||||
_run('(let ((add (fn (a b) (+ a b)))) (assert-equal 3 (add 1 2)))')
|
||||
|
||||
def test_closure_captures_env(self):
|
||||
_run('(let ((x 10)) (let ((add-x (fn (y) (+ x y)))) (assert-equal 15 (add-x 5))))')
|
||||
|
||||
def test_lambda_as_argument(self):
|
||||
_run('(assert-equal (list 2 4 6) (map (fn (x) (* x 2)) (list 1 2 3)))')
|
||||
|
||||
def test_recursive_lambda_via_define(self):
|
||||
_run('(do (define factorial (fn (n) (if (<= n 1) 1 (* n (factorial (- n 1)))))) (assert-equal 120 (factorial 5)))')
|
||||
|
||||
def test_higher_order_returns_lambda(self):
|
||||
_run('(let ((make-adder (fn (n) (fn (x) (+ n x))))) (let ((add5 (make-adder 5))) (assert-equal 8 (add5 3))))')
|
||||
|
||||
|
||||
class TestSpecHigherOrder:
|
||||
"""test.sx suite: higher-order"""
|
||||
|
||||
def test_map(self):
|
||||
_run('(do (assert-equal (list 2 4 6) (map (fn (x) (* x 2)) (list 1 2 3))) (assert-equal (list) (map (fn (x) x) (list))))')
|
||||
|
||||
def test_filter(self):
|
||||
_run('(do (assert-equal (list 2 4) (filter (fn (x) (= (mod x 2) 0)) (list 1 2 3 4))) (assert-equal (list) (filter (fn (x) false) (list 1 2 3))))')
|
||||
|
||||
def test_reduce(self):
|
||||
_run('(do (assert-equal 10 (reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3 4))) (assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list))))')
|
||||
|
||||
def test_some(self):
|
||||
_run('(do (assert-true (some (fn (x) (> x 3)) (list 1 2 3 4 5))) (assert-false (some (fn (x) (> x 10)) (list 1 2 3))))')
|
||||
|
||||
def test_every(self):
|
||||
_run('(do (assert-true (every? (fn (x) (> x 0)) (list 1 2 3))) (assert-false (every? (fn (x) (> x 2)) (list 1 2 3))))')
|
||||
|
||||
def test_map_indexed(self):
|
||||
_run('(assert-equal (list "0:a" "1:b" "2:c") (map-indexed (fn (i x) (str i ":" x)) (list "a" "b" "c")))')
|
||||
|
||||
|
||||
class TestSpecComponents:
|
||||
"""test.sx suite: components"""
|
||||
|
||||
def test_defcomp_creates_component(self):
|
||||
_run('(do (defcomp ~test-comp (&key title) (div title)) (assert-true (not (nil? ~test-comp))))')
|
||||
|
||||
def test_component_renders_with_keyword_args(self):
|
||||
_run('(do (defcomp ~greeting (&key name) (span (str "Hello, " name "!"))) (assert-true (not (nil? ~greeting))))')
|
||||
|
||||
def test_component_with_children(self):
|
||||
_run('(do (defcomp ~box (&key &rest children) (div :class "box" children)) (assert-true (not (nil? ~box))))')
|
||||
|
||||
def test_component_with_default_via_or(self):
|
||||
_run('(do (defcomp ~label (&key text) (span (or text "default"))) (assert-true (not (nil? ~label))))')
|
||||
|
||||
|
||||
class TestSpecMacros:
|
||||
"""test.sx suite: macros"""
|
||||
|
||||
def test_defmacro_creates_macro(self):
|
||||
_run('(do (defmacro unless (cond &rest body) (quasiquote (if (not (unquote cond)) (do (splice-unquote body))))) (assert-equal "yes" (unless false "yes")) (assert-nil (unless true "no")))')
|
||||
|
||||
def test_quasiquote_and_unquote(self):
|
||||
_run('(let ((x 42)) (assert-equal (list 1 42 3) (quasiquote (1 (unquote x) 3))))')
|
||||
|
||||
def test_splice_unquote(self):
|
||||
_run('(let ((xs (list 2 3 4))) (assert-equal (list 1 2 3 4 5) (quasiquote (1 (splice-unquote xs) 5))))')
|
||||
|
||||
|
||||
class TestSpecThreading:
|
||||
"""test.sx suite: threading"""
|
||||
|
||||
def test_thread_first(self):
|
||||
_run('(do (assert-equal 8 (-> 5 (+ 1) (+ 2))) (assert-equal "HELLO" (-> "hello" upcase)) (assert-equal "HELLO WORLD" (-> "hello" (str " world") upcase)))')
|
||||
|
||||
|
||||
class TestSpecTruthiness:
|
||||
"""test.sx suite: truthiness"""
|
||||
|
||||
def test_truthy_values(self):
|
||||
_run('(do (assert-true (if 1 true false)) (assert-true (if "x" true false)) (assert-true (if (list 1) true false)) (assert-true (if true true false)))')
|
||||
|
||||
def test_falsy_values(self):
|
||||
_run('(do (assert-false (if false true false)) (assert-false (if nil true false)))')
|
||||
|
||||
|
||||
class TestSpecEdgeCases:
|
||||
"""test.sx suite: edge-cases"""
|
||||
|
||||
def test_nested_let_scoping(self):
|
||||
_run('(let ((x 1)) (let ((x 2)) (assert-equal 2 x)))')
|
||||
|
||||
def test_recursive_map(self):
|
||||
_run('(assert-equal (list (list 2 4) (list 6 8)) (map (fn (sub) (map (fn (x) (* x 2)) sub)) (list (list 1 2) (list 3 4))))')
|
||||
|
||||
def test_keyword_as_value(self):
|
||||
_run('(do (assert-equal "class" :class) (assert-equal "id" :id))')
|
||||
|
||||
def test_dict_with_evaluated_values(self):
|
||||
_run('(let ((x 42)) (assert-equal 42 (get {:val x} "val")))')
|
||||
|
||||
def test_nil_propagation(self):
|
||||
_run('(do (assert-nil (get {:a 1} "missing")) (assert-equal "default" (or (get {:a 1} "missing") "default")))')
|
||||
|
||||
def test_empty_operations(self):
|
||||
_run('(do (assert-equal (list) (map (fn (x) x) (list))) (assert-equal (list) (filter (fn (x) true) (list))) (assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list))) (assert-equal 0 (len (list))) (assert-equal "" (str)))')
|
||||
|
||||
@@ -8,12 +8,22 @@ from __future__ import annotations
|
||||
import re
|
||||
|
||||
|
||||
def _escape(text: str) -> str:
|
||||
"""Escape a token for embedding in an SX string literal."""
|
||||
return (text
|
||||
.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\t", "\\t")
|
||||
.replace("\r", "\\r"))
|
||||
|
||||
|
||||
def highlight_sx(code: str) -> str:
|
||||
"""Highlight s-expression source code as sx with Tailwind spans."""
|
||||
tokens = _tokenize_sx(code)
|
||||
parts = []
|
||||
for kind, text in tokens:
|
||||
escaped = text.replace("\\", "\\\\").replace('"', '\\"')
|
||||
escaped = _escape(text)
|
||||
if kind == "comment":
|
||||
parts.append(f'(span :class "text-stone-400 italic" "{escaped}")')
|
||||
elif kind == "string":
|
||||
@@ -94,7 +104,7 @@ def highlight_python(code: str) -> str:
|
||||
tokens = _tokenize_python(code)
|
||||
parts = []
|
||||
for kind, text in tokens:
|
||||
escaped = text.replace("\\", "\\\\").replace('"', '\\"')
|
||||
escaped = _escape(text)
|
||||
if kind == "comment":
|
||||
parts.append(f'(span :class "text-stone-400 italic" "{escaped}")')
|
||||
elif kind == "string":
|
||||
@@ -176,7 +186,7 @@ def highlight_bash(code: str) -> str:
|
||||
tokens = _tokenize_bash(code)
|
||||
parts = []
|
||||
for kind, text in tokens:
|
||||
escaped = text.replace("\\", "\\\\").replace('"', '\\"')
|
||||
escaped = _escape(text)
|
||||
if kind == "comment":
|
||||
parts.append(f'(span :class "text-stone-400 italic" "{escaped}")')
|
||||
elif kind == "string":
|
||||
|
||||
@@ -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'
|
||||
';; Run code after content is swapped in\n'
|
||||
'(button :sx-get "/api/items"\n'
|
||||
' :sx-target "#item-list"\n'
|
||||
' "Load items")\n'
|
||||
' (div :id "item-list"))'
|
||||
' :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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1201,13 +1212,21 @@ ATTR_DETAILS: dict[str, dict] = {
|
||||
"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. "
|
||||
'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": {
|
||||
|
||||
67
sx/sx/async-io-demo.sx
Normal file
67
sx/sx/async-io-demo.sx
Normal file
@@ -0,0 +1,67 @@
|
||||
;; Async IO demo — Phase 5 client-side rendering with IO primitives.
|
||||
;;
|
||||
;; This component calls `highlight` inline — an IO primitive that runs
|
||||
;; server-side Python. When rendered on the server, it executes
|
||||
;; synchronously. When rendered client-side, the async renderer proxies
|
||||
;; the call via /sx/io/highlight and awaits the result.
|
||||
;;
|
||||
;; `highlight` returns SxExpr — SX source with colored spans — which the
|
||||
;; evaluator renders as DOM. The same SxExpr flows through the IO proxy:
|
||||
;; server serializes → client parses → async renderer renders to DOM.
|
||||
;;
|
||||
;; Open browser console and look for:
|
||||
;; "sx:route client+async" — async render with IO proxy
|
||||
;; "sx:io registered N proxied primitives" — IO proxy initialization
|
||||
|
||||
(defcomp ~async-io-demo-content ()
|
||||
(div :class "space-y-8"
|
||||
(div :class "border-b border-stone-200 pb-6"
|
||||
(h1 :class "text-2xl font-bold text-stone-900" "Async IO Demo")
|
||||
(p :class "mt-2 text-stone-600"
|
||||
"This page calls " (code :class "bg-stone-100 px-1 rounded text-violet-700" "highlight")
|
||||
" inline — an IO primitive that returns SX source with colored spans. "
|
||||
"On the server it runs Python directly. On the client it proxies via "
|
||||
(code :class "bg-stone-100 px-1 rounded text-violet-700" "/sx/io/highlight")
|
||||
" and the async renderer awaits the result."))
|
||||
|
||||
;; Live syntax-highlighted code blocks — each is an IO call
|
||||
(div :class "space-y-6"
|
||||
(h2 :class "text-lg font-semibold text-stone-800" "Live IO: syntax highlighting")
|
||||
|
||||
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
|
||||
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "SX component definition")
|
||||
(~doc-code :code
|
||||
(highlight "(defcomp ~card (&key title subtitle &rest children)\n (div :class \"border rounded-lg p-4 shadow-sm\"\n (h2 :class \"text-lg font-bold\" title)\n (when subtitle\n (p :class \"text-stone-500 text-sm\" subtitle))\n (div :class \"mt-3\" children)))" "lisp")))
|
||||
|
||||
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
|
||||
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Python server code")
|
||||
(~doc-code :code
|
||||
(highlight "from shared.sx.pages import mount_io_endpoint\n\n# The IO proxy serves any allowed primitive:\n# GET /sx/io/highlight?_arg0=code&_arg1=lisp\nasync def io_proxy(name):\n result = await execute_io(name, args, kwargs, ctx)\n return serialize(result)" "python")))
|
||||
|
||||
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
|
||||
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "SX async rendering spec")
|
||||
(~doc-code :code
|
||||
(highlight ";; try-client-route reads io-deps from page registry\n(let ((io-deps (get match \"io-deps\"))\n (has-io (and io-deps (not (empty? io-deps)))))\n ;; Register IO deps as proxied primitives on demand\n (when has-io (register-io-deps io-deps))\n (if has-io\n ;; Async render: IO primitives proxied via /sx/io/<name>\n (do\n (try-async-eval-content content-src env\n (fn (rendered)\n (when rendered\n (swap-rendered-content target rendered pathname))))\n true)\n ;; Sync render: pure components, no IO\n (let ((rendered (try-eval-content content-src env)))\n (swap-rendered-content target rendered pathname))))" "lisp"))))
|
||||
|
||||
;; Architecture explanation
|
||||
(div :class "rounded-lg border border-blue-200 bg-blue-50 p-5 space-y-3"
|
||||
(h2 :class "text-lg font-semibold text-blue-900" "How it works")
|
||||
(ol :class "list-decimal list-inside text-blue-800 space-y-2 text-sm"
|
||||
(li "Server renders the page — " (code "highlight") " runs Python directly")
|
||||
(li "Client receives component definitions including " (code "~async-io-demo-content"))
|
||||
(li "On client navigation, " (code "io-deps") " list routes to async renderer")
|
||||
(li (code "register-io-deps") " ensures each IO name is proxied via " (code "registerProxiedIo"))
|
||||
(li "Proxied call: " (code "fetch(\"/sx/io/highlight?_arg0=...&_arg1=lisp\")"))
|
||||
(li "Server runs highlight, returns SX source (colored span elements)")
|
||||
(li "Client parses SX → AST, async renderer recursively renders to DOM")))
|
||||
|
||||
;; Verification 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 async IO rendering")
|
||||
(ol :class "list-decimal list-inside text-amber-700 space-y-1"
|
||||
(li "Open the browser console (F12)")
|
||||
(li "Navigate to another page (e.g. Data Test)")
|
||||
(li "Click back to this page")
|
||||
(li "Look for: " (code :class "bg-amber-100 px-1 rounded" "sx:route client+async /isomorphism/async-io"))
|
||||
(li "The code blocks should render identically — same syntax highlighting")
|
||||
(li "Check Network tab: you'll see 3 requests to " (code :class "bg-amber-100 px-1 rounded" "/sx/io/highlight"))))))
|
||||
@@ -49,3 +49,13 @@
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "routing-analyzer-data"
|
||||
: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")))))
|
||||
127
sx/sx/essays.sx
127
sx/sx/essays.sx
File diff suppressed because one or more lines are too long
@@ -78,6 +78,8 @@
|
||||
:summary "A web where pages can inspect, modify, and extend their own rendering pipeline.")
|
||||
(dict :label "Server Architecture" :href "/essays/server-architecture"
|
||||
:summary "How SX enforces the boundary between host and embedded language, and what it looks like across targets.")
|
||||
(dict :label "SX and AI" :href "/essays/sx-and-ai"
|
||||
:summary "Why s-expressions are the most AI-friendly representation for web interfaces.")
|
||||
(dict :label "sx sucks" :href "/essays/sx-sucks"
|
||||
:summary "An honest accounting of everything wrong with SX and why you probably shouldn't use it.")))
|
||||
|
||||
@@ -100,17 +102,34 @@
|
||||
(dict :label "CSSX" :href "/specs/cssx")
|
||||
(dict :label "Continuations" :href "/specs/continuations")
|
||||
(dict :label "call/cc" :href "/specs/callcc")
|
||||
(dict :label "Deps" :href "/specs/deps")))
|
||||
(dict :label "Deps" :href "/specs/deps")
|
||||
(dict :label "Router" :href "/specs/router")
|
||||
(dict :label "Testing" :href "/specs/testing")))
|
||||
|
||||
(define isomorphism-nav-items (list
|
||||
(dict :label "Roadmap" :href "/isomorphism/")
|
||||
(dict :label "Bundle Analyzer" :href "/isomorphism/bundle-analyzer")))
|
||||
(dict :label "Bundle Analyzer" :href "/isomorphism/bundle-analyzer")
|
||||
(dict :label "Routing Analyzer" :href "/isomorphism/routing-analyzer")
|
||||
(dict :label "Data Test" :href "/isomorphism/data-test")
|
||||
(dict :label "Async IO" :href "/isomorphism/async-io")))
|
||||
|
||||
(define plans-nav-items (list
|
||||
(dict :label "Status" :href "/plans/status"
|
||||
:summary "Audit of all plans — what's done, what's in progress, and what remains.")
|
||||
(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.")
|
||||
(dict :label "Fragment Protocol" :href "/plans/fragment-protocol"
|
||||
:summary "Structured sexp request/response for cross-service component transfer.")
|
||||
(dict :label "Glue Decoupling" :href "/plans/glue-decoupling"
|
||||
:summary "Eliminate all cross-app model imports via glue service layer.")
|
||||
(dict :label "Social Sharing" :href "/plans/social-sharing"
|
||||
:summary "OAuth-based sharing to Facebook, Instagram, Threads, Twitter/X, LinkedIn, and Mastodon.")))
|
||||
|
||||
(define bootstrappers-nav-items (list
|
||||
(dict :label "Overview" :href "/bootstrappers/")
|
||||
@@ -175,7 +194,13 @@
|
||||
(define module-spec-items (list
|
||||
(dict :slug "deps" :filename "deps.sx" :title "Deps"
|
||||
:desc "Component dependency analysis and IO detection — per-page bundling, transitive closure, CSS scoping, pure/IO classification."
|
||||
:prose "The deps module analyzes component dependency graphs and classifies components as pure or IO-dependent. Phase 1 (bundling): walks component AST bodies to find transitive ~component references, computes the minimal set needed per page, and collects per-page CSS classes from only the used components. Phase 2 (IO detection): scans component ASTs for references to IO primitive names (from boundary.sx declarations — frag, query, service, current-user, highlight, etc.), computes transitive IO refs through the component graph, and caches the result on each component. Components with no transitive IO refs are pure — they can render anywhere without server data. IO-dependent components must expand server-side. The spec provides the classification; each host's async partial evaluator acts on it (expand IO-dependent server-side, serialize pure for client). All functions are pure — each host bootstraps them to native code via --spec-modules deps. Platform functions (component-deps, component-set-deps!, component-css-classes, component-io-refs, component-set-io-refs!, env-components, regex-find-all, scan-css-classes) are implemented natively per target.")))
|
||||
:prose "The deps module analyzes component dependency graphs and classifies components as pure or IO-dependent. Phase 1 (bundling): walks component AST bodies to find transitive ~component references, computes the minimal set needed per page, and collects per-page CSS classes from only the used components. Phase 2 (IO detection): scans component ASTs for references to IO primitive names (from boundary.sx declarations — frag, query, service, current-user, highlight, etc.), computes transitive IO refs through the component graph, and caches the result on each component. Components with no transitive IO refs are pure — they can render anywhere without server data. IO-dependent components must expand server-side. The spec provides the classification; each host's async partial evaluator acts on it (expand IO-dependent server-side, serialize pure for client). All functions are pure — each host bootstraps them to native code via --spec-modules deps. Platform functions (component-deps, component-set-deps!, component-css-classes, component-io-refs, component-set-io-refs!, env-components, regex-find-all, scan-css-classes) are implemented natively per target.")
|
||||
(dict :slug "router" :filename "router.sx" :title "Router"
|
||||
:desc "Client-side route matching — Flask-style pattern parsing, segment matching, route table search."
|
||||
:prose "The router module provides pure functions for matching URL paths against Flask-style route patterns (e.g. /docs/<slug>). Used by client-side routing (Phase 3) to determine if a page can be rendered locally without a server roundtrip. split-path-segments breaks a path into segments, parse-route-pattern converts patterns into typed segment descriptors, match-route-segments tests a path against a parsed pattern returning extracted params, and find-matching-route searches a route table for the first match. No platform interface needed — uses only pure string and list primitives. Bootstrapped via --spec-modules deps,router.")
|
||||
(dict :slug "testing" :filename "test.sx" :title "Testing"
|
||||
:desc "Self-hosting test framework — SX tests SX. Bootstraps to pytest and Node.js TAP."
|
||||
:prose "The test spec defines a minimal test framework in SX that bootstraps to every host. Tests are written in SX and verify SX semantics — the language tests itself. The framework uses only primitives already in primitives.sx (assert, equal?, type-of, str, list, len) plus assertion helpers defined in SX (assert-equal, assert-true, assert-false, assert-nil, assert-type, assert-length, assert-contains). Two bootstrap compilers read test.sx and emit native test files: bootstrap_test.py produces a pytest module, bootstrap_test_js.py produces a Node.js TAP script. The same 81 tests run on both platforms, verifying cross-host parity.")))
|
||||
|
||||
(define all-spec-items (concat core-spec-items (concat adapter-spec-items (concat browser-spec-items (concat extension-spec-items module-spec-items)))))
|
||||
|
||||
|
||||
1137
sx/sx/plans.sx
1137
sx/sx/plans.sx
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
96
sx/sx/routing-analyzer.sx
Normal file
96
sx/sx/routing-analyzer.sx
Normal file
@@ -0,0 +1,96 @@
|
||||
;; Routing analyzer — live demonstration of client-side routing classification.
|
||||
;; Shows which pages route client-side (pure, instant) vs server-side (IO/data).
|
||||
;; @css bg-green-100 text-green-800 bg-violet-600 bg-stone-200 text-violet-600 text-stone-600 text-green-600 rounded-full h-2.5 grid-cols-2 bg-blue-100 text-blue-800 bg-amber-100 text-amber-800 grid-cols-4 marker:text-stone-400 bg-blue-50 bg-amber-50 text-blue-700 text-amber-700 border-blue-200 border-amber-200 bg-blue-500 bg-amber-500 grid-cols-3 border-green-200 bg-green-50 text-green-700
|
||||
|
||||
(defcomp ~routing-analyzer-content (&key pages total-pages client-count
|
||||
server-count registry-sample)
|
||||
(~doc-page :title "Routing Analyzer"
|
||||
|
||||
(p :class "text-stone-600 mb-6"
|
||||
"Live classification of all " (strong (str total-pages)) " pages by routing mode. "
|
||||
"Pages without " (code ":data") " dependencies are "
|
||||
(span :class "text-green-700 font-medium" "client-routable")
|
||||
" — after initial load they render instantly from the page registry without a server roundtrip. "
|
||||
"Pages with data dependencies fall back to "
|
||||
(span :class "text-amber-700 font-medium" "server fetch")
|
||||
" transparently. Powered by "
|
||||
(a :href "/specs/router" :class "text-violet-700 underline" "router.sx")
|
||||
" route matching and "
|
||||
(a :href "/specs/deps" :class "text-violet-700 underline" "deps.sx")
|
||||
" IO detection.")
|
||||
|
||||
(div :class "mb-8 grid grid-cols-4 gap-4"
|
||||
(~analyzer-stat :label "Total Pages" :value (str total-pages)
|
||||
:cls "text-violet-600")
|
||||
(~analyzer-stat :label "Client-Routable" :value (str client-count)
|
||||
:cls "text-green-600")
|
||||
(~analyzer-stat :label "Server-Only" :value (str server-count)
|
||||
:cls "text-amber-600")
|
||||
(~analyzer-stat :label "Client Ratio" :value (str (round (* (/ client-count total-pages) 100)) "%")
|
||||
:cls "text-blue-600"))
|
||||
|
||||
;; Route classification bar
|
||||
(div :class "mb-8"
|
||||
(div :class "flex items-center gap-2 mb-2"
|
||||
(span :class "text-sm font-medium text-stone-600" "Client")
|
||||
(div :class "flex-1")
|
||||
(span :class "text-sm font-medium text-stone-600" "Server"))
|
||||
(div :class "w-full bg-amber-200 rounded-full h-4 overflow-hidden"
|
||||
(div :class "bg-green-500 h-4 rounded-l-full transition-all"
|
||||
:style (str "width: " (round (* (/ client-count total-pages) 100)) "%"))))
|
||||
|
||||
(~doc-section :title "Route Table" :id "routes"
|
||||
(div :class "space-y-2"
|
||||
(map (fn (page)
|
||||
(~routing-row
|
||||
:name (get page "name")
|
||||
:path (get page "path")
|
||||
:mode (get page "mode")
|
||||
:has-data (get page "has-data")
|
||||
:content-expr (get page "content-expr")
|
||||
:reason (get page "reason")))
|
||||
pages)))
|
||||
|
||||
(~doc-section :title "Page Registry Format" :id "registry"
|
||||
(p :class "text-stone-600 mb-4"
|
||||
"The server serializes page metadata as SX dict literals inside "
|
||||
(code "<script type=\"text/sx-pages\">")
|
||||
". The client's parser reads these at boot, building a route table with parsed URL patterns. "
|
||||
"No JSON involved — the same SX parser handles everything.")
|
||||
(when (not (empty? registry-sample))
|
||||
(div :class "not-prose"
|
||||
(pre :class "text-xs leading-relaxed whitespace-pre-wrap overflow-x-auto bg-stone-100 rounded border border-stone-200 p-4"
|
||||
(code (highlight registry-sample "lisp"))))))
|
||||
|
||||
(~doc-section :title "How Client Routing Works" :id "how"
|
||||
(ol :class "list-decimal pl-5 space-y-2 text-stone-700"
|
||||
(li (strong "Boot: ") "boot.sx finds " (code "<script type=\"text/sx-pages\">") ", calls " (code "parse") " on the SX content, then " (code "parse-route-pattern") " on each page's path to build " (code "_page-routes") ".")
|
||||
(li (strong "Click: ") "orchestration.sx intercepts boost link clicks via " (code "bind-client-route-link") ". Extracts the pathname from the href.")
|
||||
(li (strong "Match: ") (code "find-matching-route") " from router.sx tests the pathname against all parsed patterns. Returns the first match with extracted URL params.")
|
||||
(li (strong "Check: ") "If the matched page has " (code ":has-data true") ", skip to server fetch. Otherwise proceed to client eval.")
|
||||
(li (strong "Eval: ") (code "try-eval-content") " merges the component env + URL params + closure, then parses and renders the content expression to DOM.")
|
||||
(li (strong "Swap: ") "On success, the rendered DOM replaces " (code "#main-panel") " contents, " (code "pushState") " updates the URL, and the console logs " (code "sx:route client /path") ".")
|
||||
(li (strong "Fallback: ") "If anything fails (no match, eval error, missing component), the click falls through to a standard server fetch. Console logs " (code "sx:route server /path") ". The user sees no difference.")))))
|
||||
|
||||
(defcomp ~routing-row (&key name path mode has-data content-expr reason)
|
||||
(div :class (str "rounded border p-3 flex items-center gap-3 "
|
||||
(if (= mode "client")
|
||||
"border-green-200 bg-green-50"
|
||||
"border-amber-200 bg-amber-50"))
|
||||
;; Mode badge
|
||||
(span :class (str "inline-block px-2 py-0.5 rounded text-xs font-bold uppercase "
|
||||
(if (= mode "client")
|
||||
"bg-green-600 text-white"
|
||||
"bg-amber-500 text-white"))
|
||||
mode)
|
||||
;; Page info
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "flex items-center gap-2"
|
||||
(span :class "font-mono font-semibold text-stone-800 text-sm" name)
|
||||
(span :class "text-stone-400 text-xs font-mono" path))
|
||||
(when reason
|
||||
(div :class "text-xs text-stone-500 mt-0.5" reason)))
|
||||
;; Content expression
|
||||
(when content-expr
|
||||
(div :class "hidden md:block max-w-xs truncate"
|
||||
(code :class "text-xs text-stone-500" content-expr)))))
|
||||
@@ -182,7 +182,8 @@ continuations.sx depends on: eval (optional)
|
||||
callcc.sx depends on: eval (optional)
|
||||
|
||||
;; Spec modules (optional — loaded via --spec-modules)
|
||||
deps.sx depends on: eval (optional)")))
|
||||
deps.sx depends on: eval (optional)
|
||||
router.sx (standalone — pure string/list ops)")))
|
||||
|
||||
(div :class "space-y-3"
|
||||
(h2 :class "text-2xl font-semibold text-stone-800" "Extensions")
|
||||
|
||||
234
sx/sx/testing.sx
Normal file
234
sx/sx/testing.sx
Normal file
@@ -0,0 +1,234 @@
|
||||
;; Testing spec page — SX tests SX.
|
||||
|
||||
(defcomp ~spec-testing-content (&key spec-source)
|
||||
(~doc-page :title "Testing"
|
||||
(div :class "space-y-8"
|
||||
|
||||
;; Intro
|
||||
(div :class "space-y-4"
|
||||
(p :class "text-lg text-stone-600"
|
||||
"SX tests itself. "
|
||||
(code :class "text-violet-700 text-sm" "test.sx")
|
||||
" is a self-executing test spec — it defines "
|
||||
(code :class "text-violet-700 text-sm" "deftest")
|
||||
" and "
|
||||
(code :class "text-violet-700 text-sm" "defsuite")
|
||||
" as macros, writes 81 test cases, and runs them. Any host that provides five platform functions can evaluate the file directly.")
|
||||
(p :class "text-stone-600"
|
||||
"This is not a test "
|
||||
(em "of") " SX — it is a test " (em "in") " SX. The same s-expressions that define how "
|
||||
(code :class "text-violet-700 text-sm" "if")
|
||||
" works are used to verify that "
|
||||
(code :class "text-violet-700 text-sm" "if")
|
||||
" works. No code generation, no intermediate files — the evaluator runs the spec."))
|
||||
|
||||
;; Live test runner
|
||||
(div :class "space-y-3"
|
||||
(h2 :class "text-2xl font-semibold text-stone-800" "Run in browser")
|
||||
(p :class "text-stone-600"
|
||||
"This page loaded "
|
||||
(code :class "text-violet-700 text-sm" "sx-browser.js")
|
||||
" to render itself. The same evaluator can run "
|
||||
(code :class "text-violet-700 text-sm" "test.sx")
|
||||
" right here — SX testing SX, in your browser:")
|
||||
(div :class "flex items-center gap-4"
|
||||
(button :id "test-btn"
|
||||
:class "px-4 py-2 rounded-md bg-violet-600 text-white font-medium text-sm hover:bg-violet-700 cursor-pointer"
|
||||
:onclick "sxRunTests('test-sx-source','test-output','test-btn')"
|
||||
"Run 81 tests"))
|
||||
(pre :id "test-output"
|
||||
:class "text-sm font-mono bg-stone-900 text-green-400 rounded-lg p-4 overflow-x-auto max-h-96 overflow-y-auto"
|
||||
:style "display:none"
|
||||
"")
|
||||
;; Hidden: raw test.sx source for the browser runner
|
||||
(textarea :id "test-sx-source" :style "display:none" spec-source)
|
||||
;; Load the test runner script
|
||||
(script :src (asset-url "/scripts/sx-test-runner.js")))
|
||||
|
||||
;; How it works
|
||||
(div :class "space-y-3"
|
||||
(h2 :class "text-2xl font-semibold text-stone-800" "Architecture")
|
||||
(p :class "text-stone-600"
|
||||
"The test framework needs five platform functions. Everything else — macros, assertion helpers, test suites — is pure SX:")
|
||||
(div :class "not-prose bg-stone-100 rounded-lg p-5 mx-auto max-w-3xl"
|
||||
(pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words font-mono text-stone-700"
|
||||
"test.sx Self-executing: macros + helpers + 81 tests
|
||||
|
|
||||
|--- browser sx-browser.js evaluates test.sx in this page
|
||||
|
|
||||
|--- run.js Injects 5 platform fns, evaluates test.sx
|
||||
| |
|
||||
| +-> sx-browser.js JS evaluator (bootstrapped from spec)
|
||||
|
|
||||
|--- run.py Injects 5 platform fns, evaluates test.sx
|
||||
|
|
||||
+-> evaluator.py Python evaluator
|
||||
|
||||
Platform functions (5 total — everything else is pure SX):
|
||||
try-call (thunk) -> {:ok true} | {:ok false :error \"msg\"}
|
||||
report-pass (name) -> output pass
|
||||
report-fail (name error) -> output fail
|
||||
push-suite (name) -> push suite context
|
||||
pop-suite () -> pop suite context")))
|
||||
|
||||
;; Framework
|
||||
(div :class "space-y-3"
|
||||
(h2 :class "text-2xl font-semibold text-stone-800" "The test framework")
|
||||
(p :class "text-stone-600"
|
||||
"The framework defines two macros and nine assertion helpers, all in SX. The macros are the key — they make "
|
||||
(code :class "text-violet-700 text-sm" "defsuite")
|
||||
" and "
|
||||
(code :class "text-violet-700 text-sm" "deftest")
|
||||
" executable forms, not just declarations:")
|
||||
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
|
||||
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Macros")
|
||||
(~doc-code :code
|
||||
(highlight "(defmacro deftest (name &rest body)\n `(let ((result (try-call (fn () ,@body))))\n (if (get result \"ok\")\n (report-pass ,name)\n (report-fail ,name (get result \"error\")))))\n\n(defmacro defsuite (name &rest items)\n `(do (push-suite ,name)\n ,@items\n (pop-suite)))" "lisp")))
|
||||
(p :class "text-stone-600 text-sm"
|
||||
(code :class "text-violet-700 text-sm" "deftest")
|
||||
" wraps the body in a thunk, passes it to "
|
||||
(code :class "text-violet-700 text-sm" "try-call")
|
||||
" (the one platform function that catches errors), then reports pass or fail. "
|
||||
(code :class "text-violet-700 text-sm" "defsuite")
|
||||
" pushes a name onto the context stack, runs its children, and pops.")
|
||||
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
|
||||
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Assertion helpers")
|
||||
(~doc-code :code
|
||||
(highlight "(define assert-equal\n (fn (expected actual)\n (assert (equal? expected actual)\n (str \"Expected \" (str expected) \" but got \" (str actual)))))\n\n(define assert-true (fn (val) (assert val ...)))\n(define assert-false (fn (val) (assert (not val) ...)))\n(define assert-nil (fn (val) (assert (nil? val) ...)))\n(define assert-type (fn (expected-type val) ...))\n(define assert-length (fn (expected-len col) ...))\n(define assert-contains (fn (item col) ...))\n(define assert-throws (fn (thunk) ...))" "lisp"))))
|
||||
|
||||
;; Example tests
|
||||
(div :class "space-y-3"
|
||||
(h2 :class "text-2xl font-semibold text-stone-800" "Example: SX testing SX")
|
||||
(p :class "text-stone-600"
|
||||
"The test suites cover every language feature. Here is the arithmetic suite testing the evaluator's arithmetic primitives:")
|
||||
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
|
||||
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "From test.sx")
|
||||
(~doc-code :code
|
||||
(highlight "(defsuite \"arithmetic\"\n (deftest \"addition\"\n (assert-equal 3 (+ 1 2))\n (assert-equal 0 (+ 0 0))\n (assert-equal -1 (+ 1 -2))\n (assert-equal 10 (+ 1 2 3 4)))\n\n (deftest \"subtraction\"\n (assert-equal 1 (- 3 2))\n (assert-equal -1 (- 2 3)))\n\n (deftest \"multiplication\"\n (assert-equal 6 (* 2 3))\n (assert-equal 0 (* 0 100))\n (assert-equal 24 (* 1 2 3 4)))\n\n (deftest \"division\"\n (assert-equal 2 (/ 6 3))\n (assert-equal 2.5 (/ 5 2)))\n\n (deftest \"modulo\"\n (assert-equal 1 (mod 7 3))\n (assert-equal 0 (mod 6 3))))" "lisp"))))
|
||||
|
||||
;; Running tests — JS
|
||||
(div :class "space-y-3"
|
||||
(h2 :class "text-2xl font-semibold text-stone-800" "JavaScript: direct evaluation")
|
||||
(p :class "text-stone-600"
|
||||
(code :class "text-violet-700 text-sm" "sx-browser.js")
|
||||
" evaluates "
|
||||
(code :class "text-violet-700 text-sm" "test.sx")
|
||||
" directly. The runner injects platform functions and calls "
|
||||
(code :class "text-violet-700 text-sm" "Sx.eval")
|
||||
" on each parsed expression:")
|
||||
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
|
||||
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "run.js")
|
||||
(~doc-code :code
|
||||
(highlight "var Sx = require('./sx-browser.js');\nvar src = fs.readFileSync('test.sx', 'utf8');\n\nvar env = {\n 'try-call': function(thunk) {\n try {\n Sx.eval([thunk], env); // call the SX lambda\n return { ok: true };\n } catch(e) {\n return { ok: false, error: e.message };\n }\n },\n 'report-pass': function(name) { console.log('ok - ' + name); },\n 'report-fail': function(name, err) { console.log('not ok - ' + name); },\n 'push-suite': function(n) { stack.push(n); },\n 'pop-suite': function() { stack.pop(); },\n};\n\nvar exprs = Sx.parseAll(src);\nfor (var i = 0; i < exprs.length; i++) Sx.eval(exprs[i], env);" "javascript")))
|
||||
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
|
||||
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Output")
|
||||
(~doc-code :code
|
||||
(highlight "$ node shared/sx/tests/run.js\nTAP version 13\nok 1 - literals > numbers are numbers\nok 2 - literals > strings are strings\n...\nok 81 - edge-cases > empty operations\n\n# tests 81\n# pass 81" "bash"))))
|
||||
|
||||
;; Running tests — Python
|
||||
(div :class "space-y-3"
|
||||
(h2 :class "text-2xl font-semibold text-stone-800" "Python: direct evaluation")
|
||||
(p :class "text-stone-600"
|
||||
"Same approach — the Python evaluator runs "
|
||||
(code :class "text-violet-700 text-sm" "test.sx")
|
||||
" directly:")
|
||||
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
|
||||
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "run.py")
|
||||
(~doc-code :code
|
||||
(highlight "from shared.sx.parser import parse_all\nfrom shared.sx.evaluator import _eval, _trampoline\n\ndef try_call(thunk):\n try:\n _trampoline(_eval([thunk], {}))\n return {'ok': True}\n except Exception as e:\n return {'ok': False, 'error': str(e)}\n\nenv = {\n 'try-call': try_call,\n 'report-pass': report_pass,\n 'report-fail': report_fail,\n 'push-suite': push_suite,\n 'pop-suite': pop_suite,\n}\n\nfor expr in parse_all(src):\n _trampoline(_eval(expr, env))" "python")))
|
||||
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
|
||||
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Output")
|
||||
(~doc-code :code
|
||||
(highlight "$ python shared/sx/tests/run.py\nTAP version 13\nok 1 - literals > numbers are numbers\n...\nok 81 - edge-cases > empty operations\n\n# tests 81\n# pass 81" "bash"))))
|
||||
|
||||
;; What it proves
|
||||
(div :class "rounded-lg border border-blue-200 bg-blue-50 p-5 space-y-3"
|
||||
(h2 :class "text-lg font-semibold text-blue-900" "What this proves")
|
||||
(ol :class "list-decimal list-inside text-blue-800 space-y-2 text-sm"
|
||||
(li "The test spec is " (strong "written in SX") " and " (strong "executed by SX") " — no code generation")
|
||||
(li "The same 81 tests run on " (strong "Python, Node.js, and in the browser") " from the same file")
|
||||
(li "Each host provides only " (strong "5 platform functions") " — everything else is pure SX")
|
||||
(li "Adding a new host means implementing 5 functions, not rewriting tests")
|
||||
(li "Platform divergences (truthiness of 0, [], \"\") are " (strong "documented, not hidden"))
|
||||
(li "The spec is " (strong "executable") " — click the button above to prove it")))
|
||||
|
||||
;; Test suites
|
||||
(div :class "space-y-3"
|
||||
(h2 :class "text-2xl font-semibold text-stone-800" "All 15 test suites")
|
||||
(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" "Suite")
|
||||
(th :class "px-3 py-2 font-medium text-stone-600" "Tests")
|
||||
(th :class "px-3 py-2 font-medium text-stone-600" "Covers")))
|
||||
(tbody
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "literals")
|
||||
(td :class "px-3 py-2" "6")
|
||||
(td :class "px-3 py-2 text-stone-700" "number, string, boolean, nil, list, dict type checking"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "arithmetic")
|
||||
(td :class "px-3 py-2" "5")
|
||||
(td :class "px-3 py-2 text-stone-700" "+, -, *, /, mod with edge cases"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "comparison")
|
||||
(td :class "px-3 py-2" "3")
|
||||
(td :class "px-3 py-2 text-stone-700" "=, equal?, <, >, <=, >="))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "strings")
|
||||
(td :class "px-3 py-2" "7")
|
||||
(td :class "px-3 py-2 text-stone-700" "str, string-length, substring, contains?, upcase, trim, split/join"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "lists")
|
||||
(td :class "px-3 py-2" "10")
|
||||
(td :class "px-3 py-2 text-stone-700" "first, rest, nth, last, cons, append, reverse, empty?, contains?, flatten"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "dicts")
|
||||
(td :class "px-3 py-2" "6")
|
||||
(td :class "px-3 py-2 text-stone-700" "literals, get, assoc, dissoc, keys/vals, has-key?, merge"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "predicates")
|
||||
(td :class "px-3 py-2" "7")
|
||||
(td :class "px-3 py-2 text-stone-700" "nil?, number?, string?, list?, dict?, boolean?, not"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "special-forms")
|
||||
(td :class "px-3 py-2" "10")
|
||||
(td :class "px-3 py-2 text-stone-700" "if, when, cond, and, or, let, let (Clojure), do/begin, define, set!"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "lambdas")
|
||||
(td :class "px-3 py-2" "5")
|
||||
(td :class "px-3 py-2 text-stone-700" "basic, closures, as argument, recursion, higher-order returns"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "higher-order")
|
||||
(td :class "px-3 py-2" "6")
|
||||
(td :class "px-3 py-2 text-stone-700" "map, filter, reduce, some, every?, map-indexed"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "components")
|
||||
(td :class "px-3 py-2" "4")
|
||||
(td :class "px-3 py-2 text-stone-700" "defcomp, &key params, &rest children, defaults"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "macros")
|
||||
(td :class "px-3 py-2" "3")
|
||||
(td :class "px-3 py-2 text-stone-700" "defmacro, quasiquote/unquote, splice-unquote"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "threading")
|
||||
(td :class "px-3 py-2" "1")
|
||||
(td :class "px-3 py-2 text-stone-700" "-> thread-first macro"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "truthiness")
|
||||
(td :class "px-3 py-2" "2")
|
||||
(td :class "px-3 py-2 text-stone-700" "truthy/falsy values (platform-universal subset)"))
|
||||
(tr
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "edge-cases")
|
||||
(td :class "px-3 py-2" "6")
|
||||
(td :class "px-3 py-2 text-stone-700" "nested scoping, recursive map, keywords, dict eval, nil propagation, empty ops"))))))
|
||||
|
||||
;; Full source
|
||||
(div :class "space-y-3"
|
||||
(h2 :class "text-2xl font-semibold text-stone-800" "Full specification source")
|
||||
(p :class "text-xs text-stone-400 italic"
|
||||
"The s-expression source below is the canonical test specification. "
|
||||
"Any host that implements the five platform functions can evaluate it directly.")
|
||||
(div :class "not-prose bg-stone-100 rounded-lg p-5 mx-auto max-w-3xl"
|
||||
(pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words"
|
||||
(code (highlight spec-source "sx"))))))))
|
||||
@@ -281,6 +281,7 @@
|
||||
"godel-escher-bach" (~essay-godel-escher-bach)
|
||||
"reflexive-web" (~essay-reflexive-web)
|
||||
"server-architecture" (~essay-server-architecture)
|
||||
"sx-and-ai" (~essay-sx-and-ai)
|
||||
:else (~essays-index-content)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
@@ -341,6 +342,8 @@
|
||||
:filename (get item "filename") :href (str "/specs/" (get item "slug"))
|
||||
:source (read-spec-file (get item "filename"))))
|
||||
extension-spec-items))
|
||||
"testing" (~spec-testing-content
|
||||
:spec-source (read-spec-file "test.sx"))
|
||||
:else (let ((spec (find-spec slug)))
|
||||
(if spec
|
||||
(~spec-detail-content
|
||||
@@ -402,6 +405,60 @@
|
||||
:selected "Roadmap")
|
||||
:content (~plan-isomorphic-content))
|
||||
|
||||
(defpage bundle-analyzer
|
||||
:path "/isomorphism/bundle-analyzer"
|
||||
:auth :public
|
||||
:layout (:sx-section
|
||||
:section "Isomorphism"
|
||||
:sub-label "Isomorphism"
|
||||
:sub-href "/isomorphism/"
|
||||
:sub-nav (~section-nav :items isomorphism-nav-items :current "Bundle Analyzer")
|
||||
:selected "Bundle Analyzer")
|
||||
:data (bundle-analyzer-data)
|
||||
:content (~bundle-analyzer-content
|
||||
:pages pages :total-components total-components :total-macros total-macros
|
||||
:pure-count pure-count :io-count io-count))
|
||||
|
||||
(defpage routing-analyzer
|
||||
:path "/isomorphism/routing-analyzer"
|
||||
:auth :public
|
||||
:layout (:sx-section
|
||||
:section "Isomorphism"
|
||||
:sub-label "Isomorphism"
|
||||
:sub-href "/isomorphism/"
|
||||
:sub-nav (~section-nav :items isomorphism-nav-items :current "Routing Analyzer")
|
||||
:selected "Routing Analyzer")
|
||||
:data (routing-analyzer-data)
|
||||
:content (~routing-analyzer-content
|
||||
:pages pages :total-pages total-pages :client-count client-count
|
||||
:server-count server-count :registry-sample registry-sample))
|
||||
|
||||
(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))
|
||||
|
||||
(defpage async-io-demo
|
||||
:path "/isomorphism/async-io"
|
||||
:auth :public
|
||||
:layout (:sx-section
|
||||
:section "Isomorphism"
|
||||
:sub-label "Isomorphism"
|
||||
:sub-href "/isomorphism/"
|
||||
:sub-nav (~section-nav :items isomorphism-nav-items :current "Async IO")
|
||||
:selected "Async IO")
|
||||
:content (~async-io-demo-content))
|
||||
|
||||
;; Wildcard must come AFTER specific routes (first-match routing)
|
||||
(defpage isomorphism-page
|
||||
:path "/isomorphism/<slug>"
|
||||
:auth :public
|
||||
@@ -416,22 +473,11 @@
|
||||
"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
|
||||
:layout (:sx-section
|
||||
:section "Isomorphism"
|
||||
:sub-label "Isomorphism"
|
||||
:sub-href "/isomorphism/"
|
||||
:sub-nav (~section-nav :items isomorphism-nav-items :current "Bundle Analyzer")
|
||||
:selected "Bundle Analyzer")
|
||||
:data (bundle-analyzer-data)
|
||||
:content (~bundle-analyzer-content
|
||||
:pages pages :total-components total-components :total-macros total-macros
|
||||
:pure-count pure-count :io-count io-count))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Plans section
|
||||
;; ---------------------------------------------------------------------------
|
||||
@@ -458,6 +504,12 @@
|
||||
:current (find-current plans-nav-items slug))
|
||||
:selected (or (find-current plans-nav-items slug) ""))
|
||||
:content (case slug
|
||||
"status" (~plan-status-content)
|
||||
"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)
|
||||
"fragment-protocol" (~plan-fragment-protocol-content)
|
||||
"glue-decoupling" (~plan-glue-decoupling-content)
|
||||
"social-sharing" (~plan-social-sharing-content)
|
||||
:else (~plans-index-content)))
|
||||
|
||||
@@ -22,6 +22,8 @@ def _register_sx_helpers() -> None:
|
||||
"read-spec-file": _read_spec_file,
|
||||
"bootstrapper-data": _bootstrapper_data,
|
||||
"bundle-analyzer-data": _bundle_analyzer_data,
|
||||
"routing-analyzer-data": _routing_analyzer_data,
|
||||
"data-test-data": _data_test_data,
|
||||
})
|
||||
|
||||
|
||||
@@ -41,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())
|
||||
|
||||
@@ -342,6 +344,82 @@ def _bundle_analyzer_data() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _routing_analyzer_data() -> dict:
|
||||
"""Compute per-page routing classification for the sx-docs app."""
|
||||
from shared.sx.pages import get_all_pages
|
||||
from shared.sx.parser import serialize as sx_serialize
|
||||
from shared.sx.helpers import _sx_literal
|
||||
|
||||
pages_data = []
|
||||
full_content: list[tuple[str, str, bool]] = [] # (name, full_content, has_data)
|
||||
client_count = 0
|
||||
server_count = 0
|
||||
|
||||
for name, page_def in sorted(get_all_pages("sx").items()):
|
||||
has_data = page_def.data_expr is not None
|
||||
content_src = ""
|
||||
if page_def.content_expr is not None:
|
||||
try:
|
||||
content_src = sx_serialize(page_def.content_expr)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
full_content.append((name, content_src, has_data))
|
||||
|
||||
# Determine routing mode and reason
|
||||
if has_data:
|
||||
mode = "server"
|
||||
reason = "Has :data expression — needs server IO"
|
||||
server_count += 1
|
||||
elif not content_src:
|
||||
mode = "server"
|
||||
reason = "No content expression"
|
||||
server_count += 1
|
||||
else:
|
||||
mode = "client"
|
||||
reason = ""
|
||||
client_count += 1
|
||||
|
||||
pages_data.append({
|
||||
"name": name,
|
||||
"path": page_def.path,
|
||||
"mode": mode,
|
||||
"has-data": has_data,
|
||||
"content-expr": content_src[:80] + ("..." if len(content_src) > 80 else ""),
|
||||
"reason": reason,
|
||||
})
|
||||
|
||||
# Sort: client pages first, then server
|
||||
pages_data.sort(key=lambda p: (0 if p["mode"] == "client" else 1, p["name"]))
|
||||
|
||||
# Build a sample of the SX page registry format (use full content, first 3)
|
||||
total = client_count + server_count
|
||||
sample_entries = []
|
||||
sorted_full = sorted(full_content, key=lambda x: x[0])
|
||||
for name, csrc, hd in sorted_full[:3]:
|
||||
page_def = get_all_pages("sx").get(name)
|
||||
if not page_def:
|
||||
continue
|
||||
entry = (
|
||||
"{:name " + _sx_literal(name)
|
||||
+ "\n :path " + _sx_literal(page_def.path)
|
||||
+ "\n :auth " + _sx_literal("public")
|
||||
+ " :has-data " + ("true" if hd else "false")
|
||||
+ "\n :content " + _sx_literal(csrc)
|
||||
+ "\n :closure {}}"
|
||||
)
|
||||
sample_entries.append(entry)
|
||||
registry_sample = "\n\n".join(sample_entries)
|
||||
|
||||
return {
|
||||
"pages": pages_data,
|
||||
"total-pages": total,
|
||||
"client-count": client_count,
|
||||
"server-count": server_count,
|
||||
"registry-sample": registry_sample,
|
||||
}
|
||||
|
||||
|
||||
def _attr_detail_data(slug: str) -> dict:
|
||||
"""Return attribute detail data for a specific attribute slug.
|
||||
|
||||
@@ -411,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.")))
|
||||
|
||||
@@ -28,6 +28,7 @@ COPY test/ ./test-app-tmp/
|
||||
RUN cp -r test-app-tmp/app.py test-app-tmp/path_setup.py \
|
||||
test-app-tmp/bp test-app-tmp/sx test-app-tmp/services \
|
||||
test-app-tmp/runner.py test-app-tmp/__init__.py ./ 2>/dev/null || true && \
|
||||
([ -d test-app-tmp/sxc ] && cp -r test-app-tmp/sxc ./ || true) && \
|
||||
rm -rf test-app-tmp
|
||||
|
||||
# Sibling models for cross-domain SQLAlchemy imports
|
||||
|
||||
@@ -12,18 +12,32 @@ def register(url_prefix: str = "/") -> Blueprint:
|
||||
@bp.get("/")
|
||||
async def index():
|
||||
"""Full page dashboard with last results."""
|
||||
from shared.sx.page import get_template_context
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from sxc.pages.renders import render_dashboard_page_sx
|
||||
from sxc.pages.renders import render_dashboard_page_sx, render_results_partial_sx
|
||||
import runner
|
||||
|
||||
ctx = await get_template_context()
|
||||
result = runner.get_results()
|
||||
running = runner.is_running()
|
||||
csrf = generate_csrf_token()
|
||||
active_filter = request.args.get("filter")
|
||||
active_service = request.args.get("service")
|
||||
|
||||
is_sx = bool(request.headers.get("SX-Request") or request.headers.get("HX-Request"))
|
||||
|
||||
if is_sx:
|
||||
from shared.sx.helpers import sx_response
|
||||
inner = await render_results_partial_sx(
|
||||
result, running, csrf,
|
||||
active_filter=active_filter,
|
||||
active_service=active_service,
|
||||
)
|
||||
# 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()
|
||||
html = await render_dashboard_page_sx(
|
||||
ctx, result, running, csrf,
|
||||
active_filter=active_filter,
|
||||
@@ -78,6 +92,7 @@ def register(url_prefix: str = "/") -> Blueprint:
|
||||
async def results():
|
||||
"""HTMX partial — poll target for results table."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from shared.sx.helpers import sx_response
|
||||
from sxc.pages.renders import render_results_partial_sx
|
||||
import runner
|
||||
|
||||
@@ -93,10 +108,9 @@ def register(url_prefix: str = "/") -> Blueprint:
|
||||
active_service=active_service,
|
||||
)
|
||||
|
||||
resp = Response(html, status=200, content_type="text/html")
|
||||
# If still running, tell HTMX to keep polling
|
||||
headers = {}
|
||||
if running:
|
||||
resp.headers["HX-Trigger-After-Swap"] = "test-still-running"
|
||||
return resp
|
||||
headers["HX-Trigger-After-Swap"] = "test-still-running"
|
||||
return sx_response(html, headers=headers)
|
||||
|
||||
return bp
|
||||
|
||||
Reference in New Issue
Block a user