96 Commits

Author SHA1 Message Date
3ab26635ce Merge branch 'worktree-iso-phase-4' into macros
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
2026-03-07 11:06:09 +00:00
9b3b2ea224 Add testing section to Strange Loops essay
SX testing SX is the strange loop made concrete — the language proves
its own correctness using its own macros. Links to /specs/testing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:06:06 +00:00
3a12368c9d Merge branch 'worktree-iso-phase-4' into macros 2026-03-07 11:03:48 +00:00
bec881acb3 Fix asset-url: use Jinja global instead of nonexistent urls.asset_url
The IO handler and bridge both imported asset_url from
shared.infrastructure.urls, but it doesn't exist there — it's a Jinja
global defined in jinja_setup.py. Use current_app.jinja_env.globals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:03:46 +00:00
e89c496dc8 Merge branch 'worktree-iso-phase-4' into macros 2026-03-07 11:00:51 +00:00
7eb158c79f Add live browser test runner to /specs/testing page
sx-browser.js evaluates test.sx directly in the browser — click
"Run 81 tests" to see SX test itself. Uses the same Sx global that
rendered the page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:00:37 +00:00
e9d86d628b Make test.sx self-executing: evaluators run it directly, no codegen
test.sx now defines deftest/defsuite as macros. Any host that provides
5 platform functions (try-call, report-pass, report-fail, push-suite,
pop-suite) can evaluate the file directly — no bootstrap compilation
step needed for JS.

- Added defmacro for deftest (wraps body in thunk, catches via try-call)
- Added defmacro for defsuite (push/pop suite context stack)
- Created run.js: sx-browser.js evaluates test.sx directly (81/81 pass)
- Created run.py: Python evaluator evaluates test.sx directly (81/81 pass)
- Deleted bootstrap_test_js.py and generated test_sx_spec.js
- Updated testing docs page to reflect self-executing architecture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:50:28 +00:00
754e7557f5 Add self-hosting SX test spec: 81 tests bootstrap to Python + JS
The test framework is written in SX and tests SX — the language proves
its own correctness. test.sx defines assertion helpers (assert-equal,
assert-true, assert-type, etc.) and 15 test suites covering literals,
arithmetic, comparison, strings, lists, dicts, predicates, special forms,
lambdas, higher-order forms, components, macros, threading, truthiness,
and edge cases.

Two bootstrap compilers emit native tests from the same spec:
- bootstrap_test.py → pytest (81/81 pass)
- bootstrap_test_js.py → Node.js TAP using sx-browser.js (81/81 pass)

Also adds missing primitives to spec and Python evaluator: boolean?,
string-length, substring, string-contains?, upcase, downcase, reverse,
flatten, has-key?. Fixes number? to exclude booleans, append to
concatenate lists.

Includes testing docs page in SX app at /specs/testing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:41:53 +00:00
f674a5edcc Merge branch 'worktree-iso-phase-4' into macros 2026-03-07 10:06:00 +00:00
e09bc3b601 Fix test_sx_js: temp file for large scripts, globalThis for Node file mode
sx-browser.js grew past OS arg length limit for node -e. Write to
temp file instead. Also fix Sx global scope: Node file mode sets
`this` to module.exports, not globalThis, so the IIFE wrapper needs
.call(globalThis) to make Sx accessible to sx-test.js.

855 passed (2 pre-existing empty .sx file failures).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:06:00 +00:00
43f2547de8 Merge branch 'worktree-iso-phase-4' into macros 2026-03-07 10:01:35 +00:00
8366088ee1 Add Phase 5 unit tests: IO proxy, io-deps registry, SxExpr roundtrip
22 tests covering:
- io-deps page registry field (pure, IO, transitive, serialization)
- Dynamic IO allowlist construction from component io_refs
- SxExpr serialize→parse roundtrip (unquoted, fragments, nil, in-dict)
- IO proxy arg parsing (GET query string vs POST JSON body)
- Orchestration routing logic (io-deps truthiness, parsed entries)
- IO cache key determinism

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:01:35 +00:00
fd20811afa Merge branch 'worktree-iso-phase-4' into macros 2026-03-07 09:51:51 +00:00
84ea5d4c16 IO proxy: client-side cache with 5min TTL, server Cache-Control
Client caches IO results by (name + args) in memory. In-flight
promises are cached too (dedup concurrent calls for same args).
Server adds Cache-Control: public, max-age=300 for HTTP caching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:51:51 +00:00
51990d9445 Merge branch 'worktree-iso-phase-4' into macros 2026-03-07 09:50:23 +00:00
0d6b959045 Mark IO proxy endpoint as CSRF-exempt (read-only, no state mutation)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:50:23 +00:00
847d5d1f31 Merge branch 'worktree-iso-phase-4' into macros 2026-03-07 09:40:24 +00:00
ff2ef29d8a Fix async map: use Lambda.params/body/closure (not _params/_body/_closure)
The Lambda constructor stores properties without underscore prefix,
but asyncRenderMap/asyncRenderMapIndexed accessed them with underscores.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:40:23 +00:00
ab27491157 Merge branch 'worktree-iso-phase-4' into macros 2026-03-07 09:23:24 +00:00
aa67b036c7 IO proxy: POST for long payloads, network error resilience
- Switch to POST with JSON body when query string exceeds 1500 chars
  (highlight calls with large component sources hit URL length limits)
