30 Commits

Author SHA1 Message Date
3749fe9625 Fix bootstrapper dict literal transpilation: emit values through emit()
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m51s
The SX parser produces native Python dicts for {:key val} syntax, but
both JSEmitter and PyEmitter had no dict case in emit() — falling through
to str(expr) which output raw AST. This broke client-side routing because
process-page-scripts used {"parsed" (parse-route-pattern ...)} and the
function call was emitted as a JS array of Symbols instead of an actual
function call.

Add _emit_native_dict() to both bootstrappers + 8 unit tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:24:44 +00:00
dd1c1c9a3c Add routing-analyzer-data to boundary declarations
Missing declaration caused SX_BOUNDARY_STRICT crash on startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:12:43 +00:00
cf5e767510 Phase 3: Client-side routing with SX page registry + routing analyzer demo
Add client-side route matching so pure pages (no IO deps) can render
instantly without a server roundtrip. Page metadata serialized as SX
dict literals (not JSON) in <script type="text/sx-pages"> blocks.

- New router.sx spec: route pattern parsing and matching (6 pure functions)
- boot.sx: process page registry using SX parser at startup
- orchestration.sx: intercept boost links for client routing with
  try-first/fallback — client attempts local eval, falls back to server
- helpers.py: _build_pages_sx() serializes defpage metadata as SX
- Routing analyzer demo page showing per-page client/server classification
- 32 tests for Phase 2 IO detection (scan_io_refs, transitive_io_refs,
  compute_all_io_refs, component_pure?) + fallback/ref parity
- 37 tests for Phase 3 router functions + page registry serialization
- Fix bootstrap_py.py _emit_let cell variable initialization bug
- Fix missing primitive aliases (split, length, merge) in bootstrap_py.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:47:56 +00:00
631394989c Add not-prose to all code blocks to enforce stone-100 background
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m32s
Tailwind's prose class applies dark backgrounds to pre/code elements,
overriding the intended bg-stone-100. Adding not-prose to every code
container div across docs, specs, and examples pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:35:49 +00:00
a0e39f0014 Fix bundle analyzer source display: override prose styling + add syntax highlighting
- Add not-prose class to escape Tailwind typography dark pre/code backgrounds
- Use (highlight source "lisp") for syntax-highlighted component source
- Add missing bg-blue-500 bg-amber-500 to @css annotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:30:18 +00:00
55adbf6463 Fix bundle analyzer source readability: white bg, darker text
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:03:52 +00:00
fbfd203746 Bundle analyzer: drill-down component tree with SX source viewer
Click a page row to expand its component bundle tree. Each component
shows pure/IO badge, IO refs, dep count. Click a component to expand
its full defcomp SX source. Uses <details>/<summary> for zero-JS
expand/collapse.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:59:55 +00:00
65ed8a8941 Replace tagline with the sx identity cycle
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:43:39 +00:00
54814b4258 Update deps spec description and isomorphism roadmap for Phase 2
- deps.sx spec description now covers both Phase 1 (bundling) and Phase 2
  (IO detection, pure/IO classification, host obligation for selective
  expansion)
- Isomorphism roadmap context updated: boundary slides automatically
  based on IO detection, not future tense
- Current State section adds dependency analysis and IO detection bullets
- Phase 1 spec module note updated: 14 functions, 8 platform declarations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:38:21 +00:00
3482cbdaa6 Document host obligation for selective expansion in deps.sx
The spec classifies components as pure vs IO-dependent. Each host's
async partial evaluator must act on this: expand IO-dependent server-
side, serialize pure for client. This is host infrastructure, not SX
semantics — documented as a contract in the spec.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:34:09 +00:00
0ba7ebe349 Phase 2: IO detection & selective expansion in deps.sx
Extend the spec with IO scanning functions (scan-io-refs, transitive-io-refs,
compute-all-io-refs, component-pure?) that detect IO primitive references in
component ASTs. Components are classified as pure (no IO deps, safe for client
rendering) or IO-dependent (must expand server-side).

The partial evaluator (_aser) now uses per-component IO metadata instead of
the global _expand_components toggle: IO-dependent components expand server-
side, pure components serialize for client. Layout slot context still expands
all components for backwards compat.

