Compare commits
30 Commits
6aa2f3f6bd
...
3749fe9625
| Author | SHA1 | Date | |
|---|---|---|---|
| 3749fe9625 | |||
| dd1c1c9a3c | |||
| cf5e767510 | |||
| 631394989c | |||
| a0e39f0014 | |||
| 55adbf6463 | |||
| fbfd203746 | |||
| 65ed8a8941 | |||
| 54814b4258 | |||
| 3482cbdaa6 | |||
| 0ba7ebe349 | |||
| 652e7f81c8 | |||
| 8ff9827d7b | |||
| 07a73821e7 | |||
| 44d5414bc6 | |||
| a90c8bf3fc | |||
| a06400370a | |||
| 0191948b6e | |||
| 9ac1d273e2 | |||
| e36a036873 | |||
| d6ca185975 | |||
| 0ebf3c27fd | |||
| 4c97b03dda | |||
| 6739343a06 | |||
| 2866bcbfc3 | |||
| 1fe53c2032 | |||
| 59a8d2063d | |||
| 624b08997d | |||
| e112bffe5c | |||
| e6cada972e |
41
blog/sx/boundary.sx
Normal file
41
blog/sx/boundary.sx
Normal file
@@ -0,0 +1,41 @@
|
||||
;; Blog service — page helper declarations.
|
||||
|
||||
(define-page-helper "editor-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "editor-page-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-admin-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-data-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-preview-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-entries-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-settings-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-edit-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
@@ -57,6 +57,7 @@ x-app-env: &app-env
|
||||
AP_DOMAIN_EVENTS: events.rose-ash.com
|
||||
EXTERNAL_INBOXES: "artdag|https://celery-artdag.rose-ash.com/inbox"
|
||||
SX_BOUNDARY_STRICT: "1"
|
||||
SX_USE_REF: "1"
|
||||
|
||||
services:
|
||||
blog:
|
||||
|
||||
@@ -1,446 +1,273 @@
|
||||
# Isomorphic SX Architecture Migration Plan
|
||||
# SX Isomorphic Architecture Roadmap
|
||||
|
||||
## Context
|
||||
|
||||
The sx layer already renders full pages client-side — `sx_page()` ships raw sx source + component definitions to the browser, `sx.js` evaluates and renders them. Components are cached in localStorage with a hash-based invalidation protocol (cookie `sx-comp-hash` → server skips sending defs if hash matches).
|
||||
SX has a working server-client pipeline: server evaluates pages with IO (DB, fragments), serializes as SX wire format, client parses and renders to DOM. The language and primitives are already isomorphic — same spec, same semantics, both sides. What's missing is the **plumbing** that makes the boundary between server and client a sliding window rather than a fixed wall.
|
||||
|
||||
**Key insight from the user:** Pages/routes are just components. They belong in the same component registry, cached in localStorage alongside `defcomp` definitions. On navigation, if the client's component hash is current, the server doesn't need to send any s-expression source at all — just data. The client already has the page component cached and renders it locally with fresh data from the API.
|
||||
The key insight: **s-expressions can partially unfold on the server after IO, then finish unfolding on the client.** The system should be clever enough to know which downstream components have data fetches, resolve those server-side, and send the rest as pure SX for client rendering. Eventually, the client can also do IO (mapping server DB queries to REST calls), handle routing (SPA), and even work offline with cached data.
|
||||
|
||||
### Target Architecture
|
||||
## Current State (what's solid)
|
||||
|
||||
```
|
||||
First visit:
|
||||
Server → component defs (including page components) + page data → client caches defs in localStorage
|
||||
- **Primitive parity:** 100%. ~80 pure primitives, same names/semantics, JS and Python.
|
||||
- **eval/parse/render:** Complete both sides. sx-ref.js has eval, parse, render-to-html, render-to-dom, aser.
|
||||
- **Engine:** engine.sx (morph, swaps, triggers, history), orchestration.sx (fetch, events), boot.sx (hydration) — all transpiled.
|
||||
- **Wire format:** Server `_aser` → SX source → client parses → renders to DOM. Boundary is clean.
|
||||
- **Component caching:** Hash-based localStorage for component definitions and style dictionaries.
|
||||
- **CSS on-demand:** CSSX resolves keywords to CSS rules, injects only used rules.
|
||||
- **Boundary enforcement:** `boundary.sx` + `SX_BOUNDARY_STRICT=1` validates all primitives/IO/helpers at registration.
|
||||
|
||||
Subsequent navigation (same session, hash valid):
|
||||
Client has page component cached → fetches only JSON data from /api/data/ → renders locally
|
||||
Server sends: { data: {...} } — zero sx source
|
||||
|
||||
SSR (bots, first paint):
|
||||
Server evaluates the same page component with direct DB queries → sends rendered HTML
|
||||
Client hydrates (binds SxEngine handlers, no re-render)
|
||||
```
|
||||
|
||||
This is React-like data fetching with an s-expression view layer instead of JSX, and the component transport is a content-addressed cache rather than a JS bundle.
|
||||
|
||||
### Data Delivery Modes
|
||||
|
||||
The data side is not a single pattern — it's a spectrum that can be mixed per page and per fragment:
|
||||
|
||||
**Mode A: Server-bundled data** — Server evaluates the page's `:data` slot, resolves all queries (including cross-service `fetch_data` calls), returns one JSON blob. Fewest round-trips. Server aggregates.
|
||||
|
||||
**Mode B: Client-fetched data** — Client evaluates `:data` slot locally. Each `(query ...)` / `(service ...)` hits the relevant service's `/api/data/` endpoint independently. More round-trips but fully decoupled — each service handles its own data.
|
||||
|
||||
**Mode C: Hybrid** — Server bundles same-service data (direct DB). Client fetches cross-service data in parallel from other services' APIs. Mirrors current server pattern: own-domain = SQLAlchemy, cross-domain = `fetch_data()` HTTP.
|
||||
|
||||
The same spectrum applies to **fragments** (`frag` / `fetch_fragment`):
|
||||
|
||||
- **Server-composed:** Server calls `fetch_fragment()` during page evaluation, bakes result into data bundle or renders inline.
|
||||
- **Client-composed:** Client's `(frag ...)` primitive fetches from the service's public fragment endpoint. Fragment returns sx source, client renders locally using cached component defs.
|
||||
- **Mixed:** Stable fragments (nav, auth menu) server-composed; content-specific fragments client-fetched.
|
||||
|
||||
A `(query ...)` or `(frag ...)` call resolves differently depending on execution context (server vs client) but produces the same result. The choice of mode can be per-page, per-fragment, or even per-request.
|
||||
|
||||
## Delivery Order
|
||||
|
||||
```
|
||||
Phase 1 (Primitive Parity) ──┐
|
||||
├── Phase 4 (Client Data Primitives) ──┐
|
||||
Phase 3 (Public Data API) ───┘ ├── Phase 5 (Data-Only Navigation)
|
||||
Phase 2 (Server-Side Rendering) ────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Phases 1-3 are independent. Recommended order: **3 → 1 → 2 → 4 → 5**
|
||||
## Architecture Phases
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Primitive Parity
|
||||
### Phase 1: Component Distribution & Dependency Analysis
|
||||
|
||||
Align JS and Python primitive sets so the same component source evaluates identically on both sides.
|
||||
**What it enables:** Per-page component bundles instead of sending every definition to every page. Smaller payloads, faster boot, better cache hit rates.
|
||||
|
||||
### 1a: Add missing pure primitives to sx.js
|
||||
**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.
|
||||
|
||||
Add to `PRIMITIVES` in `shared/static/scripts/sx.js`:
|
||||
**Approach:**
|
||||
|
||||
| Primitive | JS implementation |
|
||||
|-----------|-------------------|
|
||||
| `clamp` | `Math.max(lo, Math.min(hi, x))` |
|
||||
| `chunk-every` | partition list into n-size sublists |
|
||||
| `zip-pairs` | `[[coll[0],coll[1]], [coll[2],coll[3]], ...]` |
|
||||
| `dissoc` | shallow copy without specified keys |
|
||||
| `into` | target-type-aware merge |
|
||||
| `format-date` | minimal strftime translator covering `%Y %m %d %b %B %H %M %S` |
|
||||
| `parse-int` | `parseInt` with NaN fallback to default |
|
||||
| `assert` | throw if falsy |
|
||||
1. **Transitive closure analyzer** — new module `shared/sx/deps.py`
|
||||
- Walk `Component.body` AST, collect all `Symbol` refs starting with `~`
|
||||
- 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)
|
||||
|
||||
Fix existing parity gaps: `round` needs optional `ndigits`; `min`/`max` need to accept a single list arg.
|
||||
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.
|
||||
|
||||
### 1b: Inject `window.__sxConfig` for server-context primitives
|
||||
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.
|
||||
|
||||
Modify `sx_page()` in `shared/sx/helpers.py` to inject before sx.js:
|
||||
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.
|
||||
|
||||
```js
|
||||
window.__sxConfig = {
|
||||
appUrls: { blog: "https://blog.rose-ash.com", ... },
|
||||
assetUrl: "https://static...",
|
||||
config: { /* public subset */ },
|
||||
currentUser: { id, username, display_name, avatar } | null,
|
||||
relations: [ /* serialized RelationDef list */ ]
|
||||
};
|
||||
```
|
||||
**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
|
||||
|
||||
Sources: `ctx` has `blog_url`, `market_url`, etc. `g.user` has user info. `shared/infrastructure/urls.py` has the URL map.
|
||||
|
||||
Add JS primitives reading from `__sxConfig`: `app-url`, `asset-url`, `config`, `current-user`, `relations-from`.
|
||||
|
||||
`url-for` has no JS equivalent — isomorphic code uses `app-url` instead.
|
||||
|
||||
### 1c: Add `defpage` to sx.js evaluator
|
||||
|
||||
Add `defpage` to `SPECIAL_FORMS`. Parse the declaration, store it in `_componentEnv` under `"page:name"` (same registry as components). The page definition includes: name, path pattern, auth requirement, layout spec, and unevaluated AST for data/content/filter/aside/menu slots.
|
||||
|
||||
Since pages live in `_componentEnv`, they're automatically included in the component hash, cached in localStorage, and skipped when the hash matches. No separate `<script data-pages>` block needed — they ship with components.
|
||||
|
||||
**Files:** `shared/static/scripts/sx.js`, `shared/sx/helpers.py`
|
||||
|
||||
**Verify:** `(format-date "2024-03-15" "%d %b %Y")` produces same output in Python and JS.
|
||||
**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
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Server-Side Rendering (SSR)
|
||||
### Phase 2: Smart Server/Client Boundary
|
||||
|
||||
Full-page HTML rendering on the server for SEO and first-paint.
|
||||
**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."
|
||||
|
||||
### 2a: Add `render_mode` to `execute_page()`
|
||||
**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.
|
||||
|
||||
In `shared/sx/pages.py`:
|
||||
**Approach:**
|
||||
|
||||
```python
|
||||
async def execute_page(..., render_mode: str = "client") -> str:
|
||||
```
|
||||
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
|
||||
|
||||
When `render_mode="server"`:
|
||||
- Evaluate all slots via `async_render()` (→ HTML) instead of `async_eval_to_sx()` (→ sx source)
|
||||
- Layout headers also rendered to HTML
|
||||
- Pass to new `ssr_page()` instead of `sx_page()`
|
||||
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)
|
||||
```
|
||||
|
||||
### 2b: Create `ssr_page()` in helpers.py
|
||||
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
|
||||
|
||||
Wraps pre-rendered HTML in a document shell:
|
||||
- Same `<head>` (CSS, CSRF, meta)
|
||||
- Rendered HTML inline in `<body>` — no `<script type="text/sx" data-mount>`
|
||||
- Still ships component defs in `<script type="text/sx" data-components>` (client needs them for subsequent navigation)
|
||||
- Still includes sx.js + body.js (for SPA takeover after first paint)
|
||||
- Adds `<meta name="sx-ssr" content="true">`
|
||||
- Injects `__sxConfig` (Phase 1b)
|
||||
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).
|
||||
|
||||
### 2c: SSR trigger
|
||||
**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
|
||||
|
||||
Utility `should_ssr(request)`:
|
||||
- Bot UA patterns → SSR
|
||||
- `?_render=server` → SSR (debug)
|
||||
- `SX-Request: true` header → always client
|
||||
- Per-page opt-in via `defpage :ssr true`
|
||||
- Default → client (current behavior)
|
||||
|
||||
### 2d: Hydration in sx.js
|
||||
|
||||
When sx.js detects `<meta name="sx-ssr">`:
|
||||
- Skip `Sx.mount()` — DOM already correct
|
||||
- Run `SxEngine.process(document.body)` — bind sx-get/post handlers
|
||||
- Run `Sx.hydrate()` — process `[data-sx]` elements
|
||||
- Load component defs into registry (for subsequent navigations)
|
||||
|
||||
**Files:** `shared/sx/pages.py`, `shared/sx/helpers.py`, `shared/static/scripts/sx.js`
|
||||
|
||||
**Verify:** Googlebot UA → response has rendered HTML, no `<script data-mount>`. Normal UA → unchanged behavior.
|
||||
**Verification:**
|
||||
- Components calling `(query ...)` classified IO-dependent; pure components classified pure
|
||||
- Existing pages produce identical output (regression)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Public Data API
|
||||
### Phase 3: Client-Side Routing (SPA Mode)
|
||||
|
||||
Expose browser-accessible JSON endpoints mirroring internal `/internal/data/` queries.
|
||||
**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.
|
||||
|
||||
### 3a: Shared blueprint factory
|
||||
**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.
|
||||
|
||||
New `shared/sx/api_data.py`:
|
||||
**Approach:**
|
||||
|
||||
```python
|
||||
def create_public_data_blueprint(service_name: str) -> Blueprint:
|
||||
"""Session-authed public data blueprint at /api/data/"""
|
||||
```
|
||||
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.
|
||||
|
||||
Queries registered with auth level: `"public"`, `"login"`, `"admin"`. Validates session (not HMAC). Returns JSON.
|
||||
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)
|
||||
|
||||
### 3b: Extract and share handler implementations
|
||||
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.
|
||||
|
||||
Refactor `bp/data/routes.py` per service — separate query logic from HMAC auth. Same function serves both internal and public paths.
|
||||
4. **Layout caching** — layouts depend on auth/fragments, so cache current layout and reuse across navigations. `SX-Layout-Hash` header tracks staleness.
|
||||
|
||||
### 3c: Per-service public data blueprints
|
||||
5. **Integration with orchestration.sx** — intercept `bind-boost-link` to try client-side resolution first.
|
||||
|
||||
New `bp/api_data/routes.py` per service:
|
||||
**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
|
||||
|
||||
| Service | Public queries | Auth |
|
||||
|---------|---------------|------|
|
||||
| blog | `post-by-slug`, `post-by-id`, `search-posts` | public |
|
||||
| market | `products-by-ids`, `marketplaces-for-container` | public |
|
||||
| events | `visible-entries-for-period`, `calendars-for-container`, `entries-for-page` | public |
|
||||
| cart | `cart-summary`, `cart-items` | login |
|
||||
| likes | `is-liked`, `liked-slugs` | login |
|
||||
| account | `newsletters` | public |
|
||||
**Depends on:** Phase 1 (client knows which components each page needs), Phase 2 (which pages are pure vs IO)
|
||||
|
||||
Admin queries and write-actions stay internal only.
|
||||
|
||||
### 3d: Public fragment endpoints
|
||||
|
||||
The existing internal fragment system (`/internal/fragments/<type>`, HMAC-signed) needs public equivalents. Each service already has `create_handler_blueprint()` mounting defhandler fragments. Add a parallel public endpoint:
|
||||
|
||||
`GET /api/fragments/<type>?params...` — session-authed, returns `text/sx` (same wire format the client already handles via SxEngine).
|
||||
|
||||
This can reuse the same `execute_handler()` machinery — the only difference is auth (session vs HMAC). The blueprint factory in `shared/sx/api_data.py` can handle both data and fragment registration:
|
||||
|
||||
```python
|
||||
bp.register_fragment("container-cards", handler_fn, auth="public")
|
||||
```
|
||||
|
||||
The client's `(frag ...)` primitive then fetches from these public endpoints instead of the HMAC-signed internal ones.
|
||||
|
||||
### 3e: Register in app factories
|
||||
|
||||
Each service's `app.py` registers the new blueprint.
|
||||
|
||||
**Files:** New `shared/sx/api_data.py`, new `{service}/bp/api_data/routes.py` per service, `{service}/app.py`
|
||||
|
||||
**Verify:** `curl /api/data/post-by-slug?slug=test` → JSON. `curl /api/fragments/container-cards?type=page&id=1` → sx source. Login-gated query without session → 401.
|
||||
**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
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Client Data Primitives
|
||||
### Phase 4: Client Async & IO Bridge
|
||||
|
||||
Async data-fetching in sx.js so I/O primitives work client-side via the public API.
|
||||
**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")`.
|
||||
|
||||
### 4a: Async evaluator — `sxEvalAsync()`
|
||||
**Approach:**
|
||||
|
||||
New function in `sx.js` returning a `Promise`. Mirrors `async_eval.py`:
|
||||
- Literals/symbols → `Promise.resolve(syncValue)`
|
||||
- I/O primitives (`query`, `service`, `frag`, etc.) → `fetch()` calls to `/api/data/`
|
||||
- Control flow → sequential async with short-circuit
|
||||
- `map`/`filter` with I/O → `Promise.all`
|
||||
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)
|
||||
|
||||
### 4b: I/O primitive dispatch
|
||||
2. **IO primitive bridge** — register async IO primitives in client `PRIMITIVES`:
|
||||
- `query` → fetch to `/internal/data/`
|
||||
- `service` → fetch to target service internal endpoint
|
||||
- `frag` → fetch fragment HTML
|
||||
- `current-user` → cached from initial page load
|
||||
|
||||
```javascript
|
||||
IO_PRIMITIVES = {
|
||||
"query": (svc, name, kw) => fetch(__sxConfig.appUrls[svc] + "/api/data/" + name + "?" + params(kw), {credentials:"include"}).then(r=>r.json()),
|
||||
"service": (method, kw) => fetch("/api/data/" + method + "?" + params(kw), {credentials:"include"}).then(r=>r.json()),
|
||||
"frag": (svc, type, kw) => fetch(__sxConfig.appUrls[svc] + "/api/fragments/" + type + "?" + params(kw), {credentials:"include"}).then(r=>r.text()),
|
||||
"current-user": () => Promise.resolve(__sxConfig.currentUser),
|
||||
"request-arg": (name) => Promise.resolve(new URLSearchParams(location.search).get(name)),
|
||||
"request-path": () => Promise.resolve(location.pathname),
|
||||
"nav-tree": () => fetch("/api/data/nav-tree", {credentials:"include"}).then(r=>r.json()),
|
||||
};
|
||||
```
|
||||
3. **Client data cache** — keyed by `(service, query, params-hash)`, configurable TTL, server can invalidate via `SX-Invalidate` header.
|
||||
|
||||
### 4c: Async DOM renderer — `renderDOMAsync()`
|
||||
4. **Optimistic updates** — extend existing `apply-optimistic`/`revert-optimistic` in `engine.sx` from DOM-level to data-level.
|
||||
|
||||
Two-pass (avoids restructuring sync renderer):
|
||||
1. Walk AST, collect I/O call sites with placeholders
|
||||
2. `Promise.all` to resolve all I/O in parallel
|
||||
3. Substitute resolved values into AST
|
||||
4. Call existing sync `renderDOM()` on resolved tree
|
||||
**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)
|
||||
|
||||
### 4d: Wire into `Sx.mount()`
|
||||
**Depends on:** Phase 2 (IO affinity), Phase 3 (routing for when to trigger IO)
|
||||
|
||||
Detect I/O nodes. If present → async path. Otherwise → existing sync path (zero overhead for pure components).
|
||||
|
||||
**Files:** `shared/static/scripts/sx.js` (major addition)
|
||||
|
||||
**Verify:** Page with `(query "blog" "post-by-slug" :slug "test")` in sx source → client fetches `/api/data/post-by-slug?slug=test`, renders result.
|
||||
**Verification:**
|
||||
- Client `(query ...)` returns identical data to server-side
|
||||
- Data cache prevents redundant fetches
|
||||
- Same component source → identical output on either side
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Data-Only Navigation
|
||||
### Phase 5: Streaming & Suspense
|
||||
|
||||
When the client already has page components cached, navigation requires only a data fetch — no sx source from the server.
|
||||
**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.
|
||||
|
||||
### 5a: Page components in the registry
|
||||
**Approach:**
|
||||
|
||||
`defpage` definitions are already in `_componentEnv` (Phase 1c) and cached in localStorage with the component hash. On navigation, if the hash is valid, the client has all page definitions locally.
|
||||
1. **Continuation-based suspension** — when `_aser` encounters IO during slot evaluation, emit a placeholder with a suspension ID, schedule async resolution:
|
||||
```python
|
||||
yield SxExpr(f'(~suspense :id "{placeholder_id}" :fallback (div "Loading..."))')
|
||||
schedule_fill(placeholder_id, io_coroutine)
|
||||
```
|
||||
|
||||
Build a `_pageRegistry` mapping URL path patterns → page definitions, populated when `defpage` forms are evaluated. Path patterns (`/posts/<slug>/`) converted to regex matchers for URL matching.
|
||||
2. **Chunked transfer** — Quart async generator responses:
|
||||
- First chunk: HTML shell + synchronous content + placeholders
|
||||
- Subsequent chunks: `<script>` tags replacing placeholders with resolved content
|
||||
|
||||
### 5b: Navigation intercept
|
||||
3. **Client suspension rendering** — `~suspense` component renders fallback, listens for resolution via inline script or SSE (existing SSE infrastructure in orchestration.sx).
|
||||
|
||||
Extend SxEngine's link click handler:
|
||||
4. **Priority-based IO** — above-fold content resolves first. All IO starts concurrently (`asyncio.create_task`), results flushed in priority order.
|
||||
|
||||
```
|
||||
1. Extract URL path from clicked link
|
||||
2. Match against _pageRegistry
|
||||
3. If matched:
|
||||
a. Evaluate :data slot via sxEvalAsync() → parallel API fetches
|
||||
b. Render :content/:filter/:aside via renderDOMAsync()
|
||||
c. Morph into existing ~app-body (headers persist, slots update)
|
||||
d. Push history state
|
||||
e. Update document title
|
||||
4. If not matched → existing server fetch (graceful fallback)
|
||||
```
|
||||
**Files:**
|
||||
- `shared/sx/async_eval.py` — streaming `_aser` variant
|
||||
- `shared/sx/helpers.py` — chunked response builder
|
||||
- New: `shared/sx/ref/suspense.sx` — client suspension rendering
|
||||
- `shared/sx/ref/boot.sx` — handle resolution scripts
|
||||
|
||||
### 5c: Data delivery — flexible per page
|
||||
|
||||
Three modes available (see Context section). The page definition can declare its preference:
|
||||
|
||||
```scheme
|
||||
(defpage blog-post
|
||||
:path "/posts/<slug>/"
|
||||
:data-mode :server ; :server (bundled), :client (fetch individually), :hybrid
|
||||
:data (query "blog" "post-by-slug" :slug slug)
|
||||
:content (~post-detail post))
|
||||
```
|
||||
|
||||
**Mode :server** — Client sends `SX-Page: blog-post` header on navigation. Server evaluates `:data` slot (all queries, including cross-service), returns single JSON blob:
|
||||
```python
|
||||
if request.headers.get("SX-Page"):
|
||||
data = await evaluate_data_slot(page_def, url_params)
|
||||
return jsonify(data)
|
||||
```
|
||||
|
||||
**Mode :client** — Client evaluates `:data` slot locally via `sxEvalAsync()`. Each `(query ...)` hits `/api/data/` independently. Each `(frag ...)` hits `/api/fragments/`. No server data endpoint needed.
|
||||
|
||||
**Mode :hybrid** — Server bundles own-service data (direct DB). Client fetches cross-service data and fragments in parallel. The `:data` slot is split: server evaluates local queries, returns partial bundle + a manifest of remaining queries. Client resolves the rest.
|
||||
|
||||
Default mode can be `:server` (fewest round-trips, simplest). Pages opt into `:client` or `:hybrid` when they want more decoupling or when cross-service data is heavy and benefits from parallel client fetches.
|
||||
|
||||
### 5d: Popstate handling
|
||||
|
||||
On browser back/forward:
|
||||
1. Check `_pageRegistry` for popped URL
|
||||
2. If matched → client render (same as 5b)
|
||||
3. If not → existing server fetch + morph
|
||||
|
||||
### 5e: Graceful fallback
|
||||
|
||||
Routes not in `_pageRegistry` fall through to server fetch. Partially migrated apps work — Python-only routes use server fetch, defpage routes get SPA behavior. No big-bang cutover.
|
||||
|
||||
**Files:** `shared/static/scripts/sx.js`, `shared/sx/helpers.py`, `shared/sx/pages.py`
|
||||
|
||||
**Verify:** Playwright: load page → click link to defpage route → assert no HTML response fetched (only JSON) → content correct → URL updated → back button works.
|
||||
**Depends on:** Phase 4 (client async for filling suspended subtrees), Phase 2 (IO analysis for priority)
|
||||
|
||||
---
|
||||
|
||||
## Summary: The Full Lifecycle
|
||||
### Phase 6: Full Isomorphism
|
||||
|
||||
```
|
||||
1. App startup: Python loads .sx files → defcomp + defpage registered in _COMPONENT_ENV
|
||||
→ hash computed
|
||||
**What it enables:** Same SX code runs on either side. Runtime chooses optimal split. Offline-first with cached data + client eval.
|
||||
|
||||
2. First visit: Server sends HTML shell + component/page defs + __sxConfig + page sx source
|
||||
Client evaluates, renders, caches defs in localStorage, sets cookie
|
||||
**Approach:**
|
||||
|
||||
3. Return visit: Cookie hash matches → server sends HTML shell with empty <script data-components>
|
||||
Client loads defs from localStorage → renders page
|
||||
1. **Runtime boundary optimizer** — given component tree + IO dependency graph, decide per-component: server-expand, client-render, or stream. Planning step cached at registration, recomputed on component change.
|
||||
|
||||
4. SPA navigation: Client matches URL against _pageRegistry
|
||||
→ fetches data from /api/data/ (or server data-only endpoint)
|
||||
→ renders page component locally with fresh data
|
||||
→ morphs DOM, pushes history
|
||||
→ zero sx source transferred
|
||||
2. **Affinity annotations** — optional developer hints:
|
||||
```lisp
|
||||
(defcomp ~product-grid (&key products)
|
||||
:affinity :client ;; interactive, prefer client
|
||||
...)
|
||||
(defcomp ~auth-menu (&key user)
|
||||
:affinity :server ;; auth-sensitive, always server
|
||||
...)
|
||||
```
|
||||
Default: auto (runtime decides from IO analysis).
|
||||
|
||||
5. Bot/SSR: Server detects bot UA → evaluates page server-side with direct DB queries
|
||||
→ sends rendered HTML + component defs
|
||||
→ client hydrates (binds handlers, no re-render)
|
||||
```
|
||||
3. **Offline data layer** — Service Worker intercepts `/internal/data/` requests, serves from IndexedDB when offline, syncs when back online.
|
||||
|
||||
## Migration per Service
|
||||
4. **Isomorphic testing** — evaluate same component on Python and JS, compare output. Extends existing `test_sx_ref.py` cross-evaluator comparison.
|
||||
|
||||
Each service migrates independently, no coordination needed:
|
||||
1. Add public data blueprint (Phase 3) — immediate standalone value
|
||||
2. Convert remaining Jinja routes to `defpage` — already in progress
|
||||
3. Enable SSR for bots (Phase 2) — per-page opt-in
|
||||
4. Client data primitives (Phase 4) — global once sx.js updated
|
||||
5. Data-only navigation (Phase 5) — automatic for any `defpage` route
|
||||
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.
|
||||
|
||||
**Depends on:** All previous phases.
|
||||
|
||||
---
|
||||
|
||||
## Why: Architectural Rationale
|
||||
## Cross-Cutting Concerns
|
||||
|
||||
The end state is: **sx.js is the only JavaScript in the browser.** All application code — components, pages, routing, event handling, data fetching — is expressed in sx, evaluated by the interpreter, with behavior mediated through bound primitives.
|
||||
### Error Reporting (all phases)
|
||||
- Phase 1: "Unknown component" includes which page expected it and what bundle was sent
|
||||
- Phase 2: Server logs which components expanded server-side vs sent to client
|
||||
- Phase 3: Client route failures include unmatched path and available routes
|
||||
- Phase 4: Client IO errors include query name, params, server response
|
||||
- Source location tracking in parser → propagate through eval → include in error messages
|
||||
|
||||
### Benefits
|
||||
### Backward Compatibility (all phases)
|
||||
- Pages without annotations behave as today
|
||||
- `SX-Request` / `SX-Components` / `SX-Css` header protocol continues
|
||||
- Existing `.sx` files require no changes
|
||||
- `_expand_components` continues as override
|
||||
- Each phase is opt-in: disable → identical to previous behavior
|
||||
|
||||
**Single language everywhere.** Components, pages, routing, event handling, data fetching — all sx. No context-switching between JS idioms and template syntax. One language for the entire frontend and the server rendering path.
|
||||
### Spec Integrity
|
||||
All new behavior specified in `.sx` files under `shared/sx/ref/` before implementation. Bootstrappers transpile from spec. This ensures JS and Python stay in sync.
|
||||
|
||||
**Portability.** The same source runs on any VM that implements the ~50-primitive interface. Today: Python + JS. Tomorrow: WASM, edge workers, native mobile, embedded devices. Coupled to a primitive contract, not to a specific runtime.
|
||||
## Critical Files
|
||||
|
||||
**Smaller wire transfer.** S-expressions are terser than equivalent JS. Combined with content-addressed caching (hash/localStorage), most navigations transfer zero code — just data.
|
||||
|
||||
**Inspectability.** The sx source is the running program — no build step, no source maps, no minification. View source shows exactly what executes. The AST is the structure the evaluator walks. Debugging is tracing a tree.
|
||||
|
||||
**Controlled surface area.** The only JS that runs is sx.js. Everything else is mediated through defined primitives. No npm supply chain. No third-party scripts with ambient DOM access. Components can only do what primitives allow — the capability surface is fully controlled.
|
||||
|
||||
**Hot-reloadable everything.** Components are data (cached AST). Swapping a definition is replacing a dict entry. No module system, no import graph, no HMR machinery. Already works for .sx file changes in dev mode — extends to behaviors too.
|
||||
|
||||
**AI-friendly.** S-expressions are trivially parseable and generatable. An LLM produces correct sx far more reliably than JS/JSX — fewer syntax edge cases, no semicolons/braces/arrow-function ambiguities. The codebase becomes more amenable to automated generation and transformation.
|
||||
|
||||
**Security boundary.** No `eval()`, no dynamic `<script>` injection, no prototype pollution. The sx evaluator is a sandbox — it only resolves symbols against the primitive table and component env. Auditing what any sx expression can do means auditing the primitive bindings.
|
||||
|
||||
### Performance and WASM
|
||||
|
||||
The tradeoff is interpreter overhead — a tree-walking interpreter is slower than native JS execution. For UI rendering (building DOM, handling events, fetching data), this is not the bottleneck — DOM operations dominate, and those are the same speed regardless of initiator.
|
||||
|
||||
If performance ever becomes a concern, WASM is the escape hatch at three levels:
|
||||
|
||||
1. **Evaluator in WASM.** Rewrite `sxEval` in Rust/Zig → WASM. The tight inner loop (symbol lookup, env traversal, function application) runs ~10-50x faster. DOM rendering stays in JS (it calls browser APIs regardless).
|
||||
|
||||
2. **Compile sx to WASM.** Ahead-of-time compiler: `.sx` → WASM modules. Each `defcomp` becomes a WASM function returning DOM instructions. Eliminates the interpreter entirely. The content-addressed cache stores compiled WASM blobs instead of sx source.
|
||||
|
||||
3. **Compute-heavy primitives in WASM.** Keep the sx interpreter in JS, bind specific primitives to WASM (image processing, crypto, data transformation). Most pragmatic and least disruptive — additive, no architecture change.
|
||||
|
||||
The primitive-binding model means the evaluator doesn't care what's behind a primitive. `(blur-image data radius)` could be a JS Canvas call today and a WASM JAX kernel tomorrow. The sx source doesn't change.
|
||||
|
||||
### Server-Driven by Default: The React Question
|
||||
|
||||
The sx system is architecturally aligned with HTMX/LiveView — server-driven UI — even though it does far more on the client (full s-expression evaluation, DOM rendering, morph reconciliation, component caching). The server is the single source of truth. Every UI state is a URL. Auth is enforced at render time. There are no state synchronization bugs because there is no client state to synchronize.
|
||||
|
||||
React's client-state model (`useState`, `useEffect`, Context, Suspense) exists because React was built for SPAs that need to feel like native apps — optimistic updates, offline capability, instant feedback without network latency. But it created an entire category of problems: state management libraries, hydration mismatches, cache invalidation, stale closures, memory leaks from forgotten cleanup, the `useEffect` footgun.
|
||||
|
||||
**The question is not "should sx have useState" — it's which specific interactions actually suffer from the server round-trip.**
|
||||
|
||||
For most of our apps, that's a very short list:
|
||||
- Toggle a mobile nav panel
|
||||
- Gallery image switching
|
||||
- Quantity steppers
|
||||
- Live search-as-you-type
|
||||
|
||||
These don't need a general-purpose reactive state system. They need **targeted client-side primitives** that handle those specific cases without abandoning the server-driven model.
|
||||
|
||||
**The dangerous path:** Add `useState` → need `useEffect` for cleanup → need Context to avoid prop drilling → need Suspense for async state → rebuild React inside sx → lose the simplicity that makes the server-driven model work.
|
||||
|
||||
**The careful path:** Keep server-driven as the default. Add explicit, targeted escape hatches for interactions that genuinely need client-side state. Make those escape hatches obviously different from the normal flow so they don't creep into everything.
|
||||
|
||||
#### What sx has vs React
|
||||
|
||||
| React feature | SX status | Verdict |
|
||||
|---|---|---|
|
||||
| Components + props | `defcomp` + `&key` | Done — cleaner than JSX |
|
||||
| Fragments, conditionals, lists | `<>`, `if`/`when`/`cond`, `map` | Done — more expressive |
|
||||
| Macros | `defmacro` | Done — React has nothing like this |
|
||||
| OOB updates / portals | `sx-swap-oob` | Done — more powerful (server-driven) |
|
||||
| DOM reconciliation | `_morphDOM` (id-keyed) | Done — works during SxEngine swaps |
|
||||
| Reactive client state | None | **By design.** Server is source of truth. |
|
||||
| Component lifecycle | None | Add targeted primitives if body.js behaviors move to sx |
|
||||
| Context / providers | `_componentEnv` global | Sufficient for auth/theme; revisit if trees get deep |
|
||||
| Suspense / loading | `sx-request` CSS class | Sufficient for server-driven; revisit for Phase 4 client data |
|
||||
| Two-way data binding | None | Not needed — HTMX model (form POST → new HTML) works |
|
||||
| Error boundaries | Global `sx:responseError` | Sufficient; per-component boundaries are a future nice-to-have |
|
||||
| Keyed list reconciliation | id-based morph | Works; add `:key` prop support if list update bugs arise |
|
||||
|
||||
#### Targeted escape hatches (not a general state system)
|
||||
|
||||
For the few interactions that need client-side responsiveness, add **specific primitives** rather than a general framework:
|
||||
|
||||
- `(toggle! el "class")` — CSS class toggle, no server trip
|
||||
- `(set-attr! el "attr" value)` — attribute manipulation
|
||||
- `(on-event el "click" handler)` — declarative event binding within sx
|
||||
- `(timer interval-ms handler)` — with automatic cleanup on DOM removal
|
||||
|
||||
These are imperative DOM operations exposed as primitives — not reactive state. They let components handle simple client-side interactions without importing React's entire mental model. The server-driven flow remains the default for anything involving data.
|
||||
| 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/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/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 |
|
||||
|
||||
61
events/sx/boundary.sx
Normal file
61
events/sx/boundary.sx
Normal file
@@ -0,0 +1,61 @@
|
||||
;; Events service — page helper declarations.
|
||||
|
||||
(define-page-helper "calendar-admin-data"
|
||||
:params (&key calendar-slug)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "day-admin-data"
|
||||
:params (&key calendar-slug year month day)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "slots-data"
|
||||
:params (&key calendar-slug)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "slot-data"
|
||||
:params (&key calendar-slug slot-id)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "entry-data"
|
||||
:params (&key calendar-slug entry-id)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "entry-admin-data"
|
||||
:params (&key calendar-slug entry-id year month day)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "ticket-types-data"
|
||||
:params (&key calendar-slug entry-id year month day)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "ticket-type-data"
|
||||
:params (&key calendar-slug entry-id ticket-type-id year month day)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "tickets-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "ticket-detail-data"
|
||||
:params (&key code)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "ticket-admin-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "markets-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
21
market/sx/boundary.sx
Normal file
21
market/sx/boundary.sx
Normal file
@@ -0,0 +1,21 @@
|
||||
;; Market service — page helper declarations.
|
||||
|
||||
(define-page-helper "all-markets-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "market")
|
||||
|
||||
(define-page-helper "page-markets-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "market")
|
||||
|
||||
(define-page-helper "page-admin-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "market")
|
||||
|
||||
(define-page-helper "market-home-data"
|
||||
:params (&key page-slug market-slug)
|
||||
:returns "dict"
|
||||
:service "market")
|
||||
@@ -209,13 +209,15 @@
|
||||
function error(msg) { throw new Error(msg); }
|
||||
function inspect(x) { return JSON.stringify(x); }
|
||||
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// Primitives
|
||||
// =========================================================================
|
||||
|
||||
var PRIMITIVES = {};
|
||||
|
||||
// Arithmetic
|
||||
// core.arithmetic
|
||||
PRIMITIVES["+"] = function() { var s = 0; for (var i = 0; i < arguments.length; i++) s += arguments[i]; return s; };
|
||||
PRIMITIVES["-"] = function(a, b) { return arguments.length === 1 ? -a : a - b; };
|
||||
PRIMITIVES["*"] = function() { var s = 1; for (var i = 0; i < arguments.length; i++) s *= arguments[i]; return s; };
|
||||
@@ -226,49 +228,31 @@
|
||||
PRIMITIVES["abs"] = Math.abs;
|
||||
PRIMITIVES["floor"] = Math.floor;
|
||||
PRIMITIVES["ceil"] = Math.ceil;
|
||||
PRIMITIVES["round"] = Math.round;
|
||||
PRIMITIVES["round"] = function(x, n) {
|
||||
if (n === undefined || n === 0) return Math.round(x);
|
||||
var f = Math.pow(10, n); return Math.round(x * f) / f;
|
||||
};
|
||||
PRIMITIVES["min"] = Math.min;
|
||||
PRIMITIVES["max"] = Math.max;
|
||||
PRIMITIVES["sqrt"] = Math.sqrt;
|
||||
PRIMITIVES["pow"] = Math.pow;
|
||||
PRIMITIVES["clamp"] = function(x, lo, hi) { return Math.max(lo, Math.min(hi, x)); };
|
||||
|
||||
// Comparison
|
||||
PRIMITIVES["="] = function(a, b) { return a == b; };
|
||||
PRIMITIVES["!="] = function(a, b) { return a != b; };
|
||||
|
||||
// core.comparison
|
||||
PRIMITIVES["="] = function(a, b) { return a === b; };
|
||||
PRIMITIVES["!="] = function(a, b) { return a !== b; };
|
||||
PRIMITIVES["<"] = function(a, b) { return a < b; };
|
||||
PRIMITIVES[">"] = function(a, b) { return a > b; };
|
||||
PRIMITIVES["<="] = function(a, b) { return a <= b; };
|
||||
PRIMITIVES[">="] = function(a, b) { return a >= b; };
|
||||
|
||||
// Logic
|
||||
|
||||
// core.logic
|
||||
PRIMITIVES["not"] = function(x) { return !isSxTruthy(x); };
|
||||
|
||||
// String
|
||||
PRIMITIVES["str"] = function() {
|
||||
var p = [];
|
||||
for (var i = 0; i < arguments.length; i++) {
|
||||
var v = arguments[i]; if (isNil(v)) continue; p.push(String(v));
|
||||
}
|
||||
return p.join("");
|
||||
};
|
||||
PRIMITIVES["upper"] = function(s) { return String(s).toUpperCase(); };
|
||||
PRIMITIVES["lower"] = function(s) { return String(s).toLowerCase(); };
|
||||
PRIMITIVES["trim"] = function(s) { return String(s).trim(); };
|
||||
PRIMITIVES["split"] = function(s, sep) { return String(s).split(sep || " "); };
|
||||
PRIMITIVES["join"] = function(sep, coll) { return coll.join(sep); };
|
||||
PRIMITIVES["replace"] = function(s, old, nw) { return s.split(old).join(nw); };
|
||||
PRIMITIVES["starts-with?"] = function(s, p) { return String(s).indexOf(p) === 0; };
|
||||
PRIMITIVES["ends-with?"] = function(s, p) { var str = String(s); return str.indexOf(p, str.length - p.length) !== -1; };
|
||||
PRIMITIVES["slice"] = function(c, a, b) { return b !== undefined ? c.slice(a, b) : c.slice(a); };
|
||||
PRIMITIVES["concat"] = function() {
|
||||
var out = [];
|
||||
for (var i = 0; i < arguments.length; i++) if (arguments[i]) out = out.concat(arguments[i]);
|
||||
return out;
|
||||
};
|
||||
PRIMITIVES["strip-tags"] = function(s) { return String(s).replace(/<[^>]+>/g, ""); };
|
||||
|
||||
// Predicates
|
||||
// core.predicates
|
||||
PRIMITIVES["nil?"] = isNil;
|
||||
PRIMITIVES["number?"] = function(x) { return typeof x === "number"; };
|
||||
PRIMITIVES["string?"] = function(x) { return typeof x === "string"; };
|
||||
@@ -284,7 +268,33 @@
|
||||
PRIMITIVES["even?"] = function(n) { return n % 2 === 0; };
|
||||
PRIMITIVES["zero?"] = function(n) { return n === 0; };
|
||||
|
||||
// Collections
|
||||
|
||||
// core.strings
|
||||
PRIMITIVES["str"] = function() {
|
||||
var p = [];
|
||||
for (var i = 0; i < arguments.length; i++) {
|
||||
var v = arguments[i]; if (isNil(v)) continue; p.push(String(v));
|
||||
}
|
||||
return p.join("");
|
||||
};
|
||||
PRIMITIVES["upper"] = function(s) { return String(s).toUpperCase(); };
|
||||
PRIMITIVES["lower"] = function(s) { return String(s).toLowerCase(); };
|
||||
PRIMITIVES["trim"] = function(s) { return String(s).trim(); };
|
||||
PRIMITIVES["split"] = function(s, sep) { return String(s).split(sep || " "); };
|
||||
PRIMITIVES["join"] = function(sep, coll) { return coll.join(sep); };
|
||||
PRIMITIVES["replace"] = function(s, old, nw) { return s.split(old).join(nw); };
|
||||
PRIMITIVES["index-of"] = function(s, needle, from) { return String(s).indexOf(needle, from || 0); };
|
||||
PRIMITIVES["starts-with?"] = function(s, p) { return String(s).indexOf(p) === 0; };
|
||||
PRIMITIVES["ends-with?"] = function(s, p) { var str = String(s); return str.indexOf(p, str.length - p.length) !== -1; };
|
||||
PRIMITIVES["slice"] = function(c, a, b) { return b !== undefined ? c.slice(a, b) : c.slice(a); };
|
||||
PRIMITIVES["concat"] = function() {
|
||||
var out = [];
|
||||
for (var i = 0; i < arguments.length; i++) if (!isNil(arguments[i])) out = out.concat(arguments[i]);
|
||||
return out;
|
||||
};
|
||||
|
||||
|
||||
// core.collections
|
||||
PRIMITIVES["list"] = function() { return Array.prototype.slice.call(arguments); };
|
||||
PRIMITIVES["dict"] = function() {
|
||||
var d = {};
|
||||
@@ -304,6 +314,15 @@
|
||||
PRIMITIVES["nth"] = function(c, n) { return c && n >= 0 && n < c.length ? c[n] : NIL; };
|
||||
PRIMITIVES["cons"] = function(x, c) { return [x].concat(c || []); };
|
||||
PRIMITIVES["append"] = function(c, x) { return (c || []).concat([x]); };
|
||||
PRIMITIVES["chunk-every"] = function(c, n) {
|
||||
var r = []; for (var i = 0; i < c.length; i += n) r.push(c.slice(i, i + n)); return r;
|
||||
};
|
||||
PRIMITIVES["zip-pairs"] = function(c) {
|
||||
var r = []; for (var i = 0; i < c.length - 1; i++) r.push([c[i], c[i + 1]]); return r;
|
||||
};
|
||||
|
||||
|
||||
// core.dict
|
||||
PRIMITIVES["keys"] = function(d) { return Object.keys(d || {}); };
|
||||
PRIMITIVES["vals"] = function(d) { var r = []; for (var k in d) r.push(d[k]); return r; };
|
||||
PRIMITIVES["merge"] = function() {
|
||||
@@ -321,28 +340,16 @@
|
||||
for (var i = 1; i < arguments.length; i++) delete out[arguments[i]];
|
||||
return out;
|
||||
};
|
||||
PRIMITIVES["chunk-every"] = function(c, n) {
|
||||
var r = []; for (var i = 0; i < c.length; i += n) r.push(c.slice(i, i + n)); return r;
|
||||
};
|
||||
PRIMITIVES["zip-pairs"] = function(c) {
|
||||
var r = []; for (var i = 0; i < c.length - 1; i++) r.push([c[i], c[i + 1]]); return r;
|
||||
};
|
||||
PRIMITIVES["into"] = function(target, coll) {
|
||||
if (Array.isArray(target)) return Array.isArray(coll) ? coll.slice() : Object.entries(coll);
|
||||
var r = {}; for (var i = 0; i < coll.length; i++) { var p = coll[i]; if (Array.isArray(p) && p.length >= 2) r[p[0]] = p[1]; }
|
||||
return r;
|
||||
};
|
||||
|
||||
// Format
|
||||
|
||||
// stdlib.format
|
||||
PRIMITIVES["format-decimal"] = function(v, p) { return Number(v).toFixed(p || 2); };
|
||||
PRIMITIVES["parse-int"] = function(v, d) { var n = parseInt(v, 10); return isNaN(n) ? (d || 0) : n; };
|
||||
PRIMITIVES["pluralize"] = function(n, s, p) {
|
||||
if (s || (p && p !== "s")) return n == 1 ? (s || "") : (p || "s");
|
||||
return n == 1 ? "" : "s";
|
||||
};
|
||||
PRIMITIVES["escape"] = function(s) {
|
||||
return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
};
|
||||
PRIMITIVES["format-date"] = function(s, fmt) {
|
||||
if (!s) return "";
|
||||
try {
|
||||
@@ -357,12 +364,21 @@
|
||||
} catch (e) { return String(s); }
|
||||
};
|
||||
PRIMITIVES["parse-datetime"] = function(s) { return s ? String(s) : NIL; };
|
||||
PRIMITIVES["split-ids"] = function(s) {
|
||||
if (!s) return [];
|
||||
return String(s).split(",").map(function(x) { return x.trim(); }).filter(function(x) { return x; });
|
||||
|
||||
|
||||
// stdlib.text
|
||||
PRIMITIVES["pluralize"] = function(n, s, p) {
|
||||
if (s || (p && p !== "s")) return n == 1 ? (s || "") : (p || "s");
|
||||
return n == 1 ? "" : "s";
|
||||
};
|
||||
PRIMITIVES["escape"] = function(s) {
|
||||
return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'");
|
||||
};
|
||||
PRIMITIVES["strip-tags"] = function(s) { return String(s).replace(/<[^>]+>/g, ""); };
|
||||
|
||||
|
||||
// stdlib.style
|
||||
PRIMITIVES["css"] = function() {
|
||||
// Stub — CSSX requires style dictionary which is browser-only
|
||||
var atoms = [];
|
||||
for (var i = 0; i < arguments.length; i++) {
|
||||
var a = arguments[i];
|
||||
@@ -383,6 +399,14 @@
|
||||
return new StyleValue("sx-merged", allDecls, [], [], []);
|
||||
};
|
||||
|
||||
|
||||
// stdlib.debug
|
||||
PRIMITIVES["assert"] = function(cond, msg) {
|
||||
if (!isSxTruthy(cond)) throw new Error("Assertion error: " + (msg || "Assertion failed"));
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
function isPrimitive(name) { return name in PRIMITIVES; }
|
||||
function getPrimitive(name) { return PRIMITIVES[name]; }
|
||||
|
||||
@@ -508,6 +532,92 @@
|
||||
return NIL;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Platform: deps module — component dependency analysis
|
||||
// =========================================================================
|
||||
|
||||
function componentDeps(c) {
|
||||
return c.deps ? c.deps.slice() : [];
|
||||
}
|
||||
|
||||
function componentSetDeps(c, deps) {
|
||||
c.deps = deps;
|
||||
}
|
||||
|
||||
function componentCssClasses(c) {
|
||||
return c.cssClasses ? c.cssClasses.slice() : [];
|
||||
}
|
||||
|
||||
function envComponents(env) {
|
||||
var names = [];
|
||||
for (var k in env) {
|
||||
var v = env[k];
|
||||
if (v && (v._component || v._macro)) names.push(k);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function regexFindAll(pattern, source) {
|
||||
var re = new RegExp(pattern, "g");
|
||||
var results = [];
|
||||
var m;
|
||||
while ((m = re.exec(source)) !== null) {
|
||||
if (m[1] !== undefined) results.push(m[1]);
|
||||
else results.push(m[0]);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function scanCssClasses(source) {
|
||||
var classes = {};
|
||||
var result = [];
|
||||
var m;
|
||||
var re1 = /:class\s+"([^"]*)"/g;
|
||||
while ((m = re1.exec(source)) !== null) {
|
||||
var parts = m[1].split(/\s+/);
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (parts[i] && !classes[parts[i]]) {
|
||||
classes[parts[i]] = true;
|
||||
result.push(parts[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
var re2 = /:class\s+\(str\s+((?:"[^"]*"\s*)+)\)/g;
|
||||
while ((m = re2.exec(source)) !== null) {
|
||||
var re3 = /"([^"]*)"/g;
|
||||
var m2;
|
||||
while ((m2 = re3.exec(m[1])) !== null) {
|
||||
var parts2 = m2[1].split(/\s+/);
|
||||
for (var j = 0; j < parts2.length; j++) {
|
||||
if (parts2[j] && !classes[parts2[j]]) {
|
||||
classes[parts2[j]] = true;
|
||||
result.push(parts2[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var re4 = /;;\s*@css\s+(.+)/g;
|
||||
while ((m = re4.exec(source)) !== null) {
|
||||
var parts3 = m[1].split(/\s+/);
|
||||
for (var k = 0; k < parts3.length; k++) {
|
||||
if (parts3[k] && !classes[parts3[k]]) {
|
||||
classes[parts3[k]] = true;
|
||||
result.push(parts3[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function componentIoRefs(c) {
|
||||
return c.ioRefs ? c.ioRefs.slice() : [];
|
||||
}
|
||||
|
||||
function componentSetIoRefs(c, refs) {
|
||||
c.ioRefs = refs;
|
||||
}
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// Platform interface — Parser
|
||||
// =========================================================================
|
||||
@@ -547,10 +657,10 @@
|
||||
var args = rest(expr);
|
||||
return (isSxTruthy(!sxOr((typeOf(head) == "symbol"), (typeOf(head) == "lambda"), (typeOf(head) == "list"))) ? map(function(x) { return trampoline(evalExpr(x, env)); }, expr) : (isSxTruthy((typeOf(head) == "symbol")) ? (function() {
|
||||
var name = symbolName(head);
|
||||
return (isSxTruthy((name == "if")) ? sfIf(args, env) : (isSxTruthy((name == "when")) ? sfWhen(args, env) : (isSxTruthy((name == "cond")) ? sfCond(args, env) : (isSxTruthy((name == "case")) ? sfCase(args, env) : (isSxTruthy((name == "and")) ? sfAnd(args, env) : (isSxTruthy((name == "or")) ? sfOr(args, env) : (isSxTruthy((name == "let")) ? sfLet(args, env) : (isSxTruthy((name == "let*")) ? sfLet(args, env) : (isSxTruthy((name == "lambda")) ? sfLambda(args, env) : (isSxTruthy((name == "fn")) ? sfLambda(args, env) : (isSxTruthy((name == "define")) ? sfDefine(args, env) : (isSxTruthy((name == "defcomp")) ? sfDefcomp(args, env) : (isSxTruthy((name == "defmacro")) ? sfDefmacro(args, env) : (isSxTruthy((name == "defstyle")) ? sfDefstyle(args, env) : (isSxTruthy((name == "defkeyframes")) ? sfDefkeyframes(args, env) : (isSxTruthy((name == "defhandler")) ? sfDefhandler(args, env) : (isSxTruthy((name == "defpage")) ? sfDefpage(args, env) : (isSxTruthy((name == "defquery")) ? sfDefquery(args, env) : (isSxTruthy((name == "defaction")) ? sfDefaction(args, env) : (isSxTruthy((name == "begin")) ? sfBegin(args, env) : (isSxTruthy((name == "do")) ? sfBegin(args, env) : (isSxTruthy((name == "quote")) ? sfQuote(args, env) : (isSxTruthy((name == "quasiquote")) ? sfQuasiquote(args, env) : (isSxTruthy((name == "->")) ? sfThreadFirst(args, env) : (isSxTruthy((name == "set!")) ? sfSetBang(args, env) : (isSxTruthy((name == "map")) ? hoMap(args, env) : (isSxTruthy((name == "map-indexed")) ? hoMapIndexed(args, env) : (isSxTruthy((name == "filter")) ? hoFilter(args, env) : (isSxTruthy((name == "reduce")) ? hoReduce(args, env) : (isSxTruthy((name == "some")) ? hoSome(args, env) : (isSxTruthy((name == "every?")) ? hoEvery(args, env) : (isSxTruthy((name == "for-each")) ? hoForEach(args, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? (function() {
|
||||
return (isSxTruthy((name == "if")) ? sfIf(args, env) : (isSxTruthy((name == "when")) ? sfWhen(args, env) : (isSxTruthy((name == "cond")) ? sfCond(args, env) : (isSxTruthy((name == "case")) ? sfCase(args, env) : (isSxTruthy((name == "and")) ? sfAnd(args, env) : (isSxTruthy((name == "or")) ? sfOr(args, env) : (isSxTruthy((name == "let")) ? sfLet(args, env) : (isSxTruthy((name == "let*")) ? sfLet(args, env) : (isSxTruthy((name == "letrec")) ? sfLetrec(args, env) : (isSxTruthy((name == "lambda")) ? sfLambda(args, env) : (isSxTruthy((name == "fn")) ? sfLambda(args, env) : (isSxTruthy((name == "define")) ? sfDefine(args, env) : (isSxTruthy((name == "defcomp")) ? sfDefcomp(args, env) : (isSxTruthy((name == "defmacro")) ? sfDefmacro(args, env) : (isSxTruthy((name == "defstyle")) ? sfDefstyle(args, env) : (isSxTruthy((name == "defkeyframes")) ? sfDefkeyframes(args, env) : (isSxTruthy((name == "defhandler")) ? sfDefhandler(args, env) : (isSxTruthy((name == "defpage")) ? sfDefpage(args, env) : (isSxTruthy((name == "defquery")) ? sfDefquery(args, env) : (isSxTruthy((name == "defaction")) ? sfDefaction(args, env) : (isSxTruthy((name == "begin")) ? sfBegin(args, env) : (isSxTruthy((name == "do")) ? sfBegin(args, env) : (isSxTruthy((name == "quote")) ? sfQuote(args, env) : (isSxTruthy((name == "quasiquote")) ? sfQuasiquote(args, env) : (isSxTruthy((name == "->")) ? sfThreadFirst(args, env) : (isSxTruthy((name == "set!")) ? sfSetBang(args, env) : (isSxTruthy((name == "reset")) ? sfReset(args, env) : (isSxTruthy((name == "shift")) ? sfShift(args, env) : (isSxTruthy((name == "dynamic-wind")) ? sfDynamicWind(args, env) : (isSxTruthy((name == "map")) ? hoMap(args, env) : (isSxTruthy((name == "map-indexed")) ? hoMapIndexed(args, env) : (isSxTruthy((name == "filter")) ? hoFilter(args, env) : (isSxTruthy((name == "reduce")) ? hoReduce(args, env) : (isSxTruthy((name == "some")) ? hoSome(args, env) : (isSxTruthy((name == "every?")) ? hoEvery(args, env) : (isSxTruthy((name == "for-each")) ? hoForEach(args, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? (function() {
|
||||
var mac = envGet(env, name);
|
||||
return makeThunk(expandMacro(mac, args, env), env);
|
||||
})() : (isSxTruthy(isRenderExpr(expr)) ? renderExpr(expr, env) : evalCall(head, args, env)))))))))))))))))))))))))))))))))));
|
||||
})() : (isSxTruthy(isRenderExpr(expr)) ? renderExpr(expr, env) : evalCall(head, args, env)))))))))))))))))))))))))))))))))))))));
|
||||
})() : evalCall(head, args, env)));
|
||||
})(); };
|
||||
|
||||
@@ -651,7 +761,7 @@
|
||||
})()); };
|
||||
|
||||
// sf-let
|
||||
var sfLet = function(args, env) { return (function() {
|
||||
var sfLet = function(args, env) { return (isSxTruthy((typeOf(first(args)) == "symbol")) ? sfNamedLet(args, env) : (function() {
|
||||
var bindings = first(args);
|
||||
var body = rest(args);
|
||||
var local = envExtend(env);
|
||||
@@ -668,6 +778,27 @@
|
||||
})());
|
||||
{ var _c = slice(body, 0, (len(body) - 1)); for (var _i = 0; _i < _c.length; _i++) { var e = _c[_i]; trampoline(evalExpr(e, local)); } }
|
||||
return makeThunk(last(body), local);
|
||||
})()); };
|
||||
|
||||
// sf-named-let
|
||||
var sfNamedLet = function(args, env) { return (function() {
|
||||
var loopName = symbolName(first(args));
|
||||
var bindings = nth(args, 1);
|
||||
var body = slice(args, 2);
|
||||
var params = [];
|
||||
var inits = [];
|
||||
(isSxTruthy((isSxTruthy((typeOf(first(bindings)) == "list")) && (len(first(bindings)) == 2))) ? forEach(function(binding) { params.push((isSxTruthy((typeOf(first(binding)) == "symbol")) ? symbolName(first(binding)) : first(binding)));
|
||||
return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pairIdx) { return (append_b(params, (isSxTruthy((typeOf(nth(bindings, (pairIdx * 2))) == "symbol")) ? symbolName(nth(bindings, (pairIdx * 2))) : nth(bindings, (pairIdx * 2)))), append_b(inits, nth(bindings, ((pairIdx * 2) + 1)))); }, NIL, range(0, (len(bindings) / 2))));
|
||||
return (function() {
|
||||
var loopBody = (isSxTruthy((len(body) == 1)) ? first(body) : cons(makeSymbol("begin"), body));
|
||||
var loopFn = makeLambda(params, loopBody, env);
|
||||
loopFn.name = loopName;
|
||||
lambdaClosure(loopFn)[loopName] = loopFn;
|
||||
return (function() {
|
||||
var initVals = map(function(e) { return trampoline(evalExpr(e, env)); }, inits);
|
||||
return callLambda(loopFn, initVals, env);
|
||||
})();
|
||||
})();
|
||||
})(); };
|
||||
|
||||
// sf-lambda
|
||||
@@ -797,6 +928,49 @@
|
||||
return value;
|
||||
})(); };
|
||||
|
||||
// sf-letrec
|
||||
var sfLetrec = function(args, env) { return (function() {
|
||||
var bindings = first(args);
|
||||
var body = rest(args);
|
||||
var local = envExtend(env);
|
||||
var names = [];
|
||||
var valExprs = [];
|
||||
(isSxTruthy((isSxTruthy((typeOf(first(bindings)) == "list")) && (len(first(bindings)) == 2))) ? forEach(function(binding) { return (function() {
|
||||
var vname = (isSxTruthy((typeOf(first(binding)) == "symbol")) ? symbolName(first(binding)) : first(binding));
|
||||
names.push(vname);
|
||||
valExprs.push(nth(binding, 1));
|
||||
return envSet(local, vname, NIL);
|
||||
})(); }, bindings) : reduce(function(acc, pairIdx) { return (function() {
|
||||
var vname = (isSxTruthy((typeOf(nth(bindings, (pairIdx * 2))) == "symbol")) ? symbolName(nth(bindings, (pairIdx * 2))) : nth(bindings, (pairIdx * 2)));
|
||||
var valExpr = nth(bindings, ((pairIdx * 2) + 1));
|
||||
names.push(vname);
|
||||
valExprs.push(valExpr);
|
||||
return envSet(local, vname, NIL);
|
||||
})(); }, NIL, range(0, (len(bindings) / 2))));
|
||||
(function() {
|
||||
var values = map(function(e) { return trampoline(evalExpr(e, local)); }, valExprs);
|
||||
{ var _c = zip(names, values); for (var _i = 0; _i < _c.length; _i++) { var pair = _c[_i]; local[first(pair)] = nth(pair, 1); } }
|
||||
return forEach(function(val) { return (isSxTruthy(isLambda(val)) ? forEach(function(n) { return envSet(lambdaClosure(val), n, envGet(local, n)); }, names) : NIL); }, values);
|
||||
})();
|
||||
{ var _c = slice(body, 0, (len(body) - 1)); for (var _i = 0; _i < _c.length; _i++) { var e = _c[_i]; trampoline(evalExpr(e, local)); } }
|
||||
return makeThunk(last(body), local);
|
||||
})(); };
|
||||
|
||||
// sf-dynamic-wind
|
||||
var sfDynamicWind = function(args, env) { return (function() {
|
||||
var before = trampoline(evalExpr(first(args), env));
|
||||
var body = trampoline(evalExpr(nth(args, 1), env));
|
||||
var after = trampoline(evalExpr(nth(args, 2), env));
|
||||
callThunk(before, env);
|
||||
pushWind(before, after);
|
||||
return (function() {
|
||||
var result = callThunk(body, env);
|
||||
popWind();
|
||||
callThunk(after, env);
|
||||
return result;
|
||||
})();
|
||||
})(); };
|
||||
|
||||
// expand-macro
|
||||
var expandMacro = function(mac, rawArgs, env) { return (function() {
|
||||
var local = envMerge(macroClosure(mac), env);
|
||||
@@ -989,7 +1163,7 @@ return (isSxTruthy((pos >= lenSrc)) ? error("Unexpected end of input") : (functi
|
||||
return (isSxTruthy((ch == "(")) ? ((pos = (pos + 1)), readList(")")) : (isSxTruthy((ch == "[")) ? ((pos = (pos + 1)), readList("]")) : (isSxTruthy((ch == "{")) ? ((pos = (pos + 1)), readMap()) : (isSxTruthy((ch == "\"")) ? readString() : (isSxTruthy((ch == ":")) ? readKeyword() : (isSxTruthy((ch == "`")) ? ((pos = (pos + 1)), [makeSymbol("quasiquote"), readExpr()]) : (isSxTruthy((ch == ",")) ? ((pos = (pos + 1)), (isSxTruthy((isSxTruthy((pos < lenSrc)) && (nth(source, pos) == "@"))) ? ((pos = (pos + 1)), [makeSymbol("splice-unquote"), readExpr()]) : [makeSymbol("unquote"), readExpr()])) : (isSxTruthy(sxOr((isSxTruthy((ch >= "0")) && (ch <= "9")), (isSxTruthy((ch == "-")) && isSxTruthy(((pos + 1) < lenSrc)) && (function() {
|
||||
var nextCh = nth(source, (pos + 1));
|
||||
return (isSxTruthy((nextCh >= "0")) && (nextCh <= "9"));
|
||||
})()))) ? readNumber() : (isSxTruthy(isIdentStart(ch)) ? readSymbol() : error((String("Unexpected character: ") + String(ch))))))))))));
|
||||
})()))) ? readNumber() : (isSxTruthy((isSxTruthy((ch == ".")) && isSxTruthy(((pos + 2) < lenSrc)) && isSxTruthy((nth(source, (pos + 1)) == ".")) && (nth(source, (pos + 2)) == "."))) ? ((pos = (pos + 3)), makeSymbol("...")) : (isSxTruthy(isIdentStart(ch)) ? readSymbol() : error((String("Unexpected character: ") + String(ch)))))))))))));
|
||||
})()); };
|
||||
return (function() {
|
||||
var exprs = [];
|
||||
@@ -1008,6 +1182,154 @@ continue; } else { return NIL; } } };
|
||||
var sxSerializeDict = function(d) { return (String("{") + String(join(" ", reduce(function(acc, key) { return concat(acc, [(String(":") + String(key)), sxSerialize(dictGet(d, key))]); }, [], keys(d)))) + String("}")); };
|
||||
|
||||
|
||||
// === Transpiled from adapter-html ===
|
||||
|
||||
// render-to-html
|
||||
var renderToHtml = function(expr, env) { return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m == "string") return escapeHtml(expr); if (_m == "number") return (String(expr)); if (_m == "boolean") return (isSxTruthy(expr) ? "true" : "false"); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? "" : renderListToHtml(expr, env)); if (_m == "symbol") return renderValueToHtml(trampoline(evalExpr(expr, env)), env); if (_m == "keyword") return escapeHtml(keywordName(expr)); if (_m == "raw-html") return rawHtmlContent(expr); return renderValueToHtml(trampoline(evalExpr(expr, env)), env); })(); };
|
||||
|
||||
// render-value-to-html
|
||||
var renderValueToHtml = function(val, env) { return (function() { var _m = typeOf(val); if (_m == "nil") return ""; if (_m == "string") return escapeHtml(val); if (_m == "number") return (String(val)); if (_m == "boolean") return (isSxTruthy(val) ? "true" : "false"); if (_m == "list") return renderListToHtml(val, env); if (_m == "raw-html") return rawHtmlContent(val); if (_m == "style-value") return styleValueClass(val); return escapeHtml((String(val))); })(); };
|
||||
|
||||
// RENDER_HTML_FORMS
|
||||
var RENDER_HTML_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defmacro", "defstyle", "defkeyframes", "defhandler", "map", "map-indexed", "filter", "for-each"];
|
||||
|
||||
// render-html-form?
|
||||
var isRenderHtmlForm = function(name) { return contains(RENDER_HTML_FORMS, name); };
|
||||
|
||||
// render-list-to-html
|
||||
var renderListToHtml = function(expr, env) { return (isSxTruthy(isEmpty(expr)) ? "" : (function() {
|
||||
var head = first(expr);
|
||||
return (isSxTruthy(!(typeOf(head) == "symbol")) ? join("", map(function(x) { return renderValueToHtml(x, env); }, expr)) : (function() {
|
||||
var name = symbolName(head);
|
||||
var args = rest(expr);
|
||||
return (isSxTruthy((name == "<>")) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy((name == "raw!")) ? join("", map(function(x) { return (String(trampoline(evalExpr(x, env)))); }, args)) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderHtmlElement(name, args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() {
|
||||
var val = envGet(env, name);
|
||||
return (isSxTruthy(isComponent(val)) ? renderHtmlComponent(val, args, env) : (isSxTruthy(isMacro(val)) ? renderToHtml(expandMacro(val, args, env), env) : error((String("Unknown component: ") + String(name)))));
|
||||
})() : (isSxTruthy(isRenderHtmlForm(name)) ? dispatchHtmlForm(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToHtml(expandMacro(envGet(env, name), args, env), env) : renderValueToHtml(trampoline(evalExpr(expr, env)), env)))))));
|
||||
})());
|
||||
})()); };
|
||||
|
||||
// dispatch-html-form
|
||||
var dispatchHtmlForm = function(name, expr, env) { return (isSxTruthy((name == "if")) ? (function() {
|
||||
var condVal = trampoline(evalExpr(nth(expr, 1), env));
|
||||
return (isSxTruthy(condVal) ? renderToHtml(nth(expr, 2), env) : (isSxTruthy((len(expr) > 3)) ? renderToHtml(nth(expr, 3), env) : ""));
|
||||
})() : (isSxTruthy((name == "when")) ? (isSxTruthy(!trampoline(evalExpr(nth(expr, 1), env))) ? "" : join("", map(function(i) { return renderToHtml(nth(expr, i), env); }, range(2, len(expr))))) : (isSxTruthy((name == "cond")) ? (function() {
|
||||
var branch = evalCond(rest(expr), env);
|
||||
return (isSxTruthy(branch) ? renderToHtml(branch, env) : "");
|
||||
})() : (isSxTruthy((name == "case")) ? renderToHtml(trampoline(evalExpr(expr, env)), env) : (isSxTruthy(sxOr((name == "let"), (name == "let*"))) ? (function() {
|
||||
var local = processBindings(nth(expr, 1), env);
|
||||
return join("", map(function(i) { return renderToHtml(nth(expr, i), local); }, range(2, len(expr))));
|
||||
})() : (isSxTruthy(sxOr((name == "begin"), (name == "do"))) ? join("", map(function(i) { return renderToHtml(nth(expr, i), env); }, range(1, len(expr)))) : (isSxTruthy(isDefinitionForm(name)) ? (trampoline(evalExpr(expr, env)), "") : (isSxTruthy((name == "map")) ? (function() {
|
||||
var f = trampoline(evalExpr(nth(expr, 1), env));
|
||||
var coll = trampoline(evalExpr(nth(expr, 2), env));
|
||||
return join("", map(function(item) { return (isSxTruthy(isLambda(f)) ? renderLambdaHtml(f, [item], env) : renderToHtml(apply(f, [item]), env)); }, coll));
|
||||
})() : (isSxTruthy((name == "map-indexed")) ? (function() {
|
||||
var f = trampoline(evalExpr(nth(expr, 1), env));
|
||||
var coll = trampoline(evalExpr(nth(expr, 2), env));
|
||||
return join("", mapIndexed(function(i, item) { return (isSxTruthy(isLambda(f)) ? renderLambdaHtml(f, [i, item], env) : renderToHtml(apply(f, [i, item]), env)); }, coll));
|
||||
})() : (isSxTruthy((name == "filter")) ? renderToHtml(trampoline(evalExpr(expr, env)), env) : (isSxTruthy((name == "for-each")) ? (function() {
|
||||
var f = trampoline(evalExpr(nth(expr, 1), env));
|
||||
var coll = trampoline(evalExpr(nth(expr, 2), env));
|
||||
return join("", map(function(item) { return (isSxTruthy(isLambda(f)) ? renderLambdaHtml(f, [item], env) : renderToHtml(apply(f, [item]), env)); }, coll));
|
||||
})() : renderValueToHtml(trampoline(evalExpr(expr, env)), env)))))))))))); };
|
||||
|
||||
// render-lambda-html
|
||||
var renderLambdaHtml = function(f, args, env) { return (function() {
|
||||
var local = envMerge(lambdaClosure(f), env);
|
||||
forEachIndexed(function(i, p) { return envSet(local, p, nth(args, i)); }, lambdaParams(f));
|
||||
return renderToHtml(lambdaBody(f), local);
|
||||
})(); };
|
||||
|
||||
// render-html-component
|
||||
var renderHtmlComponent = function(comp, args, env) { return (function() {
|
||||
var kwargs = {};
|
||||
var children = [];
|
||||
reduce(function(state, arg) { return (function() {
|
||||
var skip = get(state, "skip");
|
||||
return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() {
|
||||
var val = trampoline(evalExpr(nth(args, (get(state, "i") + 1)), env));
|
||||
kwargs[keywordName(arg)] = val;
|
||||
return assoc(state, "skip", true, "i", (get(state, "i") + 1));
|
||||
})() : (append_b(children, arg), assoc(state, "i", (get(state, "i") + 1)))));
|
||||
})(); }, {["i"]: 0, ["skip"]: false}, args);
|
||||
return (function() {
|
||||
var local = envMerge(componentClosure(comp), env);
|
||||
{ var _c = componentParams(comp); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; local[p] = (isSxTruthy(dictHas(kwargs, p)) ? dictGet(kwargs, p) : NIL); } }
|
||||
if (isSxTruthy(componentHasChildren(comp))) {
|
||||
local["children"] = makeRawHtml(join("", map(function(c) { return renderToHtml(c, env); }, children)));
|
||||
}
|
||||
return renderToHtml(componentBody(comp), local);
|
||||
})();
|
||||
})(); };
|
||||
|
||||
// render-html-element
|
||||
var renderHtmlElement = function(tag, args, env) { return (function() {
|
||||
var parsed = parseElementArgs(args, env);
|
||||
var attrs = first(parsed);
|
||||
var children = nth(parsed, 1);
|
||||
var isVoid = contains(VOID_ELEMENTS, tag);
|
||||
return (String("<") + String(tag) + String(renderAttrs(attrs)) + String((isSxTruthy(isVoid) ? " />" : (String(">") + String(join("", map(function(c) { return renderToHtml(c, env); }, children))) + String("</") + String(tag) + String(">")))));
|
||||
})(); };
|
||||
|
||||
|
||||
// === Transpiled from adapter-sx ===
|
||||
|
||||
// render-to-sx
|
||||
var renderToSx = function(expr, env) { return (function() {
|
||||
var result = aser(expr, env);
|
||||
return (isSxTruthy((typeOf(result) == "string")) ? result : serialize(result));
|
||||
})(); };
|
||||
|
||||
// aser
|
||||
var aser = function(expr, env) { return (function() { var _m = typeOf(expr); if (_m == "number") return expr; if (_m == "string") return expr; if (_m == "boolean") return expr; if (_m == "nil") return NIL; if (_m == "symbol") return (function() {
|
||||
var name = symbolName(expr);
|
||||
return (isSxTruthy(envHas(env, name)) ? envGet(env, name) : (isSxTruthy(isPrimitive(name)) ? getPrimitive(name) : (isSxTruthy((name == "true")) ? true : (isSxTruthy((name == "false")) ? false : (isSxTruthy((name == "nil")) ? NIL : error((String("Undefined symbol: ") + String(name))))))));
|
||||
})(); if (_m == "keyword") return keywordName(expr); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? [] : aserList(expr, env)); return expr; })(); };
|
||||
|
||||
// aser-list
|
||||
var aserList = function(expr, env) { return (function() {
|
||||
var head = first(expr);
|
||||
var args = rest(expr);
|
||||
return (isSxTruthy(!(typeOf(head) == "symbol")) ? map(function(x) { return aser(x, env); }, expr) : (function() {
|
||||
var name = symbolName(head);
|
||||
return (isSxTruthy((name == "<>")) ? aserFragment(args, env) : (isSxTruthy(startsWith(name, "~")) ? aserCall(name, args, env) : (isSxTruthy(contains(HTML_TAGS, name)) ? aserCall(name, args, env) : (isSxTruthy(sxOr(isSpecialForm(name), isHoForm(name))) ? aserSpecial(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? aser(expandMacro(envGet(env, name), args, env), env) : (function() {
|
||||
var f = trampoline(evalExpr(head, env));
|
||||
var evaledArgs = map(function(a) { return trampoline(evalExpr(a, env)); }, args);
|
||||
return (isSxTruthy((isSxTruthy(isCallable(f)) && isSxTruthy(!isLambda(f)) && !isComponent(f))) ? apply(f, evaledArgs) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, evaledArgs, env)) : (isSxTruthy(isComponent(f)) ? aserCall((String("~") + String(componentName(f))), args, env) : error((String("Not callable: ") + String(inspect(f)))))));
|
||||
})())))));
|
||||
})());
|
||||
})(); };
|
||||
|
||||
// aser-fragment
|
||||
var aserFragment = function(children, env) { return (function() {
|
||||
var parts = filter(function(x) { return !isNil(x); }, map(function(c) { return aser(c, env); }, children));
|
||||
return (isSxTruthy(isEmpty(parts)) ? "" : (String("(<> ") + String(join(" ", map(serialize, parts))) + String(")")));
|
||||
})(); };
|
||||
|
||||
// aser-call
|
||||
var aserCall = function(name, args, env) { return (function() {
|
||||
var parts = [name];
|
||||
reduce(function(state, arg) { return (function() {
|
||||
var skip = get(state, "skip");
|
||||
return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() {
|
||||
var val = aser(nth(args, (get(state, "i") + 1)), env);
|
||||
if (isSxTruthy(!isNil(val))) {
|
||||
parts.push((String(":") + String(keywordName(arg))));
|
||||
parts.push(serialize(val));
|
||||
}
|
||||
return assoc(state, "skip", true, "i", (get(state, "i") + 1));
|
||||
})() : (function() {
|
||||
var val = aser(arg, env);
|
||||
if (isSxTruthy(!isNil(val))) {
|
||||
parts.push(serialize(val));
|
||||
}
|
||||
return assoc(state, "i", (get(state, "i") + 1));
|
||||
})()));
|
||||
})(); }, {["i"]: 0, ["skip"]: false}, args);
|
||||
return (String("(") + String(join(" ", parts)) + String(")"));
|
||||
})(); };
|
||||
|
||||
|
||||
// === Transpiled from adapter-dom ===
|
||||
|
||||
// SVG_NS
|
||||
@@ -1663,7 +1985,7 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\"
|
||||
if (isSxTruthy(!domHasAttr(link, "sx-push-url"))) {
|
||||
domSetAttr(link, "sx-push-url", "true");
|
||||
}
|
||||
bindBoostLink(link, domGetAttr(link, "href"));
|
||||
bindClientRouteLink(link, domGetAttr(link, "href"));
|
||||
} } }
|
||||
return forEach(function(form) { return (isSxTruthy((isSxTruthy(!isProcessed(form, "boost")) && shouldBoostForm(form))) ? (markProcessed(form, "boost"), (function() {
|
||||
var method = upper(sxOr(domGetAttr(form, "method"), "GET"));
|
||||
@@ -1677,6 +1999,27 @@ return forEach(function(form) { return (isSxTruthy((isSxTruthy(!isProcessed(form
|
||||
return bindBoostForm(form, method, action);
|
||||
})()) : NIL); }, domQueryAll(container, "form")); };
|
||||
|
||||
// try-client-route
|
||||
var tryClientRoute = function(pathname) { return (function() {
|
||||
var match = findMatchingRoute(pathname, _pageRoutes);
|
||||
return (isSxTruthy(isNil(match)) ? false : (isSxTruthy(get(match, "has-data")) ? false : (function() {
|
||||
var contentSrc = get(match, "content");
|
||||
var closure = sxOr(get(match, "closure"), {});
|
||||
var params = get(match, "params");
|
||||
return (isSxTruthy(sxOr(isNil(contentSrc), isEmpty(contentSrc))) ? false : (function() {
|
||||
var env = merge(closure, params);
|
||||
var rendered = tryEvalContent(contentSrc, env);
|
||||
return (isSxTruthy(isNil(rendered)) ? false : (function() {
|
||||
var target = domQueryById("main-panel");
|
||||
return (isSxTruthy(isNil(target)) ? false : (domSetTextContent(target, ""), domAppend(target, rendered), hoistHeadElementsFull(target), processElements(target), sxHydrateElements(target), logInfo((String("sx:route client ") + String(pathname))), true));
|
||||
})());
|
||||
})());
|
||||
})()));
|
||||
})(); };
|
||||
|
||||
// bind-client-route-link
|
||||
var bindClientRouteLink = function(link, href) { return bindClientRouteClick(link, href, function() { return bindBoostLink(link, href); }); };
|
||||
|
||||
// process-sse
|
||||
var processSse = function(root) { return forEach(function(el) { return (isSxTruthy(!isProcessed(el, "sse")) ? (markProcessed(el, "sse"), bindSse(el)) : NIL); }, domQueryAll(sxOr(root, domBody()), "[sx-sse]")); };
|
||||
|
||||
@@ -1758,8 +2101,11 @@ return bindInlineHandlers(root); };
|
||||
var main = domQueryById("main-panel");
|
||||
var url = browserLocationHref();
|
||||
return (isSxTruthy(main) ? (function() {
|
||||
var pathname = urlPathname(url);
|
||||
return (isSxTruthy(tryClientRoute(pathname)) ? browserScrollTo(0, scrollY) : (function() {
|
||||
var headers = buildRequestHeaders(main, loadedComponentNames(), _cssHash);
|
||||
return fetchAndRestore(main, url, headers, scrollY);
|
||||
})());
|
||||
})() : NIL);
|
||||
})(); };
|
||||
|
||||
@@ -2061,10 +2407,208 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
return setSxStylesCookie(hash);
|
||||
})());
|
||||
})()) : NIL); }, scripts);
|
||||
})(); };
|
||||
|
||||
// _page-routes
|
||||
var _pageRoutes = [];
|
||||
|
||||
// process-page-scripts
|
||||
var processPageScripts = function() { return (function() {
|
||||
var scripts = queryPageScripts();
|
||||
return forEach(function(s) { return (isSxTruthy(!isProcessed(s, "pages")) ? (markProcessed(s, "pages"), (function() {
|
||||
var text = domTextContent(s);
|
||||
return (isSxTruthy((isSxTruthy(text) && !isEmpty(trim(text)))) ? (function() {
|
||||
var pages = parse(text);
|
||||
return forEach(function(page) { return append_b(_pageRoutes, merge(page, {"parsed": parseRoutePattern(get(page, "path"))})); }, pages);
|
||||
})() : NIL);
|
||||
})()) : NIL); }, scripts);
|
||||
})(); };
|
||||
|
||||
// boot-init
|
||||
var bootInit = function() { return (initCssTracking(), initStyleDict(), processSxScripts(NIL), sxHydrateElements(NIL), processElements(NIL)); };
|
||||
var bootInit = function() { return (initCssTracking(), initStyleDict(), processSxScripts(NIL), processPageScripts(), sxHydrateElements(NIL), processElements(NIL)); };
|
||||
|
||||
|
||||
// === Transpiled from deps (component dependency analysis) ===
|
||||
|
||||
// scan-refs
|
||||
var scanRefs = function(node) { return (function() {
|
||||
var refs = [];
|
||||
scanRefsWalk(node, refs);
|
||||
return refs;
|
||||
})(); };
|
||||
|
||||
// scan-refs-walk
|
||||
var scanRefsWalk = function(node, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() {
|
||||
var name = symbolName(node);
|
||||
return (isSxTruthy(startsWith(name, "~")) ? (isSxTruthy(!contains(refs, name)) ? append_b(refs, name) : NIL) : NIL);
|
||||
})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanRefsWalk(item, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanRefsWalk(dictGet(node, key), refs); }, keys(node)) : NIL))); };
|
||||
|
||||
// transitive-deps-walk
|
||||
var transitiveDepsWalk = function(n, seen, env) { return (isSxTruthy(!contains(seen, n)) ? (append_b(seen, n), (function() {
|
||||
var val = envGet(env, n);
|
||||
return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(componentBody(val))) : (isSxTruthy((typeOf(val) == "macro")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(macroBody(val))) : NIL));
|
||||
})()) : NIL); };
|
||||
|
||||
// transitive-deps
|
||||
var transitiveDeps = function(name, env) { return (function() {
|
||||
var seen = [];
|
||||
var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name)));
|
||||
transitiveDepsWalk(key, seen, env);
|
||||
return filter(function(x) { return !(x == key); }, seen);
|
||||
})(); };
|
||||
|
||||
// compute-all-deps
|
||||
var computeAllDeps = function(env) { return forEach(function(name) { return (function() {
|
||||
var val = envGet(env, name);
|
||||
return (isSxTruthy((typeOf(val) == "component")) ? componentSetDeps(val, transitiveDeps(name, env)) : NIL);
|
||||
})(); }, envComponents(env)); };
|
||||
|
||||
// scan-components-from-source
|
||||
var scanComponentsFromSource = function(source) { return (function() {
|
||||
var matches = regexFindAll("\\(~([a-zA-Z_][a-zA-Z0-9_\\-]*)", source);
|
||||
return map(function(m) { return (String("~") + String(m)); }, matches);
|
||||
})(); };
|
||||
|
||||
// components-needed
|
||||
var componentsNeeded = function(pageSource, env) { return (function() {
|
||||
var direct = scanComponentsFromSource(pageSource);
|
||||
var allNeeded = [];
|
||||
{ var _c = direct; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; if (isSxTruthy(!contains(allNeeded, name))) {
|
||||
allNeeded.push(name);
|
||||
}
|
||||
(function() {
|
||||
var val = envGet(env, name);
|
||||
return (function() {
|
||||
var deps = (isSxTruthy((isSxTruthy((typeOf(val) == "component")) && !isEmpty(componentDeps(val)))) ? componentDeps(val) : transitiveDeps(name, env));
|
||||
return forEach(function(dep) { return (isSxTruthy(!contains(allNeeded, dep)) ? append_b(allNeeded, dep) : NIL); }, deps);
|
||||
})();
|
||||
})(); } }
|
||||
return allNeeded;
|
||||
})(); };
|
||||
|
||||
// page-component-bundle
|
||||
var pageComponentBundle = function(pageSource, env) { return componentsNeeded(pageSource, env); };
|
||||
|
||||
// page-css-classes
|
||||
var pageCssClasses = function(pageSource, env) { return (function() {
|
||||
var needed = componentsNeeded(pageSource, env);
|
||||
var classes = [];
|
||||
{ var _c = needed; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; (function() {
|
||||
var val = envGet(env, name);
|
||||
return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(cls) { return (isSxTruthy(!contains(classes, cls)) ? append_b(classes, cls) : NIL); }, componentCssClasses(val)) : NIL);
|
||||
})(); } }
|
||||
{ var _c = scanCssClasses(pageSource); for (var _i = 0; _i < _c.length; _i++) { var cls = _c[_i]; if (isSxTruthy(!contains(classes, cls))) {
|
||||
classes.push(cls);
|
||||
} } }
|
||||
return classes;
|
||||
})(); };
|
||||
|
||||
// scan-io-refs-walk
|
||||
var scanIoRefsWalk = function(node, ioNames, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() {
|
||||
var name = symbolName(node);
|
||||
return (isSxTruthy(contains(ioNames, name)) ? (isSxTruthy(!contains(refs, name)) ? append_b(refs, name) : NIL) : NIL);
|
||||
})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanIoRefsWalk(item, ioNames, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanIoRefsWalk(dictGet(node, key), ioNames, refs); }, keys(node)) : NIL))); };
|
||||
|
||||
// scan-io-refs
|
||||
var scanIoRefs = function(node, ioNames) { return (function() {
|
||||
var refs = [];
|
||||
scanIoRefsWalk(node, ioNames, refs);
|
||||
return refs;
|
||||
})(); };
|
||||
|
||||
// transitive-io-refs-walk
|
||||
var transitiveIoRefsWalk = function(n, seen, allRefs, env, ioNames) { return (isSxTruthy(!contains(seen, n)) ? (append_b(seen, n), (function() {
|
||||
var val = envGet(env, n);
|
||||
return (isSxTruthy((typeOf(val) == "component")) ? (forEach(function(ref) { return (isSxTruthy(!contains(allRefs, ref)) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(componentBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(componentBody(val)))) : (isSxTruthy((typeOf(val) == "macro")) ? (forEach(function(ref) { return (isSxTruthy(!contains(allRefs, ref)) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(macroBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(macroBody(val)))) : NIL));
|
||||
})()) : NIL); };
|
||||
|
||||
// transitive-io-refs
|
||||
var transitiveIoRefs = function(name, env, ioNames) { return (function() {
|
||||
var allRefs = [];
|
||||
var seen = [];
|
||||
var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name)));
|
||||
transitiveIoRefsWalk(key, seen, allRefs, env, ioNames);
|
||||
return allRefs;
|
||||
})(); };
|
||||
|
||||
// compute-all-io-refs
|
||||
var computeAllIoRefs = function(env, ioNames) { return forEach(function(name) { return (function() {
|
||||
var val = envGet(env, name);
|
||||
return (isSxTruthy((typeOf(val) == "component")) ? componentSetIoRefs(val, transitiveIoRefs(name, env, ioNames)) : NIL);
|
||||
})(); }, envComponents(env)); };
|
||||
|
||||
// component-pure?
|
||||
var componentPure_p = function(name, env, ioNames) { return isEmpty(transitiveIoRefs(name, env, ioNames)); };
|
||||
|
||||
|
||||
// === Transpiled from router (client-side route matching) ===
|
||||
|
||||
// split-path-segments
|
||||
var splitPathSegments = function(path) { return (function() {
|
||||
var trimmed = (isSxTruthy(startsWith(path, "/")) ? slice(path, 1) : path);
|
||||
return (function() {
|
||||
var trimmed2 = (isSxTruthy((isSxTruthy(!isEmpty(trimmed)) && endsWith(trimmed, "/"))) ? slice(trimmed, 0, (length(trimmed) - 1)) : trimmed);
|
||||
return (isSxTruthy(isEmpty(trimmed2)) ? [] : split(trimmed2, "/"));
|
||||
})();
|
||||
})(); };
|
||||
|
||||
// make-route-segment
|
||||
var makeRouteSegment = function(seg) { return (isSxTruthy((isSxTruthy(startsWith(seg, "<")) && endsWith(seg, ">"))) ? (function() {
|
||||
var paramName = slice(seg, 1, (length(seg) - 1));
|
||||
return (function() {
|
||||
var d = {};
|
||||
d["type"] = "param";
|
||||
d["value"] = paramName;
|
||||
return d;
|
||||
})();
|
||||
})() : (function() {
|
||||
var d = {};
|
||||
d["type"] = "literal";
|
||||
d["value"] = seg;
|
||||
return d;
|
||||
})()); };
|
||||
|
||||
// parse-route-pattern
|
||||
var parseRoutePattern = function(pattern) { return (function() {
|
||||
var segments = splitPathSegments(pattern);
|
||||
return map(makeRouteSegment, segments);
|
||||
})(); };
|
||||
|
||||
// match-route-segments
|
||||
var matchRouteSegments = function(pathSegs, parsedSegs) { return (isSxTruthy(!(length(pathSegs) == length(parsedSegs))) ? NIL : (function() {
|
||||
var params = {};
|
||||
var matched = true;
|
||||
forEachIndexed(function(i, parsedSeg) { return (isSxTruthy(matched) ? (function() {
|
||||
var pathSeg = nth(pathSegs, i);
|
||||
var segType = get(parsedSeg, "type");
|
||||
return (isSxTruthy((segType == "literal")) ? (isSxTruthy(!(pathSeg == get(parsedSeg, "value"))) ? (matched = false) : NIL) : (isSxTruthy((segType == "param")) ? dictSet(params, get(parsedSeg, "value"), pathSeg) : (matched = false)));
|
||||
})() : NIL); }, parsedSegs);
|
||||
return (isSxTruthy(matched) ? params : NIL);
|
||||
})()); };
|
||||
|
||||
// match-route
|
||||
var matchRoute = function(path, pattern) { return (function() {
|
||||
var pathSegs = splitPathSegments(path);
|
||||
var parsedSegs = parseRoutePattern(pattern);
|
||||
return matchRouteSegments(pathSegs, parsedSegs);
|
||||
})(); };
|
||||
|
||||
// find-matching-route
|
||||
var findMatchingRoute = function(path, routes) { return (function() {
|
||||
var pathSegs = splitPathSegments(path);
|
||||
var result = NIL;
|
||||
{ var _c = routes; for (var _i = 0; _i < _c.length; _i++) { var route = _c[_i]; if (isSxTruthy(isNil(result))) {
|
||||
(function() {
|
||||
var params = matchRouteSegments(pathSegs, get(route, "parsed"));
|
||||
return (isSxTruthy(!isNil(params)) ? (function() {
|
||||
var matched = merge(route, {});
|
||||
matched["params"] = params;
|
||||
return (result = matched);
|
||||
})() : NIL);
|
||||
})();
|
||||
} } }
|
||||
return result;
|
||||
})(); };
|
||||
|
||||
|
||||
// =========================================================================
|
||||
@@ -2711,6 +3255,50 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
});
|
||||
}
|
||||
|
||||
// --- Client-side route bindings ---
|
||||
|
||||
function bindClientRouteClick(link, href, fallbackFn) {
|
||||
link.addEventListener("click", function(e) {
|
||||
e.preventDefault();
|
||||
var pathname = urlPathname(href);
|
||||
if (tryClientRoute(pathname)) {
|
||||
try { history.pushState({ sxUrl: href, scrollY: window.scrollY }, "", href); } catch (err) {}
|
||||
if (typeof window !== "undefined") window.scrollTo(0, 0);
|
||||
} else {
|
||||
logInfo("sx:route server " + pathname);
|
||||
executeRequest(link, { method: "GET", url: href }).then(function() {
|
||||
try { history.pushState({ sxUrl: href, scrollY: window.scrollY }, "", href); } catch (err) {}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function tryEvalContent(source, env) {
|
||||
try {
|
||||
var merged = merge(componentEnv);
|
||||
if (env && !isNil(env)) {
|
||||
var ks = Object.keys(env);
|
||||
for (var i = 0; i < ks.length; i++) merged[ks[i]] = env[ks[i]];
|
||||
}
|
||||
return sxRenderWithEnv(source, merged);
|
||||
} catch (e) {
|
||||
return NIL;
|
||||
}
|
||||
}
|
||||
|
||||
function urlPathname(href) {
|
||||
try {
|
||||
return new URL(href, location.href).pathname;
|
||||
} catch (e) {
|
||||
// Fallback: strip query/hash
|
||||
var idx = href.indexOf("?");
|
||||
if (idx >= 0) href = href.substring(0, idx);
|
||||
idx = href.indexOf("#");
|
||||
if (idx >= 0) href = href.substring(0, idx);
|
||||
return href;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Inline handlers ---
|
||||
|
||||
function bindInlineHandler(el, eventName, body) {
|
||||
@@ -2981,6 +3569,12 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
document.querySelectorAll('script[type="text/sx-styles"]'));
|
||||
}
|
||||
|
||||
function queryPageScripts() {
|
||||
if (!_hasDom) return [];
|
||||
return Array.prototype.slice.call(
|
||||
document.querySelectorAll('script[type="text/sx-pages"]'));
|
||||
}
|
||||
|
||||
// --- localStorage ---
|
||||
|
||||
function localStorageGet(key) {
|
||||
@@ -3076,6 +3670,9 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
};
|
||||
|
||||
// Expose render functions as primitives so SX code can call them
|
||||
if (typeof renderToHtml === "function") PRIMITIVES["render-to-html"] = renderToHtml;
|
||||
if (typeof renderToSx === "function") PRIMITIVES["render-to-sx"] = renderToSx;
|
||||
if (typeof aser === "function") PRIMITIVES["aser"] = aser;
|
||||
if (typeof renderToDom === "function") PRIMITIVES["render-to-dom"] = renderToDom;
|
||||
|
||||
// Parser — compiled from parser.sx (see PLATFORM_PARSER_JS for ident char classes)
|
||||
@@ -3095,12 +3692,25 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
}
|
||||
|
||||
function render(source) {
|
||||
if (!_hasDom) {
|
||||
var exprs = parse(source);
|
||||
var parts = [];
|
||||
for (var i = 0; i < exprs.length; i++) parts.push(renderToHtml(exprs[i], merge(componentEnv)));
|
||||
return parts.join("");
|
||||
}
|
||||
var exprs = parse(source);
|
||||
var frag = document.createDocumentFragment();
|
||||
for (var i = 0; i < exprs.length; i++) frag.appendChild(renderToDom(exprs[i], merge(componentEnv), null));
|
||||
return frag;
|
||||
}
|
||||
|
||||
function renderToString(source) {
|
||||
var exprs = parse(source);
|
||||
var parts = [];
|
||||
for (var i = 0; i < exprs.length; i++) parts.push(renderToHtml(exprs[i], merge(componentEnv)));
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
var Sx = {
|
||||
VERSION: "ref-2.0",
|
||||
parse: parse,
|
||||
@@ -3108,7 +3718,7 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
eval: function(expr, env) { return trampoline(evalExpr(expr, env || merge(componentEnv))); },
|
||||
loadComponents: loadComponents,
|
||||
render: render,
|
||||
|
||||
renderToString: renderToString,
|
||||
serialize: serialize,
|
||||
NIL: NIL,
|
||||
Symbol: Symbol,
|
||||
@@ -3116,6 +3726,8 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
isTruthy: isSxTruthy,
|
||||
isNil: isNil,
|
||||
componentEnv: componentEnv,
|
||||
renderToHtml: function(expr, env) { return renderToHtml(expr, env || merge(componentEnv)); },
|
||||
renderToSx: function(expr, env) { return renderToSx(expr, env || merge(componentEnv)); },
|
||||
renderToDom: _hasDom ? function(expr, env, ns) { return renderToDom(expr, env || merge(componentEnv), ns || null); } : null,
|
||||
parseTriggerSpec: typeof parseTriggerSpec === "function" ? parseTriggerSpec : null,
|
||||
morphNode: typeof morphNode === "function" ? morphNode : null,
|
||||
@@ -3131,7 +3743,21 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null,
|
||||
getEnv: function() { return componentEnv; },
|
||||
init: typeof bootInit === "function" ? bootInit : null,
|
||||
_version: "ref-2.0 (boot+cssx+dom+engine+orchestration+parser, bootstrap-compiled)"
|
||||
scanRefs: scanRefs,
|
||||
transitiveDeps: transitiveDeps,
|
||||
computeAllDeps: computeAllDeps,
|
||||
componentsNeeded: componentsNeeded,
|
||||
pageComponentBundle: pageComponentBundle,
|
||||
pageCssClasses: pageCssClasses,
|
||||
scanIoRefs: scanIoRefs,
|
||||
transitiveIoRefs: transitiveIoRefs,
|
||||
computeAllIoRefs: computeAllIoRefs,
|
||||
componentPure_p: componentPure_p,
|
||||
splitPathSegments: splitPathSegments,
|
||||
parseRoutePattern: parseRoutePattern,
|
||||
matchRoute: matchRoute,
|
||||
findMatchingRoute: findMatchingRoute,
|
||||
_version: "ref-2.0 (boot+cssx+dom+engine+html+orchestration+parser+sx, bootstrap-compiled)"
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -283,6 +283,7 @@
|
||||
PRIMITIVES["split"] = function(s, sep) { return String(s).split(sep || " "); };
|
||||
PRIMITIVES["join"] = function(sep, coll) { return coll.join(sep); };
|
||||
PRIMITIVES["replace"] = function(s, old, nw) { return s.split(old).join(nw); };
|
||||
PRIMITIVES["index-of"] = function(s, needle, from) { return String(s).indexOf(needle, from || 0); };
|
||||
PRIMITIVES["starts-with?"] = function(s, p) { return String(s).indexOf(p) === 0; };
|
||||
PRIMITIVES["ends-with?"] = function(s, p) { var str = String(s); return str.indexOf(p, str.length - p.length) !== -1; };
|
||||
PRIMITIVES["slice"] = function(c, a, b) { return b !== undefined ? c.slice(a, b) : c.slice(a); };
|
||||
@@ -531,6 +532,92 @@
|
||||
return NIL;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Platform: deps module — component dependency analysis
|
||||
// =========================================================================
|
||||
|
||||
function componentDeps(c) {
|
||||
return c.deps ? c.deps.slice() : [];
|
||||
}
|
||||
|
||||
function componentSetDeps(c, deps) {
|
||||
c.deps = deps;
|
||||
}
|
||||
|
||||
function componentCssClasses(c) {
|
||||
return c.cssClasses ? c.cssClasses.slice() : [];
|
||||
}
|
||||
|
||||
function envComponents(env) {
|
||||
var names = [];
|
||||
for (var k in env) {
|
||||
var v = env[k];
|
||||
if (v && (v._component || v._macro)) names.push(k);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function regexFindAll(pattern, source) {
|
||||
var re = new RegExp(pattern, "g");
|
||||
var results = [];
|
||||
var m;
|
||||
while ((m = re.exec(source)) !== null) {
|
||||
if (m[1] !== undefined) results.push(m[1]);
|
||||
else results.push(m[0]);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function scanCssClasses(source) {
|
||||
var classes = {};
|
||||
var result = [];
|
||||
var m;
|
||||
var re1 = /:class\s+"([^"]*)"/g;
|
||||
while ((m = re1.exec(source)) !== null) {
|
||||
var parts = m[1].split(/\s+/);
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (parts[i] && !classes[parts[i]]) {
|
||||
classes[parts[i]] = true;
|
||||
result.push(parts[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
var re2 = /:class\s+\(str\s+((?:"[^"]*"\s*)+)\)/g;
|
||||
while ((m = re2.exec(source)) !== null) {
|
||||
var re3 = /"([^"]*)"/g;
|
||||
var m2;
|
||||
while ((m2 = re3.exec(m[1])) !== null) {
|
||||
var parts2 = m2[1].split(/\s+/);
|
||||
for (var j = 0; j < parts2.length; j++) {
|
||||
if (parts2[j] && !classes[parts2[j]]) {
|
||||
classes[parts2[j]] = true;
|
||||
result.push(parts2[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var re4 = /;;\s*@css\s+(.+)/g;
|
||||
while ((m = re4.exec(source)) !== null) {
|
||||
var parts3 = m[1].split(/\s+/);
|
||||
for (var k = 0; k < parts3.length; k++) {
|
||||
if (parts3[k] && !classes[parts3[k]]) {
|
||||
classes[parts3[k]] = true;
|
||||
result.push(parts3[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function componentIoRefs(c) {
|
||||
return c.ioRefs ? c.ioRefs.slice() : [];
|
||||
}
|
||||
|
||||
function componentSetIoRefs(c, refs) {
|
||||
c.ioRefs = refs;
|
||||
}
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// Platform interface — Parser
|
||||
// =========================================================================
|
||||
@@ -2302,6 +2389,119 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
var bootInit = function() { return (initCssTracking(), initStyleDict(), processSxScripts(NIL), sxHydrateElements(NIL), processElements(NIL)); };
|
||||
|
||||
|
||||
// === Transpiled from deps (component dependency analysis) ===
|
||||
|
||||
// scan-refs
|
||||
var scanRefs = function(node) { return (function() {
|
||||
var refs = [];
|
||||
scanRefsWalk(node, refs);
|
||||
return refs;
|
||||
})(); };
|
||||
|
||||
// scan-refs-walk
|
||||
var scanRefsWalk = function(node, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() {
|
||||
var name = symbolName(node);
|
||||
return (isSxTruthy(startsWith(name, "~")) ? (isSxTruthy(!contains(refs, name)) ? append_b(refs, name) : NIL) : NIL);
|
||||
})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanRefsWalk(item, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanRefsWalk(dictGet(node, key), refs); }, keys(node)) : NIL))); };
|
||||
|
||||
// transitive-deps-walk
|
||||
var transitiveDepsWalk = function(n, seen, env) { return (isSxTruthy(!contains(seen, n)) ? (append_b(seen, n), (function() {
|
||||
var val = envGet(env, n);
|
||||
return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(componentBody(val))) : (isSxTruthy((typeOf(val) == "macro")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(macroBody(val))) : NIL));
|
||||
})()) : NIL); };
|
||||
|
||||
// transitive-deps
|
||||
var transitiveDeps = function(name, env) { return (function() {
|
||||
var seen = [];
|
||||
var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name)));
|
||||
transitiveDepsWalk(key, seen, env);
|
||||
return filter(function(x) { return !(x == key); }, seen);
|
||||
})(); };
|
||||
|
||||
// compute-all-deps
|
||||
var computeAllDeps = function(env) { return forEach(function(name) { return (function() {
|
||||
var val = envGet(env, name);
|
||||
return (isSxTruthy((typeOf(val) == "component")) ? componentSetDeps(val, transitiveDeps(name, env)) : NIL);
|
||||
})(); }, envComponents(env)); };
|
||||
|
||||
// scan-components-from-source
|
||||
var scanComponentsFromSource = function(source) { return (function() {
|
||||
var matches = regexFindAll("\\(~([a-zA-Z_][a-zA-Z0-9_\\-]*)", source);
|
||||
return map(function(m) { return (String("~") + String(m)); }, matches);
|
||||
})(); };
|
||||
|
||||
// components-needed
|
||||
var componentsNeeded = function(pageSource, env) { return (function() {
|
||||
var direct = scanComponentsFromSource(pageSource);
|
||||
var allNeeded = [];
|
||||
{ var _c = direct; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; if (isSxTruthy(!contains(allNeeded, name))) {
|
||||
allNeeded.push(name);
|
||||
}
|
||||
(function() {
|
||||
var val = envGet(env, name);
|
||||
return (function() {
|
||||
var deps = (isSxTruthy((isSxTruthy((typeOf(val) == "component")) && !isEmpty(componentDeps(val)))) ? componentDeps(val) : transitiveDeps(name, env));
|
||||
return forEach(function(dep) { return (isSxTruthy(!contains(allNeeded, dep)) ? append_b(allNeeded, dep) : NIL); }, deps);
|
||||
})();
|
||||
})(); } }
|
||||
return allNeeded;
|
||||
})(); };
|
||||
|
||||
// page-component-bundle
|
||||
var pageComponentBundle = function(pageSource, env) { return componentsNeeded(pageSource, env); };
|
||||
|
||||
// page-css-classes
|
||||
var pageCssClasses = function(pageSource, env) { return (function() {
|
||||
var needed = componentsNeeded(pageSource, env);
|
||||
var classes = [];
|
||||
{ var _c = needed; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; (function() {
|
||||
var val = envGet(env, name);
|
||||
return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(cls) { return (isSxTruthy(!contains(classes, cls)) ? append_b(classes, cls) : NIL); }, componentCssClasses(val)) : NIL);
|
||||
})(); } }
|
||||
{ var _c = scanCssClasses(pageSource); for (var _i = 0; _i < _c.length; _i++) { var cls = _c[_i]; if (isSxTruthy(!contains(classes, cls))) {
|
||||
classes.push(cls);
|
||||
} } }
|
||||
return classes;
|
||||
})(); };
|
||||
|
||||
// scan-io-refs-walk
|
||||
var scanIoRefsWalk = function(node, ioNames, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() {
|
||||
var name = symbolName(node);
|
||||
return (isSxTruthy(contains(ioNames, name)) ? (isSxTruthy(!contains(refs, name)) ? append_b(refs, name) : NIL) : NIL);
|
||||
})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanIoRefsWalk(item, ioNames, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanIoRefsWalk(dictGet(node, key), ioNames, refs); }, keys(node)) : NIL))); };
|
||||
|
||||
// scan-io-refs
|
||||
var scanIoRefs = function(node, ioNames) { return (function() {
|
||||
var refs = [];
|
||||
scanIoRefsWalk(node, ioNames, refs);
|
||||
return refs;
|
||||
})(); };
|
||||
|
||||
// transitive-io-refs-walk
|
||||
var transitiveIoRefsWalk = function(n, seen, allRefs, env, ioNames) { return (isSxTruthy(!contains(seen, n)) ? (append_b(seen, n), (function() {
|
||||
var val = envGet(env, n);
|
||||
return (isSxTruthy((typeOf(val) == "component")) ? (forEach(function(ref) { return (isSxTruthy(!contains(allRefs, ref)) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(componentBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(componentBody(val)))) : (isSxTruthy((typeOf(val) == "macro")) ? (forEach(function(ref) { return (isSxTruthy(!contains(allRefs, ref)) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(macroBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(macroBody(val)))) : NIL));
|
||||
})()) : NIL); };
|
||||
|
||||
// transitive-io-refs
|
||||
var transitiveIoRefs = function(name, env, ioNames) { return (function() {
|
||||
var allRefs = [];
|
||||
var seen = [];
|
||||
var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name)));
|
||||
transitiveIoRefsWalk(key, seen, allRefs, env, ioNames);
|
||||
return allRefs;
|
||||
})(); };
|
||||
|
||||
// compute-all-io-refs
|
||||
var computeAllIoRefs = function(env, ioNames) { return forEach(function(name) { return (function() {
|
||||
var val = envGet(env, name);
|
||||
return (isSxTruthy((typeOf(val) == "component")) ? componentSetIoRefs(val, transitiveIoRefs(name, env, ioNames)) : NIL);
|
||||
})(); }, envComponents(env)); };
|
||||
|
||||
// component-pure?
|
||||
var componentPure_p = function(name, env, ioNames) { return isEmpty(transitiveIoRefs(name, env, ioNames)); };
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// Platform interface — DOM adapter (browser-only)
|
||||
// =========================================================================
|
||||
@@ -3465,6 +3665,16 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null,
|
||||
getEnv: function() { return componentEnv; },
|
||||
init: typeof bootInit === "function" ? bootInit : null,
|
||||
scanRefs: scanRefs,
|
||||
transitiveDeps: transitiveDeps,
|
||||
computeAllDeps: computeAllDeps,
|
||||
componentsNeeded: componentsNeeded,
|
||||
pageComponentBundle: pageComponentBundle,
|
||||
pageCssClasses: pageCssClasses,
|
||||
scanIoRefs: scanIoRefs,
|
||||
transitiveIoRefs: transitiveIoRefs,
|
||||
computeAllIoRefs: computeAllIoRefs,
|
||||
componentPure_p: componentPure_p,
|
||||
_version: "ref-2.0 (boot+cssx+dom+engine+html+orchestration+parser+sx, bootstrap-compiled)"
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ module.exports = {
|
||||
'/root/rose-ash/federation/sx/sx_components.py',
|
||||
'/root/rose-ash/account/sx/sx_components.py',
|
||||
'/root/rose-ash/orders/sx/sx_components.py',
|
||||
'/root/rose-ash/sx/sx/**/*.sx',
|
||||
'/root/rose-ash/sx/sxc/**/*.sx',
|
||||
'/root/rose-ash/sx/sxc/sx_components.py',
|
||||
'/root/rose-ash/sx/content/highlight.py',
|
||||
|
||||
@@ -1332,8 +1332,9 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
|
||||
if isinstance(val, Macro):
|
||||
expanded = _expand_macro(val, expr[1:], env)
|
||||
return await _aser(expanded, env, ctx)
|
||||
if isinstance(val, Component) and _expand_components.get():
|
||||
return await _aser_component(val, expr[1:], env, ctx)
|
||||
if isinstance(val, Component):
|
||||
if _expand_components.get() or not val.is_pure:
|
||||
return await _aser_component(val, expr[1:], env, ctx)
|
||||
return await _aser_call(name, expr[1:], env, ctx)
|
||||
|
||||
# Serialize-mode special/HO forms (checked BEFORE HTML_TAGS
|
||||
|
||||
@@ -69,22 +69,25 @@ def validate_primitive(name: str) -> None:
|
||||
|
||||
|
||||
def validate_io(name: str) -> None:
|
||||
"""Validate that an I/O primitive is declared in boundary.sx."""
|
||||
"""Validate that an I/O primitive is declared in boundary.sx or boundary-app.sx."""
|
||||
_load_declarations()
|
||||
assert _DECLARED_IO is not None
|
||||
if name not in _DECLARED_IO:
|
||||
_report(f"Undeclared I/O primitive: {name!r}. Add to boundary.sx.")
|
||||
_report(
|
||||
f"Undeclared I/O primitive: {name!r}. "
|
||||
f"Add to boundary.sx (core) or boundary-app.sx (deployment)."
|
||||
)
|
||||
|
||||
|
||||
def validate_helper(service: str, name: str) -> None:
|
||||
"""Validate that a page helper is declared in boundary.sx."""
|
||||
"""Validate that a page helper is declared in {service}/sx/boundary.sx."""
|
||||
_load_declarations()
|
||||
assert _DECLARED_HELPERS is not None
|
||||
svc_helpers = _DECLARED_HELPERS.get(service, frozenset())
|
||||
if name not in svc_helpers:
|
||||
_report(
|
||||
f"Undeclared page helper: {name!r} for service {service!r}. "
|
||||
f"Add to boundary.sx."
|
||||
f"Add to {service}/sx/boundary.sx."
|
||||
)
|
||||
|
||||
|
||||
|
||||
213
shared/sx/deps.py
Normal file
213
shared/sx/deps.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Component dependency analysis.
|
||||
|
||||
Thin host wrapper over bootstrapped deps module from shared/sx/ref/deps.sx.
|
||||
The canonical logic lives in the spec; this module provides Python-typed
|
||||
entry points for the rest of the codebase.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from .types import Component, Macro, Symbol
|
||||
|
||||
|
||||
def _use_ref() -> bool:
|
||||
return os.environ.get("SX_USE_REF") == "1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hand-written fallback (used when SX_USE_REF != 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _scan_ast(node: Any) -> set[str]:
|
||||
refs: set[str] = set()
|
||||
_walk(node, refs)
|
||||
return refs
|
||||
|
||||
|
||||
def _walk(node: Any, refs: set[str]) -> None:
|
||||
if isinstance(node, Symbol):
|
||||
if node.name.startswith("~"):
|
||||
refs.add(node.name)
|
||||
return
|
||||
if isinstance(node, list):
|
||||
for item in node:
|
||||
_walk(item, refs)
|
||||
return
|
||||
if isinstance(node, dict):
|
||||
for v in node.values():
|
||||
_walk(v, refs)
|
||||
return
|
||||
|
||||
|
||||
def _transitive_deps_fallback(name: str, env: dict[str, Any]) -> set[str]:
|
||||
seen: set[str] = set()
|
||||
|
||||
def walk(n: str) -> None:
|
||||
if n in seen:
|
||||
return
|
||||
seen.add(n)
|
||||
val = env.get(n)
|
||||
if isinstance(val, Component):
|
||||
for dep in _scan_ast(val.body):
|
||||
walk(dep)
|
||||
elif isinstance(val, Macro):
|
||||
for dep in _scan_ast(val.body):
|
||||
walk(dep)
|
||||
|
||||
key = name if name.startswith("~") else f"~{name}"
|
||||
walk(key)
|
||||
return seen - {key}
|
||||
|
||||
|
||||
def _compute_all_deps_fallback(env: dict[str, Any]) -> None:
|
||||
for key, val in env.items():
|
||||
if isinstance(val, Component):
|
||||
val.deps = _transitive_deps_fallback(key, env)
|
||||
|
||||
|
||||
def _scan_io_refs_fallback(node: Any, io_names: set[str]) -> set[str]:
|
||||
"""Scan an AST node for references to IO primitive names."""
|
||||
refs: set[str] = set()
|
||||
_walk_io(node, io_names, refs)
|
||||
return refs
|
||||
|
||||
|
||||
def _walk_io(node: Any, io_names: set[str], refs: set[str]) -> None:
|
||||
if isinstance(node, Symbol):
|
||||
if node.name in io_names:
|
||||
refs.add(node.name)
|
||||
return
|
||||
if isinstance(node, list):
|
||||
for item in node:
|
||||
_walk_io(item, io_names, refs)
|
||||
return
|
||||
if isinstance(node, dict):
|
||||
for v in node.values():
|
||||
_walk_io(v, io_names, refs)
|
||||
return
|
||||
|
||||
|
||||
def _transitive_io_refs_fallback(
|
||||
name: str, env: dict[str, Any], io_names: set[str]
|
||||
) -> set[str]:
|
||||
"""Compute transitive IO primitive references for a component."""
|
||||
all_refs: set[str] = set()
|
||||
seen: set[str] = set()
|
||||
|
||||
def walk(n: str) -> None:
|
||||
if n in seen:
|
||||
return
|
||||
seen.add(n)
|
||||
val = env.get(n)
|
||||
if isinstance(val, Component):
|
||||
all_refs.update(_scan_io_refs_fallback(val.body, io_names))
|
||||
for dep in _scan_ast(val.body):
|
||||
walk(dep)
|
||||
elif isinstance(val, Macro):
|
||||
all_refs.update(_scan_io_refs_fallback(val.body, io_names))
|
||||
for dep in _scan_ast(val.body):
|
||||
walk(dep)
|
||||
|
||||
key = name if name.startswith("~") else f"~{name}"
|
||||
walk(key)
|
||||
return all_refs
|
||||
|
||||
|
||||
def _compute_all_io_refs_fallback(
|
||||
env: dict[str, Any], io_names: set[str]
|
||||
) -> None:
|
||||
for key, val in env.items():
|
||||
if isinstance(val, Component):
|
||||
val.io_refs = _transitive_io_refs_fallback(key, env, io_names)
|
||||
|
||||
|
||||
def _scan_components_from_sx_fallback(source: str) -> set[str]:
|
||||
import re
|
||||
return {f"~{m}" for m in re.findall(r'\(~([a-zA-Z_][a-zA-Z0-9_\-]*)', source)}
|
||||
|
||||
|
||||
def _components_needed_fallback(page_sx: str, env: dict[str, Any]) -> set[str]:
|
||||
direct = _scan_components_from_sx_fallback(page_sx)
|
||||
all_needed: set[str] = set()
|
||||
for name in direct:
|
||||
all_needed.add(name)
|
||||
val = env.get(name)
|
||||
if isinstance(val, Component) and val.deps:
|
||||
all_needed.update(val.deps)
|
||||
else:
|
||||
all_needed.update(_transitive_deps_fallback(name, env))
|
||||
return all_needed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — dispatches to bootstrapped or fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def transitive_deps(name: str, env: dict[str, Any]) -> set[str]:
|
||||
"""Compute transitive component dependencies for *name*.
|
||||
|
||||
Returns the set of all component names (with ~ prefix) that
|
||||
*name* can transitively render, NOT including *name* itself.
|
||||
"""
|
||||
if _use_ref():
|
||||
from .ref.sx_ref import transitive_deps as _ref_td
|
||||
return set(_ref_td(name, env))
|
||||
return _transitive_deps_fallback(name, env)
|
||||
|
||||
|
||||
def compute_all_deps(env: dict[str, Any]) -> None:
|
||||
"""Compute and cache deps for all Component entries in *env*."""
|
||||
if _use_ref():
|
||||
from .ref.sx_ref import compute_all_deps as _ref_cad
|
||||
_ref_cad(env)
|
||||
return
|
||||
_compute_all_deps_fallback(env)
|
||||
|
||||
|
||||
def scan_components_from_sx(source: str) -> set[str]:
|
||||
"""Extract component names referenced in SX source text.
|
||||
|
||||
Returns names with ~ prefix, e.g. {"~card", "~nav-link"}.
|
||||
"""
|
||||
if _use_ref():
|
||||
from .ref.sx_ref import scan_components_from_source as _ref_sc
|
||||
return set(_ref_sc(source))
|
||||
return _scan_components_from_sx_fallback(source)
|
||||
|
||||
|
||||
def components_needed(page_sx: str, env: dict[str, Any]) -> set[str]:
|
||||
"""Compute the full set of component names needed for a page.
|
||||
|
||||
Returns names with ~ prefix.
|
||||
"""
|
||||
if _use_ref():
|
||||
from .ref.sx_ref import components_needed as _ref_cn
|
||||
return set(_ref_cn(page_sx, env))
|
||||
return _components_needed_fallback(page_sx, env)
|
||||
|
||||
|
||||
def compute_all_io_refs(env: dict[str, Any], io_names: set[str]) -> None:
|
||||
"""Compute and cache transitive IO refs for all Component entries in *env*."""
|
||||
if _use_ref():
|
||||
from .ref.sx_ref import compute_all_io_refs as _ref_cio
|
||||
_ref_cio(env, list(io_names))
|
||||
return
|
||||
_compute_all_io_refs_fallback(env, io_names)
|
||||
|
||||
|
||||
def get_all_io_names() -> set[str]:
|
||||
"""Build the complete set of IO primitive names from all boundary tiers.
|
||||
|
||||
Includes: core IO (primitives_io.py handlers), plus all page helper names
|
||||
from every service boundary.
|
||||
"""
|
||||
from .primitives_io import IO_PRIMITIVES
|
||||
from .boundary import declared_helpers
|
||||
|
||||
names = set(IO_PRIMITIVES)
|
||||
for _svc, helper_names in declared_helpers().items():
|
||||
names.update(helper_names)
|
||||
return names
|
||||
@@ -456,34 +456,39 @@ def sx_call(component_name: str, **kwargs: Any) -> str:
|
||||
|
||||
|
||||
|
||||
def components_for_request() -> str:
|
||||
def components_for_request(source: str = "") -> str:
|
||||
"""Return defcomp/defmacro source for definitions the client doesn't have yet.
|
||||
|
||||
Reads the ``SX-Components`` header (comma-separated component names
|
||||
like ``~card,~nav-item``) and returns only the definitions the client
|
||||
is missing. If the header is absent, returns all defs.
|
||||
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.
|
||||
"""
|
||||
from quart import request
|
||||
from .jinja_bridge import client_components_tag, _COMPONENT_ENV
|
||||
from .jinja_bridge import _COMPONENT_ENV
|
||||
from .deps import components_needed
|
||||
from .types import Component, Macro
|
||||
from .parser import serialize
|
||||
|
||||
loaded_raw = request.headers.get("SX-Components", "")
|
||||
if not loaded_raw:
|
||||
# Client has nothing — send all
|
||||
tag = client_components_tag()
|
||||
if not tag:
|
||||
return ""
|
||||
start = tag.find(">") + 1
|
||||
end = tag.rfind("</script>")
|
||||
return tag[start:end] if start > 0 and end > start else ""
|
||||
# Determine which components the page needs
|
||||
if source:
|
||||
needed = components_needed(source, _COMPONENT_ENV)
|
||||
else:
|
||||
needed = None # all
|
||||
|
||||
loaded_raw = request.headers.get("SX-Components", "")
|
||||
loaded = set(loaded_raw.split(",")) if loaded_raw else set()
|
||||
|
||||
loaded = set(loaded_raw.split(","))
|
||||
parts = []
|
||||
for key, val in _COMPONENT_ENV.items():
|
||||
if isinstance(val, Component):
|
||||
comp_name = f"~{val.name}"
|
||||
# Skip if not needed for this page
|
||||
if needed is not None and comp_name not in needed and key not in needed:
|
||||
continue
|
||||
# Skip components the client already has
|
||||
if f"~{val.name}" in loaded or val.name in loaded:
|
||||
if comp_name in loaded or val.name in loaded:
|
||||
continue
|
||||
# Reconstruct defcomp source
|
||||
param_strs = ["&key"] + list(val.params)
|
||||
@@ -530,7 +535,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()
|
||||
comp_defs = components_for_request(source)
|
||||
if comp_defs:
|
||||
body = (f'<script type="text/sx" data-components>'
|
||||
f'{comp_defs}</script>\n{body}')
|
||||
@@ -626,6 +631,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>
|
||||
@@ -633,6 +639,66 @@ 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.
|
||||
"""
|
||||
from .pages import get_all_pages
|
||||
from .parser import serialize as sx_serialize
|
||||
|
||||
pages = get_all_pages(service)
|
||||
if not pages:
|
||||
return ""
|
||||
|
||||
entries = []
|
||||
for page_def in pages.values():
|
||||
content_src = ""
|
||||
if page_def.content_expr is not None:
|
||||
try:
|
||||
content_src = sx_serialize(page_def.content_expr)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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"
|
||||
|
||||
# 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
|
||||
+ " :content " + _sx_literal(content_src)
|
||||
+ " :closure " + closure_sx + "}"
|
||||
)
|
||||
entries.append(entry)
|
||||
|
||||
return "\n".join(entries)
|
||||
|
||||
|
||||
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.
|
||||
@@ -641,11 +707,11 @@ def sx_page(ctx: dict, page_sx: str, *,
|
||||
renders everything client-side. CSS rules are scanned from the sx
|
||||
source and component defs, then injected as a <style> block.
|
||||
"""
|
||||
from .jinja_bridge import client_components_tag, _COMPONENT_ENV, get_component_hash
|
||||
from .css_registry import scan_classes_from_sx, lookup_rules, get_preamble, registry_loaded, store_css_hash
|
||||
from .types import Component
|
||||
from .jinja_bridge import components_for_page, css_classes_for_page
|
||||
from .css_registry import lookup_rules, get_preamble, registry_loaded, store_css_hash
|
||||
|
||||
component_hash = get_component_hash()
|
||||
# Per-page component bundle: only definitions this page needs
|
||||
component_defs, component_hash = components_for_page(page_sx)
|
||||
|
||||
# Check if client already has this version cached (via cookie)
|
||||
# In dev mode, always send full source so edits are visible immediately
|
||||
@@ -653,28 +719,13 @@ def sx_page(ctx: dict, page_sx: str, *,
|
||||
if not _is_dev_mode() and client_hash and client_hash == component_hash:
|
||||
# Client has current components cached — send empty source
|
||||
component_defs = ""
|
||||
else:
|
||||
components_tag = client_components_tag()
|
||||
# Extract just the inner source from the <script> tag
|
||||
component_defs = ""
|
||||
if components_tag:
|
||||
start = components_tag.find(">") + 1
|
||||
end = components_tag.rfind("</script>")
|
||||
if start > 0 and end > start:
|
||||
component_defs = components_tag[start:end]
|
||||
|
||||
# Scan for CSS classes — use pre-computed sets for components, scan page sx at request time
|
||||
# Scan for CSS classes — only from components this page uses + page source
|
||||
sx_css = ""
|
||||
sx_css_classes = ""
|
||||
sx_css_hash = ""
|
||||
if registry_loaded():
|
||||
# Union pre-computed component classes instead of re-scanning source
|
||||
classes: set[str] = set()
|
||||
for val in _COMPONENT_ENV.values():
|
||||
if isinstance(val, Component) and val.css_classes:
|
||||
classes.update(val.css_classes)
|
||||
# Page sx is unique per request — scan it
|
||||
classes.update(scan_classes_from_sx(page_sx))
|
||||
classes = css_classes_for_page(page_sx)
|
||||
# Always include body classes
|
||||
classes.update(["bg-stone-50", "text-stone-900"])
|
||||
rules = lookup_rules(classes)
|
||||
@@ -702,6 +753,14 @@ def sx_page(ctx: dict, page_sx: str, *,
|
||||
else:
|
||||
styles_json = _build_style_dict_json()
|
||||
|
||||
# Page registry for client-side routing
|
||||
pages_sx = ""
|
||||
try:
|
||||
from quart import current_app
|
||||
pages_sx = _build_pages_sx(current_app.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _SX_PAGE_TEMPLATE.format(
|
||||
title=_html_escape(title),
|
||||
asset_url=asset_url,
|
||||
@@ -711,6 +770,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,
|
||||
|
||||
@@ -86,10 +86,15 @@ def _compute_component_hash() -> None:
|
||||
|
||||
|
||||
def load_sx_dir(directory: str) -> None:
|
||||
"""Load all .sx files from a directory and register components."""
|
||||
"""Load all .sx files from a directory and register components.
|
||||
|
||||
Skips boundary.sx — those are parsed separately by the boundary validator.
|
||||
"""
|
||||
for filepath in sorted(
|
||||
glob.glob(os.path.join(directory, "*.sx"))
|
||||
):
|
||||
if os.path.basename(filepath) == "boundary.sx":
|
||||
continue
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
register_components(f.read())
|
||||
|
||||
@@ -198,6 +203,11 @@ def register_components(sx_source: str) -> None:
|
||||
all_classes = scan_classes_from_sx(sx_source)
|
||||
val.css_classes = set(all_classes)
|
||||
|
||||
# Recompute transitive deps for all components (cheap — just AST walking)
|
||||
from .deps import compute_all_deps, compute_all_io_refs, get_all_io_names
|
||||
compute_all_deps(_COMPONENT_ENV)
|
||||
compute_all_io_refs(_COMPONENT_ENV, get_all_io_names())
|
||||
|
||||
_compute_component_hash()
|
||||
|
||||
|
||||
@@ -322,6 +332,71 @@ 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]:
|
||||
"""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.
|
||||
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)
|
||||
if not needed:
|
||||
return "", ""
|
||||
|
||||
# Also include macros — they're needed for client-side expansion
|
||||
parts = []
|
||||
for key, val in _COMPONENT_ENV.items():
|
||||
if isinstance(val, Component):
|
||||
if f"~{val.name}" in needed or key in needed:
|
||||
param_strs = ["&key"] + list(val.params)
|
||||
if val.has_children:
|
||||
param_strs.extend(["&rest", "children"])
|
||||
params_sx = "(" + " ".join(param_strs) + ")"
|
||||
body_sx = serialize(val.body, pretty=True)
|
||||
parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
|
||||
elif isinstance(val, Macro):
|
||||
# Include macros that are referenced in needed components' bodies
|
||||
# For now, include all macros (they're small and often shared)
|
||||
param_strs = list(val.params)
|
||||
if val.rest_param:
|
||||
param_strs.extend(["&rest", val.rest_param])
|
||||
params_sx = "(" + " ".join(param_strs) + ")"
|
||||
body_sx = serialize(val.body, pretty=True)
|
||||
parts.append(f"(defmacro {val.name} {params_sx} {body_sx})")
|
||||
|
||||
if not parts:
|
||||
return "", ""
|
||||
|
||||
source = "\n".join(parts)
|
||||
digest = hashlib.sha256(source.encode()).hexdigest()[:12]
|
||||
return source, digest
|
||||
|
||||
|
||||
def css_classes_for_page(page_sx: str) -> 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 .deps import components_needed
|
||||
from .css_registry import scan_classes_from_sx
|
||||
|
||||
needed = components_needed(page_sx, _COMPONENT_ENV)
|
||||
classes: set[str] = set()
|
||||
|
||||
for key, val in _COMPONENT_ENV.items():
|
||||
if isinstance(val, Component):
|
||||
if (f"~{val.name}" in needed or key in needed) and val.css_classes:
|
||||
classes.update(val.css_classes)
|
||||
|
||||
# Page sx is unique per request — scan it
|
||||
classes.update(scan_classes_from_sx(page_sx))
|
||||
return classes
|
||||
|
||||
|
||||
def sx_css_all() -> str:
|
||||
"""Return all CSS rules (preamble + utilities) for Jinja fallback pages."""
|
||||
from .css_registry import get_all_css
|
||||
|
||||
@@ -299,6 +299,10 @@ def prim_slice(coll: Any, start: int, end: Any = None) -> Any:
|
||||
return coll[start:]
|
||||
return coll[start:int(end)]
|
||||
|
||||
@register_primitive("index-of")
|
||||
def prim_index_of(s: str, needle: str, start: int = 0) -> int:
|
||||
return str(s).find(needle, int(start))
|
||||
|
||||
@register_primitive("starts-with?")
|
||||
def prim_starts_with(s, prefix: str) -> bool:
|
||||
if not isinstance(s, str):
|
||||
|
||||
@@ -295,6 +295,33 @@
|
||||
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)))
|
||||
(for-each
|
||||
(fn (s)
|
||||
(when (not (is-processed? s "pages"))
|
||||
(mark-processed! s "pages")
|
||||
(let ((text (dom-text-content s)))
|
||||
(when (and text (not (empty? (trim text))))
|
||||
(let ((pages (parse text)))
|
||||
(for-each
|
||||
(fn (page)
|
||||
(append! _page-routes
|
||||
(merge page
|
||||
{"parsed" (parse-route-pattern (get page "path"))})))
|
||||
pages))))))
|
||||
scripts))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Full boot sequence
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -305,12 +332,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
|
||||
(init-css-tracking)
|
||||
(init-style-dict)
|
||||
(process-sx-scripts nil)
|
||||
(process-page-scripts)
|
||||
(sx-hydrate-elements nil)
|
||||
(process-elements nil))))
|
||||
|
||||
@@ -354,6 +383,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
|
||||
|
||||
@@ -50,6 +50,8 @@ class JSEmitter:
|
||||
return self._emit_symbol(expr.name)
|
||||
if isinstance(expr, Keyword):
|
||||
return self._js_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)
|
||||
@@ -376,6 +378,11 @@ class JSEmitter:
|
||||
"event-source-listen": "eventSourceListen",
|
||||
"bind-boost-link": "bindBoostLink",
|
||||
"bind-boost-form": "bindBoostForm",
|
||||
"bind-client-route-link": "bindClientRouteLink",
|
||||
"bind-client-route-click": "bindClientRouteClick",
|
||||
"try-client-route": "tryClientRoute",
|
||||
"try-eval-content": "tryEvalContent",
|
||||
"url-pathname": "urlPathname",
|
||||
"bind-inline-handler": "bindInlineHandler",
|
||||
"bind-preload": "bindPreload",
|
||||
"mark-processed!": "markProcessed",
|
||||
@@ -490,6 +497,40 @@ class JSEmitter:
|
||||
"log-info": "logInfo",
|
||||
"log-parse-error": "logParseError",
|
||||
"parse-and-load-style-dict": "parseAndLoadStyleDict",
|
||||
"_page-routes": "_pageRoutes",
|
||||
"process-page-scripts": "processPageScripts",
|
||||
"query-page-scripts": "queryPageScripts",
|
||||
# deps.sx
|
||||
"scan-refs": "scanRefs",
|
||||
"scan-refs-walk": "scanRefsWalk",
|
||||
"transitive-deps": "transitiveDeps",
|
||||
"compute-all-deps": "computeAllDeps",
|
||||
"scan-components-from-source": "scanComponentsFromSource",
|
||||
"components-needed": "componentsNeeded",
|
||||
"page-component-bundle": "pageComponentBundle",
|
||||
"page-css-classes": "pageCssClasses",
|
||||
"component-deps": "componentDeps",
|
||||
"component-set-deps!": "componentSetDeps",
|
||||
"component-css-classes": "componentCssClasses",
|
||||
"component-io-refs": "componentIoRefs",
|
||||
"component-set-io-refs!": "componentSetIoRefs",
|
||||
"env-components": "envComponents",
|
||||
"regex-find-all": "regexFindAll",
|
||||
"scan-css-classes": "scanCssClasses",
|
||||
# deps.sx IO detection
|
||||
"scan-io-refs": "scanIoRefs",
|
||||
"scan-io-refs-walk": "scanIoRefsWalk",
|
||||
"transitive-io-refs": "transitiveIoRefs",
|
||||
"compute-all-io-refs": "computeAllIoRefs",
|
||||
"component-pure?": "componentPure_p",
|
||||
# router.sx
|
||||
"split-path-segments": "splitPathSegments",
|
||||
"make-route-segment": "makeRouteSegment",
|
||||
"parse-route-pattern": "parseRoutePattern",
|
||||
"match-route-segments": "matchRouteSegments",
|
||||
"match-route": "matchRoute",
|
||||
"find-matching-route": "findMatchingRoute",
|
||||
"for-each-indexed": "forEachIndexed",
|
||||
}
|
||||
if name in RENAMES:
|
||||
return RENAMES[name]
|
||||
@@ -700,6 +741,13 @@ class JSEmitter:
|
||||
parts = [self.emit(e) for e in exprs]
|
||||
return "(" + ", ".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._js_string(key)}: {self.emit(val)}")
|
||||
return "{" + ", ".join(parts) + "}"
|
||||
|
||||
def _emit_dict_literal(self, expr) -> str:
|
||||
pairs = expr[1:]
|
||||
parts = []
|
||||
@@ -1001,6 +1049,11 @@ ADAPTER_DEPS = {
|
||||
"parser": [],
|
||||
}
|
||||
|
||||
SPEC_MODULES = {
|
||||
"deps": ("deps.sx", "deps (component dependency analysis)"),
|
||||
"router": ("router.sx", "router (client-side route matching)"),
|
||||
}
|
||||
|
||||
|
||||
EXTENSION_NAMES = {"continuations"}
|
||||
|
||||
@@ -1091,6 +1144,7 @@ def compile_ref_to_js(
|
||||
adapters: list[str] | None = None,
|
||||
modules: list[str] | None = None,
|
||||
extensions: list[str] | None = None,
|
||||
spec_modules: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Read reference .sx files and emit JavaScript.
|
||||
|
||||
@@ -1104,6 +1158,9 @@ def compile_ref_to_js(
|
||||
extensions: List of optional extensions to include.
|
||||
Valid names: continuations.
|
||||
None = no extensions.
|
||||
spec_modules: List of spec module names to include.
|
||||
Valid names: deps.
|
||||
None = no spec modules.
|
||||
"""
|
||||
ref_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
emitter = JSEmitter()
|
||||
@@ -1131,7 +1188,17 @@ def compile_ref_to_js(
|
||||
for dep in ADAPTER_DEPS.get(a, []):
|
||||
adapter_set.add(dep)
|
||||
|
||||
# Core files always included, then selected adapters
|
||||
# Resolve spec modules
|
||||
spec_mod_set = set()
|
||||
if spec_modules:
|
||||
for sm in spec_modules:
|
||||
if sm not in SPEC_MODULES:
|
||||
raise ValueError(f"Unknown spec module: {sm!r}. Valid: {', '.join(SPEC_MODULES)}")
|
||||
spec_mod_set.add(sm)
|
||||
has_deps = "deps" in spec_mod_set
|
||||
has_router = "router" in spec_mod_set
|
||||
|
||||
# Core files always included, then selected adapters, then spec modules
|
||||
sx_files = [
|
||||
("eval.sx", "eval"),
|
||||
("render.sx", "render (core)"),
|
||||
@@ -1139,6 +1206,8 @@ def compile_ref_to_js(
|
||||
for name in ("parser", "html", "sx", "dom", "engine", "orchestration", "cssx", "boot"):
|
||||
if name in adapter_set:
|
||||
sx_files.append(ADAPTER_FILES[name])
|
||||
for name in sorted(spec_mod_set):
|
||||
sx_files.append(SPEC_MODULES[name])
|
||||
|
||||
all_sections = []
|
||||
for filename, label in sx_files:
|
||||
@@ -1190,6 +1259,9 @@ def compile_ref_to_js(
|
||||
parts.append(_assemble_primitives_js(prim_modules))
|
||||
parts.append(PLATFORM_JS_POST)
|
||||
|
||||
if has_deps:
|
||||
parts.append(PLATFORM_DEPS_JS)
|
||||
|
||||
# Parser platform must come before compiled parser.sx
|
||||
if has_parser:
|
||||
parts.append(adapter_platform["parser"])
|
||||
@@ -1211,7 +1283,7 @@ def compile_ref_to_js(
|
||||
parts.append(fixups_js(has_html, has_sx, has_dom))
|
||||
if has_continuations:
|
||||
parts.append(CONTINUATIONS_JS)
|
||||
parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label))
|
||||
parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label, has_deps, has_router))
|
||||
parts.append(EPILOGUE)
|
||||
return "\n".join(parts)
|
||||
|
||||
@@ -1386,6 +1458,7 @@ PRIMITIVES_JS_MODULES: dict[str, str] = {
|
||||
PRIMITIVES["split"] = function(s, sep) { return String(s).split(sep || " "); };
|
||||
PRIMITIVES["join"] = function(sep, coll) { return coll.join(sep); };
|
||||
PRIMITIVES["replace"] = function(s, old, nw) { return s.split(old).join(nw); };
|
||||
PRIMITIVES["index-of"] = function(s, needle, from) { return String(s).indexOf(needle, from || 0); };
|
||||
PRIMITIVES["starts-with?"] = function(s, p) { return String(s).indexOf(p) === 0; };
|
||||
PRIMITIVES["ends-with?"] = function(s, p) { var str = String(s); return str.indexOf(p, str.length - p.length) !== -1; };
|
||||
PRIMITIVES["slice"] = function(c, a, b) { return b !== undefined ? c.slice(a, b) : c.slice(a); };
|
||||
@@ -1789,6 +1862,93 @@ PLATFORM_JS_POST = '''
|
||||
return NIL;
|
||||
}'''
|
||||
|
||||
PLATFORM_DEPS_JS = '''
|
||||
// =========================================================================
|
||||
// Platform: deps module — component dependency analysis
|
||||
// =========================================================================
|
||||
|
||||
function componentDeps(c) {
|
||||
return c.deps ? c.deps.slice() : [];
|
||||
}
|
||||
|
||||
function componentSetDeps(c, deps) {
|
||||
c.deps = deps;
|
||||
}
|
||||
|
||||
function componentCssClasses(c) {
|
||||
return c.cssClasses ? c.cssClasses.slice() : [];
|
||||
}
|
||||
|
||||
function envComponents(env) {
|
||||
var names = [];
|
||||
for (var k in env) {
|
||||
var v = env[k];
|
||||
if (v && (v._component || v._macro)) names.push(k);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function regexFindAll(pattern, source) {
|
||||
var re = new RegExp(pattern, "g");
|
||||
var results = [];
|
||||
var m;
|
||||
while ((m = re.exec(source)) !== null) {
|
||||
if (m[1] !== undefined) results.push(m[1]);
|
||||
else results.push(m[0]);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function scanCssClasses(source) {
|
||||
var classes = {};
|
||||
var result = [];
|
||||
var m;
|
||||
var re1 = /:class\\s+"([^"]*)"/g;
|
||||
while ((m = re1.exec(source)) !== null) {
|
||||
var parts = m[1].split(/\\s+/);
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (parts[i] && !classes[parts[i]]) {
|
||||
classes[parts[i]] = true;
|
||||
result.push(parts[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
var re2 = /:class\\s+\\(str\\s+((?:"[^"]*"\\s*)+)\\)/g;
|
||||
while ((m = re2.exec(source)) !== null) {
|
||||
var re3 = /"([^"]*)"/g;
|
||||
var m2;
|
||||
while ((m2 = re3.exec(m[1])) !== null) {
|
||||
var parts2 = m2[1].split(/\\s+/);
|
||||
for (var j = 0; j < parts2.length; j++) {
|
||||
if (parts2[j] && !classes[parts2[j]]) {
|
||||
classes[parts2[j]] = true;
|
||||
result.push(parts2[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var re4 = /;;\\s*@css\\s+(.+)/g;
|
||||
while ((m = re4.exec(source)) !== null) {
|
||||
var parts3 = m[1].split(/\\s+/);
|
||||
for (var k = 0; k < parts3.length; k++) {
|
||||
if (parts3[k] && !classes[parts3[k]]) {
|
||||
classes[parts3[k]] = true;
|
||||
result.push(parts3[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function componentIoRefs(c) {
|
||||
return c.ioRefs ? c.ioRefs.slice() : [];
|
||||
}
|
||||
|
||||
function componentSetIoRefs(c, refs) {
|
||||
c.ioRefs = refs;
|
||||
}
|
||||
'''
|
||||
|
||||
PLATFORM_PARSER_JS = r"""
|
||||
// =========================================================================
|
||||
// Platform interface — Parser
|
||||
@@ -2456,6 +2616,50 @@ PLATFORM_ORCHESTRATION_JS = """
|
||||
});
|
||||
}
|
||||
|
||||
// --- Client-side route bindings ---
|
||||
|
||||
function bindClientRouteClick(link, href, fallbackFn) {
|
||||
link.addEventListener("click", function(e) {
|
||||
e.preventDefault();
|
||||
var pathname = urlPathname(href);
|
||||
if (tryClientRoute(pathname)) {
|
||||
try { history.pushState({ sxUrl: href, scrollY: window.scrollY }, "", href); } catch (err) {}
|
||||
if (typeof window !== "undefined") window.scrollTo(0, 0);
|
||||
} else {
|
||||
logInfo("sx:route server " + pathname);
|
||||
executeRequest(link, { method: "GET", url: href }).then(function() {
|
||||
try { history.pushState({ sxUrl: href, scrollY: window.scrollY }, "", href); } catch (err) {}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function tryEvalContent(source, env) {
|
||||
try {
|
||||
var merged = merge(componentEnv);
|
||||
if (env && !isNil(env)) {
|
||||
var ks = Object.keys(env);
|
||||
for (var i = 0; i < ks.length; i++) merged[ks[i]] = env[ks[i]];
|
||||
}
|
||||
return sxRenderWithEnv(source, merged);
|
||||
} catch (e) {
|
||||
return NIL;
|
||||
}
|
||||
}
|
||||
|
||||
function urlPathname(href) {
|
||||
try {
|
||||
return new URL(href, location.href).pathname;
|
||||
} catch (e) {
|
||||
// Fallback: strip query/hash
|
||||
var idx = href.indexOf("?");
|
||||
if (idx >= 0) href = href.substring(0, idx);
|
||||
idx = href.indexOf("#");
|
||||
if (idx >= 0) href = href.substring(0, idx);
|
||||
return href;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Inline handlers ---
|
||||
|
||||
function bindInlineHandler(el, eventName, body) {
|
||||
@@ -2728,6 +2932,12 @@ PLATFORM_BOOT_JS = """
|
||||
document.querySelectorAll('script[type="text/sx-styles"]'));
|
||||
}
|
||||
|
||||
function queryPageScripts() {
|
||||
if (!_hasDom) return [];
|
||||
return Array.prototype.slice.call(
|
||||
document.querySelectorAll('script[type="text/sx-pages"]'));
|
||||
}
|
||||
|
||||
// --- localStorage ---
|
||||
|
||||
function localStorageGet(key) {
|
||||
@@ -2835,7 +3045,7 @@ def fixups_js(has_html, has_sx, has_dom):
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label):
|
||||
def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label, has_deps=False, has_router=False):
|
||||
# Parser: use compiled sxParse from parser.sx, or inline a minimal fallback
|
||||
if has_parser:
|
||||
parser = '''
|
||||
@@ -2957,6 +3167,22 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has
|
||||
api_lines.append(' init: typeof bootInit === "function" ? bootInit : null,')
|
||||
elif has_orch:
|
||||
api_lines.append(' init: typeof engineInit === "function" ? engineInit : null,')
|
||||
if has_deps:
|
||||
api_lines.append(' scanRefs: scanRefs,')
|
||||
api_lines.append(' transitiveDeps: transitiveDeps,')
|
||||
api_lines.append(' computeAllDeps: computeAllDeps,')
|
||||
api_lines.append(' componentsNeeded: componentsNeeded,')
|
||||
api_lines.append(' pageComponentBundle: pageComponentBundle,')
|
||||
api_lines.append(' pageCssClasses: pageCssClasses,')
|
||||
api_lines.append(' scanIoRefs: scanIoRefs,')
|
||||
api_lines.append(' transitiveIoRefs: transitiveIoRefs,')
|
||||
api_lines.append(' computeAllIoRefs: computeAllIoRefs,')
|
||||
api_lines.append(' componentPure_p: componentPure_p,')
|
||||
if has_router:
|
||||
api_lines.append(' splitPathSegments: splitPathSegments,')
|
||||
api_lines.append(' parseRoutePattern: parseRoutePattern,')
|
||||
api_lines.append(' matchRoute: matchRoute,')
|
||||
api_lines.append(' findMatchingRoute: findMatchingRoute,')
|
||||
|
||||
api_lines.append(f' _version: "{version}"')
|
||||
api_lines.append(' };')
|
||||
@@ -3014,6 +3240,8 @@ if __name__ == "__main__":
|
||||
help="Comma-separated primitive modules (core.* always included). Default: all")
|
||||
p.add_argument("--extensions",
|
||||
help="Comma-separated extensions (continuations). Default: none.")
|
||||
p.add_argument("--spec-modules",
|
||||
help="Comma-separated spec modules (deps). Default: none.")
|
||||
p.add_argument("--output", "-o",
|
||||
help="Output file (default: stdout)")
|
||||
args = p.parse_args()
|
||||
@@ -3021,7 +3249,8 @@ if __name__ == "__main__":
|
||||
adapters = args.adapters.split(",") if args.adapters else None
|
||||
modules = args.modules.split(",") if args.modules else None
|
||||
extensions = args.extensions.split(",") if args.extensions else None
|
||||
js = compile_ref_to_js(adapters, modules, extensions)
|
||||
spec_modules = args.spec_modules.split(",") if args.spec_modules else None
|
||||
js = compile_ref_to_js(adapters, modules, extensions, spec_modules)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "w") as f:
|
||||
|
||||
@@ -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)
|
||||
@@ -235,6 +237,36 @@ class PyEmitter:
|
||||
"map-dict": "map_dict",
|
||||
"eval-cond": "eval_cond",
|
||||
"process-bindings": "process_bindings",
|
||||
# deps.sx
|
||||
"scan-refs": "scan_refs",
|
||||
"scan-refs-walk": "scan_refs_walk",
|
||||
"transitive-deps": "transitive_deps",
|
||||
"compute-all-deps": "compute_all_deps",
|
||||
"scan-components-from-source": "scan_components_from_source",
|
||||
"components-needed": "components_needed",
|
||||
"page-component-bundle": "page_component_bundle",
|
||||
"page-css-classes": "page_css_classes",
|
||||
"component-deps": "component_deps",
|
||||
"component-set-deps!": "component_set_deps",
|
||||
"component-css-classes": "component_css_classes",
|
||||
"component-io-refs": "component_io_refs",
|
||||
"component-set-io-refs!": "component_set_io_refs",
|
||||
"env-components": "env_components",
|
||||
"regex-find-all": "regex_find_all",
|
||||
"scan-css-classes": "scan_css_classes",
|
||||
# deps.sx IO detection
|
||||
"scan-io-refs": "scan_io_refs",
|
||||
"scan-io-refs-walk": "scan_io_refs_walk",
|
||||
"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]
|
||||
@@ -368,6 +400,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]
|
||||
@@ -376,7 +411,11 @@ class PyEmitter:
|
||||
# Build from inside out
|
||||
result = body_str
|
||||
for name, val in reversed(assignments):
|
||||
result = f"(lambda {name}: {result})({val})"
|
||||
if name in cell_vars:
|
||||
# Cell var: initialize in _cells dict, not as lambda param
|
||||
result = f"_sx_begin(_sx_cell_set(_cells, {self._py_string(name)}, {val}), {result})"
|
||||
else:
|
||||
result = f"(lambda {name}: {result})({val})"
|
||||
return result
|
||||
|
||||
def _emit_if(self, expr) -> str:
|
||||
@@ -489,6 +528,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 = []
|
||||
@@ -803,6 +849,12 @@ ADAPTER_FILES = {
|
||||
}
|
||||
|
||||
|
||||
SPEC_MODULES = {
|
||||
"deps": ("deps.sx", "deps (component dependency analysis)"),
|
||||
"router": ("router.sx", "router (client-side route matching)"),
|
||||
}
|
||||
|
||||
|
||||
EXTENSION_NAMES = {"continuations"}
|
||||
|
||||
# Extension-provided special forms (not in eval.sx core)
|
||||
@@ -889,6 +941,7 @@ def compile_ref_to_py(
|
||||
adapters: list[str] | None = None,
|
||||
modules: list[str] | None = None,
|
||||
extensions: list[str] | None = None,
|
||||
spec_modules: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Read reference .sx files and emit Python.
|
||||
|
||||
@@ -902,6 +955,9 @@ def compile_ref_to_py(
|
||||
extensions: List of optional extensions to include.
|
||||
Valid names: continuations.
|
||||
None = no extensions.
|
||||
spec_modules: List of spec module names to include.
|
||||
Valid names: deps.
|
||||
None = no spec modules.
|
||||
"""
|
||||
# Determine which primitive modules to include
|
||||
prim_modules = None # None = all
|
||||
@@ -926,7 +982,16 @@ def compile_ref_to_py(
|
||||
raise ValueError(f"Unknown adapter: {a!r}. Valid: {', '.join(ADAPTER_FILES)}")
|
||||
adapter_set.add(a)
|
||||
|
||||
# Core files always included, then selected adapters
|
||||
# Resolve spec modules
|
||||
spec_mod_set = set()
|
||||
if spec_modules:
|
||||
for sm in spec_modules:
|
||||
if sm not in SPEC_MODULES:
|
||||
raise ValueError(f"Unknown spec module: {sm!r}. Valid: {', '.join(SPEC_MODULES)}")
|
||||
spec_mod_set.add(sm)
|
||||
has_deps = "deps" in spec_mod_set
|
||||
|
||||
# Core files always included, then selected adapters, then spec modules
|
||||
sx_files = [
|
||||
("eval.sx", "eval"),
|
||||
("forms.sx", "forms (server definition forms)"),
|
||||
@@ -935,6 +1000,8 @@ def compile_ref_to_py(
|
||||
for name in ("html", "sx"):
|
||||
if name in adapter_set:
|
||||
sx_files.append(ADAPTER_FILES[name])
|
||||
for name in sorted(spec_mod_set):
|
||||
sx_files.append(SPEC_MODULES[name])
|
||||
|
||||
all_sections = []
|
||||
for filename, label in sx_files:
|
||||
@@ -969,6 +1036,9 @@ def compile_ref_to_py(
|
||||
parts.append(_assemble_primitives_py(prim_modules))
|
||||
parts.append(PRIMITIVES_PY_POST)
|
||||
|
||||
if has_deps:
|
||||
parts.append(PLATFORM_DEPS_PY)
|
||||
|
||||
for label, defines in all_sections:
|
||||
parts.append(f"\n# === Transpiled from {label} ===\n")
|
||||
for name, expr in defines:
|
||||
@@ -979,7 +1049,7 @@ def compile_ref_to_py(
|
||||
parts.append(FIXUPS_PY)
|
||||
if has_continuations:
|
||||
parts.append(CONTINUATIONS_PY)
|
||||
parts.append(public_api_py(has_html, has_sx))
|
||||
parts.append(public_api_py(has_html, has_sx, has_deps))
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@@ -1705,6 +1775,7 @@ PRIMITIVES["trim"] = lambda s: str(s).strip()
|
||||
PRIMITIVES["split"] = lambda s, sep=" ": str(s).split(sep)
|
||||
PRIMITIVES["join"] = lambda sep, coll: sep.join(coll)
|
||||
PRIMITIVES["replace"] = lambda s, old, new: s.replace(old, new)
|
||||
PRIMITIVES["index-of"] = lambda s, needle, start=0: str(s).find(needle, start)
|
||||
PRIMITIVES["starts-with?"] = lambda s, p: str(s).startswith(p)
|
||||
PRIMITIVES["ends-with?"] = lambda s, p: str(s).endswith(p)
|
||||
PRIMITIVES["slice"] = lambda c, a, b=None: c[a:b] if b is not None else c[a:]
|
||||
@@ -1900,8 +1971,63 @@ range = PRIMITIVES["range"]
|
||||
apply = lambda f, args: f(*args)
|
||||
assoc = PRIMITIVES["assoc"]
|
||||
concat = PRIMITIVES["concat"]
|
||||
split = PRIMITIVES["split"]
|
||||
length = PRIMITIVES["len"]
|
||||
merge = PRIMITIVES["merge"]
|
||||
'''
|
||||
|
||||
|
||||
PLATFORM_DEPS_PY = (
|
||||
'\n'
|
||||
'# =========================================================================\n'
|
||||
'# Platform: deps module — component dependency analysis\n'
|
||||
'# =========================================================================\n'
|
||||
'\n'
|
||||
'import re as _re\n'
|
||||
'\n'
|
||||
'def component_deps(c):\n'
|
||||
' """Return cached deps list for a component (may be empty)."""\n'
|
||||
' return list(c.deps) if hasattr(c, "deps") and c.deps else []\n'
|
||||
'\n'
|
||||
'def component_set_deps(c, deps):\n'
|
||||
' """Cache deps on a component."""\n'
|
||||
' c.deps = set(deps) if not isinstance(deps, set) else deps\n'
|
||||
'\n'
|
||||
'def component_css_classes(c):\n'
|
||||
' """Return pre-scanned CSS class list for a component."""\n'
|
||||
' return list(c.css_classes) if hasattr(c, "css_classes") and c.css_classes else []\n'
|
||||
'\n'
|
||||
'def env_components(env):\n'
|
||||
' """Return list of component/macro names in an environment."""\n'
|
||||
' return [k for k, v in env.items()\n'
|
||||
' if isinstance(v, (Component, Macro))]\n'
|
||||
'\n'
|
||||
'def regex_find_all(pattern, source):\n'
|
||||
' """Return list of capture group 1 matches."""\n'
|
||||
' return [m.group(1) for m in _re.finditer(pattern, source)]\n'
|
||||
'\n'
|
||||
'def scan_css_classes(source):\n'
|
||||
' """Extract CSS class strings from SX source."""\n'
|
||||
' classes = set()\n'
|
||||
' for m in _re.finditer(r\':class\\s+"([^"]*)"\', source):\n'
|
||||
' classes.update(m.group(1).split())\n'
|
||||
' for m in _re.finditer(r\':class\\s+\\(str\\s+((?:"[^"]*"\\s*)+)\\)\', source):\n'
|
||||
' for s in _re.findall(r\'"([^"]*)"\', m.group(1)):\n'
|
||||
' classes.update(s.split())\n'
|
||||
' for m in _re.finditer(r\';;\\s*@css\\s+(.+)\', source):\n'
|
||||
' classes.update(m.group(1).split())\n'
|
||||
' return list(classes)\n'
|
||||
'\n'
|
||||
'def component_io_refs(c):\n'
|
||||
' """Return cached IO refs list for a component (may be empty)."""\n'
|
||||
' return list(c.io_refs) if hasattr(c, "io_refs") and c.io_refs else []\n'
|
||||
'\n'
|
||||
'def component_set_io_refs(c, refs):\n'
|
||||
' """Cache IO refs on a component."""\n'
|
||||
' c.io_refs = set(refs) if not isinstance(refs, set) else refs\n'
|
||||
)
|
||||
|
||||
|
||||
FIXUPS_PY = '''
|
||||
# =========================================================================
|
||||
# Fixups -- wire up render adapter dispatch
|
||||
@@ -1995,7 +2121,7 @@ aser_special = _aser_special_with_continuations
|
||||
'''
|
||||
|
||||
|
||||
def public_api_py(has_html: bool, has_sx: bool) -> str:
|
||||
def public_api_py(has_html: bool, has_sx: bool, has_deps: bool = False) -> str:
|
||||
lines = [
|
||||
'',
|
||||
'# =========================================================================',
|
||||
@@ -2058,11 +2184,17 @@ def main():
|
||||
default=None,
|
||||
help="Comma-separated extensions (continuations). Default: none.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--spec-modules",
|
||||
default=None,
|
||||
help="Comma-separated spec modules (deps). Default: none.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
adapters = args.adapters.split(",") if args.adapters else None
|
||||
modules = args.modules.split(",") if args.modules else None
|
||||
extensions = args.extensions.split(",") if args.extensions else None
|
||||
print(compile_ref_to_py(adapters, modules, extensions))
|
||||
spec_modules = args.spec_modules.split(",") if args.spec_modules else None
|
||||
print(compile_ref_to_py(adapters, modules, extensions, spec_modules))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
118
shared/sx/ref/boundary-app.sx
Normal file
118
shared/sx/ref/boundary-app.sx
Normal file
@@ -0,0 +1,118 @@
|
||||
;; ==========================================================================
|
||||
;; boundary-app.sx — Deployment-specific boundary declarations
|
||||
;;
|
||||
;; Layout context I/O primitives for THIS deployment's service architecture.
|
||||
;; These are NOT part of the SX language contract — a different deployment
|
||||
;; would declare different layout contexts here.
|
||||
;;
|
||||
;; The core SX I/O contract lives in boundary.sx.
|
||||
;; Per-service page helpers live in {service}/sx/boundary.sx.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Layout context providers — deployment-specific I/O
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; Shared across all services (root layout)
|
||||
|
||||
(define-io-primitive "root-header-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with root header values (cart-mini, auth-menu, nav-tree, etc.)."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "select-colours"
|
||||
:params ()
|
||||
:returns "string"
|
||||
:async true
|
||||
:doc "Shared select/hover CSS class string."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "account-nav-ctx"
|
||||
:params ()
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Account nav fragments, or nil."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "app-rights"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "User rights dict from g.rights."
|
||||
:context :request)
|
||||
|
||||
;; Blog service layout
|
||||
|
||||
(define-io-primitive "post-header-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with post-level header values."
|
||||
:context :request)
|
||||
|
||||
;; Cart service layout
|
||||
|
||||
(define-io-primitive "cart-page-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with cart page header values."
|
||||
:context :request)
|
||||
|
||||
;; Events service layouts
|
||||
|
||||
(define-io-primitive "events-calendar-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with events calendar header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "events-day-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with events day header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "events-entry-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with events entry header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "events-slot-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with events slot header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "events-ticket-type-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with ticket type header values."
|
||||
:context :request)
|
||||
|
||||
;; Market service layout
|
||||
|
||||
(define-io-primitive "market-header-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with market header data."
|
||||
:context :request)
|
||||
|
||||
;; Federation service layout
|
||||
|
||||
(define-io-primitive "federation-actor-ctx"
|
||||
:params ()
|
||||
:returns "dict?"
|
||||
:async true
|
||||
:doc "Serialized ActivityPub actor dict or nil."
|
||||
:context :request)
|
||||
@@ -1,12 +1,12 @@
|
||||
;; ==========================================================================
|
||||
;; boundary.sx — SX boundary contract
|
||||
;; boundary.sx — SX language boundary contract
|
||||
;;
|
||||
;; Declares everything allowed to cross the host-SX boundary:
|
||||
;; I/O primitives (Tier 2) and page helpers (Tier 3).
|
||||
;; Declares the core I/O primitives that any SX host must provide.
|
||||
;; This is the LANGUAGE contract — not deployment-specific.
|
||||
;;
|
||||
;; Pure primitives (Tier 1) are declared in primitives.sx.
|
||||
;; This file declares what primitives.sx does NOT cover:
|
||||
;; async/side-effectful host functions that need request context.
|
||||
;; Deployment-specific I/O (layout contexts) lives in boundary-app.sx.
|
||||
;; Per-service page helpers live in {service}/sx/boundary.sx.
|
||||
;;
|
||||
;; Format:
|
||||
;; (define-io-primitive "name"
|
||||
@@ -16,13 +16,6 @@
|
||||
;; :doc "description"
|
||||
;; :context :request)
|
||||
;;
|
||||
;; (define-page-helper "name"
|
||||
;; :params (param1 param2)
|
||||
;; :returns "type"
|
||||
;; :service "service-name")
|
||||
;;
|
||||
;; Bootstrappers read this file and emit frozen sets + validation
|
||||
;; functions for the target language.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
@@ -34,9 +27,11 @@
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Tier 2: I/O primitives — async, side-effectful, need host context
|
||||
;; Tier 2: Core I/O primitives — async, side-effectful, need host context
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; Cross-service communication
|
||||
|
||||
(define-io-primitive "frag"
|
||||
:params (service frag-type &key)
|
||||
:returns "string"
|
||||
@@ -58,6 +53,15 @@
|
||||
:doc "Call an action on another service via internal HTTP."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "service"
|
||||
:params (service-or-method &rest args &key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Call a domain service method. Two-arg: (service svc method). One-arg: (service method) uses bound handler service."
|
||||
:context :request)
|
||||
|
||||
;; Request context
|
||||
|
||||
(define-io-primitive "current-user"
|
||||
:params ()
|
||||
:returns "dict?"
|
||||
@@ -72,13 +76,6 @@
|
||||
:doc "True if current request has HX-Request header."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "service"
|
||||
:params (service-or-method &rest args &key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Call a domain service method. Two-arg: (service svc method). One-arg: (service method) uses bound handler service."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-arg"
|
||||
:params (name &rest default)
|
||||
:returns "any"
|
||||
@@ -93,18 +90,11 @@
|
||||
:doc "Current request path."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "nav-tree"
|
||||
:params ()
|
||||
:returns "list"
|
||||
(define-io-primitive "request-view-args"
|
||||
:params (key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Navigation tree as list of node dicts."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "get-children"
|
||||
:params (&key parent-type parent-id)
|
||||
:returns "list"
|
||||
:async true
|
||||
:doc "Fetch child entities for a parent."
|
||||
:doc "Read a URL view argument from the current request."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "g"
|
||||
@@ -128,6 +118,8 @@
|
||||
:doc "Raise HTTP error from SX."
|
||||
:context :request)
|
||||
|
||||
;; Routing
|
||||
|
||||
(define-io-primitive "url-for"
|
||||
:params (endpoint &key)
|
||||
:returns "string"
|
||||
@@ -142,105 +134,23 @@
|
||||
:doc "Service URL prefix for dev/prod routing."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "root-header-ctx"
|
||||
;; Navigation and relations
|
||||
|
||||
(define-io-primitive "nav-tree"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:returns "list"
|
||||
:async true
|
||||
:doc "Dict with root header values (cart-mini, auth-menu, nav-tree, etc.)."
|
||||
:doc "Navigation tree as list of node dicts."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "post-header-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
(define-io-primitive "get-children"
|
||||
:params (&key parent-type parent-id)
|
||||
:returns "list"
|
||||
:async true
|
||||
:doc "Dict with post-level header values."
|
||||
:doc "Fetch child entities for a parent."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "select-colours"
|
||||
:params ()
|
||||
:returns "string"
|
||||
:async true
|
||||
:doc "Shared select/hover CSS class string."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "account-nav-ctx"
|
||||
:params ()
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Account nav fragments, or nil."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "app-rights"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "User rights dict from g.rights."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "federation-actor-ctx"
|
||||
:params ()
|
||||
:returns "dict?"
|
||||
:async true
|
||||
:doc "Serialized ActivityPub actor dict or nil."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-view-args"
|
||||
:params (key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Read a URL view argument from the current request."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "cart-page-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with cart page header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "events-calendar-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with events calendar header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "events-day-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with events day header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "events-entry-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with events entry header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "events-slot-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with events slot header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "events-ticket-type-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with ticket type header values."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "market-header-ctx"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:async true
|
||||
:doc "Dict with market header data."
|
||||
:context :request)
|
||||
|
||||
;; Moved from primitives.py — these need host context (infra/config/Quart)
|
||||
;; Config and host context (sync — no await needed)
|
||||
|
||||
(define-io-primitive "app-url"
|
||||
:params (service &rest path)
|
||||
@@ -278,180 +188,6 @@
|
||||
:context :config)
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Tier 3: Page helpers — service-scoped, registered per app
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; SX docs service
|
||||
(define-page-helper "highlight"
|
||||
:params (code lang)
|
||||
:returns "sx-source"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "primitives-data"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "special-forms-data"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "reference-data"
|
||||
:params (slug)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "attr-detail-data"
|
||||
:params (slug)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "header-detail-data"
|
||||
:params (slug)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "event-detail-data"
|
||||
:params (slug)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "read-spec-file"
|
||||
:params (filename)
|
||||
:returns "string"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "bootstrapper-data"
|
||||
:params (target)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
;; Blog service
|
||||
(define-page-helper "editor-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "editor-page-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-admin-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-data-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-preview-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-entries-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-settings-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
(define-page-helper "post-edit-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "blog")
|
||||
|
||||
;; Events service
|
||||
(define-page-helper "calendar-admin-data"
|
||||
:params (&key calendar-slug)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "day-admin-data"
|
||||
:params (&key calendar-slug year month day)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "slots-data"
|
||||
:params (&key calendar-slug)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "slot-data"
|
||||
:params (&key calendar-slug slot-id)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "entry-data"
|
||||
:params (&key calendar-slug entry-id)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "entry-admin-data"
|
||||
:params (&key calendar-slug entry-id year month day)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "ticket-types-data"
|
||||
:params (&key calendar-slug entry-id year month day)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "ticket-type-data"
|
||||
:params (&key calendar-slug entry-id ticket-type-id year month day)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "tickets-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "ticket-detail-data"
|
||||
:params (&key code)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "ticket-admin-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
(define-page-helper "markets-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "events")
|
||||
|
||||
;; Market service
|
||||
(define-page-helper "all-markets-data"
|
||||
:params (&key)
|
||||
:returns "dict"
|
||||
:service "market")
|
||||
|
||||
(define-page-helper "page-markets-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "market")
|
||||
|
||||
(define-page-helper "page-admin-data"
|
||||
:params (&key slug)
|
||||
:returns "dict"
|
||||
:service "market")
|
||||
|
||||
(define-page-helper "market-home-data"
|
||||
:params (&key page-slug market-slug)
|
||||
:returns "dict"
|
||||
:service "market")
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Boundary types — what's allowed to cross the host-SX boundary
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
"""
|
||||
Parse boundary.sx and primitives.sx to extract declared names.
|
||||
Parse boundary declarations from multiple sources.
|
||||
|
||||
Three tiers of boundary files:
|
||||
1. shared/sx/ref/boundary.sx — core SX language I/O contract
|
||||
2. shared/sx/ref/boundary-app.sx — deployment-specific layout I/O
|
||||
3. {service}/sx/boundary.sx — per-service page helpers
|
||||
|
||||
Shared by both bootstrap_py.py and bootstrap_js.py, and used at runtime
|
||||
by the validation module.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("sx.boundary_parser")
|
||||
|
||||
# Allow standalone use (from bootstrappers) or in-project imports
|
||||
try:
|
||||
from shared.sx.parser import parse_all
|
||||
@@ -26,12 +35,37 @@ def _ref_dir() -> str:
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _project_root() -> str:
|
||||
"""Return the project root containing service directories.
|
||||
|
||||
Dev: shared/sx/ref -> shared/sx -> shared -> project root
|
||||
Docker: /app/shared/sx/ref -> /app (shared is inside /app)
|
||||
"""
|
||||
ref = _ref_dir()
|
||||
# Go up 3 levels: shared/sx/ref -> project root
|
||||
root = os.path.abspath(os.path.join(ref, "..", "..", ".."))
|
||||
# Verify by checking for a known service directory or shared/
|
||||
if os.path.isdir(os.path.join(root, "shared")):
|
||||
return root
|
||||
# Docker: /app/shared/sx/ref -> /app
|
||||
# shared is INSIDE /app, not a sibling — go up to parent of shared
|
||||
root = os.path.abspath(os.path.join(ref, "..", ".."))
|
||||
if os.path.isdir(os.path.join(root, "sx")): # /app/sx exists in Docker
|
||||
return root
|
||||
return root
|
||||
|
||||
|
||||
def _read_file(filename: str) -> str:
|
||||
filepath = os.path.join(_ref_dir(), filename)
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _read_file_path(filepath: str) -> str:
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _extract_keyword_arg(expr: list, key: str) -> Any:
|
||||
"""Extract :key value from a flat keyword-arg list."""
|
||||
for i, item in enumerate(expr):
|
||||
@@ -40,6 +74,64 @@ def _extract_keyword_arg(expr: list, key: str) -> Any:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_declarations(
|
||||
source: str,
|
||||
) -> tuple[set[str], dict[str, set[str]]]:
|
||||
"""Extract I/O primitive names and page helper names from boundary source.
|
||||
|
||||
Returns (io_names, {service: helper_names}).
|
||||
"""
|
||||
exprs = parse_all(source)
|
||||
io_names: set[str] = set()
|
||||
helpers: dict[str, set[str]] = {}
|
||||
|
||||
for expr in exprs:
|
||||
if not isinstance(expr, list) or not expr:
|
||||
continue
|
||||
head = expr[0]
|
||||
if not isinstance(head, Symbol):
|
||||
continue
|
||||
|
||||
if head.name == "define-io-primitive":
|
||||
name = expr[1]
|
||||
if isinstance(name, str):
|
||||
io_names.add(name)
|
||||
|
||||
elif head.name == "define-page-helper":
|
||||
name = expr[1]
|
||||
service = _extract_keyword_arg(expr, "service")
|
||||
if isinstance(name, str) and isinstance(service, str):
|
||||
helpers.setdefault(service, set()).add(name)
|
||||
|
||||
return io_names, helpers
|
||||
|
||||
|
||||
def _find_service_boundary_files() -> list[str]:
|
||||
"""Find service boundary.sx files.
|
||||
|
||||
Dev: {project}/{service}/sx/boundary.sx (e.g. blog/sx/boundary.sx)
|
||||
Docker: /app/sx/boundary.sx (service's sx/ dir copied directly into /app/)
|
||||
"""
|
||||
root = _project_root()
|
||||
files: list[str] = []
|
||||
|
||||
# Dev layout: {root}/{service}/sx/boundary.sx
|
||||
for f in glob.glob(os.path.join(root, "*/sx/boundary.sx")):
|
||||
if "/shared/" not in f:
|
||||
files.append(f)
|
||||
|
||||
# Docker layout: service's sx/ dir is at {root}/sx/boundary.sx
|
||||
docker_path = os.path.join(root, "sx", "boundary.sx")
|
||||
if os.path.exists(docker_path) and docker_path not in files:
|
||||
files.append(docker_path)
|
||||
|
||||
return files
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_primitives_sx() -> frozenset[str]:
|
||||
"""Parse primitives.sx and return frozenset of declared pure primitive names."""
|
||||
by_module = parse_primitives_by_module()
|
||||
@@ -50,12 +142,7 @@ def parse_primitives_sx() -> frozenset[str]:
|
||||
|
||||
|
||||
def parse_primitives_by_module() -> dict[str, frozenset[str]]:
|
||||
"""Parse primitives.sx and return primitives grouped by module.
|
||||
|
||||
Returns:
|
||||
Dict mapping module name (e.g. "core.arithmetic") to frozenset of
|
||||
primitive names declared under that module.
|
||||
"""
|
||||
"""Parse primitives.sx and return primitives grouped by module."""
|
||||
source = _read_file("primitives.sx")
|
||||
exprs = parse_all(source)
|
||||
modules: dict[str, set[str]] = {}
|
||||
@@ -83,37 +170,40 @@ def parse_primitives_by_module() -> dict[str, frozenset[str]]:
|
||||
|
||||
|
||||
def parse_boundary_sx() -> tuple[frozenset[str], dict[str, frozenset[str]]]:
|
||||
"""Parse boundary.sx and return (io_names, {service: helper_names}).
|
||||
"""Parse all boundary sources and return (io_names, {service: helper_names}).
|
||||
|
||||
Returns:
|
||||
io_names: frozenset of declared I/O primitive names
|
||||
helpers: dict mapping service name to frozenset of helper names
|
||||
Loads three tiers:
|
||||
1. boundary.sx — core language I/O
|
||||
2. boundary-app.sx — deployment-specific I/O
|
||||
3. {service}/sx/boundary.sx — per-service page helpers
|
||||
"""
|
||||
source = _read_file("boundary.sx")
|
||||
exprs = parse_all(source)
|
||||
io_names: set[str] = set()
|
||||
helpers: dict[str, set[str]] = {}
|
||||
all_io: set[str] = set()
|
||||
all_helpers: dict[str, set[str]] = {}
|
||||
|
||||
for expr in exprs:
|
||||
if not isinstance(expr, list) or not expr:
|
||||
continue
|
||||
head = expr[0]
|
||||
if not isinstance(head, Symbol):
|
||||
continue
|
||||
def _merge(source: str, label: str) -> None:
|
||||
io_names, helpers = _extract_declarations(source)
|
||||
all_io.update(io_names)
|
||||
for svc, names in helpers.items():
|
||||
all_helpers.setdefault(svc, set()).update(names)
|
||||
logger.debug("Boundary %s: %d io, %d helpers", label, len(io_names), sum(len(v) for v in helpers.values()))
|
||||
|
||||
if head.name == "define-io-primitive":
|
||||
name = expr[1]
|
||||
if isinstance(name, str):
|
||||
io_names.add(name)
|
||||
# 1. Core language contract
|
||||
_merge(_read_file("boundary.sx"), "core")
|
||||
|
||||
elif head.name == "define-page-helper":
|
||||
name = expr[1]
|
||||
service = _extract_keyword_arg(expr, "service")
|
||||
if isinstance(name, str) and isinstance(service, str):
|
||||
helpers.setdefault(service, set()).add(name)
|
||||
# 2. Deployment-specific I/O
|
||||
app_path = os.path.join(_ref_dir(), "boundary-app.sx")
|
||||
if os.path.exists(app_path):
|
||||
_merge(_read_file("boundary-app.sx"), "app")
|
||||
|
||||
frozen_helpers = {svc: frozenset(names) for svc, names in helpers.items()}
|
||||
return frozenset(io_names), frozen_helpers
|
||||
# 3. Per-service boundary files
|
||||
for filepath in _find_service_boundary_files():
|
||||
try:
|
||||
_merge(_read_file_path(filepath), filepath)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse %s: %s", filepath, e)
|
||||
|
||||
frozen_helpers = {svc: frozenset(names) for svc, names in all_helpers.items()}
|
||||
return frozenset(all_io), frozen_helpers
|
||||
|
||||
|
||||
def parse_boundary_types() -> frozenset[str]:
|
||||
@@ -126,7 +216,6 @@ def parse_boundary_types() -> frozenset[str]:
|
||||
and expr[0].name == "define-boundary-types"):
|
||||
type_list = expr[1]
|
||||
if isinstance(type_list, list):
|
||||
# (list "number" "string" ...)
|
||||
return frozenset(
|
||||
item for item in type_list
|
||||
if isinstance(item, str)
|
||||
|
||||
356
shared/sx/ref/deps.sx
Normal file
356
shared/sx/ref/deps.sx
Normal file
@@ -0,0 +1,356 @@
|
||||
;; ==========================================================================
|
||||
;; deps.sx — Component dependency analysis specification
|
||||
;;
|
||||
;; Pure functions for analyzing component dependency graphs.
|
||||
;; Used by the bundling system to compute per-page component bundles
|
||||
;; instead of sending every definition to every page.
|
||||
;;
|
||||
;; All functions are pure — no IO, no platform-specific operations.
|
||||
;; Each host bootstraps this to native code alongside eval.sx/render.sx.
|
||||
;;
|
||||
;; From eval.sx platform (already provided by every host):
|
||||
;; (type-of x) → type string
|
||||
;; (symbol-name s) → string name of symbol
|
||||
;; (component-body c) → unevaluated AST of component body
|
||||
;; (component-name c) → string name (without ~)
|
||||
;; (macro-body m) → macro body AST
|
||||
;; (env-get env k) → value or nil
|
||||
;;
|
||||
;; New platform functions for deps (each host implements):
|
||||
;; (component-deps c) → cached deps list (may be empty)
|
||||
;; (component-set-deps! c d)→ cache deps on component
|
||||
;; (component-css-classes c)→ pre-scanned CSS class list
|
||||
;; (env-components env) → list of component/macro names in env
|
||||
;; (regex-find-all pat src) → list of capture group 1 matches
|
||||
;; (scan-css-classes src) → list of CSS class strings from source
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. AST scanning — collect ~component references from an AST node
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Walks all branches of control flow (if/when/cond/case) to find
|
||||
;; every component that *could* be rendered.
|
||||
|
||||
(define scan-refs
|
||||
(fn (node)
|
||||
(let ((refs (list)))
|
||||
(scan-refs-walk node refs)
|
||||
refs)))
|
||||
|
||||
|
||||
(define scan-refs-walk
|
||||
(fn (node refs)
|
||||
(cond
|
||||
;; Symbol starting with ~ → component reference
|
||||
(= (type-of node) "symbol")
|
||||
(let ((name (symbol-name node)))
|
||||
(when (starts-with? name "~")
|
||||
(when (not (contains? refs name))
|
||||
(append! refs name))))
|
||||
|
||||
;; List → recurse into all elements (covers all control flow branches)
|
||||
(= (type-of node) "list")
|
||||
(for-each (fn (item) (scan-refs-walk item refs)) node)
|
||||
|
||||
;; Dict → recurse into values
|
||||
(= (type-of node) "dict")
|
||||
(for-each (fn (key) (scan-refs-walk (dict-get node key) refs))
|
||||
(keys node))
|
||||
|
||||
;; Literals (number, string, boolean, nil, keyword) → no refs
|
||||
:else nil)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. Transitive dependency closure
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Given a component name and an environment, compute all components
|
||||
;; that it can transitively render. Handles cycles via seen-set.
|
||||
|
||||
(define transitive-deps-walk
|
||||
(fn (n seen env)
|
||||
(when (not (contains? seen n))
|
||||
(append! seen n)
|
||||
(let ((val (env-get env n)))
|
||||
(cond
|
||||
(= (type-of val) "component")
|
||||
(for-each (fn (ref) (transitive-deps-walk ref seen env))
|
||||
(scan-refs (component-body val)))
|
||||
(= (type-of val) "macro")
|
||||
(for-each (fn (ref) (transitive-deps-walk ref seen env))
|
||||
(scan-refs (macro-body val)))
|
||||
:else nil)))))
|
||||
|
||||
|
||||
(define transitive-deps
|
||||
(fn (name env)
|
||||
(let ((seen (list))
|
||||
(key (if (starts-with? name "~") name (str "~" name))))
|
||||
(transitive-deps-walk key seen env)
|
||||
(filter (fn (x) (not (= x key))) seen))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. Compute deps for all components in an environment
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Iterates env, calls transitive-deps for each component, and
|
||||
;; stores the result via the platform's component-set-deps! function.
|
||||
;;
|
||||
;; Platform interface:
|
||||
;; (env-components env) → list of component names in env
|
||||
;; (component-set-deps! comp deps) → store deps on component
|
||||
|
||||
(define compute-all-deps
|
||||
(fn (env)
|
||||
(for-each
|
||||
(fn (name)
|
||||
(let ((val (env-get env name)))
|
||||
(when (= (type-of val) "component")
|
||||
(component-set-deps! val (transitive-deps name env)))))
|
||||
(env-components env))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. Scan serialized SX source for component references
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Regex-based extraction of (~name patterns from SX wire format.
|
||||
;; Returns list of names WITH ~ prefix.
|
||||
;;
|
||||
;; Platform interface:
|
||||
;; (regex-find-all pattern source) → list of matched group strings
|
||||
|
||||
(define scan-components-from-source
|
||||
(fn (source)
|
||||
(let ((matches (regex-find-all "\\(~([a-zA-Z_][a-zA-Z0-9_\\-]*)" source)))
|
||||
(map (fn (m) (str "~" m)) matches))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. Components needed for a page
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Scans page source for direct component references, then computes
|
||||
;; the transitive closure. Returns list of ~names.
|
||||
|
||||
(define components-needed
|
||||
(fn (page-source env)
|
||||
(let ((direct (scan-components-from-source page-source))
|
||||
(all-needed (list)))
|
||||
|
||||
;; Add each direct ref + its transitive deps
|
||||
(for-each
|
||||
(fn (name)
|
||||
(when (not (contains? all-needed name))
|
||||
(append! all-needed name))
|
||||
(let ((val (env-get env name)))
|
||||
(let ((deps (if (and (= (type-of val) "component")
|
||||
(not (empty? (component-deps val))))
|
||||
(component-deps val)
|
||||
(transitive-deps name env))))
|
||||
(for-each
|
||||
(fn (dep)
|
||||
(when (not (contains? all-needed dep))
|
||||
(append! all-needed dep)))
|
||||
deps))))
|
||||
direct)
|
||||
|
||||
all-needed)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. Build per-page component bundle
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Given page source and env, returns list of component names needed.
|
||||
;; The host uses this list to serialize only the needed definitions
|
||||
;; and compute a page-specific hash.
|
||||
;;
|
||||
;; This replaces the "send everything" approach with per-page bundles.
|
||||
|
||||
(define page-component-bundle
|
||||
(fn (page-source env)
|
||||
(components-needed page-source env)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 7. CSS classes for a page
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Returns the union of CSS classes from components this page uses,
|
||||
;; plus classes from the page source itself.
|
||||
;;
|
||||
;; Platform interface:
|
||||
;; (component-css-classes c) → set/list of class strings
|
||||
;; (scan-css-classes source) → set/list of class strings from source
|
||||
|
||||
(define page-css-classes
|
||||
(fn (page-source env)
|
||||
(let ((needed (components-needed page-source env))
|
||||
(classes (list)))
|
||||
|
||||
;; Collect classes from needed components
|
||||
(for-each
|
||||
(fn (name)
|
||||
(let ((val (env-get env name)))
|
||||
(when (= (type-of val) "component")
|
||||
(for-each
|
||||
(fn (cls)
|
||||
(when (not (contains? classes cls))
|
||||
(append! classes cls)))
|
||||
(component-css-classes val)))))
|
||||
needed)
|
||||
|
||||
;; Add classes from page source
|
||||
(for-each
|
||||
(fn (cls)
|
||||
(when (not (contains? classes cls))
|
||||
(append! classes cls)))
|
||||
(scan-css-classes page-source))
|
||||
|
||||
classes)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 8. IO detection — scan component ASTs for IO primitive references
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Extends the dependency walker to detect references to IO primitives.
|
||||
;; IO names are provided by the host (from boundary.sx declarations).
|
||||
;; A component is "pure" if it (transitively) references no IO primitives.
|
||||
;;
|
||||
;; Platform interface additions:
|
||||
;; (component-io-refs c) → cached IO ref list (may be empty)
|
||||
;; (component-set-io-refs! c r) → cache IO refs on component
|
||||
|
||||
(define scan-io-refs-walk
|
||||
(fn (node io-names refs)
|
||||
(cond
|
||||
;; Symbol → check if name is in the IO set
|
||||
(= (type-of node) "symbol")
|
||||
(let ((name (symbol-name node)))
|
||||
(when (contains? io-names name)
|
||||
(when (not (contains? refs name))
|
||||
(append! refs name))))
|
||||
|
||||
;; List → recurse into all elements
|
||||
(= (type-of node) "list")
|
||||
(for-each (fn (item) (scan-io-refs-walk item io-names refs)) node)
|
||||
|
||||
;; Dict → recurse into values
|
||||
(= (type-of node) "dict")
|
||||
(for-each (fn (key) (scan-io-refs-walk (dict-get node key) io-names refs))
|
||||
(keys node))
|
||||
|
||||
;; Literals → no IO refs
|
||||
:else nil)))
|
||||
|
||||
|
||||
(define scan-io-refs
|
||||
(fn (node io-names)
|
||||
(let ((refs (list)))
|
||||
(scan-io-refs-walk node io-names refs)
|
||||
refs)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 9. Transitive IO refs — follow component deps and union IO refs
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define transitive-io-refs-walk
|
||||
(fn (n seen all-refs env io-names)
|
||||
(when (not (contains? seen n))
|
||||
(append! seen n)
|
||||
(let ((val (env-get env n)))
|
||||
(cond
|
||||
(= (type-of val) "component")
|
||||
(do
|
||||
;; Scan this component's body for IO refs
|
||||
(for-each
|
||||
(fn (ref)
|
||||
(when (not (contains? all-refs ref))
|
||||
(append! all-refs ref)))
|
||||
(scan-io-refs (component-body val) io-names))
|
||||
;; Recurse into component deps
|
||||
(for-each
|
||||
(fn (dep) (transitive-io-refs-walk dep seen all-refs env io-names))
|
||||
(scan-refs (component-body val))))
|
||||
|
||||
(= (type-of val) "macro")
|
||||
(do
|
||||
(for-each
|
||||
(fn (ref)
|
||||
(when (not (contains? all-refs ref))
|
||||
(append! all-refs ref)))
|
||||
(scan-io-refs (macro-body val) io-names))
|
||||
(for-each
|
||||
(fn (dep) (transitive-io-refs-walk dep seen all-refs env io-names))
|
||||
(scan-refs (macro-body val))))
|
||||
|
||||
:else nil)))))
|
||||
|
||||
|
||||
(define transitive-io-refs
|
||||
(fn (name env io-names)
|
||||
(let ((all-refs (list))
|
||||
(seen (list))
|
||||
(key (if (starts-with? name "~") name (str "~" name))))
|
||||
(transitive-io-refs-walk key seen all-refs env io-names)
|
||||
all-refs)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 10. Compute IO refs for all components in an environment
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define compute-all-io-refs
|
||||
(fn (env io-names)
|
||||
(for-each
|
||||
(fn (name)
|
||||
(let ((val (env-get env name)))
|
||||
(when (= (type-of val) "component")
|
||||
(component-set-io-refs! val (transitive-io-refs name env io-names)))))
|
||||
(env-components env))))
|
||||
|
||||
|
||||
(define component-pure?
|
||||
(fn (name env io-names)
|
||||
(empty? (transitive-io-refs name env io-names))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Host obligation: selective expansion in async partial evaluation
|
||||
;; --------------------------------------------------------------------------
|
||||
;; The spec classifies components as pure or IO-dependent. Each host's
|
||||
;; async partial evaluator (the server-side rendering path that bridges
|
||||
;; sync evaluation with async IO) must use this classification:
|
||||
;;
|
||||
;; IO-dependent component → expand server-side (IO must resolve)
|
||||
;; Pure component → serialize for client (can render anywhere)
|
||||
;; Layout slot context → expand all (server needs full HTML)
|
||||
;;
|
||||
;; The spec provides the data (component-io-refs, component-pure?).
|
||||
;; The host provides the async runtime that acts on it.
|
||||
;; This is not SX semantics — it is host infrastructure. Every host
|
||||
;; with a server-side async evaluator implements the same rule.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Platform interface summary
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; From eval.sx (already provided):
|
||||
;; (type-of x) → type string
|
||||
;; (symbol-name s) → string name of symbol
|
||||
;; (env-get env k) → value or nil
|
||||
;;
|
||||
;; New for deps.sx (each host implements):
|
||||
;; (component-body c) → AST body of component
|
||||
;; (component-name c) → name string
|
||||
;; (component-deps c) → cached deps list (may be empty)
|
||||
;; (component-set-deps! c d)→ cache deps on component
|
||||
;; (component-css-classes c)→ pre-scanned CSS class list
|
||||
;; (component-io-refs c) → cached IO ref list (may be empty)
|
||||
;; (component-set-io-refs! c r)→ cache IO refs on component
|
||||
;; (macro-body m) → AST body of macro
|
||||
;; (env-components env) → list of component names in env
|
||||
;; (regex-find-all pat src) → list of capture group matches
|
||||
;; (scan-css-classes src) → list of CSS class strings from source
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -505,7 +505,7 @@
|
||||
(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)
|
||||
@@ -523,6 +523,52 @@
|
||||
(dom-query-all container "form"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Client-side routing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define try-client-route
|
||||
(fn (pathname)
|
||||
;; Try to render a page client-side. Returns true if successful, false otherwise.
|
||||
;; Only works for pages without :data dependencies.
|
||||
(let ((match (find-matching-route pathname _page-routes)))
|
||||
(if (nil? match)
|
||||
false
|
||||
(if (get match "has-data")
|
||||
false
|
||||
(let ((content-src (get match "content"))
|
||||
(closure (or (get match "closure") {}))
|
||||
(params (get match "params")))
|
||||
(if (or (nil? content-src) (empty? content-src))
|
||||
false
|
||||
(let ((env (merge closure params))
|
||||
(rendered (try-eval-content content-src env)))
|
||||
(if (nil? rendered)
|
||||
false
|
||||
(let ((target (dom-query-by-id "main-panel")))
|
||||
(if (nil? target)
|
||||
false
|
||||
(do
|
||||
(dom-set-text-content target "")
|
||||
(dom-append target rendered)
|
||||
(hoist-head-elements-full target)
|
||||
(process-elements target)
|
||||
(sx-hydrate-elements target)
|
||||
(log-info (str "sx:route client " pathname))
|
||||
true))))))))))))
|
||||
|
||||
|
||||
(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)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; SSE processing
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -668,13 +714,17 @@
|
||||
|
||||
(define handle-popstate
|
||||
(fn (scrollY)
|
||||
;; Handle browser back/forward navigation
|
||||
;; Handle browser back/forward navigation.
|
||||
;; Try client-side route first, fall back to server fetch.
|
||||
(let ((main (dom-query-by-id "main-panel"))
|
||||
(url (browser-location-href)))
|
||||
(when main
|
||||
(let ((headers (build-request-headers main
|
||||
(loaded-component-names) _css-hash)))
|
||||
(fetch-and-restore main url headers scrollY))))))
|
||||
(let ((pathname (url-pathname url)))
|
||||
(if (try-client-route pathname)
|
||||
(browser-scroll-to 0 scrollY)
|
||||
(let ((headers (build-request-headers main
|
||||
(loaded-component-names) _css-hash)))
|
||||
(fetch-and-restore main url headers scrollY))))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -773,6 +823,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 +854,22 @@
|
||||
;; === Parsing ===
|
||||
;; (try-parse-json s) → parsed value or nil
|
||||
;;
|
||||
;; === Client-side routing ===
|
||||
;; (try-eval-content source env) → DOM node or nil (catches eval errors)
|
||||
;; (url-pathname href) → extract pathname from URL string
|
||||
;;
|
||||
;; 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
|
||||
|
||||
@@ -307,6 +307,11 @@
|
||||
:returns "any"
|
||||
:doc "Slice a string or list from start to end (exclusive). End is optional.")
|
||||
|
||||
(define-primitive "index-of"
|
||||
:params (s needle &rest from)
|
||||
:returns "number"
|
||||
:doc "Index of first occurrence of needle in s, or -1 if not found. Optional start index.")
|
||||
|
||||
(define-primitive "starts-with?"
|
||||
:params (s prefix)
|
||||
:returns "boolean"
|
||||
|
||||
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 (- (length 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 (- (length 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 (= (length path-segs) (length 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?, length, empty?,
|
||||
;; map, for-each, for-each-indexed, nth, get, dict-set!, merge,
|
||||
;; list, nil?, not, =
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -725,6 +725,7 @@ PRIMITIVES["trim"] = lambda s: str(s).strip()
|
||||
PRIMITIVES["split"] = lambda s, sep=" ": str(s).split(sep)
|
||||
PRIMITIVES["join"] = lambda sep, coll: sep.join(coll)
|
||||
PRIMITIVES["replace"] = lambda s, old, new: s.replace(old, new)
|
||||
PRIMITIVES["index-of"] = lambda s, needle, start=0: str(s).find(needle, start)
|
||||
PRIMITIVES["starts-with?"] = lambda s, p: str(s).startswith(p)
|
||||
PRIMITIVES["ends-with?"] = lambda s, p: str(s).endswith(p)
|
||||
PRIMITIVES["slice"] = lambda c, a, b=None: c[a:b] if b is not None else c[a:]
|
||||
@@ -875,6 +876,57 @@ range = PRIMITIVES["range"]
|
||||
apply = lambda f, args: f(*args)
|
||||
assoc = PRIMITIVES["assoc"]
|
||||
concat = PRIMITIVES["concat"]
|
||||
split = PRIMITIVES["split"]
|
||||
length = PRIMITIVES["len"]
|
||||
merge = PRIMITIVES["merge"]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Platform: deps module — component dependency analysis
|
||||
# =========================================================================
|
||||
|
||||
import re as _re
|
||||
|
||||
def component_deps(c):
|
||||
"""Return cached deps list for a component (may be empty)."""
|
||||
return list(c.deps) if hasattr(c, "deps") and c.deps else []
|
||||
|
||||
def component_set_deps(c, deps):
|
||||
"""Cache deps on a component."""
|
||||
c.deps = set(deps) if not isinstance(deps, set) else deps
|
||||
|
||||
def component_css_classes(c):
|
||||
"""Return pre-scanned CSS class list for a component."""
|
||||
return list(c.css_classes) if hasattr(c, "css_classes") and c.css_classes else []
|
||||
|
||||
def env_components(env):
|
||||
"""Return list of component/macro names in an environment."""
|
||||
return [k for k, v in env.items()
|
||||
if isinstance(v, (Component, Macro))]
|
||||
|
||||
def regex_find_all(pattern, source):
|
||||
"""Return list of capture group 1 matches."""
|
||||
return [m.group(1) for m in _re.finditer(pattern, source)]
|
||||
|
||||
def scan_css_classes(source):
|
||||
"""Extract CSS class strings from SX source."""
|
||||
classes = set()
|
||||
for m in _re.finditer(r':class\s+"([^"]*)"', source):
|
||||
classes.update(m.group(1).split())
|
||||
for m in _re.finditer(r':class\s+\(str\s+((?:"[^"]*"\s*)+)\)', source):
|
||||
for s in _re.findall(r'"([^"]*)"', m.group(1)):
|
||||
classes.update(s.split())
|
||||
for m in _re.finditer(r';;\s*@css\s+(.+)', source):
|
||||
classes.update(m.group(1).split())
|
||||
return list(classes)
|
||||
|
||||
def component_io_refs(c):
|
||||
"""Return cached IO refs list for a component (may be empty)."""
|
||||
return list(c.io_refs) if hasattr(c, "io_refs") and c.io_refs else []
|
||||
|
||||
def component_set_io_refs(c, refs):
|
||||
"""Cache IO refs on a component."""
|
||||
c.io_refs = set(refs) if not isinstance(refs, set) else refs
|
||||
|
||||
|
||||
# === Transpiled from eval ===
|
||||
@@ -1137,6 +1189,91 @@ aser_fragment = lambda children, env: (lambda parts: ('' if sx_truthy(empty_p(pa
|
||||
aser_call = lambda name, args, env: (lambda parts: _sx_begin(reduce(lambda state, arg: (lambda skip: (assoc(state, 'skip', False, 'i', (get(state, 'i') + 1)) if sx_truthy(skip) else ((lambda val: _sx_begin((_sx_begin(_sx_append(parts, sx_str(':', keyword_name(arg))), _sx_append(parts, serialize(val))) if sx_truthy((not sx_truthy(is_nil(val)))) else NIL), assoc(state, 'skip', True, 'i', (get(state, 'i') + 1))))(aser(nth(args, (get(state, 'i') + 1)), env)) if sx_truthy(((type_of(arg) == 'keyword') if not sx_truthy((type_of(arg) == 'keyword')) else ((get(state, 'i') + 1) < len(args)))) else (lambda val: _sx_begin((_sx_append(parts, serialize(val)) if sx_truthy((not sx_truthy(is_nil(val)))) else NIL), assoc(state, 'i', (get(state, 'i') + 1))))(aser(arg, env)))))(get(state, 'skip')), {'i': 0, 'skip': False}, args), sx_str('(', join(' ', parts), ')')))([name])
|
||||
|
||||
|
||||
# === Transpiled from deps (component dependency analysis) ===
|
||||
|
||||
# scan-refs
|
||||
scan_refs = lambda node: (lambda refs: _sx_begin(scan_refs_walk(node, refs), refs))([])
|
||||
|
||||
# scan-refs-walk
|
||||
scan_refs_walk = lambda node, refs: ((lambda name: ((_sx_append(refs, name) if sx_truthy((not sx_truthy(contains_p(refs, name)))) else NIL) if sx_truthy(starts_with_p(name, '~')) else NIL))(symbol_name(node)) if sx_truthy((type_of(node) == 'symbol')) else (for_each(lambda item: scan_refs_walk(item, refs), node) if sx_truthy((type_of(node) == 'list')) else (for_each(lambda key: scan_refs_walk(dict_get(node, key), refs), keys(node)) if sx_truthy((type_of(node) == 'dict')) else NIL)))
|
||||
|
||||
# transitive-deps-walk
|
||||
transitive_deps_walk = lambda n, seen, env: (_sx_begin(_sx_append(seen, n), (lambda val: (for_each(lambda ref: transitive_deps_walk(ref, seen, env), scan_refs(component_body(val))) if sx_truthy((type_of(val) == 'component')) else (for_each(lambda ref: transitive_deps_walk(ref, seen, env), scan_refs(macro_body(val))) if sx_truthy((type_of(val) == 'macro')) else NIL)))(env_get(env, n))) if sx_truthy((not sx_truthy(contains_p(seen, n)))) else NIL)
|
||||
|
||||
# transitive-deps
|
||||
transitive_deps = lambda name, env: (lambda seen: (lambda key: _sx_begin(transitive_deps_walk(key, seen, env), filter(lambda x: (not sx_truthy((x == key))), seen)))((name if sx_truthy(starts_with_p(name, '~')) else sx_str('~', name))))([])
|
||||
|
||||
# compute-all-deps
|
||||
compute_all_deps = lambda env: for_each(lambda name: (lambda val: (component_set_deps(val, transitive_deps(name, env)) if sx_truthy((type_of(val) == 'component')) else NIL))(env_get(env, name)), env_components(env))
|
||||
|
||||
# scan-components-from-source
|
||||
scan_components_from_source = lambda source: (lambda matches: map(lambda m: sx_str('~', m), matches))(regex_find_all('\\(~([a-zA-Z_][a-zA-Z0-9_\\-]*)', source))
|
||||
|
||||
# components-needed
|
||||
components_needed = lambda page_source, env: (lambda direct: (lambda all_needed: _sx_begin(for_each(_sx_fn(lambda name: (
|
||||
(_sx_append(all_needed, name) if sx_truthy((not sx_truthy(contains_p(all_needed, name)))) else NIL),
|
||||
(lambda val: (lambda deps: for_each(lambda dep: (_sx_append(all_needed, dep) if sx_truthy((not sx_truthy(contains_p(all_needed, dep)))) else NIL), deps))((component_deps(val) if sx_truthy(((type_of(val) == 'component') if not sx_truthy((type_of(val) == 'component')) else (not sx_truthy(empty_p(component_deps(val)))))) else transitive_deps(name, env))))(env_get(env, name))
|
||||
)[-1]), direct), all_needed))([]))(scan_components_from_source(page_source))
|
||||
|
||||
# page-component-bundle
|
||||
page_component_bundle = lambda page_source, env: components_needed(page_source, env)
|
||||
|
||||
# page-css-classes
|
||||
page_css_classes = lambda page_source, env: (lambda needed: (lambda classes: _sx_begin(for_each(lambda name: (lambda val: (for_each(lambda cls: (_sx_append(classes, cls) if sx_truthy((not sx_truthy(contains_p(classes, cls)))) else NIL), component_css_classes(val)) if sx_truthy((type_of(val) == 'component')) else NIL))(env_get(env, name)), needed), for_each(lambda cls: (_sx_append(classes, cls) if sx_truthy((not sx_truthy(contains_p(classes, cls)))) else NIL), scan_css_classes(page_source)), classes))([]))(components_needed(page_source, env))
|
||||
|
||||
# scan-io-refs-walk
|
||||
scan_io_refs_walk = lambda node, io_names, refs: ((lambda name: ((_sx_append(refs, name) if sx_truthy((not sx_truthy(contains_p(refs, name)))) else NIL) if sx_truthy(contains_p(io_names, name)) else NIL))(symbol_name(node)) if sx_truthy((type_of(node) == 'symbol')) else (for_each(lambda item: scan_io_refs_walk(item, io_names, refs), node) if sx_truthy((type_of(node) == 'list')) else (for_each(lambda key: scan_io_refs_walk(dict_get(node, key), io_names, refs), keys(node)) if sx_truthy((type_of(node) == 'dict')) else NIL)))
|
||||
|
||||
# scan-io-refs
|
||||
scan_io_refs = lambda node, io_names: (lambda refs: _sx_begin(scan_io_refs_walk(node, io_names, refs), refs))([])
|
||||
|
||||
# transitive-io-refs-walk
|
||||
transitive_io_refs_walk = lambda n, seen, all_refs, env, io_names: (_sx_begin(_sx_append(seen, n), (lambda val: (_sx_begin(for_each(lambda ref: (_sx_append(all_refs, ref) if sx_truthy((not sx_truthy(contains_p(all_refs, ref)))) else NIL), scan_io_refs(component_body(val), io_names)), for_each(lambda dep: transitive_io_refs_walk(dep, seen, all_refs, env, io_names), scan_refs(component_body(val)))) if sx_truthy((type_of(val) == 'component')) else (_sx_begin(for_each(lambda ref: (_sx_append(all_refs, ref) if sx_truthy((not sx_truthy(contains_p(all_refs, ref)))) else NIL), scan_io_refs(macro_body(val), io_names)), for_each(lambda dep: transitive_io_refs_walk(dep, seen, all_refs, env, io_names), scan_refs(macro_body(val)))) if sx_truthy((type_of(val) == 'macro')) else NIL)))(env_get(env, n))) if sx_truthy((not sx_truthy(contains_p(seen, n)))) else NIL)
|
||||
|
||||
# transitive-io-refs
|
||||
transitive_io_refs = lambda name, env, io_names: (lambda all_refs: (lambda seen: (lambda key: _sx_begin(transitive_io_refs_walk(key, seen, all_refs, env, io_names), all_refs))((name if sx_truthy(starts_with_p(name, '~')) else sx_str('~', name))))([]))([])
|
||||
|
||||
# compute-all-io-refs
|
||||
compute_all_io_refs = lambda env, io_names: for_each(lambda name: (lambda val: (component_set_io_refs(val, transitive_io_refs(name, env, io_names)) if sx_truthy((type_of(val) == 'component')) else NIL))(env_get(env, name)), env_components(env))
|
||||
|
||||
# component-pure?
|
||||
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
|
||||
# =========================================================================
|
||||
|
||||
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
|
||||
176
shared/sx/tests/test_deps.py
Normal file
176
shared/sx/tests/test_deps.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Tests for the component dependency analyzer."""
|
||||
|
||||
import pytest
|
||||
from shared.sx.parser import parse_all
|
||||
from shared.sx.types import Component, Macro, Symbol
|
||||
from shared.sx.deps import (
|
||||
_scan_ast,
|
||||
transitive_deps,
|
||||
compute_all_deps,
|
||||
scan_components_from_sx,
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _scan_ast
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestScanAst:
|
||||
def test_simple_component_ref(self):
|
||||
env = make_env('(defcomp ~card (&key title) (div (~badge :label title)))')
|
||||
comp = env["~card"]
|
||||
refs = _scan_ast(comp.body)
|
||||
assert refs == {"~badge"}
|
||||
|
||||
def test_no_refs(self):
|
||||
env = make_env('(defcomp ~plain (&key text) (div :class "p-4" text))')
|
||||
comp = env["~plain"]
|
||||
refs = _scan_ast(comp.body)
|
||||
assert refs == set()
|
||||
|
||||
def test_multiple_refs(self):
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key title) (div (~header :title title) (~footer)))'
|
||||
)
|
||||
comp = env["~page"]
|
||||
refs = _scan_ast(comp.body)
|
||||
assert refs == {"~header", "~footer"}
|
||||
|
||||
def test_nested_in_control_flow(self):
|
||||
env = make_env(
|
||||
'(defcomp ~card (&key big) '
|
||||
' (if big (~big-card) (~small-card)))'
|
||||
)
|
||||
comp = env["~card"]
|
||||
refs = _scan_ast(comp.body)
|
||||
assert refs == {"~big-card", "~small-card"}
|
||||
|
||||
def test_refs_in_dict(self):
|
||||
env = make_env(
|
||||
'(defcomp ~wrap (&key) (div {:slot (~inner)}))'
|
||||
)
|
||||
comp = env["~wrap"]
|
||||
refs = _scan_ast(comp.body)
|
||||
assert refs == {"~inner"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# transitive_deps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTransitiveDeps:
|
||||
def test_direct_dep(self):
|
||||
env = make_env(
|
||||
'(defcomp ~card (&key) (div (~badge)))',
|
||||
'(defcomp ~badge (&key) (span "★"))',
|
||||
)
|
||||
deps = transitive_deps("~card", env)
|
||||
assert deps == {"~badge"}
|
||||
|
||||
def test_transitive(self):
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~layout)))',
|
||||
'(defcomp ~layout (&key) (div (~header) (~footer)))',
|
||||
'(defcomp ~header (&key) (nav "header"))',
|
||||
'(defcomp ~footer (&key) (footer "footer"))',
|
||||
)
|
||||
deps = transitive_deps("~page", env)
|
||||
assert deps == {"~layout", "~header", "~footer"}
|
||||
|
||||
def test_circular(self):
|
||||
"""Circular deps should not cause infinite recursion."""
|
||||
env = make_env(
|
||||
'(defcomp ~a (&key) (div (~b)))',
|
||||
'(defcomp ~b (&key) (div (~a)))',
|
||||
)
|
||||
deps = transitive_deps("~a", env)
|
||||
assert deps == {"~b"}
|
||||
|
||||
def test_no_deps(self):
|
||||
env = make_env('(defcomp ~leaf (&key) (span "hi"))')
|
||||
deps = transitive_deps("~leaf", env)
|
||||
assert deps == set()
|
||||
|
||||
def test_missing_component(self):
|
||||
"""Referencing a component not in env should not crash."""
|
||||
env = make_env('(defcomp ~card (&key) (div (~unknown)))')
|
||||
deps = transitive_deps("~card", env)
|
||||
assert "~unknown" in deps
|
||||
|
||||
def test_without_tilde_prefix(self):
|
||||
env = make_env(
|
||||
'(defcomp ~card (&key) (div (~badge)))',
|
||||
'(defcomp ~badge (&key) (span "★"))',
|
||||
)
|
||||
deps = transitive_deps("card", env)
|
||||
assert deps == {"~badge"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_all_deps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComputeAllDeps:
|
||||
def test_sets_deps_on_components(self):
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~card)))',
|
||||
'(defcomp ~card (&key) (div (~badge)))',
|
||||
'(defcomp ~badge (&key) (span "★"))',
|
||||
)
|
||||
compute_all_deps(env)
|
||||
assert env["~page"].deps == {"~card", "~badge"}
|
||||
assert env["~card"].deps == {"~badge"}
|
||||
assert env["~badge"].deps == set()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# scan_components_from_sx
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestScanComponentsFromSx:
|
||||
def test_basic(self):
|
||||
source = '(~card :title "hi" (~badge :label "new"))'
|
||||
refs = scan_components_from_sx(source)
|
||||
assert refs == {"~card", "~badge"}
|
||||
|
||||
def test_no_components(self):
|
||||
source = '(div :class "p-4" (p "hello"))'
|
||||
refs = scan_components_from_sx(source)
|
||||
assert refs == set()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# components_needed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComponentsNeeded:
|
||||
def test_page_with_deps(self):
|
||||
env = make_env(
|
||||
'(defcomp ~page-layout (&key) (div (~nav) (~footer)))',
|
||||
'(defcomp ~nav (&key) (nav "nav"))',
|
||||
'(defcomp ~footer (&key) (footer "footer"))',
|
||||
'(defcomp ~unused (&key) (div "not needed"))',
|
||||
)
|
||||
compute_all_deps(env)
|
||||
page_sx = '(~page-layout)'
|
||||
needed = components_needed(page_sx, env)
|
||||
assert "~page-layout" in needed
|
||||
assert "~nav" in needed
|
||||
assert "~footer" in needed
|
||||
assert "~unused" not in needed
|
||||
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")))',
|
||||
)
|
||||
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("") == '""'
|
||||
@@ -167,6 +167,13 @@ class Component:
|
||||
body: Any # unevaluated s-expression body
|
||||
closure: dict[str, Any] = field(default_factory=dict)
|
||||
css_classes: set[str] = field(default_factory=set) # pre-scanned :class values
|
||||
deps: set[str] = field(default_factory=set) # transitive component deps (~names)
|
||||
io_refs: set[str] = field(default_factory=set) # transitive IO primitive refs
|
||||
|
||||
@property
|
||||
def is_pure(self) -> bool:
|
||||
"""True if this component has no transitive IO dependencies."""
|
||||
return not self.io_refs
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Component ~{self.name}({', '.join(self.params)})>"
|
||||
|
||||
@@ -209,7 +209,7 @@ PRIMITIVES = {
|
||||
"Arithmetic": ["+", "-", "*", "/", "mod", "sqrt", "pow", "abs", "floor", "ceil", "round", "min", "max"],
|
||||
"Comparison": ["=", "!=", "<", ">", "<=", ">="],
|
||||
"Logic": ["not", "and", "or"],
|
||||
"String": ["str", "upper", "lower", "trim", "split", "join", "starts-with?", "ends-with?", "replace", "substring"],
|
||||
"String": ["str", "upper", "lower", "trim", "split", "join", "index-of", "starts-with?", "ends-with?", "replace", "substring"],
|
||||
"Collections": ["list", "dict", "len", "first", "last", "rest", "nth", "cons", "append", "keys", "vals", "merge", "assoc", "range", "concat", "reverse", "sort", "flatten", "zip"],
|
||||
"Higher-Order": ["map", "map-indexed", "filter", "reduce", "some", "every?", "for-each"],
|
||||
"Predicates": ["nil?", "number?", "string?", "list?", "dict?", "empty?", "contains?", "odd?", "even?", "zero?"],
|
||||
|
||||
120
sx/sx/analyzer.sx
Normal file
120
sx/sx/analyzer.sx
Normal file
@@ -0,0 +1,120 @@
|
||||
;; Bundle analyzer — live demonstration of dependency analysis + IO detection.
|
||||
;; Shows per-page component bundles vs total, visualizing payload savings.
|
||||
;; Drill down into each bundle to see component tree; expand to see SX source.
|
||||
;; @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-3 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
|
||||
|
||||
(defcomp ~bundle-analyzer-content (&key pages total-components total-macros
|
||||
pure-count io-count)
|
||||
(~doc-page :title "Page Bundle Analyzer"
|
||||
|
||||
(p :class "text-stone-600 mb-6"
|
||||
"Live analysis of component dependency graphs and IO classification across all pages. "
|
||||
"Each bar shows how many of the "
|
||||
(strong (str total-components))
|
||||
" total components a page actually needs, computed by the "
|
||||
(a :href "/specs/deps" :class "text-violet-700 underline" "deps.sx")
|
||||
" transitive closure algorithm. "
|
||||
"Click a page to see its component tree; expand a component to see its SX source.")
|
||||
|
||||
(div :class "mb-8 grid grid-cols-4 gap-4"
|
||||
(~analyzer-stat :label "Total Components" :value (str total-components)
|
||||
:cls "text-violet-600")
|
||||
(~analyzer-stat :label "Total Macros" :value (str total-macros)
|
||||
:cls "text-stone-600")
|
||||
(~analyzer-stat :label "Pure Components" :value (str pure-count)
|
||||
:cls "text-blue-600")
|
||||
(~analyzer-stat :label "IO-Dependent" :value (str io-count)
|
||||
:cls "text-amber-600"))
|
||||
|
||||
(~doc-section :title "Per-Page Bundles" :id "bundles"
|
||||
(div :class "space-y-3"
|
||||
(map (fn (page)
|
||||
(~analyzer-row
|
||||
:name (get page "name")
|
||||
:path (get page "path")
|
||||
:needed (get page "needed")
|
||||
:direct (get page "direct")
|
||||
:total total-components
|
||||
:pct (get page "pct")
|
||||
:savings (get page "savings")
|
||||
:io-refs (get page "io-refs")
|
||||
:pure-in-page (get page "pure-in-page")
|
||||
:io-in-page (get page "io-in-page")
|
||||
:components (get page "components")))
|
||||
pages)))
|
||||
|
||||
(~doc-section :title "How It Works" :id "how"
|
||||
(ol :class "list-decimal pl-5 space-y-2 text-stone-700"
|
||||
(li (strong "Scan: ") "Regex finds all " (code "(~name") " patterns in the page's content expression.")
|
||||
(li (strong "Resolve: ") "Each referenced component's body AST is walked to find transitive " (code "~") " references.")
|
||||
(li (strong "Closure: ") "The full set is the union of direct + transitive deps, following chains through the component graph.")
|
||||
(li (strong "Bundle: ") "Only these component definitions are serialized into the page payload. Everything else is omitted.")
|
||||
(li (strong "IO detect: ") "Each component body is scanned for references to IO primitives (frag, query, service, etc.). Components with zero transitive IO refs are pure — safe for client rendering."))
|
||||
(p :class "mt-4 text-stone-600"
|
||||
"The analysis handles circular references (via seen-set), "
|
||||
"walks all branches of control flow (if/when/cond/case), "
|
||||
"and includes macro definitions shared across components."))))
|
||||
|
||||
(defcomp ~analyzer-stat (&key label value cls)
|
||||
(div :class "rounded-lg border border-stone-200 p-4 text-center"
|
||||
(div :class (str "text-3xl font-bold " cls) value)
|
||||
(div :class "text-sm text-stone-500 mt-1" label)))
|
||||
|
||||
(defcomp ~analyzer-row (&key name path needed direct total pct savings
|
||||
io-refs pure-in-page io-in-page components)
|
||||
(details :class "rounded border border-stone-200"
|
||||
(summary :class "p-4 cursor-pointer hover:bg-stone-50 transition-colors"
|
||||
(div :class "flex items-center justify-between mb-2"
|
||||
(div
|
||||
(span :class "font-mono font-semibold text-stone-800" name)
|
||||
(span :class "text-stone-400 text-sm ml-2" path))
|
||||
(div :class "flex items-center gap-2"
|
||||
(span :class "inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
|
||||
(str pure-in-page " pure"))
|
||||
(span :class "inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800"
|
||||
(str io-in-page " IO"))
|
||||
(div :class "text-right"
|
||||
(span :class "font-mono text-sm"
|
||||
(span :class "text-violet-700 font-bold" (str needed))
|
||||
(span :class "text-stone-400" (str " / " total)))
|
||||
(span :class "ml-2 inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800"
|
||||
(str savings "% saved")))))
|
||||
(div :class "w-full bg-stone-200 rounded-full h-2.5"
|
||||
(div :class "bg-violet-600 h-2.5 rounded-full transition-all"
|
||||
:style (str "width: " pct "%"))))
|
||||
|
||||
;; Component tree (shown when expanded)
|
||||
(div :class "border-t border-stone-200 p-4 bg-stone-50"
|
||||
(div :class "text-xs font-medium text-stone-500 uppercase tracking-wide mb-3"
|
||||
(str needed " components in bundle"))
|
||||
(div :class "space-y-1"
|
||||
(map (fn (comp)
|
||||
(~analyzer-component
|
||||
:comp-name (get comp "name")
|
||||
:is-pure (get comp "is-pure")
|
||||
:io-refs (get comp "io-refs")
|
||||
:deps (get comp "deps")
|
||||
:source (get comp "source")))
|
||||
components)))))
|
||||
|
||||
(defcomp ~analyzer-component (&key comp-name is-pure io-refs deps source)
|
||||
(details :class (str "rounded border "
|
||||
(if is-pure "border-blue-200 bg-blue-50" "border-amber-200 bg-amber-50"))
|
||||
(summary :class "px-3 py-2 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
(div :class "flex items-center justify-between"
|
||||
(div :class "flex items-center gap-2"
|
||||
(span :class (str "inline-block w-2 h-2 rounded-full "
|
||||
(if is-pure "bg-blue-500" "bg-amber-500")))
|
||||
(span :class "font-mono text-sm font-medium text-stone-800" comp-name))
|
||||
(div :class "flex items-center gap-2"
|
||||
(when (not (empty? io-refs))
|
||||
(span :class "text-xs text-amber-700"
|
||||
(str "IO: " (join ", " io-refs))))
|
||||
(when (not (empty? deps))
|
||||
(span :class "text-xs text-stone-500"
|
||||
(str (len deps) " deps"))))))
|
||||
|
||||
;; SX source (shown when component expanded)
|
||||
(div :class "not-prose border-t border-stone-200 p-3 bg-stone-100 rounded-b"
|
||||
(pre :class "text-xs leading-relaxed whitespace-pre-wrap overflow-x-auto"
|
||||
(code (highlight source "lisp"))))))
|
||||
56
sx/sx/boundary.sx
Normal file
56
sx/sx/boundary.sx
Normal file
@@ -0,0 +1,56 @@
|
||||
;; SX docs service — page helper declarations.
|
||||
|
||||
(define-page-helper "highlight"
|
||||
:params (code lang)
|
||||
:returns "sx-source"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "primitives-data"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "special-forms-data"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "reference-data"
|
||||
:params (slug)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "attr-detail-data"
|
||||
:params (slug)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "header-detail-data"
|
||||
:params (slug)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "event-detail-data"
|
||||
:params (slug)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "read-spec-file"
|
||||
:params (filename)
|
||||
:returns "string"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "bootstrapper-data"
|
||||
:params (target)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "bundle-analyzer-data"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "routing-analyzer-data"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
(defcomp ~doc-oob-code (&key target-id text)
|
||||
(div :id target-id :sx-swap-oob "innerHTML"
|
||||
(div :class "bg-stone-100 rounded p-4 mt-3"
|
||||
(div :class "not-prose bg-stone-100 rounded p-4 mt-3"
|
||||
(pre :class "text-sm whitespace-pre-wrap break-words"
|
||||
(code text)))))
|
||||
|
||||
@@ -146,13 +146,13 @@
|
||||
forms))))
|
||||
|
||||
(defcomp ~doc-special-form-card (&key name syntax doc tail-position example)
|
||||
(div :class "border border-stone-200 rounded-lg p-4 space-y-3"
|
||||
(div :class "not-prose border border-stone-200 rounded-lg p-4 space-y-3"
|
||||
(div :class "flex items-baseline gap-3"
|
||||
(code :class "text-lg font-bold text-violet-700" name)
|
||||
(when (not (= tail-position "none"))
|
||||
(span :class "text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700" "TCO")))
|
||||
(when (not (= syntax ""))
|
||||
(pre :class "bg-stone-50 rounded px-3 py-2 text-sm font-mono text-stone-700 overflow-x-auto"
|
||||
(pre :class "bg-stone-100 rounded px-3 py-2 text-sm font-mono text-stone-700 overflow-x-auto"
|
||||
syntax))
|
||||
(p :class "text-stone-600 text-sm whitespace-pre-line" doc)
|
||||
(when (not (= tail-position ""))
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
(dict :label "Examples" :href "/examples/click-to-load")
|
||||
(dict :label "Essays" :href "/essays/")
|
||||
(dict :label "Specs" :href "/specs/")
|
||||
(dict :label "Bootstrappers" :href "/bootstrappers/"))))
|
||||
(dict :label "Bootstrappers" :href "/bootstrappers/")
|
||||
(dict :label "Isomorphism" :href "/isomorphism/")
|
||||
(dict :label "Plans" :href "/plans/"))))
|
||||
(<> (map (lambda (item)
|
||||
(~nav-link
|
||||
:href (get item "href")
|
||||
|
||||
@@ -99,7 +99,20 @@
|
||||
(dict :label "Boot" :href "/specs/boot")
|
||||
(dict :label "CSSX" :href "/specs/cssx")
|
||||
(dict :label "Continuations" :href "/specs/continuations")
|
||||
(dict :label "call/cc" :href "/specs/callcc")))
|
||||
(dict :label "call/cc" :href "/specs/callcc")
|
||||
(dict :label "Deps" :href "/specs/deps")
|
||||
(dict :label "Router" :href "/specs/router")))
|
||||
|
||||
(define isomorphism-nav-items (list
|
||||
(dict :label "Roadmap" :href "/isomorphism/")
|
||||
(dict :label "Bundle Analyzer" :href "/isomorphism/bundle-analyzer")
|
||||
(dict :label "Routing Analyzer" :href "/isomorphism/routing-analyzer")))
|
||||
|
||||
(define plans-nav-items (list
|
||||
(dict :label "Reader Macros" :href "/plans/reader-macros"
|
||||
:summary "Extensible parse-time transformations via # dispatch — datum comments, raw strings, and quote shorthand.")
|
||||
(dict :label "SX-Activity" :href "/plans/sx-activity"
|
||||
:summary "A new web built on SX — executable content, shared components, parsers, and logic on IPFS, provenance on Bitcoin, all running within your own security context.")))
|
||||
|
||||
(define bootstrappers-nav-items (list
|
||||
(dict :label "Overview" :href "/bootstrappers/")
|
||||
@@ -161,7 +174,15 @@
|
||||
:desc "Full first-class continuations — call-with-current-continuation."
|
||||
:prose "Full call/cc captures the entire remaining computation as a first-class function — not just up to a delimiter, but all the way to the top level. Invoking the continuation abandons the current computation entirely and resumes from where it was captured. Strictly more powerful than delimited continuations, but harder to implement in targets that don't support it natively. Recommended for Scheme and Haskell targets where it's natural. Python, JavaScript, and Rust targets should prefer delimited continuations (continuations.sx) unless full escape semantics are genuinely needed. Optional extension: the continuation type is shared with continuations.sx if both are loaded.")))
|
||||
|
||||
(define all-spec-items (concat core-spec-items (concat adapter-spec-items (concat browser-spec-items extension-spec-items))))
|
||||
(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.")
|
||||
(dict :slug "router" :filename "router.sx" :title "Router"
|
||||
:desc "Client-side route matching — Flask-style pattern parsing, segment matching, route table search."
|
||||
:prose "The router module provides pure functions for matching URL paths against Flask-style route patterns (e.g. /docs/<slug>). Used by client-side routing (Phase 3) to determine if a page can be rendered locally without a server roundtrip. split-path-segments breaks a path into segments, parse-route-pattern converts patterns into typed segment descriptors, match-route-segments tests a path against a parsed pattern returning extracted params, and find-matching-route searches a route table for the first match. No platform interface needed — uses only pure string and list primitives. Bootstrapped via --spec-modules deps,router.")))
|
||||
|
||||
(define all-spec-items (concat core-spec-items (concat adapter-spec-items (concat browser-spec-items (concat extension-spec-items module-spec-items)))))
|
||||
|
||||
(define find-spec
|
||||
(fn (slug)
|
||||
|
||||
1023
sx/sx/plans.sx
Normal file
1023
sx/sx/plans.sx
Normal file
File diff suppressed because it is too large
Load Diff
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)))))
|
||||
@@ -160,7 +160,7 @@
|
||||
|
||||
(div :class "space-y-3"
|
||||
(h2 :class "text-2xl font-semibold text-stone-800" "Dependency graph")
|
||||
(div :class "bg-stone-100 rounded-lg p-5 mx-auto max-w-3xl"
|
||||
(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"
|
||||
"parser.sx (standalone — no dependencies)
|
||||
primitives.sx (standalone — declarative registry)
|
||||
@@ -179,7 +179,11 @@ boot.sx depends on: cssx, orchestration, adapter-dom, render
|
||||
|
||||
;; Extensions (optional — loaded only when target requests them)
|
||||
continuations.sx depends on: eval (optional)
|
||||
callcc.sx depends on: eval (optional)")))
|
||||
callcc.sx depends on: eval (optional)
|
||||
|
||||
;; Spec modules (optional — loaded via --spec-modules)
|
||||
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")
|
||||
@@ -251,7 +255,7 @@ callcc.sx depends on: eval (optional)")))
|
||||
(p :class "text-stone-600" (get spec "desc"))
|
||||
(when (get spec "prose")
|
||||
(p :class "text-sm text-stone-500 leading-relaxed" (get spec "prose")))
|
||||
(div :class "bg-stone-100 rounded-lg p-5 max-h-72 overflow-y-auto"
|
||||
(div :class "not-prose bg-stone-100 rounded-lg p-5 max-h-72 overflow-y-auto"
|
||||
(pre :class "text-xs leading-relaxed whitespace-pre-wrap break-words"
|
||||
(code (highlight (get spec "source") "sx"))))))
|
||||
spec-files))))
|
||||
@@ -271,7 +275,7 @@ callcc.sx depends on: eval (optional)")))
|
||||
(p :class "text-xs text-stone-400 italic"
|
||||
"The s-expression source below is the canonical specification. "
|
||||
"The English description above is a summary.")))
|
||||
(div :class "bg-stone-100 rounded-lg p-5 mx-auto max-w-3xl"
|
||||
(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"))))))
|
||||
|
||||
@@ -347,7 +351,7 @@ callcc.sx depends on: eval (optional)")))
|
||||
" spec files (parser, eval, primitives, render, adapters, engine, orchestration, boot, cssx) "
|
||||
"and emits a standalone JavaScript file. Platform bridge functions (DOM operations, fetch, timers) "
|
||||
"are emitted as native JS implementations.")
|
||||
(div :class "bg-stone-100 rounded-lg p-5 max-h-96 overflow-y-auto border border-stone-200"
|
||||
(div :class "not-prose bg-stone-100 rounded-lg p-5 max-h-96 overflow-y-auto border border-stone-200"
|
||||
(pre :class "text-xs leading-relaxed whitespace-pre-wrap break-words"
|
||||
(code (highlight bootstrapper-source "python")))))
|
||||
|
||||
@@ -358,7 +362,7 @@ callcc.sx depends on: eval (optional)")))
|
||||
(p :class "text-sm text-stone-500"
|
||||
"The JavaScript below was generated by running the bootstrapper against the current spec files. "
|
||||
"It is a complete, self-contained SX runtime — parser, evaluator, DOM adapter, engine, and CSS system.")
|
||||
(div :class "bg-stone-100 rounded-lg p-5 max-h-96 overflow-y-auto border border-violet-300"
|
||||
(div :class "not-prose bg-stone-100 rounded-lg p-5 max-h-96 overflow-y-auto border border-violet-300"
|
||||
(pre :class "text-xs leading-relaxed whitespace-pre-wrap break-words"
|
||||
(code (highlight bootstrapped-output "javascript"))))))))
|
||||
|
||||
@@ -388,7 +392,7 @@ callcc.sx depends on: eval (optional)")))
|
||||
" spec files (eval, primitives, render, adapter-html) "
|
||||
"and emits a standalone Python module. Platform bridge functions (type constructors, environment ops) "
|
||||
"are emitted as native Python implementations.")
|
||||
(div :class "bg-stone-100 rounded-lg p-5 max-h-96 overflow-y-auto border border-stone-200"
|
||||
(div :class "not-prose bg-stone-100 rounded-lg p-5 max-h-96 overflow-y-auto border border-stone-200"
|
||||
(pre :class "text-xs leading-relaxed whitespace-pre-wrap break-words"
|
||||
(code (highlight bootstrapper-source "python")))))
|
||||
|
||||
@@ -399,7 +403,7 @@ callcc.sx depends on: eval (optional)")))
|
||||
(p :class "text-sm text-stone-500"
|
||||
"The Python below was generated by running the bootstrapper against the current spec files. "
|
||||
"It is a complete server-side SX evaluator — eval, primitives, and HTML renderer.")
|
||||
(div :class "bg-stone-100 rounded-lg p-5 max-h-96 overflow-y-auto border border-violet-300"
|
||||
(div :class "not-prose bg-stone-100 rounded-lg p-5 max-h-96 overflow-y-auto border border-violet-300"
|
||||
(pre :class "text-xs leading-relaxed whitespace-pre-wrap break-words"
|
||||
(code (highlight bootstrapped-output "python"))))))))
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
children))
|
||||
|
||||
(defcomp ~doc-code (&key code)
|
||||
(div :class "bg-stone-100 rounded-lg p-5 mx-auto max-w-3xl"
|
||||
(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 code))))
|
||||
|
||||
(defcomp ~doc-note (&key &rest children)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
(div :class "border border-dashed border-stone-300 rounded p-4 bg-stone-100" children))
|
||||
|
||||
(defcomp ~example-source (&key code)
|
||||
(div :class "bg-stone-100 rounded p-5 mt-3 mx-auto max-w-3xl"
|
||||
(div :class "not-prose bg-stone-100 rounded p-5 mt-3 mx-auto max-w-3xl"
|
||||
(pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words" (code code))))
|
||||
|
||||
;; --- Click to load demo ---
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
(p :class "text-sm text-stone-400"
|
||||
"© Giles Bradshaw 2026")
|
||||
(p :class "text-lg text-stone-500 max-w-2xl mx-auto mb-12"
|
||||
"A hypermedia-driven UI engine that combines htmx's server-first philosophy "
|
||||
"with React's component model. S-expressions over the wire — no HTML, no JavaScript frameworks.")
|
||||
"(sx === code === data === protocol === content === behaviour === layout === style === spec === sx)")
|
||||
(div :class "bg-stone-100 rounded-lg p-6 text-left font-mono text-sm mx-auto max-w-2xl"
|
||||
(pre :class "leading-relaxed whitespace-pre-wrap" children))))
|
||||
|
||||
|
||||
@@ -386,3 +386,95 @@
|
||||
(~bootstrapper-js-content
|
||||
:bootstrapper-source bootstrapper-source
|
||||
:bootstrapped-output bootstrapped-output))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Isomorphism section
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defpage isomorphism-index
|
||||
:path "/isomorphism/"
|
||||
:auth :public
|
||||
:layout (:sx-section
|
||||
:section "Isomorphism"
|
||||
:sub-label "Isomorphism"
|
||||
:sub-href "/isomorphism/"
|
||||
:sub-nav (~section-nav :items isomorphism-nav-items :current "Roadmap")
|
||||
:selected "Roadmap")
|
||||
:content (~plan-isomorphic-content))
|
||||
|
||||
(defpage isomorphism-page
|
||||
:path "/isomorphism/<slug>"
|
||||
:auth :public
|
||||
:layout (:sx-section
|
||||
:section "Isomorphism"
|
||||
:sub-label "Isomorphism"
|
||||
:sub-href "/isomorphism/"
|
||||
:sub-nav (~section-nav :items isomorphism-nav-items
|
||||
:current (find-current isomorphism-nav-items slug))
|
||||
:selected (or (find-current isomorphism-nav-items slug) ""))
|
||||
:content (case slug
|
||||
"bundle-analyzer" (~bundle-analyzer-content
|
||||
:pages pages :total-components total-components :total-macros total-macros
|
||||
:pure-count pure-count :io-count io-count)
|
||||
"routing-analyzer" (~routing-analyzer-content
|
||||
:pages pages :total-pages total-pages :client-count client-count
|
||||
:server-count server-count :registry-sample registry-sample)
|
||||
:else (~plan-isomorphic-content)))
|
||||
|
||||
(defpage bundle-analyzer
|
||||
:path "/isomorphism/bundle-analyzer"
|
||||
:auth :public
|
||||
: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))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Plans section
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defpage plans-index
|
||||
:path "/plans/"
|
||||
:auth :public
|
||||
:layout (:sx-section
|
||||
:section "Plans"
|
||||
:sub-label "Plans"
|
||||
:sub-href "/plans/"
|
||||
:sub-nav (~section-nav :items plans-nav-items :current "")
|
||||
:selected "")
|
||||
:content (~plans-index-content))
|
||||
|
||||
(defpage plan-page
|
||||
:path "/plans/<slug>"
|
||||
:auth :public
|
||||
:layout (:sx-section
|
||||
:section "Plans"
|
||||
:sub-label "Plans"
|
||||
:sub-href "/plans/"
|
||||
:sub-nav (~section-nav :items plans-nav-items
|
||||
:current (find-current plans-nav-items slug))
|
||||
:selected (or (find-current plans-nav-items slug) ""))
|
||||
:content (case slug
|
||||
"reader-macros" (~plan-reader-macros-content)
|
||||
"sx-activity" (~plan-sx-activity-content)
|
||||
:else (~plans-index-content)))
|
||||
|
||||
@@ -21,6 +21,8 @@ def _register_sx_helpers() -> None:
|
||||
"event-detail-data": _event_detail_data,
|
||||
"read-spec-file": _read_spec_file,
|
||||
"bootstrapper-data": _bootstrapper_data,
|
||||
"bundle-analyzer-data": _bundle_analyzer_data,
|
||||
"routing-analyzer-data": _routing_analyzer_data,
|
||||
})
|
||||
|
||||
|
||||
@@ -265,6 +267,158 @@ def _bootstrapper_data(target: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _bundle_analyzer_data() -> dict:
|
||||
"""Compute per-page component bundle analysis for the sx-docs app."""
|
||||
from shared.sx.jinja_bridge import get_component_env
|
||||
from shared.sx.pages import get_all_pages
|
||||
from shared.sx.deps import components_needed, scan_components_from_sx
|
||||
from shared.sx.parser import serialize
|
||||
from shared.sx.types import Component, Macro
|
||||
|
||||
env = get_component_env()
|
||||
total_components = sum(1 for v in env.values() if isinstance(v, Component))
|
||||
total_macros = sum(1 for v in env.values() if isinstance(v, Macro))
|
||||
pure_count = sum(1 for v in env.values() if isinstance(v, Component) and v.is_pure)
|
||||
io_count = total_components - pure_count
|
||||
|
||||
pages_data = []
|
||||
for name, page_def in sorted(get_all_pages("sx").items()):
|
||||
content_sx = serialize(page_def.content_expr)
|
||||
direct = scan_components_from_sx(content_sx)
|
||||
needed = components_needed(content_sx, env)
|
||||
n = len(needed)
|
||||
pct = round(n / total_components * 100) if total_components else 0
|
||||
savings = 100 - pct
|
||||
|
||||
# IO classification + component details for this page
|
||||
pure_in_page = 0
|
||||
io_in_page = 0
|
||||
page_io_refs: set[str] = set()
|
||||
comp_details = []
|
||||
for comp_name in sorted(needed):
|
||||
val = env.get(comp_name)
|
||||
if isinstance(val, Component):
|
||||
is_pure = val.is_pure
|
||||
if is_pure:
|
||||
pure_in_page += 1
|
||||
else:
|
||||
io_in_page += 1
|
||||
page_io_refs.update(val.io_refs)
|
||||
# Reconstruct defcomp source
|
||||
param_strs = ["&key"] + list(val.params)
|
||||
if val.has_children:
|
||||
param_strs.extend(["&rest", "children"])
|
||||
params_sx = "(" + " ".join(param_strs) + ")"
|
||||
body_sx = serialize(val.body, pretty=True)
|
||||
source = f"(defcomp ~{val.name} {params_sx}\n {body_sx})"
|
||||
comp_details.append({
|
||||
"name": comp_name,
|
||||
"is-pure": is_pure,
|
||||
"io-refs": sorted(val.io_refs),
|
||||
"deps": sorted(val.deps),
|
||||
"source": source,
|
||||
})
|
||||
|
||||
pages_data.append({
|
||||
"name": name,
|
||||
"path": page_def.path,
|
||||
"direct": len(direct),
|
||||
"needed": n,
|
||||
"pct": pct,
|
||||
"savings": savings,
|
||||
"io-refs": len(page_io_refs),
|
||||
"pure-in-page": pure_in_page,
|
||||
"io-in-page": io_in_page,
|
||||
"components": comp_details,
|
||||
})
|
||||
|
||||
pages_data.sort(key=lambda p: p["needed"], reverse=True)
|
||||
|
||||
return {
|
||||
"pages": pages_data,
|
||||
"total-components": total_components,
|
||||
"total-macros": total_macros,
|
||||
"pure-count": pure_count,
|
||||
"io-count": io_count,
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user