- Include CSRF token header on POST requests
- Add .catch() on fetch to gracefully handle network errors (return NIL)
- Upgrade async eval miss logs from logInfo to logWarn for visibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:23:20 +00:00
9ac90a787d Merge branch 'worktree-iso-phase-4' into macros 2026-03-07 09:14:07 +00:00
cb0990feb3 Dynamic IO proxy: derive proxied primitives from component io_refs
Replace hardcoded IO primitive lists on both client and server with
data-driven registration. Page registry entries carry :io-deps (list
of IO primitive names) instead of :has-io boolean. Client registers
proxied IO on demand per page via registerIoDeps(). Server builds
allowlist from component analysis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:13:53 +00:00
8c89311182 Essay: make clear the entire site was built by agentic AI, no editor, no Lisp experience
Rewrote the closing sections to state plainly: every spec file, bootstrapper,
component, page, and deployment was produced through Claude in a terminal.
No VS Code, no vi, no prior Lisp. The proof that SX is AI-amenable is
that this site exists.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:10:45 +00:00
a745de7e35 New essay: SX and AI — why s-expressions are ideal for AI code generation
Covers syntax tax (zero for s-expressions), uniform representation,
spec fits in context window, trivial structural validation, self-documenting
components, token efficiency (~40% fewer than JSX), free composability,
and the instant feedback loop with no build step.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:04:51 +00:00
a5f5373a63 Merge branch 'worktree-iso-phase-4' into macros 2026-03-07 08:48:51 +00:00
c2a85ed026 Fix async IO demo: use ~doc-code instead of raw!, fix JS highlight
highlight returns SxExpr (SX source with colored spans), not raw HTML.
Must render via evaluator (~doc-code :code), not (raw! ...). Also
replace JavaScript example with SX (no JS highlighter exists).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 08:48:48 +00:00
69ced865db Merge branch 'worktree-iso-phase-1' into macros
# Conflicts:
#	shared/sx/helpers.py
#	shared/sx/pages.py
#	sx/sx/nav-data.sx
#	sx/sx/plans.sx
#	sx/sxc/pages/docs.sx
2026-03-07 08:38:32 +00:00
2b0a45b337 Fix code block rendering: escape newlines/tabs in syntax highlighter output
highlight_sx/python/bash produced SX string literals with literal newline
and tab characters, breaking the wire format parser. Add centralized
_escape() helper that properly escapes \n, \t, \r (plus existing \\ and
" escaping). Code blocks now render with correct indentation and syntax
highlighting in both server and client renders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 08:35:33 +00:00
feb368f7fb Add plans audit: status overview + fragment protocol, glue decoupling, social sharing pages
Audit all plan files and create documentation pages for what remains:
- Status overview with green/amber/stone badges for all 15 plans
- Fragment Protocol: what exists (GET), what remains (POST sexp, structured response)
- Glue Decoupling: 25+ cross-app imports to eliminate via glue service layer
- Social Sharing: 6-phase OAuth-based sharing to major platforms

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 08:35:27 +00:00
6215d3573b Send content expression component deps in SX responses for client routing
When a page has a content expression but no data dependency, compute its
transitive component deps and pass them as extra_component_names to
sx_response(). This ensures the client has all component definitions
needed for future client-side route rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 08:35:20 +00:00
79fa1411dc Phase 5: async IO rendering — components call IO primitives client-side
Wire async rendering into client-side routing: pages whose component
trees reference IO primitives (highlight, current-user, etc.) now
render client-side via Promise-aware asyncRenderToDom. IO calls proxy
through /sx/io/<name> endpoint, which falls back to page helpers.

- Add has-io flag to page registry entries (helpers.py)
- Remove IO purity filter — include IO-dependent components in bundles
- Extend try-client-route with 4 paths: pure, data, IO, data+IO
- Convert tryAsyncEvalContent to callback style, add platform mapping
- IO proxy falls back to page helpers (highlight works via proxy)
- Demo page: /isomorphism/async-io with inline highlight calls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 08:12:42 +00:00
04ff03f5d4 Live-read all DOM attributes: forms and preloads too
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m49s
bindBoostForm re-reads method/action at submit time.
bind-preload-for re-reads verb-info and headers at preload time.
No closed-over stale values anywhere in the event binding system.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:11:09 +00:00
b85a46bb62 Re-read element attributes at click time, not from closed-over bind values
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m39s
All click handlers (bind-event, bindBoostLink, bindClientRouteClick)
now re-read href/verb-info from the DOM element when the click fires,
instead of using values captured at bind time. This ensures correct
behavior when DOM is replaced or attributes are morphed after binding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:06:21 +00:00
09d06a4c87 Filter data page deps by IO purity: only bundle pure component trees
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m43s
Pages whose component trees reference IO primitives (e.g. highlight)
cannot render client-side, so exclude their deps from the client bundle.
This prevents "Undefined symbol: highlight" errors on pages like
bundle-analyzer while still allowing pure data pages like data-test
to render client-side with caching.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:49:27 +00:00
0385be0a0d Fix /results polling: use sx_response() for SX wire format
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m56s
The /results endpoint returns SX wire format but was sending it with
content-type text/html. The SX engine couldn't process it, so raw
s-expressions appeared as text and the browser tried to resolve
quoted strings like "a.jpg" as URLs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:39:55 +00:00
1e52bb33a6 Fix test dashboard: return SX wire format for SX-Request on index route
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m43s
Filter card links (/?filter=failed) were blanking the page because the
index route always returned full HTML, even for SX-Request. Now returns
sx_response() partial like test_detail already does.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:55:23 +00:00
a8e61dd0ea Merge specced eval-cond/process-bindings from render.sx
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m18s
2026-03-06 16:58:58 +00:00
20ac0fe948 Spec eval-cond and process-bindings in render.sx (remove platform implementations)
eval-cond and process-bindings were hand-written platform JS in
bootstrap_js.py rather than specced in .sx files. This violated the
SX host architecture principle. Now specced in render.sx as shared
render adapter helpers, bootstrapped to both JS and Python.

eval-cond handles both scheme-style ((test body) ...) and clojure-style
(test body test body ...) cond clauses. Returns unevaluated body
expression for the adapter to render in its own mode.

process-bindings evaluates let-binding pairs and returns extended env.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:58:53 +00:00
2aa0f1d010 Merge evalCond scheme-style fix
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m15s
2026-03-06 16:49:51 +00:00
a2d0a8a0fa Fix evalCond in HTML/DOM renderers: handle scheme-style cond clauses
The platform evalCond helper (used by render-to-html and render-to-dom)
only handled clojure-style (test body test body ...) but components use
scheme-style ((test body) (test body) ...). This caused "Not callable:
true" errors when rendering cond with nested clause pairs, breaking the
test dashboard and any page using scheme-style cond.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:49:42 +00:00
b8d3e46a9b Fix rose-ash test Dockerfile: copy sxc directory
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m28s
The test app's bp/dashboard/routes.py imports from sxc.pages.renders
but the Dockerfile wasn't copying the sxc directory into the image.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:37:52 +00:00
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
46 changed files with 8947 additions and 553 deletions

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,96 @@
// sx-test-runner.js — Run test.sx in the browser using sx-browser.js.
// Loaded on the /specs/testing page. Uses the Sx global.
(function() {
var NIL = Sx.NIL;
function isNil(x) { return x === NIL || x === null || x === undefined; }
function deepEqual(a, b) {
if (a === b) return true;
if (isNil(a) && isNil(b)) return true;
if (typeof a !== typeof b) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (var i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false;
return true;
}
if (a && typeof a === "object" && b && typeof b === "object") {
var ka = Object.keys(a), kb = Object.keys(b);
if (ka.length !== kb.length) return false;
for (var j = 0; j < ka.length; j++) if (!deepEqual(a[ka[j]], b[ka[j]])) return false;
return true;
}
return false;
}
window.sxRunTests = function(srcId, outId, btnId) {
var src = document.getElementById(srcId).textContent;
var out = document.getElementById(outId);
var btn = document.getElementById(btnId);
var stack = [], passed = 0, failed = 0, num = 0, lines = [];
var env = {
"try-call": function(thunk) {
try {
Sx.eval([thunk], env);
return { ok: true };
} catch(e) {
return { ok: false, error: e.message || String(e) };
}
},
"report-pass": function(name) {
num++; passed++;
lines.push("ok " + num + " - " + stack.concat([name]).join(" > "));
},
"report-fail": function(name, error) {
num++; failed++;
lines.push("not ok " + num + " - " + stack.concat([name]).join(" > "));
lines.push(" # " + error);
},
"push-suite": function(name) { stack.push(name); },
"pop-suite": function() { stack.pop(); },
"equal?": function(a, b) { return deepEqual(a, b); },
"eq?": function(a, b) { return a === b; },
"boolean?": function(x) { return typeof x === "boolean"; },
"string-length": function(s) { return String(s).length; },
"substring": function(s, start, end) { return String(s).slice(start, end); },
"string-contains?": function(s, n) { return String(s).indexOf(n) !== -1; },
"upcase": function(s) { return String(s).toUpperCase(); },
"downcase": function(s) { return String(s).toLowerCase(); },
"reverse": function(c) { return c ? c.slice().reverse() : []; },
"flatten": function(c) {
var r = [];
for (var i = 0; i < (c||[]).length; i++) {
if (Array.isArray(c[i])) for (var j = 0; j < c[i].length; j++) r.push(c[i][j]);
else r.push(c[i]);
}
return r;
},
"has-key?": function(d, k) { return d && typeof d === "object" && k in d; },
"append": function(c, x) { return Array.isArray(x) ? (c||[]).concat(x) : (c||[]).concat([x]); },
};
try {
var t0 = performance.now();
var exprs = Sx.parseAll(src);
for (var i = 0; i < exprs.length; i++) Sx.eval(exprs[i], env);
var elapsed = Math.round(performance.now() - t0);
lines.push("");
lines.push("1.." + num);
lines.push("# tests " + (passed + failed));
lines.push("# pass " + passed);
if (failed > 0) lines.push("# fail " + failed);
lines.push("# time " + elapsed + "ms");
} catch(e) {
lines.push("");
lines.push("FATAL: " + (e.message || String(e)));
}
out.textContent = lines.join("\n");
out.style.display = "block";
btn.textContent = passed + "/" + (passed + failed) + " passed" + (failed === 0 ? "" : " (" + failed + " failed)");
btn.className = failed > 0
? "px-4 py-2 rounded-md bg-red-600 text-white font-medium text-sm cursor-default"
: "px-4 py-2 rounded-md bg-green-600 text-white font-medium text-sm cursor-default";
};
})();

View File

@@ -456,7 +456,8 @@ def sx_call(component_name: str, **kwargs: Any) -> str:
def components_for_request(source: str = "") -> str: def components_for_request(source: str = "",
extra_names: set[str] | None = None) -> str:
"""Return defcomp/defmacro source for definitions the client doesn't have yet. """Return defcomp/defmacro source for definitions the client doesn't have yet.
Reads the ``SX-Components`` header (comma-separated component names Reads the ``SX-Components`` header (comma-separated component names
@@ -464,6 +465,10 @@ def components_for_request(source: str = "") -> str:
is missing. If *source* is provided, only sends components needed is missing. If *source* is provided, only sends components needed
for that source (plus transitive deps). If the header is absent, for that source (plus transitive deps). If the header is absent,
returns all needed defs. returns all needed defs.
*extra_names* — additional component names (``~foo``) to include
beyond what *source* references. Used by ``execute_page`` to send
components the page's content expression needs for client-side routing.
""" """
from quart import request from quart import request
from .jinja_bridge import _COMPONENT_ENV from .jinja_bridge import _COMPONENT_ENV
@@ -477,6 +482,12 @@ def components_for_request(source: str = "") -> str:
else: else:
needed = None # all needed = None # all
# Merge in extra names (e.g. from page content expression deps)
if extra_names and needed is not None:
needed = needed | extra_names
elif extra_names:
needed = extra_names
loaded_raw = request.headers.get("SX-Components", "") loaded_raw = request.headers.get("SX-Components", "")
loaded = set(loaded_raw.split(",")) if loaded_raw else set() loaded = set(loaded_raw.split(",")) if loaded_raw else set()
@@ -510,7 +521,8 @@ def components_for_request(source: str = "") -> str:
def sx_response(source: str, status: int = 200, def sx_response(source: str, status: int = 200,
headers: dict | None = None): headers: dict | None = None,
extra_component_names: set[str] | None = None):
"""Return an s-expression wire-format response. """Return an s-expression wire-format response.
Takes a raw sx string:: Takes a raw sx string::
@@ -520,6 +532,10 @@ def sx_response(source: str, status: int = 200,
For SX requests, missing component definitions are prepended as a For SX requests, missing component definitions are prepended as a
``<script type="text/sx" data-components>`` block so the client ``<script type="text/sx" data-components>`` block so the client
can process them before rendering OOB content. can process them before rendering OOB content.
*extra_component_names* — additional component names to include beyond
what *source* references. Used by defpage to send components the page's
content expression needs for client-side routing.
""" """
from quart import request, Response from quart import request, Response
@@ -535,7 +551,7 @@ def sx_response(source: str, status: int = 200,
# For SX requests, prepend missing component definitions # For SX requests, prepend missing component definitions
comp_defs = "" comp_defs = ""
if request.headers.get("SX-Request"): if request.headers.get("SX-Request"):
comp_defs = components_for_request(source) comp_defs = components_for_request(source, extra_names=extra_component_names)
if comp_defs: if comp_defs:
body = (f'<script type="text/sx" data-components>' body = (f'<script type="text/sx" data-components>'
f'{comp_defs}</script>\n{body}') f'{comp_defs}</script>\n{body}')
@@ -631,6 +647,7 @@ details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.
<body class="bg-stone-50 text-stone-900"> <body class="bg-stone-50 text-stone-900">
<script type="text/sx-styles" data-hash="{styles_hash}">{styles_json}</script> <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" 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 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/sx-browser.js?v={sx_js_hash}"></script>
<script src="{asset_url}/scripts/body.js?v={body_js_hash}"></script> <script src="{asset_url}/scripts/body.js?v={body_js_hash}"></script>
@@ -638,6 +655,94 @@ details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.
</html>""" </html>"""
def _build_pages_sx(service: str) -> str:
"""Build SX page registry for client-side routing.
Returns SX dict literals (one per page) parseable by the client's
``parse`` function. Each dict has keys: name, path, auth, has-data,
content, closure, deps.
"""
import logging
_log = logging.getLogger("sx.pages")
from .pages import get_all_pages
from .parser import serialize as sx_serialize
from .deps import components_needed
from .jinja_bridge import _COMPONENT_ENV
pages = get_all_pages(service)
_log.debug("_build_pages_sx(%s): %d pages in registry", service, len(pages))
if not pages:
_log.warning("_build_pages_sx(%s): no pages found — page registry will be empty", service)
return ""
entries = []
for page_def in pages.values():
content_src = ""
if page_def.content_expr is not None:
content_src = sx_serialize(page_def.content_expr)
auth = page_def.auth if isinstance(page_def.auth, str) else "custom"
has_data = "true" if page_def.data_expr is not None else "false"
# Component deps needed to render this page client-side
# Compute for all pages (including :data pages) — the client
# renders both pure and data pages after fetching data
deps: set[str] = set()
if content_src:
deps = components_needed(content_src, _COMPONENT_ENV)
deps_sx = "(" + " ".join(_sx_literal(d) for d in sorted(deps)) + ")"
# Collect IO primitive names referenced by dep components
from .types import Component as _Comp
io_deps: set[str] = set()
for dep_name in deps:
comp = _COMPONENT_ENV.get(dep_name)
if isinstance(comp, _Comp) and comp.io_refs:
io_deps.update(comp.io_refs)
io_deps_sx = (
"(" + " ".join(_sx_literal(n) for n in sorted(io_deps)) + ")"
if io_deps else "()"
)
# Build closure as SX dict
closure_parts: list[str] = []
for k, v in page_def.closure.items():
if isinstance(v, (str, int, float, bool)):
closure_parts.append(f":{k} {_sx_literal(v)}")
closure_sx = "{" + " ".join(closure_parts) + "}"
entry = (
"{:name " + _sx_literal(page_def.name)
+ " :path " + _sx_literal(page_def.path)
+ " :auth " + _sx_literal(auth)
+ " :has-data " + has_data
+ " :io-deps " + io_deps_sx
+ " :content " + _sx_literal(content_src)
+ " :deps " + deps_sx
+ " :closure " + closure_sx + "}"
)
entries.append(entry)
result = "\n".join(entries)
_log.debug("_build_pages_sx(%s): built %d entries, %d bytes", service, len(entries), len(result))
return result
def _sx_literal(v: object) -> str:
"""Serialize a Python value as an SX literal."""
if v is None:
return "nil"
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, (int, float)):
return str(v)
if isinstance(v, str):
escaped = v.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
return f'"{escaped}"'
return "nil"
def sx_page(ctx: dict, page_sx: str, *, def sx_page(ctx: dict, page_sx: str, *,
meta_html: str = "") -> str: meta_html: str = "") -> str:
"""Return a minimal HTML shell that boots the page from sx source. """Return a minimal HTML shell that boots the page from sx source.
@@ -649,8 +754,9 @@ def sx_page(ctx: dict, page_sx: str, *,
from .jinja_bridge import components_for_page, css_classes_for_page from .jinja_bridge import components_for_page, css_classes_for_page
from .css_registry import lookup_rules, get_preamble, registry_loaded, store_css_hash from .css_registry import lookup_rules, get_preamble, registry_loaded, store_css_hash
# Per-page component bundle: only definitions this page needs # Per-page component bundle: this page's deps + all :data page deps
component_defs, component_hash = components_for_page(page_sx) from quart import current_app as _ca
component_defs, component_hash = components_for_page(page_sx, service=_ca.name)
# Check if client already has this version cached (via cookie) # Check if client already has this version cached (via cookie)
# In dev mode, always send full source so edits are visible immediately # In dev mode, always send full source so edits are visible immediately
@@ -664,7 +770,7 @@ def sx_page(ctx: dict, page_sx: str, *,
sx_css_classes = "" sx_css_classes = ""
sx_css_hash = "" sx_css_hash = ""
if registry_loaded(): if registry_loaded():
classes = css_classes_for_page(page_sx) classes = css_classes_for_page(page_sx, service=_ca.name)
# Always include body classes # Always include body classes
classes.update(["bg-stone-50", "text-stone-900"]) classes.update(["bg-stone-50", "text-stone-900"])
rules = lookup_rules(classes) rules = lookup_rules(classes)
@@ -681,8 +787,9 @@ def sx_page(ctx: dict, page_sx: str, *,
from .parser import parse as _parse, serialize as _serialize from .parser import parse as _parse, serialize as _serialize
try: try:
page_sx = _serialize(_parse(page_sx), pretty=True) page_sx = _serialize(_parse(page_sx), pretty=True)
except Exception: except Exception as e:
pass import logging
logging.getLogger("sx").warning("Pretty-print page_sx failed: %s", e)
# Style dictionary for client-side css primitive # Style dictionary for client-side css primitive
styles_hash = _get_style_dict_hash() styles_hash = _get_style_dict_hash()
@@ -692,6 +799,15 @@ def sx_page(ctx: dict, page_sx: str, *,
else: else:
styles_json = _build_style_dict_json() styles_json = _build_style_dict_json()
# Page registry for client-side routing
import logging
_plog = logging.getLogger("sx.pages")
from quart import current_app
pages_sx = _build_pages_sx(current_app.name)
_plog.debug("sx_page: pages_sx %d bytes for service %s", len(pages_sx), current_app.name)
if pages_sx:
_plog.debug("sx_page: pages_sx first 200 chars: %s", pages_sx[:200])
return _SX_PAGE_TEMPLATE.format( return _SX_PAGE_TEMPLATE.format(
title=_html_escape(title), title=_html_escape(title),
asset_url=asset_url, asset_url=asset_url,
@@ -701,6 +817,7 @@ def sx_page(ctx: dict, page_sx: str, *,
component_defs=component_defs, component_defs=component_defs,
styles_hash=styles_hash, styles_hash=styles_hash,
styles_json=styles_json, styles_json=styles_json,
pages_sx=pages_sx,
page_sx=page_sx, page_sx=page_sx,
sx_css=sx_css, sx_css=sx_css,
sx_css_classes=sx_css_classes, sx_css_classes=sx_css_classes,
@@ -760,7 +877,7 @@ def _get_sx_styles_cookie() -> str:
try: try:
from quart import request from quart import request
return request.cookies.get("sx-styles-hash", "") return request.cookies.get("sx-styles-hash", "")
except Exception: except RuntimeError:
return "" return ""
@@ -770,7 +887,7 @@ def _script_hash(filename: str) -> str:
try: try:
data = (Path("static") / "scripts" / filename).read_bytes() data = (Path("static") / "scripts" / filename).read_bytes()
_SCRIPT_HASH_CACHE[filename] = hashlib.md5(data).hexdigest()[:8] _SCRIPT_HASH_CACHE[filename] = hashlib.md5(data).hexdigest()[:8]
except Exception: except OSError:
_SCRIPT_HASH_CACHE[filename] = "dev" _SCRIPT_HASH_CACHE[filename] = "dev"
return _SCRIPT_HASH_CACHE[filename] return _SCRIPT_HASH_CACHE[filename]
@@ -780,7 +897,7 @@ def _get_csrf_token() -> str:
try: try:
from quart import g from quart import g
return getattr(g, "csrf_token", "") return getattr(g, "csrf_token", "")
except Exception: except RuntimeError:
return "" return ""
@@ -789,7 +906,7 @@ def _get_sx_comp_cookie() -> str:
try: try:
from quart import request from quart import request
return request.cookies.get("sx-comp-hash", "") return request.cookies.get("sx-comp-hash", "")
except Exception: except RuntimeError:
return "" return ""
@@ -833,7 +950,9 @@ def _pretty_print_sx_body(body: str) -> str:
pretty_parts = [_serialize(expr, pretty=True) for expr in exprs] pretty_parts = [_serialize(expr, pretty=True) for expr in exprs]
parts.append("\n\n".join(pretty_parts)) parts.append("\n\n".join(pretty_parts))
return "\n\n".join(parts) return "\n\n".join(parts)
except Exception: except Exception as e:
import logging
logging.getLogger("sx").warning("Pretty-print sx body failed: %s", e)
return body return body

View File

@@ -332,17 +332,33 @@ def client_components_tag(*names: str) -> str:
return f'<script type="text/sx" data-components>{source}</script>' return f'<script type="text/sx" data-components>{source}</script>'
def components_for_page(page_sx: str) -> tuple[str, str]: def components_for_page(page_sx: str, service: str | None = None) -> tuple[str, str]:
"""Return (component_defs_source, page_hash) for a page. """Return (component_defs_source, page_hash) for a page.
Scans *page_sx* for component references, computes the transitive Scans *page_sx* for component references, computes the transitive
closure, and returns only the definitions needed for this page. closure, and returns only the definitions needed for this page.
When *service* is given, also includes deps for all :data pages
in that service so the client can render them without a server
roundtrip on navigation.
The hash is computed from the page-specific bundle for caching. The hash is computed from the page-specific bundle for caching.
""" """
from .deps import components_needed from .deps import components_needed
from .parser import serialize from .parser import serialize
needed = components_needed(page_sx, _COMPONENT_ENV) needed = components_needed(page_sx, _COMPONENT_ENV)
# Include deps for all :data pages so the client can render them.
# Pages with IO deps use the async render path (Phase 5) — the IO
# primitives are proxied via /sx/io/<name>.
if service:
from .pages import get_all_pages
for page_def in get_all_pages(service).values():
if page_def.data_expr is not None and page_def.content_expr is not None:
content_src = serialize(page_def.content_expr)
needed |= components_needed(content_src, _COMPONENT_ENV)
if not needed: if not needed:
return "", "" return "", ""
@@ -375,16 +391,24 @@ def components_for_page(page_sx: str) -> tuple[str, str]:
return source, digest return source, digest
def css_classes_for_page(page_sx: str) -> set[str]: def css_classes_for_page(page_sx: str, service: str | None = None) -> set[str]:
"""Return CSS classes needed for a page's component bundle + page source. """Return CSS classes needed for a page's component bundle + page source.
Instead of unioning ALL component CSS classes, only includes classes Instead of unioning ALL component CSS classes, only includes classes
from components the page actually uses. from components the page actually uses (plus all :data page deps).
""" """
from .deps import components_needed from .deps import components_needed
from .css_registry import scan_classes_from_sx from .css_registry import scan_classes_from_sx
from .parser import serialize
needed = components_needed(page_sx, _COMPONENT_ENV) needed = components_needed(page_sx, _COMPONENT_ENV)
if service:
from .pages import get_all_pages
for page_def in get_all_pages(service).values():
if page_def.data_expr is not None and page_def.content_expr is not None:
content_src = serialize(page_def.content_expr)
needed |= components_needed(content_src, _COMPONENT_ENV)
classes: set[str] = set() classes: set[str] = set()
for key, val in _COMPONENT_ENV.items(): for key, val in _COMPONENT_ENV.items():

View File

@@ -279,13 +279,25 @@ async def execute_page(
is_htmx = is_htmx_request() is_htmx = is_htmx_request()
if is_htmx: if is_htmx:
# Compute content expression deps so the server sends component
# definitions the client needs for future client-side routing
extra_deps: set[str] | None = None
if page_def.content_expr is not None and page_def.data_expr is None:
from .deps import components_needed
from .parser import serialize
try:
content_src = serialize(page_def.content_expr)
extra_deps = components_needed(content_src, get_component_env())
except Exception:
pass # non-critical — client will just fall back to server
return sx_response(await oob_page_sx( return sx_response(await oob_page_sx(
oobs=oob_headers if oob_headers else "", oobs=oob_headers if oob_headers else "",
filter=filter_sx, filter=filter_sx,
aside=aside_sx, aside=aside_sx,
content=content_sx, content=content_sx,
menu=menu_sx, menu=menu_sx,
)) ), extra_component_names=extra_deps)
else: else:
return await full_page_sx( return await full_page_sx(
tctx, tctx,
@@ -306,12 +318,22 @@ def auto_mount_pages(app: Any, service_name: str) -> None:
Pages must have absolute paths (from the service URL root). Pages must have absolute paths (from the service URL root).
Called once per service in app.py after setup_*_pages(). Called once per service in app.py after setup_*_pages().
Also mounts the /sx/data/ endpoint for client-side data fetching.
""" """
pages = get_all_pages(service_name) pages = get_all_pages(service_name)
for page_def in pages.values(): for page_def in pages.values():
_mount_one_page(app, service_name, page_def) _mount_one_page(app, service_name, page_def)
logger.info("Auto-mounted %d defpages for %s", len(pages), service_name) logger.info("Auto-mounted %d defpages for %s", len(pages), service_name)
# Mount page data endpoint for client-side rendering of :data pages
has_data_pages = any(p.data_expr is not None for p in pages.values())
if has_data_pages:
auto_mount_page_data(app, service_name)
# Mount IO proxy endpoint for Phase 5: client-side IO primitives
mount_io_endpoint(app, service_name)
def mount_pages(bp: Any, service_name: str, def mount_pages(bp: Any, service_name: str,
names: set[str] | list[str] | None = None) -> None: names: set[str] | list[str] | None = None) -> None:
@@ -393,3 +415,202 @@ def _apply_cache(fn: Any, cache: dict) -> Any:
tag = cache.get("tag") tag = cache.get("tag")
scope = cache.get("scope", "user") scope = cache.get("scope", "user")
return cache_page(ttl=ttl, tag=tag, scope=scope)(fn) return cache_page(ttl=ttl, tag=tag, scope=scope)(fn)
async def _check_page_auth(auth: str | list) -> Any | None:
"""Check auth for the data endpoint. Returns None if OK, or a response."""
from quart import g, abort as quart_abort
if auth == "public":
return None
user = g.get("user")
if auth == "login":
if not user:
quart_abort(401)
elif auth == "admin":
if not user or not user.get("rights", {}).get("admin"):
quart_abort(403)
elif isinstance(auth, list) and auth and auth[0] == "rights":
if not user:
quart_abort(401)
user_rights = set(user.get("rights", {}).keys())
required = set(auth[1:])
if not required.issubset(user_rights):
quart_abort(403)
return None
# ---------------------------------------------------------------------------
# Page data endpoint — evaluate :data expression, return SX
# ---------------------------------------------------------------------------
async def evaluate_page_data(
page_def: PageDef,
service_name: str,
url_params: dict[str, Any] | None = None,
) -> str:
"""Evaluate a defpage's :data expression and return result as SX.
This is the data-only counterpart to execute_page(). The client
fetches this when it has all component definitions but needs the
data bindings to render a :data page client-side.
Returns SX wire format (e.g. ``{:posts (list ...) :count 42}``),
parsed by the client's SX parser and merged into the eval env.
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval
from .parser import serialize
if page_def.data_expr is None:
return "nil"
if url_params is None:
url_params = {}
# Build environment (same as execute_page)
env = dict(get_component_env())
env.update(get_page_helpers(service_name))
env.update(page_def.closure)
for key, val in url_params.items():
kebab = key.replace("_", "-")
env[kebab] = val
env[key] = val
ctx = _get_request_context()
data_result = await async_eval(page_def.data_expr, env, ctx)
# Kebab-case dict keys (matching execute_page line 214-215)
if isinstance(data_result, dict):
data_result = {
k.replace("_", "-"): v for k, v in data_result.items()
}
# Serialize the result as SX
return serialize(data_result)
def auto_mount_page_data(app: Any, service_name: str) -> None:
"""Mount a single /sx/data/ endpoint that serves page data as SX.
For each defpage with :data, the client can GET /sx/data/<page-name>
(with URL params as query args) and receive the evaluated :data
result serialized as SX wire format (text/sx).
Auth is enforced per-page: the endpoint looks up the page's auth
setting and checks it before evaluating the data expression.
"""
from quart import make_response, request, abort as quart_abort
async def page_data_view(page_name: str) -> Any:
page_def = get_page(service_name, page_name)
if page_def is None:
quart_abort(404)
if page_def.data_expr is None:
quart_abort(404)
# Check auth — same enforcement as the page route itself
auth_error = await _check_page_auth(page_def.auth)
if auth_error is not None:
return auth_error
# Extract URL params from query string
url_params = dict(request.args)
result_sx = await evaluate_page_data(
page_def, service_name, url_params=url_params,
)
resp = await make_response(result_sx, 200)
resp.content_type = "text/sx; charset=utf-8"
return resp
page_data_view.__name__ = "sx_page_data"
page_data_view.__qualname__ = "sx_page_data"
app.add_url_rule(
"/sx/data/<page_name>",
endpoint="sx_page_data",
view_func=page_data_view,
methods=["GET"],
)
logger.info("Mounted page data endpoint for %s at /sx/data/<page_name>", service_name)
def mount_io_endpoint(app: Any, service_name: str) -> None:
"""Mount /sx/io/<name> endpoint for client-side IO primitive calls.
The client can call any allowed IO primitive or page helper via GET/POST.
Result is returned as SX wire format (text/sx).
Falls back to page helpers when the name isn't a global IO primitive,
so service-specific functions like ``highlight`` work via the proxy.
"""
import asyncio as _asyncio
from quart import make_response, request, abort as quart_abort
from .primitives_io import IO_PRIMITIVES, execute_io
from .jinja_bridge import _get_request_context
from .parser import serialize
# Build allowlist from all component IO refs across this service
from .jinja_bridge import _COMPONENT_ENV
from .types import Component as _Comp
_ALLOWED_IO: set[str] = set()
for _val in _COMPONENT_ENV.values():
if isinstance(_val, _Comp) and _val.io_refs:
_ALLOWED_IO.update(_val.io_refs)
from shared.browser.app.csrf import csrf_exempt
@csrf_exempt
async def io_proxy(name: str) -> Any:
if name not in _ALLOWED_IO:
quart_abort(403)
# Parse args from query string or JSON body
args: list = []
kwargs: dict = {}
if request.method == "GET":
for k, v in request.args.items():
if k.startswith("_arg"):
args.append(v)
else:
kwargs[k] = v
else:
data = await request.get_json(silent=True) or {}
args = data.get("args", [])
kwargs = data.get("kwargs", {})
# Try global IO primitives first
if name in IO_PRIMITIVES:
ctx = _get_request_context()
result = await execute_io(name, args, kwargs, ctx)
else:
# Fall back to page helpers (service-specific functions)
helpers = get_page_helpers(service_name)
helper_fn = helpers.get(name)
if helper_fn is None:
quart_abort(404)
result = helper_fn(*args, **kwargs) if kwargs else helper_fn(*args)
if _asyncio.iscoroutine(result):
result = await result
result_sx = serialize(result) if result is not None else "nil"
resp = await make_response(result_sx, 200)
resp.content_type = "text/sx; charset=utf-8"
resp.headers["Cache-Control"] = "public, max-age=300"
return resp
io_proxy.__name__ = "sx_io_proxy"
io_proxy.__qualname__ = "sx_io_proxy"
app.add_url_rule(
"/sx/io/<name>",
endpoint="sx_io_proxy",
view_func=io_proxy,
methods=["GET", "POST"],
)
logger.info("Mounted IO proxy for %s: %s", service_name, sorted(_ALLOWED_IO))

View File

@@ -192,9 +192,13 @@ def prim_is_zero(n: Any) -> bool:
def prim_is_nil(x: Any) -> bool: def prim_is_nil(x: Any) -> bool:
return x is None or x is NIL return x is None or x is NIL
@register_primitive("boolean?")
def prim_is_boolean(x: Any) -> bool:
return isinstance(x, bool)
@register_primitive("number?") @register_primitive("number?")
def prim_is_number(x: Any) -> bool: def prim_is_number(x: Any) -> bool:
return isinstance(x, (int, float)) return isinstance(x, (int, float)) and not isinstance(x, bool)
@register_primitive("string?") @register_primitive("string?")
def prim_is_string(x: Any) -> bool: def prim_is_string(x: Any) -> bool:
@@ -268,13 +272,27 @@ def prim_concat(*colls: Any) -> list:
return result return result
@register_primitive("upper") @register_primitive("upper")
@register_primitive("upcase")
def prim_upper(s: str) -> str: def prim_upper(s: str) -> str:
return s.upper() return s.upper()
@register_primitive("lower") @register_primitive("lower")
@register_primitive("downcase")
def prim_lower(s: str) -> str: def prim_lower(s: str) -> str:
return s.lower() return s.lower()
@register_primitive("string-length")
def prim_string_length(s: str) -> int:
return len(s)
@register_primitive("substring")
def prim_substring(s: str, start: int, end: int) -> str:
return s[int(start):int(end)]
@register_primitive("string-contains?")
def prim_string_contains(s: str, needle: str) -> bool:
return needle in s
@register_primitive("trim") @register_primitive("trim")
def prim_trim(s: str) -> str: def prim_trim(s: str) -> str:
return s.strip() return s.strip()
@@ -384,8 +402,36 @@ def prim_cons(x: Any, coll: Any) -> list:
@register_primitive("append") @register_primitive("append")
def prim_append(coll: Any, x: Any) -> list: def prim_append(coll: Any, x: Any) -> list:
if isinstance(x, list):
return list(coll) + x if coll else list(x)
return list(coll) + [x] if coll else [x] return list(coll) + [x] if coll else [x]
@register_primitive("reverse")
def prim_reverse(coll: Any) -> list:
return list(reversed(coll)) if coll else []
@register_primitive("flatten")
def prim_flatten(coll: Any) -> list:
result = []
for item in (coll or []):
if isinstance(item, list):
result.extend(item)
else:
result.append(item)
return result
@register_primitive("has-key?")
def prim_has_key(d: Any, key: Any) -> bool:
if not isinstance(d, dict):
return False
k = key.name if isinstance(key, Keyword) else key
return k in d
@register_primitive("append!")
def prim_append_mut(coll: Any, x: Any) -> list:
coll.append(x)
return coll
@register_primitive("chunk-every") @register_primitive("chunk-every")
def prim_chunk_every(coll: Any, n: Any) -> list: def prim_chunk_every(coll: Any, n: Any) -> list:
n = int(n) n = int(n)
@@ -439,6 +485,13 @@ def prim_dissoc(d: Any, *keys_to_remove: Any) -> dict:
result.pop(key, None) result.pop(key, None)
return result return result
@register_primitive("dict-set!")
def prim_dict_set_mut(d: Any, key: Any, val: Any) -> Any:
if isinstance(key, Keyword):
key = key.name
d[key] = val
return val
@register_primitive("into") @register_primitive("into")
def prim_into(target: Any, coll: Any) -> Any: def prim_into(target: Any, coll: Any) -> Any:
if isinstance(target, list): if isinstance(target, list):

View File

@@ -377,7 +377,10 @@ async def _io_asset_url(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> str: ) -> str:
"""``(asset-url "/img/logo.png")`` → versioned static URL.""" """``(asset-url "/img/logo.png")`` → versioned static URL."""
from shared.infrastructure.urls import asset_url from quart import current_app
asset_url = current_app.jinja_env.globals.get("asset_url")
if asset_url is None:
raise RuntimeError("asset_url Jinja global not registered")
path = str(args[0]) if args else "" path = str(args[0]) if args else ""
return asset_url(path) return asset_url(path)
@@ -458,7 +461,10 @@ def _bridge_app_url(service, *path_parts):
return app_url(str(service), path) return app_url(str(service), path)
def _bridge_asset_url(*path_parts): def _bridge_asset_url(*path_parts):
from shared.infrastructure.urls import asset_url from quart import current_app
asset_url = current_app.jinja_env.globals.get("asset_url")
if asset_url is None:
raise RuntimeError("asset_url Jinja global not registered")
path = str(path_parts[0]) if path_parts else "" path = str(path_parts[0]) if path_parts else ""
return asset_url(path) return asset_url(path)

View File

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

View File

@@ -295,6 +295,38 @@
scripts)))) scripts))))
;; --------------------------------------------------------------------------
;; Page registry for client-side routing
;; --------------------------------------------------------------------------
(define _page-routes (list))
(define process-page-scripts
(fn ()
;; Process <script type="text/sx-pages"> tags.
;; Parses SX page registry and builds route entries with parsed patterns.
(let ((scripts (query-page-scripts)))
(log-info (str "pages: found " (len scripts) " script tags"))
(for-each
(fn (s)
(when (not (is-processed? s "pages"))
(mark-processed! s "pages")
(let ((text (dom-text-content s)))
(log-info (str "pages: script text length=" (if text (len text) 0)))
(if (and text (not (empty? (trim text))))
(let ((pages (parse text)))
(log-info (str "pages: parsed " (len pages) " entries"))
(for-each
(fn (page)
(append! _page-routes
(merge page
{"parsed" (parse-route-pattern (get page "path"))})))
pages))
(log-warn "pages: script tag is empty")))))
scripts)
(log-info (str "pages: " (len _page-routes) " routes loaded")))))
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
;; Full boot sequence ;; Full boot sequence
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
@@ -305,11 +337,14 @@
;; 1. CSS tracking ;; 1. CSS tracking
;; 2. Style dictionary ;; 2. Style dictionary
;; 3. Process scripts (components + mounts) ;; 3. Process scripts (components + mounts)
;; 4. Hydrate [data-sx] elements ;; 4. Process page registry (client-side routing)
;; 5. Process engine elements ;; 5. Hydrate [data-sx] elements
;; 6. Process engine elements
(do (do
(log-info (str "sx-browser " SX_VERSION))
(init-css-tracking) (init-css-tracking)
(init-style-dict) (init-style-dict)
(process-page-scripts)
(process-sx-scripts nil) (process-sx-scripts nil)
(sx-hydrate-elements nil) (sx-hydrate-elements nil)
(process-elements nil)))) (process-elements nil))))
@@ -354,6 +389,7 @@
;; === Script queries === ;; === Script queries ===
;; (query-sx-scripts root) → list of <script type="text/sx"> elements ;; (query-sx-scripts root) → list of <script type="text/sx"> elements
;; (query-style-scripts) → list of <script type="text/sx-styles"> elements ;; (query-style-scripts) → list of <script type="text/sx-styles"> elements
;; (query-page-scripts) → list of <script type="text/sx-pages"> elements
;; ;;
;; === localStorage === ;; === localStorage ===
;; (local-storage-get key) → string or nil ;; (local-storage-get key) → string or nil

File diff suppressed because it is too large Load Diff

View File

@@ -52,6 +52,8 @@ class PyEmitter:
return self._emit_symbol(expr.name) return self._emit_symbol(expr.name)
if isinstance(expr, Keyword): if isinstance(expr, Keyword):
return self._py_string(expr.name) return self._py_string(expr.name)
if isinstance(expr, dict):
return self._emit_native_dict(expr)
if isinstance(expr, list): if isinstance(expr, list):
return self._emit_list(expr) return self._emit_list(expr)
return str(expr) return str(expr)
@@ -234,6 +236,8 @@ class PyEmitter:
"map-indexed": "map_indexed", "map-indexed": "map_indexed",
"map-dict": "map_dict", "map-dict": "map_dict",
"eval-cond": "eval_cond", "eval-cond": "eval_cond",
"eval-cond-scheme": "eval_cond_scheme",
"eval-cond-clojure": "eval_cond_clojure",
"process-bindings": "process_bindings", "process-bindings": "process_bindings",
# deps.sx # deps.sx
"scan-refs": "scan_refs", "scan-refs": "scan_refs",
@@ -258,6 +262,13 @@ class PyEmitter:
"transitive-io-refs": "transitive_io_refs", "transitive-io-refs": "transitive_io_refs",
"compute-all-io-refs": "compute_all_io_refs", "compute-all-io-refs": "compute_all_io_refs",
"component-pure?": "component_pure_p", "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: if name in RENAMES:
return RENAMES[name] return RENAMES[name]
@@ -391,6 +402,9 @@ class PyEmitter:
assignments.append((self._mangle(vname), self.emit(bindings[i + 1]))) assignments.append((self._mangle(vname), self.emit(bindings[i + 1])))
# Nested IIFE for sequential let (each binding can see previous ones): # Nested IIFE for sequential let (each binding can see previous ones):
# (lambda a: (lambda b: body)(val_b))(val_a) # (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] body_parts = [self.emit(b) for b in body]
if len(body) == 1: if len(body) == 1:
body_str = body_parts[0] body_str = body_parts[0]
@@ -399,6 +413,10 @@ class PyEmitter:
# Build from inside out # Build from inside out
result = body_str result = body_str
for name, val in reversed(assignments): for name, val in reversed(assignments):
if name in cell_vars:
# Cell var: initialize in _cells dict, not as lambda param
result = f"_sx_begin(_sx_cell_set(_cells, {self._py_string(name)}, {val}), {result})"
else:
result = f"(lambda {name}: {result})({val})" result = f"(lambda {name}: {result})({val})"
return result return result
@@ -512,6 +530,13 @@ class PyEmitter:
parts = [self.emit(e) for e in exprs] parts = [self.emit(e) for e in exprs]
return "_sx_begin(" + ", ".join(parts) + ")" 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: def _emit_dict_literal(self, expr) -> str:
pairs = expr[1:] pairs = expr[1:]
parts = [] parts = []
@@ -828,6 +853,7 @@ ADAPTER_FILES = {
SPEC_MODULES = { SPEC_MODULES = {
"deps": ("deps.sx", "deps (component dependency analysis)"), "deps": ("deps.sx", "deps (component dependency analysis)"),
"router": ("router.sx", "router (client-side route matching)"),
} }
@@ -1947,6 +1973,9 @@ range = PRIMITIVES["range"]
apply = lambda f, args: f(*args) apply = lambda f, args: f(*args)
assoc = PRIMITIVES["assoc"] assoc = PRIMITIVES["assoc"]
concat = PRIMITIVES["concat"] concat = PRIMITIVES["concat"]
split = PRIMITIVES["split"]
length = PRIMITIVES["len"]
merge = PRIMITIVES["merge"]
''' '''

View File

@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""
Bootstrap compiler: test.sx -> pytest test module.
Reads test.sx and emits a Python test file that runs each deftest
as a pytest test case, grouped into classes by defsuite.
The emitted tests use the SX evaluator to run SX test bodies,
verifying that the Python implementation matches the spec.
Usage:
python bootstrap_test.py --output shared/sx/tests/test_sx_spec.py
pytest shared/sx/tests/test_sx_spec.py -v
"""
from __future__ import annotations
import os
import re
import sys
import argparse
_HERE = os.path.dirname(os.path.abspath(__file__))
_PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", ".."))
sys.path.insert(0, _PROJECT)
from shared.sx.parser import parse_all
from shared.sx.types import Symbol, Keyword, NIL as SX_NIL
def _slugify(name: str) -> str:
"""Convert a test/suite name to a valid Python identifier."""
s = name.lower().strip()
s = re.sub(r'[^a-z0-9]+', '_', s)
s = s.strip('_')
return s
def _sx_to_source(expr) -> str:
"""Convert an SX AST node back to SX source string."""
if isinstance(expr, bool):
return "true" if expr else "false"
if isinstance(expr, (int, float)):
return str(expr)
if isinstance(expr, str):
escaped = expr.replace('\\', '\\\\').replace('"', '\\"')
return f'"{escaped}"'
if expr is None or expr is SX_NIL:
return "nil"
if isinstance(expr, Symbol):
return expr.name
if isinstance(expr, Keyword):
return f":{expr.name}"
if isinstance(expr, dict):
pairs = []
for k, v in expr.items():
pairs.append(f":{k} {_sx_to_source(v)}")
return "{" + " ".join(pairs) + "}"
if isinstance(expr, list):
if not expr:
return "()"
return "(" + " ".join(_sx_to_source(e) for e in expr) + ")"
return str(expr)
def _parse_test_sx(path: str) -> tuple[list[dict], list]:
"""Parse test.sx and return (suites, preamble_exprs).
Preamble exprs are define forms (assertion helpers) that must be
evaluated before tests run. Suites contain the actual test cases.
"""
with open(path) as f:
content = f.read()
exprs = parse_all(content)
suites = []
preamble = []
for expr in exprs:
if not isinstance(expr, list) or not expr:
continue
head = expr[0]
if isinstance(head, Symbol) and head.name == "defsuite":
suite = _parse_suite(expr)
if suite:
suites.append(suite)
elif isinstance(head, Symbol) and head.name == "define":
preamble.append(expr)
return suites, preamble
def _parse_suite(expr: list) -> dict | None:
"""Parse a (defsuite "name" ...) form."""
if len(expr) < 2:
return None
name = expr[1]
if not isinstance(name, str):
return None
tests = []
for child in expr[2:]:
if not isinstance(child, list) or not child:
continue
head = child[0]
if isinstance(head, Symbol):
if head.name == "deftest":
test = _parse_test(child)
if test:
tests.append(test)
elif head.name == "defsuite":
sub = _parse_suite(child)
if sub:
tests.append(sub)
return {"type": "suite", "name": name, "tests": tests}
def _parse_test(expr: list) -> dict | None:
"""Parse a (deftest "name" body ...) form."""
if len(expr) < 3:
return None
name = expr[1]
if not isinstance(name, str):
return None
body = expr[2:]
return {"type": "test", "name": name, "body": body}
def _emit_py(suites: list[dict], preamble: list) -> str:
"""Emit a pytest module from parsed suites."""
# Serialize preamble (assertion helpers) as SX source
preamble_sx = "\n".join(_sx_to_source(expr) for expr in preamble)
preamble_escaped = preamble_sx.replace('\\', '\\\\').replace("'", "\\'")
lines = []
lines.append('"""Auto-generated from test.sx — SX spec self-tests.')
lines.append('')
lines.append('DO NOT EDIT. Regenerate with:')
lines.append(' python shared/sx/ref/bootstrap_test.py --output shared/sx/tests/test_sx_spec.py')
lines.append('"""')
lines.append('from __future__ import annotations')
lines.append('')
lines.append('import pytest')
lines.append('from shared.sx.parser import parse_all')
lines.append('from shared.sx.evaluator import _eval, _trampoline')
lines.append('')
lines.append('')
lines.append(f"_PREAMBLE = '''{preamble_escaped}'''")
lines.append('')
lines.append('')
lines.append('def _make_env() -> dict:')
lines.append(' """Create a fresh env with assertion helpers loaded."""')
lines.append(' env = {}')
lines.append(' for expr in parse_all(_PREAMBLE):')
lines.append(' _trampoline(_eval(expr, env))')
lines.append(' return env')
lines.append('')
lines.append('')
lines.append('def _run(sx_source: str, env: dict | None = None) -> object:')
lines.append(' """Evaluate SX source and return the result."""')
lines.append(' if env is None:')
lines.append(' env = _make_env()')
lines.append(' exprs = parse_all(sx_source)')
lines.append(' result = None')
lines.append(' for expr in exprs:')
lines.append(' result = _trampoline(_eval(expr, env))')
lines.append(' return result')
lines.append('')
for suite in suites:
_emit_suite(suite, lines, indent=0)
return "\n".join(lines)
def _emit_suite(suite: dict, lines: list[str], indent: int):
"""Emit a pytest class for a suite."""
class_name = f"TestSpec{_slugify(suite['name']).title().replace('_', '')}"
pad = " " * indent
lines.append(f'{pad}class {class_name}:')
lines.append(f'{pad} """test.sx suite: {suite["name"]}"""')
lines.append('')
for item in suite["tests"]:
if item["type"] == "test":
_emit_test(item, lines, indent + 1)
elif item["type"] == "suite":
_emit_suite(item, lines, indent + 1)
lines.append('')
def _emit_test(test: dict, lines: list[str], indent: int):
"""Emit a pytest test method."""
method_name = f"test_{_slugify(test['name'])}"
pad = " " * indent
# Convert body expressions to SX source
body_parts = []
for expr in test["body"]:
body_parts.append(_sx_to_source(expr))
# Wrap in (do ...) if multiple expressions, or use single
if len(body_parts) == 1:
sx_source = body_parts[0]
else:
sx_source = "(do " + " ".join(body_parts) + ")"
# Escape for Python string
sx_escaped = sx_source.replace('\\', '\\\\').replace("'", "\\'")
lines.append(f"{pad}def {method_name}(self):")
lines.append(f"{pad} _run('{sx_escaped}')")
lines.append('')
def main():
parser = argparse.ArgumentParser(description="Bootstrap test.sx to pytest")
parser.add_argument("--output", "-o", help="Output file path")
parser.add_argument("--dry-run", action="store_true", help="Print to stdout")
args = parser.parse_args()
test_sx = os.path.join(_HERE, "test.sx")
suites, preamble = _parse_test_sx(test_sx)
print(f"Parsed {len(suites)} suites, {len(preamble)} preamble defines from test.sx", file=sys.stderr)
total_tests = sum(
sum(1 for t in s["tests"] if t["type"] == "test")
for s in suites
)
print(f"Total test cases: {total_tests}", file=sys.stderr)
output = _emit_py(suites, preamble)
if args.output and not args.dry_run:
with open(args.output, "w") as f:
f.write(output)
print(f"Wrote {args.output}", file=sys.stderr)
else:
print(output)
if __name__ == "__main__":
main()

View File

@@ -388,15 +388,33 @@
(dom-has-attr? el "href"))) (dom-has-attr? el "href")))
(prevent-default e)) (prevent-default e))
;; Delay modifier ;; Re-read verb info from element at click time (not closed-over)
(let ((live-info (or (get-verb-info el) verbInfo))
(is-get-link (and (= event-name "click")
(= (get live-info "method") "GET")
(dom-has-attr? el "href")
(not (get mods "delay"))))
(client-routed false))
(when is-get-link
(set! client-routed
(try-client-route
(url-pathname (get live-info "url"))
(dom-get-attr el "sx-target"))))
(if client-routed
(do
(browser-push-state (get live-info "url"))
(browser-scroll-to 0 0))
(do
(when is-get-link
(log-info (str "sx:route server fetch " (get live-info "url"))))
(if (get mods "delay") (if (get mods "delay")
(do (do
(clear-timeout timer) (clear-timeout timer)
(set! timer (set! timer
(set-timeout (set-timeout
(fn () (execute-request el verbInfo nil)) (fn () (execute-request el nil nil))
(get mods "delay")))) (get mods "delay"))))
(execute-request el verbInfo nil))))) (execute-request el nil nil))))))))
(if (get mods "once") (dict "once" true) nil)))))) (if (get mods "once") (dict "once" true) nil))))))
@@ -491,21 +509,24 @@
(define boost-descendants (define boost-descendants
(fn (container) (fn (container)
;; Boost links and forms within a container ;; Boost links and forms within a container.
;; Links get sx-get, forms get sx-post/sx-get ;; The sx-boost attribute value is the default target selector
;; for boosted descendants (e.g. sx-boost="#main-panel").
(let ((boost-target (dom-get-attr container "sx-boost")))
(for-each (for-each
(fn (link) (fn (link)
(when (and (not (is-processed? link "boost")) (when (and (not (is-processed? link "boost"))
(should-boost-link? link)) (should-boost-link? link))
(mark-processed! link "boost") (mark-processed! link "boost")
;; Set default sx-target if not specified ;; Inherit target from boost container if not specified
(when (not (dom-has-attr? link "sx-target")) (when (and (not (dom-has-attr? link "sx-target"))
(dom-set-attr link "sx-target" "#main-panel")) boost-target (not (= boost-target "true")))
(dom-set-attr link "sx-target" boost-target))
(when (not (dom-has-attr? link "sx-swap")) (when (not (dom-has-attr? link "sx-swap"))
(dom-set-attr link "sx-swap" "innerHTML")) (dom-set-attr link "sx-swap" "innerHTML"))
(when (not (dom-has-attr? link "sx-push-url")) (when (not (dom-has-attr? link "sx-push-url"))
(dom-set-attr link "sx-push-url" "true")) (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]")) (dom-query-all container "a[href]"))
(for-each (for-each
(fn (form) (fn (form)
@@ -515,12 +536,197 @@
(let ((method (upper (or (dom-get-attr form "method") "GET"))) (let ((method (upper (or (dom-get-attr form "method") "GET")))
(action (or (dom-get-attr form "action") (action (or (dom-get-attr form "action")
(browser-location-href)))) (browser-location-href))))
(when (not (dom-has-attr? form "sx-target")) (when (and (not (dom-has-attr? form "sx-target"))
(dom-set-attr form "sx-target" "#main-panel")) boost-target (not (= boost-target "true")))
(dom-set-attr form "sx-target" boost-target))
(when (not (dom-has-attr? form "sx-swap")) (when (not (dom-has-attr? form "sx-swap"))
(dom-set-attr form "sx-swap" "innerHTML")) (dom-set-attr form "sx-swap" "innerHTML"))
(bind-boost-form form method action)))) (bind-boost-form form method action))))
(dom-query-all container "form")))) (dom-query-all container "form")))))
;; --------------------------------------------------------------------------
;; Client-side routing — data cache
;; --------------------------------------------------------------------------
;; Cache for page data resolved via resolve-page-data.
;; Keyed by "page-name:param1=val1&param2=val2", value is {data, ts}.
;; Default TTL: 30s. Prevents redundant fetches on back/forward navigation.
(define _page-data-cache (dict))
(define _page-data-cache-ttl 30000) ;; 30 seconds in ms
(define page-data-cache-key
(fn (page-name params)
;; Build a cache key from page name + params.
;; Params are from route matching so order is deterministic.
(let ((base page-name))
(if (or (nil? params) (empty? (keys params)))
base
(let ((parts (list)))
(for-each
(fn (k)
(append! parts (str k "=" (get params k))))
(keys params))
(str base ":" (join "&" parts)))))))
(define page-data-cache-get
(fn (cache-key)
;; Return cached data if fresh, else nil.
(let ((entry (get _page-data-cache cache-key)))
(if (nil? entry)
nil
(if (> (- (now-ms) (get entry "ts")) _page-data-cache-ttl)
(do
(dict-set! _page-data-cache cache-key nil)
nil)
(get entry "data"))))))
(define page-data-cache-set
(fn (cache-key data)
;; Store data with current timestamp.
(dict-set! _page-data-cache cache-key
{"data" data "ts" (now-ms)})))
;; --------------------------------------------------------------------------
;; Client-side routing
;; --------------------------------------------------------------------------
;; No app-specific nav update here — apps handle sx:clientRoute event.
(define swap-rendered-content
(fn (target rendered pathname)
;; Swap rendered DOM content into target and run post-processing.
;; Shared by pure and data page client routes.
(do
(dom-set-text-content target "")
(dom-append target rendered)
(hoist-head-elements-full target)
(process-elements target)
(sx-hydrate-elements target)
(dom-dispatch target "sx:clientRoute"
(dict "pathname" pathname))
(log-info (str "sx:route client " pathname)))))
(define resolve-route-target
(fn (target-sel)
;; Resolve a target selector to a DOM element, or nil.
(if (and target-sel (not (= target-sel "true")))
(dom-query target-sel)
nil)))
(define deps-satisfied?
(fn (match)
;; Check if all component deps for a page are loaded client-side.
(let ((deps (get match "deps"))
(loaded (loaded-component-names)))
(if (or (nil? deps) (empty? deps))
true
(every? (fn (dep) (contains? loaded dep)) deps)))))
(define try-client-route
(fn (pathname target-sel)
;; Try to render a page client-side. Returns true if successful, false otherwise.
;; target-sel is the CSS selector for the swap target (from sx-boost value).
;; For pure pages: renders immediately. For :data pages: fetches data then renders.
(let ((match (find-matching-route pathname _page-routes)))
(if (nil? match)
(do (log-info (str "sx:route no match (" (len _page-routes) " routes) " pathname)) false)
(let ((content-src (get match "content"))
(closure (or (get match "closure") {}))
(params (get match "params"))
(page-name (get match "name")))
(if (or (nil? content-src) (empty? content-src))
(do (log-warn (str "sx:route no content for " pathname)) false)
(let ((target (resolve-route-target target-sel)))
(if (nil? target)
(do (log-warn (str "sx:route target not found: " target-sel)) false)
(if (not (deps-satisfied? match))
(do (log-info (str "sx:route deps miss for " page-name)) false)
(let ((io-deps (get match "io-deps"))
(has-io (and io-deps (not (empty? io-deps)))))
;; Ensure IO deps are registered as proxied primitives
(when has-io (register-io-deps io-deps))
(if (get match "has-data")
;; Data page: check cache, else resolve asynchronously
(let ((cache-key (page-data-cache-key page-name params))
(cached (page-data-cache-get cache-key)))
(if cached
;; Cache hit
(let ((env (merge closure params cached)))
(if has-io
;; Async render (data+IO)
(do
(log-info (str "sx:route client+cache+async " pathname))
(try-async-eval-content content-src env
(fn (rendered)
(if (nil? rendered)
(log-warn (str "sx:route async eval failed for " pathname))
(swap-rendered-content target rendered pathname))))
true)
;; Sync render (data only)
(let ((rendered (try-eval-content content-src env)))
(if (nil? rendered)
(do (log-warn (str "sx:route cached eval failed for " pathname)) false)
(do
(log-info (str "sx:route client+cache " pathname))
(swap-rendered-content target rendered pathname)
true)))))
;; Cache miss: fetch, cache, render
(do
(log-info (str "sx:route client+data " pathname))
(resolve-page-data page-name params
(fn (data)
(page-data-cache-set cache-key data)
(let ((env (merge closure params data)))
(if has-io
;; Async render (data+IO)
(try-async-eval-content content-src env
(fn (rendered)
(if (nil? rendered)
(log-warn (str "sx:route data+async eval failed for " pathname))
(swap-rendered-content target rendered pathname))))
;; Sync render (data only)
(let ((rendered (try-eval-content content-src env)))
(if (nil? rendered)
(log-warn (str "sx:route data eval failed for " pathname))
(swap-rendered-content target rendered pathname)))))))
true)))
;; Non-data page
(if has-io
;; Async render (IO only, no data)
(do
(log-info (str "sx:route client+async " pathname))
(try-async-eval-content content-src (merge closure params)
(fn (rendered)
(if (nil? rendered)
(log-warn (str "sx:route async eval failed for " pathname))
(swap-rendered-content target rendered pathname))))
true)
;; Pure page: render immediately
(let ((env (merge closure params))
(rendered (try-eval-content content-src env)))
(if (nil? rendered)
(do (log-info (str "sx:route server (eval failed) " pathname)) false)
(do
(swap-rendered-content target rendered pathname)
true)))))))))))))))
(define bind-client-route-link
(fn (link href)
;; Bind a boost link with client-side routing. If the route can be
;; rendered client-side (pure page, no :data), do so. Otherwise
;; fall back to standard server fetch via bind-boost-link.
(bind-client-route-click link href
(fn ()
;; Fallback: use standard boost link binding
(bind-boost-link link href)))))
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
@@ -608,17 +814,18 @@
;; Bind preload event listeners based on sx-preload attribute ;; Bind preload event listeners based on sx-preload attribute
(let ((preload-attr (dom-get-attr el "sx-preload"))) (let ((preload-attr (dom-get-attr el "sx-preload")))
(when preload-attr (when preload-attr
(let ((info (get-verb-info el))) (let ((events (if (= preload-attr "mousedown")
(when info
(let ((url (get info "url"))
(headers (build-request-headers el
(loaded-component-names) _css-hash))
(events (if (= preload-attr "mousedown")
(list "mousedown" "touchstart") (list "mousedown" "touchstart")
(list "mouseover"))) (list "mouseover")))
(debounce-ms (if (= preload-attr "mousedown") 0 100))) (debounce-ms (if (= preload-attr "mousedown") 0 100)))
;; Re-read verb info and headers at preload time, not bind time
(bind-preload el events debounce-ms (bind-preload el events debounce-ms
(fn () (do-preload url headers)))))))))) (fn ()
(let ((info (get-verb-info el)))
(when info
(do-preload (get info "url")
(build-request-headers el
(loaded-component-names) _css-hash)))))))))))
(define do-preload (define do-preload
@@ -668,13 +875,25 @@
(define handle-popstate (define handle-popstate
(fn (scrollY) (fn (scrollY)
;; Handle browser back/forward navigation ;; Handle browser back/forward navigation.
(let ((main (dom-query-by-id "main-panel")) ;; Derive target from [sx-boost] container or fall back to #main-panel.
(url (browser-location-href))) ;; Try client-side route first, fall back to server fetch.
(when main (let ((url (browser-location-href))
(let ((headers (build-request-headers main (boost-el (dom-query "[sx-boost]"))
(target-sel (if boost-el
(let ((attr (dom-get-attr boost-el "sx-boost")))
(if (and attr (not (= attr "true"))) attr nil))
nil))
;; Fall back to #main-panel if no sx-boost target
(target-sel (or target-sel "#main-panel"))
(target (dom-query target-sel))
(pathname (url-pathname url)))
(when target
(if (try-client-route pathname target-sel)
(browser-scroll-to 0 scrollY)
(let ((headers (build-request-headers target
(loaded-component-names) _css-hash))) (loaded-component-names) _css-hash)))
(fetch-and-restore main url headers scrollY)))))) (fetch-and-restore target url headers scrollY)))))))
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
@@ -727,7 +946,7 @@
;; cross-origin ;; cross-origin
;; success-fn: (fn (resp-ok status get-header text) ...) ;; success-fn: (fn (resp-ok status get-header text) ...)
;; error-fn: (fn (err) ...) ;; error-fn: (fn (err) ...)
;; (fetch-location url) → fetch URL and swap to #main-panel ;; (fetch-location url) → fetch URL and swap to boost target
;; (fetch-and-restore main url headers scroll-y) → popstate fetch+swap ;; (fetch-and-restore main url headers scroll-y) → popstate fetch+swap
;; (fetch-preload url headers cache) → preload into cache ;; (fetch-preload url headers cache) → preload into cache
;; ;;
@@ -773,6 +992,7 @@
;; === Boost bindings === ;; === Boost bindings ===
;; (bind-boost-link el href) → void (click handler + pushState) ;; (bind-boost-link el href) → void (click handler + pushState)
;; (bind-boost-form form method action) → void (submit handler) ;; (bind-boost-form form method action) → void (submit handler)
;; (bind-client-route-click link href fallback-fn) → void (client route click handler)
;; ;;
;; === Inline handlers === ;; === Inline handlers ===
;; (bind-inline-handler el event-name body) → void (new Function) ;; (bind-inline-handler el event-name body) → void (new Function)
@@ -803,10 +1023,29 @@
;; === Parsing === ;; === Parsing ===
;; (try-parse-json s) → parsed value or nil ;; (try-parse-json s) → parsed value or nil
;; ;;
;; === Client-side routing ===
;; (try-eval-content source env) → DOM node or nil (catches eval errors)
;; (try-async-eval-content source env callback) → void; async render,
;; calls (callback rendered-or-nil). Used for pages with IO deps.
;; (register-io-deps names) → void; ensure each IO name is registered
;; as a proxied IO primitive on the client. Idempotent.
;; (url-pathname href) → extract pathname from URL string
;; (resolve-page-data name params cb) → void; resolves data for a named page.
;; Platform decides transport (HTTP, cache, IPC, etc). Calls (cb data-dict)
;; when data is available. params is a dict of URL/route parameters.
;;
;; From boot.sx:
;; _page-routes → list of route entries
;;
;; From router.sx:
;; (find-matching-route path routes) → matching entry with params, or nil
;; (parse-route-pattern pattern) → parsed pattern segments
;;
;; === Browser (via engine.sx) === ;; === Browser (via engine.sx) ===
;; (browser-location-href) → current URL string ;; (browser-location-href) → current URL string
;; (browser-navigate url) → void ;; (browser-navigate url) → void
;; (browser-reload) → void ;; (browser-reload) → void
;; (browser-scroll-to x y) → void
;; (browser-media-matches? query) → boolean ;; (browser-media-matches? query) → boolean
;; (browser-confirm msg) → boolean ;; (browser-confirm msg) → boolean
;; (browser-prompt msg) → string or nil ;; (browser-prompt msg) → string or nil

View File

@@ -208,10 +208,16 @@
:returns "boolean" :returns "boolean"
:doc "True if x is nil/null/None.") :doc "True if x is nil/null/None.")
(define-primitive "boolean?"
:params (x)
:returns "boolean"
:doc "True if x is a boolean (true or false). Must be checked before
number? on platforms where booleans are numeric subtypes.")
(define-primitive "number?" (define-primitive "number?"
:params (x) :params (x)
:returns "boolean" :returns "boolean"
:doc "True if x is a number (int or float).") :doc "True if x is a number (int or float). Excludes booleans.")
(define-primitive "string?" (define-primitive "string?"
:params (x) :params (x)
@@ -277,11 +283,36 @@
:returns "string" :returns "string"
:doc "Uppercase string.") :doc "Uppercase string.")
(define-primitive "upcase"
:params (s)
:returns "string"
:doc "Alias for upper. Uppercase string.")
(define-primitive "lower" (define-primitive "lower"
:params (s) :params (s)
:returns "string" :returns "string"
:doc "Lowercase string.") :doc "Lowercase string.")
(define-primitive "downcase"
:params (s)
:returns "string"
:doc "Alias for lower. Lowercase string.")
(define-primitive "string-length"
:params (s)
:returns "number"
:doc "Length of string in characters.")
(define-primitive "substring"
:params (s start end)
:returns "string"
:doc "Extract substring from start (inclusive) to end (exclusive).")
(define-primitive "string-contains?"
:params (s needle)
:returns "boolean"
:doc "True if string s contains substring needle.")
(define-primitive "trim" (define-primitive "trim"
:params (s) :params (s)
:returns "string" :returns "string"
@@ -382,7 +413,22 @@
(define-primitive "append" (define-primitive "append"
:params (coll x) :params (coll x)
:returns "list" :returns "list"
:doc "Append x to end of coll (returns new list).") :doc "If x is a list, concatenate. Otherwise append x as single element.")
(define-primitive "append!"
:params (coll x)
:returns "list"
:doc "Mutate coll by appending x in-place. Returns coll.")
(define-primitive "reverse"
:params (coll)
:returns "list"
:doc "Return coll in reverse order.")
(define-primitive "flatten"
:params (coll)
:returns "list"
:doc "Flatten one level of nesting. Nested lists become top-level elements.")
(define-primitive "chunk-every" (define-primitive "chunk-every"
:params (coll n) :params (coll n)
@@ -416,6 +462,11 @@
:returns "dict" :returns "dict"
:doc "Merge dicts left to right. Later keys win. Skips nil.") :doc "Merge dicts left to right. Later keys win. Skips nil.")
(define-primitive "has-key?"
:params (d key)
:returns "boolean"
:doc "True if dict d contains key.")
(define-primitive "assoc" (define-primitive "assoc"
:params (d &rest pairs) :params (d &rest pairs)
:returns "dict" :returns "dict"
@@ -426,6 +477,11 @@
:returns "dict" :returns "dict"
:doc "Return new dict with keys removed.") :doc "Return new dict with keys removed.")
(define-primitive "dict-set!"
:params (d key val)
:returns "any"
:doc "Mutate dict d by setting key to val in-place. Returns val.")
(define-primitive "into" (define-primitive "into"
:params (target coll) :params (target coll)
:returns "any" :returns "any"

View File

@@ -124,6 +124,75 @@
(keys attrs))))) (keys attrs)))))
;; --------------------------------------------------------------------------
;; Render adapter helpers
;; --------------------------------------------------------------------------
;; Shared by HTML and DOM adapters for evaluating control forms during
;; rendering. Unlike sf-cond (eval.sx) which returns a thunk for TCO,
;; eval-cond returns the unevaluated body expression so the adapter
;; can render it in its own mode (HTML string vs DOM nodes).
;; eval-cond: find matching cond branch, return unevaluated body expr.
;; Handles both scheme-style ((test body) ...) and clojure-style
;; (test body test body ...).
(define eval-cond
(fn (clauses env)
(if (and (not (empty? clauses))
(= (type-of (first clauses)) "list")
(= (len (first clauses)) 2))
;; Scheme-style
(eval-cond-scheme clauses env)
;; Clojure-style
(eval-cond-clojure clauses env))))
(define eval-cond-scheme
(fn (clauses env)
(if (empty? clauses)
nil
(let ((clause (first clauses))
(test (first clause))
(body (nth clause 1)))
(if (or (and (= (type-of test) "symbol")
(or (= (symbol-name test) "else")
(= (symbol-name test) ":else")))
(and (= (type-of test) "keyword")
(= (keyword-name test) "else")))
body
(if (trampoline (eval-expr test env))
body
(eval-cond-scheme (rest clauses) env)))))))
(define eval-cond-clojure
(fn (clauses env)
(if (< (len clauses) 2)
nil
(let ((test (first clauses))
(body (nth clauses 1)))
(if (or (and (= (type-of test) "keyword") (= (keyword-name test) "else"))
(and (= (type-of test) "symbol")
(or (= (symbol-name test) "else")
(= (symbol-name test) ":else"))))
body
(if (trampoline (eval-expr test env))
body
(eval-cond-clojure (slice clauses 2) env)))))))
;; process-bindings: evaluate let-binding pairs, return extended env.
;; bindings = ((name1 expr1) (name2 expr2) ...)
(define process-bindings
(fn (bindings env)
(let ((local (merge env)))
(for-each
(fn (pair)
(when (and (= (type-of pair) "list") (>= (len pair) 2))
(let ((name (if (= (type-of (first pair)) "symbol")
(symbol-name (first pair))
(str (first pair)))))
(env-set! local name (trampoline (eval-expr (nth pair 1) local))))))
bindings)
local)))
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
;; Platform interface (shared across adapters) ;; Platform interface (shared across adapters)
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------

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 (- (len trimmed) 1))
trimmed)))
(if (empty? trimmed2)
(list)
(split trimmed2 "/"))))))
;; --------------------------------------------------------------------------
;; 2. Parse Flask-style route pattern into segment descriptors
;; --------------------------------------------------------------------------
;; "/docs/<slug>" → ({"type" "literal" "value" "docs"}
;; {"type" "param" "value" "slug"})
(define make-route-segment
(fn (seg)
(if (and (starts-with? seg "<") (ends-with? seg ">"))
(let ((param-name (slice seg 1 (- (len seg) 1))))
(let ((d {}))
(dict-set! d "type" "param")
(dict-set! d "value" param-name)
d))
(let ((d {}))
(dict-set! d "type" "literal")
(dict-set! d "value" seg)
d))))
(define parse-route-pattern
(fn (pattern)
(let ((segments (split-path-segments pattern)))
(map make-route-segment segments))))
;; --------------------------------------------------------------------------
;; 3. Match path segments against parsed pattern
;; --------------------------------------------------------------------------
;; Returns params dict if match, nil if no match.
(define match-route-segments
(fn (path-segs parsed-segs)
(if (not (= (len path-segs) (len parsed-segs)))
nil
(let ((params {})
(matched true))
(for-each-indexed
(fn (i parsed-seg)
(when matched
(let ((path-seg (nth path-segs i))
(seg-type (get parsed-seg "type")))
(cond
(= seg-type "literal")
(when (not (= path-seg (get parsed-seg "value")))
(set! matched false))
(= seg-type "param")
(dict-set! params (get parsed-seg "value") path-seg)
:else
(set! matched false)))))
parsed-segs)
(if matched params nil)))))
;; --------------------------------------------------------------------------
;; 4. Public API: match a URL path against a pattern string
;; --------------------------------------------------------------------------
;; Returns params dict (may be empty for exact matches) or nil.
(define match-route
(fn (path pattern)
(let ((path-segs (split-path-segments path))
(parsed-segs (parse-route-pattern pattern)))
(match-route-segments path-segs parsed-segs))))
;; --------------------------------------------------------------------------
;; 5. Search a list of route entries for first match
;; --------------------------------------------------------------------------
;; Each entry: {"pattern" "/docs/<slug>" "parsed" [...] "name" "docs-page" ...}
;; Returns matching entry with "params" added, or nil.
(define find-matching-route
(fn (path routes)
(let ((path-segs (split-path-segments path))
(result nil))
(for-each
(fn (route)
(when (nil? result)
(let ((params (match-route-segments path-segs (get route "parsed"))))
(when (not (nil? params))
(let ((matched (merge route {})))
(dict-set! matched "params" params)
(set! result matched))))))
routes)
result)))
;; --------------------------------------------------------------------------
;; Platform interface — none required
;; --------------------------------------------------------------------------
;; All functions use only pure primitives:
;; split, slice, starts-with?, ends-with?, len, empty?,
;; map, for-each, for-each-indexed, nth, get, dict-set!, merge,
;; list, nil?, not, =
;; --------------------------------------------------------------------------