Spec: 5 new functions + 2 platform interface additions in deps.sx
Host: io_refs field + is_pure property on Component, compute_all_io_refs()
Bootstrap: both sx_ref.py and sx-ref.js updated with IO functions
Bundle analyzer: shows pure/IO classification per page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:19:17 +00:00
652e7f81c8 Add Isomorphism as top-level section in sx-docs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m34s
Move isomorphic architecture roadmap and bundle analyzer from Plans
into their own top-level "Isomorphism" section. The roadmap is the
default page at /isomorphism/, bundle analyzer at /isomorphism/bundle-analyzer.

Plans section retains reader macros and SX-Activity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:57:17 +00:00
8ff9827d7b Skip boundary.sx in component loader
boundary.sx files use define-page-helper which isn't an SX eval form —
they're parsed by boundary_parser.py. Exclude them from load_sx_dir()
to prevent EvalError on startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:51:49 +00:00
07a73821e7 Fix boundary parser Docker path: handle /app/sx/boundary.sx layout
In Docker, each service's sx/ dir is copied directly to /app/sx/,
not /app/{service}/sx/. Add fallback search for /app/sx/boundary.sx
alongside the dev glob pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:47:50 +00:00
44d5414bc6 Split boundary.sx: separate language contract from app-specific declarations
boundary.sx was mixing three concerns in one file:
- Core SX I/O primitives (the language contract)
- Deployment-specific layout I/O (app architecture)
- Per-service page helpers (fully app-specific)

Now split into three tiers:
1. shared/sx/ref/boundary.sx — core I/O only (frag, query, current-user, etc.)
2. shared/sx/ref/boundary-app.sx — deployment layout contexts (*-header-ctx, *-ctx)
3. {service}/sx/boundary.sx — per-service page helpers

The boundary parser loads all three tiers automatically. Validation error
messages now point to the correct file for each tier.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:41:38 +00:00
a90c8bf3fc Fix: use len (not count) in analyzer.sx — matches primitives.sx
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:36:13 +00:00
a06400370a Fix: use count instead of length in analyzer.sx
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:33:04 +00:00
0191948b6e Declare bundle-analyzer-data page helper in boundary.sx
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:28:32 +00:00
9ac1d273e2 Rewrite Phase 1 plan: express in SX terms, not Python
Remove Python-specific references (deps.py, sx_ref.py, bootstrap_py.py,
test_deps.py). Phase 1 is about deps.sx the spec module — hosts are
interchangeable. Show SX code examples, describe platform interface
abstractly, link to live bundle analyzer for proof.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:26:22 +00:00
e36a036873 Add live bundle analyzer page to sx-docs
Demonstrates Phase 1 dep analysis in action: computes per-page component
bundles for all sx-docs pages using the deps.sx transitive closure
algorithm, showing needed vs total components with visual progress bars.

- New page at /plans/bundle-analyzer with Python data helper
- New components: ~bundle-analyzer-content, ~analyzer-stat, ~analyzer-row
- Linked from Phase 1 section and Plans nav
- Added sx/sx/ to tailwind content paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:23:58 +00:00
d6ca185975 Update sx-docs: add deps spec to viewer, mark Phase 1 complete
Add deps.sx to the spec navigator in sx-docs (nav-data, specs page).
Update isomorphic architecture plan to show Phase 1 as complete with
link to the canonical spec at /specs/deps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:10:52 +00:00
0ebf3c27fd Enable bootstrapped SX evaluator in production
Add SX_USE_REF=1 to production docker-compose.yml so all services
use the spec-bootstrapped evaluator, renderer, and deps analysis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:05:24 +00:00
4c97b03dda Wire deps.sx into both bootstrappers, rebootstrap Python + JS
deps.sx is now a spec module that both bootstrap_py.py and bootstrap_js.py
can include via --spec-modules deps. Platform functions (component-deps,
component-set-deps!, component-css-classes, env-components, regex-find-all,
scan-css-classes) implemented natively in both Python and JS.

- Fix deps.sx: env-get-or → env-get, extract nested define to top-level
- bootstrap_py.py: SPEC_MODULES, PLATFORM_DEPS_PY, mangle entries, CLI arg
- bootstrap_js.py: SPEC_MODULES, PLATFORM_DEPS_JS, mangle entries, CLI arg
- Regenerate sx_ref.py and sx-ref.js with deps module
- deps.py: thin dispatcher (SX_USE_REF=1 → bootstrapped, else fallback)
- scan_components_from_sx now returns ~prefixed names (consistent with spec)

