34 Commits

Author SHA1 Message Date
c43f774992 Skip event processor in standalone mode (no DB for LISTEN/NOTIFY)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m28s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:02:05 +00:00
9cde15c3ce Skip DB registration in standalone mode (fixes sx-web.org startup)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m5s
The sx app is stateless — no database needed. In standalone mode
(SX_STANDALONE=true), the factory now skips register_db() so the app
doesn't crash trying to connect to a non-existent PostgreSQL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:53:05 +00:00
6a98c39937 Use existing Caddy for sx-web.org routing
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m44s
Single Caddy instance handles all domains. sx-web stack joins
externalnet instead of running its own Caddy (port conflict).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:41:36 +00:00
60ed828e0e Merge branch 'macros'
# Conflicts:
#	blog/bp/post/admin/routes.py
#	events/sxc/pages/calendar.py
#	events/sxc/pages/entries.py
#	events/sxc/pages/slots.py
#	events/sxc/pages/tickets.py
2026-03-05 16:40:06 +00:00
0f4520d987 Add standalone mode for sx-web.org deployment
- SX_STANDALONE=true env var: no OAuth, no root header, no cross-service
  fragments. Same image runs in both rose-ash cooperative and standalone.
- Factory: added no_oauth parameter to create_base_app()
- Standalone layout defcomps skip ~root-header-auto/~root-mobile-auto
- Fixed Dockerfile: was missing sx/sx/ component directory copy
- CI: deploys sx-web swarm stack on main branch when sx changes
- Stack config at ~/sx-web/ (Caddy → sx_docs, Redis)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:39:21 +00:00
5fff83ae79 Add header and event detail pages, fix copyright, rename essay
- Detail pages for all 18 HTTP headers with descriptions, example usage,
  direction badges (request/response/both), and live demos for SX-Prompt,
  SX-Trigger, SX-Retarget
- Detail pages for all 10 DOM events with descriptions, example usage,
  and live demos for beforeRequest, afterSettle, responseError,
  validationFailed
- Header and event table rows now link to their detail pages
- Fix copyright symbol on home page (was literal \u00a9, now actual ©)
- Rename "Godel, Escher, Bach" essay to "Strange Loops" with updated summary
- Remove duplicate script injection from bootstrapper page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:25:15 +00:00
1797bd4b16 Add Bootstrappers section, essays index, specs prose, layout fixes
- New Bootstrappers top-level section with overview index and JS bootstrapper
  page that runs bootstrap_js.py and displays both source and generated output
  with live script injection (full page load, not SX navigation)
- Essays section: index page with linked cards and summaries, sx-sucks moved
  to end of nav, removed "grand tradition" line
- Specs: English prose descriptions alongside all canonical .sx specs, added
  Boot/CSSX/Browser spec files to architecture page
- Layout: menu bar nav items wrap instead of overflow, baseline alignment
  between label and nav options
- Homepage: added copyright line

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:12:38 +00:00
436848060d Merge branch 'worktree-sx-loop-cleanup' into macros
# Conflicts:
#	blog/sx/sx_components.py
#	federation/sx/profile.sx
#	federation/sx/sx_components.py
#	orders/sx/sx_components.py
2026-03-05 16:08:36 +00:00
c1ad6fd8d4 Replace Python sx_call loops with data-driven SX defcomps using map
Move rendering logic from Python for-loops building sx_call strings into
SX defcomp components that use map/lambda over data dicts. Python now
serializes display data into plain dicts and passes them via a single
sx_call; the SX layer handles iteration and conditional rendering.

Covers orders (rows, items, calendar, tickets), federation (timeline,
search, actors, profile activities), and blog (cards, pages, filters,
snippets, menu items, tag groups, page search, nav OOB).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:03:29 +00:00
cea009084f Fix sx-browser.js navigation bugs: CSS tracking meta tag and stale verb info
Two fixes for sx-browser.js (spec-compiled) vs sx.js (hand-written):

1. CSS meta tag mismatch: initCssTracking read meta[name="sx-css-hash"]
   but the page template uses meta[name="sx-css-classes"]. This left
   _cssHash empty, causing the server to send ALL CSS as "new" on every
   navigation, appending duplicate rules that broke Tailwind responsive
   ordering (e.g. menu bar layout).

2. Stale verb info after morph: execute-request used captured verbInfo
   from bind time. After morph updated element attributes (e.g. during
   OOB nav swap), click handlers still fired with old URLs. Now re-reads
   verb info from the element first, matching sx.js behavior.

Also includes: render-expression dispatch in eval.sx, NIL guard for
preload cache in bootstrap_js.py, and helpers.py switched to
sx-browser.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:28:56 +00:00
af77fc32c7 Move spec metadata from Python to SX, add orchestration to spec viewer
Spec file registry (slugs, filenames, titles, descriptions) now lives in
nav-data.sx as SX data definitions. Python helper reduced to pure file I/O
(read-spec-file). Architecture page updated with engine/orchestration split
and dependency graph.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:34:58 +00:00
d696735f95 Merge branch 'worktree-sx-meta-eval' into macros
# Conflicts:
#	shared/static/scripts/sx-browser.js
2026-03-05 13:20:36 +00:00
bea071a039 Add CSSX and boot adapters to SX spec (style dictionary + browser lifecycle)
- cssx.sx: on-demand CSS style dictionary (variant splitting, atom resolution, content-addressed hashing, style merging)
- boot.sx: browser boot lifecycle (script processing, mount/hydrate/update, component caching, head element hoisting)
- bootstrap_js.py: platform JS for cssx (FNV-1a hash, regex, CSS injection) and boot (localStorage, cookies, DOM mounting)
- Rebuilt sx-browser.js (136K) and sx-ref.js (148K) with all adapters

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:20:29 +00:00
1c7346ab37 Resolve merge conflict in generated sx-browser.js 2026-03-05 13:04:44 +00:00
d07a408c89 Merge branch 'worktree-sx-meta-eval' into macros 2026-03-05 13:04:35 +00:00
eac0fce8f7 Split orchestration from engine into separate adapter
engine.sx now contains only pure logic: parsing, morph, swap, headers,
retry, target resolution, etc. orchestration.sx contains the browser
wiring: request execution, trigger binding, SSE, boost, post-swap
lifecycle, and init. Dependency is one-way: orchestration → engine.

Bootstrap compiler gains "orchestration" as a separate adapter with
deps on engine+dom. Engine-only builds get morph/swap without the
full browser runtime.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:04:27 +00:00
639f96fe6b Merge branch 'worktree-sx-meta-eval' into macros 2026-03-05 12:54:48 +00:00
d4b23aae4c Add engine orchestration to SX spec (fetch, triggers, swap, SSE, history, init)
29 orchestration functions written in SX + adapter style: request pipeline
(execute-request, do-fetch, handle-fetch-success), trigger binding (poll,
intersect, load, revealed, event), post-swap processing, OOB swaps, boost,
SSE, inline handlers, preload, history/popstate, and engine-init. Platform
JS implementations in bootstrap_js.py for all browser-specific operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:54:39 +00:00
3197022299 Restructure Specs section into Architecture, Core, and Adapters pages
- Add Architecture intro page explaining the spec's two-layer design
  (core language + selectable adapters) with dependency graph
- Split specs into Core (parser, eval, primitives, render) and
  Adapters (DOM, HTML, SX wire, SxEngine) overview pages
- Add individual detail pages for all adapter and engine specs
- Update nav with Architecture landing, Core, Adapters, and all
  individual spec file links

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:55:59 +00:00
7c99002345 Merge sx-browser.js into macros 2026-03-05 11:52:06 +00:00
157a32b426 Add sx-browser.js — browser-only build from SX spec (dom+engine)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:52:03 +00:00
ab50fb5f56 Merge core+adapter SX ref restructure into macros 2026-03-05 11:50:04 +00:00
daeecab310 Restructure SX ref spec into core + selectable adapters
Split monolithic render.sx into core (tag registries, shared utils) plus
four adapter .sx files: adapter-html (server HTML strings), adapter-sx
(SX wire format), adapter-dom (browser DOM nodes), and engine (SxEngine
triggers, morphing, swaps). All adapters written in s-expressions with
platform interface declarations for JS bridge functions.

Bootstrap compiler now accepts --adapters flag to emit targeted builds:
  -a html        → server-only (1108 lines)
  -a dom,engine  → browser-only (1634 lines)
  -a html,sx     → server with SX wire (1169 lines)
  (default)      → all adapters (1800 lines)

Fixes: keyword arg i-counter desync in reduce across all adapters,
render-aware special forms (let/if/when/cond/map) in HTML adapter,
component children double-escaping, ~prefixed macro dispatch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:49:44 +00:00
7ecbf19c11 Add Specs section, Reflexive Web essay, fix highlight and dev caching
- Fix highlight() returning SxExpr so syntax-highlighted code renders
  as DOM elements instead of leaking SX source text into the page
- Add Specs section that reads and displays canonical SX spec files
  from shared/sx/ref/ with syntax highlighting
- Add "The Reflexive Web" essay on SX becoming a complete LISP with
  AI as native participant
- Change logo from (<x>) to (<sx>) everywhere
- Unify all backgrounds to bg-stone-100, center code blocks
- Skip component/style cookie cache in dev mode so .sx edits are
  visible immediately on refresh without clearing localStorage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:49:05 +00:00
6fa843016b Gate server-side component expansion with contextvar, fix nth arg order, add GEB essay and manifesto links
- Add _expand_components contextvar so _aser only expands components
  during page slot evaluation (fixes highlight on examples, avoids
  breaking fragment responses)
- Fix nth arg order (nth coll n) in docs.sx, examples.sx (delete-row,
  edit-row, bulk-update)
- Add "Godel, Escher, Bach and SX" essay with Wikipedia links
- Update SX Manifesto: new authors, Wikipedia links throughout,
  remove Marx/Engels link

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:03:50 +00:00
4a515f1a0d Add canonical SX language spec reference to CLAUDE.md
Points AI and developers to shared/sx/ref/ as the authoritative
source for SX semantics — eval rules, type system, rendering modes,
component calling convention, and platform interface.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:36:22 +00:00
824396c7b0 Merge branch 'worktree-sx-meta-eval' into macros 2026-03-05 10:23:45 +00:00
dea4f52454 Expand known components server-side in _aser to fix nested highlight calls
_aser previously serialized all ~component calls for client rendering.
Components whose bodies call Python-only functions (e.g. highlight) would
fail on the client with "Undefined symbol". Now _aser expands components
that are defined in the env via _aser_component, producing SX wire format
with tag-level bodies inlined. Unknown components still serialize as-is.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:20:24 +00:00
a9526c4fa1 Update reference SX spec to match sx.js macros branch (CSSX, dict literals, new primitives)
- eval.sx: Add defstyle, defkeyframes, defhandler special forms; add ho-for-each
- parser.sx: Add dict {...} literal parsing and quasiquote/unquote sugar
- primitives.sx: Add parse-datetime, split-ids, css, merge-styles primitives
- render.sx: Add StyleValue handling, SVG filter elements, definition forms in render, fix render-to-html to handle HTML tags directly
- bootstrap_js.py: Add StyleValue type, buildKeyframes, isEvery platform helper, new primitives (format-date, parse-datetime, split-ids, css, merge-styles), dict/quasiquote parser, expose render functions as primitives
- sx-ref.js: Regenerated — 132/132 tests passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:17:28 +00:00
4a3a510a23 Merge branch 'macros' into worktree-sx-meta-eval 2026-03-05 10:03:15 +00:00
e1ae81f736 Add bootstrap compiler: reference SX spec → JavaScript
bootstrap_js.py reads the reference .sx specification (eval.sx, render.sx)
and transpiles the defined evaluator functions into standalone JavaScript.
The output sx-ref.js is a fully functional SX evaluator bootstrapped from
the s-expression spec, comparable against the hand-written sx.js.

Key features:
- JSEmitter class transpiles SX AST → JS (fn→function, let→IIFE, cond→ternary, etc.)
- Platform interface (types, env ops, primitives) implemented as native JS
- Post-transpilation fixup wraps callLambda to handle both Lambda objects and primitives
- 93/93 tests passing: arithmetic, strings, control flow, closures, HO forms,
  components, macros, threading, dict ops, predicates

Fixed during development:
- Bool before int isinstance check (Python bool is subclass of int)
- SX NIL sentinel detection (not Python None)
- Cond style detection (determine Scheme vs Clojure once, not per-pair)
- Predicate null safety (x != null instead of x && to avoid 0-as-falsy in SX)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:58:48 +00:00
8c69e329e0 Fix dict kwarg evaluation in renderComponentDOM, no-cache static in dev
Dict values (e.g. {:X-CSRFToken csrf}) passed as component kwargs were
not being evaluated through sxEval — symbols stayed unresolved in the DOM.
Also add Cache-Control: no-cache headers for /static/ in dev mode so
browser always fetches fresh JS/CSS without needing hard refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:37:07 +00:00
235428628a Add reference SX evaluator written in s-expressions
Meta-circular evaluator: the SX language specifying its own semantics.
A thin bootstrap compiler per target (JS, Python, Rust) reads these
.sx files and emits a native evaluator.

Files:
- eval.sx: Core evaluator — type dispatch, special forms, TCO trampoline,
  lambda/component/macro invocation, higher-order forms
- primitives.sx: Declarative specification of ~80 built-in pure functions
- render.sx: Three rendering modes (DOM, HTML string, SX wire format)
- parser.sx: Tokenizer, parser, and serializer specification

Platform-specific concerns (DOM ops, async I/O, HTML emission) are
declared as interfaces that each target implements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:31:40 +00:00
51ebf347ba Move events/market/blog composition from Python to .sx defcomps (Phase 9)
Continues the pattern of eliminating Python sx_call tree-building in favour
of data-driven .sx defcomps. POST/PUT/DELETE routes now pass plain data
(dicts, lists, scalars) and let .sx handle iteration, conditionals, and
layout via map/let/when/if. Single response components wrap OOB swaps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:17:09 +00:00
66 changed files with 17188 additions and 366 deletions

View File

@@ -84,13 +84,27 @@ jobs:
fi
done
# Deploy swarm stack only on main branch
# Deploy swarm stacks only on main branch
if [ '${{ github.ref_name }}' = 'main' ]; then
source .env
docker stack deploy -c docker-compose.yml rose-ash
echo 'Waiting for swarm services to update...'
sleep 10
docker stack services rose-ash
# Deploy sx-web standalone stack (sx-web.org)
SX_REBUILT=false
if [ \"\$REBUILD_ALL\" = true ] || echo \"\$CHANGED\" | grep -q '^sx/'; then
SX_REBUILT=true
fi
if [ \"\$SX_REBUILT\" = true ]; then
echo 'Deploying sx-web stack (sx-web.org)...'
docker stack deploy -c /root/sx-web/docker-compose.yml sx-web
sleep 5
docker stack services sx-web
# Reload Caddy to pick up any Caddyfile changes
docker service update --force caddy_caddy 2>/dev/null || true
fi
else
echo 'Skipping swarm deploy (branch: ${{ github.ref_name }})'
fi

View File

@@ -52,6 +52,65 @@ artdag/
test/ # Integration & e2e tests
```
## SX Language — Canonical Reference
The SX language is defined by a self-hosting specification in `shared/sx/ref/`. **Read these files for authoritative SX semantics** — they supersede any implementation detail in `sx.js` or Python evaluators.
### Specification files
- **`shared/sx/ref/eval.sx`** — Core evaluator: types, trampoline (TCO), `eval-expr` dispatch, special forms (`if`, `when`, `cond`, `case`, `let`, `and`, `or`, `lambda`, `define`, `defcomp`, `defmacro`, `quasiquote`), higher-order forms (`map`, `filter`, `reduce`, `some`, `every?`, `for-each`), macro expansion, function/lambda/component calling.
- **`shared/sx/ref/parser.sx`** — Tokenizer and parser: grammar, string escapes, dict literals `{:key val}`, quote sugar (`` ` ``, `,`, `,@`), serializer.
- **`shared/sx/ref/primitives.sx`** — All ~80 built-in pure functions: arithmetic, comparison, predicates, string ops, collection ops, dict ops, format helpers, CSSX style primitives.
- **`shared/sx/ref/render.sx`** — Three rendering modes: `render-to-html` (server HTML), `render-to-sx`/`aser` (SX wire format for client), `render-to-dom` (browser). HTML tag registry, void elements, boolean attrs.
- **`shared/sx/ref/bootstrap_js.py`** — Transpiler: reads the `.sx` spec files and emits `sx-ref.js`.
### Type system
```
number, string, boolean, nil, symbol, keyword, list, dict,
lambda, component, macro, thunk (TCO deferred eval)
```
### Evaluation rules (from eval.sx)
1. **Literals** (number, string, boolean, nil) — pass through
2. **Symbols** — look up in env, then primitives, then `true`/`false`/`nil`, else error
3. **Keywords** — evaluate to their string name
4. **Dicts** — evaluate all values recursively
5. **Lists** — dispatch on head:
- Special forms (`if`, `when`, `cond`, `case`, `let`, `lambda`, `define`, `defcomp`, `defmacro`, `quote`, `quasiquote`, `begin`/`do`, `set!`, `->`)
- Higher-order forms (`map`, `filter`, `reduce`, `some`, `every?`, `for-each`, `map-indexed`)
- Macros — expand then re-evaluate
- Function calls — evaluate head and args, then: native callable → `apply`, lambda → bind params + TCO thunk, component → parse keyword args + bind params + TCO thunk
### Component calling convention
```lisp
(defcomp ~card (&key title subtitle &rest children)
(div :class "card"
(h2 title)
(when subtitle (p subtitle))
children))
```
- `&key` params are keyword arguments: `(~card :title "Hi" :subtitle "Sub")`
- `&rest children` captures positional args as `children`
- Component body evaluated in merged env: `closure + caller-env + bound-params`
### Rendering modes (from render.sx)
| Mode | Function | Expands components? | Output |
|------|----------|-------------------|--------|
| HTML | `render-to-html` | Yes (recursive) | HTML string |
| SX wire | `aser` | No — serializes `(~name ...)` | SX source text |
| DOM | `render-to-dom` | Yes (recursive) | DOM nodes |
The `aser` (async-serialize) mode evaluates control flow and function calls but serializes HTML tags and component calls as SX source — the client renders them. This is the wire format for HTMX-like responses.
### Platform interface
Each target (JS, Python) must provide: type inspection (`type-of`), constructors (`make-lambda`, `make-component`, `make-macro`, `make-thunk`), accessors, environment operations (`env-has?`, `env-get`, `env-set!`, `env-extend`, `env-merge`), and DOM/HTML rendering primitives.
## Tech Stack
**Web platform:** Python 3.11+, Quart (async Flask), SQLAlchemy (asyncpg), Jinja2, HTMX, PostgreSQL, Redis, Docker Swarm, Hypercorn.
@@ -110,11 +169,11 @@ cd artdag/l1 && mypy app/types.py app/routers/recipes.py tests/
### SX Rendering Pipeline
The SX system renders component trees defined in s-expressions. The same AST can be evaluated in different modes depending on where the server/client rendering boundary is drawn:
The SX system renders component trees defined in s-expressions. Canonical semantics are in `shared/sx/ref/` (see "SX Language" section above). The same AST can be evaluated in different modes depending on where the server/client rendering boundary is drawn:
- `render_to_html(name, **kw)` — server-side, produces HTML. Used by route handlers returning full HTML.
- `render_to_sx(name, **kw)` — server-side, produces SX wire format. Component calls stay **unexpanded** (serialized for client-side rendering by sx.js).
- `render_to_sx_with_env(name, env, **kw)` — server-side, **expands the top-level component** then serializes children as SX wire format. Used by layout components that need Python context (auth state, fragments, URLs) resolved server-side.
- `render_to_html(name, **kw)` — server-side, produces HTML. Maps to `render-to-html` in the spec.
- `render_to_sx(name, **kw)` — server-side, produces SX wire format. Maps to `aser` in the spec. Component calls stay **unexpanded**.
- `render_to_sx_with_env(name, env, **kw)` — server-side, **expands known components** then serializes as SX wire format. Used by layout components that need Python context.
- `sx_page(ctx, page_sx)` — produces the full HTML shell (`<!doctype html>...`) with component definitions, CSS, and page SX inlined for client-side boot.
See the docstring in `shared/sx/async_eval.py` for the full evaluation modes table.

View File

@@ -143,6 +143,80 @@
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
edit-form delete-form))
;; Data-driven snippets list (replaces Python _snippets_sx loop)
(defcomp ~blog-snippets-from-data (&key snippets user-id is-admin csrf badge-colours)
(~blog-snippets-list
:rows (<> (map (lambda (s)
(let* ((s-id (get s "id"))
(s-name (get s "name"))
(s-uid (get s "user_id"))
(s-vis (get s "visibility"))
(owner (if (= s-uid user-id) "You" (str "User #" s-uid)))
(badge-cls (or (get badge-colours s-vis) "bg-stone-200 text-stone-700"))
(extra (<>
(when is-admin
(~blog-snippet-visibility-select
:patch-url (get s "patch_url")
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:options (<>
(~blog-snippet-option :value "private" :selected (= s-vis "private") :label "private")
(~blog-snippet-option :value "shared" :selected (= s-vis "shared") :label "shared")
(~blog-snippet-option :value "admin" :selected (= s-vis "admin") :label "admin"))
:cls "text-sm border border-stone-300 rounded px-2 py-1"))
(when (or (= s-uid user-id) is-admin)
(~delete-btn :url (get s "delete_url") :trigger-target "#snippets-list"
:title "Delete snippet?"
:text (str "Delete \u201c" s-name "\u201d?")
:sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0")))))
(~blog-snippet-row :name s-name :owner owner :badge-cls badge-cls
:visibility s-vis :extra extra)))
(or snippets (list))))))
;; Data-driven menu items list (replaces Python _menu_items_list_sx loop)
(defcomp ~blog-menu-items-from-data (&key items csrf)
(~blog-menu-items-list
:rows (<> (map (lambda (item)
(let* ((img (~img-or-placeholder :src (get item "feature_image") :alt (get item "label")
:size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0")))
(~blog-menu-item-row
:img img :label (get item "label") :slug (get item "slug")
:sort-order (get item "sort_order") :edit-url (get item "edit_url")
:delete-url (get item "delete_url")
:confirm-text (str "Remove " (get item "label") " from the menu?")
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}"))))
(or items (list))))))
;; Data-driven tag groups main (replaces Python _tag_groups_main_panel_sx loops)
(defcomp ~blog-tag-groups-from-data (&key groups unassigned-tags csrf create-url)
(~blog-tag-groups-main
:form (~blog-tag-groups-create-form :create-url create-url :csrf csrf)
:groups (if (empty? (or groups (list)))
(~empty-state :message "No tag groups yet." :cls "text-stone-500 text-sm")
(~blog-tag-groups-list
:items (<> (map (lambda (g)
(let* ((icon (if (get g "feature_image")
(~blog-tag-group-icon-image :src (get g "feature_image") :name (get g "name"))
(~blog-tag-group-icon-color :style (get g "style") :initial (get g "initial")))))
(~blog-tag-group-li :icon icon :edit-href (get g "edit_href")
:name (get g "name") :slug (get g "slug") :sort-order (get g "sort_order"))))
groups))))
:unassigned (when (not (empty? (or unassigned-tags (list))))
(~blog-unassigned-tags
:heading (str "Unassigned Tags (" (len unassigned-tags) ")")
:spans (<> (map (lambda (t)
(~blog-unassigned-tag :name (get t "name")))
unassigned-tags))))))
;; Data-driven tag group edit (replaces Python _tag_groups_edit_main_panel_sx loop)
(defcomp ~blog-tag-checkboxes-from-data (&key tags)
(<> (map (lambda (t)
(~blog-tag-checkbox
:tag-id (get t "tag_id") :checked (get t "checked")
:img (when (get t "feature_image") (~blog-tag-checkbox-image :src (get t "feature_image")))
:name (get t "name")))
(or tags (list)))))
;; Preview panel components
(defcomp ~blog-preview-panel (&key sections)
@@ -412,3 +486,108 @@
(~blog-data-model-content
:columns (get model-data "columns")
:relationships (get model-data "relationships")))))
;; ---------------------------------------------------------------------------
;; Calendar month view for browsing/toggling entries (B1)
;; ---------------------------------------------------------------------------
(defcomp ~blog-cal-entry-associated (&key name toggle-url csrf)
(div :class "flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900"
(span :class "truncate flex-1" name)
(button :type "button" :class "flex-shrink-0 hover:text-red-600"
:data-confirm "" :data-confirm-title "Remove entry?"
:data-confirm-text (str "Remove " name " from this post?")
:data-confirm-icon "warning" :data-confirm-confirm-text "Yes, remove it"
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
:sx-post toggle-url :sx-trigger "confirmed"
:sx-target "#associated-entries-list" :sx-swap "outerHTML"
:sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:sx-on:afterSwap "document.body.dispatchEvent(new CustomEvent('entryToggled'))"
(i :class "fa fa-times"))))
(defcomp ~blog-cal-entry-unassociated (&key name toggle-url csrf)
(button :type "button"
:class "w-full text-left text-[10px] rounded px-1 py-0.5 bg-stone-100 text-stone-700 hover:bg-stone-200"
:data-confirm "" :data-confirm-title "Add entry?"
:data-confirm-text (str "Add " name " to this post?")
:data-confirm-icon "question" :data-confirm-confirm-text "Yes, add it"
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
:sx-post toggle-url :sx-trigger "confirmed"
:sx-target "#associated-entries-list" :sx-swap "outerHTML"
:sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:sx-on:afterSwap "document.body.dispatchEvent(new CustomEvent('entryToggled'))"
(span :class "truncate block" name)))
(defcomp ~blog-calendar-view (&key cal-id year month-name
current-url prev-month-url prev-year-url
next-month-url next-year-url
weekday-names days csrf)
(let* ((target (str "#calendar-view-" cal-id)))
(div :id (str "calendar-view-" cal-id)
:sx-get current-url :sx-trigger "entryToggled from:body" :sx-swap "outerHTML"
(header :class "flex items-center justify-center mb-4"
(nav :class "flex items-center gap-2 text-xl"
(a :class "px-2 py-1 hover:bg-stone-100 rounded"
:sx-get prev-year-url :sx-target target :sx-swap "outerHTML"
(raw! "&laquo;"))
(a :class "px-2 py-1 hover:bg-stone-100 rounded"
:sx-get prev-month-url :sx-target target :sx-swap "outerHTML"
(raw! "&lsaquo;"))
(div :class "px-3 font-medium" (str month-name " " year))
(a :class "px-2 py-1 hover:bg-stone-100 rounded"
:sx-get next-month-url :sx-target target :sx-swap "outerHTML"
(raw! "&rsaquo;"))
(a :class "px-2 py-1 hover:bg-stone-100 rounded"
:sx-get next-year-url :sx-target target :sx-swap "outerHTML"
(raw! "&raquo;"))))
(div :class "rounded border bg-white"
(div :class "hidden sm:grid grid-cols-7 text-center text-xs font-semibold text-stone-700 bg-stone-50 border-b"
(map (lambda (wd) (div :class "py-2" wd)) (or weekday-names (list))))
(div :class "grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200"
(map (lambda (day)
(let* ((extra-cls (if (get day "in_month") "" " bg-stone-50 text-stone-400"))
(entries (or (get day "entries") (list))))
(div :class (str "min-h-20 bg-white px-2 py-2 text-xs" extra-cls)
(div :class "font-medium mb-1" (str (get day "day")))
(when (not (empty? entries))
(div :class "space-y-0.5"
(map (lambda (e)
(if (get e "is_associated")
(~blog-cal-entry-associated
:name (get e "name") :toggle-url (get e "toggle_url") :csrf csrf)
(~blog-cal-entry-unassociated
:name (get e "name") :toggle-url (get e "toggle_url") :csrf csrf)))
entries))))))
(or days (list))))))))
;; ---------------------------------------------------------------------------
;; Nav entries OOB — renders associated entry/calendar items in scroll wrapper (B2)
;; ---------------------------------------------------------------------------
(defcomp ~blog-nav-entries-oob (&key entries calendars)
(let* ((entry-list (or entries (list)))
(cal-list (or calendars (list)))
(has-items (or (not (empty? entry-list)) (not (empty? cal-list))))
(nav-cls "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black [.hover-capable_&]:hover:bg-yellow-300 aria-selected:bg-stone-500 aria-selected:text-white [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500 p-2")
(scroll-hs "on load or scroll if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end"))
(if (not has-items)
(~blog-nav-entries-empty)
(~scroll-nav-wrapper
:wrapper-id "entries-calendars-nav-wrapper"
:container-id "associated-items-container"
:arrow-cls "entries-nav-arrow"
:left-hs "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200"
:scroll-hs scroll-hs
:right-hs "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200"
:items (<>
(map (lambda (e)
(~calendar-entry-nav
:href (get e "href") :nav-class nav-cls
:name (get e "name") :date-str (get e "date_str")))
entry-list)
(map (lambda (c)
(~blog-nav-calendar-item
:href (get c "href") :nav-cls nav-cls
:name (get c "name")))
cal-list))
:oob true))))

View File

@@ -106,6 +106,43 @@
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors))))))))
;; Data-driven blog cards list (replaces Python _blog_cards_sx loop)
(defcomp ~blog-cards-from-data (&key posts view sentinel)
(<>
(map (lambda (p)
(if (= view "tile")
(~blog-card-tile
:href (get p "href") :hx-select (get p "hx_select")
:feature-image (get p "feature_image") :title (get p "title")
:is-draft (get p "is_draft") :publish-requested (get p "publish_requested")
:status-timestamp (get p "status_timestamp")
:excerpt (get p "excerpt") :tags (get p "tags") :authors (get p "authors"))
(~blog-card
:slug (get p "slug") :href (get p "href") :hx-select (get p "hx_select")
:title (get p "title") :feature-image (get p "feature_image")
:excerpt (get p "excerpt") :is-draft (get p "is_draft")
:publish-requested (get p "publish_requested")
:status-timestamp (get p "status_timestamp")
:has-like (get p "has_like") :liked (get p "liked")
:like-url (get p "like_url") :csrf-token (get p "csrf_token")
:tags (get p "tags") :authors (get p "authors")
:widget (when (get p "widget") (~rich-text :html (get p "widget"))))))
(or posts (list)))
sentinel))
;; Data-driven page cards list (replaces Python _page_cards_sx loop)
(defcomp ~page-cards-from-data (&key pages sentinel)
(<>
(map (lambda (pg)
(~blog-page-card
:href (get pg "href") :hx-select (get pg "hx_select")
:title (get pg "title")
:has-calendar (get pg "has_calendar") :has-market (get pg "has_market")
:pub-timestamp (get pg "pub_timestamp")
:feature-image (get pg "feature_image") :excerpt (get pg "excerpt")))
(or pages (list)))
sentinel))
(defcomp ~blog-page-badges (&key has-calendar has-market)
(div :class "flex justify-center gap-2 mt-2"
(when has-calendar (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800"

View File

@@ -63,3 +63,39 @@
(defcomp ~blog-filter-summary (&key text)
(span :class "text-sm text-stone-600" text))
;; Data-driven tag groups filter (replaces Python _tag_groups_filter_sx loop)
(defcomp ~blog-tag-groups-filter-from-data (&key groups selected-groups hx-select)
(let* ((is-any (empty? (or selected-groups (list))))
(any-cls (if is-any "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50")))
(~blog-filter-nav
:items (<>
(~blog-filter-any-topic :cls any-cls :hx-select hx-select)
(map (lambda (g)
(let* ((slug (get g "slug"))
(name (get g "name"))
(is-on (contains? selected-groups slug))
(cls (if is-on "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
(icon (if (get g "feature_image")
(~blog-filter-group-icon-image :src (get g "feature_image") :name name)
(~blog-filter-group-icon-color :style (get g "style") :initial (get g "initial")))))
(~blog-filter-group-li :cls cls :hx-get (str "?group=" slug "&page=1") :hx-select hx-select
:icon icon :name name :count (get g "count"))))
(or groups (list)))))))
;; Data-driven authors filter (replaces Python _authors_filter_sx loop)
(defcomp ~blog-authors-filter-from-data (&key authors selected-authors hx-select)
(let* ((is-any (empty? (or selected-authors (list))))
(any-cls (if is-any "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50")))
(~blog-filter-nav
:items (<>
(~blog-filter-any-author :cls any-cls :hx-select hx-select)
(map (lambda (a)
(let* ((slug (get a "slug"))
(is-on (contains? selected-authors slug))
(cls (if is-on "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
(icon (when (get a "profile_image")
(~blog-filter-author-icon :src (get a "profile_image") :name (get a "name")))))
(~blog-filter-author-li :cls cls :hx-get (str "?author=" slug "&page=1") :hx-select hx-select
:icon icon :name (get a "name") :count (get a "count"))))
(or authors (list)))))))

View File

@@ -24,3 +24,37 @@
(defcomp ~page-search-empty (&key query)
(div :class "p-3 text-center text-stone-400 border border-stone-200 rounded-md"
(str "No pages found matching \"" query "\"")))
;; Data-driven page search results (replaces Python render_page_search_results loop)
(defcomp ~page-search-results-from-data (&key pages query has-more search-url next-page)
(if (and (not pages) query)
(~page-search-empty :query query)
(when pages
(~page-search-results
:items (<> (map (lambda (p)
(~page-search-item
:id (get p "id") :title (get p "title")
:slug (get p "slug") :feature-image (get p "feature_image")))
pages))
:sentinel (when has-more
(~page-search-sentinel :url search-url :query query :next-page next-page))))))
;; Data-driven menu nav items (replaces Python render_menu_items_nav_oob loop)
(defcomp ~blog-menu-nav-from-data (&key items nav-cls container-id arrow-cls scroll-hs)
(if (not items)
(~blog-nav-empty :wrapper-id "menu-items-nav-wrapper")
(~scroll-nav-wrapper :wrapper-id "menu-items-nav-wrapper" :container-id container-id
:arrow-cls arrow-cls
:left-hs (str "on click set #" container-id ".scrollLeft to #" container-id ".scrollLeft - 200")
:scroll-hs scroll-hs
:right-hs (str "on click set #" container-id ".scrollLeft to #" container-id ".scrollLeft + 200")
:items (<> (map (lambda (item)
(let* ((img (~img-or-placeholder :src (get item "feature_image") :alt (get item "label")
:size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0")))
(if (= (get item "slug") "cart")
(~blog-nav-item-plain :href (get item "href") :selected (get item "selected")
:nav-cls nav-cls :img img :label (get item "label"))
(~blog-nav-item-link :href (get item "href") :hx-get (get item "hx_get")
:selected (get item "selected") :nav-cls nav-cls :img img :label (get item "label")))))
items))
:oob true)))

View File

@@ -36,6 +36,36 @@
(div :class "hidden sm:grid grid-cols-7 text-center text-md font-semibold text-stone-700 mb-2" weekdays)
(div :class "grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200 rounded-xl overflow-hidden" cells))))
;; Calendar grid from data — all iteration in sx
(defcomp ~events-calendar-grid-from-data (&key pill-cls month-name year
prev-year-href prev-month-href
next-month-href next-year-href
weekday-names cells)
(~events-calendar-grid
:arrows (<>
(~events-calendar-nav-arrow :pill-cls pill-cls :href prev-year-href :label "\u00ab")
(~events-calendar-nav-arrow :pill-cls pill-cls :href prev-month-href :label "\u2039")
(~events-calendar-month-label :month-name month-name :year year)
(~events-calendar-nav-arrow :pill-cls pill-cls :href next-month-href :label "\u203a")
(~events-calendar-nav-arrow :pill-cls pill-cls :href next-year-href :label "\u00bb"))
:weekdays (<> (map (lambda (wd) (~events-calendar-weekday :name wd))
(or weekday-names (list))))
:cells (<> (map (lambda (cell)
(~events-calendar-cell
:cell-cls (get cell "cell-cls")
:day-short (when (get cell "day-str")
(~events-calendar-day-short :day-str (get cell "day-str")))
:day-num (when (get cell "day-href")
(~events-calendar-day-num :pill-cls pill-cls
:href (get cell "day-href") :num (get cell "day-num")))
:badges (when (get cell "badges")
(<> (map (lambda (b)
(~events-calendar-entry-badge
:bg-cls (get b "bg-cls") :name (get b "name")
:state-label (get b "state-label")))
(get cell "badges"))))))
(or cells (list))))))
(defcomp ~events-calendar-description-display (&key description edit-url)
(div :id "calendar-description"
(if description

View File

@@ -82,3 +82,42 @@
(div :class "flex-1 min-w-0"
(div :class "font-medium truncate" name)
(div :class "text-xs text-stone-600 truncate" time-str))))
;; Day table from data — all row iteration in sx
(defcomp ~events-day-table-from-data (&key list-container pre-action add-url tr-cls pill-cls rows)
(~events-day-table
:list-container list-container
:rows (if (empty? (or rows (list)))
(~events-day-empty-row)
(<> (map (lambda (r)
(~events-day-row
:tr-cls tr-cls
:name (~events-day-row-name
:href (get r "href") :pill-cls pill-cls :name (get r "name"))
:slot (if (get r "slot-name")
(~events-day-row-slot
:href (get r "slot-href") :pill-cls pill-cls
:slot-name (get r "slot-name") :time-str (get r "slot-time"))
(~events-day-row-time :start (get r "start") :end (get r "end")))
:state (~events-day-row-state
:state-id (get r "state-id")
:badge (~entry-state-badge :state (get r "state")))
:cost (~events-day-row-cost :cost-str (get r "cost-str"))
:tickets (if (get r "has-tickets")
(~events-day-row-tickets
:price-str (get r "price-str") :count-str (get r "count-str"))
(~events-day-row-no-tickets))
:actions (~events-day-row-actions)))
(or rows (list)))))
:pre-action pre-action :add-url add-url))
;; Day entries nav OOB from data
(defcomp ~events-day-entries-nav-oob-from-data (&key nav-btn entries)
(if (empty? (or entries (list)))
(~events-day-entries-nav-oob-empty)
(~events-day-entries-nav-oob
:items (<> (map (lambda (e)
(~events-day-nav-entry
:href (get e "href") :nav-btn nav-btn
:name (get e "name") :time-str (get e "time-str")))
entries)))))

View File

@@ -1,5 +1,78 @@
;; Events entry card components (all events / page summary)
;; ---------------------------------------------------------------------------
;; State badges — cond maps state string to class + label
;; ---------------------------------------------------------------------------
(defcomp ~entry-state-badge (&key state)
(~badge
:cls (cond
((= state "confirmed") "bg-emerald-100 text-emerald-800")
((= state "provisional") "bg-amber-100 text-amber-800")
((= state "ordered") "bg-sky-100 text-sky-800")
((= state "pending") "bg-stone-100 text-stone-700")
((= state "declined") "bg-red-100 text-red-800")
(true "bg-stone-100 text-stone-700"))
:label (cond
((= state "confirmed") "Confirmed")
((= state "provisional") "Provisional")
((= state "ordered") "Ordered")
((= state "pending") "Pending")
((= state "declined") "Declined")
(true (or state "Unknown")))))
(defcomp ~entry-state-badge-lg (&key state)
(span :class (str "inline-flex items-center rounded-full px-3 py-1 text-sm font-medium "
(cond
((= state "confirmed") "bg-emerald-100 text-emerald-800")
((= state "provisional") "bg-amber-100 text-amber-800")
((= state "ordered") "bg-sky-100 text-sky-800")
((= state "pending") "bg-stone-100 text-stone-700")
((= state "declined") "bg-red-100 text-red-800")
(true "bg-stone-100 text-stone-700")))
(cond
((= state "confirmed") "Confirmed")
((= state "provisional") "Provisional")
((= state "ordered") "Ordered")
((= state "pending") "Pending")
((= state "declined") "Declined")
(true (or state "Unknown")))))
(defcomp ~ticket-state-badge (&key state)
(~badge
:cls (cond
((= state "confirmed") "bg-emerald-100 text-emerald-800")
((= state "checked_in") "bg-blue-100 text-blue-800")
((= state "reserved") "bg-amber-100 text-amber-800")
((= state "cancelled") "bg-red-100 text-red-800")
(true "bg-stone-100 text-stone-700"))
:label (cond
((= state "confirmed") "Confirmed")
((= state "checked_in") "Checked in")
((= state "reserved") "Reserved")
((= state "cancelled") "Cancelled")
(true (or state "Unknown")))))
(defcomp ~ticket-state-badge-lg (&key state)
(span :class (str "inline-flex items-center rounded-full px-3 py-1 text-sm font-medium "
(cond
((= state "confirmed") "bg-emerald-100 text-emerald-800")
((= state "checked_in") "bg-blue-100 text-blue-800")
((= state "reserved") "bg-amber-100 text-amber-800")
((= state "cancelled") "bg-red-100 text-red-800")
(true "bg-stone-100 text-stone-700")))
(cond
((= state "confirmed") "Confirmed")
((= state "checked_in") "Checked in")
((= state "reserved") "Reserved")
((= state "cancelled") "Cancelled")
(true (or state "Unknown")))))
;; ---------------------------------------------------------------------------
;; Entry card components
;; ---------------------------------------------------------------------------
(defcomp ~events-entry-title-linked (&key href name)
(a :href href :class "hover:text-emerald-700"
(h2 :class "text-lg font-semibold text-stone-900" name)))
@@ -63,3 +136,127 @@
(defcomp ~events-main-panel-body (&key toggle body)
(<> toggle body (div :class "pb-8")))
;; ---------------------------------------------------------------------------
;; Composition defcomps — receive data, compose entry card trees
;; ---------------------------------------------------------------------------
;; Ticket widget from data — replaces _ticket_widget_html Python composition
(defcomp ~events-tw-widget-from-data (&key entry-id price qty ticket-url csrf)
(~events-tw-widget :entry-id (str entry-id) :price price
:inner (if (= (or qty 0) 0)
(~events-tw-form :ticket-url ticket-url :target (str "#page-ticket-" entry-id)
:csrf csrf :entry-id (str entry-id) :count-val "1"
:btn (~events-tw-cart-plus))
(<>
(~events-tw-form :ticket-url ticket-url :target (str "#page-ticket-" entry-id)
:csrf csrf :entry-id (str entry-id) :count-val (str (- qty 1))
:btn (~events-tw-minus))
(~events-tw-cart-icon :qty (str qty))
(~events-tw-form :ticket-url ticket-url :target (str "#page-ticket-" entry-id)
:csrf csrf :entry-id (str entry-id) :count-val (str (+ qty 1))
:btn (~events-tw-plus))))))
;; Entry card (list view) from data
(defcomp ~events-entry-card-from-data (&key entry-href name day-href
page-badge-href page-badge-title cal-name
date-str start-time end-time is-page-scoped
cost has-ticket ticket-data)
(~events-entry-card
:title (if entry-href
(~events-entry-title-linked :href entry-href :name name)
(~events-entry-title-plain :name name))
:badges (<>
(when page-badge-title
(~events-entry-page-badge :href page-badge-href :title page-badge-title))
(when cal-name
(~events-entry-cal-badge :name cal-name)))
:time-parts (<>
(when (and day-href (not is-page-scoped))
(~events-entry-time-linked :href day-href :date-str date-str))
(when (and (not day-href) (not is-page-scoped) date-str)
(~events-entry-time-plain :date-str date-str))
start-time
(when end-time (str " \u2013 " end-time)))
:cost (when cost (~events-entry-cost :cost cost))
:widget (when has-ticket
(~events-entry-widget-wrapper
:widget (~events-tw-widget-from-data
:entry-id (get ticket-data "entry-id")
:price (get ticket-data "price")
:qty (get ticket-data "qty")
:ticket-url (get ticket-data "ticket-url")
:csrf (get ticket-data "csrf"))))))
;; Entry card (tile view) from data
(defcomp ~events-entry-card-tile-from-data (&key entry-href name day-href
page-badge-href page-badge-title cal-name
date-str time-str
cost has-ticket ticket-data)
(~events-entry-card-tile
:title (if entry-href
(~events-entry-title-tile-linked :href entry-href :name name)
(~events-entry-title-tile-plain :name name))
:badges (<>
(when page-badge-title
(~events-entry-page-badge :href page-badge-href :title page-badge-title))
(when cal-name
(~events-entry-cal-badge :name cal-name)))
:time time-str
:cost (when cost (~events-entry-cost :cost cost))
:widget (when has-ticket
(~events-entry-tile-widget-wrapper
:widget (~events-tw-widget-from-data
:entry-id (get ticket-data "entry-id")
:price (get ticket-data "price")
:qty (get ticket-data "qty")
:ticket-url (get ticket-data "ticket-url")
:csrf (get ticket-data "csrf"))))))
;; Entry cards list (with date separators + sentinel) from data
(defcomp ~events-entry-cards-from-data (&key items view page has-more next-url)
(<>
(map (lambda (item)
(if (get item "is-separator")
(~events-date-separator :date-str (get item "date-str"))
(if (= view "tile")
(~events-entry-card-tile-from-data
:entry-href (get item "entry-href") :name (get item "name")
:day-href (get item "day-href")
:page-badge-href (get item "page-badge-href")
:page-badge-title (get item "page-badge-title")
:cal-name (get item "cal-name")
:date-str (get item "date-str") :time-str (get item "time-str")
:cost (get item "cost") :has-ticket (get item "has-ticket")
:ticket-data (get item "ticket-data"))
(~events-entry-card-from-data
:entry-href (get item "entry-href") :name (get item "name")
:day-href (get item "day-href")
:page-badge-href (get item "page-badge-href")
:page-badge-title (get item "page-badge-title")
:cal-name (get item "cal-name")
:date-str (get item "date-str")
:start-time (get item "start-time") :end-time (get item "end-time")
:is-page-scoped (get item "is-page-scoped")
:cost (get item "cost") :has-ticket (get item "has-ticket")
:ticket-data (get item "ticket-data")))))
(or items (list)))
(when has-more
(~sentinel-simple :id (str "sentinel-" page) :next-url next-url))))
;; Events main panel (toggle + cards grid) from data
(defcomp ~events-main-panel-from-data (&key toggle items view page has-more next-url)
(~events-main-panel-body
:toggle toggle
:body (if items
(~events-grid
:grid-cls (if (= view "tile")
"max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
"max-w-full px-3 py-3 space-y-3")
:cards (~events-entry-cards-from-data
:items items :view view :page page
:has-more has-more :next-url next-url))
(~empty-state :icon "fa fa-calendar-xmark"
:message "No upcoming events"
:cls "px-3 py-12 text-center text-stone-400"))))

View File

@@ -318,6 +318,64 @@
"+ Add slot"))
;; ---------------------------------------------------------------------------
;; Composition defcomps — receive data, compose form trees
;; ---------------------------------------------------------------------------
;; Day checkboxes from data — replaces Python loop
(defcomp ~events-day-checkboxes-from-data (&key days-data all-checked)
(<>
(~events-day-all-checkbox :checked (when all-checked "checked"))
(map (lambda (d)
(~events-day-checkbox
:name (get d "name")
:label (get d "label")
:checked (when (get d "checked") "checked")))
(or days-data (list)))))
;; Slot options from data — replaces _slot_options_html Python loop
(defcomp ~events-slot-options-from-data (&key slots)
(<> (map (lambda (s)
(~events-slot-option
:value (get s "value")
:data-start (get s "data-start")
:data-end (get s "data-end")
:data-flexible (get s "data-flexible")
:data-cost (get s "data-cost")
:selected (get s "selected")
:label (get s "label")))
(or slots (list)))))
;; Slot picker from data — wraps picker + options
(defcomp ~events-slot-picker-from-data (&key id slots)
(if (empty? (or slots (list)))
(~events-no-slots)
(~events-slot-picker
:id id
:options (~events-slot-options-from-data :slots slots))))
;; Slot edit form from data
(defcomp ~events-slot-edit-form-from-data (&key slot-id list-container put-url cancel-url csrf
name-val cost-val start-val end-val desc-val
days-data all-checked flexible-checked
action-btn cancel-btn)
(~events-slot-edit-form
:slot-id slot-id :list-container list-container
:put-url put-url :cancel-url cancel-url :csrf csrf
:name-val name-val :cost-val cost-val :start-val start-val
:end-val end-val :desc-val desc-val
:days (~events-day-checkboxes-from-data :days-data days-data :all-checked all-checked)
:flexible-checked flexible-checked
:action-btn action-btn :cancel-btn cancel-btn))
;; Slot add form from data
(defcomp ~events-slot-add-form-from-data (&key post-url csrf days-data action-btn cancel-btn cancel-url)
(~events-slot-add-form
:post-url post-url :csrf csrf
:days (~events-day-checkboxes-from-data :days-data days-data)
:action-btn action-btn :cancel-btn cancel-btn :cancel-url cancel-url))
;; ---------------------------------------------------------------------------
;; Entry add form (_types/day/_add.html)
;; ---------------------------------------------------------------------------

View File

@@ -68,3 +68,65 @@
(defcomp ~events-frag-bookings-list (&key items)
(div :class "divide-y divide-stone-100" items))
;; ---------------------------------------------------------------------------
;; From-data defcomps — iteration in sx
;; ---------------------------------------------------------------------------
;; Container cards: list of widgets, each with entries
(defcomp ~events-frag-container-cards-from-data (&key widgets)
(<> (map (lambda (w)
(if (get w "entries")
(~events-frag-entries-widget
:cards (<> (map (lambda (e)
(~events-frag-entry-card
:href (get e "href") :name (get e "name")
:date-str (get e "date-str") :time-str (get e "time-str")))
(get w "entries"))))
""))
(or widgets (list)))))
;; Ticket item from data — composes badge + optional spans
(defcomp ~events-frag-ticket-item-from-data (&key href entry-name date-str calendar-name type-name state)
(~events-frag-ticket-item
:href href :entry-name entry-name :date-str date-str
:calendar-name (when calendar-name (span "\u00b7 " calendar-name))
:type-name (when type-name (span "\u00b7 " type-name))
:badge (~status-pill :status state)))
;; Tickets panel from data — full panel with list iteration
(defcomp ~events-frag-tickets-panel-from-data (&key tickets)
(~events-frag-tickets-panel
:items (if (empty? (or tickets (list)))
(~empty-state :message "No tickets yet." :cls "text-sm text-stone-500")
(~events-frag-tickets-list
:items (<> (map (lambda (t)
(~events-frag-ticket-item-from-data
:href (get t "href") :entry-name (get t "entry-name")
:date-str (get t "date-str") :calendar-name (get t "calendar-name")
:type-name (get t "type-name") :state (get t "state")))
tickets))))))
;; Booking item from data — composes badge + optional spans
(defcomp ~events-frag-booking-item-from-data (&key name date-str end-time calendar-name cost-str state)
(~events-frag-booking-item
:name name
:date-str (<> date-str (when end-time (span "\u2013 " end-time)))
:calendar-name (when calendar-name (span "\u00b7 " calendar-name))
:cost-str (when cost-str (span "\u00b7 \u00a3" cost-str))
:badge (~status-pill :status state)))
;; Bookings panel from data — full panel with list iteration
(defcomp ~events-frag-bookings-panel-from-data (&key bookings)
(~events-frag-bookings-panel
:items (if (empty? (or bookings (list)))
(~empty-state :message "No bookings yet." :cls "text-sm text-stone-500")
(~events-frag-bookings-list
:items (<> (map (lambda (b)
(~events-frag-booking-item-from-data
:href (get b "href") :name (get b "name")
:date-str (get b "date-str") :end-time (get b "end-time")
:calendar-name (get b "calendar-name") :cost-str (get b "cost-str")
:state (get b "state")))
bookings))))))

View File

@@ -226,6 +226,54 @@
(~clear-oob-div :id "calendars-row")
(~clear-oob-div :id "calendars-header-child")))
;; ---------------------------------------------------------------------------
;; OOB clear helpers for renders.py — clear all deeper IDs except kept ones
;; ---------------------------------------------------------------------------
(defcomp ~events-clear-deeper-post ()
"Clear all events IDs deeper than post level."
(<>
(~clear-oob-div :id "entry-admin-row") (~clear-oob-div :id "entry-admin-header-child")
(~clear-oob-div :id "entry-row") (~clear-oob-div :id "entry-header-child")
(~clear-oob-div :id "day-admin-row") (~clear-oob-div :id "day-admin-header-child")
(~clear-oob-div :id "day-row") (~clear-oob-div :id "day-header-child")
(~clear-oob-div :id "calendar-admin-row") (~clear-oob-div :id "calendar-admin-header-child")
(~clear-oob-div :id "calendar-row") (~clear-oob-div :id "calendar-header-child")
(~clear-oob-div :id "calendars-row") (~clear-oob-div :id "calendars-header-child")
(~clear-oob-div :id "post-admin-row") (~clear-oob-div :id "post-admin-header-child")))
(defcomp ~events-clear-deeper-post-admin ()
"Clear all events IDs deeper than post-admin level."
(<>
(~clear-oob-div :id "entry-admin-row") (~clear-oob-div :id "entry-admin-header-child")
(~clear-oob-div :id "entry-row") (~clear-oob-div :id "entry-header-child")
(~clear-oob-div :id "day-admin-row") (~clear-oob-div :id "day-admin-header-child")
(~clear-oob-div :id "day-row") (~clear-oob-div :id "day-header-child")
(~clear-oob-div :id "calendar-admin-row") (~clear-oob-div :id "calendar-admin-header-child")
(~clear-oob-div :id "calendar-row") (~clear-oob-div :id "calendar-header-child")
(~clear-oob-div :id "calendars-row") (~clear-oob-div :id "calendars-header-child")))
(defcomp ~events-clear-deeper-calendar ()
"Clear all events IDs deeper than calendar level."
(<>
(~clear-oob-div :id "entry-admin-row") (~clear-oob-div :id "entry-admin-header-child")
(~clear-oob-div :id "entry-row") (~clear-oob-div :id "entry-header-child")
(~clear-oob-div :id "day-admin-row") (~clear-oob-div :id "day-admin-header-child")
(~clear-oob-div :id "day-row") (~clear-oob-div :id "day-header-child")
(~clear-oob-div :id "calendar-admin-row") (~clear-oob-div :id "calendar-admin-header-child")
(~clear-oob-div :id "calendars-row") (~clear-oob-div :id "calendars-header-child")
(~clear-oob-div :id "post-admin-row") (~clear-oob-div :id "post-admin-header-child")))
(defcomp ~events-clear-deeper-day ()
"Clear all events IDs deeper than day level."
(<>
(~clear-oob-div :id "entry-admin-row") (~clear-oob-div :id "entry-admin-header-child")
(~clear-oob-div :id "entry-row") (~clear-oob-div :id "entry-header-child")
(~clear-oob-div :id "day-admin-row") (~clear-oob-div :id "day-admin-header-child")
(~clear-oob-div :id "calendar-admin-row") (~clear-oob-div :id "calendar-admin-header-child")
(~clear-oob-div :id "calendars-row") (~clear-oob-div :id "calendars-header-child")
(~clear-oob-div :id "post-admin-row") (~clear-oob-div :id "post-admin-header-child")))
;; ---------------------------------------------------------------------------
;; Calendar admin layout: root + post + child(post-admin + cal + cal-admin)
;; ---------------------------------------------------------------------------

View File

@@ -73,6 +73,50 @@
:sx-get add-url :sx-target "#slot-add-container" :sx-swap "innerHTML"
"+ Add slot"))))
;; ---------------------------------------------------------------------------
;; Composition defcomps — receive data, compose slot/table trees
;; ---------------------------------------------------------------------------
;; Days pills from data — replaces Python loop
(defcomp ~events-days-pills-from-data (&key days)
(if (empty? (or days (list)))
(~events-slot-no-days)
(~events-slot-days-pills
:days-inner (<> (map (lambda (d) (~events-slot-day-pill :day d)) days)))))
;; Slot panel from data
(defcomp ~events-slot-panel-from-data (&key slot-id list-container days
flexible time-str cost-str
pre-action edit-url description oob)
(<>
(~events-slot-panel
:slot-id slot-id :list-container list-container
:days (~events-days-pills-from-data :days days)
:flexible flexible :time-str time-str :cost-str cost-str
:pre-action pre-action :edit-url edit-url)
(when oob
(~events-slot-description-oob :description (or description "")))))
;; Slots table from data
(defcomp ~events-slots-table-from-data (&key list-container slots pre-action add-url
tr-cls pill-cls action-btn hx-select csrf-hdr)
(~events-slots-table
:list-container list-container
:rows (if (empty? (or slots (list)))
(~events-slots-empty-row)
(<> (map (lambda (s)
(~events-slots-row
:tr-cls tr-cls :slot-href (get s "slot-href")
:pill-cls pill-cls :hx-select hx-select
:slot-name (get s "slot-name") :description (get s "description")
:flexible (get s "flexible")
:days (~events-days-pills-from-data :days (get s "days"))
:time-str (get s "time-str")
:cost-str (get s "cost-str") :action-btn action-btn
:del-url (get s "del-url") :csrf-hdr csrf-hdr))
(or slots (list)))))
:pre-action pre-action :add-url add-url))
(defcomp ~events-ticket-type-col (&key label value)
(div :class "flex flex-col"
(div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" label)
@@ -158,102 +202,138 @@
(button :type "button" :class "px-4 py-2 bg-stone-200 text-stone-700 rounded hover:bg-stone-300 text-sm"
:onclick hide-js "Cancel"))))
;; Data-driven buy form — Python passes pre-resolved data, .sx does layout + iteration
(defcomp ~events-buy-form (&key entry-id info-sold info-remaining info-basket
ticket-types user-ticket-counts-by-type
user-ticket-count price-str adjust-url csrf state
my-tickets-href)
(if (!= state "confirmed")
(~events-buy-not-confirmed :entry-id (str entry-id))
(let ((eid-s (str entry-id))
(target (str "#ticket-buy-" entry-id)))
(div :id (str "ticket-buy-" entry-id) :class "rounded-xl border border-stone-200 bg-white p-4"
(h3 :class "text-sm font-semibold text-stone-700 mb-3"
(i :class "fa fa-ticket mr-1" :aria-hidden "true") "Tickets")
;; Info bar
(when (or info-sold info-remaining info-basket)
(div :class "flex items-center gap-3 mb-3 text-xs text-stone-500"
(when info-sold (span (str info-sold " sold")))
(when info-remaining (span (str info-remaining " remaining")))
(when info-basket
(span :class "text-emerald-600 font-medium"
(i :class "fa fa-shopping-cart text-[0.6rem]" :aria-hidden "true")
(str " " info-basket " in basket")))))
;; Body — multi-type or default
(if (and ticket-types (not (empty? ticket-types)))
(div :class "space-y-2"
(map (fn (tt)
(let ((tt-count (if user-ticket-counts-by-type
(get user-ticket-counts-by-type (str (get tt "id")) 0)
0))
(tt-id (get tt "id")))
(div :class "flex items-center justify-between p-3 rounded-lg bg-stone-50 border border-stone-100"
(div (div :class "font-medium text-sm" (get tt "name"))
(div :class "text-xs text-stone-500" (get tt "cost_str")))
(~events-adjust-inline :csrf csrf :adjust-url adjust-url :target target
:entry-id eid-s :count tt-count :ticket-type-id tt-id
:my-tickets-href my-tickets-href))))
ticket-types))
(<> (div :class "flex items-center justify-between mb-4"
(div (span :class "font-medium text-green-600" price-str)
(span :class "text-sm text-stone-500 ml-2" "per ticket")))
(~events-adjust-inline :csrf csrf :adjust-url adjust-url :target target
:entry-id eid-s :count (if user-ticket-count user-ticket-count 0)
:ticket-type-id nil :my-tickets-href my-tickets-href)))))))
;; Inline +/- controls (used by both default and per-type)
(defcomp ~events-adjust-inline (&key csrf adjust-url target entry-id count ticket-type-id my-tickets-href)
(if (= count 0)
(form :sx-post adjust-url :sx-target target :sx-swap "outerHTML" :class "flex items-center"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "entry_id" :value entry-id)
(when ticket-type-id (input :type "hidden" :name "ticket_type_id" :value (str ticket-type-id)))
(input :type "hidden" :name "count" :value "1")
(button :type "submit"
:class "relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50 rounded p-1"
(i :class "fa fa-cart-plus text-2xl" :aria-hidden "true")))
(div :class "flex items-center gap-2"
(form :sx-post adjust-url :sx-target target :sx-swap "outerHTML"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "entry_id" :value entry-id)
(when ticket-type-id (input :type "hidden" :name "ticket_type_id" :value (str ticket-type-id)))
(input :type "hidden" :name "count" :value (str (- count 1)))
(button :type "submit"
:class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
"-"))
(a :class "relative inline-flex items-center justify-center text-emerald-700" :href my-tickets-href
(span :class "relative inline-flex items-center justify-center"
(i :class "fa-solid fa-shopping-cart text-2xl" :aria-hidden "true")
(span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
(span :class "flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold" (str count)))))
(form :sx-post adjust-url :sx-target target :sx-swap "outerHTML"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "entry_id" :value entry-id)
(when ticket-type-id (input :type "hidden" :name "ticket_type_id" :value (str ticket-type-id)))
(input :type "hidden" :name "count" :value (str (+ count 1)))
(button :type "submit"
:class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
"+")))))
(defcomp ~events-buy-not-confirmed (&key entry-id)
(div :id (str "ticket-buy-" entry-id) :class "rounded-xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-500"
(i :class "fa fa-ticket mr-1" :aria-hidden "true")
"Tickets available once this event is confirmed."))
(defcomp ~events-buy-info-sold (&key count)
(span (str count " sold")))
(defcomp ~events-buy-info-remaining (&key count)
(span (str count " remaining")))
(defcomp ~events-buy-result (&key entry-id tickets remaining my-tickets-href)
(let ((count (len tickets))
(suffix (if (= count 1) "" "s")))
(div :id (str "ticket-buy-" entry-id) :class "rounded-xl border border-emerald-200 bg-emerald-50 p-4"
(div :class "flex items-center gap-2 mb-3"
(i :class "fa fa-check-circle text-emerald-600" :aria-hidden "true")
(span :class "font-semibold text-emerald-800" (str count " ticket" suffix " reserved")))
(div :class "space-y-2 mb-4"
(map (fn (t)
(a :href (get t "href") :class "flex items-center justify-between p-2 rounded-lg bg-white border border-emerald-100 hover:border-emerald-300 transition text-sm"
(div :class "flex items-center gap-2"
(i :class "fa fa-ticket text-emerald-500" :aria-hidden "true")
(span :class "font-mono text-xs text-stone-500" (get t "code_short")))
(span :class "text-xs text-emerald-600 font-medium" "View ticket")))
tickets))
(when (not (nil? remaining))
(let ((r-suffix (if (= remaining 1) "" "s")))
(p :class "text-xs text-stone-500" (str remaining " ticket" r-suffix " remaining"))))
(div :class "mt-3 flex gap-2"
(a :href my-tickets-href :class "text-sm text-emerald-700 hover:text-emerald-900 underline"
"View all my tickets")))))
(defcomp ~events-buy-info-basket (&key count)
(span :class "text-emerald-600 font-medium"
(i :class "fa fa-shopping-cart text-[0.6rem]" :aria-hidden "true")
(str " " count " in basket")))
;; Single response wrappers for POST routes (include OOB cart icon)
(defcomp ~events-buy-response (&key entry-id tickets remaining my-tickets-href
cart-count blog-href cart-href logo)
(<>
(~events-cart-icon :cart-count cart-count :blog-href blog-href :cart-href cart-href :logo logo)
(~events-buy-result :entry-id entry-id :tickets tickets :remaining remaining
:my-tickets-href my-tickets-href)))
(defcomp ~events-buy-info-bar (&key items)
(div :class "flex items-center gap-3 mb-3 text-xs text-stone-500" items))
(defcomp ~events-adjust-response (&key cart-count blog-href cart-href logo
entry-id info-sold info-remaining info-basket
ticket-types user-ticket-counts-by-type
user-ticket-count price-str adjust-url csrf state
my-tickets-href)
(<>
(~events-cart-icon :cart-count cart-count :blog-href blog-href :cart-href cart-href :logo logo)
(~events-buy-form :entry-id entry-id :info-sold info-sold :info-remaining info-remaining
:info-basket info-basket :ticket-types ticket-types
:user-ticket-counts-by-type user-ticket-counts-by-type
:user-ticket-count user-ticket-count :price-str price-str
:adjust-url adjust-url :csrf csrf :state state
:my-tickets-href my-tickets-href)))
(defcomp ~events-buy-type-item (&key type-name cost-str adjust-controls)
(div :class "flex items-center justify-between p-3 rounded-lg bg-stone-50 border border-stone-100"
(div (div :class "font-medium text-sm" type-name)
(div :class "text-xs text-stone-500" cost-str))
adjust-controls))
(defcomp ~events-buy-types-wrapper (&key items)
(div :class "space-y-2" items))
(defcomp ~events-buy-default (&key price-str adjust-controls)
(<> (div :class "flex items-center justify-between mb-4"
(div (span :class "font-medium text-green-600" price-str)
(span :class "text-sm text-stone-500 ml-2" "per ticket")))
adjust-controls))
(defcomp ~events-buy-panel (&key entry-id info body)
(div :id (str "ticket-buy-" entry-id) :class "rounded-xl border border-stone-200 bg-white p-4"
(h3 :class "text-sm font-semibold text-stone-700 mb-3"
(i :class "fa fa-ticket mr-1" :aria-hidden "true") "Tickets")
info body))
(defcomp ~events-adjust-form (&key adjust-url target extra-cls csrf entry-id tt count-val btn)
(form :sx-post adjust-url :sx-target target :sx-swap "outerHTML" :class extra-cls
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "entry_id" :value entry-id)
tt
(input :type "hidden" :name "count" :value count-val)
btn))
(defcomp ~events-adjust-tt-hidden (&key ticket-type-id)
(input :type "hidden" :name "ticket_type_id" :value ticket-type-id))
(defcomp ~events-adjust-cart-plus ()
(button :type "submit"
:class "relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50 rounded p-1"
(i :class "fa fa-cart-plus text-2xl" :aria-hidden "true")))
(defcomp ~events-adjust-minus ()
(button :type "submit"
:class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
"-"))
(defcomp ~events-adjust-plus ()
(button :type "submit"
:class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
"+"))
(defcomp ~events-adjust-cart-icon (&key href count)
(a :class "relative inline-flex items-center justify-center text-emerald-700" :href href
(span :class "relative inline-flex items-center justify-center"
(i :class "fa-solid fa-shopping-cart text-2xl" :aria-hidden "true")
(span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
(span :class "flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold" count)))))
(defcomp ~events-adjust-controls (&key minus cart-icon plus)
(div :class "flex items-center gap-2" minus cart-icon plus))
(defcomp ~events-buy-result (&key entry-id count-label tickets remaining my-tickets-href)
(div :id (str "ticket-buy-" entry-id) :class "rounded-xl border border-emerald-200 bg-emerald-50 p-4"
(div :class "flex items-center gap-2 mb-3"
(i :class "fa fa-check-circle text-emerald-600" :aria-hidden "true")
(span :class "font-semibold text-emerald-800" count-label))
(div :class "space-y-2 mb-4" tickets)
remaining
(div :class "mt-3 flex gap-2"
(a :href my-tickets-href :class "text-sm text-emerald-700 hover:text-emerald-900 underline"
"View all my tickets"))))
(defcomp ~events-buy-result-ticket (&key href code-short)
(a :href href :class "flex items-center justify-between p-2 rounded-lg bg-white border border-emerald-100 hover:border-emerald-300 transition text-sm"
(div :class "flex items-center gap-2"
(i :class "fa fa-ticket text-emerald-500" :aria-hidden "true")
(span :class "font-mono text-xs text-stone-500" code-short))
(span :class "text-xs text-emerald-600 font-medium" "View ticket")))
(defcomp ~events-buy-result-remaining (&key text)
(p :class "text-xs text-stone-500" text))
;; Unified OOB cart icon — picks logo or badge based on count
(defcomp ~events-cart-icon (&key cart-count blog-href cart-href logo)
(if (= cart-count 0)
(~events-cart-icon-logo :blog-href blog-href :logo logo)
(~events-cart-icon-badge :cart-href cart-href :count (str cart-count))))
(defcomp ~events-cart-icon-logo (&key blog-href logo)
(div :id "cart-mini" :sx-swap-oob "true"
@@ -384,3 +464,169 @@
(defcomp ~events-entry-nav-post-link (&key href img title)
(a :href href :class "flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0"
img (div :class "flex-1 min-w-0" (div :class "font-medium truncate" title))))
;; ---------------------------------------------------------------------------
;; Composition defcomps — nav OOB, posts panel from data
;; ---------------------------------------------------------------------------
;; Post image helper from data
(defcomp ~events-post-img-from-data (&key src alt)
(if src
(~events-post-img :src src :alt alt)
(~events-post-img-placeholder)))
;; Entry posts nav OOB from data
(defcomp ~events-entry-posts-nav-oob-from-data (&key nav-btn posts)
(if (empty? (or posts (list)))
(~events-entry-posts-nav-oob-empty)
(~events-entry-posts-nav-oob
:items (<> (map (lambda (p)
(~events-entry-nav-post
:href (get p "href") :nav-btn nav-btn
:img (~events-post-img-from-data :src (get p "img") :alt (get p "title"))
:title (get p "title")))
posts)))))
;; Entry posts nav (non-OOB) from data — for desktop nav embedding
(defcomp ~events-entry-posts-nav-inner-from-data (&key posts)
(when (not (empty? (or posts (list))))
(~events-entry-posts-nav-oob
:items (<> (map (lambda (p)
(~events-entry-nav-post-link
:href (get p "href")
:img (~events-post-img-from-data :src (get p "img") :alt (get p "title"))
:title (get p "title")))
posts)))))
;; Post nav entries+calendars OOB from data
(defcomp ~events-post-nav-wrapper-from-data (&key nav-btn entries calendars hyperscript)
(if (and (empty? (or entries (list))) (empty? (or calendars (list))))
(~events-post-nav-oob-empty)
(~events-post-nav-wrapper
:items (<>
(map (lambda (e)
(~events-post-nav-entry
:href (get e "href") :nav-btn nav-btn
:name (get e "name") :time-str (get e "time-str")))
(or entries (list)))
(map (lambda (c)
(~events-post-nav-calendar
:href (get c "href") :nav-btn nav-btn :name (get c "name")))
(or calendars (list))))
:hyperscript hyperscript)))
;; Entry posts panel from data
(defcomp ~events-entry-posts-panel-from-data (&key entry-id posts search-url)
(~events-entry-posts-panel
:posts (if (empty? (or posts (list)))
(~events-entry-posts-none)
(~events-entry-posts-list
:items (<> (map (lambda (p)
(~events-entry-post-item
:img (~events-post-img-from-data :src (get p "img") :alt (get p "title"))
:title (get p "title")
:del-url (get p "del-url") :entry-id entry-id
:csrf-hdr (get p "csrf-hdr")))
posts))))
:search-url search-url :entry-id entry-id))
;; CRUD list/panel from data — shared by calendars + markets
(defcomp ~events-crud-list-from-data (&key items empty-msg list-id)
(if (empty? (or items (list)))
(~empty-state :message empty-msg :cls "text-gray-500 mt-4")
(<> (map (lambda (item)
(~crud-item
:href (get item "href") :name (get item "name") :slug (get item "slug")
:del-url (get item "del-url") :csrf-hdr (get item "csrf-hdr")
:list-id list-id
:confirm-title (get item "confirm-title")
:confirm-text (get item "confirm-text")))
items))))
(defcomp ~events-crud-panel-from-data (&key can-create create-url csrf errors-id list-id
placeholder btn-label items empty-msg)
(~crud-panel
:form (when can-create
(~crud-create-form
:create-url create-url :csrf csrf :errors-id errors-id
:list-id list-id :placeholder placeholder :btn-label btn-label))
:list (~events-crud-list-from-data :items items :empty-msg empty-msg :list-id list-id)
:list-id list-id))
;; Post nav admin cog
(defcomp ~events-post-nav-admin-cog (&key href aclass)
(div :class "relative nav-group"
(a :href href :class aclass
(i :class "fa fa-cog" :aria-hidden "true"))))
;; Post nav from data — calendar links + container nav + admin
(defcomp ~events-post-nav-from-data (&key calendars container-nav select-colours
has-admin admin-href aclass)
(<>
(map (lambda (c)
(~nav-link :href (get c "href") :icon "fa fa-calendar"
:label (get c "name") :select-colours select-colours
:is-selected (get c "is-selected")))
(or calendars (list)))
(when container-nav container-nav)
(when has-admin
(~events-post-nav-admin-cog :href admin-href :aclass aclass))))
;; Calendar nav from data — slots + admin link
(defcomp ~events-calendar-nav-from-data (&key slots-href admin-href select-colours is-admin)
(<>
(~nav-link :href slots-href :icon "fa fa-clock"
:label "Slots" :select-colours select-colours)
(when is-admin
(~nav-link :href admin-href :icon "fa fa-cog"
:select-colours select-colours))))
;; Calendar admin nav from data
(defcomp ~events-calendar-admin-nav-from-data (&key links select-colours)
(<> (map (lambda (l)
(~nav-link :href (get l "href") :label (get l "label")
:select-colours select-colours))
(or links (list)))))
;; Day nav from data — confirmed entries + admin link
(defcomp ~events-day-nav-from-data (&key entries is-admin admin-href)
(<>
(when (not (empty? (or entries (list))))
(~events-day-entries-nav
:inner (<> (map (lambda (e)
(~events-day-entry-link
:href (get e "href") :name (get e "name") :time-str (get e "time-str")))
entries))))
(when is-admin
(~nav-link :href admin-href :icon "fa fa-cog"))))
;; Post search results from data
(defcomp ~events-post-search-results-from-data (&key items page next-url has-more)
(<>
(map (lambda (item)
(~events-post-search-item
:post-url (get item "post-url") :entry-id (get item "entry-id")
:csrf (get item "csrf") :post-id (get item "post-id")
:img (~events-post-img-from-data :src (get item "img") :alt (get item "title"))
:title (get item "title")))
(or items (list)))
(cond
(has-more (~events-post-search-sentinel :page page :next-url next-url))
((not (empty? (or items (list)))) (~events-post-search-end))
(true ""))))
;; Entry options from data — state-driven button composition
(defcomp ~events-entry-options-from-data (&key entry-id state buttons)
(~events-entry-options
:entry-id entry-id
:buttons (<> (map (lambda (b)
(~events-entry-option-button
:url (get b "url") :target (str "#calendar_entry_options_" entry-id)
:csrf (get b "csrf") :btn-type (get b "btn-type")
:action-btn (get b "action-btn")
:confirm-title (get b "confirm-title")
:confirm-text (get b "confirm-text")
:label (get b "label")
:is-btn (get b "is-btn")))
(or buttons (list))))))

View File

@@ -204,3 +204,152 @@
(h3 :class "text-lg font-semibold" (str "Tickets for: " entry-name))
(span :class "text-sm text-stone-500" count-label))
body))
;; ---------------------------------------------------------------------------
;; Composition defcomps — receive data, compose ticket trees
;; ---------------------------------------------------------------------------
;; My tickets panel from data
(defcomp ~events-tickets-panel-from-data (&key list-container tickets)
(~events-tickets-panel
:list-container list-container
:has-tickets (not (empty? (or tickets (list))))
:cards (<> (map (lambda (t)
(~events-ticket-card
:href (get t "href") :entry-name (get t "entry-name")
:type-name (get t "type-name") :time-str (get t "time-str")
:cal-name (get t "cal-name")
:badge (~ticket-state-badge :state (get t "state"))
:code-prefix (get t "code-prefix")))
(or tickets (list))))))
;; Ticket detail from data — uses lg badge variant
(defcomp ~events-ticket-detail-from-data (&key list-container back-href header-bg entry-name
state type-name code time-date time-range
cal-name type-desc checkin-str qr-script)
(~events-ticket-detail
:list-container list-container :back-href back-href
:header-bg header-bg :entry-name entry-name
:badge (~ticket-state-badge-lg :state state)
:type-name type-name :code code
:time-date time-date :time-range time-range
:cal-name cal-name :type-desc type-desc
:checkin-str checkin-str :qr-script qr-script))
;; Ticket admin row from data — conditional action column
(defcomp ~events-ticket-admin-row-from-data (&key code code-short entry-name date-str
type-name state checkin-url csrf
checked-in-time)
(~events-ticket-admin-row
:code code :code-short code-short
:entry-name entry-name
:date (when date-str (~events-ticket-admin-date :date-str date-str))
:type-name type-name
:badge (~ticket-state-badge :state state)
:action (cond
((or (= state "confirmed") (= state "reserved"))
(~events-ticket-admin-checkin-form
:checkin-url checkin-url :code code :csrf csrf))
((= state "checked_in")
(~events-ticket-admin-checked-in :time-str (or checked-in-time "")))
(true nil))))
;; Ticket admin panel from data
(defcomp ~events-ticket-admin-panel-from-data (&key list-container lookup-url tickets
total confirmed checked-in reserved)
(~events-ticket-admin-panel
:list-container list-container
:stats (<>
(~events-ticket-admin-stat :border "border-stone-200" :bg ""
:text-cls "text-stone-900" :label-cls "text-stone-500"
:value (str (or total 0)) :label "Total")
(~events-ticket-admin-stat :border "border-emerald-200" :bg "bg-emerald-50"
:text-cls "text-emerald-700" :label-cls "text-emerald-600"
:value (str (or confirmed 0)) :label "Confirmed")
(~events-ticket-admin-stat :border "border-blue-200" :bg "bg-blue-50"
:text-cls "text-blue-700" :label-cls "text-blue-600"
:value (str (or checked-in 0)) :label "Checked In")
(~events-ticket-admin-stat :border "border-amber-200" :bg "bg-amber-50"
:text-cls "text-amber-700" :label-cls "text-amber-600"
:value (str (or reserved 0)) :label "Reserved"))
:lookup-url lookup-url
:has-tickets (not (empty? (or tickets (list))))
:rows (<> (map (lambda (t)
(~events-ticket-admin-row-from-data
:code (get t "code") :code-short (get t "code-short")
:entry-name (get t "entry-name") :date-str (get t "date-str")
:type-name (get t "type-name") :state (get t "state")
:checkin-url (get t "checkin-url") :csrf (get t "csrf")
:checked-in-time (get t "checked-in-time")))
(or tickets (list))))))
;; Entry tickets admin from data
(defcomp ~events-entry-tickets-admin-from-data (&key entry-name count-label tickets csrf)
(~events-entry-tickets-admin-panel
:entry-name entry-name :count-label count-label
:body (if (empty? (or tickets (list)))
(~events-entry-tickets-admin-empty)
(~events-entry-tickets-admin-table
:rows (<> (map (lambda (t)
(~events-entry-tickets-admin-row
:code (get t "code") :code-short (get t "code-short")
:type-name (get t "type-name")
:badge (~ticket-state-badge :state (get t "state"))
:action (cond
((or (= (get t "state") "confirmed") (= (get t "state") "reserved"))
(~events-entry-tickets-admin-checkin
:checkin-url (get t "checkin-url") :code (get t "code") :csrf csrf))
((= (get t "state") "checked_in")
(~events-ticket-admin-checked-in :time-str (or (get t "checked-in-time") "")))
(true nil))))
(or tickets (list))))))))
;; Checkin success row from data
(defcomp ~events-checkin-success-row-from-data (&key code code-short entry-name date-str type-name time-str)
(~events-checkin-success-row
:code code :code-short code-short
:entry-name entry-name
:date (when date-str (~events-ticket-admin-date :date-str date-str))
:type-name type-name
:badge (~ticket-state-badge :state "checked_in")
:time-str time-str))
;; Ticket types table from data
(defcomp ~events-ticket-types-table-from-data (&key list-container ticket-types action-btn add-url
tr-cls pill-cls hx-select csrf-hdr)
(~events-ticket-types-table
:list-container list-container
:rows (if (empty? (or ticket-types (list)))
(~events-ticket-types-empty-row)
(<> (map (lambda (tt)
(~events-ticket-types-row
:tr-cls tr-cls :tt-href (get tt "tt-href")
:pill-cls pill-cls :hx-select hx-select
:tt-name (get tt "tt-name") :cost-str (get tt "cost-str")
:count (get tt "count") :action-btn action-btn
:del-url (get tt "del-url") :csrf-hdr csrf-hdr))
(or ticket-types (list)))))
:action-btn action-btn :add-url add-url))
;; Lookup result from data
(defcomp ~events-lookup-result-from-data (&key entry-name type-name date-str cal-name
state code checked-in-str
checkin-url csrf)
(~events-lookup-card
:info (<>
(~events-lookup-info :entry-name entry-name)
(when type-name (~events-lookup-type :type-name type-name))
(when date-str (~events-lookup-date :date-str date-str))
(when cal-name (~events-lookup-cal :cal-name cal-name))
(~events-lookup-status
:badge (~ticket-state-badge :state state) :code code)
(when checked-in-str
(~events-lookup-checkin-time :date-str checked-in-str)))
:code code
:action (cond
((or (= state "confirmed") (= state "reserved"))
(~events-lookup-checkin-btn :checkin-url checkin-url :code code :csrf csrf))
((= state "checked_in") (~events-lookup-checked-in))
((= state "cancelled") (~events-lookup-cancelled))
(true nil))))

View File

@@ -2,12 +2,12 @@
from __future__ import annotations
from shared.sx.helpers import (
render_to_sx_with_env,
render_to_sx_with_env, sx_call,
post_admin_header_sx, oob_header_sx,
header_child_sx, full_page_sx, oob_page_sx,
)
from .utils import _clear_deeper_oob, _ensure_container_nav
from .utils import _ensure_container_nav
from .calendar import (
_post_header_sx, _calendars_header_sx,
_calendar_header_sx, _day_header_sx,
@@ -129,7 +129,7 @@ async def render_page_summary_oob(ctx: dict, entries, has_more, pending_tickets,
)
oobs = await _post_header_sx(ctx, oob=True)
oobs += _clear_deeper_oob("post-row", "post-header-child")
oobs += sx_call("events-clear-deeper-post")
return await oob_page_sx(oobs=oobs, content=content)
@@ -172,8 +172,7 @@ async def render_calendars_oob(ctx: dict) -> str:
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
oobs = await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child")
oobs += sx_call("events-clear-deeper-post-admin")
return await oob_page_sx(oobs=oobs, content=content)
@@ -196,8 +195,7 @@ async def render_calendar_oob(ctx: dict) -> str:
oobs = await _post_header_sx(ctx, oob=True)
oobs += await oob_header_sx("post-header-child", "calendar-header-child",
_calendar_header_sx(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"calendar-row", "calendar-header-child")
oobs += sx_call("events-clear-deeper-calendar")
return await oob_page_sx(oobs=oobs, content=content)
@@ -221,9 +219,7 @@ async def render_day_oob(ctx: dict) -> str:
oobs = _calendar_header_sx(ctx, oob=True)
oobs += await oob_header_sx("calendar-header-child", "day-header-child",
_day_header_sx(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"calendar-row", "calendar-header-child",
"day-row", "day-header-child")
oobs += sx_call("events-clear-deeper-day")
return await oob_page_sx(oobs=oobs, content=content)

View File

@@ -14,31 +14,6 @@ from shared.sx.helpers import sx_call
# Post header helpers — thin wrapper over shared post_header_sx
# ---------------------------------------------------------------------------
def _clear_oob(*ids: str) -> str:
"""Generate OOB swaps to remove orphaned header rows/children."""
from shared.sx.helpers import sx_call
return "".join(sx_call("clear-oob-div", id=i) for i in ids)
# All possible header row/child IDs at each depth (deepest first)
_EVENTS_DEEP_IDS = [
"entry-admin-row", "entry-admin-header-child",
"entry-row", "entry-header-child",
"day-admin-row", "day-admin-header-child",
"day-row", "day-header-child",
"calendar-admin-row", "calendar-admin-header-child",
"calendar-row", "calendar-header-child",
"calendars-row", "calendars-header-child",
"post-admin-row", "post-admin-header-child",
]
def _clear_deeper_oob(*keep_ids: str) -> str:
"""Clear all events header rows/children NOT in keep_ids."""
to_clear = [i for i in _EVENTS_DEEP_IDS if i not in keep_ids]
return _clear_oob(*to_clear)
async def _ensure_container_nav(ctx: dict) -> dict:
"""Fetch container_nav if not already present (for post header row)."""
if ctx.get("container_nav"):
@@ -87,19 +62,6 @@ def _entry_state_badge_html(state: str) -> str:
return sx_call("badge", cls=cls, label=label)
def _ticket_state_badge_html(state: str) -> str:
"""Render a ticket state badge."""
cls_map = {
"confirmed": "bg-emerald-100 text-emerald-800",
"checked_in": "bg-blue-100 text-blue-800",
"reserved": "bg-amber-100 text-amber-800",
"cancelled": "bg-red-100 text-red-800",
}
cls = cls_map.get(state, "bg-stone-100 text-stone-700")
label = (state or "").replace("_", " ").capitalize()
return sx_call("badge", cls=cls, label=label)
# ---------------------------------------------------------------------------
# View toggle + SVG caching
# ---------------------------------------------------------------------------
@@ -150,6 +112,12 @@ def _view_toggle_html(ctx: dict, view: str) -> str:
def _cart_icon_oob(count: int) -> str:
"""Render the OOB cart icon/badge swap."""
ctx = _cart_icon_ctx(count)
return sx_call("events-cart-icon", **ctx)
def _cart_icon_ctx(count: int) -> dict:
"""Return data dict for the ~events-cart-icon component."""
from quart import g
blog_url_fn = getattr(g, "blog_url", None)
@@ -160,11 +128,11 @@ def _cart_icon_oob(count: int) -> str:
site_obj = site_fn() if callable(site_fn) else site_fn
logo = getattr(site_obj, "logo", "") if site_obj else ""
if count == 0:
blog_href = blog_url_fn("/") if blog_url_fn else "/"
return sx_call("events-cart-icon-logo",
blog_href=blog_href, logo=logo)
blog_href = blog_url_fn("/") if blog_url_fn else "/"
cart_href = cart_url_fn("/") if cart_url_fn else "/"
return sx_call("events-cart-icon-badge",
cart_href=cart_href, count=str(count))
return {
"cart_count": count,
"blog_href": blog_href,
"cart_href": cart_href,
"logo": logo,
}

View File

@@ -90,3 +90,16 @@
(url-for "social.actor_timeline_page"
:id (get remote-actor "id")
:before (get (last items) "before_cursor")))))))
;; Data-driven activities list (replaces Python loop in render_profile_page)
(defcomp ~federation-activities-from-data (&key activities)
(if (empty? (or activities (list)))
(~federation-activities-empty)
(~federation-activities-list
:items (<> (map (lambda (a)
(~federation-activity-card
:activity-type (get a "activity_type")
:published (get a "published")
:obj-type (when (get a "object_type")
(~federation-activity-obj-type :obj-type (get a "object_type")))))
activities)))))

View File

@@ -40,6 +40,47 @@
summary)
button))
;; Data-driven actor card (replaces Python _actor_card_sx loop)
(defcomp ~federation-actor-card-from-data (&key d has-actor csrf follow-url unfollow-url list-type)
(let* ((icon-url (get d "icon_url"))
(display-name (get d "display_name"))
(username (get d "username"))
(domain (get d "domain"))
(actor-url (get d "actor_url"))
(safe-id (get d "safe_id"))
(initial (or (get d "initial") "?"))
(avatar (~avatar
:src icon-url
:cls (if icon-url "w-12 h-12 rounded-full"
"w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold")
:initial (when (not icon-url) initial)))
(name-sx (if (get d "external_link")
(~federation-actor-name-link-external :href (get d "name_href") :name display-name)
(~federation-actor-name-link :href (get d "name_href") :name display-name)))
(summary-sx (when (get d "summary")
(~federation-actor-summary :summary (get d "summary"))))
(is-followed (get d "is_followed"))
(button (when has-actor
(if (or (= list-type "following") is-followed)
(~federation-unfollow-button :action unfollow-url :csrf csrf :actor-url actor-url)
(~federation-follow-button :action follow-url :csrf csrf :actor-url actor-url
:label (if (= list-type "followers") "Follow Back" "Follow"))))))
(~federation-actor-card
:cls "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4"
:id (str "actor-" safe-id)
:avatar avatar :name name-sx :username username :domain domain
:summary summary-sx :button button)))
;; Data-driven actor list (replaces Python _search_results_sx / _actor_list_items_sx loops)
(defcomp ~federation-actor-list-from-data (&key actors next-url has-actor csrf
follow-url unfollow-url list-type)
(<>
(map (lambda (d)
(~federation-actor-card-from-data :d d :has-actor has-actor :csrf csrf
:follow-url follow-url :unfollow-url unfollow-url :list-type list-type))
(or actors (list)))
(when next-url (~federation-scroll-sentinel :url next-url))))
(defcomp ~federation-search-info (&key cls text)
(p :class cls text))

View File

@@ -90,6 +90,65 @@
compose)
(div :id "timeline" timeline))
;; --- Data-driven post card (replaces Python _post_card_sx loop) ---
(defcomp ~federation-post-card-from-data (&key d has-actor csrf
like-url unlike-url
boost-url unboost-url)
(let* ((boosted-by (get d "boosted_by"))
(actor-icon (get d "actor_icon"))
(actor-name (get d "actor_name"))
(initial (or (get d "initial") "?"))
(avatar (~avatar
:src actor-icon
:cls (if actor-icon "w-10 h-10 rounded-full"
"w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm")
:initial (when (not actor-icon) initial)))
(boost (when boosted-by (~federation-boost-label :name boosted-by)))
(content-sx (if (get d "summary")
(~federation-content :content (get d "content") :summary (get d "summary"))
(~federation-content :content (get d "content"))))
(original (when (get d "original_url")
(~federation-original-link :url (get d "original_url"))))
(safe-id (get d "safe_id"))
(interactions (when has-actor
(let* ((oid (get d "object_id"))
(ainbox (get d "author_inbox"))
(target (str "#interactions-" safe-id))
(liked (get d "liked_by_me"))
(boosted-me (get d "boosted_by_me"))
(l-action (if liked unlike-url like-url))
(l-cls (str "flex items-center gap-1 " (if liked "text-red-500 hover:text-red-600" "hover:text-red-500")))
(l-icon (if liked "\u2665" "\u2661"))
(b-action (if boosted-me unboost-url boost-url))
(b-cls (str "flex items-center gap-1 " (if boosted-me "text-green-600 hover:text-green-700" "hover:text-green-600")))
(reply-url (get d "reply_url"))
(reply (when reply-url (~federation-reply-link :url reply-url)))
(like-form (~federation-like-form
:action l-action :target target :oid oid :ainbox ainbox
:csrf csrf :cls l-cls :icon l-icon :count (get d "like_count")))
(boost-form (~federation-boost-form
:action b-action :target target :oid oid :ainbox ainbox
:csrf csrf :cls b-cls :count (get d "boost_count"))))
(div :id (str "interactions-" safe-id)
(~federation-interaction-buttons :like like-form :boost boost-form :reply reply))))))
(~federation-post-card
:boost boost :avatar avatar
:actor-name actor-name :actor-username (get d "actor_username")
:domain (get d "domain") :time (get d "time")
:content content-sx :original original
:interactions interactions)))
;; Data-driven timeline items (replaces Python _timeline_items_sx loop)
(defcomp ~federation-timeline-items-from-data (&key items next-url has-actor csrf
like-url unlike-url boost-url unboost-url)
(<>
(map (lambda (d)
(~federation-post-card-from-data :d d :has-actor has-actor :csrf csrf
:like-url like-url :unlike-url unlike-url :boost-url boost-url :unboost-url unboost-url))
(or items (list)))
(when next-url (~federation-scroll-sentinel :url next-url))))
;; --- Compose ---
(defcomp ~federation-compose-reply (&key reply-to)

View File

@@ -43,3 +43,17 @@
(defcomp ~market-cart-add-oob (&key id content inner)
(div :id id :sx-swap-oob "outerHTML"
(if content content (when inner inner))))
;; Cart added response — composes cart mini + add/remove OOB in sx
(defcomp ~market-cart-added-response (&key has-count cart-href blog-href logo
slug action csrf quantity minus-val plus-val)
(<>
(if has-count
(~market-cart-mini-count :href cart-href :count (str has-count))
(~market-cart-mini-empty :href blog-href :logo logo))
(~market-cart-add-oob :id (str "cart-add-" slug)
:inner (if (= (or quantity "0") "0")
(~market-cart-add-empty :cart-id (str "cart-" slug) :action action :csrf csrf)
(~market-cart-add-quantity :cart-id (str "cart-" slug) :action action :csrf csrf
:minus-val minus-val :plus-val plus-val
:quantity quantity :cart-href cart-href)))))

View File

@@ -271,3 +271,18 @@
(~market-filter-stickers-from-data :items sticker-data :hx-select hx-select))
(when brand-data
(~market-filter-brands-from-data :items brand-data :hx-select hx-select))))
;; Composite mobile filter — eliminates SxExpr nesting in Python (M2)
(defcomp ~market-mobile-filter-from-data (&key search-bar
sort-chip liked-chip label-chips sticker-chips brand-chips
sort-data like-data label-data sticker-data brand-data
clear-href hx-select)
(~market-mobile-filter-summary
:search-bar search-bar
:chips (~market-mobile-chips-from-data
:sort-chip sort-chip :liked-chip liked-chip
:label-chips label-chips :sticker-chips sticker-chips :brand-chips brand-chips)
:filter (~market-mobile-filter-content-from-data
:sort-data sort-data :like-data like-data
:label-data label-data :sticker-data sticker-data :brand-data brand-data
:clear-href clear-href :hx-select hx-select)))

View File

@@ -102,10 +102,37 @@
(<> (~root-header-auto)
(~header-child-sx :inner (<> post-header market-header product-header admin-header))))
;; OOB wrappers
;; OOB wrappers — compose headers + clear divs in sx (no Python concatenation)
(defcomp ~market-oob-wrap (&key parts)
(<> parts))
(defcomp ~market-clear-product-oob ()
"Clear admin-level OOB divs when rendering product detail."
(<>
(~clear-oob-div :id "product-admin-row")
(~clear-oob-div :id "product-admin-header-child")
(~clear-oob-div :id "market-admin-row")
(~clear-oob-div :id "market-admin-header-child")
(~clear-oob-div :id "post-admin-row")
(~clear-oob-div :id "post-admin-header-child")))
(defcomp ~market-clear-product-admin-oob ()
"Clear deeper OOB divs when rendering product admin."
(<>
(~clear-oob-div :id "market-admin-row")
(~clear-oob-div :id "market-admin-header-child")
(~clear-oob-div :id "post-admin-row")
(~clear-oob-div :id "post-admin-header-child")))
(defcomp ~market-product-oob (&key market-header oob-header)
"Product detail OOB: market header + product header + clear deeper."
(<> market-header oob-header (~market-clear-product-oob)))
(defcomp ~market-product-admin-oob (&key product-header oob-header)
"Product admin OOB: product header + admin header + clear deeper."
(<> product-header oob-header (~market-clear-product-admin-oob)))
;; Content wrappers
(defcomp ~market-content-padded (&key content)
(<> content (div :class "pb-8")))

View File

@@ -197,12 +197,6 @@ def _market_cards_sx(markets: list, page_info: dict, page: int, has_more: bool,
next_url=next_url)
def _markets_grid(cards_sx: str) -> str:
"""Wrap market cards in a grid as sx."""
from shared.sx.parser import SxExpr
return sx_call("market-markets-grid", cards=SxExpr(cards_sx))
def _no_markets_sx(message: str = "No markets available") -> str:
"""Empty state for markets as sx."""
return sx_call("empty-state", icon="fa fa-store", message=message,

View File

@@ -366,19 +366,23 @@ def _mobile_filter_content_data(ctx: dict) -> dict:
async def _mobile_filter_summary_sx(ctx: dict) -> str:
"""Build mobile filter summary — delegates to .sx defcomps."""
# Search bar (still uses render_to_sx for shared component)
"""Build mobile filter summary — single sx_call with data kwargs."""
search_bar = await search_mobile_sx(ctx)
# Chips data
chips_data = _mobile_chips_data(ctx)
chips = sx_call("market-mobile-chips-from-data", **chips_data)
# Expanded filter content data
filter_data = _mobile_filter_content_data(ctx)
filter_content = sx_call("market-mobile-filter-content-from-data", **filter_data)
return sx_call("market-mobile-filter-summary",
search_bar=SxExpr(search_bar),
chips=SxExpr(chips),
filter=SxExpr(filter_content))
return sx_call("market-mobile-filter-from-data",
search_bar=SxExpr(search_bar),
sort_chip=chips_data.get("sort-chip"),
liked_chip=chips_data.get("liked-chip"),
label_chips=chips_data.get("label-chips"),
sticker_chips=chips_data.get("sticker-chips"),
brand_chips=chips_data.get("brand-chips"),
sort_data=filter_data.get("sort-data"),
like_data=filter_data.get("like-data"),
label_data=filter_data.get("label-data"),
sticker_data=filter_data.get("sticker-data"),
brand_data=filter_data.get("brand-data"),
clear_href=filter_data.get("clear-href"),
hx_select=filter_data.get("hx-select"),
)

View File

@@ -11,7 +11,7 @@ from shared.sx.helpers import (
full_page_sx, oob_page_sx,
)
from .utils import _clear_deeper_oob, _product_detail_sx, _product_meta_sx
from .utils import _product_detail_sx, _product_meta_sx
from .cards import _product_cards_sx, _market_cards_sx
from .filters import _desktop_filter_sx, _mobile_filter_summary_sx
from .layouts import (
@@ -25,15 +25,9 @@ from .helpers import _markets_admin_panel_sx
# Browse page
# ---------------------------------------------------------------------------
def _product_grid(cards_sx: str) -> str:
"""Wrap product cards in a grid as sx."""
return sx_call("market-product-grid", cards=SxExpr(cards_sx))
async def render_browse_page(ctx: dict) -> str:
"""Full page: product browse with filters."""
cards = _product_cards_sx(ctx)
content = _product_grid(cards)
content = sx_call("market-product-grid", cards=SxExpr(_product_cards_sx(ctx)))
from shared.sx.helpers import render_to_sx_with_env
hdr = await render_to_sx_with_env("market-browse-layout-full", {})
@@ -47,8 +41,7 @@ async def render_browse_page(ctx: dict) -> str:
async def render_browse_oob(ctx: dict) -> str:
"""OOB response: product browse."""
cards = _product_cards_sx(ctx)
content = _product_grid(cards)
content = sx_call("market-product-grid", cards=SxExpr(_product_cards_sx(ctx)))
# Layout handles all OOB headers via auto-fetch macros
oobs = sx_call("market-browse-layout-oob")
@@ -86,13 +79,10 @@ async def render_product_oob(ctx: dict, d: dict) -> str:
"""OOB response: product detail."""
content = _product_detail_sx(d, ctx)
oobs = sx_call("market-oob-wrap",
parts=SxExpr("(<> " + _market_header_sx(ctx, oob=True) + " "
+ await _oob_header_sx("market-header-child", "product-header-child",
_product_header_sx(ctx, d)) + " "
+ _clear_deeper_oob("post-row", "post-header-child",
"market-row", "market-header-child",
"product-row", "product-header-child") + ")"))
oobs = sx_call("market-product-oob",
market_header=SxExpr(_market_header_sx(ctx, oob=True)),
oob_header=SxExpr(await _oob_header_sx("market-header-child", "product-header-child",
_product_header_sx(ctx, d))))
menu = _mobile_nav_panel_sx(ctx)
return await oob_page_sx(oobs=oobs, content=content, menu=menu)
@@ -118,14 +108,10 @@ async def render_product_admin_oob(ctx: dict, d: dict) -> str:
"""OOB response: product admin."""
content = _product_detail_sx(d, ctx)
oobs = sx_call("market-oob-wrap",
parts=SxExpr("(<> " + _product_header_sx(ctx, d, oob=True) + " "
+ await _oob_header_sx("product-header-child", "product-admin-header-child",
_product_admin_header_sx(ctx, d)) + " "
+ _clear_deeper_oob("post-row", "post-header-child",
"market-row", "market-header-child",
"product-row", "product-header-child",
"product-admin-row", "product-admin-header-child") + ")"))
oobs = sx_call("market-product-admin-oob",
product_header=SxExpr(_product_header_sx(ctx, d, oob=True)),
oob_header=SxExpr(await _oob_header_sx("product-header-child", "product-admin-header-child",
_product_admin_header_sx(ctx, d))))
return await oob_page_sx(oobs=oobs, content=content)
@@ -195,10 +181,7 @@ def render_like_toggle_button(slug: str, liked: bool, *,
def render_cart_added_response(cart: list, item: Any, d: dict) -> str:
"""Render the HTMX response after add-to-cart.
Returns OOB fragments: cart-mini icon + product add/remove buttons + cart item row.
"""
"""Render the HTMX response after add-to-cart via ~market-cart-added-response."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
from shared.infrastructure.urls import cart_url as _cart_url
@@ -206,37 +189,28 @@ def render_cart_added_response(cart: list, item: Any, d: dict) -> str:
csrf = generate_csrf_token()
slug = d.get("slug", "")
count = sum(getattr(ci, "quantity", 0) for ci in cart)
quantity = getattr(item, "quantity", 0) if item else 0
action = url_for("market.browse.product.cart", product_slug=slug)
# 1. Cart mini icon OOB
if count > 0:
cart_href = _cart_url("/")
cart_mini = sx_call("market-cart-mini-count", href=cart_href, count=str(count))
blog_href = ""
logo = ""
else:
from shared.config import config
cart_href = ""
blog_href = config().get("blog_url", "/")
logo = config().get("logo", "")
cart_mini = sx_call("market-cart-mini-empty", href=blog_href, logo=logo)
# 2. Add/remove buttons OOB
action = url_for("market.browse.product.cart", product_slug=slug)
quantity = getattr(item, "quantity", 0) if item else 0
if not quantity:
cart_add = sx_call(
"market-cart-add-empty",
cart_id=f"cart-{slug}", action=action, csrf=csrf,
)
else:
cart_href = _cart_url("/") if callable(_cart_url) else "/"
cart_add = sx_call(
"market-cart-add-quantity",
cart_id=f"cart-{slug}", action=action, csrf=csrf,
minus_val=str(quantity - 1), plus_val=str(quantity + 1),
quantity=str(quantity), cart_href=cart_href,
)
add_sx = sx_call(
"market-cart-add-oob",
id=f"cart-add-{slug}",
inner=cart_add,
return sx_call("market-cart-added-response",
has_count=str(count) if count > 0 else None,
cart_href=cart_href,
blog_href=blog_href,
logo=logo,
slug=slug,
action=action,
csrf=csrf,
quantity=str(quantity),
minus_val=str(quantity - 1) if quantity > 0 else "0",
plus_val=str(quantity + 1),
)
return "(<> " + cart_mini + " " + add_sx + ")"

View File

@@ -1,28 +1,9 @@
"""Price helpers, OOB helpers, product detail/meta data builders."""
"""Price helpers, product detail/meta data builders."""
from __future__ import annotations
from shared.sx.helpers import sx_call
# ---------------------------------------------------------------------------
# OOB orphan cleanup
# ---------------------------------------------------------------------------
_MARKET_DEEP_IDS = [
"product-admin-row", "product-admin-header-child",
"product-row", "product-header-child",
"market-admin-row", "market-admin-header-child",
"market-row", "market-header-child",
"post-admin-row", "post-admin-header-child",
]
def _clear_deeper_oob(*keep_ids: str) -> str:
"""Clear all market header rows/children NOT in keep_ids."""
to_clear = [i for i in _MARKET_DEEP_IDS if i not in keep_ids]
return " ".join(sx_call("clear-oob-div", id=i) for i in to_clear)
# ---------------------------------------------------------------------------
# Price helpers
# ---------------------------------------------------------------------------

View File

@@ -47,6 +47,17 @@
(h2 :class "text-base sm:text-lg font-semibold" "Event tickets in this order")
(ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" items)))
;; Data-driven ticket items (replaces Python loop)
(defcomp ~checkout-return-tickets-from-data (&key tickets)
(~checkout-return-tickets
:items (<> (map (lambda (tk)
(~checkout-return-ticket
:name (get tk "name") :pill (get tk "pill")
:state (get tk "state") :type-name (get tk "type_name")
:date-str (get tk "date_str") :code (get tk "code")
:price (get tk "price")))
(or tickets (list))))))
(defcomp ~checkout-return-content (&key summary items calendar tickets status-message)
(div :class "max-w-full px-1 py-1"
(when summary

View File

@@ -47,6 +47,8 @@ def create_base_app(
context_fn: Callable[[], Awaitable[dict]] | None = None,
before_request_fns: Sequence[Callable[[], Awaitable[None]]] | None = None,
domain_services_fn: Callable[[], None] | None = None,
no_oauth: bool = False,
no_db: bool = False,
) -> Quart:
"""
Create a Quart app with shared infrastructure.
@@ -110,7 +112,8 @@ def create_base_app(
# --- infrastructure ---
register_middleware(app)
register_db(app)
if not no_db:
register_db(app)
register_redis(app)
setup_jinja(app)
setup_sx_bridge(app)
@@ -156,7 +159,7 @@ def create_base_app(
# Auto-register OAuth client blueprint for non-account apps
# (account is the OAuth authorization server)
_NO_OAUTH = {"account"}
if name not in _NO_OAUTH:
if name not in _NO_OAUTH and not no_oauth:
from shared.infrastructure.oauth import create_oauth_blueprint
app.register_blueprint(create_oauth_blueprint(name))
@@ -205,7 +208,7 @@ def create_base_app(
# Auth state check via grant verification + silent OAuth handshake
# MUST run before _load_user so stale sessions are cleared first
if name not in _NO_OAUTH:
if name not in _NO_OAUTH and not no_oauth:
@app.before_request
async def _check_auth_state():
from quart import session as qs
@@ -341,6 +344,14 @@ def create_base_app(
response.headers["HX-Preserve-Search"] = value
return response
# Prevent browser caching of static files in dev (forces fresh fetch on reload)
if app.config["NO_PAGE_CACHE"]:
@app.after_request
async def _no_cache_static(response):
if request.path.startswith("/static/"):
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return response
# --- context processor ---
if context_fn is not None:
@app.context_processor
@@ -355,7 +366,7 @@ def create_base_app(
return await base_context()
# --- event processor ---
_event_processor = EventProcessor(app_name=name)
_event_processor = None if no_db else EventProcessor(app_name=name)
# --- startup ---
@app.before_serving
@@ -364,11 +375,13 @@ def create_base_app(
register_shared_handlers()
await init_config()
print(pretty())
await _event_processor.start()
if _event_processor:
await _event_processor.start()
@app.after_serving
async def _stop_event_processor():
await _event_processor.stop()
if _event_processor:
await _event_processor.stop()
from shared.infrastructure.auth_redis import close_auth_redis
await close_auth_redis()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1286,8 +1286,8 @@
kwargs[args[i].name] = sxEval(v, env);
}
} else {
// Data arrays, dicts, etc — pass through as-is
kwargs[args[i].name] = v;
// Data arrays, dicts, etc — evaluate in caller's env
kwargs[args[i].name] = sxEval(v, env);
}
i += 2;
} else {

View File

@@ -41,10 +41,18 @@ Usage::
from __future__ import annotations
import contextvars
import inspect
from typing import Any
from .types import Component, Keyword, Lambda, Macro, NIL, StyleValue, Symbol
# When True, _aser expands known components server-side instead of serializing
# them for client rendering. Set during page slot evaluation so Python-only
# helpers (e.g. highlight) in component bodies execute on the server.
_expand_components: contextvars.ContextVar[bool] = contextvars.ContextVar(
"_expand_components", default=False
)
from .evaluator import _expand_macro, EvalError
from .primitives import _PRIMITIVES
from .primitives_io import IO_PRIMITIVES, RequestContext, execute_io
@@ -1058,6 +1066,24 @@ async def async_eval_slot_to_sx(
"""
if ctx is None:
ctx = RequestContext()
# Enable server-side component expansion for this slot evaluation.
# This lets _aser expand known components (so Python-only helpers
# like highlight execute server-side) instead of serializing them
# for client rendering.
token = _expand_components.set(True)
try:
return await _eval_slot_inner(expr, env, ctx)
finally:
_expand_components.reset(token)
async def _eval_slot_inner(
expr: Any,
env: dict[str, Any],
ctx: RequestContext,
) -> str:
"""Inner implementation — runs with _expand_components=True."""
# If expr is a component call, expand it through _aser
if isinstance(expr, list) and expr:
head = expr[0]
@@ -1159,12 +1185,15 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
if name.startswith("html:"):
return await _aser_call(name[5:], expr[1:], env, ctx)
# Component call — expand macros, serialize regular components
# Component call — expand macros, expand known components (in slot
# eval context only), serialize unknown
if name.startswith("~"):
val = env.get(name)
if isinstance(val, Macro):
expanded = _expand_macro(val, expr[1:], env)
return await _aser(expanded, env, ctx)
if isinstance(val, Component) and _expand_components.get():
return await _aser_component(val, expr[1:], env, ctx)
return await _aser_call(name, expr[1:], env, ctx)
# Serialize-mode special/HO forms (checked BEFORE HTML_TAGS

View File

@@ -606,7 +606,7 @@ details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.
<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-mount="body">{page_sx}</script>
<script src="{asset_url}/scripts/sx.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>
</body>
</html>"""
@@ -627,8 +627,9 @@ def sx_page(ctx: dict, page_sx: str, *,
component_hash = get_component_hash()
# Check if client already has this version cached (via cookie)
# In dev mode, always send full source so edits are visible immediately
client_hash = _get_sx_comp_cookie()
if client_hash and client_hash == component_hash:
if not _is_dev_mode() and client_hash and client_hash == component_hash:
# Client has current components cached — send empty source
component_defs = ""
else:
@@ -675,7 +676,7 @@ def sx_page(ctx: dict, page_sx: str, *,
# Style dictionary for client-side css primitive
styles_hash = _get_style_dict_hash()
client_styles_hash = _get_sx_styles_cookie()
if client_styles_hash and client_styles_hash == styles_hash:
if not _is_dev_mode() and client_styles_hash and client_styles_hash == styles_hash:
styles_json = "" # Client has cached version
else:
styles_json = _build_style_dict_json()
@@ -692,7 +693,7 @@ def sx_page(ctx: dict, page_sx: str, *,
page_sx=page_sx,
sx_css=sx_css,
sx_css_classes=sx_css_classes,
sx_js_hash=_script_hash("sx.js"),
sx_js_hash=_script_hash("sx-browser.js"),
body_js_hash=_script_hash("body.js"),
)

View File

@@ -0,0 +1,469 @@
;; ==========================================================================
;; adapter-dom.sx — DOM rendering adapter
;;
;; Renders SX expressions to live DOM nodes. Browser-only.
;; Mirrors the render-to-html adapter but produces Element/Text/Fragment
;; nodes instead of HTML strings.
;;
;; Depends on:
;; render.sx — HTML_TAGS, VOID_ELEMENTS, BOOLEAN_ATTRS, definition-form?
;; eval.sx — eval-expr, trampoline, call-component, expand-macro
;; ==========================================================================
(define SVG_NS "http://www.w3.org/2000/svg")
(define MATH_NS "http://www.w3.org/1998/Math/MathML")
;; --------------------------------------------------------------------------
;; render-to-dom — main entry point
;; --------------------------------------------------------------------------
(define render-to-dom
(fn (expr env ns)
(case (type-of expr)
;; nil / boolean false / boolean true → empty fragment
"nil" (create-fragment)
"boolean" (create-fragment)
;; Pre-rendered raw HTML → parse into fragment
"raw-html" (dom-parse-html (raw-html-content expr))
;; String → text node
"string" (create-text-node expr)
;; Number → text node
"number" (create-text-node (str expr))
;; Symbol → evaluate then render
"symbol" (render-to-dom (trampoline (eval-expr expr env)) env ns)
;; Keyword → text
"keyword" (create-text-node (keyword-name expr))
;; Pre-rendered DOM node → pass through
"dom-node" expr
;; Dict → empty
"dict" (create-fragment)
;; List → dispatch
"list"
(if (empty? expr)
(create-fragment)
(render-dom-list expr env ns))
;; Style value → text of class name
"style-value" (create-text-node (style-value-class expr))
;; Fallback
:else (create-text-node (str expr)))))
;; --------------------------------------------------------------------------
;; render-dom-list — dispatch on list head
;; --------------------------------------------------------------------------
(define render-dom-list
(fn (expr env ns)
(let ((head (first expr)))
(cond
;; Symbol head — dispatch on name
(= (type-of head) "symbol")
(let ((name (symbol-name head))
(args (rest expr)))
(cond
;; raw! → insert unescaped HTML
(= name "raw!")
(render-dom-raw args env)
;; <> → fragment
(= name "<>")
(render-dom-fragment args env ns)
;; html: prefix → force element rendering
(starts-with? name "html:")
(render-dom-element (slice name 5) args env ns)
;; Render-aware special forms
(render-dom-form? name)
(if (and (contains? HTML_TAGS name)
(or (and (> (len args) 0)
(= (type-of (first args)) "keyword"))
ns))
;; Ambiguous: tag name that's also a form — treat as tag
;; when keyword arg or namespace present
(render-dom-element name args env ns)
(dispatch-render-form name expr env ns))
;; Macro expansion
(and (env-has? env name) (macro? (env-get env name)))
(render-to-dom
(expand-macro (env-get env name) args env)
env ns)
;; HTML tag
(contains? HTML_TAGS name)
(render-dom-element name args env ns)
;; Component (~name)
(starts-with? name "~")
(let ((comp (env-get env name)))
(if (component? comp)
(render-dom-component comp args env ns)
(render-dom-unknown-component name)))
;; Custom element (hyphenated with keyword attrs)
(and (> (index-of name "-") 0)
(> (len args) 0)
(= (type-of (first args)) "keyword"))
(render-dom-element name args env ns)
;; Inside SVG/MathML namespace — treat as element
ns
(render-dom-element name args env ns)
;; Fallback — evaluate then render
:else
(render-to-dom (trampoline (eval-expr expr env)) env ns)))
;; Lambda or list head → evaluate
(or (lambda? head) (= (type-of head) "list"))
(render-to-dom (trampoline (eval-expr expr env)) env ns)
;; Data list
:else
(let ((frag (create-fragment)))
(for-each (fn (x) (dom-append frag (render-to-dom x env ns))) expr)
frag)))))
;; --------------------------------------------------------------------------
;; render-dom-element — create a DOM element with attrs and children
;; --------------------------------------------------------------------------
(define render-dom-element
(fn (tag args env ns)
;; Detect namespace from tag
(let ((new-ns (cond (= tag "svg") SVG_NS
(= tag "math") MATH_NS
:else ns))
(el (dom-create-element tag new-ns))
(extra-class nil))
;; Process args: keywords → attrs, others → children
(reduce
(fn (state arg)
(let ((skip (get state "skip")))
(if skip
(assoc state "skip" false "i" (inc (get state "i")))
(if (and (= (type-of arg) "keyword")
(< (inc (get state "i")) (len args)))
;; Keyword arg → attribute
(let ((attr-name (keyword-name arg))
(attr-val (trampoline
(eval-expr
(nth args (inc (get state "i")))
env))))
(cond
;; nil or false → skip
(or (nil? attr-val) (= attr-val false))
nil
;; :style StyleValue → convert to class
(and (= attr-name "style") (style-value? attr-val))
(set! extra-class (style-value-class attr-val))
;; Boolean attr
(contains? BOOLEAN_ATTRS attr-name)
(when attr-val (dom-set-attr el attr-name ""))
;; true → empty attr
(= attr-val true)
(dom-set-attr el attr-name "")
;; Normal attr
:else
(dom-set-attr el attr-name (str attr-val)))
(assoc state "skip" true "i" (inc (get state "i"))))
;; Positional arg → child
(do
(when (not (contains? VOID_ELEMENTS tag))
(dom-append el (render-to-dom arg env new-ns)))
(assoc state "i" (inc (get state "i"))))))))
(dict "i" 0 "skip" false)
args)
;; Merge StyleValue class
(when extra-class
(let ((existing (dom-get-attr el "class")))
(dom-set-attr el "class"
(if existing (str existing " " extra-class) extra-class))))
el)))
;; --------------------------------------------------------------------------
;; render-dom-component — expand and render a component
;; --------------------------------------------------------------------------
(define render-dom-component
(fn (comp args env ns)
;; Parse kwargs and children, bind into component env, render body.
(let ((kwargs (dict))
(children (list)))
;; Separate keyword args from positional children
(reduce
(fn (state arg)
(let ((skip (get state "skip")))
(if skip
(assoc state "skip" false "i" (inc (get state "i")))
(if (and (= (type-of arg) "keyword")
(< (inc (get state "i")) (len args)))
;; Keyword arg — evaluate in caller's env
(let ((val (trampoline
(eval-expr (nth args (inc (get state "i"))) env))))
(dict-set! kwargs (keyword-name arg) val)
(assoc state "skip" true "i" (inc (get state "i"))))
(do
(append! children arg)
(assoc state "i" (inc (get state "i"))))))))
(dict "i" 0 "skip" false)
args)
;; Build component env: closure + caller env + params
(let ((local (env-merge (component-closure comp) env)))
;; Bind params from kwargs
(for-each
(fn (p)
(env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(component-params comp))
;; If component accepts children, pre-render them to a fragment
(when (component-has-children? comp)
(let ((child-frag (create-fragment)))
(for-each
(fn (c) (dom-append child-frag (render-to-dom c env ns)))
children)
(env-set! local "children" child-frag)))
(render-to-dom (component-body comp) local ns)))))
;; --------------------------------------------------------------------------
;; render-dom-fragment — render children into a DocumentFragment
;; --------------------------------------------------------------------------
(define render-dom-fragment
(fn (args env ns)
(let ((frag (create-fragment)))
(for-each
(fn (x) (dom-append frag (render-to-dom x env ns)))
args)
frag)))
;; --------------------------------------------------------------------------
;; render-dom-raw — insert unescaped content
;; --------------------------------------------------------------------------
(define render-dom-raw
(fn (args env)
(let ((frag (create-fragment)))
(for-each
(fn (arg)
(let ((val (trampoline (eval-expr arg env))))
(cond
(= (type-of val) "string")
(dom-append frag (dom-parse-html val))
(= (type-of val) "dom-node")
(dom-append frag (dom-clone val))
(not (nil? val))
(dom-append frag (create-text-node (str val))))))
args)
frag)))
;; --------------------------------------------------------------------------
;; render-dom-unknown-component — visible warning element
;; --------------------------------------------------------------------------
(define render-dom-unknown-component
(fn (name)
(let ((el (dom-create-element "div" nil)))
(dom-set-attr el "style"
"background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;padding:4px 8px;margin:2px;border-radius:4px;font-size:12px;font-family:monospace")
(dom-append el (create-text-node (str "Unknown component: " name)))
el)))
;; --------------------------------------------------------------------------
;; Render-aware special forms for DOM output
;; --------------------------------------------------------------------------
;; These forms need special handling in DOM rendering because they
;; produce DOM nodes rather than evaluated values.
(define RENDER_DOM_FORMS
(list "if" "when" "cond" "case" "let" "let*" "begin" "do"
"define" "defcomp" "defmacro" "defstyle" "defkeyframes" "defhandler"
"map" "map-indexed" "filter" "for-each"))
(define render-dom-form?
(fn (name)
(contains? RENDER_DOM_FORMS name)))
(define dispatch-render-form
(fn (name expr env ns)
(cond
;; if
(= name "if")
(let ((cond-val (trampoline (eval-expr (nth expr 1) env))))
(if cond-val
(render-to-dom (nth expr 2) env ns)
(if (> (len expr) 3)
(render-to-dom (nth expr 3) env ns)
(create-fragment))))
;; when
(= name "when")
(if (not (trampoline (eval-expr (nth expr 1) env)))
(create-fragment)
(let ((frag (create-fragment)))
(for-each
(fn (i)
(dom-append frag (render-to-dom (nth expr i) env ns)))
(range 2 (len expr)))
frag))
;; cond
(= name "cond")
(let ((branch (eval-cond (rest expr) env)))
(if branch
(render-to-dom branch env ns)
(create-fragment)))
;; case
(= name "case")
(render-to-dom (trampoline (eval-expr expr env)) env ns)
;; let / let*
(or (= name "let") (= name "let*"))
(let ((local (process-bindings (nth expr 1) env))
(frag (create-fragment)))
(for-each
(fn (i)
(dom-append frag (render-to-dom (nth expr i) local ns)))
(range 2 (len expr)))
frag)
;; begin / do
(or (= name "begin") (= name "do"))
(let ((frag (create-fragment)))
(for-each
(fn (i)
(dom-append frag (render-to-dom (nth expr i) env ns)))
(range 1 (len expr)))
frag)
;; Definition forms — eval for side effects
(definition-form? name)
(do (trampoline (eval-expr expr env)) (create-fragment))
;; map
(= name "map")
(let ((f (trampoline (eval-expr (nth expr 1) env)))
(coll (trampoline (eval-expr (nth expr 2) env)))
(frag (create-fragment)))
(for-each
(fn (item)
(let ((val (if (lambda? f)
(render-lambda-dom f (list item) env ns)
(render-to-dom (apply f (list item)) env ns))))
(dom-append frag val)))
coll)
frag)
;; map-indexed
(= name "map-indexed")
(let ((f (trampoline (eval-expr (nth expr 1) env)))
(coll (trampoline (eval-expr (nth expr 2) env)))
(frag (create-fragment)))
(for-each-indexed
(fn (i item)
(let ((val (if (lambda? f)
(render-lambda-dom f (list i item) env ns)
(render-to-dom (apply f (list i item)) env ns))))
(dom-append frag val)))
coll)
frag)
;; filter — evaluate fully then render
(= name "filter")
(render-to-dom (trampoline (eval-expr expr env)) env ns)
;; for-each (render variant)
(= name "for-each")
(let ((f (trampoline (eval-expr (nth expr 1) env)))
(coll (trampoline (eval-expr (nth expr 2) env)))
(frag (create-fragment)))
(for-each
(fn (item)
(let ((val (if (lambda? f)
(render-lambda-dom f (list item) env ns)
(render-to-dom (apply f (list item)) env ns))))
(dom-append frag val)))
coll)
frag)
;; Fallback
:else
(render-to-dom (trampoline (eval-expr expr env)) env ns))))
;; --------------------------------------------------------------------------
;; render-lambda-dom — render a lambda body in DOM context
;; --------------------------------------------------------------------------
(define render-lambda-dom
(fn (f args env ns)
;; Bind lambda params and render body as DOM
(let ((local (env-merge (lambda-closure f) env)))
(for-each-indexed
(fn (i p)
(env-set! local p (nth args i)))
(lambda-params f))
(render-to-dom (lambda-body f) local ns))))
;; --------------------------------------------------------------------------
;; Platform interface — DOM adapter
;; --------------------------------------------------------------------------
;;
;; Element creation:
;; (dom-create-element tag ns) → Element (ns=nil for HTML, string for SVG/MathML)
;; (create-text-node s) → Text node
;; (create-fragment) → DocumentFragment
;;
;; Tree mutation:
;; (dom-append parent child) → void (appendChild)
;; (dom-set-attr el name val) → void (setAttribute)
;; (dom-get-attr el name) → string or nil (getAttribute)
;;
;; Content parsing:
;; (dom-parse-html s) → DocumentFragment from HTML string
;; (dom-clone node) → deep clone of a DOM node
;;
;; Type checking:
;; DOM nodes have type-of → "dom-node"
;;
;; From render.sx:
;; HTML_TAGS, VOID_ELEMENTS, BOOLEAN_ATTRS, definition-form?
;; style-value?, style-value-class
;;
;; From eval.sx:
;; eval-expr, trampoline, expand-macro, process-bindings, eval-cond
;; env-has?, env-get, env-set!, env-merge
;; lambda?, component?, macro?
;; lambda-closure, lambda-params, lambda-body
;; component-params, component-body, component-closure,
;; component-has-children?, component-name
;;
;; Iteration:
;; (for-each-indexed fn coll) → call fn(index, item) for each element
;; --------------------------------------------------------------------------

View File

@@ -0,0 +1,312 @@
;; ==========================================================================
;; adapter-html.sx — HTML string rendering adapter
;;
;; Renders evaluated SX expressions to HTML strings. Used server-side.
;;
;; Depends on:
;; render.sx — HTML_TAGS, VOID_ELEMENTS, BOOLEAN_ATTRS,
;; parse-element-args, render-attrs, definition-form?
;; eval.sx — eval-expr, trampoline, expand-macro, process-bindings,
;; eval-cond, env-has?, env-get, env-set!, env-merge,
;; lambda?, component?, macro?,
;; lambda-closure, lambda-params, lambda-body
;; ==========================================================================
(define render-to-html
(fn (expr env)
(case (type-of expr)
;; Literals — render directly
"nil" ""
"string" (escape-html expr)
"number" (str expr)
"boolean" (if expr "true" "false")
;; List — dispatch to render-list which handles HTML tags, special forms, etc.
"list" (if (empty? expr) "" (render-list-to-html expr env))
;; Symbol — evaluate then render
"symbol" (render-value-to-html (trampoline (eval-expr expr env)) env)
;; Keyword — render as text
"keyword" (escape-html (keyword-name expr))
;; Raw HTML passthrough
"raw-html" (raw-html-content expr)
;; Everything else — evaluate first
:else (render-value-to-html (trampoline (eval-expr expr env)) env))))
(define render-value-to-html
(fn (val env)
(case (type-of val)
"nil" ""
"string" (escape-html val)
"number" (str val)
"boolean" (if val "true" "false")
"list" (render-list-to-html val env)
"raw-html" (raw-html-content val)
"style-value" (style-value-class val)
:else (escape-html (str val)))))
;; --------------------------------------------------------------------------
;; Render-aware form classification
;; --------------------------------------------------------------------------
(define RENDER_HTML_FORMS
(list "if" "when" "cond" "case" "let" "let*" "begin" "do"
"define" "defcomp" "defmacro" "defstyle" "defkeyframes" "defhandler"
"map" "map-indexed" "filter" "for-each"))
(define render-html-form?
(fn (name)
(contains? RENDER_HTML_FORMS name)))
;; --------------------------------------------------------------------------
;; render-list-to-html — dispatch on list head
;; --------------------------------------------------------------------------
(define render-list-to-html
(fn (expr env)
(if (empty? expr)
""
(let ((head (first expr)))
(if (not (= (type-of head) "symbol"))
;; Data list — render each item
(join "" (map (fn (x) (render-value-to-html x env)) expr))
(let ((name (symbol-name head))
(args (rest expr)))
(cond
;; Fragment
(= name "<>")
(join "" (map (fn (x) (render-to-html x env)) args))
;; Raw HTML passthrough
(= name "raw!")
(join "" (map (fn (x) (str (trampoline (eval-expr x env)))) args))
;; HTML tag
(contains? HTML_TAGS name)
(render-html-element name args env)
;; Component or macro call (~name)
(starts-with? name "~")
(let ((val (env-get env name)))
(cond
(component? val)
(render-html-component val args env)
(macro? val)
(render-to-html
(expand-macro val args env)
env)
:else
(error (str "Unknown component: " name))))
;; Render-aware special forms
(render-html-form? name)
(dispatch-html-form name expr env)
;; Macro expansion
(and (env-has? env name) (macro? (env-get env name)))
(render-to-html
(expand-macro (env-get env name) args env)
env)
;; Fallback — evaluate then render result
:else
(render-value-to-html
(trampoline (eval-expr expr env))
env))))))))
;; --------------------------------------------------------------------------
;; dispatch-html-form — render-aware special form handling for HTML output
;; --------------------------------------------------------------------------
(define dispatch-html-form
(fn (name expr env)
(cond
;; if
(= name "if")
(let ((cond-val (trampoline (eval-expr (nth expr 1) env))))
(if cond-val
(render-to-html (nth expr 2) env)
(if (> (len expr) 3)
(render-to-html (nth expr 3) env)
"")))
;; when
(= name "when")
(if (not (trampoline (eval-expr (nth expr 1) env)))
""
(join ""
(map
(fn (i) (render-to-html (nth expr i) env))
(range 2 (len expr)))))
;; cond
(= name "cond")
(let ((branch (eval-cond (rest expr) env)))
(if branch
(render-to-html branch env)
""))
;; case
(= name "case")
(render-to-html (trampoline (eval-expr expr env)) env)
;; let / let*
(or (= name "let") (= name "let*"))
(let ((local (process-bindings (nth expr 1) env)))
(join ""
(map
(fn (i) (render-to-html (nth expr i) local))
(range 2 (len expr)))))
;; begin / do
(or (= name "begin") (= name "do"))
(join ""
(map
(fn (i) (render-to-html (nth expr i) env))
(range 1 (len expr))))
;; Definition forms — eval for side effects
(definition-form? name)
(do (trampoline (eval-expr expr env)) "")
;; map
(= name "map")
(let ((f (trampoline (eval-expr (nth expr 1) env)))
(coll (trampoline (eval-expr (nth expr 2) env))))
(join ""
(map
(fn (item)
(if (lambda? f)
(render-lambda-html f (list item) env)
(render-to-html (apply f (list item)) env)))
coll)))
;; map-indexed
(= name "map-indexed")
(let ((f (trampoline (eval-expr (nth expr 1) env)))
(coll (trampoline (eval-expr (nth expr 2) env))))
(join ""
(map-indexed
(fn (i item)
(if (lambda? f)
(render-lambda-html f (list i item) env)
(render-to-html (apply f (list i item)) env)))
coll)))
;; filter — evaluate fully then render
(= name "filter")
(render-to-html (trampoline (eval-expr expr env)) env)
;; for-each (render variant)
(= name "for-each")
(let ((f (trampoline (eval-expr (nth expr 1) env)))
(coll (trampoline (eval-expr (nth expr 2) env))))
(join ""
(map
(fn (item)
(if (lambda? f)
(render-lambda-html f (list item) env)
(render-to-html (apply f (list item)) env)))
coll)))
;; Fallback
:else
(render-value-to-html (trampoline (eval-expr expr env)) env))))
;; --------------------------------------------------------------------------
;; render-lambda-html — render a lambda body in HTML context
;; --------------------------------------------------------------------------
(define render-lambda-html
(fn (f args env)
(let ((local (env-merge (lambda-closure f) env)))
(for-each-indexed
(fn (i p)
(env-set! local p (nth args i)))
(lambda-params f))
(render-to-html (lambda-body f) local))))
;; --------------------------------------------------------------------------
;; render-html-component — expand and render a component
;; --------------------------------------------------------------------------
(define render-html-component
(fn (comp args env)
;; Expand component and render body through HTML adapter.
;; Component body contains rendering forms (HTML tags) that only the
;; adapter understands, so expansion must happen here, not in eval-expr.
(let ((kwargs (dict))
(children (list)))
;; Separate keyword args from positional children
(reduce
(fn (state arg)
(let ((skip (get state "skip")))
(if skip
(assoc state "skip" false "i" (inc (get state "i")))
(if (and (= (type-of arg) "keyword")
(< (inc (get state "i")) (len args)))
(let ((val (trampoline
(eval-expr (nth args (inc (get state "i"))) env))))
(dict-set! kwargs (keyword-name arg) val)
(assoc state "skip" true "i" (inc (get state "i"))))
(do
(append! children arg)
(assoc state "i" (inc (get state "i"))))))))
(dict "i" 0 "skip" false)
args)
;; Build component env: closure + caller env + params
(let ((local (env-merge (component-closure comp) env)))
;; Bind params from kwargs
(for-each
(fn (p)
(env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(component-params comp))
;; If component accepts children, pre-render them to raw HTML
(when (component-has-children? comp)
(env-set! local "children"
(make-raw-html
(join "" (map (fn (c) (render-to-html c env)) children)))))
(render-to-html (component-body comp) local)))))
(define render-html-element
(fn (tag args env)
(let ((parsed (parse-element-args args env))
(attrs (first parsed))
(children (nth parsed 1))
(is-void (contains? VOID_ELEMENTS tag)))
(str "<" tag
(render-attrs attrs)
(if is-void
" />"
(str ">"
(join "" (map (fn (c) (render-to-html c env)) children))
"</" tag ">"))))))
;; --------------------------------------------------------------------------
;; Platform interface — HTML adapter
;; --------------------------------------------------------------------------
;;
;; Inherited from render.sx:
;; escape-html, escape-attr, raw-html-content, style-value?, style-value-class
;;
;; From eval.sx:
;; eval-expr, trampoline, expand-macro, process-bindings, eval-cond
;; env-has?, env-get, env-set!, env-merge
;; lambda?, component?, macro?
;; lambda-closure, lambda-params, lambda-body
;; component-params, component-body, component-closure,
;; component-has-children?, component-name
;;
;; Raw HTML construction:
;; (make-raw-html s) → wrap string as raw HTML (not double-escaped)
;;
;; Iteration:
;; (for-each-indexed fn coll) → call fn(index, item) for each element
;; (map-indexed fn coll) → map fn(index, item) over each element
;; --------------------------------------------------------------------------

147
shared/sx/ref/adapter-sx.sx Normal file
View File

@@ -0,0 +1,147 @@
;; ==========================================================================
;; adapter-sx.sx — SX wire format rendering adapter
;;
;; Serializes SX expressions for client-side rendering.
;; Component calls are NOT expanded — they're sent to the client as-is.
;; HTML tags are serialized as SX source text. Special forms are evaluated.
;;
;; Depends on:
;; render.sx — HTML_TAGS
;; eval.sx — eval-expr, trampoline, call-lambda, expand-macro
;; ==========================================================================
(define render-to-sx
(fn (expr env)
(let ((result (aser expr env)))
;; aser-call already returns serialized SX strings;
;; only serialize non-string values
(if (= (type-of result) "string")
result
(serialize result)))))
(define aser
(fn (expr env)
;; Evaluate for SX wire format — serialize rendering forms,
;; evaluate control flow and function calls.
(case (type-of expr)
"number" expr
"string" expr
"boolean" expr
"nil" nil
"symbol"
(let ((name (symbol-name expr)))
(cond
(env-has? env name) (env-get env name)
(primitive? name) (get-primitive name)
(= name "true") true
(= name "false") false
(= name "nil") nil
:else (error (str "Undefined symbol: " name))))
"keyword" (keyword-name expr)
"list"
(if (empty? expr)
(list)
(aser-list expr env))
:else expr)))
(define aser-list
(fn (expr env)
(let ((head (first expr))
(args (rest expr)))
(if (not (= (type-of head) "symbol"))
(map (fn (x) (aser x env)) expr)
(let ((name (symbol-name head)))
(cond
;; Fragment — serialize children
(= name "<>")
(aser-fragment args env)
;; Component call — serialize WITHOUT expanding
(starts-with? name "~")
(aser-call name args env)
;; HTML tag — serialize
(contains? HTML_TAGS name)
(aser-call name args env)
;; Special/HO forms — evaluate (produces data)
(or (special-form? name) (ho-form? name))
(aser-special name expr env)
;; Macro — expand then aser
(and (env-has? env name) (macro? (env-get env name)))
(aser (expand-macro (env-get env name) args env) env)
;; Function call — evaluate fully
:else
(let ((f (trampoline (eval-expr head env)))
(evaled-args (map (fn (a) (trampoline (eval-expr a env))) args)))
(cond
(and (callable? f) (not (lambda? f)) (not (component? f)))
(apply f evaled-args)
(lambda? f)
(trampoline (call-lambda f evaled-args env))
(component? f)
(aser-call (str "~" (component-name f)) args env)
:else (error (str "Not callable: " (inspect f)))))))))))
(define aser-fragment
(fn (children env)
;; Serialize (<> child1 child2 ...) to sx source string
(let ((parts (filter
(fn (x) (not (nil? x)))
(map (fn (c) (aser c env)) children))))
(if (empty? parts)
""
(str "(<> " (join " " (map serialize parts)) ")")))))
(define aser-call
(fn (name args env)
;; Serialize (name :key val child ...) — evaluate args but keep as sx
(let ((parts (list name)))
(reduce
(fn (state arg)
(let ((skip (get state "skip")))
(if skip
(assoc state "skip" false "i" (inc (get state "i")))
(if (and (= (type-of arg) "keyword")
(< (inc (get state "i")) (len args)))
(let ((val (aser (nth args (inc (get state "i"))) env)))
(when (not (nil? val))
(append! parts (str ":" (keyword-name arg)))
(append! parts (serialize val)))
(assoc state "skip" true "i" (inc (get state "i"))))
(let ((val (aser arg env)))
(when (not (nil? val))
(append! parts (serialize val)))
(assoc state "i" (inc (get state "i"))))))))
(dict "i" 0 "skip" false)
args)
(str "(" (join " " parts) ")"))))
;; --------------------------------------------------------------------------
;; Platform interface — SX wire adapter
;; --------------------------------------------------------------------------
;;
;; Serialization:
;; (serialize val) → SX source string representation of val
;;
;; Form classification:
;; (special-form? name) → boolean
;; (ho-form? name) → boolean
;; (aser-special name expr env) → evaluate special/HO form through aser
;;
;; From eval.sx:
;; eval-expr, trampoline, call-lambda, expand-macro
;; env-has?, env-get, callable?, lambda?, component?, macro?
;; primitive?, get-primitive, component-name
;; --------------------------------------------------------------------------

384
shared/sx/ref/boot.sx Normal file
View File

@@ -0,0 +1,384 @@
;; ==========================================================================
;; boot.sx — Browser boot, mount, hydrate, script processing
;;
;; Handles the browser startup lifecycle:
;; 1. CSS tracking init
;; 2. Style dictionary loading (from <script type="text/sx-styles">)
;; 3. Component script processing (from <script type="text/sx">)
;; 4. Hydration of [data-sx] elements
;; 5. Engine element processing
;;
;; Also provides the public mounting/hydration API:
;; mount, hydrate, update, render-component
;;
;; Depends on:
;; cssx.sx — load-style-dict
;; orchestration.sx — process-elements, engine-init
;; adapter-dom.sx — render-to-dom
;; render.sx — shared registries
;; ==========================================================================
;; --------------------------------------------------------------------------
;; Head element hoisting (full version)
;; --------------------------------------------------------------------------
;; Moves <meta>, <title>, <link rel=canonical>, <script type=application/ld+json>
;; from rendered content to <head>, deduplicating as needed.
(define HEAD_HOIST_SELECTOR
"meta, title, link[rel='canonical'], script[type='application/ld+json']")
(define hoist-head-elements-full
(fn (root)
(let ((els (dom-query-all root HEAD_HOIST_SELECTOR)))
(for-each
(fn (el)
(let ((tag (lower (dom-tag-name el))))
(cond
;; <title> — replace document title
(= tag "title")
(do
(set-document-title (dom-text-content el))
(dom-remove-child (dom-parent el) el))
;; <meta> — deduplicate by name or property
(= tag "meta")
(do
(let ((name (dom-get-attr el "name"))
(prop (dom-get-attr el "property")))
(when name
(remove-head-element (str "meta[name=\"" name "\"]")))
(when prop
(remove-head-element (str "meta[property=\"" prop "\"]"))))
(dom-remove-child (dom-parent el) el)
(dom-append-to-head el))
;; <link rel=canonical> — deduplicate
(and (= tag "link")
(= (dom-get-attr el "rel") "canonical"))
(do
(remove-head-element "link[rel=\"canonical\"]")
(dom-remove-child (dom-parent el) el)
(dom-append-to-head el))
;; Everything else (ld+json, etc.) — just move
:else
(do
(dom-remove-child (dom-parent el) el)
(dom-append-to-head el)))))
els))))
;; --------------------------------------------------------------------------
;; Mount — render SX source into a DOM element
;; --------------------------------------------------------------------------
(define sx-mount
(fn (target source extra-env)
;; Render SX source string into target element.
;; target: Element or CSS selector string
;; source: SX source string
;; extra-env: optional extra bindings dict
(let ((el (resolve-mount-target target)))
(when el
(let ((node (sx-render-with-env source extra-env)))
(dom-set-text-content el "")
(dom-append el node)
;; Hoist head elements from rendered content
(hoist-head-elements-full el)
;; Process sx- attributes and hydrate
(process-elements el)
(sx-hydrate-elements el))))))
;; --------------------------------------------------------------------------
;; Hydrate — render all [data-sx] elements
;; --------------------------------------------------------------------------
(define sx-hydrate-elements
(fn (root)
;; Find all [data-sx] elements within root and render them.
(let ((els (dom-query-all (or root (dom-body)) "[data-sx]")))
(for-each
(fn (el)
(when (not (is-processed? el "hydrated"))
(mark-processed! el "hydrated")
(sx-update-element el nil)))
els))))
;; --------------------------------------------------------------------------
;; Update — re-render a [data-sx] element with new env data
;; --------------------------------------------------------------------------
(define sx-update-element
(fn (el new-env)
;; Re-render a [data-sx] element.
;; Reads source from data-sx attr, base env from data-sx-env attr.
(let ((target (resolve-mount-target el)))
(when target
(let ((source (dom-get-attr target "data-sx")))
(when source
(let ((base-env (parse-env-attr target))
(env (merge-envs base-env new-env)))
(let ((node (sx-render-with-env source env)))
(dom-set-text-content target "")
(dom-append target node)
;; Update stored env if new-env provided
(when new-env
(store-env-attr target base-env new-env))))))))))
;; --------------------------------------------------------------------------
;; Render component — build synthetic call from kwargs dict
;; --------------------------------------------------------------------------
(define sx-render-component
(fn (name kwargs extra-env)
;; Render a named component with keyword args.
;; name: component name (with or without ~ prefix)
;; kwargs: dict of param-name → value
;; extra-env: optional extra env bindings
(let ((full-name (if (starts-with? name "~") name (str "~" name))))
(let ((env (get-render-env extra-env))
(comp (env-get env full-name)))
(if (not (component? comp))
(error (str "Unknown component: " full-name))
;; Build synthetic call expression
(let ((call-expr (list (make-symbol full-name))))
(for-each
(fn (k)
(append! call-expr (make-keyword (to-kebab k)))
(append! call-expr (dict-get kwargs k)))
(keys kwargs))
(render-to-dom call-expr env nil)))))))
;; --------------------------------------------------------------------------
;; Script processing — <script type="text/sx">
;; --------------------------------------------------------------------------
(define process-sx-scripts
(fn (root)
;; Process all <script type="text/sx"> tags.
;; - data-components + data-hash → localStorage cache
;; - data-mount="<selector>" → render into target
;; - Default: load as components
(let ((scripts (query-sx-scripts root)))
(for-each
(fn (s)
(when (not (is-processed? s "script"))
(mark-processed! s "script")
(let ((text (dom-text-content s)))
(cond
;; Component definitions
(dom-has-attr? s "data-components")
(process-component-script s text)
;; Empty script — skip
(or (nil? text) (empty? (trim text)))
nil
;; Mount directive
(dom-has-attr? s "data-mount")
(let ((mount-sel (dom-get-attr s "data-mount"))
(target (dom-query mount-sel)))
(when target
(sx-mount target text nil)))
;; Default: load as components
:else
(sx-load-components text)))))
scripts))))
;; --------------------------------------------------------------------------
;; Component script with caching
;; --------------------------------------------------------------------------
(define process-component-script
(fn (script text)
;; Handle <script type="text/sx" data-components data-hash="...">
(let ((hash (dom-get-attr script "data-hash")))
(if (nil? hash)
;; Legacy: no hash — just load inline
(when (and text (not (empty? (trim text))))
(sx-load-components text))
;; Hash-based caching
(let ((has-inline (and text (not (empty? (trim text))))))
(let ((cached-hash (local-storage-get "sx-components-hash")))
(if (= cached-hash hash)
;; Cache hit
(if has-inline
;; Server sent full source (cookie stale) — update cache
(do
(local-storage-set "sx-components-hash" hash)
(local-storage-set "sx-components-src" text)
(sx-load-components text)
(log-info "components: downloaded (cookie stale)"))
;; Server omitted source — load from cache
(let ((cached (local-storage-get "sx-components-src")))
(if cached
(do
(sx-load-components cached)
(log-info (str "components: cached (" hash ")")))
;; Cache entry missing — clear cookie and reload
(do
(clear-sx-comp-cookie)
(browser-reload)))))
;; Cache miss — hash mismatch
(if has-inline
;; Server sent full source — cache it
(do
(local-storage-set "sx-components-hash" hash)
(local-storage-set "sx-components-src" text)
(sx-load-components text)
(log-info (str "components: downloaded (" hash ")")))
;; Server omitted but cache stale — clear and reload
(do
(local-storage-remove "sx-components-hash")
(local-storage-remove "sx-components-src")
(clear-sx-comp-cookie)
(browser-reload)))))
(set-sx-comp-cookie hash))))))
;; --------------------------------------------------------------------------
;; Style dictionary initialization
;; --------------------------------------------------------------------------
(define init-style-dict
(fn ()
;; Process <script type="text/sx-styles"> tags with caching.
(let ((scripts (query-style-scripts)))
(for-each
(fn (s)
(when (not (is-processed? s "styles"))
(mark-processed! s "styles")
(let ((text (dom-text-content s))
(hash (dom-get-attr s "data-hash")))
(if (nil? hash)
;; No hash — just parse inline
(when (and text (not (empty? (trim text))))
(parse-and-load-style-dict text))
;; Hash-based caching
(let ((has-inline (and text (not (empty? (trim text))))))
(let ((cached-hash (local-storage-get "sx-styles-hash")))
(if (= cached-hash hash)
;; Cache hit
(if has-inline
(do
(local-storage-set "sx-styles-src" text)
(parse-and-load-style-dict text)
(log-info "styles: downloaded (cookie stale)"))
(let ((cached (local-storage-get "sx-styles-src")))
(if cached
(do
(parse-and-load-style-dict cached)
(log-info (str "styles: cached (" hash ")")))
(do
(clear-sx-styles-cookie)
(browser-reload)))))
;; Cache miss
(if has-inline
(do
(local-storage-set "sx-styles-hash" hash)
(local-storage-set "sx-styles-src" text)
(parse-and-load-style-dict text)
(log-info (str "styles: downloaded (" hash ")")))
(do
(local-storage-remove "sx-styles-hash")
(local-storage-remove "sx-styles-src")
(clear-sx-styles-cookie)
(browser-reload)))))
(set-sx-styles-cookie hash))))))
scripts))))
;; --------------------------------------------------------------------------
;; Full boot sequence
;; --------------------------------------------------------------------------
(define boot-init
(fn ()
;; Full browser initialization:
;; 1. CSS tracking
;; 2. Style dictionary
;; 3. Process scripts (components + mounts)
;; 4. Hydrate [data-sx] elements
;; 5. Process engine elements
(do
(init-css-tracking)
(init-style-dict)
(process-sx-scripts nil)
(sx-hydrate-elements nil)
(process-elements nil))))
;; --------------------------------------------------------------------------
;; Platform interface — Boot
;; --------------------------------------------------------------------------
;;
;; From orchestration.sx:
;; process-elements, init-css-tracking
;;
;; From cssx.sx:
;; load-style-dict
;;
;; === DOM / Render ===
;; (resolve-mount-target target) → Element (string → querySelector, else identity)
;; (sx-render-with-env source extra-env) → DOM node (parse + render with componentEnv + extra)
;; (get-render-env extra-env) → merged component env + extra
;; (merge-envs base new) → merged env dict
;; (render-to-dom expr env ns) → DOM node
;; (sx-load-components text) → void (parse + eval into componentEnv)
;;
;; === DOM queries ===
;; (dom-query sel) → Element or nil
;; (dom-query-all root sel) → list of Elements
;; (dom-body) → document.body
;; (dom-get-attr el name) → string or nil
;; (dom-has-attr? el name) → boolean
;; (dom-text-content el) → string
;; (dom-set-text-content el s) → void
;; (dom-append el child) → void
;; (dom-remove-child parent el) → void
;; (dom-parent el) → Element
;; (dom-append-to-head el) → void
;; (dom-tag-name el) → string
;;
;; === Head hoisting ===
;; (set-document-title s) → void (document.title = s)
;; (remove-head-element sel) → void (remove matching element from <head>)
;;
;; === Script queries ===
;; (query-sx-scripts root) → list of <script type="text/sx"> elements
;; (query-style-scripts) → list of <script type="text/sx-styles"> elements
;;
;; === localStorage ===
;; (local-storage-get key) → string or nil
;; (local-storage-set key val) → void
;; (local-storage-remove key) → void
;;
;; === Cookies ===
;; (set-sx-comp-cookie hash) → void
;; (clear-sx-comp-cookie) → void
;; (set-sx-styles-cookie hash) → void
;; (clear-sx-styles-cookie) → void
;;
;; === Env ===
;; (parse-env-attr el) → dict (parse data-sx-env JSON attr)
;; (store-env-attr el base new) → void (merge and store back as JSON)
;; (to-kebab s) → string (underscore → kebab-case)
;;
;; === Logging ===
;; (log-info msg) → void (console.log with prefix)
;; (log-parse-error label text err) → void (diagnostic parse error)
;;
;; === JSON parsing ===
;; (parse-and-load-style-dict text) → void (JSON.parse + load-style-dict)
;;
;; === Processing markers ===
;; (mark-processed! el key) → void
;; (is-processed? el key) → boolean
;; --------------------------------------------------------------------------

File diff suppressed because it is too large Load Diff

314
shared/sx/ref/cssx.sx Normal file
View File

@@ -0,0 +1,314 @@
;; ==========================================================================
;; cssx.sx — On-demand CSS style dictionary
;;
;; Resolves keyword atoms (e.g. :flex, :gap-4, :hover:bg-sky-200) into
;; StyleValue objects with content-addressed class names. CSS rules are
;; injected into the document on first use.
;;
;; The style dictionary is loaded from a JSON blob (typically served
;; inline in a <script type="text/sx-styles"> tag) containing:
;; a — atom → CSS declarations map
;; v — pseudo-variant → CSS pseudo-selector map
;; b — responsive breakpoint → media query map
;; k — keyframe name → @keyframes rule map
;; p — arbitrary patterns: [[regex, template], ...]
;; c — child selector prefixes: ["space-x-", "space-y-", ...]
;;
;; Depends on:
;; render.sx — StyleValue type
;; ==========================================================================
;; --------------------------------------------------------------------------
;; State — populated by load-style-dict
;; --------------------------------------------------------------------------
(define _style-atoms (dict))
(define _pseudo-variants (dict))
(define _responsive-breakpoints (dict))
(define _style-keyframes (dict))
(define _arbitrary-patterns (list))
(define _child-selector-prefixes (list))
(define _style-cache (dict))
(define _injected-styles (dict))
;; --------------------------------------------------------------------------
;; Load style dictionary from parsed JSON data
;; --------------------------------------------------------------------------
(define load-style-dict
(fn (data)
(set! _style-atoms (or (get data "a") (dict)))
(set! _pseudo-variants (or (get data "v") (dict)))
(set! _responsive-breakpoints (or (get data "b") (dict)))
(set! _style-keyframes (or (get data "k") (dict)))
(set! _child-selector-prefixes (or (get data "c") (list)))
;; Compile arbitrary patterns from [regex, template] pairs
(set! _arbitrary-patterns
(map
(fn (pair)
(dict "re" (compile-regex (str "^" (first pair) "$"))
"tmpl" (nth pair 1)))
(or (get data "p") (list))))
;; Clear cache on reload
(set! _style-cache (dict))))
;; --------------------------------------------------------------------------
;; Variant splitting
;; --------------------------------------------------------------------------
(define split-variant
(fn (atom)
;; Parse variant prefixes: "sm:hover:bg-sky-200" → ["sm:hover", "bg-sky-200"]
;; Returns [variant, base] where variant is nil for no variant.
;; Check responsive prefix first
(let ((result nil))
(for-each
(fn (bp)
(when (nil? result)
(let ((prefix (str bp ":")))
(when (starts-with? atom prefix)
(let ((rest-atom (slice atom (len prefix))))
;; Check for compound variant (sm:hover:...)
(let ((inner-match nil))
(for-each
(fn (pv)
(when (nil? inner-match)
(let ((inner-prefix (str pv ":")))
(when (starts-with? rest-atom inner-prefix)
(set! inner-match
(list (str bp ":" pv)
(slice rest-atom (len inner-prefix))))))))
(keys _pseudo-variants))
(set! result
(or inner-match (list bp rest-atom)))))))))
(keys _responsive-breakpoints))
(when (nil? result)
;; Check pseudo variants
(for-each
(fn (pv)
(when (nil? result)
(let ((prefix (str pv ":")))
(when (starts-with? atom prefix)
(set! result (list pv (slice atom (len prefix))))))))
(keys _pseudo-variants)))
(or result (list nil atom)))))
;; --------------------------------------------------------------------------
;; Atom resolution
;; --------------------------------------------------------------------------
(define resolve-atom
(fn (atom)
;; Look up atom → CSS declarations string, or nil
(let ((decls (dict-get _style-atoms atom)))
(if (not (nil? decls))
decls
;; Dynamic keyframes: animate-{name}
(if (starts-with? atom "animate-")
(let ((kf-name (slice atom 8)))
(if (dict-has? _style-keyframes kf-name)
(str "animation-name:" kf-name)
nil))
;; Try arbitrary patterns
(let ((match-result nil))
(for-each
(fn (pat)
(when (nil? match-result)
(let ((m (regex-match (get pat "re") atom)))
(when m
(set! match-result
(regex-replace-groups (get pat "tmpl") m))))))
_arbitrary-patterns)
match-result))))))
;; --------------------------------------------------------------------------
;; Child selector detection
;; --------------------------------------------------------------------------
(define is-child-selector-atom?
(fn (atom)
(some
(fn (prefix) (starts-with? atom prefix))
_child-selector-prefixes)))
;; --------------------------------------------------------------------------
;; FNV-1a 32-bit hash → 6 hex chars
;; --------------------------------------------------------------------------
(define hash-style
(fn (input)
;; FNV-1a 32-bit hash for content-addressed class names
(fnv1a-hash input)))
;; --------------------------------------------------------------------------
;; Full style resolution pipeline
;; --------------------------------------------------------------------------
(define resolve-style
(fn (atoms)
;; Resolve a list of atom strings into a StyleValue.
;; Uses content-addressed caching.
(let ((key (join "\0" atoms)))
(let ((cached (dict-get _style-cache key)))
(if (not (nil? cached))
cached
;; Resolve each atom
(let ((base-decls (list))
(media-rules (list))
(pseudo-rules (list))
(kf-needed (list)))
(for-each
(fn (a)
(when a
(let ((clean (if (starts-with? a ":") (slice a 1) a)))
(let ((parts (split-variant clean)))
(let ((variant (first parts))
(base (nth parts 1))
(decls (resolve-atom base)))
(when decls
;; Check keyframes
(when (starts-with? base "animate-")
(let ((kf-name (slice base 8)))
(when (dict-has? _style-keyframes kf-name)
(append! kf-needed
(list kf-name (dict-get _style-keyframes kf-name))))))
(cond
(nil? variant)
(append! base-decls decls)
(dict-has? _responsive-breakpoints variant)
(append! media-rules
(list (dict-get _responsive-breakpoints variant) decls))
(dict-has? _pseudo-variants variant)
(append! pseudo-rules
(list (dict-get _pseudo-variants variant) decls))
;; Compound variant: "sm:hover"
:else
(let ((vparts (split variant ":"))
(media-part nil)
(pseudo-part nil))
(for-each
(fn (vp)
(cond
(dict-has? _responsive-breakpoints vp)
(set! media-part (dict-get _responsive-breakpoints vp))
(dict-has? _pseudo-variants vp)
(set! pseudo-part (dict-get _pseudo-variants vp))))
vparts)
(when media-part
(append! media-rules (list media-part decls)))
(when pseudo-part
(append! pseudo-rules (list pseudo-part decls)))
(when (and (nil? media-part) (nil? pseudo-part))
(append! base-decls decls))))))))))
atoms)
;; Build hash input
(let ((hash-input (join ";" base-decls)))
(for-each
(fn (mr)
(set! hash-input
(str hash-input "@" (first mr) "{" (nth mr 1) "}")))
(chunk-every media-rules 2))
(for-each
(fn (pr)
(set! hash-input
(str hash-input (first pr) "{" (nth pr 1) "}")))
(chunk-every pseudo-rules 2))
(for-each
(fn (kf)
(set! hash-input (str hash-input (nth kf 1))))
(chunk-every kf-needed 2))
(let ((cn (str "sx-" (hash-style hash-input)))
(sv (make-style-value cn
(join ";" base-decls)
(chunk-every media-rules 2)
(chunk-every pseudo-rules 2)
(chunk-every kf-needed 2))))
(dict-set! _style-cache key sv)
;; Inject CSS rules
(inject-style-value sv atoms)
sv))))))))
;; --------------------------------------------------------------------------
;; Merge multiple StyleValues
;; --------------------------------------------------------------------------
(define merge-style-values
(fn (styles)
(if (= (len styles) 1)
(first styles)
(let ((all-decls (list))
(all-media (list))
(all-pseudo (list))
(all-kf (list)))
(for-each
(fn (sv)
(when (style-value-declarations sv)
(append! all-decls (style-value-declarations sv)))
(set! all-media (concat all-media (style-value-media-rules sv)))
(set! all-pseudo (concat all-pseudo (style-value-pseudo-rules sv)))
(set! all-kf (concat all-kf (style-value-keyframes sv))))
styles)
(let ((hash-input (join ";" all-decls)))
(for-each
(fn (mr)
(set! hash-input
(str hash-input "@" (first mr) "{" (nth mr 1) "}")))
all-media)
(for-each
(fn (pr)
(set! hash-input
(str hash-input (first pr) "{" (nth pr 1) "}")))
all-pseudo)
(for-each
(fn (kf)
(set! hash-input (str hash-input (nth kf 1))))
all-kf)
(let ((cn (str "sx-" (hash-style hash-input)))
(merged (make-style-value cn
(join ";" all-decls)
all-media all-pseudo all-kf)))
(inject-style-value merged (list))
merged))))))
;; --------------------------------------------------------------------------
;; Platform interface — CSSX
;; --------------------------------------------------------------------------
;;
;; Hash:
;; (fnv1a-hash input) → 6-char hex string (FNV-1a 32-bit)
;;
;; Regex:
;; (compile-regex pattern) → compiled regex object
;; (regex-match re str) → match array or nil
;; (regex-replace-groups tmpl match) → string with {0},{1},... replaced
;;
;; StyleValue construction:
;; (make-style-value cn decls media pseudo kf) → StyleValue object
;; (style-value-declarations sv) → declarations string
;; (style-value-media-rules sv) → list of [query, decls] pairs
;; (style-value-pseudo-rules sv) → list of [selector, decls] pairs
;; (style-value-keyframes sv) → list of [name, rule] pairs
;;
;; CSS injection:
;; (inject-style-value sv atoms) → void (append CSS rules to <style id="sx-css">)
;; --------------------------------------------------------------------------

664
shared/sx/ref/engine.sx Normal file
View File

@@ -0,0 +1,664 @@
;; ==========================================================================
;; engine.sx — SxEngine pure logic
;;
;; Fetch/swap/history engine for browser-side SX. Like HTMX but native
;; to the SX rendering pipeline.
;;
;; This file specifies the pure LOGIC of the engine in s-expressions:
;; parsing trigger specs, morph algorithm, swap dispatch, header building,
;; retry logic, target resolution, etc.
;;
;; Orchestration (binding events, executing requests, processing elements)
;; lives in orchestration.sx, which depends on this file.
;;
;; Depends on:
;; adapter-dom.sx — render-to-dom (for SX response rendering)
;; render.sx — shared registries
;; ==========================================================================
;; --------------------------------------------------------------------------
;; Constants
;; --------------------------------------------------------------------------
(define ENGINE_VERBS (list "get" "post" "put" "delete" "patch"))
(define DEFAULT_SWAP "outerHTML")
;; --------------------------------------------------------------------------
;; Trigger parsing
;; --------------------------------------------------------------------------
;; Parses the sx-trigger attribute value into a list of trigger descriptors.
;; Each descriptor is a dict with "event" and "modifiers" keys.
(define parse-time
(fn (s)
;; Parse time string: "2s" → 2000, "500ms" → 500
(cond
(nil? s) 0
(ends-with? s "ms") (parse-int s 0)
(ends-with? s "s") (* (parse-int (replace s "s" "") 0) 1000)
:else (parse-int s 0))))
(define parse-trigger-spec
(fn (spec)
;; Parse "click delay:500ms once,change" → list of trigger descriptors
(if (nil? spec)
nil
(let ((raw-parts (split spec ",")))
(filter
(fn (x) (not (nil? x)))
(map
(fn (part)
(let ((tokens (split (trim part) " ")))
(if (empty? tokens)
nil
(if (and (= (first tokens) "every") (>= (len tokens) 2))
;; Polling trigger
(dict
"event" "every"
"modifiers" (dict "interval" (parse-time (nth tokens 1))))
;; Normal trigger with optional modifiers
(let ((mods (dict)))
(for-each
(fn (tok)
(cond
(= tok "once")
(dict-set! mods "once" true)
(= tok "changed")
(dict-set! mods "changed" true)
(starts-with? tok "delay:")
(dict-set! mods "delay"
(parse-time (slice tok 6)))
(starts-with? tok "from:")
(dict-set! mods "from"
(slice tok 5))))
(rest tokens))
(dict "event" (first tokens) "modifiers" mods))))))
raw-parts))))))
(define default-trigger
(fn (tag-name)
;; Default trigger for element type
(cond
(= tag-name "FORM")
(list (dict "event" "submit" "modifiers" (dict)))
(or (= tag-name "INPUT")
(= tag-name "SELECT")
(= tag-name "TEXTAREA"))
(list (dict "event" "change" "modifiers" (dict)))
:else
(list (dict "event" "click" "modifiers" (dict))))))
;; --------------------------------------------------------------------------
;; Verb extraction
;; --------------------------------------------------------------------------
(define get-verb-info
(fn (el)
;; Check element for sx-get, sx-post, etc. Returns (dict "method" "url") or nil.
(some
(fn (verb)
(let ((url (dom-get-attr el (str "sx-" verb))))
(if url
(dict "method" (upper verb) "url" url)
nil)))
ENGINE_VERBS)))
;; --------------------------------------------------------------------------
;; Request header building
;; --------------------------------------------------------------------------
(define build-request-headers
(fn (el loaded-components css-hash)
;; Build the SX request headers dict
(let ((headers (dict
"SX-Request" "true"
"SX-Current-URL" (browser-location-href))))
;; Target selector
(let ((target-sel (dom-get-attr el "sx-target")))
(when target-sel
(dict-set! headers "SX-Target" target-sel)))
;; Loaded component names
(when (not (empty? loaded-components))
(dict-set! headers "SX-Components"
(join "," loaded-components)))
;; CSS class hash
(when css-hash
(dict-set! headers "SX-Css" css-hash))
;; Extra headers from sx-headers attribute
(let ((extra-h (dom-get-attr el "sx-headers")))
(when extra-h
(let ((parsed (parse-header-value extra-h)))
(when parsed
(for-each
(fn (key) (dict-set! headers key (str (get parsed key))))
(keys parsed))))))
headers)))
;; --------------------------------------------------------------------------
;; Response header processing
;; --------------------------------------------------------------------------
(define process-response-headers
(fn (get-header)
;; Extract all SX response header directives into a dict.
;; get-header is (fn (name) → string or nil).
(dict
"redirect" (get-header "SX-Redirect")
"refresh" (get-header "SX-Refresh")
"trigger" (get-header "SX-Trigger")
"retarget" (get-header "SX-Retarget")
"reswap" (get-header "SX-Reswap")
"location" (get-header "SX-Location")
"replace-url" (get-header "SX-Replace-Url")
"css-hash" (get-header "SX-Css-Hash")
"trigger-swap" (get-header "SX-Trigger-After-Swap")
"trigger-settle" (get-header "SX-Trigger-After-Settle")
"content-type" (get-header "Content-Type"))))
;; --------------------------------------------------------------------------
;; Swap specification parsing
;; --------------------------------------------------------------------------
(define parse-swap-spec
(fn (raw-swap global-transitions?)
;; Parse "innerHTML transition:true" → dict with style + transition flag
(let ((parts (split (or raw-swap DEFAULT_SWAP) " "))
(style (first parts))
(use-transition global-transitions?))
(for-each
(fn (p)
(cond
(= p "transition:true") (set! use-transition true)
(= p "transition:false") (set! use-transition false)))
(rest parts))
(dict "style" style "transition" use-transition))))
;; --------------------------------------------------------------------------
;; Retry logic
;; --------------------------------------------------------------------------
(define parse-retry-spec
(fn (retry-attr)
;; Parse "exponential:1000:30000" → spec dict or nil
(if (nil? retry-attr)
nil
(let ((parts (split retry-attr ":")))
(dict
"strategy" (first parts)
"start-ms" (parse-int (nth parts 1) 1000)
"cap-ms" (parse-int (nth parts 2) 30000))))))
(define next-retry-ms
(fn (current-ms cap-ms)
;; Exponential backoff: double current, cap at max
(min (* current-ms 2) cap-ms)))
;; --------------------------------------------------------------------------
;; Form parameter filtering
;; --------------------------------------------------------------------------
(define filter-params
(fn (params-spec all-params)
;; Filter form parameters by sx-params spec.
;; all-params is a list of (key value) pairs.
;; Returns filtered list of (key value) pairs.
(cond
(nil? params-spec) all-params
(= params-spec "none") (list)
(= params-spec "*") all-params
(starts-with? params-spec "not ")
(let ((excluded (map trim (split (slice params-spec 4) ","))))
(filter
(fn (p) (not (contains? excluded (first p))))
all-params))
:else
(let ((allowed (map trim (split params-spec ","))))
(filter
(fn (p) (contains? allowed (first p)))
all-params)))))
;; --------------------------------------------------------------------------
;; Target resolution
;; --------------------------------------------------------------------------
(define resolve-target
(fn (el)
;; Resolve the swap target for an element
(let ((sel (dom-get-attr el "sx-target")))
(cond
(or (nil? sel) (= sel "this")) el
(= sel "closest") (dom-parent el)
:else (dom-query sel)))))
;; --------------------------------------------------------------------------
;; Optimistic updates
;; --------------------------------------------------------------------------
(define apply-optimistic
(fn (el)
;; Apply optimistic update preview. Returns state for reverting, or nil.
(let ((directive (dom-get-attr el "sx-optimistic")))
(if (nil? directive)
nil
(let ((target (or (resolve-target el) el))
(state (dict "target" target "directive" directive)))
(cond
(= directive "remove")
(do
(dict-set! state "opacity" (dom-get-style target "opacity"))
(dom-set-style target "opacity" "0")
(dom-set-style target "pointer-events" "none"))
(= directive "disable")
(do
(dict-set! state "disabled" (dom-get-prop target "disabled"))
(dom-set-prop target "disabled" true))
(starts-with? directive "add-class:")
(let ((cls (slice directive 10)))
(dict-set! state "add-class" cls)
(dom-add-class target cls)))
state)))))
(define revert-optimistic
(fn (state)
;; Revert an optimistic update
(when state
(let ((target (get state "target"))
(directive (get state "directive")))
(cond
(= directive "remove")
(do
(dom-set-style target "opacity" (or (get state "opacity") ""))
(dom-set-style target "pointer-events" ""))
(= directive "disable")
(dom-set-prop target "disabled" (or (get state "disabled") false))
(get state "add-class")
(dom-remove-class target (get state "add-class")))))))
;; --------------------------------------------------------------------------
;; Out-of-band swap identification
;; --------------------------------------------------------------------------
(define find-oob-swaps
(fn (container)
;; Find elements marked for out-of-band swapping.
;; Returns list of (dict "element" el "swap-type" type "target-id" id).
(let ((results (list)))
(for-each
(fn (attr)
(let ((oob-els (dom-query-all container (str "[" attr "]"))))
(for-each
(fn (oob)
(let ((swap-type (or (dom-get-attr oob attr) "outerHTML"))
(target-id (dom-id oob)))
(dom-remove-attr oob attr)
(when target-id
(append! results
(dict "element" oob
"swap-type" swap-type
"target-id" target-id)))))
oob-els)))
(list "sx-swap-oob" "hx-swap-oob"))
results)))
;; --------------------------------------------------------------------------
;; DOM morph algorithm
;; --------------------------------------------------------------------------
;; Lightweight reconciler: patches oldNode to match newNode in-place,
;; preserving event listeners, focus, scroll position, and form state
;; on keyed (id) elements.
(define morph-node
(fn (old-node new-node)
;; Morph old-node to match new-node, preserving listeners/state.
(cond
;; sx-preserve / sx-ignore → skip
(or (dom-has-attr? old-node "sx-preserve")
(dom-has-attr? old-node "sx-ignore"))
nil
;; Different node type or tag → replace wholesale
(or (not (= (dom-node-type old-node) (dom-node-type new-node)))
(not (= (dom-node-name old-node) (dom-node-name new-node))))
(dom-replace-child (dom-parent old-node)
(dom-clone new-node) old-node)
;; Text/comment nodes → update content
(or (= (dom-node-type old-node) 3) (= (dom-node-type old-node) 8))
(when (not (= (dom-text-content old-node) (dom-text-content new-node)))
(dom-set-text-content old-node (dom-text-content new-node)))
;; Element nodes → sync attributes, then recurse children
(= (dom-node-type old-node) 1)
(do
(sync-attrs old-node new-node)
;; Skip morphing focused input to preserve user's in-progress edits
(when (not (and (dom-is-active-element? old-node)
(dom-is-input-element? old-node)))
(morph-children old-node new-node))))))
(define sync-attrs
(fn (old-el new-el)
;; Add/update attributes from new, remove those not in new
(for-each
(fn (attr)
(let ((name (first attr))
(val (nth attr 1)))
(when (not (= (dom-get-attr old-el name) val))
(dom-set-attr old-el name val))))
(dom-attr-list new-el))
(for-each
(fn (attr)
(when (not (dom-has-attr? new-el (first attr)))
(dom-remove-attr old-el (first attr))))
(dom-attr-list old-el))))
(define morph-children
(fn (old-parent new-parent)
;; Reconcile children of old-parent to match new-parent.
;; Keyed elements (with id) are matched and moved in-place.
(let ((old-kids (dom-child-list old-parent))
(new-kids (dom-child-list new-parent))
;; Build ID map of old children for keyed matching
(old-by-id (reduce
(fn (acc kid)
(let ((id (dom-id kid)))
(if id (do (dict-set! acc id kid) acc) acc)))
(dict) old-kids))
(oi 0))
;; Walk new children, morph/insert/append
(for-each
(fn (new-child)
(let ((match-id (dom-id new-child))
(match-by-id (if match-id (dict-get old-by-id match-id) nil)))
(cond
;; Keyed match — move into position if needed, then morph
(and match-by-id (not (nil? match-by-id)))
(do
(when (and (< oi (len old-kids))
(not (= match-by-id (nth old-kids oi))))
(dom-insert-before old-parent match-by-id
(if (< oi (len old-kids)) (nth old-kids oi) nil)))
(morph-node match-by-id new-child)
(set! oi (inc oi)))
;; Positional match
(< oi (len old-kids))
(let ((old-child (nth old-kids oi)))
(if (and (dom-id old-child) (not match-id))
;; Old has ID, new doesn't — insert new before old
(dom-insert-before old-parent
(dom-clone new-child) old-child)
;; Normal positional morph
(do
(morph-node old-child new-child)
(set! oi (inc oi)))))
;; Extra new children — append
:else
(dom-append old-parent (dom-clone new-child)))))
new-kids)
;; Remove leftover old children
(for-each
(fn (i)
(when (>= i oi)
(let ((leftover (nth old-kids i)))
(when (and (dom-is-child-of? leftover old-parent)
(not (dom-has-attr? leftover "sx-preserve"))
(not (dom-has-attr? leftover "sx-ignore")))
(dom-remove-child old-parent leftover)))))
(range oi (len old-kids))))))
;; --------------------------------------------------------------------------
;; Swap dispatch
;; --------------------------------------------------------------------------
(define swap-dom-nodes
(fn (target new-nodes strategy)
;; Execute a swap strategy on live DOM nodes.
;; new-nodes is typically a DocumentFragment or Element.
(case strategy
"innerHTML"
(if (dom-is-fragment? new-nodes)
(morph-children target new-nodes)
(let ((wrapper (dom-create-element "div" nil)))
(dom-append wrapper new-nodes)
(morph-children target wrapper)))
"outerHTML"
(let ((parent (dom-parent target)))
(if (dom-is-fragment? new-nodes)
;; Fragment — morph first child, insert rest
(let ((fc (dom-first-child new-nodes)))
(if fc
(do
(morph-node target fc)
;; Insert remaining siblings after morphed element
(let ((sib (dom-next-sibling fc)))
(insert-remaining-siblings parent target sib)))
(dom-remove-child parent target)))
(morph-node target new-nodes))
parent)
"afterend"
(dom-insert-after target new-nodes)
"beforeend"
(dom-append target new-nodes)
"afterbegin"
(dom-prepend target new-nodes)
"beforebegin"
(dom-insert-before (dom-parent target) new-nodes target)
"delete"
(dom-remove-child (dom-parent target) target)
"none"
nil
;; Default = innerHTML
:else
(if (dom-is-fragment? new-nodes)
(morph-children target new-nodes)
(let ((wrapper (dom-create-element "div" nil)))
(dom-append wrapper new-nodes)
(morph-children target wrapper))))))
(define insert-remaining-siblings
(fn (parent ref-node sib)
;; Insert sibling chain after ref-node
(when sib
(let ((next (dom-next-sibling sib)))
(dom-insert-after ref-node sib)
(insert-remaining-siblings parent sib next)))))
;; --------------------------------------------------------------------------
;; String-based swap (fallback for HTML responses)
;; --------------------------------------------------------------------------
(define swap-html-string
(fn (target html strategy)
;; Execute a swap strategy using an HTML string (DOMParser pipeline).
(case strategy
"innerHTML"
(dom-set-inner-html target html)
"outerHTML"
(let ((parent (dom-parent target)))
(dom-insert-adjacent-html target "afterend" html)
(dom-remove-child parent target)
parent)
"afterend"
(dom-insert-adjacent-html target "afterend" html)
"beforeend"
(dom-insert-adjacent-html target "beforeend" html)
"afterbegin"
(dom-insert-adjacent-html target "afterbegin" html)
"beforebegin"
(dom-insert-adjacent-html target "beforebegin" html)
"delete"
(dom-remove-child (dom-parent target) target)
"none"
nil
:else
(dom-set-inner-html target html))))
;; --------------------------------------------------------------------------
;; History management
;; --------------------------------------------------------------------------
(define handle-history
(fn (el url resp-headers)
;; Process history push/replace based on element attrs and response headers
(let ((push-url (dom-get-attr el "sx-push-url"))
(replace-url (dom-get-attr el "sx-replace-url"))
(hdr-replace (get resp-headers "replace-url")))
(cond
;; Server override
hdr-replace
(browser-replace-state hdr-replace)
;; Client push
(and push-url (not (= push-url "false")))
(browser-push-state
(if (= push-url "true") url push-url))
;; Client replace
(and replace-url (not (= replace-url "false")))
(browser-replace-state
(if (= replace-url "true") url replace-url))))))
;; --------------------------------------------------------------------------
;; Preload cache
;; --------------------------------------------------------------------------
(define PRELOAD_TTL 30000) ;; 30 seconds
(define preload-cache-get
(fn (cache url)
;; Get and consume a cached preload response.
;; Returns (dict "text" ... "content-type" ...) or nil.
(let ((entry (dict-get cache url)))
(if (nil? entry)
nil
(if (> (- (now-ms) (get entry "timestamp")) PRELOAD_TTL)
(do (dict-delete! cache url) nil)
(do (dict-delete! cache url) entry))))))
(define preload-cache-set
(fn (cache url text content-type)
;; Store a preloaded response
(dict-set! cache url
(dict "text" text "content-type" content-type "timestamp" (now-ms)))))
;; --------------------------------------------------------------------------
;; Trigger dispatch table
;; --------------------------------------------------------------------------
;; Maps trigger event names to binding strategies.
;; This is the logic; actual browser event binding is platform interface.
(define classify-trigger
(fn (trigger)
;; Classify a parsed trigger descriptor for binding.
;; Returns one of: "poll", "intersect", "load", "revealed", "event"
(let ((event (get trigger "event")))
(cond
(= event "every") "poll"
(= event "intersect") "intersect"
(= event "load") "load"
(= event "revealed") "revealed"
:else "event"))))
;; --------------------------------------------------------------------------
;; Boost logic
;; --------------------------------------------------------------------------
(define should-boost-link?
(fn (link)
;; Whether a link inside an sx-boost container should be boosted
(let ((href (dom-get-attr link "href")))
(and href
(not (starts-with? href "#"))
(not (starts-with? href "javascript:"))
(not (starts-with? href "mailto:"))
(browser-same-origin? href)
(not (dom-has-attr? link "sx-get"))
(not (dom-has-attr? link "sx-post"))
(not (dom-has-attr? link "sx-disable"))))))
(define should-boost-form?
(fn (form)
;; Whether a form inside an sx-boost container should be boosted
(and (not (dom-has-attr? form "sx-get"))
(not (dom-has-attr? form "sx-post"))
(not (dom-has-attr? form "sx-disable")))))
;; --------------------------------------------------------------------------
;; SSE event classification
;; --------------------------------------------------------------------------
(define parse-sse-swap
(fn (el)
;; Parse sx-sse-swap attribute
;; Returns event name to listen for (default "message")
(or (dom-get-attr el "sx-sse-swap") "message")))
;; --------------------------------------------------------------------------
;; Platform interface — Engine (pure logic)
;; --------------------------------------------------------------------------
;;
;; From adapter-dom.sx:
;; dom-get-attr, dom-set-attr, dom-remove-attr, dom-has-attr?, dom-attr-list
;; dom-query, dom-query-all, dom-id, dom-parent, dom-first-child,
;; dom-next-sibling, dom-child-list, dom-node-type, dom-node-name,
;; dom-text-content, dom-set-text-content, dom-is-fragment?,
;; dom-is-child-of?, dom-is-active-element?, dom-is-input-element?,
;; dom-create-element, dom-append, dom-prepend, dom-insert-before,
;; dom-insert-after, dom-remove-child, dom-replace-child, dom-clone,
;; dom-get-style, dom-set-style, dom-get-prop, dom-set-prop,
;; dom-add-class, dom-remove-class, dom-set-inner-html,
;; dom-insert-adjacent-html
;;
;; Browser/Network:
;; (browser-location-href) → current URL string
;; (browser-same-origin? url) → boolean
;; (browser-push-state url) → void (history.pushState)
;; (browser-replace-state url) → void (history.replaceState)
;;
;; Parsing:
;; (parse-header-value s) → parsed dict from header string
;; (now-ms) → current timestamp in milliseconds
;; --------------------------------------------------------------------------

770
shared/sx/ref/eval.sx Normal file
View File

@@ -0,0 +1,770 @@
;; ==========================================================================
;; eval.sx — Reference SX evaluator written in SX
;;
;; This is the canonical specification of SX evaluation semantics.
;; A thin bootstrap compiler per target reads this file and emits
;; a native evaluator (JavaScript, Python, Rust, etc.).
;;
;; The evaluator is written in a restricted subset of SX:
;; - defcomp, define, defmacro, lambda/fn
;; - if, when, cond, case, let, do, and, or
;; - map, filter, reduce, some, every?
;; - Primitives: list ops, string ops, arithmetic, predicates
;; - quote, quasiquote/unquote/splice-unquote
;; - Pattern matching via (case (type-of expr) ...)
;;
;; Platform-specific concerns (DOM rendering, async I/O, HTML emission)
;; are declared as interfaces — each target provides its own adapter.
;; ==========================================================================
;; --------------------------------------------------------------------------
;; 1. Types
;; --------------------------------------------------------------------------
;;
;; The evaluator operates on these value types:
;;
;; number — integer or float
;; string — double-quoted text
;; boolean — true / false
;; nil — singleton null
;; symbol — unquoted identifier (e.g. div, ~card, map)
;; keyword — colon-prefixed key (e.g. :class, :id)
;; list — ordered sequence (also used as code)
;; dict — string-keyed hash map
;; lambda — closure: {params, body, closure-env, name?}
;; macro — AST transformer: {params, rest-param, body, closure-env}
;; component — UI component: {name, params, has-children, body, closure-env}
;; thunk — deferred eval for TCO: {expr, env}
;;
;; Each target must provide:
;; (type-of x) → one of the strings above
;; (make-lambda ...) → platform Lambda value
;; (make-component ..) → platform Component value
;; (make-macro ...) → platform Macro value
;; (make-thunk ...) → platform Thunk value
;;
;; These are declared in platform.sx and implemented per target.
;; --------------------------------------------------------------------------
;; --------------------------------------------------------------------------
;; 2. Trampoline — tail-call optimization
;; --------------------------------------------------------------------------
(define trampoline
(fn (val)
;; Iteratively resolve thunks until we get an actual value.
;; Each target implements thunk? and thunk-expr/thunk-env.
(let ((result val))
(do
;; Loop while result is a thunk
;; Note: this is pseudo-iteration — bootstrap compilers convert
;; this tail-recursive form to a while loop.
(if (thunk? result)
(trampoline (eval-expr (thunk-expr result) (thunk-env result)))
result)))))
;; --------------------------------------------------------------------------
;; 3. Core evaluator
;; --------------------------------------------------------------------------
(define eval-expr
(fn (expr env)
(case (type-of expr)
;; --- literals pass through ---
"number" expr
"string" expr
"boolean" expr
"nil" nil
;; --- symbol lookup ---
"symbol"
(let ((name (symbol-name expr)))
(cond
(env-has? env name) (env-get env name)
(primitive? name) (get-primitive name)
(= name "true") true
(= name "false") false
(= name "nil") nil
:else (error (str "Undefined symbol: " name))))
;; --- keyword → its string name ---
"keyword" (keyword-name expr)
;; --- dict literal ---
"dict"
(map-dict (fn (k v) (trampoline (eval-expr v env))) expr)
;; --- list = call or special form ---
"list"
(if (empty? expr)
(list)
(eval-list expr env))
;; --- anything else passes through ---
:else expr)))
;; --------------------------------------------------------------------------
;; 4. List evaluation — dispatch on head
;; --------------------------------------------------------------------------
(define eval-list
(fn (expr env)
(let ((head (first expr))
(args (rest expr)))
;; If head isn't a symbol, lambda, or list → treat as data list
(if (not (or (= (type-of head) "symbol")
(= (type-of head) "lambda")
(= (type-of head) "list")))
(map (fn (x) (trampoline (eval-expr x env))) expr)
;; Head is a symbol — check special forms, then function call
(if (= (type-of head) "symbol")
(let ((name (symbol-name head)))
(cond
;; Special forms
(= name "if") (sf-if args env)
(= name "when") (sf-when args env)
(= name "cond") (sf-cond args env)
(= name "case") (sf-case args env)
(= name "and") (sf-and args env)
(= name "or") (sf-or args env)
(= name "let") (sf-let args env)
(= name "let*") (sf-let args env)
(= name "lambda") (sf-lambda args env)
(= name "fn") (sf-lambda args env)
(= name "define") (sf-define args env)
(= name "defcomp") (sf-defcomp args env)
(= name "defmacro") (sf-defmacro args env)
(= name "defstyle") (sf-defstyle args env)
(= name "defkeyframes") (sf-defkeyframes args env)
(= name "defhandler") (sf-define args env)
(= name "begin") (sf-begin args env)
(= name "do") (sf-begin args env)
(= name "quote") (sf-quote args env)
(= name "quasiquote") (sf-quasiquote args env)
(= name "->") (sf-thread-first args env)
(= name "set!") (sf-set! args env)
;; Higher-order forms
(= name "map") (ho-map args env)
(= name "map-indexed") (ho-map-indexed args env)
(= name "filter") (ho-filter args env)
(= name "reduce") (ho-reduce args env)
(= name "some") (ho-some args env)
(= name "every?") (ho-every args env)
(= name "for-each") (ho-for-each args env)
;; Macro expansion
(and (env-has? env name) (macro? (env-get env name)))
(let ((mac (env-get env name)))
(make-thunk (expand-macro mac args env) env))
;; Render expression — delegate to active adapter
(is-render-expr? expr)
(render-expr expr env)
;; Fall through to function call
:else (eval-call head args env)))
;; Head is lambda or list — evaluate as function call
(eval-call head args env))))))
;; --------------------------------------------------------------------------
;; 5. Function / lambda / component call
;; --------------------------------------------------------------------------
(define eval-call
(fn (head args env)
(let ((f (trampoline (eval-expr head env)))
(evaluated-args (map (fn (a) (trampoline (eval-expr a env))) args)))
(cond
;; Native callable (primitive function)
(and (callable? f) (not (lambda? f)) (not (component? f)))
(apply f evaluated-args)
;; Lambda
(lambda? f)
(call-lambda f evaluated-args env)
;; Component
(component? f)
(call-component f args env)
:else (error (str "Not callable: " (inspect f)))))))
(define call-lambda
(fn (f args caller-env)
(let ((params (lambda-params f))
(local (env-merge (lambda-closure f) caller-env)))
(if (!= (len args) (len params))
(error (str (or (lambda-name f) "lambda")
" expects " (len params) " args, got " (len args)))
(do
;; Bind params
(for-each
(fn (pair) (env-set! local (first pair) (nth pair 1)))
(zip params args))
;; Return thunk for TCO
(make-thunk (lambda-body f) local))))))
(define call-component
(fn (comp raw-args env)
;; Parse keyword args and children from unevaluated arg list
(let ((parsed (parse-keyword-args raw-args env))
(kwargs (first parsed))
(children (nth parsed 1))
(local (env-merge (component-closure comp) env)))
;; Bind keyword params
(for-each
(fn (p) (env-set! local p (or (dict-get kwargs p) nil)))
(component-params comp))
;; Bind children if component accepts them
(when (component-has-children? comp)
(env-set! local "children" children))
;; Return thunk — body evaluated in local env
(make-thunk (component-body comp) local))))
(define parse-keyword-args
(fn (raw-args env)
;; Walk args: keyword + next-val → kwargs dict, else → children list
(let ((kwargs (dict))
(children (list))
(i 0))
;; Iterative parse — bootstrap converts to while loop
(reduce
(fn (state arg)
(let ((idx (get state "i"))
(skip (get state "skip")))
(if skip
;; This arg was consumed as a keyword value
(assoc state "skip" false "i" (inc idx))
(if (and (= (type-of arg) "keyword")
(< (inc idx) (len raw-args)))
;; Keyword: evaluate next arg and store
(do
(dict-set! kwargs (keyword-name arg)
(trampoline (eval-expr (nth raw-args (inc idx)) env)))
(assoc state "skip" true "i" (inc idx)))
;; Positional: evaluate and add to children
(do
(append! children (trampoline (eval-expr arg env)))
(assoc state "i" (inc idx)))))))
(dict "i" 0 "skip" false)
raw-args)
(list kwargs children))))
;; --------------------------------------------------------------------------
;; 6. Special forms
;; --------------------------------------------------------------------------
(define sf-if
(fn (args env)
(let ((condition (trampoline (eval-expr (first args) env))))
(if (and condition (not (nil? condition)))
(make-thunk (nth args 1) env)
(if (> (len args) 2)
(make-thunk (nth args 2) env)
nil)))))
(define sf-when
(fn (args env)
(let ((condition (trampoline (eval-expr (first args) env))))
(if (and condition (not (nil? condition)))
(do
;; Evaluate all but last for side effects
(for-each
(fn (e) (trampoline (eval-expr e env)))
(slice args 1 (dec (len args))))
;; Last is tail position
(make-thunk (last args) env))
nil))))
(define sf-cond
(fn (args env)
;; Detect scheme-style: first arg is a 2-element list
(if (and (= (type-of (first args)) "list")
(= (len (first args)) 2))
;; Scheme-style: ((test body) ...)
(sf-cond-scheme args env)
;; Clojure-style: test body test body ...
(sf-cond-clojure args env))))
(define sf-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")))
(make-thunk body env)
(if (trampoline (eval-expr test env))
(make-thunk body env)
(sf-cond-scheme (rest clauses) env)))))))
(define sf-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"))))
(make-thunk body env)
(if (trampoline (eval-expr test env))
(make-thunk body env)
(sf-cond-clojure (slice clauses 2) env)))))))
(define sf-case
(fn (args env)
(let ((match-val (trampoline (eval-expr (first args) env)))
(clauses (rest args)))
(sf-case-loop match-val clauses env))))
(define sf-case-loop
(fn (match-val 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"))))
(make-thunk body env)
(if (= match-val (trampoline (eval-expr test env)))
(make-thunk body env)
(sf-case-loop match-val (slice clauses 2) env)))))))
(define sf-and
(fn (args env)
(if (empty? args)
true
(let ((val (trampoline (eval-expr (first args) env))))
(if (not val)
val
(if (= (len args) 1)
val
(sf-and (rest args) env)))))))
(define sf-or
(fn (args env)
(if (empty? args)
false
(let ((val (trampoline (eval-expr (first args) env))))
(if val
val
(sf-or (rest args) env))))))
(define sf-let
(fn (args env)
(let ((bindings (first args))
(body (rest args))
(local (env-extend env)))
;; Parse bindings — support both ((name val) ...) and (name val name val ...)
(if (and (= (type-of (first bindings)) "list")
(= (len (first bindings)) 2))
;; Scheme-style
(for-each
(fn (binding)
(let ((vname (if (= (type-of (first binding)) "symbol")
(symbol-name (first binding))
(first binding))))
(env-set! local vname (trampoline (eval-expr (nth binding 1) local)))))
bindings)
;; Clojure-style
(let ((i 0))
(reduce
(fn (acc pair-idx)
(let ((vname (if (= (type-of (nth bindings (* pair-idx 2))) "symbol")
(symbol-name (nth bindings (* pair-idx 2)))
(nth bindings (* pair-idx 2))))
(val-expr (nth bindings (inc (* pair-idx 2)))))
(env-set! local vname (trampoline (eval-expr val-expr local)))))
nil
(range 0 (/ (len bindings) 2)))))
;; Evaluate body — last expression in tail position
(for-each
(fn (e) (trampoline (eval-expr e local)))
(slice body 0 (dec (len body))))
(make-thunk (last body) local))))
(define sf-lambda
(fn (args env)
(let ((params-expr (first args))
(body (nth args 1))
(param-names (map (fn (p)
(if (= (type-of p) "symbol")
(symbol-name p)
p))
params-expr)))
(make-lambda param-names body env))))
(define sf-define
(fn (args env)
(let ((name-sym (first args))
(value (trampoline (eval-expr (nth args 1) env))))
(when (and (lambda? value) (nil? (lambda-name value)))
(set-lambda-name! value (symbol-name name-sym)))
(env-set! env (symbol-name name-sym) value)
value)))
(define sf-defcomp
(fn (args env)
(let ((name-sym (first args))
(params-raw (nth args 1))
(body (nth args 2))
(comp-name (strip-prefix (symbol-name name-sym) "~"))
(parsed (parse-comp-params params-raw))
(params (first parsed))
(has-children (nth parsed 1)))
(let ((comp (make-component comp-name params has-children body env)))
(env-set! env (symbol-name name-sym) comp)
comp))))
(define parse-comp-params
(fn (params-expr)
;; Parse (&key param1 param2 &children) → (params has-children)
;; Also accepts &rest as synonym for &children.
(let ((params (list))
(has-children false)
(in-key false))
(for-each
(fn (p)
(when (= (type-of p) "symbol")
(let ((name (symbol-name p)))
(cond
(= name "&key") (set! in-key true)
(= name "&rest") (set! has-children true)
(= name "&children") (set! has-children true)
has-children nil ;; skip params after &children/&rest
in-key (append! params name)
:else (append! params name)))))
params-expr)
(list params has-children))))
(define sf-defmacro
(fn (args env)
(let ((name-sym (first args))
(params-raw (nth args 1))
(body (nth args 2))
(parsed (parse-macro-params params-raw))
(params (first parsed))
(rest-param (nth parsed 1)))
(let ((mac (make-macro params rest-param body env (symbol-name name-sym))))
(env-set! env (symbol-name name-sym) mac)
mac))))
(define parse-macro-params
(fn (params-expr)
;; Parse (a b &rest rest) → ((a b) rest)
(let ((params (list))
(rest-param nil))
(reduce
(fn (state p)
(if (and (= (type-of p) "symbol") (= (symbol-name p) "&rest"))
(assoc state "in-rest" true)
(if (get state "in-rest")
(do (set! rest-param (if (= (type-of p) "symbol")
(symbol-name p) p))
state)
(do (append! params (if (= (type-of p) "symbol")
(symbol-name p) p))
state))))
(dict "in-rest" false)
params-expr)
(list params rest-param))))
(define sf-defstyle
(fn (args env)
;; (defstyle name expr) — bind name to evaluated expr (typically a StyleValue)
(let ((name-sym (first args))
(value (trampoline (eval-expr (nth args 1) env))))
(env-set! env (symbol-name name-sym) value)
value)))
(define sf-defkeyframes
(fn (args env)
;; (defkeyframes name (selector body) ...) — build @keyframes rule,
;; register in keyframes dict, return StyleValue.
;; Delegates to platform: build-keyframes returns a StyleValue.
(let ((kf-name (symbol-name (first args)))
(steps (rest args)))
(build-keyframes kf-name steps env))))
(define sf-begin
(fn (args env)
(if (empty? args)
nil
(do
(for-each
(fn (e) (trampoline (eval-expr e env)))
(slice args 0 (dec (len args))))
(make-thunk (last args) env)))))
(define sf-quote
(fn (args env)
(if (empty? args) nil (first args))))
(define sf-quasiquote
(fn (args env)
(qq-expand (first args) env)))
(define qq-expand
(fn (template env)
(if (not (= (type-of template) "list"))
template
(if (empty? template)
(list)
(let ((head (first template)))
(if (and (= (type-of head) "symbol") (= (symbol-name head) "unquote"))
(trampoline (eval-expr (nth template 1) env))
;; Walk children, handling splice-unquote
(reduce
(fn (result item)
(if (and (= (type-of item) "list")
(= (len item) 2)
(= (type-of (first item)) "symbol")
(= (symbol-name (first item)) "splice-unquote"))
(let ((spliced (trampoline (eval-expr (nth item 1) env))))
(if (= (type-of spliced) "list")
(concat result spliced)
(if (nil? spliced) result (append result spliced))))
(append result (qq-expand item env))))
(list)
template)))))))
(define sf-thread-first
(fn (args env)
(let ((val (trampoline (eval-expr (first args) env))))
(reduce
(fn (result form)
(if (= (type-of form) "list")
(let ((f (trampoline (eval-expr (first form) env)))
(rest-args (map (fn (a) (trampoline (eval-expr a env)))
(rest form)))
(all-args (cons result rest-args)))
(cond
(and (callable? f) (not (lambda? f)))
(apply f all-args)
(lambda? f)
(trampoline (call-lambda f all-args env))
:else (error (str "-> form not callable: " (inspect f)))))
(let ((f (trampoline (eval-expr form env))))
(cond
(and (callable? f) (not (lambda? f)))
(f result)
(lambda? f)
(trampoline (call-lambda f (list result) env))
:else (error (str "-> form not callable: " (inspect f)))))))
val
(rest args)))))
(define sf-set!
(fn (args env)
(let ((name (symbol-name (first args)))
(value (trampoline (eval-expr (nth args 1) env))))
(env-set! env name value)
value)))
;; --------------------------------------------------------------------------
;; 6b. Macro expansion
;; --------------------------------------------------------------------------
(define expand-macro
(fn (mac raw-args env)
(let ((local (env-merge (macro-closure mac) env)))
;; Bind positional params (unevaluated)
(for-each
(fn (pair)
(env-set! local (first pair)
(if (< (nth pair 1) (len raw-args))
(nth raw-args (nth pair 1))
nil)))
(map-indexed (fn (i p) (list p i)) (macro-params mac)))
;; Bind &rest param
(when (macro-rest-param mac)
(env-set! local (macro-rest-param mac)
(slice raw-args (len (macro-params mac)))))
;; Evaluate body → new AST
(trampoline (eval-expr (macro-body mac) local)))))
;; --------------------------------------------------------------------------
;; 7. Higher-order forms
;; --------------------------------------------------------------------------
(define ho-map
(fn (args env)
(let ((f (trampoline (eval-expr (first args) env)))
(coll (trampoline (eval-expr (nth args 1) env))))
(map (fn (item) (trampoline (call-lambda f (list item) env))) coll))))
(define ho-map-indexed
(fn (args env)
(let ((f (trampoline (eval-expr (first args) env)))
(coll (trampoline (eval-expr (nth args 1) env))))
(map-indexed
(fn (i item) (trampoline (call-lambda f (list i item) env)))
coll))))
(define ho-filter
(fn (args env)
(let ((f (trampoline (eval-expr (first args) env)))
(coll (trampoline (eval-expr (nth args 1) env))))
(filter
(fn (item) (trampoline (call-lambda f (list item) env)))
coll))))
(define ho-reduce
(fn (args env)
(let ((f (trampoline (eval-expr (first args) env)))
(init (trampoline (eval-expr (nth args 1) env)))
(coll (trampoline (eval-expr (nth args 2) env))))
(reduce
(fn (acc item) (trampoline (call-lambda f (list acc item) env)))
init
coll))))
(define ho-some
(fn (args env)
(let ((f (trampoline (eval-expr (first args) env)))
(coll (trampoline (eval-expr (nth args 1) env))))
(some
(fn (item) (trampoline (call-lambda f (list item) env)))
coll))))
(define ho-every
(fn (args env)
(let ((f (trampoline (eval-expr (first args) env)))
(coll (trampoline (eval-expr (nth args 1) env))))
(every?
(fn (item) (trampoline (call-lambda f (list item) env)))
coll))))
(define ho-for-each
(fn (args env)
(let ((f (trampoline (eval-expr (first args) env)))
(coll (trampoline (eval-expr (nth args 1) env))))
(for-each
(fn (item) (trampoline (call-lambda f (list item) env)))
coll))))
;; --------------------------------------------------------------------------
;; 8. Primitives — pure functions available in all targets
;; --------------------------------------------------------------------------
;; These are the ~80 built-in functions. Each target implements them
;; natively but they MUST have identical semantics. This section serves
;; as the specification — bootstrap compilers use it for reference.
;;
;; Primitives are NOT defined here as SX lambdas (that would be circular).
;; Instead, this is a declarative registry that bootstrap compilers read.
;; --------------------------------------------------------------------------
;; See primitives.sx for the full specification.
;; --------------------------------------------------------------------------
;; 9. Platform interface — must be provided by each target
;; --------------------------------------------------------------------------
;;
;; Type inspection:
;; (type-of x) → "number" | "string" | "boolean" | "nil"
;; | "symbol" | "keyword" | "list" | "dict"
;; | "lambda" | "component" | "macro" | "thunk"
;; (symbol-name sym) → string
;; (keyword-name kw) → string
;;
;; Constructors:
;; (make-lambda params body env) → Lambda
;; (make-component name params has-children body env) → Component
;; (make-macro params rest-param body env name) → Macro
;; (make-thunk expr env) → Thunk
;;
;; Accessors:
;; (lambda-params f) → list of strings
;; (lambda-body f) → expr
;; (lambda-closure f) → env
;; (lambda-name f) → string or nil
;; (set-lambda-name! f n) → void
;; (component-params c) → list of strings
;; (component-body c) → expr
;; (component-closure c) → env
;; (component-has-children? c) → boolean
;; (macro-params m) → list of strings
;; (macro-rest-param m) → string or nil
;; (macro-body m) → expr
;; (macro-closure m) → env
;; (thunk? x) → boolean
;; (thunk-expr t) → expr
;; (thunk-env t) → env
;;
;; Predicates:
;; (callable? x) → boolean (native function or lambda)
;; (lambda? x) → boolean
;; (component? x) → boolean
;; (macro? x) → boolean
;; (primitive? name) → boolean (is name a registered primitive?)
;; (get-primitive name) → function
;;
;; Environment:
;; (env-has? env name) → boolean
;; (env-get env name) → value
;; (env-set! env name val) → void (mutating)
;; (env-extend env) → new env inheriting from env
;; (env-merge base overlay) → new env with overlay on top
;;
;; Mutation helpers (for parse-keyword-args):
;; (dict-set! d key val) → void
;; (dict-get d key) → value or nil
;; (append! lst val) → void (mutating append)
;;
;; Error:
;; (error msg) → raise/throw with message
;; (inspect x) → string representation for debugging
;;
;; Utility:
;; (strip-prefix s prefix) → string with prefix removed (or s unchanged)
;; (apply f args) → call f with args list
;; (zip lists...) → list of tuples
;;
;; CSSX (style system):
;; (build-keyframes name steps env) → StyleValue (platform builds @keyframes)
;; --------------------------------------------------------------------------

View File

@@ -0,0 +1,816 @@
;; ==========================================================================
;; orchestration.sx — Engine orchestration (browser wiring)
;;
;; Binds the pure engine logic to actual browser events, fetch, DOM
;; processing, and lifecycle management. This is the runtime that makes
;; the engine go.
;;
;; Dependency is one-way: orchestration → engine, never reverse.
;;
;; Depends on:
;; engine.sx — parse-trigger-spec, get-verb-info, build-request-headers,
;; process-response-headers, parse-swap-spec, parse-retry-spec,
;; next-retry-ms, resolve-target, apply-optimistic,
;; revert-optimistic, find-oob-swaps, swap-dom-nodes,
;; swap-html-string, morph-children, handle-history,
;; preload-cache-get, preload-cache-set, classify-trigger,
;; should-boost-link?, should-boost-form?, parse-sse-swap,
;; default-trigger, filter-params, PRELOAD_TTL
;; adapter-dom.sx — render-to-dom
;; render.sx — shared registries
;; ==========================================================================
;; --------------------------------------------------------------------------
;; Engine state
;; --------------------------------------------------------------------------
(define _preload-cache (dict))
(define _css-hash "")
;; --------------------------------------------------------------------------
;; Event dispatch helpers
;; --------------------------------------------------------------------------
(define dispatch-trigger-events
(fn (el header-val)
;; Dispatch events from SX-Trigger / SX-Trigger-After-Swap headers.
;; Value can be JSON object (name → detail) or comma-separated names.
(when header-val
(let ((parsed (try-parse-json header-val)))
(if parsed
;; JSON object: keys are event names, values are detail
(for-each
(fn (key)
(dom-dispatch el key (get parsed key)))
(keys parsed))
;; Comma-separated event names
(for-each
(fn (name)
(let ((trimmed (trim name)))
(when (not (empty? trimmed))
(dom-dispatch el trimmed (dict)))))
(split header-val ",")))))))
;; --------------------------------------------------------------------------
;; CSS tracking
;; --------------------------------------------------------------------------
(define init-css-tracking
(fn ()
;; Read initial CSS hash from meta tag
(let ((meta (dom-query "meta[name=\"sx-css-classes\"]")))
(when meta
(let ((content (dom-get-attr meta "content")))
(when content
(set! _css-hash content)))))))
;; --------------------------------------------------------------------------
;; Request execution
;; --------------------------------------------------------------------------
(define execute-request
(fn (el verbInfo extraParams)
;; Gate checks then delegate to do-fetch.
;; verbInfo: dict with "method" and "url" (or nil to read from element).
;; Re-read from element in case attributes were morphed since binding.
;; Returns a promise.
(let ((info (or (get-verb-info el) verbInfo)))
(if (nil? info)
(promise-resolve nil)
(let ((verb (get info "method"))
(url (get info "url")))
;; Media query gate
(if (let ((media (dom-get-attr el "sx-media")))
(and media (not (browser-media-matches? media))))
(promise-resolve nil)
;; Confirm gate
(if (let ((confirm-msg (dom-get-attr el "sx-confirm")))
(and confirm-msg (not (browser-confirm confirm-msg))))
(promise-resolve nil)
;; Prompt
(let ((prompt-msg (dom-get-attr el "sx-prompt"))
(prompt-val (if prompt-msg (browser-prompt prompt-msg) nil)))
(if (and prompt-msg (nil? prompt-val))
(promise-resolve nil)
;; Validation gate
(if (not (validate-for-request el))
(promise-resolve nil)
(do-fetch el verb verb url
(if prompt-val
(assoc (or extraParams (dict)) "SX-Prompt" prompt-val)
extraParams))))))))))))
(define do-fetch
(fn (el verb method url extraParams)
;; Execute the actual fetch. Manages abort, headers, body, loading state.
(let ((sync (dom-get-attr el "sx-sync")))
;; Abort previous if sync mode
(when (= sync "replace")
(abort-previous el))
(let ((ctrl (new-abort-controller)))
(track-controller el ctrl)
;; Build request
(let ((body-info (build-request-body el method url))
(final-url (get body-info "url"))
(body (get body-info "body"))
(ct (get body-info "content-type"))
(headers (build-request-headers el
(loaded-component-names) _css-hash))
(csrf (csrf-token)))
;; Merge extra params as headers
(when extraParams
(for-each
(fn (k) (dict-set! headers k (get extraParams k)))
(keys extraParams)))
;; Content-Type
(when ct
(dict-set! headers "Content-Type" ct))
;; CSRF
(when csrf
(dict-set! headers "X-CSRFToken" csrf))
;; Preload cache check
(let ((cached (preload-cache-get _preload-cache final-url))
(optimistic-state (apply-optimistic el))
(indicator (show-indicator el))
(disabled-elts (disable-elements el)))
;; Loading indicators
(dom-add-class el "sx-request")
(dom-set-attr el "aria-busy" "true")
(dom-dispatch el "sx:beforeRequest" (dict "url" final-url "method" method))
;; Fetch
(fetch-request
(dict "url" final-url
"method" method
"headers" headers
"body" body
"signal" (controller-signal ctrl)
"cross-origin" (cross-origin? final-url)
"preloaded" cached)
;; Success callback
(fn (resp-ok status get-header text)
(do
(clear-loading-state el indicator disabled-elts)
(revert-optimistic optimistic-state)
(if (not resp-ok)
(do
(dom-dispatch el "sx:responseError"
(dict "status" status "text" text))
(handle-retry el verb method final-url extraParams))
(do
(dom-dispatch el "sx:afterRequest"
(dict "status" status))
(handle-fetch-success el final-url verb extraParams
get-header text)))))
;; Error callback
(fn (err)
(do
(clear-loading-state el indicator disabled-elts)
(revert-optimistic optimistic-state)
(when (not (abort-error? err))
(dom-dispatch el "sx:requestError"
(dict "error" err))))))))))))
(define handle-fetch-success
(fn (el url verb extraParams get-header text)
;; Route a successful response through the appropriate handler.
(let ((resp-headers (process-response-headers get-header)))
;; CSS hash update
(let ((new-hash (get resp-headers "css-hash")))
(when new-hash (set! _css-hash new-hash)))
;; Triggers (before swap)
(dispatch-trigger-events el (get resp-headers "trigger"))
(cond
;; Redirect
(get resp-headers "redirect")
(browser-navigate (get resp-headers "redirect"))
;; Refresh
(get resp-headers "refresh")
(browser-reload)
;; Location (SX-Location header)
(get resp-headers "location")
(fetch-location (get resp-headers "location"))
;; Normal response — route by content type
:else
(let ((target-el (if (get resp-headers "retarget")
(dom-query (get resp-headers "retarget"))
(resolve-target el)))
(swap-spec (parse-swap-spec
(or (get resp-headers "reswap")
(dom-get-attr el "sx-swap"))
(dom-has-class? (dom-body) "sx-transitions")))
(swap-style (get swap-spec "style"))
(use-transition (get swap-spec "transition"))
(ct (or (get resp-headers "content-type") "")))
;; Dispatch by content type
(if (contains? ct "text/sx")
(handle-sx-response el target-el text swap-style use-transition)
(handle-html-response el target-el text swap-style use-transition))
;; Post-swap triggers
(dispatch-trigger-events el (get resp-headers "trigger-swap"))
;; History
(handle-history el url resp-headers)
;; Settle triggers (after small delay)
(when (get resp-headers "trigger-settle")
(set-timeout
(fn () (dispatch-trigger-events el
(get resp-headers "trigger-settle")))
20))
;; Lifecycle event
(dom-dispatch el "sx:afterSwap"
(dict "target" target-el "swap" swap-style)))))))
(define handle-sx-response
(fn (el target text swap-style use-transition)
;; Handle SX-format response: strip components, extract CSS, render, swap.
(let ((cleaned (strip-component-scripts text)))
(let ((final (extract-response-css cleaned)))
(let ((trimmed (trim final)))
(when (not (empty? trimmed))
(let ((rendered (sx-render trimmed))
(container (dom-create-element "div" nil)))
(dom-append container rendered)
;; Process OOB swaps
(process-oob-swaps container
(fn (t oob s)
(swap-dom-nodes t oob s)
(sx-hydrate t)
(process-elements t)))
;; Select if specified
(let ((select-sel (dom-get-attr el "sx-select"))
(content (if select-sel
(select-from-container container select-sel)
(children-to-fragment container))))
;; Swap
(with-transition use-transition
(fn ()
(swap-dom-nodes target content swap-style)
(post-swap target)))))))))))
(define handle-html-response
(fn (el target text swap-style use-transition)
;; Handle HTML-format response: parse, OOB, select, swap.
(let ((doc (dom-parse-html-document text)))
(when doc
(let ((select-sel (dom-get-attr el "sx-select")))
(if select-sel
;; Select from parsed document
(let ((html (select-html-from-doc doc select-sel)))
(with-transition use-transition
(fn ()
(swap-html-string target html swap-style)
(post-swap target))))
;; Full body content
(let ((container (dom-create-element "div" nil)))
(dom-set-inner-html container (dom-body-inner-html doc))
;; Process OOB swaps
(process-oob-swaps container
(fn (t oob s)
(swap-dom-nodes t oob s)
(post-swap t)))
;; Hoist head elements
(hoist-head-elements container)
;; Swap remaining content
(with-transition use-transition
(fn ()
(swap-dom-nodes target (children-to-fragment container) swap-style)
(post-swap target))))))))))
;; --------------------------------------------------------------------------
;; Retry
;; --------------------------------------------------------------------------
(define handle-retry
(fn (el verb method url extraParams)
;; Handle retry on failure if sx-retry is configured
(let ((retry-attr (dom-get-attr el "sx-retry"))
(spec (parse-retry-spec retry-attr)))
(when spec
(let ((current-ms (or (dom-get-attr el "data-sx-retry-ms")
(get spec "start-ms"))))
(let ((ms (parse-int current-ms (get spec "start-ms"))))
(dom-set-attr el "data-sx-retry-ms"
(str (next-retry-ms ms (get spec "cap-ms"))))
(set-timeout
(fn () (do-fetch el verb method url extraParams))
ms)))))))
;; --------------------------------------------------------------------------
;; Trigger binding
;; --------------------------------------------------------------------------
(define bind-triggers
(fn (el verbInfo)
;; Bind triggers from sx-trigger attribute (or defaults)
(let ((triggers (or (parse-trigger-spec (dom-get-attr el "sx-trigger"))
(default-trigger (dom-tag-name el)))))
(for-each
(fn (trigger)
(let ((kind (classify-trigger trigger))
(mods (get trigger "modifiers")))
(cond
(= kind "poll")
(set-interval
(fn () (execute-request el nil nil))
(get mods "interval"))
(= kind "intersect")
(observe-intersection el
(fn () (execute-request el nil nil))
false (get mods "delay"))
(= kind "load")
(set-timeout
(fn () (execute-request el nil nil))
(or (get mods "delay") 0))
(= kind "revealed")
(observe-intersection el
(fn () (execute-request el nil nil))
true (get mods "delay"))
(= kind "event")
(bind-event el (get trigger "event") mods verbInfo))))
triggers))))
(define bind-event
(fn (el event-name mods verbInfo)
;; Bind a standard DOM event trigger.
;; Handles delay, once, changed, optimistic, preventDefault.
(let ((timer nil)
(last-val nil)
(listen-target (if (get mods "from")
(dom-query (get mods "from"))
el)))
(when listen-target
(dom-add-listener listen-target event-name
(fn (e)
(let ((should-fire true))
;; Changed modifier: skip if value unchanged
(when (get mods "changed")
(let ((val (element-value el)))
(if (= val last-val)
(set! should-fire false)
(set! last-val val))))
(when should-fire
;; Prevent default for submit/click on links
(when (or (= event-name "submit")
(and (= event-name "click")
(dom-has-attr? el "href")))
(prevent-default e))
;; Delay modifier
(if (get mods "delay")
(do
(clear-timeout timer)
(set! timer
(set-timeout
(fn () (execute-request el verbInfo nil))
(get mods "delay"))))
(execute-request el verbInfo nil)))))
(if (get mods "once") (dict "once" true) nil))))))
;; --------------------------------------------------------------------------
;; Post-swap lifecycle
;; --------------------------------------------------------------------------
(define post-swap
(fn (root)
;; Run lifecycle after swap: activate scripts, process SX, hydrate, process
(activate-scripts root)
(sx-process-scripts root)
(sx-hydrate root)
(process-elements root)))
(define activate-scripts
(fn (root)
;; Re-activate scripts in swapped content.
;; Scripts inserted via innerHTML are inert — clone to make them execute.
(when root
(let ((scripts (dom-query-all root "script")))
(for-each
(fn (dead)
;; Skip already-processed or data-components scripts
(when (and (not (dom-has-attr? dead "data-components"))
(not (dom-has-attr? dead "data-sx-activated")))
(let ((live (create-script-clone dead)))
(dom-set-attr live "data-sx-activated" "true")
(dom-replace-child (dom-parent dead) live dead))))
scripts)))))
;; --------------------------------------------------------------------------
;; OOB swap processing
;; --------------------------------------------------------------------------
(define process-oob-swaps
(fn (container swap-fn)
;; Find and process out-of-band swaps in container.
;; swap-fn is (fn (target oob-element swap-type) ...).
(let ((oobs (find-oob-swaps container)))
(for-each
(fn (oob)
(let ((target-id (get oob "target-id"))
(target (dom-query-by-id target-id))
(oob-el (get oob "element"))
(swap-type (get oob "swap-type")))
;; Remove from source container
(when (dom-parent oob-el)
(dom-remove-child (dom-parent oob-el) oob-el))
;; Swap into target
(when target
(swap-fn target oob-el swap-type))))
oobs))))
;; --------------------------------------------------------------------------
;; Head element hoisting
;; --------------------------------------------------------------------------
(define hoist-head-elements
(fn (container)
;; Move style[data-sx-css] and link[rel=stylesheet] to <head>
;; so they take effect globally.
(for-each
(fn (style)
(when (dom-parent style)
(dom-remove-child (dom-parent style) style))
(dom-append-to-head style))
(dom-query-all container "style[data-sx-css]"))
(for-each
(fn (link)
(when (dom-parent link)
(dom-remove-child (dom-parent link) link))
(dom-append-to-head link))
(dom-query-all container "link[rel=\"stylesheet\"]"))))
;; --------------------------------------------------------------------------
;; Boost processing
;; --------------------------------------------------------------------------
(define process-boosted
(fn (root)
;; Find [sx-boost] containers and boost their descendants
(for-each
(fn (container)
(boost-descendants container))
(dom-query-all (or root (dom-body)) "[sx-boost]"))))
(define boost-descendants
(fn (container)
;; Boost links and forms within a container
;; Links get sx-get, forms get sx-post/sx-get
(for-each
(fn (link)
(when (and (not (is-processed? link "boost"))
(should-boost-link? link))
(mark-processed! link "boost")
;; Set default sx-target if not specified
(when (not (dom-has-attr? link "sx-target"))
(dom-set-attr link "sx-target" "#main-panel"))
(when (not (dom-has-attr? link "sx-swap"))
(dom-set-attr link "sx-swap" "innerHTML"))
(when (not (dom-has-attr? link "sx-push-url"))
(dom-set-attr link "sx-push-url" "true"))
(bind-boost-link link (dom-get-attr link "href"))))
(dom-query-all container "a[href]"))
(for-each
(fn (form)
(when (and (not (is-processed? form "boost"))
(should-boost-form? form))
(mark-processed! form "boost")
(let ((method (upper (or (dom-get-attr form "method") "GET")))
(action (or (dom-get-attr form "action")
(browser-location-href))))
(when (not (dom-has-attr? form "sx-target"))
(dom-set-attr form "sx-target" "#main-panel"))
(when (not (dom-has-attr? form "sx-swap"))
(dom-set-attr form "sx-swap" "innerHTML"))
(bind-boost-form form method action))))
(dom-query-all container "form"))))
;; --------------------------------------------------------------------------
;; SSE processing
;; --------------------------------------------------------------------------
(define process-sse
(fn (root)
;; Find and bind SSE elements
(for-each
(fn (el)
(when (not (is-processed? el "sse"))
(mark-processed! el "sse")
(bind-sse el)))
(dom-query-all (or root (dom-body)) "[sx-sse]"))))
(define bind-sse
(fn (el)
;; Connect to SSE endpoint and bind swap handler
(let ((url (dom-get-attr el "sx-sse")))
(when url
(let ((source (event-source-connect url el))
(event-name (parse-sse-swap el)))
(event-source-listen source event-name
(fn (data)
(bind-sse-swap el data))))))))
(define bind-sse-swap
(fn (el data)
;; Handle an SSE event: swap data into element
(let ((target (resolve-target el))
(swap-spec (parse-swap-spec
(dom-get-attr el "sx-swap")
(dom-has-class? (dom-body) "sx-transitions")))
(swap-style (get swap-spec "style"))
(use-transition (get swap-spec "transition"))
(trimmed (trim data)))
(when (not (empty? trimmed))
(if (starts-with? trimmed "(")
;; SX response
(let ((rendered (sx-render trimmed))
(container (dom-create-element "div" nil)))
(dom-append container rendered)
(with-transition use-transition
(fn ()
(swap-dom-nodes target (children-to-fragment container) swap-style)
(post-swap target))))
;; HTML response
(with-transition use-transition
(fn ()
(swap-html-string target trimmed swap-style)
(post-swap target))))))))
;; --------------------------------------------------------------------------
;; Inline event handlers
;; --------------------------------------------------------------------------
(define bind-inline-handlers
(fn (root)
;; Find elements with sx-on:* attributes and bind handlers
(for-each
(fn (el)
(for-each
(fn (attr)
(let ((name (first attr))
(body (nth attr 1)))
(when (starts-with? name "sx-on:")
(let ((event-name (slice name 6)))
(when (not (is-processed? el (str "on:" event-name)))
(mark-processed! el (str "on:" event-name))
(bind-inline-handler el event-name body))))))
(dom-attr-list el)))
(dom-query-all (or root (dom-body)) "[sx-on\\:beforeRequest],[sx-on\\:afterRequest],[sx-on\\:afterSwap],[sx-on\\:afterSettle],[sx-on\\:load]"))))
;; --------------------------------------------------------------------------
;; Preload
;; --------------------------------------------------------------------------
(define bind-preload-for
(fn (el)
;; Bind preload event listeners based on sx-preload attribute
(let ((preload-attr (dom-get-attr el "sx-preload")))
(when preload-attr
(let ((info (get-verb-info el)))
(when info
(let ((url (get info "url"))
(headers (build-request-headers el
(loaded-component-names) _css-hash))
(events (if (= preload-attr "mousedown")
(list "mousedown" "touchstart")
(list "mouseover")))
(debounce-ms (if (= preload-attr "mousedown") 0 100)))
(bind-preload el events debounce-ms
(fn () (do-preload url headers))))))))))
(define do-preload
(fn (url headers)
;; Execute a preload fetch into the cache
(when (nil? (preload-cache-get _preload-cache url))
(fetch-preload url headers _preload-cache))))
;; --------------------------------------------------------------------------
;; Main element processing
;; --------------------------------------------------------------------------
(define VERB_SELECTOR
(str "[sx-get],[sx-post],[sx-put],[sx-delete],[sx-patch]"))
(define process-elements
(fn (root)
;; Find all elements with sx-* verb attributes and process them.
(let ((els (dom-query-all (or root (dom-body)) VERB_SELECTOR)))
(for-each
(fn (el)
(when (not (is-processed? el "verb"))
(mark-processed! el "verb")
(process-one el)))
els))
;; Also process boost, SSE, inline handlers
(process-boosted root)
(process-sse root)
(bind-inline-handlers root)))
(define process-one
(fn (el)
;; Process a single element with an sx-* verb attribute
(let ((verb-info (get-verb-info el)))
(when verb-info
;; Check for disabled
(when (not (dom-has-attr? el "sx-disable"))
(bind-triggers el verb-info)
(bind-preload-for el))))))
;; --------------------------------------------------------------------------
;; History: popstate handler
;; --------------------------------------------------------------------------
(define handle-popstate
(fn (scrollY)
;; Handle browser back/forward navigation
(let ((main (dom-query-by-id "main-panel"))
(url (browser-location-href)))
(when main
(let ((headers (build-request-headers main
(loaded-component-names) _css-hash)))
(fetch-and-restore main url headers scrollY))))))
;; --------------------------------------------------------------------------
;; Initialization
;; --------------------------------------------------------------------------
(define engine-init
(fn ()
;; Initialize: CSS tracking, scripts, hydrate, process.
(do
(init-css-tracking)
(sx-process-scripts nil)
(sx-hydrate nil)
(process-elements nil))))
;; --------------------------------------------------------------------------
;; Platform interface — Orchestration
;; --------------------------------------------------------------------------
;;
;; From engine.sx (pure logic):
;; parse-trigger-spec, default-trigger, get-verb-info, classify-trigger,
;; build-request-headers, process-response-headers, parse-swap-spec,
;; parse-retry-spec, next-retry-ms, resolve-target, apply-optimistic,
;; revert-optimistic, find-oob-swaps, swap-dom-nodes, swap-html-string,
;; morph-children, handle-history, preload-cache-get, preload-cache-set,
;; should-boost-link?, should-boost-form?, parse-sse-swap, filter-params,
;; PRELOAD_TTL
;;
;; === Promises ===
;; (promise-resolve val) → resolved Promise
;; (promise-catch p fn) → p.catch(fn)
;;
;; === Abort controllers ===
;; (abort-previous el) → abort + remove controller for element
;; (track-controller el ctrl) → store controller for element
;; (new-abort-controller) → new AbortController()
;; (controller-signal ctrl) → ctrl.signal
;; (abort-error? err) → boolean (err.name === "AbortError")
;;
;; === Timers ===
;; (set-timeout fn ms) → timer id
;; (set-interval fn ms) → timer id
;; (clear-timeout id) → void
;; (request-animation-frame fn) → void
;;
;; === Fetch ===
;; (fetch-request config success-fn error-fn) → Promise
;; config: dict with url, method, headers, body, signal, preloaded,
;; cross-origin
;; success-fn: (fn (resp-ok status get-header text) ...)
;; error-fn: (fn (err) ...)
;; (fetch-location url) → fetch URL and swap to #main-panel
;; (fetch-and-restore main url headers scroll-y) → popstate fetch+swap
;; (fetch-preload url headers cache) → preload into cache
;;
;; === Request body ===
;; (build-request-body el method url) → dict with body, url, content-type
;;
;; === Loading state ===
;; (show-indicator el) → indicator state (or nil)
;; (disable-elements el) → list of disabled elements
;; (clear-loading-state el indicator disabled-elts) → void
;;
;; === DOM extras (beyond adapter-dom.sx) ===
;; (dom-query-by-id id) → Element or nil
;; (dom-matches? el sel) → boolean
;; (dom-closest el sel) → Element or nil
;; (dom-body) → document.body
;; (dom-has-class? el cls) → boolean
;; (dom-append-to-head el) → void
;; (dom-parse-html-document text) → parsed document (DOMParser)
;; (dom-outer-html el) → string
;; (dom-body-inner-html doc) → string
;; (dom-tag-name el) → uppercase tag name
;;
;; === Events ===
;; (dom-dispatch el name detail) → boolean (dispatchEvent)
;; (dom-add-listener el event fn opts) → void
;; (prevent-default e) → void
;; (element-value el) → el.value or nil
;;
;; === Validation ===
;; (validate-for-request el) → boolean
;;
;; === View Transitions ===
;; (with-transition enabled fn) → void
;;
;; === IntersectionObserver ===
;; (observe-intersection el fn once? delay) → void
;;
;; === EventSource ===
;; (event-source-connect url el) → EventSource (with cleanup)
;; (event-source-listen source event fn) → void
;;
;; === Boost bindings ===
;; (bind-boost-link el href) → void (click handler + pushState)
;; (bind-boost-form form method action) → void (submit handler)
;;
;; === Inline handlers ===
;; (bind-inline-handler el event-name body) → void (new Function)
;;
;; === Preload ===
;; (bind-preload el events debounce-ms fn) → void
;;
;; === Processing markers ===
;; (mark-processed! el key) → void
;; (is-processed? el key) → boolean
;;
;; === Script handling ===
;; (create-script-clone script) → live script Element
;;
;; === SX API (references to Sx/SxRef object) ===
;; (sx-render source) → DOM nodes
;; (sx-process-scripts root) → void
;; (sx-hydrate root) → void
;; (loaded-component-names) → list of ~name strings
;;
;; === Response processing ===
;; (strip-component-scripts text) → cleaned text
;; (extract-response-css text) → cleaned text
;; (select-from-container el sel) → DocumentFragment
;; (children-to-fragment el) → DocumentFragment
;; (select-html-from-doc doc sel) → HTML string
;;
;; === Parsing ===
;; (try-parse-json s) → parsed value or nil
;;
;; === Browser (via engine.sx) ===
;; (browser-location-href) → current URL string
;; (browser-navigate url) → void
;; (browser-reload) → void
;; (browser-media-matches? query) → boolean
;; (browser-confirm msg) → boolean
;; (browser-prompt msg) → string or nil
;; (csrf-token) → string
;; (cross-origin? url) → boolean
;; (now-ms) → timestamp ms
;; --------------------------------------------------------------------------

378
shared/sx/ref/parser.sx Normal file
View File

@@ -0,0 +1,378 @@
;; ==========================================================================
;; parser.sx — Reference SX parser specification
;;
;; Defines how SX source text is tokenized and parsed into AST.
;; The parser is intentionally simple — s-expressions need minimal parsing.
;;
;; Grammar:
;; program → expr*
;; expr → atom | list | quote-sugar
;; list → '(' expr* ')'
;; atom → string | number | keyword | symbol | boolean | nil
;; string → '"' (char | escape)* '"'
;; number → '-'? digit+ ('.' digit+)? ([eE] [+-]? digit+)?
;; keyword → ':' ident
;; symbol → ident
;; boolean → 'true' | 'false'
;; nil → 'nil'
;; ident → [a-zA-Z_~*+\-><=/!?&] [a-zA-Z0-9_~*+\-><=/!?.:&]*
;; comment → ';' to end of line (discarded)
;;
;; Dict literal:
;; {key val ...} → dict object (keys are keywords or expressions)
;;
;; Quote sugar:
;; `(expr) → (quasiquote expr)
;; ,(expr) → (unquote expr)
;; ,@(expr) → (splice-unquote expr)
;; ==========================================================================
;; --------------------------------------------------------------------------
;; Tokenizer
;; --------------------------------------------------------------------------
;; Produces a flat stream of tokens from source text.
;; Each token is a (type value line col) tuple.
(define tokenize
(fn (source)
(let ((pos 0)
(line 1)
(col 1)
(tokens (list))
(len-src (len source)))
;; Main loop — bootstrap compilers convert to while
(define scan-next
(fn ()
(when (< pos len-src)
(let ((ch (nth source pos)))
(cond
;; Whitespace — skip
(whitespace? ch)
(do (advance-pos!) (scan-next))
;; Comment — skip to end of line
(= ch ";")
(do (skip-to-eol!) (scan-next))
;; String
(= ch "\"")
(do (append! tokens (scan-string)) (scan-next))
;; Open paren
(= ch "(")
(do (append! tokens (list "lparen" "(" line col))
(advance-pos!)
(scan-next))
;; Close paren
(= ch ")")
(do (append! tokens (list "rparen" ")" line col))
(advance-pos!)
(scan-next))
;; Open bracket (list sugar)
(= ch "[")
(do (append! tokens (list "lbracket" "[" line col))
(advance-pos!)
(scan-next))
;; Close bracket
(= ch "]")
(do (append! tokens (list "rbracket" "]" line col))
(advance-pos!)
(scan-next))
;; Open brace (dict literal)
(= ch "{")
(do (append! tokens (list "lbrace" "{" line col))
(advance-pos!)
(scan-next))
;; Close brace
(= ch "}")
(do (append! tokens (list "rbrace" "}" line col))
(advance-pos!)
(scan-next))
;; Quasiquote sugar
(= ch "`")
(do (advance-pos!)
(let ((inner (scan-next-expr)))
(append! tokens (list "quasiquote" inner line col))
(scan-next)))
;; Unquote / splice-unquote
(= ch ",")
(do (advance-pos!)
(if (and (< pos len-src) (= (nth source pos) "@"))
(do (advance-pos!)
(let ((inner (scan-next-expr)))
(append! tokens (list "splice-unquote" inner line col))
(scan-next)))
(let ((inner (scan-next-expr)))
(append! tokens (list "unquote" inner line col))
(scan-next))))
;; Keyword
(= ch ":")
(do (append! tokens (scan-keyword)) (scan-next))
;; Number (or negative number)
(or (digit? ch)
(and (= ch "-") (< (inc pos) len-src)
(digit? (nth source (inc pos)))))
(do (append! tokens (scan-number)) (scan-next))
;; Symbol
(ident-start? ch)
(do (append! tokens (scan-symbol)) (scan-next))
;; Unknown — skip
:else
(do (advance-pos!) (scan-next)))))))
(scan-next)
tokens)))
;; --------------------------------------------------------------------------
;; Token scanners (pseudo-code — each target implements natively)
;; --------------------------------------------------------------------------
(define scan-string
(fn ()
;; Scan from opening " to closing ", handling escape sequences.
;; Returns ("string" value line col).
;; Escape sequences: \" \\ \n \t \r
(let ((start-line line)
(start-col col)
(result ""))
(advance-pos!) ;; skip opening "
(define scan-str-loop
(fn ()
(if (>= pos (len source))
(error "Unterminated string")
(let ((ch (nth source pos)))
(cond
(= ch "\"")
(do (advance-pos!) nil) ;; done
(= ch "\\")
(do (advance-pos!)
(let ((esc (nth source pos)))
(set! result (str result
(case esc
"n" "\n"
"t" "\t"
"r" "\r"
:else esc)))
(advance-pos!)
(scan-str-loop)))
:else
(do (set! result (str result ch))
(advance-pos!)
(scan-str-loop)))))))
(scan-str-loop)
(list "string" result start-line start-col))))
(define scan-keyword
(fn ()
;; Scan :identifier
(let ((start-line line) (start-col col))
(advance-pos!) ;; skip :
(let ((name (scan-ident-chars)))
(list "keyword" name start-line start-col)))))
(define scan-number
(fn ()
;; Scan integer or float literal
(let ((start-line line) (start-col col) (buf ""))
(when (= (nth source pos) "-")
(set! buf "-")
(advance-pos!))
;; Integer part
(define scan-digits
(fn ()
(when (and (< pos (len source)) (digit? (nth source pos)))
(set! buf (str buf (nth source pos)))
(advance-pos!)
(scan-digits))))
(scan-digits)
;; Decimal part
(when (and (< pos (len source)) (= (nth source pos) "."))
(set! buf (str buf "."))
(advance-pos!)
(scan-digits))
;; Exponent
(when (and (< pos (len source))
(or (= (nth source pos) "e") (= (nth source pos) "E")))
(set! buf (str buf (nth source pos)))
(advance-pos!)
(when (and (< pos (len source))
(or (= (nth source pos) "+") (= (nth source pos) "-")))
(set! buf (str buf (nth source pos)))
(advance-pos!))
(scan-digits))
(list "number" (parse-number buf) start-line start-col))))
(define scan-symbol
(fn ()
;; Scan identifier, check for true/false/nil
(let ((start-line line)
(start-col col)
(name (scan-ident-chars)))
(cond
(= name "true") (list "boolean" true start-line start-col)
(= name "false") (list "boolean" false start-line start-col)
(= name "nil") (list "nil" nil start-line start-col)
:else (list "symbol" name start-line start-col)))))
;; --------------------------------------------------------------------------
;; Parser — tokens → AST
;; --------------------------------------------------------------------------
(define parse
(fn (tokens)
;; Parse all top-level expressions from token stream.
(let ((pos 0)
(exprs (list)))
(define parse-loop
(fn ()
(when (< pos (len tokens))
(let ((result (parse-expr tokens)))
(append! exprs result)
(parse-loop)))))
(parse-loop)
exprs)))
(define parse-expr
(fn (tokens)
;; Parse a single expression.
(let ((tok (nth tokens pos)))
(case (first tok) ;; token type
"lparen"
(do (set! pos (inc pos))
(parse-list tokens "rparen"))
"lbracket"
(do (set! pos (inc pos))
(parse-list tokens "rbracket"))
"lbrace"
(do (set! pos (inc pos))
(parse-dict tokens))
"string" (do (set! pos (inc pos)) (nth tok 1))
"number" (do (set! pos (inc pos)) (nth tok 1))
"boolean" (do (set! pos (inc pos)) (nth tok 1))
"nil" (do (set! pos (inc pos)) nil)
"keyword"
(do (set! pos (inc pos))
(make-keyword (nth tok 1)))
"symbol"
(do (set! pos (inc pos))
(make-symbol (nth tok 1)))
:else (error (str "Unexpected token: " (inspect tok)))))))
(define parse-list
(fn (tokens close-type)
;; Parse expressions until close-type token.
(let ((items (list)))
(define parse-list-loop
(fn ()
(if (>= pos (len tokens))
(error "Unterminated list")
(if (= (first (nth tokens pos)) close-type)
(do (set! pos (inc pos)) nil) ;; done
(do (append! items (parse-expr tokens))
(parse-list-loop))))))
(parse-list-loop)
items)))
(define parse-dict
(fn (tokens)
;; Parse {key val key val ...} until "rbrace" token.
;; Returns a dict (plain object).
(let ((result (dict)))
(define parse-dict-loop
(fn ()
(if (>= pos (len tokens))
(error "Unterminated dict")
(if (= (first (nth tokens pos)) "rbrace")
(do (set! pos (inc pos)) nil) ;; done
(let ((key-expr (parse-expr tokens))
(key-str (if (= (type-of key-expr) "keyword")
(keyword-name key-expr)
(str key-expr)))
(val-expr (parse-expr tokens)))
(dict-set! result key-str val-expr)
(parse-dict-loop))))))
(parse-dict-loop)
result)))
;; --------------------------------------------------------------------------
;; Serializer — AST → SX source text
;; --------------------------------------------------------------------------
(define serialize
(fn (val)
(case (type-of val)
"nil" "nil"
"boolean" (if val "true" "false")
"number" (str val)
"string" (str "\"" (escape-string val) "\"")
"symbol" (symbol-name val)
"keyword" (str ":" (keyword-name val))
"list" (str "(" (join " " (map serialize val)) ")")
"dict" (serialize-dict val)
"sx-expr" (sx-expr-source val)
:else (str val))))
(define serialize-dict
(fn (d)
(str "(dict "
(join " "
(reduce
(fn (acc key)
(concat acc (list (str ":" key) (serialize (dict-get d key)))))
(list)
(keys d)))
")")))
;; --------------------------------------------------------------------------
;; Platform parser interface
;; --------------------------------------------------------------------------
;;
;; Character classification:
;; (whitespace? ch) → boolean
;; (digit? ch) → boolean
;; (ident-start? ch) → boolean (letter, _, ~, *, +, -, etc.)
;; (ident-char? ch) → boolean (ident-start + digits, ., :)
;;
;; Constructors:
;; (make-symbol name) → Symbol value
;; (make-keyword name) → Keyword value
;; (parse-number s) → number (int or float from string)
;;
;; String utilities:
;; (escape-string s) → string with " and \ escaped
;; (sx-expr-source e) → unwrap SxExpr to its source string
;;
;; Cursor state (mutable — each target manages its own way):
;; pos, line, col — current position in source
;; (advance-pos!) → increment pos, update line/col
;; (skip-to-eol!) → advance past end of line
;; (scan-ident-chars) → consume and return identifier string
;; --------------------------------------------------------------------------

459
shared/sx/ref/primitives.sx Normal file
View File

@@ -0,0 +1,459 @@
;; ==========================================================================
;; primitives.sx — Specification of all SX built-in pure functions
;;
;; Each entry declares: name, parameter signature, and semantics.
;; Bootstrap compilers implement these natively per target.
;;
;; This file is a SPECIFICATION, not executable code. The define-primitive
;; form is a declarative macro that bootstrap compilers consume to generate
;; native primitive registrations.
;;
;; Format:
;; (define-primitive "name"
;; :params (param1 param2 &rest rest)
;; :returns "type"
;; :doc "description"
;; :body (reference-implementation ...))
;;
;; The :body is optional — when provided, it gives a reference
;; implementation in SX that bootstrap compilers MAY use for testing
;; or as a fallback. Most targets will implement natively for performance.
;; ==========================================================================
;; --------------------------------------------------------------------------
;; Arithmetic
;; --------------------------------------------------------------------------
(define-primitive "+"
:params (&rest args)
:returns "number"
:doc "Sum all arguments."
:body (reduce (fn (a b) (native-add a b)) 0 args))
(define-primitive "-"
:params (a &rest b)
:returns "number"
:doc "Subtract. Unary: negate. Binary: a - b."
:body (if (empty? b) (native-neg a) (native-sub a (first b))))
(define-primitive "*"
:params (&rest args)
:returns "number"
:doc "Multiply all arguments."
:body (reduce (fn (a b) (native-mul a b)) 1 args))
(define-primitive "/"
:params (a b)
:returns "number"
:doc "Divide a by b."
:body (native-div a b))
(define-primitive "mod"
:params (a b)
:returns "number"
:doc "Modulo a % b."
:body (native-mod a b))
(define-primitive "sqrt"
:params (x)
:returns "number"
:doc "Square root.")
(define-primitive "pow"
:params (x n)
:returns "number"
:doc "x raised to power n.")
(define-primitive "abs"
:params (x)
:returns "number"
:doc "Absolute value.")
(define-primitive "floor"
:params (x)
:returns "number"
:doc "Floor to integer.")
(define-primitive "ceil"
:params (x)
:returns "number"
:doc "Ceiling to integer.")
(define-primitive "round"
:params (x &rest ndigits)
:returns "number"
:doc "Round to ndigits decimal places (default 0).")
(define-primitive "min"
:params (&rest args)
:returns "number"
:doc "Minimum. Single list arg or variadic.")
(define-primitive "max"
:params (&rest args)
:returns "number"
:doc "Maximum. Single list arg or variadic.")
(define-primitive "clamp"
:params (x lo hi)
:returns "number"
:doc "Clamp x to range [lo, hi]."
:body (max lo (min hi x)))
(define-primitive "inc"
:params (n)
:returns "number"
:doc "Increment by 1."
:body (+ n 1))
(define-primitive "dec"
:params (n)
:returns "number"
:doc "Decrement by 1."
:body (- n 1))
;; --------------------------------------------------------------------------
;; Comparison
;; --------------------------------------------------------------------------
(define-primitive "="
:params (a b)
:returns "boolean"
:doc "Equality (value equality, not identity).")
(define-primitive "!="
:params (a b)
:returns "boolean"
:doc "Inequality."
:body (not (= a b)))
(define-primitive "<"
:params (a b)
:returns "boolean"
:doc "Less than.")
(define-primitive ">"
:params (a b)
:returns "boolean"
:doc "Greater than.")
(define-primitive "<="
:params (a b)
:returns "boolean"
:doc "Less than or equal.")
(define-primitive ">="
:params (a b)
:returns "boolean"
:doc "Greater than or equal.")
;; --------------------------------------------------------------------------
;; Predicates
;; --------------------------------------------------------------------------
(define-primitive "odd?"
:params (n)
:returns "boolean"
:doc "True if n is odd."
:body (= (mod n 2) 1))
(define-primitive "even?"
:params (n)
:returns "boolean"
:doc "True if n is even."
:body (= (mod n 2) 0))
(define-primitive "zero?"
:params (n)
:returns "boolean"
:doc "True if n is zero."
:body (= n 0))
(define-primitive "nil?"
:params (x)
:returns "boolean"
:doc "True if x is nil/null/None.")
(define-primitive "number?"
:params (x)
:returns "boolean"
:doc "True if x is a number (int or float).")
(define-primitive "string?"
:params (x)
:returns "boolean"
:doc "True if x is a string.")
(define-primitive "list?"
:params (x)
:returns "boolean"
:doc "True if x is a list/array.")
(define-primitive "dict?"
:params (x)
:returns "boolean"
:doc "True if x is a dict/map.")
(define-primitive "empty?"
:params (coll)
:returns "boolean"
:doc "True if coll is nil or has length 0.")
(define-primitive "contains?"
:params (coll key)
:returns "boolean"
:doc "True if coll contains key. Strings: substring check. Dicts: key check. Lists: membership.")
;; --------------------------------------------------------------------------
;; Logic
;; --------------------------------------------------------------------------
(define-primitive "not"
:params (x)
:returns "boolean"
:doc "Logical negation. Note: and/or are special forms (short-circuit).")
;; --------------------------------------------------------------------------
;; Strings
;; --------------------------------------------------------------------------
(define-primitive "str"
:params (&rest args)
:returns "string"
:doc "Concatenate all args as strings. nil → empty string, bool → true/false.")
(define-primitive "concat"
:params (&rest colls)
:returns "list"
:doc "Concatenate multiple lists into one. Skips nil values.")
(define-primitive "upper"
:params (s)
:returns "string"
:doc "Uppercase string.")
(define-primitive "lower"
:params (s)
:returns "string"
:doc "Lowercase string.")
(define-primitive "trim"
:params (s)
:returns "string"
:doc "Strip leading/trailing whitespace.")
(define-primitive "split"
:params (s &rest sep)
:returns "list"
:doc "Split string by separator (default space).")
(define-primitive "join"
:params (sep coll)
:returns "string"
:doc "Join collection items with separator string.")
(define-primitive "replace"
:params (s old new)
:returns "string"
:doc "Replace all occurrences of old with new in s.")
(define-primitive "slice"
:params (coll start &rest end)
:returns "any"
:doc "Slice a string or list from start to end (exclusive). End is optional.")
(define-primitive "starts-with?"
:params (s prefix)
:returns "boolean"
:doc "True if string s starts with prefix.")
(define-primitive "ends-with?"
:params (s suffix)
:returns "boolean"
:doc "True if string s ends with suffix.")
;; --------------------------------------------------------------------------
;; Collections — construction
;; --------------------------------------------------------------------------
(define-primitive "list"
:params (&rest args)
:returns "list"
:doc "Create a list from arguments.")
(define-primitive "dict"
:params (&rest pairs)
:returns "dict"
:doc "Create a dict from key/value pairs: (dict :a 1 :b 2).")
(define-primitive "range"
:params (start end &rest step)
:returns "list"
:doc "Integer range [start, end) with optional step.")
;; --------------------------------------------------------------------------
;; Collections — access
;; --------------------------------------------------------------------------
(define-primitive "get"
:params (coll key &rest default)
:returns "any"
:doc "Get value from dict by key, or list by index. Optional default.")
(define-primitive "len"
:params (coll)
:returns "number"
:doc "Length of string, list, or dict.")
(define-primitive "first"
:params (coll)
:returns "any"
:doc "First element, or nil if empty.")
(define-primitive "last"
:params (coll)
:returns "any"
:doc "Last element, or nil if empty.")
(define-primitive "rest"
:params (coll)
:returns "list"
:doc "All elements except the first.")
(define-primitive "nth"
:params (coll n)
:returns "any"
:doc "Element at index n, or nil if out of bounds.")
(define-primitive "cons"
:params (x coll)
:returns "list"
:doc "Prepend x to coll.")
(define-primitive "append"
:params (coll x)
:returns "list"
:doc "Append x to end of coll (returns new list).")
(define-primitive "chunk-every"
:params (coll n)
:returns "list"
:doc "Split coll into sub-lists of size n.")
(define-primitive "zip-pairs"
:params (coll)
:returns "list"
:doc "Consecutive pairs: (1 2 3 4) → ((1 2) (2 3) (3 4)).")
;; --------------------------------------------------------------------------
;; Collections — dict operations
;; --------------------------------------------------------------------------
(define-primitive "keys"
:params (d)
:returns "list"
:doc "List of dict keys.")
(define-primitive "vals"
:params (d)
:returns "list"
:doc "List of dict values.")
(define-primitive "merge"
:params (&rest dicts)
:returns "dict"
:doc "Merge dicts left to right. Later keys win. Skips nil.")
(define-primitive "assoc"
:params (d &rest pairs)
:returns "dict"
:doc "Return new dict with key/value pairs added/overwritten.")
(define-primitive "dissoc"
:params (d &rest keys)
:returns "dict"
:doc "Return new dict with keys removed.")
(define-primitive "into"
:params (target coll)
:returns "any"
:doc "Pour coll into target. List target: convert to list. Dict target: convert pairs to dict.")
;; --------------------------------------------------------------------------
;; Format helpers
;; --------------------------------------------------------------------------
(define-primitive "format-date"
:params (date-str fmt)
:returns "string"
:doc "Parse ISO date string and format with strftime-style format.")
(define-primitive "format-decimal"
:params (val &rest places)
:returns "string"
:doc "Format number with fixed decimal places (default 2).")
(define-primitive "parse-int"
:params (val &rest default)
:returns "number"
:doc "Parse string to integer with optional default on failure.")
;; --------------------------------------------------------------------------
;; Text helpers
;; --------------------------------------------------------------------------
(define-primitive "pluralize"
:params (count &rest forms)
:returns "string"
:doc "Pluralize: (pluralize 1) → \"\", (pluralize 2) → \"s\". Or (pluralize n \"item\" \"items\").")
(define-primitive "escape"
:params (s)
:returns "string"
:doc "HTML-escape a string (&, <, >, \", ').")
(define-primitive "strip-tags"
:params (s)
:returns "string"
:doc "Remove HTML tags from string.")
;; --------------------------------------------------------------------------
;; Date & parsing helpers
;; --------------------------------------------------------------------------
(define-primitive "parse-datetime"
:params (s)
:returns "string"
:doc "Parse datetime string — identity passthrough (returns string or nil).")
(define-primitive "split-ids"
:params (s)
:returns "list"
:doc "Split comma-separated ID string into list of trimmed non-empty strings.")
;; --------------------------------------------------------------------------
;; CSSX — style system primitives
;; --------------------------------------------------------------------------
(define-primitive "css"
:params (&rest atoms)
:returns "style-value"
:doc "Resolve style atoms to a StyleValue with className and CSS declarations.
Atoms are keywords or strings: (css :flex :gap-4 :hover:bg-sky-200).")
(define-primitive "merge-styles"
:params (&rest styles)
:returns "style-value"
:doc "Merge multiple StyleValues into one combined StyleValue.")

147
shared/sx/ref/render.sx Normal file
View File

@@ -0,0 +1,147 @@
;; ==========================================================================
;; render.sx — Core rendering specification
;;
;; Shared registries and utilities used by all rendering adapters.
;; This file defines WHAT is renderable (tag registries, attribute rules)
;; and HOW arguments are parsed — but not the output format.
;;
;; Adapters:
;; adapter-html.sx — HTML string output (server)
;; adapter-sx.sx — SX wire format output (server → client)
;; adapter-dom.sx — Live DOM node output (browser)
;;
;; Each adapter imports these shared definitions and provides its own
;; render entry point (render-to-html, render-to-sx, render-to-dom).
;; ==========================================================================
;; --------------------------------------------------------------------------
;; HTML tag registry
;; --------------------------------------------------------------------------
;; Tags known to the renderer. Unknown names are treated as function calls.
;; Void elements self-close (no children). Boolean attrs emit name only.
(define HTML_TAGS
(list
;; Document
"html" "head" "body" "title" "meta" "link" "script" "style" "noscript"
;; Sections
"header" "nav" "main" "section" "article" "aside" "footer"
"h1" "h2" "h3" "h4" "h5" "h6" "hgroup"
;; Block
"div" "p" "blockquote" "pre" "figure" "figcaption" "address" "details" "summary"
;; Inline
"a" "span" "em" "strong" "small" "b" "i" "u" "s" "mark" "sub" "sup"
"abbr" "cite" "code" "time" "br" "wbr" "hr"
;; Lists
"ul" "ol" "li" "dl" "dt" "dd"
;; Tables
"table" "thead" "tbody" "tfoot" "tr" "th" "td" "caption" "colgroup" "col"
;; Forms
"form" "input" "textarea" "select" "option" "optgroup" "button" "label"
"fieldset" "legend" "output" "datalist"
;; Media
"img" "video" "audio" "source" "picture" "canvas" "iframe"
;; SVG
"svg" "math" "path" "circle" "ellipse" "rect" "line" "polyline" "polygon"
"text" "tspan" "g" "defs" "use" "clipPath" "mask" "pattern"
"linearGradient" "radialGradient" "stop" "filter"
"feGaussianBlur" "feOffset" "feBlend" "feColorMatrix" "feComposite"
"feMerge" "feMergeNode" "feTurbulence"
"feComponentTransfer" "feFuncR" "feFuncG" "feFuncB" "feFuncA"
"feDisplacementMap" "feFlood" "feImage" "feMorphology"
"feSpecularLighting" "feDiffuseLighting"
"fePointLight" "feSpotLight" "feDistantLight"
"animate" "animateTransform" "foreignObject"
;; Other
"template" "slot" "dialog" "menu"))
(define VOID_ELEMENTS
(list "area" "base" "br" "col" "embed" "hr" "img" "input"
"link" "meta" "param" "source" "track" "wbr"))
(define BOOLEAN_ATTRS
(list "async" "autofocus" "autoplay" "checked" "controls" "default"
"defer" "disabled" "formnovalidate" "hidden" "inert" "ismap"
"loop" "multiple" "muted" "nomodule" "novalidate" "open"
"playsinline" "readonly" "required" "reversed" "selected"))
;; --------------------------------------------------------------------------
;; Shared utilities
;; --------------------------------------------------------------------------
(define definition-form?
(fn (name)
(or (= name "define") (= name "defcomp") (= name "defmacro")
(= name "defstyle") (= name "defkeyframes") (= name "defhandler"))))
(define parse-element-args
(fn (args env)
;; Parse (:key val :key2 val2 child1 child2) into (attrs-dict children-list)
(let ((attrs (dict))
(children (list)))
(reduce
(fn (state arg)
(let ((skip (get state "skip")))
(if skip
(assoc state "skip" false "i" (inc (get state "i")))
(if (and (= (type-of arg) "keyword")
(< (inc (get state "i")) (len args)))
(let ((val (trampoline (eval-expr (nth args (inc (get state "i"))) env))))
(dict-set! attrs (keyword-name arg) val)
(assoc state "skip" true "i" (inc (get state "i"))))
(do
(append! children arg)
(assoc state "i" (inc (get state "i"))))))))
(dict "i" 0 "skip" false)
args)
(list attrs children))))
(define render-attrs
(fn (attrs)
;; Render an attrs dict to an HTML attribute string.
;; Used by adapter-html.sx and adapter-sx.sx.
(join ""
(map
(fn (key)
(let ((val (dict-get attrs key)))
(cond
;; Boolean attrs
(and (contains? BOOLEAN_ATTRS key) val)
(str " " key)
(and (contains? BOOLEAN_ATTRS key) (not val))
""
;; Nil values — skip
(nil? val) ""
;; StyleValue on :style → emit as class
(and (= key "style") (style-value? val))
(str " class=\"" (style-value-class val) "\"")
;; Normal attr
:else (str " " key "=\"" (escape-attr (str val)) "\""))))
(keys attrs)))))
;; --------------------------------------------------------------------------
;; Platform interface (shared across adapters)
;; --------------------------------------------------------------------------
;;
;; HTML/attribute escaping (used by HTML and SX wire adapters):
;; (escape-html s) → HTML-escaped string
;; (escape-attr s) → attribute-value-escaped string
;; (raw-html-content r) → unwrap RawHTML marker to string
;;
;; StyleValue:
;; (style-value? x) → boolean (is x a StyleValue?)
;; (style-value-class sv) → string (CSS class name)
;;
;; Serialization:
;; (serialize val) → SX source string representation of val
;;
;; Form classification (used by SX wire adapter):
;; (special-form? name) → boolean
;; (ho-form? name) → boolean
;; (aser-special name expr env) → evaluate special/HO form through aser
;; --------------------------------------------------------------------------

View File

@@ -71,7 +71,7 @@
(h1 (or site-title ""))
(when app-label
(span :class "text-lg text-white/80 font-normal" app-label))))
(nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
(nav :class "hidden md:flex flex-wrap gap-4 text-sm ml-2 justify-end items-center flex-0"
(when nav-tree nav-tree)
(when auth-menu auth-menu)
(when nav-panel nav-panel)
@@ -92,7 +92,7 @@
(<>
(div :id id
:sx-swap-oob (if oob "outerHTML" nil)
:class (str "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-" c "-" shade)
:class (str "flex flex-col items-center md:flex-row md:items-baseline justify-center md:justify-between w-full p-1 bg-" c "-" shade)
(div :class "relative nav-group"
(a :href link-href
:sx-get (if external nil link-href)
@@ -108,7 +108,7 @@
(when selected
(span :class "text-lg text-white/80 font-normal" selected))))))
(when nav
(nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
(nav :class "hidden md:flex flex-wrap gap-4 text-sm ml-2 justify-end items-baseline flex-0"
nav)))
(when (and child-id (not oob))
(div :id child-id :class "flex flex-col w-full items-center"

View File

@@ -120,6 +120,57 @@
(<> auth (~header-child-sx :id "auth-header-child" :inner
(<> orders (~header-child-sx :id "orders-header-child" :inner order))))))
;; ---------------------------------------------------------------------------
;; Data-driven order rows (replaces Python loop)
;; ---------------------------------------------------------------------------
(defcomp ~order-rows-from-data (&key orders page total-pages next-url)
(<>
(map (lambda (o)
(<>
(~order-row-desktop :oid (get o "oid") :created (get o "created")
:desc (get o "desc") :total (get o "total")
:pill (get o "pill_desktop") :status (get o "status") :url (get o "url"))
(~order-row-mobile :oid (get o "oid") :created (get o "created")
:total (get o "total") :pill (get o "pill_mobile")
:status (get o "status") :url (get o "url"))))
(or orders (list)))
(if next-url
(~infinite-scroll :url next-url :page page :total-pages total-pages
:id-prefix "orders" :colspan 5)
(~order-end-row))))
;; ---------------------------------------------------------------------------
;; Data-driven order items (replaces Python loop)
;; ---------------------------------------------------------------------------
(defcomp ~order-items-from-data (&key items)
(~order-items-panel
:items (<> (map (lambda (item)
(let* ((img (if (get item "product_image")
(~order-item-image :src (get item "product_image") :alt (or (get item "product_title") "Product image"))
(~order-item-no-image))))
(~order-item-row
:href (get item "href") :img img
:title (or (get item "product_title") "Unknown product")
:pid (str "Product ID: " (get item "product_id"))
:qty (str "Qty: " (get item "quantity"))
:price (get item "price"))))
(or items (list))))))
;; ---------------------------------------------------------------------------
;; Data-driven calendar entries (replaces Python loop)
;; ---------------------------------------------------------------------------
(defcomp ~order-calendar-from-data (&key entries)
(~order-calendar-section
:items (<> (map (lambda (e)
(~order-calendar-entry
:name (get e "name") :pill (get e "pill")
:status (get e "status") :date-str (get e "date_str")
:cost (get e "cost")))
(or entries (list))))))
;; ---------------------------------------------------------------------------
;; Checkout error screens
;; ---------------------------------------------------------------------------

View File

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

View File

@@ -1,10 +1,12 @@
from __future__ import annotations
import os
import path_setup # noqa: F401
from shared.infrastructure.factory import create_base_app
from bp import register_pages
from services import register_domain_services
SX_STANDALONE = os.getenv("SX_STANDALONE") == "true"
async def sx_docs_context() -> dict:
"""SX docs app context processor — fetches cross-service fragments."""
@@ -39,11 +41,30 @@ async def sx_docs_context() -> dict:
return ctx
async def sx_standalone_context() -> dict:
"""Minimal context for standalone mode — no cross-service fragments."""
from shared.infrastructure.context import base_context
ctx = await base_context()
ctx["menu_items"] = []
ctx["cart_mini"] = ""
ctx["auth_menu"] = ""
ctx["nav_tree"] = ""
return ctx
def create_app() -> "Quart":
from shared.infrastructure.factory import create_base_app
extra_kw = {}
if SX_STANDALONE:
extra_kw["no_oauth"] = True
extra_kw["no_db"] = True
app = create_base_app(
"sx",
context_fn=sx_docs_context,
context_fn=sx_standalone_context if SX_STANDALONE else sx_docs_context,
domain_services_fn=register_domain_services,
**extra_kw,
)
from sxc.pages import setup_sx_pages

View File

@@ -901,4 +901,30 @@ def register(url_prefix: str = "/") -> Blueprint:
return Response(generate(), content_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
# --- Header demos ---
@bp.get("/reference/api/trigger-event")
async def ref_trigger_event():
from shared.sx.helpers import sx_response
now = datetime.now().strftime("%H:%M:%S")
sx_src = f'(span :class "text-stone-800 text-sm" "Loaded at " (strong "{now}") " — check the border!")'
resp = sx_response(sx_src)
resp.headers["SX-Trigger"] = "showNotice"
return resp
@bp.get("/reference/api/retarget")
async def ref_retarget():
from shared.sx.helpers import sx_response
now = datetime.now().strftime("%H:%M:%S")
sx_src = f'(span :class "text-violet-700 text-sm" "Retargeted at " (strong "{now}"))'
resp = sx_response(sx_src)
resp.headers["SX-Retarget"] = "#ref-hdr-retarget-alt"
return resp
# --- Event demos ---
@bp.get("/reference/api/error-500")
async def ref_error_500():
return Response("Server error", status=500, content_type="text/plain")
return bp

View File

@@ -236,14 +236,15 @@ def _tokenize_bash(code: str) -> list[tuple[str, str]]:
return tokens
def highlight(code: str, language: str = "lisp") -> str:
"""Highlight code in the given language. Returns sx source."""
def highlight(code: str, language: str = "lisp"):
"""Highlight code in the given language. Returns SxExpr for wire format."""
from shared.sx.parser import SxExpr
if language in ("lisp", "sx", "sexp"):
return highlight_sx(code)
return SxExpr(highlight_sx(code))
elif language in ("python", "py"):
return highlight_python(code)
return SxExpr(highlight_python(code))
elif language in ("bash", "sh", "shell"):
return highlight_bash(code)
return SxExpr(highlight_bash(code))
# Fallback: no highlighting, just escaped text
escaped = code.replace("\\", "\\\\").replace('"', '\\"')
return f'(span "{escaped}")'
return SxExpr(f'(span "{escaped}")')

View File

@@ -261,6 +261,463 @@ EDIT_ROW_DATA = [
{"id": "4", "name": "Widget D", "price": "45.00", "stock": "67"},
]
# ---------------------------------------------------------------------------
# Reference: Header detail pages
# ---------------------------------------------------------------------------
HEADER_DETAILS: dict[str, dict] = {
# --- Request Headers ---
"SX-Request": {
"direction": "request",
"description": (
"Sent on every sx-initiated request. Allows the server to distinguish "
"AJAX partial requests from full page loads, and return the appropriate "
"response format (fragment vs full page)."
),
"example": (
';; Server-side: check for sx request\n'
'(if (header "SX-Request")\n'
' ;; Return a fragment\n'
' (div :class "result" "Partial content")\n'
' ;; Return full page\n'
' (~full-page-layout ...))'
),
},
"SX-Current-URL": {
"direction": "request",
"description": (
"Sends the browser's current URL so the server knows where the user is. "
"Useful for server-side logic that depends on context — e.g. highlighting "
"the current nav item, or returning context-appropriate content."
),
"example": (
';; Server reads the current URL to decide context\n'
'(let ((url (header "SX-Current-URL")))\n'
' (nav\n'
' (a :href "/docs" :class (if (starts-with? url "/docs") "active" "") "Docs")\n'
' (a :href "/api" :class (if (starts-with? url "/api") "active" "") "API")))'
),
},
"SX-Target": {
"direction": "request",
"description": (
"Tells the server which element will receive the response. "
"The server can use this to tailor the response — for example, "
"returning different content depending on whether the target is "
"a sidebar, modal, or main panel."
),
"example": (
';; Server checks target to decide response format\n'
'(let ((target (header "SX-Target")))\n'
' (if (= target "#sidebar")\n'
' (~compact-summary :data data)\n'
' (~full-detail :data data)))'
),
},
"SX-Components": {
"direction": "request",
"description": (
"Comma-separated list of component names the client already has cached. "
"The server can skip sending defcomp definitions the client already knows, "
"reducing response size. This is the component caching protocol."
),
"example": (
';; Client sends: SX-Components: ~card,~nav-link,~footer\n'
';; Server omits those defcomps from the response.\n'
';; Only new/changed components are sent.\n'
'(response\n'
' :components (filter-new known-components)\n'
' :content (~page-content))'
),
},
"SX-Css": {
"direction": "request",
"description": (
"Sends the CSS classes or hash the client already has. "
"The server uses this to send only new CSS rules the client needs, "
"avoiding duplicate rule injection. Part of the on-demand CSS protocol."
),
"example": (
';; Client sends hash of known CSS classes\n'
';; Server compares and only returns new classes\n'
'(let ((client-css (header "SX-Css")))\n'
' (set-header "SX-Css-Add"\n'
' (join "," (diff new-classes client-css))))'
),
},
"SX-History-Restore": {
"direction": "request",
"description": (
"Set to \"true\" when the browser restores a page from history (back/forward). "
"The server can use this to return cached content or skip side effects "
"that should only happen on initial navigation."
),
"example": (
';; Skip analytics on history restore\n'
'(when (not (header "SX-History-Restore"))\n'
' (track-page-view url))\n'
'(~page-content :data data)'
),
},
"SX-Css-Hash": {
"direction": "both",
"description": (
"Request: 8-character hash of the client's known CSS class set. "
"Response: hash of the cumulative CSS set after this response. "
"Client stores the response hash and sends it on the next request, "
"enabling efficient CSS delta tracking."
),
"example": (
';; Request header: SX-Css-Hash: a1b2c3d4\n'
';; Server compares hash to decide if CSS diff needed\n'
';;\n'
';; Response header: SX-Css-Hash: e5f6g7h8\n'
';; Client stores new hash for next request'
),
},
"SX-Prompt": {
"direction": "request",
"description": (
"Contains the value entered by the user in a window.prompt() dialog, "
"triggered by the sx-prompt attribute. Allows collecting a single text "
"input without a form."
),
"example": (
';; Button triggers a prompt dialog\n'
'(button :sx-get "/api/rename"\n'
' :sx-prompt "Enter new name:"\n'
' "Rename")\n'
'\n'
';; Server reads the prompted value\n'
'(let ((name (header "SX-Prompt")))\n'
' (span "Renamed to: " (strong name)))'
),
"demo": "ref-header-prompt-demo",
},
# --- Response Headers ---
"SX-Css-Add": {
"direction": "response",
"description": (
"Comma-separated list of new CSS class names added by this response. "
"The client injects the corresponding CSS rules into the document. "
"Only classes the client doesn't already have are included."
),
"example": (
';; Server response includes new CSS classes\n'
';; SX-Css-Add: bg-emerald-500,text-white,rounded-xl\n'
';;\n'
';; Client automatically injects rules for these\n'
';; classes from the style dictionary.'
),
},
"SX-Trigger": {
"direction": "response",
"description": (
"Dispatch custom DOM event(s) on the target element after the response "
"is received. Can be a simple event name or JSON for multiple events "
"with detail data. Useful for coordinating UI updates across components."
),
"example": (
';; Simple event\n'
';; SX-Trigger: itemAdded\n'
';;\n'
';; Multiple events with data\n'
';; SX-Trigger: {"itemAdded": {"id": 42}, "showNotification": {"message": "Saved!"}}\n'
';;\n'
';; Listen in SX:\n'
'(div :sx-on:itemAdded "this.querySelector(\'.count\').textContent = event.detail.id")'
),
"demo": "ref-header-trigger-demo",
},
"SX-Trigger-After-Swap": {
"direction": "response",
"description": (
"Like SX-Trigger, but fires after the DOM swap completes. "
"Use this when your event handler needs to reference the new DOM content "
"that was just swapped in."
),
"example": (
';; Server signals that new content needs initialization\n'
';; SX-Trigger-After-Swap: contentReady\n'
';;\n'
';; Client initializes after swap\n'
'(div :sx-on:contentReady "initCharts(this)")'
),
},
"SX-Trigger-After-Settle": {
"direction": "response",
"description": (
"Like SX-Trigger, but fires after the DOM has fully settled — "
"scripts executed, transitions complete. The latest point to react "
"to a response."
),
"example": (
';; SX-Trigger-After-Settle: animationReady\n'
';;\n'
';; Trigger animations after everything has settled\n'
'(div :sx-on:animationReady "this.classList.add(\'fade-in\')")'
),
},
"SX-Retarget": {
"direction": "response",
"description": (
"Override the target element for this response. The server can redirect "
"content to a different element than what the client specified in sx-target. "
"Useful for error messages or redirecting content dynamically."
),
"example": (
';; Client targets a form result area\n'
'(form :sx-post "/api/save"\n'
' :sx-target "#result" ...)\n'
'\n'
';; Server redirects errors to a different element\n'
';; SX-Retarget: #error-banner\n'
'(div :class "error" "Validation failed")'
),
"demo": "ref-header-retarget-demo",
},
"SX-Reswap": {
"direction": "response",
"description": (
"Override the swap strategy for this response. The server can change "
"how content is inserted regardless of what the client specified in sx-swap. "
"Useful when the server decides the swap mode based on the result."
),
"example": (
';; Client expects innerHTML swap\n'
'(button :sx-get "/api/check"\n'
' :sx-target "#panel" :sx-swap "innerHTML" ...)\n'
'\n'
';; Server overrides to append instead\n'
';; SX-Reswap: beforeend\n'
'(div :class "notification" "New item added")'
),
},
"SX-Redirect": {
"direction": "response",
"description": (
"Redirect the browser to a new URL using full page navigation. "
"Unlike sx-push-url which does client-side history, this triggers "
"a real browser navigation — useful after form submissions like login or checkout."
),
"example": (
';; After successful login, redirect to dashboard\n'
';; SX-Redirect: /dashboard\n'
';;\n'
';; Server handler:\n'
'(when (valid-credentials? user pass)\n'
' (set-header "SX-Redirect" "/dashboard")\n'
' (span "Redirecting..."))'
),
},
"SX-Refresh": {
"direction": "response",
"description": (
"Set to \"true\" to reload the current page. "
"A blunt tool — useful when server-side state has changed significantly "
"and a partial update won't suffice."
),
"example": (
';; After a major state change, force refresh\n'
';; SX-Refresh: true\n'
';;\n'
';; Server handler:\n'
'(when (deploy-complete?)\n'
' (set-header "SX-Refresh" "true")\n'
' (span "Deployed — refreshing..."))'
),
},
"SX-Location": {
"direction": "response",
"description": (
"Trigger client-side navigation: fetch the given URL, swap it into "
"#main-panel, and push to browser history. Like clicking an sx-boosted link, "
"but triggered from the server. Can be a URL string or JSON with options."
),
"example": (
';; Simple: navigate to a page\n'
';; SX-Location: /docs/introduction\n'
';;\n'
';; With options:\n'
';; SX-Location: {"path": "/docs/intro", "target": "#sidebar", "swap": "innerHTML"}'
),
},
"SX-Replace-Url": {
"direction": "response",
"description": (
"Replace the current URL using history.replaceState without creating "
"a new history entry. Useful for normalizing URLs after redirects, "
"or updating the URL to reflect server-resolved state."
),
"example": (
';; Normalize URL after slug resolution\n'
';; SX-Replace-Url: /docs/introduction\n'
';;\n'
';; Server handler:\n'
'(let ((canonical (resolve-slug slug)))\n'
' (set-header "SX-Replace-Url" canonical)\n'
' (~doc-content :slug canonical))'
),
},
}
# ---------------------------------------------------------------------------
# Reference: Event detail pages
# ---------------------------------------------------------------------------
EVENT_DETAILS: dict[str, dict] = {
"sx:beforeRequest": {
"description": (
"Fired on the triggering element before an sx request is issued. "
"Call event.preventDefault() to cancel the request entirely. "
"Useful for validation, confirmation, or conditional request logic."
),
"example": (
';; Cancel request if form is empty\n'
'(form :sx-post "/api/save"\n'
' :sx-target "#result"\n'
' :sx-on:sx:beforeRequest "if (!this.querySelector(\'input\').value) event.preventDefault()"\n'
' (input :name "data" :placeholder "Required")\n'
' (button :type "submit" "Save"))'
),
"demo": "ref-event-before-request-demo",
},
"sx:afterRequest": {
"description": (
"Fired on the triggering element after a successful sx response is received, "
"before the swap happens. The response data is available on event.detail. "
"Use this for logging, analytics, or pre-swap side effects."
),
"example": (
';; Log successful requests\n'
'(button :sx-get "/api/data"\n'
' :sx-target "#result"\n'
' :sx-on:sx:afterRequest "console.log(\'Response received\', event.detail)"\n'
' "Load data")'
),
},
"sx:afterSwap": {
"description": (
"Fired after the response content has been swapped into the DOM. "
"The new content is in place but scripts may not have executed yet. "
"Use this to initialize UI on newly inserted content."
),
"example": (
';; Initialize tooltips on new content\n'
'(div :sx-on:sx:afterSwap "initTooltips(this)"\n'
' (button :sx-get "/api/items"\n'
' :sx-target "#item-list"\n'
' "Load items")\n'
' (div :id "item-list"))'
),
},
"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": {
"description": (
"Fired when the server responds with an HTTP error (4xx or 5xx). "
"event.detail contains the status code and response. "
"Use this for error handling, showing notifications, or retry logic."
),
"example": (
';; Show error notification\n'
'(div :sx-on:sx:responseError "alert(\'Error: \' + event.detail.status)"\n'
' (button :sx-get "/api/risky"\n'
' :sx-target "#result"\n'
' "Try it")\n'
' (div :id "result"))'
),
"demo": "ref-event-response-error-demo",
},
"sx:sendError": {
"description": (
"Fired when the request fails to send — typically a network error, "
"DNS failure, or CORS issue. Unlike sx:responseError, no HTTP response "
"was received at all."
),
"example": (
';; Handle network failures\n'
'(div :sx-on:sx:sendError "this.querySelector(\'.status\').textContent = \'Offline\'"\n'
' (button :sx-get "/api/data"\n'
' :sx-target "#result"\n'
' "Load")\n'
' (span :class "status")\n'
' (div :id "result"))'
),
},
"sx:validationFailed": {
"description": (
"Fired when sx-validate is set and the form fails HTML5 validation. "
"The request is not sent. Use this to show custom validation UI "
"or highlight invalid fields."
),
"example": (
';; Highlight invalid fields\n'
'(form :sx-post "/api/save"\n'
' :sx-validate "true"\n'
' :sx-on:sx:validationFailed "this.classList.add(\'shake\')"\n'
' (input :type "email" :required "true" :name "email"\n'
' :placeholder "Email (required)")\n'
' (button :type "submit" "Save"))'
),
"demo": "ref-event-validation-failed-demo",
},
"sx:sseOpen": {
"description": (
"Fired when a Server-Sent Events connection is successfully established. "
"Use this to update connection status indicators."
),
"example": (
';; Show connected status\n'
'(div :sx-sse "/api/stream"\n'
' :sx-on:sx:sseOpen "this.querySelector(\'.status\').textContent = \'Connected\'"\n'
' (span :class "status" "Connecting...")\n'
' (div :id "messages"))'
),
},
"sx:sseMessage": {
"description": (
"Fired when an SSE message is received and swapped into the DOM. "
"event.detail contains the message data. Fires for each individual message."
),
"example": (
';; Count received messages\n'
'(div :sx-sse "/api/stream"\n'
' :sx-sse-swap "update"\n'
' :sx-on:sx:sseMessage "this.dataset.count = (parseInt(this.dataset.count||0)+1); this.querySelector(\'.count\').textContent = this.dataset.count"\n'
' (span :class "count" "0") " messages received"\n'
' (div :id "stream-content"))'
),
},
"sx:sseError": {
"description": (
"Fired when an SSE connection encounters an error or is closed unexpectedly. "
"Use this to show reconnection status or fall back to polling."
),
"example": (
';; Show disconnected status\n'
'(div :sx-sse "/api/stream"\n'
' :sx-on:sx:sseError "this.querySelector(\'.status\').textContent = \'Disconnected\'"\n'
' (span :class "status" "Connecting...")\n'
' (div :id "messages"))'
),
},
}
# ---------------------------------------------------------------------------
# Reference: Attribute detail pages
# ---------------------------------------------------------------------------

View File

@@ -2,14 +2,14 @@
(defcomp ~doc-placeholder (&key id)
(div :id id
(div :class "bg-stone-50 border border-stone-200 rounded p-4 mt-3"
(div :class "bg-stone-100 rounded p-4 mt-3"
(p :class "text-stone-400 italic text-sm"
"Trigger the demo to see the actual content."))))
(defcomp ~doc-oob-code (&key target-id text)
(div :id target-id :sx-swap-oob "innerHTML"
(div :class "bg-stone-50 border border-stone-200 rounded p-4 mt-3 overflow-x-auto"
(pre :class "text-sm whitespace-pre-wrap"
(div :class "bg-stone-100 rounded p-4 mt-3"
(pre :class "text-sm whitespace-pre-wrap break-words"
(code text)))))
(defcomp ~doc-attr-table (&key title rows)
@@ -17,7 +17,7 @@
(h3 :class "text-xl font-semibold text-stone-700" title)
(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-50"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "Attribute")
(th :class "px-3 py-2 font-medium text-stone-600" "Description")
(th :class "px-3 py-2 font-medium text-stone-600 text-center w-20" "In sx?")))
@@ -28,21 +28,33 @@
(h3 :class "text-xl font-semibold text-stone-700" title)
(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-50"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "Header")
(th :class "px-3 py-2 font-medium text-stone-600" "Value")
(th :class "px-3 py-2 font-medium text-stone-600" "Description")))
(tbody rows)))))
(defcomp ~doc-headers-row (&key name value description)
(defcomp ~doc-headers-row (&key name value description href)
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700 whitespace-nowrap" name)
(td :class "px-3 py-2 font-mono text-sm whitespace-nowrap"
(if href
(a :href href
:sx-get href :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
:class "text-violet-700 hover:text-violet-900 underline" name)
(span :class "text-violet-700" name)))
(td :class "px-3 py-2 font-mono text-sm text-stone-500" value)
(td :class "px-3 py-2 text-stone-700 text-sm" description)))
(defcomp ~doc-two-col-row (&key name description)
(defcomp ~doc-two-col-row (&key name description href)
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700 whitespace-nowrap" name)
(td :class "px-3 py-2 font-mono text-sm whitespace-nowrap"
(if href
(a :href href
:sx-get href :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
:class "text-violet-700 hover:text-violet-900 underline" name)
(span :class "text-violet-700" name)))
(td :class "px-3 py-2 text-stone-700 text-sm" description)))
(defcomp ~doc-two-col-table (&key title intro col1 col2 rows)
@@ -51,13 +63,13 @@
(when intro (p :class "text-stone-600 mb-6" intro))
(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-50"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" (or col1 "Name"))
(th :class "px-3 py-2 font-medium text-stone-600" (or col2 "Description"))))
(tbody rows)))))
(defcomp ~sx-docs-label ()
(span :class "font-mono" "(<x>)"))
(span :class "font-mono" "(<sx>)"))
(defcomp ~doc-clear-cache-btn ()
(button :onclick "localStorage.removeItem('sx-components-hash');localStorage.removeItem('sx-components-src');var e=Sx.getEnv();Object.keys(e).forEach(function(k){if(k.charAt(0)==='~')delete e[k]});var b=this;b.textContent='Cleared!';setTimeout(function(){b.textContent='Clear component cache'},2000)"
@@ -88,7 +100,8 @@
(~doc-headers-row
:name (get h "name")
:value (get h "value")
:description (get h "desc")))
:description (get h "desc")
:href (get h "href")))
headers))))
;; Build two-col table from a list of {name, desc} dicts.
@@ -98,7 +111,8 @@
:rows (<> (map (fn (item)
(~doc-two-col-row
:name (get item "name")
:description (get item "desc")))
:description (get item "desc")
:href (get item "href")))
items))))
;; Build all primitives category tables from a {category: [prim, ...]} dict.

File diff suppressed because one or more lines are too long

View File

@@ -11,7 +11,9 @@
(dict :label "Reference" :href "/reference/")
(dict :label "Protocols" :href "/protocols/wire-format")
(dict :label "Examples" :href "/examples/click-to-load")
(dict :label "Essays" :href "/essays/sx-sucks"))))
(dict :label "Essays" :href "/essays/")
(dict :label "Specs" :href "/specs/")
(dict :label "Bootstrappers" :href "/bootstrappers/"))))
(<> (map (lambda (item)
(~nav-link
:href (get item "href")
@@ -91,3 +93,45 @@
:label "sx" :href "/" :level 1 :colour "violet"
:items (~sx-main-nav :section section))
(~root-mobile-auto)))
;; ---------------------------------------------------------------------------
;; Standalone layouts (no root header, no auth — for sx-web.org)
;; ---------------------------------------------------------------------------
(defcomp ~sx-standalone-layout-full (&key section)
(~sx-header-row :nav (~sx-main-nav :section section)))
(defcomp ~sx-standalone-layout-oob (&key section)
(<> (~sx-header-row
:nav (~sx-main-nav :section section)
:oob true)
(~clear-oob-div :id "sx-header-child")))
(defcomp ~sx-standalone-layout-mobile (&key section)
(~mobile-menu-section
:label "sx" :href "/" :level 1 :colour "violet"
:items (~sx-main-nav :section section)))
(defcomp ~sx-standalone-section-layout-full (&key section sub-label sub-href sub-nav selected)
(~sx-header-row
:nav (~sx-main-nav :section section)
:child (~sx-sub-row :sub-label sub-label :sub-href sub-href
:sub-nav sub-nav :selected selected)))
(defcomp ~sx-standalone-section-layout-oob (&key section sub-label sub-href sub-nav selected)
(<> (~oob-header-sx :parent-id "sx-header-child"
:row (~sx-sub-row :sub-label sub-label :sub-href sub-href
:sub-nav sub-nav :selected selected))
(~sx-header-row
:nav (~sx-main-nav :section section)
:oob true)))
(defcomp ~sx-standalone-section-layout-mobile (&key section sub-label sub-href sub-nav)
(<>
(when sub-nav
(~mobile-menu-section
:label (or sub-label section) :href sub-href :level 2 :colour "violet"
:items sub-nav))
(~mobile-menu-section
:label "sx" :href "/" :level 1 :colour "violet"
:items (~sx-main-nav :section section))))

View File

@@ -55,15 +55,101 @@
(dict :label "Retry" :href "/examples/retry")))
(define essays-nav-items (list
(dict :label "sx sucks" :href "/essays/sx-sucks")
(dict :label "Why S-Expressions" :href "/essays/why-sexps")
(dict :label "The htmx/React Hybrid" :href "/essays/htmx-react-hybrid")
(dict :label "On-Demand CSS" :href "/essays/on-demand-css")
(dict :label "Client Reactivity" :href "/essays/client-reactivity")
(dict :label "SX Native" :href "/essays/sx-native")
(dict :label "The SX Manifesto" :href "/essays/sx-manifesto")
(dict :label "Tail-Call Optimization" :href "/essays/tail-call-optimization")
(dict :label "Continuations" :href "/essays/continuations")))
(dict :label "Why S-Expressions" :href "/essays/why-sexps"
:summary "Why SX uses s-expressions instead of HTML templates, JSX, or any other syntax.")
(dict :label "The htmx/React Hybrid" :href "/essays/htmx-react-hybrid"
:summary "How SX combines the server-driven simplicity of htmx with the component model of React.")
(dict :label "On-Demand CSS" :href "/essays/on-demand-css"
:summary "The CSSX system: keyword atoms resolved to class names, CSS rules injected on first use.")
(dict :label "Client Reactivity" :href "/essays/client-reactivity"
:summary "Reactive UI updates without a virtual DOM, diffing library, or build step.")
(dict :label "SX Native" :href "/essays/sx-native"
:summary "Extending SX beyond the browser — native desktop and mobile rendering from the same source.")
(dict :label "The SX Manifesto" :href "/essays/sx-manifesto"
:summary "The design principles behind SX: simplicity, self-hosting, and s-expressions all the way down.")
(dict :label "Tail-Call Optimization" :href "/essays/tail-call-optimization"
:summary "How SX implements proper tail calls via trampolining in a language that doesn't have them.")
(dict :label "Continuations" :href "/essays/continuations"
:summary "First-class continuations in a tree-walking evaluator — theory and implementation.")
(dict :label "Strange Loops" :href "/essays/godel-escher-bach"
:summary "Self-reference, and the tangled hierarchy of a language that defines itself.")
(dict :label "The Reflexive Web" :href "/essays/reflexive-web"
:summary "A web where pages can inspect, modify, and extend their own rendering pipeline.")
(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.")))
(define specs-nav-items (list
(dict :label "Architecture" :href "/specs/")
(dict :label "Core" :href "/specs/core")
(dict :label "Parser" :href "/specs/parser")
(dict :label "Evaluator" :href "/specs/evaluator")
(dict :label "Primitives" :href "/specs/primitives")
(dict :label "Renderer" :href "/specs/renderer")
(dict :label "Adapters" :href "/specs/adapters")
(dict :label "DOM Adapter" :href "/specs/adapter-dom")
(dict :label "HTML Adapter" :href "/specs/adapter-html")
(dict :label "SX Wire Adapter" :href "/specs/adapter-sx")
(dict :label "Browser" :href "/specs/browser")
(dict :label "SxEngine" :href "/specs/engine")
(dict :label "Orchestration" :href "/specs/orchestration")
(dict :label "Boot" :href "/specs/boot")
(dict :label "CSSX" :href "/specs/cssx")))
(define bootstrappers-nav-items (list
(dict :label "Overview" :href "/bootstrappers/")
(dict :label "JavaScript" :href "/bootstrappers/javascript")))
;; Spec file registry — canonical metadata for spec viewer pages.
;; Python only handles file I/O (read-spec-file); all metadata lives here.
;; The :prose field is an English-language description shown alongside the
;; canonical s-expression source.
(define core-spec-items (list
(dict :slug "parser" :filename "parser.sx" :title "Parser"
:desc "Tokenization and parsing of SX source text into AST."
:prose "The parser converts SX source text into an abstract syntax tree. It tokenizes the input into atoms, strings, numbers, keywords, and delimiters, then assembles them into nested list structures. The parser is intentionally minimal — s-expressions need very little syntax to parse. Special reader macros handle quasiquote (\\`), unquote (~), splice (~@), and the quote (') shorthand. The output is a tree of plain lists, symbols, keywords, strings, and numbers that the evaluator can walk directly.")
(dict :slug "evaluator" :filename "eval.sx" :title "Evaluator"
:desc "Tree-walking evaluation of SX expressions."
:prose "The evaluator walks the AST produced by the parser and reduces it to values. It implements lexical scoping with closures, special forms (define, let, if, cond, fn, defcomp, defmacro, quasiquote, set!, do), and function application. Macros are expanded at eval time. Component definitions (defcomp) create callable component objects that participate in the rendering pipeline. The evaluator delegates rendering expressions — HTML tags, components, fragments — to whichever adapter is active, making the same source renderable to DOM nodes, HTML strings, or SX wire format.")
(dict :slug "primitives" :filename "primitives.sx" :title "Primitives"
:desc "All built-in pure functions and their signatures."
:prose "Primitives are the built-in functions available in every SX environment. Each entry declares a name, parameter signature, and semantics. Bootstrap compilers implement these natively per target (JavaScript, Python, etc.). The registry covers arithmetic, comparison, string manipulation, list operations, dict operations, type predicates, and control flow helpers. All primitives are pure — they take values and return values with no side effects. Platform-specific operations (DOM access, HTTP, file I/O) are provided separately via platform bridge functions, not primitives.")
(dict :slug "renderer" :filename "render.sx" :title "Renderer"
:desc "Shared rendering registries and utilities used by all adapters."
:prose "The renderer defines what is renderable and how arguments are parsed, but not the output format. It maintains registries of known HTML tags, SVG tags, void elements, and boolean attributes. It specifies how keyword arguments on elements become HTML attributes, how children are collected, and how special attributes (class, style, data-*) are handled. All three adapters (DOM, HTML, SX wire) share these definitions so they agree on what constitutes valid markup. The renderer also defines the StyleValue type used by the CSSX on-demand CSS system.")))
(define adapter-spec-items (list
(dict :slug "adapter-dom" :filename "adapter-dom.sx" :title "DOM Adapter"
:desc "Renders SX expressions to live DOM nodes. Browser-only."
:prose "The DOM adapter renders evaluated SX expressions into live browser DOM nodes — Elements, Text nodes, and DocumentFragments. It mirrors the HTML adapter's logic but produces DOM objects instead of strings. This is the adapter used by the browser-side SX runtime for initial mount, hydration, and dynamic updates. It handles element creation, attribute setting (including event handlers and style objects), SVG namespace handling, and fragment composition.")
(dict :slug "adapter-html" :filename "adapter-html.sx" :title "HTML Adapter"
:desc "Renders SX expressions to HTML strings. Server-side."
:prose "The HTML adapter renders evaluated SX expressions to HTML strings. It is used server-side to produce complete HTML pages and fragments. It handles void elements (self-closing tags like <br>, <img>), boolean attributes, style serialization, class merging, and proper escaping. The output is standard HTML5 that any browser can parse.")
(dict :slug "adapter-sx" :filename "adapter-sx.sx" :title "SX Wire Adapter"
:desc "Serializes SX for client-side rendering. Component calls stay unexpanded."
:prose "The SX wire adapter serializes expressions as SX source text for transmission to the browser, where sx.js renders them client-side. Unlike the HTML adapter, component calls (~name ...) are NOT expanded — they are sent to the client as-is, allowing the browser to render them with its local component registry. HTML tags ARE serialized as s-expression source. This is the format used for SX-over-HTTP responses and the page boot payload.")
(dict :slug "engine" :filename "engine.sx" :title "SxEngine"
:desc "Pure logic for fetch, swap, history, SSE, triggers, morph, and indicators."
:prose "The engine specifies the pure logic of the browser-side fetch/swap/history system. Like HTMX but native to SX. It defines trigger parsing (click, submit, intersect, poll, load, revealed), swap algorithms (innerHTML, outerHTML, morph, beforebegin, etc.), the morph/diff algorithm for patching existing DOM, history management (push-url, replace-url, popstate), out-of-band swap identification, Server-Sent Events parsing, retry logic with exponential backoff, request header building, response header processing, and optimistic UI updates. This file contains no browser API calls — all platform interaction is in orchestration.sx.")
(dict :slug "orchestration" :filename "orchestration.sx" :title "Orchestration"
:desc "Browser wiring that binds engine logic to DOM events, fetch, and lifecycle."
:prose "Orchestration is the browser wiring layer. It binds the pure engine logic to actual browser APIs: DOM event listeners, fetch(), AbortController, setTimeout/setInterval, IntersectionObserver, history.pushState, and EventSource (SSE). It implements the full request lifecycle — from trigger through fetch through swap — including CSS tracking, response type detection (SX vs HTML), OOB swap processing, script activation, element boosting, and preload. Dependency is strictly one-way: orchestration depends on engine, never the reverse.")))
(define browser-spec-items (list
(dict :slug "boot" :filename "boot.sx" :title "Boot"
:desc "Browser startup lifecycle: mount, hydrate, script processing."
:prose "Boot handles the browser startup sequence and provides the public API for mounting SX content. On page load it: (1) initializes CSS tracking, (2) loads the style dictionary from inline JSON, (3) processes <script type=\"text/sx\"> tags (component definitions and mount directives), (4) hydrates [data-sx] elements, and (5) activates the engine on all elements. It also provides the public mount/hydrate/update/render-component API, and the head element hoisting logic that moves <meta>, <title>, and <link> tags from rendered content into <head>.")
(dict :slug "cssx" :filename "cssx.sx" :title "CSSX"
:desc "On-demand CSS: style dictionary, keyword resolution, rule injection."
:prose "CSSX is the on-demand CSS system. It resolves keyword atoms (:flex, :gap-4, :hover:bg-sky-200) into StyleValue objects with content-addressed class names, injecting CSS rules into the document on first use. The style dictionary is a JSON blob containing: atoms (keyword to CSS declarations), pseudo-variants (hover:, focus:, etc.), responsive breakpoints (md:, lg:, etc.), keyframe animations, arbitrary value patterns, and child selector prefixes (space-x-, space-y-). Classes are only emitted when used, keeping the CSS payload minimal. The dictionary is typically served inline in a <script type=\"text/sx-styles\"> tag.")))
(define all-spec-items (concat core-spec-items (concat adapter-spec-items browser-spec-items)))
(define find-spec
(fn (slug)
(some (fn (item)
(when (= (get item "slug") slug) item))
all-spec-items)))
;; Find the current nav label for a slug by matching href suffix.
;; Returns the label string or nil if no match.

View File

@@ -1,4 +1,5 @@
;; Reference page layouts — receive data from Python primitives
;; @css bg-blue-100 text-blue-700 bg-emerald-100 text-emerald-700 bg-amber-100 text-amber-700
(defcomp ~reference-attrs-content (&key req-table beh-table uniq-table)
(~doc-page :title "Attribute Reference"
@@ -45,6 +46,40 @@
"Trigger the demo to see the raw response the server sends.")
(~doc-placeholder :id wire-placeholder-id)))))
(defcomp ~reference-header-detail-content (&key title direction description
example-code demo)
(~doc-page :title title
(let ((badge-class (if (= direction "request")
"bg-blue-100 text-blue-700"
(if (= direction "response")
"bg-emerald-100 text-emerald-700"
"bg-amber-100 text-amber-700")))
(badge-label (if (= direction "request") "Request Header"
(if (= direction "response") "Response Header"
"Request & Response"))))
(div :class "flex items-center gap-3 mb-4"
(span :class (str "text-xs font-medium px-2 py-1 rounded " badge-class)
badge-label)))
(p :class "text-stone-600 mb-6" description)
(when demo
(~example-card :title "Demo"
(~example-demo demo)))
(when example-code
(<>
(h3 :class "text-lg font-semibold text-stone-700 mt-6" "Example usage")
(~example-source :code (highlight example-code "lisp"))))))
(defcomp ~reference-event-detail-content (&key title description example-code demo)
(~doc-page :title title
(p :class "text-stone-600 mb-6" description)
(when demo
(~example-card :title "Demo"
(~example-demo demo)))
(when example-code
(<>
(h3 :class "text-lg font-semibold text-stone-700 mt-6" "Example usage")
(~example-source :code (highlight example-code "lisp"))))))
(defcomp ~reference-attr-not-found (&key slug)
(~doc-page :title "Not Found"
(p :class "text-stone-600"

330
sx/sx/specs.sx Normal file
View File

@@ -0,0 +1,330 @@
;; Spec viewer components — display canonical SX specification source
;; ---------------------------------------------------------------------------
;; Architecture intro page
;; ---------------------------------------------------------------------------
(defcomp ~spec-architecture-content ()
(~doc-page :title "Spec Architecture"
(div :class "space-y-8"
(div :class "space-y-4"
(p :class "text-lg text-stone-600"
"SX is defined in SX. The canonical specification is a set of s-expression files that are both documentation and executable definition. Bootstrap compilers read these files to generate native implementations in JavaScript, Python, Rust, or any other target.")
(p :class "text-stone-600"
"The spec is split into two layers: a "
(strong "core") " that defines the language itself, and "
(strong "adapters") " that connect it to specific environments."))
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Core")
(p :class "text-stone-600"
"The core is platform-independent. It defines how SX source is parsed, how expressions are evaluated, what primitives exist, and what shared rendering definitions all adapters use. These four files are the language.")
(div :class "overflow-x-auto rounded border border-stone-200"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "File")
(th :class "px-3 py-2 font-medium text-stone-600" "Role")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700"
(a :href "/specs/parser" :class "hover:underline"
:sx-get "/specs/parser" :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
"parser.sx"))
(td :class "px-3 py-2 text-stone-700" "Tokenization and parsing of SX source text into AST"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700"
(a :href "/specs/evaluator" :class "hover:underline"
:sx-get "/specs/evaluator" :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
"eval.sx"))
(td :class "px-3 py-2 text-stone-700" "Tree-walking evaluation of SX expressions"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700"
(a :href "/specs/primitives" :class "hover:underline"
:sx-get "/specs/primitives" :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
"primitives.sx"))
(td :class "px-3 py-2 text-stone-700" "All built-in pure functions and their signatures"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700"
(a :href "/specs/renderer" :class "hover:underline"
:sx-get "/specs/renderer" :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
"render.sx"))
(td :class "px-3 py-2 text-stone-700" "Shared rendering registries and utilities used by all adapters"))))))
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Adapters")
(p :class "text-stone-600"
"Adapters are selectable rendering backends. Each one takes the same evaluated expression tree and produces output for a specific environment. You only need the adapters relevant to your target.")
(div :class "overflow-x-auto rounded border border-stone-200"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "File")
(th :class "px-3 py-2 font-medium text-stone-600" "Output")
(th :class "px-3 py-2 font-medium text-stone-600" "Environment")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700"
(a :href "/specs/adapter-dom" :class "hover:underline"
:sx-get "/specs/adapter-dom" :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
"adapter-dom.sx"))
(td :class "px-3 py-2 text-stone-700" "Live DOM nodes")
(td :class "px-3 py-2 text-stone-500" "Browser"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700"
(a :href "/specs/adapter-html" :class "hover:underline"
:sx-get "/specs/adapter-html" :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
"adapter-html.sx"))
(td :class "px-3 py-2 text-stone-700" "HTML strings")
(td :class "px-3 py-2 text-stone-500" "Server"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700"
(a :href "/specs/adapter-sx" :class "hover:underline"
:sx-get "/specs/adapter-sx" :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
"adapter-sx.sx"))
(td :class "px-3 py-2 text-stone-700" "SX wire format")
(td :class "px-3 py-2 text-stone-500" "Server to client"))))))
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Engine")
(p :class "text-stone-600"
"The engine is the browser-side fetch/swap/history system. It processes "
(code :class "text-violet-700 text-sm" "sx-*")
" attributes on elements to make HTTP requests, swap content, manage browser history, and handle events. It is split into two files: pure logic ("
(code :class "text-violet-700 text-sm" "engine.sx")
") and browser wiring ("
(code :class "text-violet-700 text-sm" "orchestration.sx")
").")
(div :class "overflow-x-auto rounded border border-stone-200"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "File")
(th :class "px-3 py-2 font-medium text-stone-600" "Role")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700"
(a :href "/specs/engine" :class "hover:underline"
:sx-get "/specs/engine" :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
"engine.sx"))
(td :class "px-3 py-2 text-stone-700" "Pure logic — trigger parsing, swap algorithms, morph, history, SSE, indicators"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700"
(a :href "/specs/orchestration" :class "hover:underline"
:sx-get "/specs/orchestration" :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
"orchestration.sx"))
(td :class "px-3 py-2 text-stone-700" "Browser wiring — binds engine to DOM events, fetch, request lifecycle"))))))
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Browser")
(p :class "text-stone-600"
"Browser-level support: startup lifecycle and on-demand CSS. "
(code :class "text-violet-700 text-sm" "boot.sx")
" handles page load — processing scripts, mounting content, and hydrating elements. "
(code :class "text-violet-700 text-sm" "cssx.sx")
" provides the on-demand CSS system that resolves keyword atoms into class names and injects rules as needed.")
(div :class "overflow-x-auto rounded border border-stone-200"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "File")
(th :class "px-3 py-2 font-medium text-stone-600" "Role")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700"
(a :href "/specs/boot" :class "hover:underline"
:sx-get "/specs/boot" :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
"boot.sx"))
(td :class "px-3 py-2 text-stone-700" "Browser startup lifecycle — mount, hydrate, script processing, head hoisting"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700"
(a :href "/specs/cssx" :class "hover:underline"
:sx-get "/specs/cssx" :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
"cssx.sx"))
(td :class "px-3 py-2 text-stone-700" "On-demand CSS — style dictionary, keyword resolution, rule injection"))))))
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Dependency graph")
(div :class "bg-stone-100 rounded-lg p-5 mx-auto max-w-3xl"
(pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words font-mono text-stone-700"
"parser.sx (standalone — no dependencies)
primitives.sx (standalone — declarative registry)
eval.sx depends on: parser, primitives
render.sx (standalone — shared registries)
adapter-dom.sx depends on: render, eval
adapter-html.sx depends on: render, eval
adapter-sx.sx depends on: render, eval
engine.sx depends on: eval, adapter-dom
orchestration.sx depends on: engine, adapter-dom
cssx.sx depends on: render
boot.sx depends on: cssx, orchestration, adapter-dom, render")))
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Self-hosting")
(p :class "text-stone-600"
"Every spec file is written in the same restricted subset of SX that the evaluator itself defines. A bootstrap compiler for a new target only needs to understand this subset — roughly 20 special forms and 80 primitives — to generate a fully native implementation. The spec files are the single source of truth; implementations are derived artifacts.")
(p :class "text-stone-600"
"This is not a theoretical exercise. The JavaScript implementation ("
(code :class "text-violet-700 text-sm" "sx.js")
") and the Python implementation ("
(code :class "text-violet-700 text-sm" "shared/sx/")
") are both generated from these spec files via "
(code :class "text-violet-700 text-sm" "bootstrap_js.py")
" and its Python counterpart.")))))
;; ---------------------------------------------------------------------------
;; Overview pages (Core / Adapters) — show truncated previews of each file
;; ---------------------------------------------------------------------------
(defcomp ~spec-overview-content (&key spec-title spec-files)
(~doc-page :title (or spec-title "Specs")
(p :class "text-stone-600 mb-6"
(case spec-title
"Core Language"
"The core specification defines the language itself — parsing, evaluation, primitives, and shared rendering definitions. These four files are platform-independent and sufficient to implement SX on any target."
"Adapters & Engine"
"Adapters connect the core language to specific environments. Each adapter takes evaluated expression trees and produces output for its target. The engine adds browser-side fetch/swap behaviour, split into pure logic and browser orchestration."
"Browser"
"Browser-level support: the startup lifecycle that boots SX in the browser, and the on-demand CSS system that resolves keyword atoms into Tailwind-compatible class names."
:else ""))
(div :class "space-y-8"
(map (fn (spec)
(div :class "space-y-3"
(div :class "flex items-baseline gap-3"
(h2 :class "text-2xl font-semibold text-stone-800"
(a :href (get spec "href")
:sx-get (get spec "href") :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
:class "text-violet-700 hover:text-violet-900 underline"
(get spec "title")))
(span :class "text-sm text-stone-400 font-mono" (get spec "filename")))
(p :class "text-stone-600" (get spec "desc"))
(when (get spec "prose")
(p :class "text-sm text-stone-500 leading-relaxed" (get spec "prose")))
(div :class "bg-stone-100 rounded-lg p-5 max-h-72 overflow-y-auto"
(pre :class "text-xs leading-relaxed whitespace-pre-wrap break-words"
(code (highlight (get spec "source") "sx"))))))
spec-files))))
;; ---------------------------------------------------------------------------
;; Detail page — full source of a single spec file
;; ---------------------------------------------------------------------------
(defcomp ~spec-detail-content (&key spec-title spec-desc spec-filename spec-source spec-prose)
(~doc-page :title spec-title
(div :class "flex items-baseline gap-3 mb-4"
(span :class "text-sm text-stone-400 font-mono" spec-filename)
(span :class "text-sm text-stone-500" spec-desc))
(when spec-prose
(div :class "mb-6 space-y-3"
(p :class "text-stone-600 leading-relaxed" spec-prose)
(p :class "text-xs text-stone-400 italic"
"The s-expression source below is the canonical specification. "
"The English description above is a summary.")))
(div :class "bg-stone-100 rounded-lg p-5 mx-auto max-w-3xl"
(pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words"
(code (highlight spec-source "sx"))))))
;; ---------------------------------------------------------------------------
;; Bootstrappers — summary index
;; ---------------------------------------------------------------------------
(defcomp ~bootstrappers-index-content ()
(~doc-page :title "Bootstrappers"
(div :class "space-y-6"
(p :class "text-lg text-stone-600"
"A bootstrapper reads the canonical " (code :class "text-violet-700 text-sm" ".sx")
" specification files and emits a native implementation for a specific target. "
"The spec files are the single source of truth — bootstrappers are the bridge from specification to runnable code.")
(p :class "text-stone-600"
"Each bootstrapper is a compiler that understands the restricted SX subset used in the spec files "
"(roughly 20 special forms and 80 primitives) and translates it into idiomatic target code. "
"Platform-specific operations (DOM access, HTTP, timers) are emitted as native implementations "
"rather than translated from SX.")
(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" "Target")
(th :class "px-3 py-2 font-medium text-stone-600" "Bootstrapper")
(th :class "px-3 py-2 font-medium text-stone-600" "Output")
(th :class "px-3 py-2 font-medium text-stone-600" "Status")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "JavaScript")
(td :class "px-3 py-2 font-mono text-sm text-violet-700"
(a :href "/bootstrappers/javascript" :class "hover:underline"
"bootstrap_js.py"))
(td :class "px-3 py-2 font-mono text-sm text-stone-500" "sx-browser.js")
(td :class "px-3 py-2 text-green-600" "Live"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Python")
(td :class "px-3 py-2 font-mono text-sm text-stone-400" "bootstrap_py.py")
(td :class "px-3 py-2 font-mono text-sm text-stone-400" "shared/sx/")
(td :class "px-3 py-2 text-stone-400" "Planned"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Rust")
(td :class "px-3 py-2 font-mono text-sm text-stone-400" "bootstrap_rs.py")
(td :class "px-3 py-2 font-mono text-sm text-stone-400" "sx-native")
(td :class "px-3 py-2 text-stone-400" "Planned")))))
)))
;; ---------------------------------------------------------------------------
;; Bootstrapper detail — shows bootstrapper source + generated output
;; ---------------------------------------------------------------------------
;; @css border-violet-300 animate-pulse
(defcomp ~bootstrapper-js-content (&key bootstrapper-source bootstrapped-output)
(~doc-page :title "JavaScript Bootstrapper"
(div :class "space-y-8"
(div :class "space-y-3"
(p :class "text-stone-600"
"This page reads the canonical " (code :class "text-violet-700 text-sm" ".sx")
" spec files, runs the Python bootstrapper, and displays both the compiler source and its generated JavaScript output. "
"The generated code below is live — it was produced by the bootstrapper at page load time, not served from a static file.")
(p :class "text-xs text-stone-400 italic"
"The sx-browser.js powering this page IS the bootstrapped output. "
"This page re-runs the bootstrapper to display the source and result."))
(div :class "space-y-3"
(div :class "flex items-baseline gap-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Bootstrapper")
(span :class "text-sm text-stone-400 font-mono" "bootstrap_js.py"))
(p :class "text-sm text-stone-500"
"The compiler reads " (code :class "text-violet-700 text-sm" ".sx")
" spec files (parser, eval, primitives, render, adapters, engine, orchestration, boot, cssx) "
"and emits a standalone JavaScript file. Platform bridge functions (DOM operations, fetch, timers) "
"are emitted as native JS implementations.")
(div :class "bg-stone-100 rounded-lg p-5 max-h-96 overflow-y-auto border border-stone-200"
(pre :class "text-xs leading-relaxed whitespace-pre-wrap break-words"
(code (highlight bootstrapper-source "python")))))
(div :class "space-y-3"
(div :class "flex items-baseline gap-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Generated Output")
(span :class "text-sm text-stone-400 font-mono" "sx-browser.js"))
(p :class "text-sm text-stone-500"
"The JavaScript below was generated by running the bootstrapper against the current spec files. "
"It is a complete, self-contained SX runtime — parser, evaluator, DOM adapter, engine, and CSS system.")
(div :class "bg-stone-100 rounded-lg p-5 max-h-96 overflow-y-auto border border-violet-300"
(pre :class "text-xs leading-relaxed whitespace-pre-wrap break-words"
(code (highlight bootstrapped-output "javascript"))))))))
;; ---------------------------------------------------------------------------
;; Not found
;; ---------------------------------------------------------------------------
(defcomp ~spec-not-found (&key slug)
(~doc-page :title "Spec Not Found"
(p :class "text-stone-600"
"No specification found for \"" slug "\". This spec may not exist yet.")))

View File

@@ -16,8 +16,8 @@
children))
(defcomp ~doc-code (&key code)
(div :class "bg-stone-50 border border-stone-200 rounded-lg p-4 overflow-x-auto"
(pre :class "text-sm" (code code))))
(div :class "bg-stone-100 rounded-lg p-5 mx-auto max-w-3xl"
(pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words" (code code))))
(defcomp ~doc-note (&key &rest children)
(div :class "border-l-4 border-violet-400 bg-violet-50 p-4 text-stone-700 text-sm"
@@ -27,7 +27,7 @@
(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-50"
(tr :class "border-b border-stone-200 bg-stone-100"
(map (fn (h) (th :class "px-3 py-2 font-medium text-stone-600" h)) headers)))
(tbody
(map (fn (row)
@@ -61,15 +61,15 @@
(defcomp ~doc-nav (&key items current)
(nav :class "flex flex-wrap gap-2 mb-8"
(map (fn (item)
(a :href (nth 1 item)
:sx-get (nth 1 item)
(a :href (nth item 1)
:sx-get (nth item 1)
:sx-target "#main-panel"
:sx-select "#main-panel"
:sx-swap "outerHTML"
:sx-push-url "true"
:class (str "px-3 py-1.5 rounded text-sm font-medium no-underline "
(if (= (nth 0 item) current)
(if (= (nth item 0) current)
"bg-violet-100 text-violet-800"
"bg-stone-100 text-stone-600 hover:bg-stone-200"))
(nth 0 item)))
(nth item 0)))
items)))

View File

@@ -2,24 +2,24 @@
(defcomp ~example-card (&key title description &rest children)
(div :class "border border-stone-200 rounded-lg overflow-hidden"
(div :class "bg-stone-50 px-4 py-3 border-b border-stone-200"
(div :class "bg-stone-100 px-4 py-3 border-b border-stone-200"
(h3 :class "font-semibold text-stone-800" title)
(when description
(p :class "text-sm text-stone-500 mt-1" description)))
(div :class "p-4" children)))
(defcomp ~example-demo (&key &rest children)
(div :class "border border-dashed border-stone-300 rounded p-4 bg-white" children))
(div :class "border border-dashed border-stone-300 rounded p-4 bg-stone-100" children))
(defcomp ~example-source (&key code)
(div :class "bg-stone-50 border border-stone-200 rounded p-4 mt-3 overflow-x-auto"
(pre :class "text-sm" (code code))))
(div :class "bg-stone-100 rounded p-5 mt-3 mx-auto max-w-3xl"
(pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words" (code code))))
;; --- Click to load demo ---
(defcomp ~click-to-load-demo ()
(div :class "space-y-4"
(div :id "click-result" :class "p-4 rounded bg-stone-50 text-stone-500 text-center"
(div :id "click-result" :class "p-4 rounded bg-stone-100 text-stone-500 text-center"
"Click the button to load content.")
(button
:sx-get "/examples/api/click"
@@ -50,7 +50,7 @@
(button :type "submit"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Submit"))
(div :id "form-result" :class "p-3 rounded bg-stone-50 text-stone-500 text-sm text-center"
(div :id "form-result" :class "p-3 rounded bg-stone-100 text-stone-500 text-sm text-center"
"Submit the form to see the result.")))
(defcomp ~form-result (&key name)
@@ -66,7 +66,7 @@
:sx-get "/examples/api/poll"
:sx-trigger "load, every 2s"
:sx-swap "innerHTML"
:class "p-4 rounded border border-stone-200 bg-white text-center font-mono"
:class "p-4 rounded border border-stone-200 bg-stone-100 text-center font-mono"
"Loading...")))
(defcomp ~poll-result (&key time count)
@@ -91,7 +91,7 @@
(th :class "px-3 py-2 font-medium text-stone-600 w-20" "")))
(tbody :id "delete-rows"
(map (fn (item)
(~delete-row :id (nth 0 item) :name (nth 1 item)))
(~delete-row :id (nth item 0) :name (nth item 1)))
items)))))
(defcomp ~delete-row (&key id name)
@@ -145,10 +145,10 @@
(defcomp ~oob-demo ()
(div :class "space-y-4"
(div :class "grid grid-cols-2 gap-4"
(div :id "oob-box-a" :class "p-4 rounded border border-stone-200 bg-white text-center"
(div :id "oob-box-a" :class "p-4 rounded border border-stone-200 bg-stone-100 text-center"
(p :class "text-stone-500" "Box A")
(p :class "text-sm text-stone-400" "Waiting..."))
(div :id "oob-box-b" :class "p-4 rounded border border-stone-200 bg-white text-center"
(div :id "oob-box-b" :class "p-4 rounded border border-stone-200 bg-stone-100 text-center"
(p :class "text-stone-500" "Box B")
(p :class "text-sm text-stone-400" "Waiting...")))
(button
@@ -167,7 +167,7 @@
:sx-get "/examples/api/lazy"
:sx-trigger "load"
:sx-swap "innerHTML"
:class "p-6 rounded border border-stone-200 bg-stone-50 text-center"
:class "p-6 rounded border border-stone-200 bg-stone-100 text-center"
(div :class "animate-pulse space-y-2"
(div :class "h-4 bg-stone-200 rounded w-3/4 mx-auto")
(div :class "h-4 bg-stone-200 rounded w-1/2 mx-auto")))))
@@ -328,7 +328,7 @@
(p :class "text-sm text-stone-400" "Messages will appear here."))))
(defcomp ~reset-message (&key message time)
(div :class "px-3 py-2 bg-stone-50 rounded text-sm text-stone-700"
(div :class "px-3 py-2 bg-stone-100 rounded text-sm text-stone-700"
(str "[" time "] " message)))
;; --- Edit row demo ---
@@ -344,7 +344,7 @@
(th :class "px-3 py-2 font-medium text-stone-600 w-24" "")))
(tbody :id "edit-rows"
(map (fn (row)
(~edit-row-view :id (nth 0 row) :name (nth 1 row) :price (nth 2 row) :stock (nth 3 row)))
(~edit-row-view :id (nth row 0) :name (nth row 1) :price (nth row 2) :stock (nth row 3)))
rows)))))
(defcomp ~edit-row-view (&key id name price stock)
@@ -415,7 +415,7 @@
(th :class "px-3 py-2 font-medium text-stone-600" "Status")))
(tbody :id "bulk-table"
(map (fn (u)
(~bulk-row :id (nth 0 u) :name (nth 1 u) :email (nth 2 u) :status (nth 3 u)))
(~bulk-row :id (nth u 0) :name (nth u 1) :email (nth u 2) :status (nth u 3)))
users))))))
(defcomp ~bulk-row (&key id name email status)
@@ -488,7 +488,7 @@
:sx-swap "innerHTML"
:class "px-3 py-1.5 bg-stone-600 text-white rounded text-sm hover:bg-stone-700"
"Full Dashboard"))
(div :id "filter-target" :class "border border-stone-200 rounded p-4 bg-white"
(div :id "filter-target" :class "border border-stone-200 rounded p-4 bg-stone-100"
(p :class "text-sm text-stone-400" "Click a button to load content."))))
;; --- Tabs demo ---
@@ -525,7 +525,7 @@
:sx-swap "innerHTML"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Load with animation")
(div :id "anim-target" :class "p-4 rounded border border-stone-200 bg-white text-center"
(div :id "anim-target" :class "p-4 rounded border border-stone-200 bg-stone-100 text-center"
(p :class "text-stone-400" "Content will fade in here."))))
(defcomp ~anim-result (&key color time)
@@ -552,7 +552,7 @@
:sx-get "/examples/api/dialog/close"
:sx-target "#dialog-container"
:sx-swap "innerHTML")
(div :class "relative bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4 space-y-4"
(div :class "relative bg-stone-100 rounded-lg shadow-xl p-6 max-w-md w-full mx-4 space-y-4"
(h3 :class "text-lg font-semibold text-stone-800" title)
(p :class "text-stone-600" message)
(div :class "flex justify-end gap-2"
@@ -573,23 +573,23 @@
(defcomp ~keyboard-shortcuts-demo ()
(div :class "space-y-4"
(div :class "p-4 rounded border border-stone-200 bg-stone-50"
(div :class "p-4 rounded border border-stone-200 bg-stone-100"
(p :class "text-sm text-stone-600 font-medium mb-2" "Keyboard shortcuts:")
(div :class "flex gap-4"
(div :class "flex items-center gap-1"
(kbd :class "px-2 py-0.5 bg-white border border-stone-300 rounded text-xs font-mono" "s")
(kbd :class "px-2 py-0.5 bg-stone-100 border border-stone-300 rounded text-xs font-mono" "s")
(span :class "text-sm text-stone-500" "Search"))
(div :class "flex items-center gap-1"
(kbd :class "px-2 py-0.5 bg-white border border-stone-300 rounded text-xs font-mono" "n")
(kbd :class "px-2 py-0.5 bg-stone-100 border border-stone-300 rounded text-xs font-mono" "n")
(span :class "text-sm text-stone-500" "New item"))
(div :class "flex items-center gap-1"
(kbd :class "px-2 py-0.5 bg-white border border-stone-300 rounded text-xs font-mono" "h")
(kbd :class "px-2 py-0.5 bg-stone-100 border border-stone-300 rounded text-xs font-mono" "h")
(span :class "text-sm text-stone-500" "Help"))))
(div :id "kbd-target"
:sx-get "/examples/api/keyboard?key=s"
:sx-trigger "keyup[key=='s'&&!event.target.matches('input,textarea')] from:body"
:sx-swap "innerHTML"
:class "p-4 rounded border border-stone-200 bg-white text-center"
:class "p-4 rounded border border-stone-200 bg-stone-100 text-center"
(p :class "text-stone-400 text-sm" "Press a shortcut key..."))
(div :sx-get "/examples/api/keyboard?key=n"
:sx-trigger "keyup[key=='n'&&!event.target.matches('input,textarea')] from:body"
@@ -675,7 +675,7 @@
(button :type "submit"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Submit as JSON"))
(div :id "json-result" :class "p-3 rounded bg-stone-50 text-stone-500 text-sm"
(div :id "json-result" :class "p-3 rounded bg-stone-100 text-stone-500 text-sm"
"Submit the form to see the server echo the parsed JSON.")))
(defcomp ~json-result (&key body content-type)
@@ -697,7 +697,7 @@
:sx-vals "{\"source\": \"button\", \"version\": \"2.0\"}"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Send with vals")
(div :id "vals-result" :class "p-3 rounded bg-stone-50 text-sm text-stone-400"
(div :id "vals-result" :class "p-3 rounded bg-stone-100 text-sm text-stone-400"
"Click to see server-received values."))
(div :class "space-y-2"
(h4 :class "text-sm font-semibold text-stone-700" "sx-headers — send custom headers")
@@ -708,7 +708,7 @@
:sx-headers {:X-Custom-Token "abc123" :X-Request-Source "demo"}
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Send with headers")
(div :id "headers-result" :class "p-3 rounded bg-stone-50 text-sm text-stone-400"
(div :id "headers-result" :class "p-3 rounded bg-stone-100 text-sm text-stone-400"
"Click to see server-received headers."))))
(defcomp ~echo-result (&key label items)
@@ -729,7 +729,7 @@
:class "sx-loading-btn px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm flex items-center gap-2"
(span :class "sx-spinner w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin")
(span "Load slow endpoint"))
(div :id "loading-result" :class "p-4 rounded border border-stone-200 bg-white text-center"
(div :id "loading-result" :class "p-4 rounded border border-stone-200 bg-stone-100 text-center"
(p :class "text-stone-400 text-sm" "Click the button — it takes 2 seconds."))))
(defcomp ~loading-result (&key time)
@@ -749,7 +749,7 @@
:sx-sync "replace"
:placeholder "Type to search (random delay 0.5-2s)..."
:class "w-full px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500")
(div :id "sync-result" :class "p-4 rounded border border-stone-200 bg-white"
(div :id "sync-result" :class "p-4 rounded border border-stone-200 bg-stone-100"
(p :class "text-sm text-stone-400" "Type to trigger requests — stale ones get aborted."))))
(defcomp ~sync-result (&key query delay)
@@ -768,7 +768,7 @@
:sx-retry "exponential:1000:8000"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Call flaky endpoint")
(div :id "retry-result" :class "p-4 rounded border border-stone-200 bg-white text-center"
(div :id "retry-result" :class "p-4 rounded border border-stone-200 bg-stone-100 text-center"
(p :class "text-stone-400 text-sm" "Endpoint fails twice, succeeds on 3rd attempt."))))
(defcomp ~retry-result (&key attempt message)

View File

@@ -235,7 +235,7 @@
(div :class "p-3 bg-amber-50 rounded text-center"
(p :class "text-2xl font-bold text-amber-700" "$4.2k")
(p :class "text-xs text-amber-600" "Revenue")))
(div :id "dash-footer" :class "p-3 bg-stone-50 rounded"
(div :id "dash-footer" :class "p-3 bg-stone-100 rounded"
(p :class "text-sm text-stone-500" "Last updated: " now)))))
;; ---------------------------------------------------------------------------

View File

@@ -3,14 +3,16 @@
(defcomp ~sx-hero (&key &rest children)
(div :class "max-w-4xl mx-auto px-6 py-16 text-center"
(h1 :class "text-5xl font-bold text-stone-900 mb-4"
(span :class "text-violet-600 font-mono" "(<x>)"))
(p :class "text-2xl text-stone-600 mb-8"
(span :class "text-violet-600 font-mono" "(<sx>)"))
(p :class "text-2xl text-stone-600 mb-4"
"s-expressions for the web")
(p :class "text-sm text-stone-400"
"© Giles Bradshaw 2026")
(p :class "text-lg text-stone-500 max-w-2xl mx-auto mb-12"
"A hypermedia-driven UI engine that combines htmx's server-first philosophy "
"with React's component model. S-expressions over the wire — no HTML, no JavaScript frameworks.")
(div :class "bg-stone-50 border border-stone-200 rounded-lg p-6 text-left font-mono text-sm overflow-x-auto"
(pre :class "leading-relaxed" children))))
(div :class "bg-stone-100 rounded-lg p-6 text-left font-mono text-sm mx-auto max-w-2xl"
(pre :class "leading-relaxed whitespace-pre-wrap" children))))
(defcomp ~sx-philosophy ()
(div :class "max-w-4xl mx-auto px-6 py-12"

View File

@@ -115,6 +115,43 @@
:handler-code attr-handler
:wire-placeholder-id attr-wire-id)))
(defpage reference-header-detail
:path "/reference/headers/<slug>"
:auth :public
:layout (:sx-section
:section "Reference"
:sub-label "Reference"
:sub-href "/reference/"
:sub-nav (~section-nav :items reference-nav-items :current "Headers")
:selected "Headers")
:data (header-detail-data slug)
:content (if header-not-found
(~reference-attr-not-found :slug slug)
(~reference-header-detail-content
:title header-title
:direction header-direction
:description header-description
:example-code header-example
:demo header-demo)))
(defpage reference-event-detail
:path "/reference/events/<slug>"
:auth :public
:layout (:sx-section
:section "Reference"
:sub-label "Reference"
:sub-href "/reference/"
:sub-nav (~section-nav :items reference-nav-items :current "Events")
:selected "Events")
:data (event-detail-data slug)
:content (if event-not-found
(~reference-attr-not-found :slug slug)
(~reference-event-detail-content
:title event-title
:description event-description
:example-code event-example
:demo event-demo)))
;; ---------------------------------------------------------------------------
;; Protocols section
;; ---------------------------------------------------------------------------
@@ -214,10 +251,10 @@
:layout (:sx-section
:section "Essays"
:sub-label "Essays"
:sub-href "/essays/sx-sucks"
:sub-nav (~section-nav :items essays-nav-items :current "sx sucks")
:selected "sx sucks")
:content (~essay-sx-sucks))
:sub-href "/essays/"
:sub-nav (~section-nav :items essays-nav-items :current "")
:selected "")
:content (~essays-index-content))
(defpage essay-page
:path "/essays/<slug>"
@@ -225,7 +262,7 @@
:layout (:sx-section
:section "Essays"
:sub-label "Essays"
:sub-href "/essays/sx-sucks"
:sub-href "/essays/"
:sub-nav (~section-nav :items essays-nav-items
:current (find-current essays-nav-items slug))
:selected (or (find-current essays-nav-items slug) ""))
@@ -239,4 +276,98 @@
"sx-manifesto" (~essay-sx-manifesto)
"tail-call-optimization" (~essay-tail-call-optimization)
"continuations" (~essay-continuations)
:else (~essay-sx-sucks)))
"godel-escher-bach" (~essay-godel-escher-bach)
"reflexive-web" (~essay-reflexive-web)
:else (~essays-index-content)))
;; ---------------------------------------------------------------------------
;; Specs section
;; ---------------------------------------------------------------------------
(defpage specs-index
:path "/specs/"
:auth :public
:layout (:sx-section
:section "Specs"
:sub-label "Specs"
:sub-href "/specs/"
:sub-nav (~section-nav :items specs-nav-items :current "Architecture")
:selected "Architecture")
:content (~spec-architecture-content))
(defpage specs-page
:path "/specs/<slug>"
:auth :public
:layout (:sx-section
:section "Specs"
:sub-label "Specs"
:sub-href "/specs/"
:sub-nav (~section-nav :items specs-nav-items
:current (find-current specs-nav-items slug))
:selected (or (find-current specs-nav-items slug) ""))
:content (case slug
"core" (~spec-overview-content
:spec-title "Core Language"
:spec-files (map (fn (item)
(dict :title (get item "title") :desc (get item "desc")
:prose (get item "prose")
:filename (get item "filename") :href (str "/specs/" (get item "slug"))
:source (read-spec-file (get item "filename"))))
core-spec-items))
"adapters" (~spec-overview-content
:spec-title "Adapters & Engine"
:spec-files (map (fn (item)
(dict :title (get item "title") :desc (get item "desc")
:prose (get item "prose")
:filename (get item "filename") :href (str "/specs/" (get item "slug"))
:source (read-spec-file (get item "filename"))))
adapter-spec-items))
"browser" (~spec-overview-content
:spec-title "Browser"
:spec-files (map (fn (item)
(dict :title (get item "title") :desc (get item "desc")
:prose (get item "prose")
:filename (get item "filename") :href (str "/specs/" (get item "slug"))
:source (read-spec-file (get item "filename"))))
browser-spec-items))
:else (let ((spec (find-spec slug)))
(if spec
(~spec-detail-content
:spec-title (get spec "title")
:spec-desc (get spec "desc")
:spec-filename (get spec "filename")
:spec-source (read-spec-file (get spec "filename"))
:spec-prose (get spec "prose"))
(~spec-not-found :slug slug)))))
;; ---------------------------------------------------------------------------
;; Bootstrappers section
;; ---------------------------------------------------------------------------
(defpage bootstrappers-index
:path "/bootstrappers/"
:auth :public
:layout (:sx-section
:section "Bootstrappers"
:sub-label "Bootstrappers"
:sub-href "/bootstrappers/"
:sub-nav (~section-nav :items bootstrappers-nav-items :current "Overview")
:selected "Overview")
:content (~bootstrappers-index-content))
(defpage bootstrapper-page
:path "/bootstrappers/<slug>"
:auth :public
:layout (:sx-section
:section "Bootstrappers"
:sub-label "Bootstrappers"
:sub-href "/bootstrappers/"
:sub-nav (~section-nav :items bootstrappers-nav-items
:current (find-current bootstrappers-nav-items slug))
:selected (or (find-current bootstrappers-nav-items slug) ""))
:data (bootstrapper-data slug)
:content (if bootstrapper-not-found
(~spec-not-found :slug slug)
(~bootstrapper-js-content
:bootstrapper-source bootstrapper-source
:bootstrapped-output bootstrapped-output)))

View File

@@ -16,6 +16,10 @@ def _register_sx_helpers() -> None:
"primitives-data": _primitives_data,
"reference-data": _reference_data,
"attr-detail-data": _attr_detail_data,
"header-detail-data": _header_detail_data,
"event-detail-data": _event_detail_data,
"read-spec-file": _read_spec_file,
"bootstrapper-data": _bootstrapper_data,
})
@@ -37,7 +41,7 @@ def _reference_data(slug: str) -> dict:
from content.pages import (
REQUEST_ATTRS, BEHAVIOR_ATTRS, SX_UNIQUE_ATTRS,
REQUEST_HEADERS, RESPONSE_HEADERS,
EVENTS, JS_API, ATTR_DETAILS,
EVENTS, JS_API, ATTR_DETAILS, HEADER_DETAILS,
)
if slug == "attributes":
@@ -61,18 +65,22 @@ def _reference_data(slug: str) -> dict:
elif slug == "headers":
return {
"req-headers": [
{"name": n, "value": v, "desc": d}
{"name": n, "value": v, "desc": d,
"href": f"/reference/headers/{n}" if n in HEADER_DETAILS else None}
for n, v, d in REQUEST_HEADERS
],
"resp-headers": [
{"name": n, "value": v, "desc": d}
{"name": n, "value": v, "desc": d,
"href": f"/reference/headers/{n}" if n in HEADER_DETAILS else None}
for n, v, d in RESPONSE_HEADERS
],
}
elif slug == "events":
from content.pages import EVENT_DETAILS
return {
"events-list": [
{"name": n, "desc": d}
{"name": n, "desc": d,
"href": f"/reference/events/{n}" if n in EVENT_DETAILS else None}
for n, d in EVENTS
],
}
@@ -103,6 +111,61 @@ def _reference_data(slug: str) -> dict:
}
def _read_spec_file(filename: str) -> str:
"""Read a spec .sx file from the ref directory. Pure I/O — metadata lives in .sx."""
import os
ref_dir = os.path.join(os.path.dirname(__file__), "..", "..", "shared", "sx", "ref")
if not os.path.isdir(ref_dir):
ref_dir = "/app/shared/sx/ref"
filepath = os.path.join(ref_dir, filename)
try:
with open(filepath, encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
return ";; spec file not found"
def _bootstrapper_data(target: str) -> dict:
"""Return bootstrapper source and generated output for a target.
Returns a dict whose keys become SX env bindings:
- bootstrapper-source: the Python bootstrapper source code
- bootstrapped-output: the generated JavaScript
- bootstrapper-not-found: truthy if target unknown
"""
import os
if target != "javascript":
return {"bootstrapper-not-found": True}
ref_dir = os.path.join(os.path.dirname(__file__), "..", "..", "shared", "sx", "ref")
if not os.path.isdir(ref_dir):
ref_dir = "/app/shared/sx/ref"
# Read bootstrapper source
bs_path = os.path.join(ref_dir, "bootstrap_js.py")
try:
with open(bs_path, encoding="utf-8") as f:
bootstrapper_source = f.read()
except FileNotFoundError:
bootstrapper_source = "# bootstrapper source not found"
# Run the bootstrap to generate JS
from shared.sx.ref.bootstrap_js import compile_ref_to_js
try:
bootstrapped_output = compile_ref_to_js(
adapters=["dom", "engine", "orchestration", "boot", "cssx"]
)
except Exception as e:
bootstrapped_output = f"// bootstrap error: {e}"
return {
"bootstrapper-not-found": None,
"bootstrapper-source": bootstrapper_source,
"bootstrapped-output": bootstrapped_output,
}
def _attr_detail_data(slug: str) -> dict:
"""Return attribute detail data for a specific attribute slug.
@@ -133,3 +196,42 @@ def _attr_detail_data(slug: str) -> dict:
"attr-demo": SxExpr(f"(~{demo_name})") if demo_name else None,
"attr-wire-id": wire_id,
}
def _header_detail_data(slug: str) -> dict:
"""Return header detail data for a specific header slug."""
from content.pages import HEADER_DETAILS
from shared.sx.helpers import SxExpr
detail = HEADER_DETAILS.get(slug)
if not detail:
return {"header-not-found": True}
demo_name = detail.get("demo")
return {
"header-not-found": None,
"header-title": slug,
"header-direction": detail["direction"],
"header-description": detail["description"],
"header-example": detail.get("example"),
"header-demo": SxExpr(f"(~{demo_name})") if demo_name else None,
}
def _event_detail_data(slug: str) -> dict:
"""Return event detail data for a specific event slug."""
from content.pages import EVENT_DETAILS
from shared.sx.helpers import SxExpr
detail = EVENT_DETAILS.get(slug)
if not detail:
return {"event-not-found": True}
demo_name = detail.get("demo")
return {
"event-not-found": None,
"event-title": slug,
"event-description": detail["description"],
"event-example": detail.get("example"),
"event-demo": SxExpr(f"(~{demo_name})") if demo_name else None,
}

View File

@@ -1,11 +1,27 @@
"""SX docs layout registration — all layouts delegate to .sx defcomps."""
from __future__ import annotations
import os
def _register_sx_layouts() -> None:
"""Register the sx docs layout presets."""
from shared.sx.layouts import register_sx_layout
register_sx_layout("sx", "sx-layout-full", "sx-layout-oob", "sx-layout-mobile")
register_sx_layout("sx-section", "sx-section-layout-full",
"sx-section-layout-oob", "sx-section-layout-mobile")
if os.getenv("SX_STANDALONE") == "true":
register_sx_layout("sx",
"sx-standalone-layout-full",
"sx-standalone-layout-oob",
"sx-standalone-layout-mobile")
register_sx_layout("sx-section",
"sx-standalone-section-layout-full",
"sx-standalone-section-layout-oob",
"sx-standalone-section-layout-mobile")
else:
register_sx_layout("sx",
"sx-layout-full",
"sx-layout-oob",
"sx-layout-mobile")
register_sx_layout("sx-section",
"sx-section-layout-full",
"sx-section-layout-oob",
"sx-section-layout-mobile")

View File

@@ -16,7 +16,7 @@
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Load server time")
(div :id "ref-get-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Click to load.")))
;; ---------------------------------------------------------------------------
@@ -36,7 +36,7 @@
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Greet"))
(div :id "ref-post-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Submit to see greeting.")))
;; ---------------------------------------------------------------------------
@@ -45,7 +45,7 @@
(defcomp ~ref-put-demo ()
(div :id "ref-put-view"
(div :class "flex items-center justify-between p-3 bg-stone-50 rounded"
(div :class "flex items-center justify-between p-3 bg-stone-100 rounded"
(span :class "text-stone-700 text-sm" "Status: " (strong "draft"))
(button
:sx-put "/reference/api/status"
@@ -83,7 +83,7 @@
(defcomp ~ref-patch-demo ()
(div :id "ref-patch-view" :class "space-y-2"
(div :class "p-3 bg-stone-50 rounded"
(div :class "p-3 bg-stone-100 rounded"
(span :class "text-stone-700 text-sm" "Theme: " (strong :id "ref-patch-val" "light")))
(div :class "flex gap-2"
(button :sx-patch "/reference/api/theme"
@@ -93,7 +93,7 @@
(button :sx-patch "/reference/api/theme"
:sx-vals "{\"theme\": \"light\"}"
:sx-target "#ref-patch-val" :sx-swap "innerHTML"
:class "px-3 py-1 bg-white border border-stone-300 text-stone-700 rounded text-sm" "Light"))))
:class "px-3 py-1 bg-stone-100 border border-stone-300 text-stone-700 rounded text-sm" "Light"))))
;; ---------------------------------------------------------------------------
;; sx-trigger
@@ -108,7 +108,7 @@
:sx-swap "innerHTML"
:class "w-full px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500")
(div :id "ref-trigger-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Start typing to trigger a search.")))
;; ---------------------------------------------------------------------------
@@ -186,7 +186,7 @@
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
"Load (selecting #the-content)")
(div :id "ref-select-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Only the selected fragment will appear here.")))
;; ---------------------------------------------------------------------------
@@ -242,7 +242,7 @@
(p :class "text-xs text-stone-400"
"With sync:replace, each new keystroke aborts the in-flight request.")
(div :id "ref-sync-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Type to see only the latest result.")))
;; ---------------------------------------------------------------------------
@@ -262,7 +262,7 @@
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
"Upload"))
(div :id "ref-encoding-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Select a file and submit.")))
;; ---------------------------------------------------------------------------
@@ -278,7 +278,7 @@
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
"Send with custom headers")
(div :id "ref-headers-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Click to see echoed headers.")))
;; ---------------------------------------------------------------------------
@@ -302,7 +302,7 @@
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
"Filter"))
(div :id "ref-include-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Click Filter — the select value is included in the request.")))
;; ---------------------------------------------------------------------------
@@ -318,7 +318,7 @@
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
"Send with extra values")
(div :id "ref-vals-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Click to see echoed values.")))
;; ---------------------------------------------------------------------------
@@ -369,7 +369,7 @@
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
"Click me")
(div :id "ref-on-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Click the button — runs JavaScript, no server request.")))
;; ---------------------------------------------------------------------------
@@ -385,7 +385,7 @@
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
"Call flaky endpoint")
(div :id "ref-retry-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"This endpoint fails 2 out of 3 times. sx-retry retries automatically.")))
;; ---------------------------------------------------------------------------
@@ -440,7 +440,7 @@
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Hover then click (preloaded)")
(div :id "ref-preload-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Hover over the button first, then click — the response is instant.")))
;; ---------------------------------------------------------------------------
@@ -461,7 +461,7 @@
(input :id "ref-preserved-input" :sx-preserve "true"
:type "text" :placeholder "Type here — preserved across swaps"
:class "w-full px-3 py-2 border border-stone-300 rounded text-sm")
(div :class "p-2 bg-stone-50 rounded text-sm text-stone-600"
(div :class "p-2 bg-stone-100 rounded text-sm text-stone-600"
"This text will be replaced on swap."))))
;; ---------------------------------------------------------------------------
@@ -484,7 +484,7 @@
:style "display: none"
"Loading..."))
(div :id "ref-indicator-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Click to load (indicator shows during request).")))
;; ---------------------------------------------------------------------------
@@ -506,7 +506,7 @@
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Submit"))
(div :id "ref-validate-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Submit with invalid/empty email to see validation.")))
;; ---------------------------------------------------------------------------
@@ -526,7 +526,7 @@
(p :class "text-sm text-amber-800" "This subtree has sx-ignore — it won't change.")
(input :type "text" :placeholder "Type here — ignored during swap"
:class "mt-1 w-full px-2 py-1 border border-amber-300 rounded text-sm"))
(div :class "p-2 bg-stone-50 rounded text-sm text-stone-600"
(div :class "p-2 bg-stone-100 rounded text-sm text-stone-600"
"This text WILL be replaced on swap."))))
;; ---------------------------------------------------------------------------
@@ -566,7 +566,7 @@
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Load (replaces URL)")
(div :id "ref-replurl-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Click to load — URL changes but no new history entry.")))
;; ---------------------------------------------------------------------------
@@ -586,7 +586,7 @@
"Click (disables during request)")
(span :class "text-xs text-stone-400" "Button is disabled while request is in-flight."))
(div :id "ref-diselt-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Click the button to see it disable during the request.")))
;; ---------------------------------------------------------------------------
@@ -603,7 +603,7 @@
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Prompt & send")
(div :id "ref-prompt-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Click to enter a name via prompt — it is sent as the SX-Prompt header.")))
;; ---------------------------------------------------------------------------
@@ -626,7 +626,7 @@
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Submit"))
(div :id "ref-params-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Only 'name' will be sent — 'secret' is filtered by sx-params.")))
;; ---------------------------------------------------------------------------
@@ -639,7 +639,155 @@
:sx-sse-swap "time"
:sx-swap "innerHTML"
(div :id "ref-sse-result"
:class "p-3 rounded bg-stone-50 text-stone-600 text-sm font-mono"
:class "p-3 rounded bg-stone-100 text-stone-600 text-sm font-mono"
"Connecting to SSE stream..."))
(p :class "text-xs text-stone-400"
"Server pushes time updates every 2 seconds via Server-Sent Events.")))
;; ===========================================================================
;; Header detail demos
;; ===========================================================================
;; ---------------------------------------------------------------------------
;; SX-Prompt header demo
;; ---------------------------------------------------------------------------
(defcomp ~ref-header-prompt-demo ()
(div :class "space-y-3"
(button
:sx-get "/reference/api/prompt-echo"
:sx-target "#ref-hdr-prompt-result"
:sx-swap "innerHTML"
:sx-prompt "Enter your name:"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Prompt & send")
(div :id "ref-hdr-prompt-result"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Click to enter a name via prompt — the value is sent as the SX-Prompt header.")))
;; ---------------------------------------------------------------------------
;; SX-Trigger response header demo
;; ---------------------------------------------------------------------------
(defcomp ~ref-header-trigger-demo ()
(div :class "space-y-3"
(button
:sx-get "/reference/api/trigger-event"
:sx-target "#ref-hdr-trigger-result"
:sx-swap "innerHTML"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Load with trigger")
(div :id "ref-hdr-trigger-result"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
:sx-on:showNotice "this.style.borderColor = '#8b5cf6'; this.style.borderWidth = '2px'"
"Click — the server response includes SX-Trigger: showNotice, which highlights this box.")))
;; ---------------------------------------------------------------------------
;; SX-Retarget response header demo
;; ---------------------------------------------------------------------------
(defcomp ~ref-header-retarget-demo ()
(div :class "space-y-3"
(button
:sx-get "/reference/api/retarget"
:sx-target "#ref-hdr-retarget-main"
:sx-swap "innerHTML"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Load (server retargets)")
(div :class "grid grid-cols-2 gap-3"
(div :class "rounded border border-stone-200 p-3"
(div :class "text-xs text-stone-400 mb-1" "Original target")
(div :id "ref-hdr-retarget-main" :class "text-sm text-stone-500" "Waiting..."))
(div :class "rounded border border-stone-200 p-3"
(div :class "text-xs text-stone-400 mb-1" "Retarget destination")
(div :id "ref-hdr-retarget-alt" :class "text-sm text-stone-500" "Waiting...")))))
;; ===========================================================================
;; Event detail demos
;; ===========================================================================
;; ---------------------------------------------------------------------------
;; sx:beforeRequest event demo
;; ---------------------------------------------------------------------------
(defcomp ~ref-event-before-request-demo ()
(div :class "space-y-3"
(div :class "flex gap-2 items-center"
(input :id "ref-evt-br-input" :type "text" :placeholder "Type something first..."
:class "flex-1 px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500")
(button
:sx-get "/reference/api/time"
:sx-target "#ref-evt-br-result"
:sx-swap "innerHTML"
:sx-on:sx:beforeRequest "if (!document.getElementById('ref-evt-br-input').value) { event.preventDefault(); document.getElementById('ref-evt-br-result').textContent = 'Cancelled — input is empty!'; }"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Load"))
(div :id "ref-evt-br-result"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Request is cancelled via preventDefault() if the input is empty.")))
;; ---------------------------------------------------------------------------
;; sx:afterSettle event demo
;; ---------------------------------------------------------------------------
(defcomp ~ref-event-after-settle-demo ()
(div :class "space-y-3"
(button
:sx-get "/reference/api/swap-item"
:sx-target "#ref-evt-settle-list"
:sx-swap "beforeend"
:sx-on:sx:afterSettle "var items = document.querySelectorAll('#ref-evt-settle-list > div'); if (items.length) items[items.length-1].scrollIntoView({behavior:'smooth'})"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Add item (scrolls after settle)")
(div :id "ref-evt-settle-list"
: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."))))
;; ---------------------------------------------------------------------------
;; sx:responseError event demo
;; ---------------------------------------------------------------------------
(defcomp ~ref-event-response-error-demo ()
(div :class "space-y-3"
(button
:sx-get "/reference/api/error-500"
:sx-target "#ref-evt-err-result"
:sx-swap "innerHTML"
:sx-on:sx:responseError "var s=document.getElementById('ref-evt-err-status'); s.style.display='block'; s.textContent='Error ' + (event.detail ? event.detail.status || '?' : '?') + ' received'"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Call failing endpoint")
(div :id "ref-evt-err-status"
:class "p-2 rounded bg-red-50 text-red-600 text-sm"
:style "display: none"
"")
(div :id "ref-evt-err-result"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Click to trigger an error — the sx:responseError event fires.")))
;; ---------------------------------------------------------------------------
;; sx:validationFailed event demo
;; ---------------------------------------------------------------------------
;; @css invalid:border-red-400
(defcomp ~ref-event-validation-failed-demo ()
(div :class "space-y-3"
(form
:sx-post "/reference/api/greet"
:sx-target "#ref-evt-vf-result"
:sx-swap "innerHTML"
:sx-validate "true"
:sx-on:sx:validationFailed "document.getElementById('ref-evt-vf-status').style.display = 'block'"
:class "flex gap-2"
(input :type "email" :name "email" :required "true"
:placeholder "Email (required)"
:class "flex-1 px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500 invalid:border-red-400")
(button :type "submit"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Submit"))
(div :id "ref-evt-vf-status"
:class "p-2 rounded bg-amber-50 text-amber-700 text-sm"
:style "display: none"
"Validation failed — form was not submitted.")
(div :id "ref-evt-vf-result"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Submit with empty/invalid email to trigger the event.")))