View File

@@ -876,6 +876,9 @@ range = PRIMITIVES["range"]
apply = lambda f, args: f(*args) apply = lambda f, args: f(*args)
assoc = PRIMITIVES["assoc"] assoc = PRIMITIVES["assoc"]
concat = PRIMITIVES["concat"] concat = PRIMITIVES["concat"]
split = PRIMITIVES["split"]
length = PRIMITIVES["len"]
merge = PRIMITIVES["merge"]
# ========================================================================= # =========================================================================
@@ -1137,6 +1140,18 @@ parse_element_args = lambda args, env: (lambda attrs: (lambda children: _sx_begi
# render-attrs # render-attrs
render_attrs = lambda attrs: join('', map(lambda key: (lambda val: (sx_str(' ', key) if sx_truthy((contains_p(BOOLEAN_ATTRS, key) if not sx_truthy(contains_p(BOOLEAN_ATTRS, key)) else val)) else ('' if sx_truthy((contains_p(BOOLEAN_ATTRS, key) if not sx_truthy(contains_p(BOOLEAN_ATTRS, key)) else (not sx_truthy(val)))) else ('' if sx_truthy(is_nil(val)) else (sx_str(' class="', style_value_class(val), '"') if sx_truthy(((key == 'style') if not sx_truthy((key == 'style')) else is_style_value(val))) else sx_str(' ', key, '="', escape_attr(sx_str(val)), '"'))))))(dict_get(attrs, key)), keys(attrs))) render_attrs = lambda attrs: join('', map(lambda key: (lambda val: (sx_str(' ', key) if sx_truthy((contains_p(BOOLEAN_ATTRS, key) if not sx_truthy(contains_p(BOOLEAN_ATTRS, key)) else val)) else ('' if sx_truthy((contains_p(BOOLEAN_ATTRS, key) if not sx_truthy(contains_p(BOOLEAN_ATTRS, key)) else (not sx_truthy(val)))) else ('' if sx_truthy(is_nil(val)) else (sx_str(' class="', style_value_class(val), '"') if sx_truthy(((key == 'style') if not sx_truthy((key == 'style')) else is_style_value(val))) else sx_str(' ', key, '="', escape_attr(sx_str(val)), '"'))))))(dict_get(attrs, key)), keys(attrs)))
# eval-cond
eval_cond = lambda clauses, env: (eval_cond_scheme(clauses, env) if sx_truthy(((not sx_truthy(empty_p(clauses))) if not sx_truthy((not sx_truthy(empty_p(clauses)))) else ((type_of(first(clauses)) == 'list') if not sx_truthy((type_of(first(clauses)) == 'list')) else (len(first(clauses)) == 2)))) else eval_cond_clojure(clauses, env))
# eval-cond-scheme
eval_cond_scheme = lambda clauses, env: (NIL if sx_truthy(empty_p(clauses)) else (lambda clause: (lambda test: (lambda body: (body if sx_truthy((((type_of(test) == 'symbol') if not sx_truthy((type_of(test) == 'symbol')) else ((symbol_name(test) == 'else') if sx_truthy((symbol_name(test) == 'else')) else (symbol_name(test) == ':else'))) if sx_truthy(((type_of(test) == 'symbol') if not sx_truthy((type_of(test) == 'symbol')) else ((symbol_name(test) == 'else') if sx_truthy((symbol_name(test) == 'else')) else (symbol_name(test) == ':else')))) else ((type_of(test) == 'keyword') if not sx_truthy((type_of(test) == 'keyword')) else (keyword_name(test) == 'else')))) else (body if sx_truthy(trampoline(eval_expr(test, env))) else eval_cond_scheme(rest(clauses), env))))(nth(clause, 1)))(first(clause)))(first(clauses)))
# eval-cond-clojure
eval_cond_clojure = lambda clauses, env: (NIL if sx_truthy((len(clauses) < 2)) else (lambda test: (lambda body: (body if sx_truthy((((type_of(test) == 'keyword') if not sx_truthy((type_of(test) == 'keyword')) else (keyword_name(test) == 'else')) if sx_truthy(((type_of(test) == 'keyword') if not sx_truthy((type_of(test) == 'keyword')) else (keyword_name(test) == 'else'))) else ((type_of(test) == 'symbol') if not sx_truthy((type_of(test) == 'symbol')) else ((symbol_name(test) == 'else') if sx_truthy((symbol_name(test) == 'else')) else (symbol_name(test) == ':else'))))) else (body if sx_truthy(trampoline(eval_expr(test, env))) else eval_cond_clojure(slice(clauses, 2), env))))(nth(clauses, 1)))(first(clauses)))
# process-bindings
process_bindings = lambda bindings, env: (lambda local: _sx_begin(for_each(lambda pair: ((lambda name: _sx_dict_set(local, name, trampoline(eval_expr(nth(pair, 1), local))))((symbol_name(first(pair)) if sx_truthy((type_of(first(pair)) == 'symbol')) else sx_str(first(pair)))) if sx_truthy(((type_of(pair) == 'list') if not sx_truthy((type_of(pair) == 'list')) else (len(pair) >= 2))) else NIL), bindings), local))(merge(env))
# === Transpiled from adapter-html === # === Transpiled from adapter-html ===
@@ -1237,6 +1252,40 @@ compute_all_io_refs = lambda env, io_names: for_each(lambda name: (lambda val: (
component_pure_p = lambda name, env, io_names: empty_p(transitive_io_refs(name, env, io_names)) 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 # Fixups -- wire up render adapter dispatch
# ========================================================================= # =========================================================================

597
shared/sx/ref/test.sx Normal file
View File

@@ -0,0 +1,597 @@
;; ==========================================================================
;; test.sx — Self-hosting SX test framework
;;
;; Defines a minimal test framework in SX that tests SX — the language
;; proves its own correctness. The framework is self-executing: any host
;; that provides 5 platform functions can evaluate this file directly.
;;
;; Platform functions required:
;; try-call (thunk) → {:ok true} | {:ok false :error "msg"}
;; report-pass (name) → platform-specific pass output
;; report-fail (name error) → platform-specific fail output
;; push-suite (name) → push suite name onto context stack
;; pop-suite () → pop suite name from context stack
;;
;; Usage:
;; ;; Host injects platform functions into env, then:
;; (eval-file "test.sx" env)
;;
;; The same test.sx runs on every host — Python, JavaScript, etc.
;; ==========================================================================
;; --------------------------------------------------------------------------
;; 1. Test framework macros
;; --------------------------------------------------------------------------
;;
;; deftest and defsuite are macros that make test.sx directly executable.
;; The host provides try-call (error catching), reporting, and suite
;; context — everything else is pure SX.
(defmacro deftest (name &rest body)
`(let ((result (try-call (fn () ,@body))))
(if (get result "ok")
(report-pass ,name)
(report-fail ,name (get result "error")))))
(defmacro defsuite (name &rest items)
`(do (push-suite ,name)
,@items
(pop-suite)))
;; --------------------------------------------------------------------------
;; 2. Assertion helpers — defined in SX, available in test bodies
;; --------------------------------------------------------------------------
;;
;; These are regular functions (not special forms). They use the `assert`
;; primitive underneath but provide better error messages.
(define assert-equal
(fn (expected actual)
(assert (equal? expected actual)
(str "Expected " (str expected) " but got " (str actual)))))
(define assert-not-equal
(fn (a b)
(assert (not (equal? a b))
(str "Expected values to differ but both are " (str a)))))
(define assert-true
(fn (val)
(assert val (str "Expected truthy but got " (str val)))))
(define assert-false
(fn (val)
(assert (not val) (str "Expected falsy but got " (str val)))))
(define assert-nil
(fn (val)
(assert (nil? val) (str "Expected nil but got " (str val)))))
(define assert-type
(fn (expected-type val)
;; Implemented via predicate dispatch since type-of is a platform
;; function not available in all hosts. Uses nested if to avoid
;; Scheme-style cond detection for 2-element predicate calls.
;; Boolean checked before number (subtypes on some platforms).
(let ((actual-type
(if (nil? val) "nil"
(if (boolean? val) "boolean"
(if (number? val) "number"
(if (string? val) "string"
(if (list? val) "list"
(if (dict? val) "dict"
"unknown"))))))))
(assert (= expected-type actual-type)
(str "Expected type " expected-type " but got " actual-type)))))
(define assert-length
(fn (expected-len col)
(assert (= (len col) expected-len)
(str "Expected length " expected-len " but got " (len col)))))
(define assert-contains
(fn (item col)
(assert (some (fn (x) (equal? x item)) col)
(str "Expected collection to contain " (str item)))))
(define assert-throws
(fn (thunk)
(let ((result (try-call thunk)))
(assert (not (get result "ok"))
"Expected an error to be thrown but none was"))))
;; ==========================================================================
;; 3. Test suites — SX testing SX
;; ==========================================================================
;; --------------------------------------------------------------------------
;; 3a. Literals and types
;; --------------------------------------------------------------------------
(defsuite "literals"
(deftest "numbers are numbers"
(assert-type "number" 42)
(assert-type "number" 3.14)
(assert-type "number" -1))
(deftest "strings are strings"
(assert-type "string" "hello")
(assert-type "string" ""))
(deftest "booleans are booleans"
(assert-type "boolean" true)
(assert-type "boolean" false))
(deftest "nil is nil"
(assert-type "nil" nil)
(assert-nil nil))
(deftest "lists are lists"
(assert-type "list" (list 1 2 3))
(assert-type "list" (list)))
(deftest "dicts are dicts"
(assert-type "dict" {:a 1 :b 2})))
;; --------------------------------------------------------------------------
;; 3b. Arithmetic
;; --------------------------------------------------------------------------
(defsuite "arithmetic"
(deftest "addition"
(assert-equal 3 (+ 1 2))
(assert-equal 0 (+ 0 0))
(assert-equal -1 (+ 1 -2))
(assert-equal 10 (+ 1 2 3 4)))
(deftest "subtraction"
(assert-equal 1 (- 3 2))
(assert-equal -1 (- 2 3)))
(deftest "multiplication"
(assert-equal 6 (* 2 3))
(assert-equal 0 (* 0 100))
(assert-equal 24 (* 1 2 3 4)))
(deftest "division"
(assert-equal 2 (/ 6 3))
(assert-equal 2.5 (/ 5 2)))
(deftest "modulo"
(assert-equal 1 (mod 7 3))
(assert-equal 0 (mod 6 3))))
;; --------------------------------------------------------------------------
;; 3c. Comparison
;; --------------------------------------------------------------------------
(defsuite "comparison"
(deftest "equality"
(assert-true (= 1 1))
(assert-false (= 1 2))
(assert-true (= "a" "a"))
(assert-false (= "a" "b")))
(deftest "deep equality"
(assert-true (equal? (list 1 2 3) (list 1 2 3)))
(assert-false (equal? (list 1 2) (list 1 3)))
(assert-true (equal? {:a 1} {:a 1}))
(assert-false (equal? {:a 1} {:a 2})))
(deftest "ordering"
(assert-true (< 1 2))
(assert-false (< 2 1))
(assert-true (> 2 1))
(assert-true (<= 1 1))
(assert-true (<= 1 2))
(assert-true (>= 2 2))
(assert-true (>= 3 2))))
;; --------------------------------------------------------------------------
;; 3d. String operations
;; --------------------------------------------------------------------------
(defsuite "strings"
(deftest "str concatenation"
(assert-equal "abc" (str "a" "b" "c"))
(assert-equal "hello world" (str "hello" " " "world"))
(assert-equal "42" (str 42))
(assert-equal "" (str)))
(deftest "string-length"
(assert-equal 5 (string-length "hello"))
(assert-equal 0 (string-length "")))
(deftest "substring"
(assert-equal "ell" (substring "hello" 1 4))
(assert-equal "hello" (substring "hello" 0 5)))
(deftest "string-contains?"
(assert-true (string-contains? "hello world" "world"))
(assert-false (string-contains? "hello" "xyz")))
(deftest "upcase and downcase"
(assert-equal "HELLO" (upcase "hello"))
(assert-equal "hello" (downcase "HELLO")))
(deftest "trim"
(assert-equal "hello" (trim " hello "))
(assert-equal "hello" (trim "hello")))
(deftest "split and join"
(assert-equal (list "a" "b" "c") (split "a,b,c" ","))
(assert-equal "a-b-c" (join "-" (list "a" "b" "c")))))
;; --------------------------------------------------------------------------
;; 3e. List operations
;; --------------------------------------------------------------------------
(defsuite "lists"
(deftest "constructors"
(assert-equal (list 1 2 3) (list 1 2 3))
(assert-equal (list) (list))
(assert-length 3 (list 1 2 3)))
(deftest "first and rest"
(assert-equal 1 (first (list 1 2 3)))
(assert-equal (list 2 3) (rest (list 1 2 3)))
(assert-nil (first (list)))
(assert-equal (list) (rest (list))))
(deftest "nth"
(assert-equal 1 (nth (list 1 2 3) 0))
(assert-equal 2 (nth (list 1 2 3) 1))
(assert-equal 3 (nth (list 1 2 3) 2)))
(deftest "last"
(assert-equal 3 (last (list 1 2 3)))
(assert-nil (last (list))))
(deftest "cons and append"
(assert-equal (list 0 1 2) (cons 0 (list 1 2)))
(assert-equal (list 1 2 3 4) (append (list 1 2) (list 3 4))))
(deftest "reverse"
(assert-equal (list 3 2 1) (reverse (list 1 2 3)))
(assert-equal (list) (reverse (list))))
(deftest "empty?"
(assert-true (empty? (list)))
(assert-false (empty? (list 1))))
(deftest "len"
(assert-equal 0 (len (list)))
(assert-equal 3 (len (list 1 2 3))))
(deftest "contains?"
(assert-true (contains? (list 1 2 3) 2))
(assert-false (contains? (list 1 2 3) 4)))
(deftest "flatten"
(assert-equal (list 1 2 3 4) (flatten (list (list 1 2) (list 3 4))))))
;; --------------------------------------------------------------------------
;; 3f. Dict operations
;; --------------------------------------------------------------------------
(defsuite "dicts"
(deftest "dict literal"
(assert-type "dict" {:a 1 :b 2})
(assert-equal 1 (get {:a 1} "a"))
(assert-equal 2 (get {:a 1 :b 2} "b")))
(deftest "assoc"
(assert-equal {:a 1 :b 2} (assoc {:a 1} "b" 2))
(assert-equal {:a 99} (assoc {:a 1} "a" 99)))
(deftest "dissoc"
(assert-equal {:b 2} (dissoc {:a 1 :b 2} "a")))
(deftest "keys and vals"
(let ((d {:a 1 :b 2}))
(assert-length 2 (keys d))
(assert-length 2 (vals d))
(assert-contains "a" (keys d))
(assert-contains "b" (keys d))))
(deftest "has-key?"
(assert-true (has-key? {:a 1} "a"))
(assert-false (has-key? {:a 1} "b")))
(deftest "merge"
(assert-equal {:a 1 :b 2 :c 3}
(merge {:a 1 :b 2} {:c 3}))
(assert-equal {:a 99 :b 2}
(merge {:a 1 :b 2} {:a 99}))))
;; --------------------------------------------------------------------------
;; 3g. Predicates
;; --------------------------------------------------------------------------
(defsuite "predicates"
(deftest "nil?"
(assert-true (nil? nil))
(assert-false (nil? 0))
(assert-false (nil? false))
(assert-false (nil? "")))
(deftest "number?"
(assert-true (number? 42))
(assert-true (number? 3.14))
(assert-false (number? "42")))
(deftest "string?"
(assert-true (string? "hello"))
(assert-false (string? 42)))
(deftest "list?"
(assert-true (list? (list 1 2)))
(assert-false (list? "not a list")))
(deftest "dict?"
(assert-true (dict? {:a 1}))
(assert-false (dict? (list 1))))
(deftest "boolean?"
(assert-true (boolean? true))
(assert-true (boolean? false))
(assert-false (boolean? nil))
(assert-false (boolean? 0)))
(deftest "not"
(assert-true (not false))
(assert-true (not nil))
(assert-false (not true))
(assert-false (not 1))
(assert-false (not "x"))))
;; --------------------------------------------------------------------------
;; 3h. Special forms
;; --------------------------------------------------------------------------
(defsuite "special-forms"
(deftest "if"
(assert-equal "yes" (if true "yes" "no"))
(assert-equal "no" (if false "yes" "no"))
(assert-equal "no" (if nil "yes" "no"))
(assert-nil (if false "yes")))
(deftest "when"
(assert-equal "yes" (when true "yes"))
(assert-nil (when false "yes")))
(deftest "cond"
(assert-equal "a" (cond true "a" :else "b"))
(assert-equal "b" (cond false "a" :else "b"))
(assert-equal "c" (cond
false "a"
false "b"
:else "c")))
(deftest "and"
(assert-true (and true true))
(assert-false (and true false))
(assert-false (and false true))
(assert-equal 3 (and 1 2 3)))
(deftest "or"
(assert-equal 1 (or 1 2))
(assert-equal 2 (or false 2))
(assert-equal "fallback" (or nil false "fallback"))
(assert-false (or false false)))
(deftest "let"
(assert-equal 3 (let ((x 1) (y 2)) (+ x y)))
(assert-equal "hello world"
(let ((a "hello") (b " world")) (str a b))))
(deftest "let clojure-style"
(assert-equal 3 (let (x 1 y 2) (+ x y))))
(deftest "do / begin"
(assert-equal 3 (do 1 2 3))
(assert-equal "last" (begin "first" "middle" "last")))
(deftest "define"
(define x 42)
(assert-equal 42 x))
(deftest "set!"
(define x 1)
(set! x 2)
(assert-equal 2 x)))
;; --------------------------------------------------------------------------
;; 3i. Lambda and closures
;; --------------------------------------------------------------------------
(defsuite "lambdas"
(deftest "basic lambda"
(let ((add (fn (a b) (+ a b))))
(assert-equal 3 (add 1 2))))
(deftest "closure captures env"
(let ((x 10))
(let ((add-x (fn (y) (+ x y))))
(assert-equal 15 (add-x 5)))))
(deftest "lambda as argument"
(assert-equal (list 2 4 6)
(map (fn (x) (* x 2)) (list 1 2 3))))
(deftest "recursive lambda via define"
(define factorial
(fn (n) (if (<= n 1) 1 (* n (factorial (- n 1))))))
(assert-equal 120 (factorial 5)))
(deftest "higher-order returns lambda"
(let ((make-adder (fn (n) (fn (x) (+ n x)))))
(let ((add5 (make-adder 5)))
(assert-equal 8 (add5 3))))))
;; --------------------------------------------------------------------------
;; 3j. Higher-order forms
;; --------------------------------------------------------------------------
(defsuite "higher-order"
(deftest "map"
(assert-equal (list 2 4 6)
(map (fn (x) (* x 2)) (list 1 2 3)))
(assert-equal (list) (map (fn (x) x) (list))))
(deftest "filter"
(assert-equal (list 2 4)
(filter (fn (x) (= (mod x 2) 0)) (list 1 2 3 4)))
(assert-equal (list)
(filter (fn (x) false) (list 1 2 3))))
(deftest "reduce"
(assert-equal 10 (reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3 4)))
(assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list))))
(deftest "some"
(assert-true (some (fn (x) (> x 3)) (list 1 2 3 4 5)))
(assert-false (some (fn (x) (> x 10)) (list 1 2 3))))
(deftest "every?"
(assert-true (every? (fn (x) (> x 0)) (list 1 2 3)))
(assert-false (every? (fn (x) (> x 2)) (list 1 2 3))))
(deftest "map-indexed"
(assert-equal (list "0:a" "1:b" "2:c")
(map-indexed (fn (i x) (str i ":" x)) (list "a" "b" "c")))))
;; --------------------------------------------------------------------------
;; 3k. Components
;; --------------------------------------------------------------------------
(defsuite "components"
(deftest "defcomp creates component"
(defcomp ~test-comp (&key title)
(div title))
;; Component is bound and not nil
(assert-true (not (nil? ~test-comp))))
(deftest "component renders with keyword args"
(defcomp ~greeting (&key name)
(span (str "Hello, " name "!")))
(assert-true (not (nil? ~greeting))))
(deftest "component with children"
(defcomp ~box (&key &rest children)
(div :class "box" children))
(assert-true (not (nil? ~box))))
(deftest "component with default via or"
(defcomp ~label (&key text)
(span (or text "default")))
(assert-true (not (nil? ~label)))))
;; --------------------------------------------------------------------------
;; 3l. Macros
;; --------------------------------------------------------------------------
(defsuite "macros"
(deftest "defmacro creates macro"
(defmacro unless (cond &rest body)
`(if (not ,cond) (do ,@body)))
(assert-equal "yes" (unless false "yes"))
(assert-nil (unless true "no")))
(deftest "quasiquote and unquote"
(let ((x 42))
(assert-equal (list 1 42 3) `(1 ,x 3))))
(deftest "splice-unquote"
(let ((xs (list 2 3 4)))
(assert-equal (list 1 2 3 4 5) `(1 ,@xs 5)))))
;; --------------------------------------------------------------------------
;; 3m. Threading macro
;; --------------------------------------------------------------------------
(defsuite "threading"
(deftest "thread-first"
(assert-equal 8 (-> 5 (+ 1) (+ 2)))
(assert-equal "HELLO" (-> "hello" upcase))
(assert-equal "HELLO WORLD"
(-> "hello"
(str " world")
upcase))))
;; --------------------------------------------------------------------------
;; 3n. Truthiness
;; --------------------------------------------------------------------------
(defsuite "truthiness"
(deftest "truthy values"
(assert-true (if 1 true false))
(assert-true (if "x" true false))
(assert-true (if (list 1) true false))
(assert-true (if true true false)))
(deftest "falsy values"
(assert-false (if false true false))
(assert-false (if nil true false)))
;; NOTE: empty list, zero, and empty string truthiness is
;; platform-dependent. Python treats all three as falsy.
;; JavaScript treats [] as truthy but 0 and "" as falsy.
;; These tests are omitted — each bootstrapper should emit
;; platform-specific truthiness tests instead.
)
;; --------------------------------------------------------------------------
;; 3o. Edge cases and regression tests
;; --------------------------------------------------------------------------
(defsuite "edge-cases"
(deftest "nested let scoping"
(let ((x 1))
(let ((x 2))
(assert-equal 2 x))
;; outer x should be unchanged by inner let
;; (this tests that let creates a new scope)
))
(deftest "recursive map"
(assert-equal (list (list 2 4) (list 6 8))
(map (fn (sub) (map (fn (x) (* x 2)) sub))
(list (list 1 2) (list 3 4)))))
(deftest "keyword as value"
(assert-equal "class" :class)
(assert-equal "id" :id))
(deftest "dict with evaluated values"
(let ((x 42))
(assert-equal 42 (get {:val x} "val"))))
(deftest "nil propagation"
(assert-nil (get {:a 1} "missing"))
(assert-equal "default" (or (get {:a 1} "missing") "default")))
(deftest "empty operations"
(assert-equal (list) (map (fn (x) x) (list)))
(assert-equal (list) (filter (fn (x) true) (list)))
(assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list)))
(assert-equal 0 (len (list)))
(assert-equal "" (str))))

108
shared/sx/tests/run.js Normal file
View File

@@ -0,0 +1,108 @@
// Run test.sx directly against sx-browser.js.
//
// sx-browser.js parses and evaluates test.sx — SX tests itself.
// This script provides only platform functions (error catching, reporting).
//
// Usage: node shared/sx/tests/run.js
Object.defineProperty(globalThis, "document", { value: undefined, writable: true });
var path = require("path");
var fs = require("fs");
var Sx = require(path.resolve(__dirname, "../../static/scripts/sx-browser.js"));
// --- Test state ---
var suiteStack = [];
var passed = 0, failed = 0, testNum = 0;
// --- Helpers ---
function isNil(x) { return x === Sx.NIL || x === null || x === undefined; }
function deepEqual(a, b) {
if (a === b) return true;
if (isNil(a) && isNil(b)) return true;
if (typeof a !== typeof b) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (var i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false;
return true;
}
if (a && typeof a === "object" && b && typeof b === "object") {
var ka = Object.keys(a), kb = Object.keys(b);
if (ka.length !== kb.length) return false;
for (var j = 0; j < ka.length; j++) if (!deepEqual(a[ka[j]], b[ka[j]])) return false;
return true;
}
return false;
}
// --- Platform functions injected into the SX env ---
var env = {
// Error catching — calls an SX thunk, returns result dict
"try-call": function(thunk) {
try {
Sx.eval([thunk], env);
return { ok: true };
} catch(e) {
return { ok: false, error: e.message || String(e) };
}
},
// Test reporting
"report-pass": function(name) {
testNum++;
passed++;
var fullName = suiteStack.concat([name]).join(" > ");
console.log("ok " + testNum + " - " + fullName);
},
"report-fail": function(name, error) {
testNum++;
failed++;
var fullName = suiteStack.concat([name]).join(" > ");
console.log("not ok " + testNum + " - " + fullName);
console.log(" # " + error);
},
// Suite context
"push-suite": function(name) { suiteStack.push(name); },
"pop-suite": function() { suiteStack.pop(); },
// Primitives that sx-browser.js has internally but doesn't expose through env
"equal?": function(a, b) { return deepEqual(a, b); },
"eq?": function(a, b) { return a === b; },
"boolean?": function(x) { return typeof x === "boolean"; },
"string-length": function(s) { return String(s).length; },
"substring": function(s, start, end) { return String(s).slice(start, end); },
"string-contains?": function(s, needle) { return String(s).indexOf(needle) !== -1; },
"upcase": function(s) { return String(s).toUpperCase(); },
"downcase": function(s) { return String(s).toLowerCase(); },
"reverse": function(c) { return c ? c.slice().reverse() : []; },
"flatten": function(c) {
var r = [];
for (var i = 0; i < (c||[]).length; i++) {
if (Array.isArray(c[i])) for (var j = 0; j < c[i].length; j++) r.push(c[i][j]);
else r.push(c[i]);
}
return r;
},
"has-key?": function(d, k) { return d && typeof d === "object" && k in d; },
"append": function(c, x) { return Array.isArray(x) ? (c||[]).concat(x) : (c||[]).concat([x]); },
};
// --- Read and evaluate test.sx ---
var src = fs.readFileSync(path.resolve(__dirname, "../ref/test.sx"), "utf8");
var exprs = Sx.parseAll(src);
console.log("TAP version 13");
for (var i = 0; i < exprs.length; i++) {
Sx.eval(exprs[i], env);
}
// --- Summary ---
console.log("");
console.log("1.." + testNum);
console.log("# tests " + (passed + failed));
console.log("# pass " + passed);
if (failed > 0) {
console.log("# fail " + failed);
process.exit(1);
}

92
shared/sx/tests/run.py Normal file
View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Run test.sx directly against the Python SX evaluator.
The Python evaluator parses and evaluates test.sx — SX tests itself.
This script provides only platform functions (error catching, reporting).
Usage: python shared/sx/tests/run.py
"""
from __future__ import annotations
import os
import sys
import traceback
_HERE = os.path.dirname(os.path.abspath(__file__))
_PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", ".."))
sys.path.insert(0, _PROJECT)
from shared.sx.parser import parse_all
from shared.sx.evaluator import _eval, _trampoline
# --- Test state ---
suite_stack: list[str] = []
passed = 0
failed = 0
test_num = 0
def try_call(thunk):
"""Call an SX thunk, catching errors."""
try:
_trampoline(_eval([thunk], {}))
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
def report_pass(name):
global passed, test_num
test_num += 1
passed += 1
full_name = " > ".join(suite_stack + [name])
print(f"ok {test_num} - {full_name}")
def report_fail(name, error):
global failed, test_num
test_num += 1
failed += 1
full_name = " > ".join(suite_stack + [name])
print(f"not ok {test_num} - {full_name}")
print(f" # {error}")
def push_suite(name):
suite_stack.append(name)
def pop_suite():
suite_stack.pop()
def main():
env = {
"try-call": try_call,
"report-pass": report_pass,
"report-fail": report_fail,
"push-suite": push_suite,
"pop-suite": pop_suite,
}
test_sx = os.path.join(_HERE, "..", "ref", "test.sx")
with open(test_sx) as f:
src = f.read()
exprs = parse_all(src)
print("TAP version 13")
for expr in exprs:
_trampoline(_eval(expr, env))
print()
print(f"1..{test_num}")
print(f"# tests {passed + failed}")
print(f"# pass {passed}")
if failed > 0:
print(f"# fail {failed}")
sys.exit(1)
if __name__ == "__main__":
main()

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,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,387 @@
"""Tests for Phase 5 async IO proxy infrastructure.
Tests the io-deps page registry field, SxExpr serialization through
the IO proxy pipeline, dynamic allowlist construction, and the
orchestration.sx routing logic for IO-dependent pages.
"""
import pytest
from shared.sx.parser import parse_all, serialize, SxExpr
from shared.sx.types import Component, Macro, Symbol, Keyword, NIL
from shared.sx.deps import (
_compute_all_io_refs_fallback,
components_needed,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_env(*sx_sources: str) -> dict:
"""Parse and evaluate component definitions into an env dict."""
from shared.sx.evaluator import _eval, _trampoline
env: dict = {}
for source in sx_sources:
exprs = parse_all(source)
for expr in exprs:
_trampoline(_eval(expr, env))
return env
IO_NAMES = {"highlight", "current-user", "app-url", "config", "fetch-data"}
# ---------------------------------------------------------------------------
# io-deps in page registry entries
# ---------------------------------------------------------------------------
class TestIoDepsSerialization:
"""The page registry should emit :io-deps as a list of IO primitive names."""
def test_pure_page_gets_empty_io_deps(self):
"""Pages with no IO-dependent components get :io-deps ()."""
env = make_env(
'(defcomp ~card (&key title) (div title))',
)
_compute_all_io_refs_fallback(env, IO_NAMES)
deps = {"~card"}
io_deps: set[str] = set()
for dep_name in deps:
comp = env.get(dep_name)
if isinstance(comp, Component) and comp.io_refs:
io_deps.update(comp.io_refs)
assert io_deps == set()
def test_io_page_gets_io_dep_names(self):
"""Pages with IO-dependent components get :io-deps ("highlight" ...)."""
env = make_env(
'(defcomp ~code-block (&key src) (pre (highlight src "lisp")))',
)
_compute_all_io_refs_fallback(env, IO_NAMES)
deps = {"~code-block"}
io_deps: set[str] = set()
for dep_name in deps:
comp = env.get(dep_name)
if isinstance(comp, Component) and comp.io_refs:
io_deps.update(comp.io_refs)
assert io_deps == {"highlight"}
def test_multiple_io_deps_collected(self):
"""Multiple distinct IO primitives from different components are unioned."""
env = make_env(
'(defcomp ~nav (&key) (nav (app-url "/")))',
'(defcomp ~page (&key) (div (~nav) (config "key")))',
)
_compute_all_io_refs_fallback(env, IO_NAMES)
deps = {"~nav", "~page"}
io_deps: set[str] = set()
for dep_name in deps:
comp = env.get(dep_name)
if isinstance(comp, Component) and comp.io_refs:
io_deps.update(comp.io_refs)
assert io_deps == {"app-url", "config"}
def test_transitive_io_deps_included(self):
"""IO deps from transitive component dependencies are included."""
env = make_env(
'(defcomp ~inner (&key) (div (highlight "code" "lisp")))',
'(defcomp ~outer (&key) (div (~inner)))',
)
_compute_all_io_refs_fallback(env, IO_NAMES)
deps = {"~inner", "~outer"}
io_deps: set[str] = set()
for dep_name in deps:
comp = env.get(dep_name)
if isinstance(comp, Component) and comp.io_refs:
io_deps.update(comp.io_refs)
# Both components transitively depend on highlight
assert "highlight" in io_deps
def test_io_deps_sx_format(self):
"""io-deps serializes as a proper SX list of strings."""
from shared.sx.helpers import _sx_literal
io_deps = {"highlight", "config"}
io_deps_sx = (
"(" + " ".join(_sx_literal(n) for n in sorted(io_deps)) + ")"
)
assert io_deps_sx == '("config" "highlight")'
# Parse it back
parsed = parse_all(io_deps_sx)
assert len(parsed) == 1
assert parsed[0] == ["config", "highlight"]
def test_empty_io_deps_sx_format(self):
io_deps_sx = "()"
parsed = parse_all(io_deps_sx)
assert len(parsed) == 1
assert parsed[0] == []
# ---------------------------------------------------------------------------
# Dynamic IO allowlist from component IO refs
# ---------------------------------------------------------------------------
class TestDynamicAllowlist:
"""The IO proxy allowlist should be built from component IO refs."""
def test_allowlist_from_env(self):
"""Union of all component io_refs gives the allowlist."""
env = make_env(
'(defcomp ~a (&key) (div (highlight "x" "lisp")))',
'(defcomp ~b (&key) (div (config "key")))',
'(defcomp ~c (&key) (div "pure"))',
)
_compute_all_io_refs_fallback(env, IO_NAMES)
allowed: set[str] = set()
for val in env.values():
if isinstance(val, Component) and val.io_refs:
allowed.update(val.io_refs)
assert "highlight" in allowed
assert "config" in allowed
assert len(allowed) == 2 # only these two
def test_pure_env_has_empty_allowlist(self):
"""An env with only pure components yields empty allowlist."""
env = make_env(
'(defcomp ~a (&key) (div "hello"))',
'(defcomp ~b (&key) (span "world"))',
)
_compute_all_io_refs_fallback(env, IO_NAMES)
allowed: set[str] = set()
for val in env.values():
if isinstance(val, Component) and val.io_refs:
allowed.update(val.io_refs)
assert allowed == set()
# ---------------------------------------------------------------------------
# SxExpr serialization through IO proxy pipeline
# ---------------------------------------------------------------------------
class TestSxExprIoRoundtrip:
"""SxExpr (from highlight etc.) must survive serialize → parse."""
def test_sxexpr_serializes_unquoted(self):
"""SxExpr is emitted as raw SX source, not as a quoted string."""
expr = SxExpr('(span :class "text-red-500" "hello")')
sx = serialize(expr)
assert sx == '(span :class "text-red-500" "hello")'
assert not sx.startswith('"')
def test_sxexpr_roundtrip(self):
"""SxExpr → serialize → parse → yields an AST list."""
expr = SxExpr('(span :class "text-violet-600" "keyword")')
sx = serialize(expr)
parsed = parse_all(sx)
assert len(parsed) == 1
# Should be a list: [Symbol("span"), Keyword("class"), "text-violet-600", "keyword"]
node = parsed[0]
assert isinstance(node, list)
assert isinstance(node[0], Symbol)
assert node[0].name == "span"
def test_fragment_sxexpr_roundtrip(self):
"""Fragment SxExpr with multiple children."""
expr = SxExpr(
'(<> (span :class "text-red-500" "if") '
'(span " ") '
'(span :class "text-green-500" "true"))'
)
sx = serialize(expr)
parsed = parse_all(sx)
assert len(parsed) == 1
node = parsed[0]
assert isinstance(node, list)
assert node[0].name == "<>"
def test_nil_serializes_as_nil(self):
"""None result from IO proxy serializes as 'nil'."""
sx = serialize(None)
assert sx == "nil"
parsed = parse_all(sx)
assert parsed[0] is NIL or parsed[0] is None
def test_sxexpr_in_dict_value(self):
"""SxExpr as a dict value serializes inline (not quoted)."""
expr = SxExpr('(span "hello")')
data = {"code": expr}
sx = serialize(data)
# Should be {:code (span "hello")} not {:code "(span \"hello\")"}
assert '(span "hello")' in sx
parsed = parse_all(sx)
d = parsed[0]
# The value should be a list (AST), not a string
assert isinstance(d["code"], list)
# ---------------------------------------------------------------------------
# IO proxy arg parsing (GET query string vs POST JSON body)
# ---------------------------------------------------------------------------
class TestIoProxyArgParsing:
"""Test the arg extraction logic used by the IO proxy."""
def test_get_args_from_query_string(self):
"""GET: _arg0, _arg1, ... become positional args."""
query = {"_arg0": "(defcomp ~card ...)", "_arg1": "lisp"}
args = []
kwargs = {}
for k, v in query.items():
if k.startswith("_arg"):
args.append(v)
else:
kwargs[k] = v
assert args == ["(defcomp ~card ...)", "lisp"]
assert kwargs == {}
def test_get_kwargs_from_query_string(self):
"""GET: non-_arg keys become kwargs."""
query = {"_arg0": "code", "language": "python"}
args = []
kwargs = {}
for k, v in query.items():
if k.startswith("_arg"):
args.append(v)
else:
kwargs[k] = v
assert args == ["code"]
assert kwargs == {"language": "python"}
def test_post_json_body(self):
"""POST: args and kwargs from JSON body."""
body = {"args": ["(defcomp ~card ...)", "lisp"], "kwargs": {}}
args = body.get("args", [])
kwargs = body.get("kwargs", {})
assert args == ["(defcomp ~card ...)", "lisp"]
assert kwargs == {}
# ---------------------------------------------------------------------------
# IO-aware client routing logic (orchestration.sx)
# ---------------------------------------------------------------------------
class TestIoRoutingLogic:
"""Test the orchestration.sx routing decisions for IO pages.
Uses the SX evaluator to run the actual routing logic.
"""
def _eval(self, src, env):
from shared.sx.evaluator import _eval, _trampoline
result = None
for expr in parse_all(src):
result = _trampoline(_eval(expr, env))
return result
def test_io_deps_list_truthiness(self):
"""A non-empty io-deps list is truthy, empty is falsy."""
env = make_env()
# Non-empty list — (and io-deps (not (empty? io-deps))) is truthy
result = self._eval(
'(let ((io-deps (list "highlight")))'
' (if (and io-deps (not (empty? io-deps))) true false))',
env,
)
assert result is True
# Empty list — (and io-deps (not (empty? io-deps))) is falsy
# (and short-circuits: empty list is falsy, returns [])
result = self._eval(
'(let ((io-deps (list)))'
' (if (and io-deps (not (empty? io-deps))) true false))',
env,
)
assert result is False
def test_io_deps_from_parsed_page_entry(self):
"""io-deps field round-trips through serialize → parse correctly."""
entry_sx = '{:name "test" :io-deps ("highlight" "config")}'
parsed = parse_all(entry_sx)
entry = parsed[0]
env = make_env()
env["entry"] = entry
io_deps = self._eval('(get entry "io-deps")', env)
assert io_deps == ["highlight", "config"]
has_io = self._eval(
'(let ((d (get entry "io-deps")))'
' (and d (not (empty? d))))',
env,
)
assert has_io is True
def test_empty_io_deps_from_parsed_page_entry(self):
"""Empty io-deps list means page is pure."""
entry_sx = '{:name "test" :io-deps ()}'
parsed = parse_all(entry_sx)
entry = parsed[0]
env = make_env()
env["entry"] = entry
has_io = self._eval(
'(let ((d (get entry "io-deps")))'
' (if (and d (not (empty? d))) true false))',
env,
)
assert has_io is False
# ---------------------------------------------------------------------------
# Cache key determinism for IO proxy
# ---------------------------------------------------------------------------
class TestIoCacheKey:
"""The client-side IO cache keys by name + args. Verify determinism."""
def test_same_args_same_key(self):
"""Identical calls produce identical cache keys."""
def make_key(name, args, kwargs=None):
key = name
for a in args:
key += "\0" + str(a)
if kwargs:
for k, v in sorted(kwargs.items()):
key += "\0" + k + "=" + str(v)
return key
k1 = make_key("highlight", ["(div 1)", "lisp"])
k2 = make_key("highlight", ["(div 1)", "lisp"])
assert k1 == k2
def test_different_args_different_key(self):
def make_key(name, args):
key = name
for a in args:
key += "\0" + str(a)
return key
k1 = make_key("highlight", ["(div 1)", "lisp"])
k2 = make_key("highlight", ["(div 2)", "lisp"])
assert k1 != k2
def test_different_name_different_key(self):
def make_key(name, args):
key = name
for a in args:
key += "\0" + str(a)
return key
k1 = make_key("highlight", ["code"])
k2 = make_key("config", ["code"])
assert k1 != k2

View File

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

View File

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

@@ -20,19 +20,39 @@ SX_TEST_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sx-te
def _js_render(sx_text: str, components_text: str = "") -> str: def _js_render(sx_text: str, components_text: str = "") -> str:
"""Run sx.js + sx-test.js in Node and return the renderToString result.""" """Run sx.js + sx-test.js in Node and return the renderToString result."""
# Build a small Node script import tempfile, os
# Build a small Node script that requires the source files
script = f""" script = f"""
global.document = undefined; // no DOM needed for string render globalThis.document = undefined; // no DOM needed for string render
// sx.js IIFE uses (typeof window !== "undefined" ? window : this).
// In Node file mode, `this` is module.exports, not globalThis.
// Patch: make the IIFE target globalThis so Sx is accessible.
var _origThis = this;
Object.defineProperty(globalThis, 'document', {{ value: undefined, writable: true }});
(function() {{
var _savedThis = globalThis;
{SX_JS.read_text()} {SX_JS.read_text()}
// Hoist Sx from module.exports to globalThis if needed
if (typeof Sx === 'undefined' && typeof module !== 'undefined' && module.exports && module.exports.Sx) {{
globalThis.Sx = module.exports.Sx;
}}
}}).call(globalThis);
{SX_TEST_JS.read_text()} {SX_TEST_JS.read_text()}
if ({json.dumps(components_text)}) Sx.loadComponents({json.dumps(components_text)}); if ({json.dumps(components_text)}) Sx.loadComponents({json.dumps(components_text)});
var result = Sx.renderToString({json.dumps(sx_text)}); var result = Sx.renderToString({json.dumps(sx_text)});
process.stdout.write(result); process.stdout.write(result);
""" """
# Write to temp file to avoid OS arg length limits
fd, tmp = tempfile.mkstemp(suffix=".js")
try:
with os.fdopen(fd, "w") as f:
f.write(script)
result = subprocess.run( result = subprocess.run(
["node", "-e", script], ["node", tmp],
capture_output=True, text=True, timeout=5, capture_output=True, text=True, timeout=5,
) )
finally:
os.unlink(tmp)
if result.returncode != 0: if result.returncode != 0:
pytest.fail(f"Node.js error:\n{result.stderr}") pytest.fail(f"Node.js error:\n{result.stderr}")
return result.stdout return result.stdout

View File

@@ -0,0 +1,343 @@
"""Auto-generated from test.sx — SX spec self-tests.
DO NOT EDIT. Regenerate with:
python shared/sx/ref/bootstrap_test.py --output shared/sx/tests/test_sx_spec.py
"""
from __future__ import annotations
import pytest
from shared.sx.parser import parse_all
from shared.sx.evaluator import _eval, _trampoline
_PREAMBLE = '''(define assert-equal (fn (expected actual) (assert (equal? expected actual) (str "Expected " (str expected) " but got " (str actual)))))
(define assert-not-equal (fn (a b) (assert (not (equal? a b)) (str "Expected values to differ but both are " (str a)))))
(define assert-true (fn (val) (assert val (str "Expected truthy but got " (str val)))))
(define assert-false (fn (val) (assert (not val) (str "Expected falsy but got " (str val)))))
(define assert-nil (fn (val) (assert (nil? val) (str "Expected nil but got " (str val)))))
(define assert-type (fn (expected-type val) (let ((actual-type (if (nil? val) "nil" (if (boolean? val) "boolean" (if (number? val) "number" (if (string? val) "string" (if (list? val) "list" (if (dict? val) "dict" "unknown")))))))) (assert (= expected-type actual-type) (str "Expected type " expected-type " but got " actual-type)))))
(define assert-length (fn (expected-len col) (assert (= (len col) expected-len) (str "Expected length " expected-len " but got " (len col)))))
(define assert-contains (fn (item col) (assert (some (fn (x) (equal? x item)) col) (str "Expected collection to contain " (str item)))))
(define assert-throws (fn (thunk) (let ((result (try-call thunk))) (assert (not (get result "ok")) "Expected an error to be thrown but none was"))))'''
def _make_env() -> dict:
"""Create a fresh env with assertion helpers loaded."""
env = {}
for expr in parse_all(_PREAMBLE):
_trampoline(_eval(expr, env))
return env
def _run(sx_source: str, env: dict | None = None) -> object:
"""Evaluate SX source and return the result."""
if env is None:
env = _make_env()
exprs = parse_all(sx_source)
result = None
for expr in exprs:
result = _trampoline(_eval(expr, env))
return result
class TestSpecLiterals:
"""test.sx suite: literals"""
def test_numbers_are_numbers(self):
_run('(do (assert-type "number" 42) (assert-type "number" 3.14) (assert-type "number" -1))')
def test_strings_are_strings(self):
_run('(do (assert-type "string" "hello") (assert-type "string" ""))')
def test_booleans_are_booleans(self):
_run('(do (assert-type "boolean" true) (assert-type "boolean" false))')
def test_nil_is_nil(self):
_run('(do (assert-type "nil" nil) (assert-nil nil))')
def test_lists_are_lists(self):
_run('(do (assert-type "list" (list 1 2 3)) (assert-type "list" (list)))')
def test_dicts_are_dicts(self):
_run('(assert-type "dict" {:a 1 :b 2})')
class TestSpecArithmetic:
"""test.sx suite: arithmetic"""
def test_addition(self):
_run('(do (assert-equal 3 (+ 1 2)) (assert-equal 0 (+ 0 0)) (assert-equal -1 (+ 1 -2)) (assert-equal 10 (+ 1 2 3 4)))')
def test_subtraction(self):
_run('(do (assert-equal 1 (- 3 2)) (assert-equal -1 (- 2 3)))')
def test_multiplication(self):
_run('(do (assert-equal 6 (* 2 3)) (assert-equal 0 (* 0 100)) (assert-equal 24 (* 1 2 3 4)))')
def test_division(self):
_run('(do (assert-equal 2 (/ 6 3)) (assert-equal 2.5 (/ 5 2)))')
def test_modulo(self):
_run('(do (assert-equal 1 (mod 7 3)) (assert-equal 0 (mod 6 3)))')
class TestSpecComparison:
"""test.sx suite: comparison"""
def test_equality(self):
_run('(do (assert-true (= 1 1)) (assert-false (= 1 2)) (assert-true (= "a" "a")) (assert-false (= "a" "b")))')
def test_deep_equality(self):
_run('(do (assert-true (equal? (list 1 2 3) (list 1 2 3))) (assert-false (equal? (list 1 2) (list 1 3))) (assert-true (equal? {:a 1} {:a 1})) (assert-false (equal? {:a 1} {:a 2})))')
def test_ordering(self):
_run('(do (assert-true (< 1 2)) (assert-false (< 2 1)) (assert-true (> 2 1)) (assert-true (<= 1 1)) (assert-true (<= 1 2)) (assert-true (>= 2 2)) (assert-true (>= 3 2)))')
class TestSpecStrings:
"""test.sx suite: strings"""
def test_str_concatenation(self):
_run('(do (assert-equal "abc" (str "a" "b" "c")) (assert-equal "hello world" (str "hello" " " "world")) (assert-equal "42" (str 42)) (assert-equal "" (str)))')
def test_string_length(self):
_run('(do (assert-equal 5 (string-length "hello")) (assert-equal 0 (string-length "")))')
def test_substring(self):
_run('(do (assert-equal "ell" (substring "hello" 1 4)) (assert-equal "hello" (substring "hello" 0 5)))')
def test_string_contains(self):
_run('(do (assert-true (string-contains? "hello world" "world")) (assert-false (string-contains? "hello" "xyz")))')
def test_upcase_and_downcase(self):
_run('(do (assert-equal "HELLO" (upcase "hello")) (assert-equal "hello" (downcase "HELLO")))')
def test_trim(self):
_run('(do (assert-equal "hello" (trim " hello ")) (assert-equal "hello" (trim "hello")))')
def test_split_and_join(self):
_run('(do (assert-equal (list "a" "b" "c") (split "a,b,c" ",")) (assert-equal "a-b-c" (join "-" (list "a" "b" "c"))))')
class TestSpecLists:
"""test.sx suite: lists"""
def test_constructors(self):
_run('(do (assert-equal (list 1 2 3) (list 1 2 3)) (assert-equal (list) (list)) (assert-length 3 (list 1 2 3)))')
def test_first_and_rest(self):
_run('(do (assert-equal 1 (first (list 1 2 3))) (assert-equal (list 2 3) (rest (list 1 2 3))) (assert-nil (first (list))) (assert-equal (list) (rest (list))))')
def test_nth(self):
_run('(do (assert-equal 1 (nth (list 1 2 3) 0)) (assert-equal 2 (nth (list 1 2 3) 1)) (assert-equal 3 (nth (list 1 2 3) 2)))')
def test_last(self):
_run('(do (assert-equal 3 (last (list 1 2 3))) (assert-nil (last (list))))')
def test_cons_and_append(self):
_run('(do (assert-equal (list 0 1 2) (cons 0 (list 1 2))) (assert-equal (list 1 2 3 4) (append (list 1 2) (list 3 4))))')
def test_reverse(self):
_run('(do (assert-equal (list 3 2 1) (reverse (list 1 2 3))) (assert-equal (list) (reverse (list))))')
def test_empty(self):
_run('(do (assert-true (empty? (list))) (assert-false (empty? (list 1))))')
def test_len(self):
_run('(do (assert-equal 0 (len (list))) (assert-equal 3 (len (list 1 2 3))))')
def test_contains(self):
_run('(do (assert-true (contains? (list 1 2 3) 2)) (assert-false (contains? (list 1 2 3) 4)))')
def test_flatten(self):
_run('(assert-equal (list 1 2 3 4) (flatten (list (list 1 2) (list 3 4))))')
class TestSpecDicts:
"""test.sx suite: dicts"""
def test_dict_literal(self):
_run('(do (assert-type "dict" {:a 1 :b 2}) (assert-equal 1 (get {:a 1} "a")) (assert-equal 2 (get {:a 1 :b 2} "b")))')
def test_assoc(self):
_run('(do (assert-equal {:a 1 :b 2} (assoc {:a 1} "b" 2)) (assert-equal {:a 99} (assoc {:a 1} "a" 99)))')
def test_dissoc(self):
_run('(assert-equal {:b 2} (dissoc {:a 1 :b 2} "a"))')
def test_keys_and_vals(self):
_run('(let ((d {:a 1 :b 2})) (assert-length 2 (keys d)) (assert-length 2 (vals d)) (assert-contains "a" (keys d)) (assert-contains "b" (keys d)))')
def test_has_key(self):
_run('(do (assert-true (has-key? {:a 1} "a")) (assert-false (has-key? {:a 1} "b")))')
def test_merge(self):
_run('(do (assert-equal {:a 1 :b 2 :c 3} (merge {:a 1 :b 2} {:c 3})) (assert-equal {:a 99 :b 2} (merge {:a 1 :b 2} {:a 99})))')
class TestSpecPredicates:
"""test.sx suite: predicates"""
def test_nil(self):
_run('(do (assert-true (nil? nil)) (assert-false (nil? 0)) (assert-false (nil? false)) (assert-false (nil? "")))')
def test_number(self):
_run('(do (assert-true (number? 42)) (assert-true (number? 3.14)) (assert-false (number? "42")))')
def test_string(self):
_run('(do (assert-true (string? "hello")) (assert-false (string? 42)))')
def test_list(self):
_run('(do (assert-true (list? (list 1 2))) (assert-false (list? "not a list")))')
def test_dict(self):
_run('(do (assert-true (dict? {:a 1})) (assert-false (dict? (list 1))))')
def test_boolean(self):
_run('(do (assert-true (boolean? true)) (assert-true (boolean? false)) (assert-false (boolean? nil)) (assert-false (boolean? 0)))')
def test_not(self):
_run('(do (assert-true (not false)) (assert-true (not nil)) (assert-false (not true)) (assert-false (not 1)) (assert-false (not "x")))')
class TestSpecSpecialForms:
"""test.sx suite: special-forms"""
def test_if(self):
_run('(do (assert-equal "yes" (if true "yes" "no")) (assert-equal "no" (if false "yes" "no")) (assert-equal "no" (if nil "yes" "no")) (assert-nil (if false "yes")))')
def test_when(self):
_run('(do (assert-equal "yes" (when true "yes")) (assert-nil (when false "yes")))')
def test_cond(self):
_run('(do (assert-equal "a" (cond true "a" :else "b")) (assert-equal "b" (cond false "a" :else "b")) (assert-equal "c" (cond false "a" false "b" :else "c")))')
def test_and(self):
_run('(do (assert-true (and true true)) (assert-false (and true false)) (assert-false (and false true)) (assert-equal 3 (and 1 2 3)))')
def test_or(self):
_run('(do (assert-equal 1 (or 1 2)) (assert-equal 2 (or false 2)) (assert-equal "fallback" (or nil false "fallback")) (assert-false (or false false)))')
def test_let(self):
_run('(do (assert-equal 3 (let ((x 1) (y 2)) (+ x y))) (assert-equal "hello world" (let ((a "hello") (b " world")) (str a b))))')
def test_let_clojure_style(self):
_run('(assert-equal 3 (let (x 1 y 2) (+ x y)))')
def test_do_begin(self):
_run('(do (assert-equal 3 (do 1 2 3)) (assert-equal "last" (begin "first" "middle" "last")))')
def test_define(self):
_run('(do (define x 42) (assert-equal 42 x))')
def test_set(self):
_run('(do (define x 1) (set! x 2) (assert-equal 2 x))')
class TestSpecLambdas:
"""test.sx suite: lambdas"""
def test_basic_lambda(self):
_run('(let ((add (fn (a b) (+ a b)))) (assert-equal 3 (add 1 2)))')
def test_closure_captures_env(self):
_run('(let ((x 10)) (let ((add-x (fn (y) (+ x y)))) (assert-equal 15 (add-x 5))))')
def test_lambda_as_argument(self):
_run('(assert-equal (list 2 4 6) (map (fn (x) (* x 2)) (list 1 2 3)))')
def test_recursive_lambda_via_define(self):
_run('(do (define factorial (fn (n) (if (<= n 1) 1 (* n (factorial (- n 1)))))) (assert-equal 120 (factorial 5)))')
def test_higher_order_returns_lambda(self):
_run('(let ((make-adder (fn (n) (fn (x) (+ n x))))) (let ((add5 (make-adder 5))) (assert-equal 8 (add5 3))))')
class TestSpecHigherOrder:
"""test.sx suite: higher-order"""
def test_map(self):
_run('(do (assert-equal (list 2 4 6) (map (fn (x) (* x 2)) (list 1 2 3))) (assert-equal (list) (map (fn (x) x) (list))))')
def test_filter(self):
_run('(do (assert-equal (list 2 4) (filter (fn (x) (= (mod x 2) 0)) (list 1 2 3 4))) (assert-equal (list) (filter (fn (x) false) (list 1 2 3))))')
def test_reduce(self):
_run('(do (assert-equal 10 (reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3 4))) (assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list))))')
def test_some(self):
_run('(do (assert-true (some (fn (x) (> x 3)) (list 1 2 3 4 5))) (assert-false (some (fn (x) (> x 10)) (list 1 2 3))))')
def test_every(self):
_run('(do (assert-true (every? (fn (x) (> x 0)) (list 1 2 3))) (assert-false (every? (fn (x) (> x 2)) (list 1 2 3))))')
def test_map_indexed(self):
_run('(assert-equal (list "0:a" "1:b" "2:c") (map-indexed (fn (i x) (str i ":" x)) (list "a" "b" "c")))')
class TestSpecComponents:
"""test.sx suite: components"""
def test_defcomp_creates_component(self):
_run('(do (defcomp ~test-comp (&key title) (div title)) (assert-true (not (nil? ~test-comp))))')
def test_component_renders_with_keyword_args(self):
_run('(do (defcomp ~greeting (&key name) (span (str "Hello, " name "!"))) (assert-true (not (nil? ~greeting))))')
def test_component_with_children(self):
_run('(do (defcomp ~box (&key &rest children) (div :class "box" children)) (assert-true (not (nil? ~box))))')
def test_component_with_default_via_or(self):
_run('(do (defcomp ~label (&key text) (span (or text "default"))) (assert-true (not (nil? ~label))))')
class TestSpecMacros:
"""test.sx suite: macros"""
def test_defmacro_creates_macro(self):
_run('(do (defmacro unless (cond &rest body) (quasiquote (if (not (unquote cond)) (do (splice-unquote body))))) (assert-equal "yes" (unless false "yes")) (assert-nil (unless true "no")))')
def test_quasiquote_and_unquote(self):
_run('(let ((x 42)) (assert-equal (list 1 42 3) (quasiquote (1 (unquote x) 3))))')
def test_splice_unquote(self):
_run('(let ((xs (list 2 3 4))) (assert-equal (list 1 2 3 4 5) (quasiquote (1 (splice-unquote xs) 5))))')
class TestSpecThreading:
"""test.sx suite: threading"""
def test_thread_first(self):
_run('(do (assert-equal 8 (-> 5 (+ 1) (+ 2))) (assert-equal "HELLO" (-> "hello" upcase)) (assert-equal "HELLO WORLD" (-> "hello" (str " world") upcase)))')
class TestSpecTruthiness:
"""test.sx suite: truthiness"""
def test_truthy_values(self):
_run('(do (assert-true (if 1 true false)) (assert-true (if "x" true false)) (assert-true (if (list 1) true false)) (assert-true (if true true false)))')
def test_falsy_values(self):
_run('(do (assert-false (if false true false)) (assert-false (if nil true false)))')
class TestSpecEdgeCases:
"""test.sx suite: edge-cases"""
def test_nested_let_scoping(self):
_run('(let ((x 1)) (let ((x 2)) (assert-equal 2 x)))')
def test_recursive_map(self):
_run('(assert-equal (list (list 2 4) (list 6 8)) (map (fn (sub) (map (fn (x) (* x 2)) sub)) (list (list 1 2) (list 3 4))))')
def test_keyword_as_value(self):
_run('(do (assert-equal "class" :class) (assert-equal "id" :id))')
def test_dict_with_evaluated_values(self):
_run('(let ((x 42)) (assert-equal 42 (get {:val x} "val")))')
def test_nil_propagation(self):
_run('(do (assert-nil (get {:a 1} "missing")) (assert-equal "default" (or (get {:a 1} "missing") "default")))')
def test_empty_operations(self):
_run('(do (assert-equal (list) (map (fn (x) x) (list))) (assert-equal (list) (filter (fn (x) true) (list))) (assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list))) (assert-equal 0 (len (list))) (assert-equal "" (str)))')

View File

@@ -8,12 +8,22 @@ from __future__ import annotations
import re import re
def _escape(text: str) -> str:
"""Escape a token for embedding in an SX string literal."""
return (text
.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\t", "\\t")
.replace("\r", "\\r"))
def highlight_sx(code: str) -> str: def highlight_sx(code: str) -> str:
"""Highlight s-expression source code as sx with Tailwind spans.""" """Highlight s-expression source code as sx with Tailwind spans."""
tokens = _tokenize_sx(code) tokens = _tokenize_sx(code)
parts = [] parts = []
for kind, text in tokens: for kind, text in tokens:
escaped = text.replace("\\", "\\\\").replace('"', '\\"') escaped = _escape(text)
if kind == "comment": if kind == "comment":
parts.append(f'(span :class "text-stone-400 italic" "{escaped}")') parts.append(f'(span :class "text-stone-400 italic" "{escaped}")')
elif kind == "string": elif kind == "string":
@@ -94,7 +104,7 @@ def highlight_python(code: str) -> str:
tokens = _tokenize_python(code) tokens = _tokenize_python(code)
parts = [] parts = []
for kind, text in tokens: for kind, text in tokens:
escaped = text.replace("\\", "\\\\").replace('"', '\\"') escaped = _escape(text)
if kind == "comment": if kind == "comment":
parts.append(f'(span :class "text-stone-400 italic" "{escaped}")') parts.append(f'(span :class "text-stone-400 italic" "{escaped}")')
elif kind == "string": elif kind == "string":
@@ -176,7 +186,7 @@ def highlight_bash(code: str) -> str:
tokens = _tokenize_bash(code) tokens = _tokenize_bash(code)
parts = [] parts = []
for kind, text in tokens: for kind, text in tokens:
escaped = text.replace("\\", "\\\\").replace('"', '\\"') escaped = _escape(text)
if kind == "comment": if kind == "comment":
parts.append(f'(span :class "text-stone-400 italic" "{escaped}")') parts.append(f'(span :class "text-stone-400 italic" "{escaped}")')
elif kind == "string": elif kind == "string":

View File

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

67
sx/sx/async-io-demo.sx Normal file
View File

@@ -0,0 +1,67 @@
;; Async IO demo — Phase 5 client-side rendering with IO primitives.
;;
;; This component calls `highlight` inline — an IO primitive that runs
;; server-side Python. When rendered on the server, it executes
;; synchronously. When rendered client-side, the async renderer proxies
;; the call via /sx/io/highlight and awaits the result.
;;
;; `highlight` returns SxExpr — SX source with colored spans — which the
;; evaluator renders as DOM. The same SxExpr flows through the IO proxy:
;; server serializes → client parses → async renderer renders to DOM.
;;
;; Open browser console and look for:
;; "sx:route client+async" — async render with IO proxy
;; "sx:io registered N proxied primitives" — IO proxy initialization
(defcomp ~async-io-demo-content ()
(div :class "space-y-8"
(div :class "border-b border-stone-200 pb-6"
(h1 :class "text-2xl font-bold text-stone-900" "Async IO Demo")
(p :class "mt-2 text-stone-600"
"This page calls " (code :class "bg-stone-100 px-1 rounded text-violet-700" "highlight")
" inline — an IO primitive that returns SX source with colored spans. "
"On the server it runs Python directly. On the client it proxies via "
(code :class "bg-stone-100 px-1 rounded text-violet-700" "/sx/io/highlight")
" and the async renderer awaits the result."))
;; Live syntax-highlighted code blocks — each is an IO call
(div :class "space-y-6"
(h2 :class "text-lg font-semibold text-stone-800" "Live IO: syntax highlighting")
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "SX component definition")
(~doc-code :code
(highlight "(defcomp ~card (&key title subtitle &rest children)\n (div :class \"border rounded-lg p-4 shadow-sm\"\n (h2 :class \"text-lg font-bold\" title)\n (when subtitle\n (p :class \"text-stone-500 text-sm\" subtitle))\n (div :class \"mt-3\" children)))" "lisp")))
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Python server code")
(~doc-code :code
(highlight "from shared.sx.pages import mount_io_endpoint\n\n# The IO proxy serves any allowed primitive:\n# GET /sx/io/highlight?_arg0=code&_arg1=lisp\nasync def io_proxy(name):\n result = await execute_io(name, args, kwargs, ctx)\n return serialize(result)" "python")))
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "SX async rendering spec")
(~doc-code :code
(highlight ";; try-client-route reads io-deps from page registry\n(let ((io-deps (get match \"io-deps\"))\n (has-io (and io-deps (not (empty? io-deps)))))\n ;; Register IO deps as proxied primitives on demand\n (when has-io (register-io-deps io-deps))\n (if has-io\n ;; Async render: IO primitives proxied via /sx/io/<name>\n (do\n (try-async-eval-content content-src env\n (fn (rendered)\n (when rendered\n (swap-rendered-content target rendered pathname))))\n true)\n ;; Sync render: pure components, no IO\n (let ((rendered (try-eval-content content-src env)))\n (swap-rendered-content target rendered pathname))))" "lisp"))))
;; Architecture explanation
(div :class "rounded-lg border border-blue-200 bg-blue-50 p-5 space-y-3"
(h2 :class "text-lg font-semibold text-blue-900" "How it works")
(ol :class "list-decimal list-inside text-blue-800 space-y-2 text-sm"
(li "Server renders the page — " (code "highlight") " runs Python directly")
(li "Client receives component definitions including " (code "~async-io-demo-content"))
(li "On client navigation, " (code "io-deps") " list routes to async renderer")
(li (code "register-io-deps") " ensures each IO name is proxied via " (code "registerProxiedIo"))
(li "Proxied call: " (code "fetch(\"/sx/io/highlight?_arg0=...&_arg1=lisp\")"))
(li "Server runs highlight, returns SX source (colored span elements)")
(li "Client parses SX → AST, async renderer recursively renders to DOM")))
;; Verification instructions
(div :class "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2"
(p :class "font-semibold text-amber-800" "How to verify async IO rendering")
(ol :class "list-decimal list-inside text-amber-700 space-y-1"
(li "Open the browser console (F12)")
(li "Navigate to another page (e.g. Data Test)")
(li "Click back to this page")
(li "Look for: " (code :class "bg-amber-100 px-1 rounded" "sx:route client+async /isomorphism/async-io"))
(li "The code blocks should render identically — same syntax highlighting")
(li "Check Network tab: you'll see 3 requests to " (code :class "bg-amber-100 px-1 rounded" "/sx/io/highlight"))))))

View File

@@ -49,3 +49,13 @@
:params () :params ()
:returns "dict" :returns "dict"
:service "sx") :service "sx")
(define-page-helper "routing-analyzer-data"
:params ()
:returns "dict"
:service "sx")
(define-page-helper "data-test-data"
:params ()
:returns "dict"
:service "sx")

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

@@ -0,0 +1,56 @@
;; Data test page — exercises Phase 4 client-side data rendering + caching.
;;
;; This page has a :data expression. When navigated to:
;; - Full page load: server evaluates data + renders content (normal path)
;; - Client route (1st): client fetches /sx/data/data-test, caches, renders
;; - Client route (2nd within 30s): client uses cached data, renders instantly
;;
;; Open browser console and look for:
;; "sx:route client+data" — cache miss, fetched from server
;; "sx:route client+cache" — cache hit, rendered from cached data
(defcomp ~data-test-content (&key server-time items phase transport)
(div :class "space-y-8"
(div :class "border-b border-stone-200 pb-6"
(h1 :class "text-2xl font-bold text-stone-900" "Data Test")
(p :class "mt-2 text-stone-600"
"This page tests the Phase 4 data endpoint and client-side data cache. "
"The content you see was rendered using data from the server, but the "
"rendering itself may have happened client-side."))
;; Server-provided metadata
(div :class "rounded-lg border border-stone-200 bg-white p-6 space-y-3"
(h2 :class "text-lg font-semibold text-stone-800" "Data from server")
(dl :class "grid grid-cols-2 gap-2 text-sm"
(dt :class "font-medium text-stone-600" "Phase")
(dd :class "text-stone-900" phase)
(dt :class "font-medium text-stone-600" "Transport")
(dd :class "text-stone-900" transport)
(dt :class "font-medium text-stone-600" "Server time")
(dd :class "font-mono text-stone-900" server-time)))
;; Pipeline steps from data
(div :class "space-y-3"
(h2 :class "text-lg font-semibold text-stone-800" "Pipeline steps")
(div :class "space-y-2"
(map-indexed
(fn (i item)
(div :class "flex items-start gap-3 rounded border border-stone-100 bg-white p-3"
(span :class "flex-none rounded-full bg-violet-100 text-violet-700 w-6 h-6 flex items-center justify-center text-xs font-bold"
(str (+ i 1)))
(div
(div :class "font-medium text-stone-900" (get item "label"))
(div :class "text-sm text-stone-500" (get item "detail")))))
items)))
;; How to verify — updated with cache instructions
(div :class "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2"
(p :class "font-semibold text-amber-800" "How to verify client-side rendering + caching")
(ol :class "list-decimal list-inside text-amber-700 space-y-1"
(li "Open the browser console (F12)")
(li "Navigate to this page from another page using a link")
(li "Look for: " (code :class "bg-amber-100 px-1 rounded" "sx:route client+data /isomorphism/data-test"))
(li "Navigate away, then back within 30 seconds")
(li "Look for: " (code :class "bg-amber-100 px-1 rounded" "sx:route client+cache /isomorphism/data-test"))
(li "The server-time value should be the same (cached data)")
(li "Wait 30+ seconds, navigate back again — new fetch, updated time")))))

File diff suppressed because one or more lines are too long

View File

@@ -78,6 +78,8 @@
:summary "A web where pages can inspect, modify, and extend their own rendering pipeline.") :summary "A web where pages can inspect, modify, and extend their own rendering pipeline.")
(dict :label "Server Architecture" :href "/essays/server-architecture" (dict :label "Server Architecture" :href "/essays/server-architecture"
:summary "How SX enforces the boundary between host and embedded language, and what it looks like across targets.") :summary "How SX enforces the boundary between host and embedded language, and what it looks like across targets.")
(dict :label "SX and AI" :href "/essays/sx-and-ai"
:summary "Why s-expressions are the most AI-friendly representation for web interfaces.")
(dict :label "sx sucks" :href "/essays/sx-sucks" (dict :label "sx sucks" :href "/essays/sx-sucks"
:summary "An honest accounting of everything wrong with SX and why you probably shouldn't use it."))) :summary "An honest accounting of everything wrong with SX and why you probably shouldn't use it.")))
@@ -100,17 +102,34 @@
(dict :label "CSSX" :href "/specs/cssx") (dict :label "CSSX" :href "/specs/cssx")
(dict :label "Continuations" :href "/specs/continuations") (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 "Deps" :href "/specs/deps")
(dict :label "Router" :href "/specs/router")
(dict :label "Testing" :href "/specs/testing")))
(define isomorphism-nav-items (list (define isomorphism-nav-items (list
(dict :label "Roadmap" :href "/isomorphism/") (dict :label "Roadmap" :href "/isomorphism/")
(dict :label "Bundle Analyzer" :href "/isomorphism/bundle-analyzer"))) (dict :label "Bundle Analyzer" :href "/isomorphism/bundle-analyzer")
(dict :label "Routing Analyzer" :href "/isomorphism/routing-analyzer")
(dict :label "Data Test" :href "/isomorphism/data-test")
(dict :label "Async IO" :href "/isomorphism/async-io")))
(define plans-nav-items (list (define plans-nav-items (list
(dict :label "Status" :href "/plans/status"
:summary "Audit of all plans — what's done, what's in progress, and what remains.")
(dict :label "Reader Macros" :href "/plans/reader-macros" (dict :label "Reader Macros" :href "/plans/reader-macros"
:summary "Extensible parse-time transformations via # dispatch — datum comments, raw strings, and quote shorthand.") :summary "Extensible parse-time transformations via # dispatch — datum comments, raw strings, and quote shorthand.")
(dict :label "SX-Activity" :href "/plans/sx-activity" (dict :label "SX-Activity" :href "/plans/sx-activity"
:summary "A new web built on SX — executable content, shared components, parsers, and logic on IPFS, provenance on Bitcoin, all running within your own security context."))) :summary "A new web built on SX — executable content, shared components, parsers, and logic on IPFS, provenance on Bitcoin, all running within your own security context.")
(dict :label "Predictive Prefetching" :href "/plans/predictive-prefetch"
:summary "Prefetch missing component definitions before the user clicks — hover a link, fetch its deps, navigate client-side.")
(dict :label "Content-Addressed Components" :href "/plans/content-addressed-components"
:summary "Components identified by CID, stored on IPFS, fetched from anywhere. Canonical serialization, content verification, federated sharing.")
(dict :label "Fragment Protocol" :href "/plans/fragment-protocol"
:summary "Structured sexp request/response for cross-service component transfer.")
(dict :label "Glue Decoupling" :href "/plans/glue-decoupling"
:summary "Eliminate all cross-app model imports via glue service layer.")
(dict :label "Social Sharing" :href "/plans/social-sharing"
:summary "OAuth-based sharing to Facebook, Instagram, Threads, Twitter/X, LinkedIn, and Mastodon.")))
(define bootstrappers-nav-items (list (define bootstrappers-nav-items (list
(dict :label "Overview" :href "/bootstrappers/") (dict :label "Overview" :href "/bootstrappers/")
@@ -175,7 +194,13 @@
(define module-spec-items (list (define module-spec-items (list
(dict :slug "deps" :filename "deps.sx" :title "Deps" (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." :desc "Component dependency analysis and IO detection — per-page bundling, transitive closure, CSS scoping, pure/IO classification."
:prose "The deps module analyzes component dependency graphs and classifies components as pure or IO-dependent. Phase 1 (bundling): walks component AST bodies to find transitive ~component references, computes the minimal set needed per page, and collects per-page CSS classes from only the used components. Phase 2 (IO detection): scans component ASTs for references to IO primitive names (from boundary.sx declarations — frag, query, service, current-user, highlight, etc.), computes transitive IO refs through the component graph, and caches the result on each component. Components with no transitive IO refs are pure — they can render anywhere without server data. IO-dependent components must expand server-side. The spec provides the classification; each host's async partial evaluator acts on it (expand IO-dependent server-side, serialize pure for client). All functions are pure — each host bootstraps them to native code via --spec-modules deps. Platform functions (component-deps, component-set-deps!, component-css-classes, component-io-refs, component-set-io-refs!, env-components, regex-find-all, scan-css-classes) are implemented natively per target."))) :prose "The deps module analyzes component dependency graphs and classifies components as pure or IO-dependent. Phase 1 (bundling): walks component AST bodies to find transitive ~component references, computes the minimal set needed per page, and collects per-page CSS classes from only the used components. Phase 2 (IO detection): scans component ASTs for references to IO primitive names (from boundary.sx declarations — frag, query, service, current-user, highlight, etc.), computes transitive IO refs through the component graph, and caches the result on each component. Components with no transitive IO refs are pure — they can render anywhere without server data. IO-dependent components must expand server-side. The spec provides the classification; each host's async partial evaluator acts on it (expand IO-dependent server-side, serialize pure for client). All functions are pure — each host bootstraps them to native code via --spec-modules deps. Platform functions (component-deps, component-set-deps!, component-css-classes, component-io-refs, component-set-io-refs!, env-components, regex-find-all, scan-css-classes) are implemented natively per target.")
(dict :slug "router" :filename "router.sx" :title "Router"
:desc "Client-side route matching — Flask-style pattern parsing, segment matching, route table search."
:prose "The router module provides pure functions for matching URL paths against Flask-style route patterns (e.g. /docs/<slug>). Used by client-side routing (Phase 3) to determine if a page can be rendered locally without a server roundtrip. split-path-segments breaks a path into segments, parse-route-pattern converts patterns into typed segment descriptors, match-route-segments tests a path against a parsed pattern returning extracted params, and find-matching-route searches a route table for the first match. No platform interface needed — uses only pure string and list primitives. Bootstrapped via --spec-modules deps,router.")
(dict :slug "testing" :filename "test.sx" :title "Testing"
:desc "Self-hosting test framework — SX tests SX. Bootstraps to pytest and Node.js TAP."
:prose "The test spec defines a minimal test framework in SX that bootstraps to every host. Tests are written in SX and verify SX semantics — the language tests itself. The framework uses only primitives already in primitives.sx (assert, equal?, type-of, str, list, len) plus assertion helpers defined in SX (assert-equal, assert-true, assert-false, assert-nil, assert-type, assert-length, assert-contains). Two bootstrap compilers read test.sx and emit native test files: bootstrap_test.py produces a pytest module, bootstrap_test_js.py produces a Node.js TAP script. The same 81 tests run on both platforms, verifying cross-host parity.")))
(define all-spec-items (concat core-spec-items (concat adapter-spec-items (concat browser-spec-items (concat extension-spec-items module-spec-items))))) (define all-spec-items (concat core-spec-items (concat adapter-spec-items (concat browser-spec-items (concat extension-spec-items module-spec-items)))))

File diff suppressed because it is too large Load Diff

View File

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

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

@@ -182,7 +182,8 @@ 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) ;; Spec modules (optional — loaded via --spec-modules)
deps.sx depends on: eval (optional)"))) deps.sx depends on: eval (optional)
router.sx (standalone — pure string/list ops)")))
(div :class "space-y-3" (div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Extensions") (h2 :class "text-2xl font-semibold text-stone-800" "Extensions")

234
sx/sx/testing.sx Normal file
View File

@@ -0,0 +1,234 @@
;; Testing spec page — SX tests SX.
(defcomp ~spec-testing-content (&key spec-source)
(~doc-page :title "Testing"
(div :class "space-y-8"
;; Intro
(div :class "space-y-4"
(p :class "text-lg text-stone-600"
"SX tests itself. "
(code :class "text-violet-700 text-sm" "test.sx")
" is a self-executing test spec — it defines "
(code :class "text-violet-700 text-sm" "deftest")
" and "
(code :class "text-violet-700 text-sm" "defsuite")
" as macros, writes 81 test cases, and runs them. Any host that provides five platform functions can evaluate the file directly.")
(p :class "text-stone-600"
"This is not a test "
(em "of") " SX — it is a test " (em "in") " SX. The same s-expressions that define how "
(code :class "text-violet-700 text-sm" "if")
" works are used to verify that "
(code :class "text-violet-700 text-sm" "if")
" works. No code generation, no intermediate files — the evaluator runs the spec."))
;; Live test runner
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Run in browser")
(p :class "text-stone-600"
"This page loaded "
(code :class "text-violet-700 text-sm" "sx-browser.js")
" to render itself. The same evaluator can run "
(code :class "text-violet-700 text-sm" "test.sx")
" right here — SX testing SX, in your browser:")
(div :class "flex items-center gap-4"
(button :id "test-btn"
:class "px-4 py-2 rounded-md bg-violet-600 text-white font-medium text-sm hover:bg-violet-700 cursor-pointer"
:onclick "sxRunTests('test-sx-source','test-output','test-btn')"
"Run 81 tests"))
(pre :id "test-output"
:class "text-sm font-mono bg-stone-900 text-green-400 rounded-lg p-4 overflow-x-auto max-h-96 overflow-y-auto"
:style "display:none"
"")
;; Hidden: raw test.sx source for the browser runner
(textarea :id "test-sx-source" :style "display:none" spec-source)
;; Load the test runner script
(script :src (asset-url "/scripts/sx-test-runner.js")))
;; How it works
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Architecture")
(p :class "text-stone-600"
"The test framework needs five platform functions. Everything else — macros, assertion helpers, test suites — is pure SX:")
(div :class "not-prose bg-stone-100 rounded-lg p-5 mx-auto max-w-3xl"
(pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words font-mono text-stone-700"
"test.sx Self-executing: macros + helpers + 81 tests
|
|--- browser sx-browser.js evaluates test.sx in this page
|
|--- run.js Injects 5 platform fns, evaluates test.sx
| |
| +-> sx-browser.js JS evaluator (bootstrapped from spec)
|
|--- run.py Injects 5 platform fns, evaluates test.sx
|
+-> evaluator.py Python evaluator
Platform functions (5 total — everything else is pure SX):
try-call (thunk) -> {:ok true} | {:ok false :error \"msg\"}
report-pass (name) -> output pass
report-fail (name error) -> output fail
push-suite (name) -> push suite context
pop-suite () -> pop suite context")))
;; Framework
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "The test framework")
(p :class "text-stone-600"
"The framework defines two macros and nine assertion helpers, all in SX. The macros are the key — they make "
(code :class "text-violet-700 text-sm" "defsuite")
" and "
(code :class "text-violet-700 text-sm" "deftest")
" executable forms, not just declarations:")
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Macros")
(~doc-code :code
(highlight "(defmacro deftest (name &rest body)\n `(let ((result (try-call (fn () ,@body))))\n (if (get result \"ok\")\n (report-pass ,name)\n (report-fail ,name (get result \"error\")))))\n\n(defmacro defsuite (name &rest items)\n `(do (push-suite ,name)\n ,@items\n (pop-suite)))" "lisp")))
(p :class "text-stone-600 text-sm"
(code :class "text-violet-700 text-sm" "deftest")
" wraps the body in a thunk, passes it to "
(code :class "text-violet-700 text-sm" "try-call")
" (the one platform function that catches errors), then reports pass or fail. "
(code :class "text-violet-700 text-sm" "defsuite")
" pushes a name onto the context stack, runs its children, and pops.")
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Assertion helpers")
(~doc-code :code
(highlight "(define assert-equal\n (fn (expected actual)\n (assert (equal? expected actual)\n (str \"Expected \" (str expected) \" but got \" (str actual)))))\n\n(define assert-true (fn (val) (assert val ...)))\n(define assert-false (fn (val) (assert (not val) ...)))\n(define assert-nil (fn (val) (assert (nil? val) ...)))\n(define assert-type (fn (expected-type val) ...))\n(define assert-length (fn (expected-len col) ...))\n(define assert-contains (fn (item col) ...))\n(define assert-throws (fn (thunk) ...))" "lisp"))))
;; Example tests
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Example: SX testing SX")
(p :class "text-stone-600"
"The test suites cover every language feature. Here is the arithmetic suite testing the evaluator's arithmetic primitives:")
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "From test.sx")
(~doc-code :code
(highlight "(defsuite \"arithmetic\"\n (deftest \"addition\"\n (assert-equal 3 (+ 1 2))\n (assert-equal 0 (+ 0 0))\n (assert-equal -1 (+ 1 -2))\n (assert-equal 10 (+ 1 2 3 4)))\n\n (deftest \"subtraction\"\n (assert-equal 1 (- 3 2))\n (assert-equal -1 (- 2 3)))\n\n (deftest \"multiplication\"\n (assert-equal 6 (* 2 3))\n (assert-equal 0 (* 0 100))\n (assert-equal 24 (* 1 2 3 4)))\n\n (deftest \"division\"\n (assert-equal 2 (/ 6 3))\n (assert-equal 2.5 (/ 5 2)))\n\n (deftest \"modulo\"\n (assert-equal 1 (mod 7 3))\n (assert-equal 0 (mod 6 3))))" "lisp"))))
;; Running tests — JS
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "JavaScript: direct evaluation")
(p :class "text-stone-600"
(code :class "text-violet-700 text-sm" "sx-browser.js")
" evaluates "
(code :class "text-violet-700 text-sm" "test.sx")
" directly. The runner injects platform functions and calls "
(code :class "text-violet-700 text-sm" "Sx.eval")
" on each parsed expression:")
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "run.js")
(~doc-code :code
(highlight "var Sx = require('./sx-browser.js');\nvar src = fs.readFileSync('test.sx', 'utf8');\n\nvar env = {\n 'try-call': function(thunk) {\n try {\n Sx.eval([thunk], env); // call the SX lambda\n return { ok: true };\n } catch(e) {\n return { ok: false, error: e.message };\n }\n },\n 'report-pass': function(name) { console.log('ok - ' + name); },\n 'report-fail': function(name, err) { console.log('not ok - ' + name); },\n 'push-suite': function(n) { stack.push(n); },\n 'pop-suite': function() { stack.pop(); },\n};\n\nvar exprs = Sx.parseAll(src);\nfor (var i = 0; i < exprs.length; i++) Sx.eval(exprs[i], env);" "javascript")))
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Output")
(~doc-code :code
(highlight "$ node shared/sx/tests/run.js\nTAP version 13\nok 1 - literals > numbers are numbers\nok 2 - literals > strings are strings\n...\nok 81 - edge-cases > empty operations\n\n# tests 81\n# pass 81" "bash"))))
;; Running tests — Python
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Python: direct evaluation")
(p :class "text-stone-600"
"Same approach — the Python evaluator runs "
(code :class "text-violet-700 text-sm" "test.sx")
" directly:")
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "run.py")
(~doc-code :code
(highlight "from shared.sx.parser import parse_all\nfrom shared.sx.evaluator import _eval, _trampoline\n\ndef try_call(thunk):\n try:\n _trampoline(_eval([thunk], {}))\n return {'ok': True}\n except Exception as e:\n return {'ok': False, 'error': str(e)}\n\nenv = {\n 'try-call': try_call,\n 'report-pass': report_pass,\n 'report-fail': report_fail,\n 'push-suite': push_suite,\n 'pop-suite': pop_suite,\n}\n\nfor expr in parse_all(src):\n _trampoline(_eval(expr, env))" "python")))
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Output")
(~doc-code :code
(highlight "$ python shared/sx/tests/run.py\nTAP version 13\nok 1 - literals > numbers are numbers\n...\nok 81 - edge-cases > empty operations\n\n# tests 81\n# pass 81" "bash"))))
;; What it proves
(div :class "rounded-lg border border-blue-200 bg-blue-50 p-5 space-y-3"
(h2 :class "text-lg font-semibold text-blue-900" "What this proves")
(ol :class "list-decimal list-inside text-blue-800 space-y-2 text-sm"
(li "The test spec is " (strong "written in SX") " and " (strong "executed by SX") " — no code generation")
(li "The same 81 tests run on " (strong "Python, Node.js, and in the browser") " from the same file")
(li "Each host provides only " (strong "5 platform functions") " — everything else is pure SX")
(li "Adding a new host means implementing 5 functions, not rewriting tests")
(li "Platform divergences (truthiness of 0, [], \"\") are " (strong "documented, not hidden"))
(li "The spec is " (strong "executable") " — click the button above to prove it")))
;; Test suites
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "All 15 test suites")
(div :class "overflow-x-auto rounded border border-stone-200"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "Suite")
(th :class "px-3 py-2 font-medium text-stone-600" "Tests")
(th :class "px-3 py-2 font-medium text-stone-600" "Covers")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "literals")
(td :class "px-3 py-2" "6")
(td :class "px-3 py-2 text-stone-700" "number, string, boolean, nil, list, dict type checking"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "arithmetic")
(td :class "px-3 py-2" "5")
(td :class "px-3 py-2 text-stone-700" "+, -, *, /, mod with edge cases"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "comparison")
(td :class "px-3 py-2" "3")
(td :class "px-3 py-2 text-stone-700" "=, equal?, <, >, <=, >="))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "strings")
(td :class "px-3 py-2" "7")
(td :class "px-3 py-2 text-stone-700" "str, string-length, substring, contains?, upcase, trim, split/join"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "lists")
(td :class "px-3 py-2" "10")
(td :class "px-3 py-2 text-stone-700" "first, rest, nth, last, cons, append, reverse, empty?, contains?, flatten"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "dicts")
(td :class "px-3 py-2" "6")
(td :class "px-3 py-2 text-stone-700" "literals, get, assoc, dissoc, keys/vals, has-key?, merge"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "predicates")
(td :class "px-3 py-2" "7")
(td :class "px-3 py-2 text-stone-700" "nil?, number?, string?, list?, dict?, boolean?, not"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "special-forms")
(td :class "px-3 py-2" "10")
(td :class "px-3 py-2 text-stone-700" "if, when, cond, and, or, let, let (Clojure), do/begin, define, set!"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "lambdas")
(td :class "px-3 py-2" "5")
(td :class "px-3 py-2 text-stone-700" "basic, closures, as argument, recursion, higher-order returns"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "higher-order")
(td :class "px-3 py-2" "6")
(td :class "px-3 py-2 text-stone-700" "map, filter, reduce, some, every?, map-indexed"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "components")
(td :class "px-3 py-2" "4")
(td :class "px-3 py-2 text-stone-700" "defcomp, &key params, &rest children, defaults"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "macros")
(td :class "px-3 py-2" "3")
(td :class "px-3 py-2 text-stone-700" "defmacro, quasiquote/unquote, splice-unquote"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "threading")
(td :class "px-3 py-2" "1")
(td :class "px-3 py-2 text-stone-700" "-> thread-first macro"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "truthiness")
(td :class "px-3 py-2" "2")
(td :class "px-3 py-2 text-stone-700" "truthy/falsy values (platform-universal subset)"))
(tr
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "edge-cases")
(td :class "px-3 py-2" "6")
(td :class "px-3 py-2 text-stone-700" "nested scoping, recursive map, keywords, dict eval, nil propagation, empty ops"))))))
;; Full source
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Full specification source")
(p :class "text-xs text-stone-400 italic"
"The s-expression source below is the canonical test specification. "
"Any host that implements the five platform functions can evaluate it directly.")
(div :class "not-prose bg-stone-100 rounded-lg p-5 mx-auto max-w-3xl"
(pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words"
(code (highlight spec-source "sx"))))))))