Verified: 541 Python tests pass, JS deps tested with Node.js, both code
paths (fallback + bootstrapped) produce identical results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:55:32 +00:00
6739343a06 Add deps.sx spec: component dependency analysis
Canonical specification for per-page component bundling. Pure functions
for AST scanning, transitive closure, page bundle computation, and
per-page CSS class collection. deps.py becomes a thin host wrapper;
future hosts (Go, Rust, Haskell, etc.) bootstrap from this spec.

Defines 8 functions: scan-refs, scan-refs-walk, transitive-deps,
compute-all-deps, scan-components-from-source, components-needed,
page-component-bundle, page-css-classes.

Platform interface: component-body, component-name, component-deps,
component-set-deps!, component-css-classes, macro-body, env-components,
regex-find-all, scan-css-classes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:31:43 +00:00
2866bcbfc3 Implement isomorphic Phase 1: per-page component bundling
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m43s
Add component dependency analyzer (shared/sx/deps.py) that walks
component AST bodies to compute transitive dependency sets. sx_page()
and sx_response() now send only the components each page needs instead
of the entire registry.

Changes:
- New: shared/sx/deps.py — transitive_deps(), components_needed(),
  scan_components_from_sx(), compute_all_deps()
- shared/sx/types.py — Add deps: set[str] field to Component
- shared/sx/jinja_bridge.py — Compute deps on registration, add
  components_for_page() and css_classes_for_page()
- shared/sx/helpers.py — sx_page() uses per-page bundle + hash,
  sx_response() passes source to components_for_request() for
  page-scoped component diffing
- New: shared/sx/tests/test_deps.py — 15 tests covering AST scanning,
  transitive deps, circular refs, per-page bundling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:19:10 +00:00
1fe53c2032 Add serverless IPFS applications to SX-Activity plan
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m15s
Entire web applications as content-addressed SX on IPFS — no server,
no DNS, no hosting, no deployment pipeline. Server becomes an optional
IO provider, not an application host. The application is the content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:19:05 +00:00
59a8d2063d Expand SX-Activity plan: the evaluable web
Reframe Phase 6 from "ActivityPub but SX" to the full vision: a new web
where content, components, parsers, transforms, server/client logic, and
media all share one executable format on IPFS, sandboxed by boundary
enforcement, with Bitcoin-anchored provenance. Updated context section
and nav summary to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:17:21 +00:00
624b08997d Add Reader Macros and SX-Activity plans to SX docs
Reader Macros: # dispatch for datum comments (#;), raw strings (#|...|),
and quote shorthand (#').

SX-Activity: ActivityPub federation with SX wire format, IPFS-backed
component registry, content-addressed media, Bitcoin-anchored provenance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:12:36 +00:00
e112bffe5c Add index-of string primitive: spec, Python, JS, rebootstrap
(index-of s needle from?) returns first index of needle in s, or -1.
Optional start offset. Specced in primitives.sx, implemented in both
hand-written primitives.py and bootstrapper templates, rebootstrapped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:48:44 +00:00
e6cada972e Add Plans section to SX docs with isomorphic architecture roadmap
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m38s
New top-level nav section at /plans/ with the 6-phase isomorphic
architecture plan: component distribution, smart boundary, SPA routing,
client IO bridge, streaming suspense, and full isomorphism.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:21:24 +00:00
44 changed files with 5495 additions and 825 deletions

41
blog/sx/boundary.sx Normal file
View 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")

View File

@@ -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:

View File

@@ -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
View 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
View 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")

View File

@@ -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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
};
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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#x27;");
};
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)"
};

View File

@@ -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)"
};

View File

@@ -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',

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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,

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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:

View File

@@ -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__":

View 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)

View File

@@ -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
;; --------------------------------------------------------------------------

View File

@@ -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
View 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
;; --------------------------------------------------------------------------

View File

@@ -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

View File

@@ -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
View 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, =
;; --------------------------------------------------------------------------

View File

@@ -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
# =========================================================================

View 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

View 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

View 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")))',
)

View 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("") == '""'

View File

@@ -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)})>"

View File

@@ -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
View 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
View 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")

View File

@@ -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 ""))

View File

@@ -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")

View File

@@ -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

File diff suppressed because it is too large Load Diff

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

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

View File

@@ -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"))))))))

View File

@@ -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)

View File

@@ -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 ---

View File

@@ -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))))

View File

@@ -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)))

View File

@@ -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.