View File

@@ -281,6 +281,7 @@
"godel-escher-bach" (~essay-godel-escher-bach) "godel-escher-bach" (~essay-godel-escher-bach)
"reflexive-web" (~essay-reflexive-web) "reflexive-web" (~essay-reflexive-web)
"server-architecture" (~essay-server-architecture) "server-architecture" (~essay-server-architecture)
"sx-and-ai" (~essay-sx-and-ai)
:else (~essays-index-content))) :else (~essays-index-content)))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
@@ -341,6 +342,8 @@
:filename (get item "filename") :href (str "/specs/" (get item "slug")) :filename (get item "filename") :href (str "/specs/" (get item "slug"))
:source (read-spec-file (get item "filename")))) :source (read-spec-file (get item "filename"))))
extension-spec-items)) extension-spec-items))
"testing" (~spec-testing-content
:spec-source (read-spec-file "test.sx"))
:else (let ((spec (find-spec slug))) :else (let ((spec (find-spec slug)))
(if spec (if spec
(~spec-detail-content (~spec-detail-content
@@ -402,6 +405,60 @@
:selected "Roadmap") :selected "Roadmap")
:content (~plan-isomorphic-content)) :content (~plan-isomorphic-content))
(defpage bundle-analyzer
:path "/isomorphism/bundle-analyzer"
:auth :public
:layout (:sx-section
:section "Isomorphism"
:sub-label "Isomorphism"
:sub-href "/isomorphism/"
:sub-nav (~section-nav :items isomorphism-nav-items :current "Bundle Analyzer")
:selected "Bundle Analyzer")
:data (bundle-analyzer-data)
:content (~bundle-analyzer-content
:pages pages :total-components total-components :total-macros total-macros
:pure-count pure-count :io-count io-count))
(defpage routing-analyzer
:path "/isomorphism/routing-analyzer"
:auth :public
:layout (:sx-section
:section "Isomorphism"
:sub-label "Isomorphism"
:sub-href "/isomorphism/"
:sub-nav (~section-nav :items isomorphism-nav-items :current "Routing Analyzer")
:selected "Routing Analyzer")
:data (routing-analyzer-data)
:content (~routing-analyzer-content
:pages pages :total-pages total-pages :client-count client-count
:server-count server-count :registry-sample registry-sample))
(defpage data-test
:path "/isomorphism/data-test"
:auth :public
:layout (:sx-section
:section "Isomorphism"
:sub-label "Isomorphism"
:sub-href "/isomorphism/"
:sub-nav (~section-nav :items isomorphism-nav-items :current "Data Test")
:selected "Data Test")
:data (data-test-data)
:content (~data-test-content
:server-time server-time :items items
:phase phase :transport transport))
(defpage async-io-demo
:path "/isomorphism/async-io"
:auth :public
:layout (:sx-section
:section "Isomorphism"
:sub-label "Isomorphism"
:sub-href "/isomorphism/"
:sub-nav (~section-nav :items isomorphism-nav-items :current "Async IO")
:selected "Async IO")
:content (~async-io-demo-content))
;; Wildcard must come AFTER specific routes (first-match routing)
(defpage isomorphism-page (defpage isomorphism-page
:path "/isomorphism/<slug>" :path "/isomorphism/<slug>"
:auth :public :auth :public
@@ -416,22 +473,11 @@
"bundle-analyzer" (~bundle-analyzer-content "bundle-analyzer" (~bundle-analyzer-content
:pages pages :total-components total-components :total-macros total-macros :pages pages :total-components total-components :total-macros total-macros
:pure-count pure-count :io-count io-count) :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))) :else (~plan-isomorphic-content)))
(defpage bundle-analyzer
:path "/isomorphism/bundle-analyzer"
:auth :public
:layout (:sx-section
:section "Isomorphism"
:sub-label "Isomorphism"
:sub-href "/isomorphism/"
:sub-nav (~section-nav :items isomorphism-nav-items :current "Bundle Analyzer")
:selected "Bundle Analyzer")
:data (bundle-analyzer-data)
:content (~bundle-analyzer-content
:pages pages :total-components total-components :total-macros total-macros
:pure-count pure-count :io-count io-count))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Plans section ;; Plans section
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
@@ -458,6 +504,12 @@
:current (find-current plans-nav-items slug)) :current (find-current plans-nav-items slug))
:selected (or (find-current plans-nav-items slug) "")) :selected (or (find-current plans-nav-items slug) ""))
:content (case slug :content (case slug
"status" (~plan-status-content)
"reader-macros" (~plan-reader-macros-content) "reader-macros" (~plan-reader-macros-content)
"sx-activity" (~plan-sx-activity-content) "sx-activity" (~plan-sx-activity-content)
"predictive-prefetch" (~plan-predictive-prefetch-content)
"content-addressed-components" (~plan-content-addressed-components-content)
"fragment-protocol" (~plan-fragment-protocol-content)
"glue-decoupling" (~plan-glue-decoupling-content)
"social-sharing" (~plan-social-sharing-content)
:else (~plans-index-content))) :else (~plans-index-content)))

View File

@@ -22,6 +22,8 @@ def _register_sx_helpers() -> None:
"read-spec-file": _read_spec_file, "read-spec-file": _read_spec_file,
"bootstrapper-data": _bootstrapper_data, "bootstrapper-data": _bootstrapper_data,
"bundle-analyzer-data": _bundle_analyzer_data, "bundle-analyzer-data": _bundle_analyzer_data,
"routing-analyzer-data": _routing_analyzer_data,
"data-test-data": _data_test_data,
}) })
@@ -41,10 +43,10 @@ def _special_forms_data() -> dict:
from shared.sx.parser import parse_all, serialize from shared.sx.parser import parse_all, serialize
from shared.sx.types import Symbol, Keyword from shared.sx.types import Symbol, Keyword
spec_path = os.path.join( ref_dir = os.path.join(os.path.dirname(__file__), "..", "..", "shared", "sx", "ref")
os.path.dirname(os.path.abspath(__file__)), if not os.path.isdir(ref_dir):
"..", "..", "..", "shared", "sx", "ref", "special-forms.sx", ref_dir = "/app/shared/sx/ref"
) spec_path = os.path.join(ref_dir, "special-forms.sx")
with open(spec_path) as f: with open(spec_path) as f:
exprs = parse_all(f.read()) exprs = parse_all(f.read())
@@ -342,6 +344,82 @@ def _bundle_analyzer_data() -> dict:
} }
def _routing_analyzer_data() -> dict:
"""Compute per-page routing classification for the sx-docs app."""
from shared.sx.pages import get_all_pages
from shared.sx.parser import serialize as sx_serialize
from shared.sx.helpers import _sx_literal
pages_data = []
full_content: list[tuple[str, str, bool]] = [] # (name, full_content, has_data)
client_count = 0
server_count = 0
for name, page_def in sorted(get_all_pages("sx").items()):
has_data = page_def.data_expr is not None
content_src = ""
if page_def.content_expr is not None:
try:
content_src = sx_serialize(page_def.content_expr)
except Exception:
pass
full_content.append((name, content_src, has_data))
# Determine routing mode and reason
if has_data:
mode = "server"
reason = "Has :data expression — needs server IO"
server_count += 1
elif not content_src:
mode = "server"
reason = "No content expression"
server_count += 1
else:
mode = "client"
reason = ""
client_count += 1
pages_data.append({
"name": name,
"path": page_def.path,
"mode": mode,
"has-data": has_data,
"content-expr": content_src[:80] + ("..." if len(content_src) > 80 else ""),
"reason": reason,
})
# Sort: client pages first, then server
pages_data.sort(key=lambda p: (0 if p["mode"] == "client" else 1, p["name"]))
# Build a sample of the SX page registry format (use full content, first 3)
total = client_count + server_count
sample_entries = []
sorted_full = sorted(full_content, key=lambda x: x[0])
for name, csrc, hd in sorted_full[:3]:
page_def = get_all_pages("sx").get(name)
if not page_def:
continue
entry = (
"{:name " + _sx_literal(name)
+ "\n :path " + _sx_literal(page_def.path)
+ "\n :auth " + _sx_literal("public")
+ " :has-data " + ("true" if hd else "false")
+ "\n :content " + _sx_literal(csrc)
+ "\n :closure {}}"
)
sample_entries.append(entry)
registry_sample = "\n\n".join(sample_entries)
return {
"pages": pages_data,
"total-pages": total,
"client-count": client_count,
"server-count": server_count,
"registry-sample": registry_sample,
}
def _attr_detail_data(slug: str) -> dict: def _attr_detail_data(slug: str) -> dict:
"""Return attribute detail data for a specific attribute slug. """Return attribute detail data for a specific attribute slug.
@@ -411,3 +489,26 @@ def _event_detail_data(slug: str) -> dict:
"event-example": detail.get("example"), "event-example": detail.get("example"),
"event-demo": sx_call(demo_name) if demo_name else None, "event-demo": sx_call(demo_name) if demo_name else None,
} }
def _data_test_data() -> dict:
"""Return test data for the client-side data rendering test page.
This exercises the Phase 4 data endpoint: server evaluates this
helper, serializes the result as SX, the client fetches and parses
it, then renders the page content with these bindings.
"""
from datetime import datetime, timezone
return {
"server-time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"items": [
{"label": "Eval", "detail": "Server evaluates :data expression"},
{"label": "Serialize", "detail": "Result serialized as SX wire format"},
{"label": "Fetch", "detail": "Client calls resolve-page-data"},
{"label": "Parse", "detail": "Client parses SX response to dict"},
{"label": "Render", "detail": "Client merges data into env, renders content"},
],
"phase": "Phase 4 — Client Async & IO Bridge",
"transport": "SX wire format (text/sx)",
}

View File

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

View File

@@ -28,6 +28,7 @@ COPY test/ ./test-app-tmp/
RUN cp -r test-app-tmp/app.py test-app-tmp/path_setup.py \ RUN cp -r test-app-tmp/app.py test-app-tmp/path_setup.py \
test-app-tmp/bp test-app-tmp/sx test-app-tmp/services \ test-app-tmp/bp test-app-tmp/sx test-app-tmp/services \
test-app-tmp/runner.py test-app-tmp/__init__.py ./ 2>/dev/null || true && \ test-app-tmp/runner.py test-app-tmp/__init__.py ./ 2>/dev/null || true && \
([ -d test-app-tmp/sxc ] && cp -r test-app-tmp/sxc ./ || true) && \
rm -rf test-app-tmp rm -rf test-app-tmp
# Sibling models for cross-domain SQLAlchemy imports # Sibling models for cross-domain SQLAlchemy imports

View File

@@ -12,18 +12,32 @@ def register(url_prefix: str = "/") -> Blueprint:
@bp.get("/") @bp.get("/")
async def index(): async def index():
"""Full page dashboard with last results.""" """Full page dashboard with last results."""
from shared.sx.page import get_template_context
from shared.browser.app.csrf import generate_csrf_token from shared.browser.app.csrf import generate_csrf_token
from sxc.pages.renders import render_dashboard_page_sx from sxc.pages.renders import render_dashboard_page_sx, render_results_partial_sx
import runner import runner
ctx = await get_template_context()
result = runner.get_results() result = runner.get_results()
running = runner.is_running() running = runner.is_running()
csrf = generate_csrf_token() csrf = generate_csrf_token()
active_filter = request.args.get("filter") active_filter = request.args.get("filter")
active_service = request.args.get("service") active_service = request.args.get("service")
is_sx = bool(request.headers.get("SX-Request") or request.headers.get("HX-Request"))
if is_sx:
from shared.sx.helpers import sx_response
inner = await render_results_partial_sx(
result, running, csrf,
active_filter=active_filter,
active_service=active_service,
)
# Wrap in #main-panel so sx-select="#main-panel" works
sx = (f'(section :id "main-panel" :class "flex-1 md:h-full md:min-h-0'
f' overflow-y-auto overscroll-contain js-grid-viewport" {inner})')
return sx_response(sx)
from shared.sx.page import get_template_context
ctx = await get_template_context()
html = await render_dashboard_page_sx( html = await render_dashboard_page_sx(
ctx, result, running, csrf, ctx, result, running, csrf,
active_filter=active_filter, active_filter=active_filter,
@@ -78,6 +92,7 @@ def register(url_prefix: str = "/") -> Blueprint:
async def results(): async def results():
"""HTMX partial — poll target for results table.""" """HTMX partial — poll target for results table."""
from shared.browser.app.csrf import generate_csrf_token from shared.browser.app.csrf import generate_csrf_token
from shared.sx.helpers import sx_response
from sxc.pages.renders import render_results_partial_sx from sxc.pages.renders import render_results_partial_sx
import runner import runner
@@ -93,10 +108,9 @@ def register(url_prefix: str = "/") -> Blueprint:
active_service=active_service, active_service=active_service,
) )
resp = Response(html, status=200, content_type="text/html") headers = {}
# If still running, tell HTMX to keep polling
if running: if running:
resp.headers["HX-Trigger-After-Swap"] = "test-still-running" headers["HX-Trigger-After-Swap"] = "test-still-running"
return resp return sx_response(html, headers=headers)
return bp return bp