Merge branch 'macros'
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
# 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
This commit is contained in:
@@ -84,13 +84,25 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Deploy swarm stack only on main branch
|
# Deploy swarm stacks only on main branch
|
||||||
if [ '${{ github.ref_name }}' = 'main' ]; then
|
if [ '${{ github.ref_name }}' = 'main' ]; then
|
||||||
source .env
|
source .env
|
||||||
docker stack deploy -c docker-compose.yml rose-ash
|
docker stack deploy -c docker-compose.yml rose-ash
|
||||||
echo 'Waiting for swarm services to update...'
|
echo 'Waiting for swarm services to update...'
|
||||||
sleep 10
|
sleep 10
|
||||||
docker stack services rose-ash
|
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
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo 'Skipping swarm deploy (branch: ${{ github.ref_name }})'
|
echo 'Skipping swarm deploy (branch: ${{ github.ref_name }})'
|
||||||
fi
|
fi
|
||||||
|
|||||||
67
CLAUDE.md
67
CLAUDE.md
@@ -52,6 +52,65 @@ artdag/
|
|||||||
test/ # Integration & e2e tests
|
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
|
## Tech Stack
|
||||||
|
|
||||||
**Web platform:** Python 3.11+, Quart (async Flask), SQLAlchemy (asyncpg), Jinja2, HTMX, PostgreSQL, Redis, Docker Swarm, Hypercorn.
|
**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
|
### 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_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. Component calls stay **unexpanded** (serialized for client-side rendering by sx.js).
|
- `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 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_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.
|
- `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.
|
See the docstring in `shared/sx/async_eval.py` for the full evaluation modes table.
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ def register(url_prefix="/"):
|
|||||||
return sx_response(sx_call(
|
return sx_response(sx_call(
|
||||||
"account-newsletter-toggle",
|
"account-newsletter-toggle",
|
||||||
id=f"nl-{nid}", url=toggle_url,
|
id=f"nl-{nid}", url=toggle_url,
|
||||||
hdrs=f'{{"X-CSRFToken": "{csrf}"}}',
|
hdrs={"X-CSRFToken": csrf},
|
||||||
target=f"#nl-{nid}",
|
target=f"#nl-{nid}",
|
||||||
cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}",
|
cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}",
|
||||||
checked=checked,
|
checked=checked,
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
:toggle (~account-newsletter-toggle
|
:toggle (~account-newsletter-toggle
|
||||||
:id (str "nl-" nid)
|
:id (str "nl-" nid)
|
||||||
:url toggle-url
|
:url toggle-url
|
||||||
:hdrs (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
:hdrs {:X-CSRFToken csrf}
|
||||||
:target (str "#nl-" nid)
|
:target (str "#nl-" nid)
|
||||||
:cls (str "relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 " bg)
|
:cls (str "relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 " bg)
|
||||||
:checked checked
|
:checked checked
|
||||||
|
|||||||
@@ -10,11 +10,18 @@ from quart import (
|
|||||||
url_for,
|
url_for,
|
||||||
)
|
)
|
||||||
from shared.browser.app.authz import require_admin, require_post_author
|
from shared.browser.app.authz import require_admin, require_post_author
|
||||||
|
from markupsafe import escape
|
||||||
from shared.sx.helpers import sx_response, sx_call
|
from shared.sx.helpers import sx_response, sx_call
|
||||||
from shared.sx.parser import SxExpr, serialize as sx_serialize
|
from shared.sx.parser import SxExpr, serialize as sx_serialize
|
||||||
from shared.utils import host_url
|
from shared.utils import host_url
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_html_sx(html: str) -> str:
|
||||||
|
"""Wrap raw HTML in (raw! "...") so it's valid inside sx source."""
|
||||||
|
if not html:
|
||||||
|
return ""
|
||||||
|
return "(raw! " + sx_serialize(html) + ")"
|
||||||
|
|
||||||
def _post_to_edit_dict(post) -> dict:
|
def _post_to_edit_dict(post) -> dict:
|
||||||
"""Convert an ORM Post to a dict matching the shape templates expect.
|
"""Convert an ORM Post to a dict matching the shape templates expect.
|
||||||
|
|
||||||
@@ -89,58 +96,95 @@ def _render_calendar_view(
|
|||||||
prev_year, next_year, month_entries, associated_entry_ids,
|
prev_year, next_year, month_entries, associated_entry_ids,
|
||||||
post_slug: str,
|
post_slug: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build calendar month grid via ~blog-calendar-view defcomp."""
|
"""Build calendar month grid HTML."""
|
||||||
from quart import url_for as qurl
|
from quart import url_for as qurl
|
||||||
from shared.browser.app.csrf import generate_csrf_token
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
esc = escape
|
||||||
|
|
||||||
cal_id = calendar.id
|
|
||||||
csrf = generate_csrf_token()
|
csrf = generate_csrf_token()
|
||||||
|
cal_id = calendar.id
|
||||||
|
|
||||||
def cal_url(y, m):
|
def cal_url(y, m):
|
||||||
return host_url(qurl("blog.post.admin.calendar_view",
|
return esc(host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal_id, year=y, month=m)))
|
||||||
slug=post_slug, calendar_id=cal_id, year=y, month=m))
|
|
||||||
|
|
||||||
# Flatten weeks into day dicts with pre-computed entries per day
|
cur_url = cal_url(year, month)
|
||||||
days = []
|
toggle_url_fn = lambda eid: esc(host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=eid)))
|
||||||
|
|
||||||
|
nav = (
|
||||||
|
f'<header class="flex items-center justify-center mb-4">'
|
||||||
|
f'<nav class="flex items-center gap-2 text-xl">'
|
||||||
|
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">«</a>'
|
||||||
|
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_month_year, prev_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">‹</a>'
|
||||||
|
f'<div class="px-3 font-medium">{esc(month_name)} {year}</div>'
|
||||||
|
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_month_year, next_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">›</a>'
|
||||||
|
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">»</a>'
|
||||||
|
f'</nav></header>'
|
||||||
|
)
|
||||||
|
|
||||||
|
wd_cells = "".join(f'<div class="py-2">{esc(wd)}</div>' for wd in weekday_names)
|
||||||
|
wd_row = f'<div class="hidden sm:grid grid-cols-7 text-center text-xs font-semibold text-stone-700 bg-stone-50 border-b">{wd_cells}</div>'
|
||||||
|
|
||||||
|
cells: list[str] = []
|
||||||
for week in weeks:
|
for week in weeks:
|
||||||
for day in week:
|
for day in week:
|
||||||
|
extra_cls = " bg-stone-50 text-stone-400" if not day.in_month else ""
|
||||||
day_date = day.date
|
day_date = day.date
|
||||||
day_entries = []
|
|
||||||
|
entry_btns: list[str] = []
|
||||||
for e in month_entries:
|
for e in month_entries:
|
||||||
e_start = getattr(e, "start_at", None)
|
e_start = getattr(e, "start_at", None)
|
||||||
if not e_start or e_start.date() != day_date:
|
if not e_start or e_start.date() != day_date:
|
||||||
continue
|
continue
|
||||||
e_id = getattr(e, "id", None)
|
e_id = getattr(e, "id", None)
|
||||||
toggle_url = host_url(qurl("blog.post.admin.toggle_entry",
|
e_name = esc(getattr(e, "name", ""))
|
||||||
slug=post_slug, entry_id=e_id))
|
t_url = toggle_url_fn(e_id)
|
||||||
day_entries.append({
|
hx_hdrs = '{:X-CSRFToken "' + csrf + '"}'
|
||||||
"name": str(getattr(e, "name", "")),
|
|
||||||
"toggle_url": toggle_url,
|
|
||||||
"is_associated": e_id in associated_entry_ids,
|
|
||||||
})
|
|
||||||
# Wrap nested entries list as SxExpr so it serializes as (list ...)
|
|
||||||
entries_sx = SxExpr("(list " + " ".join(
|
|
||||||
sx_serialize(e) for e in day_entries
|
|
||||||
) + ")") if day_entries else None
|
|
||||||
days.append({
|
|
||||||
"day": day_date.day,
|
|
||||||
"in_month": day.in_month,
|
|
||||||
"entries": entries_sx,
|
|
||||||
})
|
|
||||||
|
|
||||||
return sx_call("blog-calendar-view",
|
if e_id in associated_entry_ids:
|
||||||
cal_id=str(cal_id),
|
entry_btns.append(
|
||||||
year=str(year),
|
f'<div class="flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900">'
|
||||||
month_name=month_name,
|
f'<span class="truncate flex-1">{e_name}</span>'
|
||||||
current_url=cal_url(year, month),
|
f'<button type="button" class="flex-shrink-0 hover:text-red-600"'
|
||||||
prev_month_url=cal_url(prev_month_year, prev_month),
|
f' data-confirm data-confirm-title="Remove entry?"'
|
||||||
prev_year_url=cal_url(prev_year, month),
|
f' data-confirm-text="Remove {e_name} from this post?"'
|
||||||
next_month_url=cal_url(next_month_year, next_month),
|
f' data-confirm-icon="warning" data-confirm-confirm-text="Yes, remove it"'
|
||||||
next_year_url=cal_url(next_year, month),
|
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
|
||||||
weekday_names=list(weekday_names),
|
f' sx-post="{t_url}" sx-trigger="confirmed"'
|
||||||
days=days,
|
f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
|
||||||
csrf=csrf,
|
f""" sx-headers='{hx_hdrs}'"""
|
||||||
|
f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
|
||||||
|
f'><i class="fa fa-times"></i></button></div>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
entry_btns.append(
|
||||||
|
f'<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"'
|
||||||
|
f' data-confirm data-confirm-title="Add entry?"'
|
||||||
|
f' data-confirm-text="Add {e_name} to this post?"'
|
||||||
|
f' data-confirm-icon="question" data-confirm-confirm-text="Yes, add it"'
|
||||||
|
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
|
||||||
|
f' sx-post="{t_url}" sx-trigger="confirmed"'
|
||||||
|
f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
|
||||||
|
f""" sx-headers='{hx_hdrs}'"""
|
||||||
|
f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
|
||||||
|
f'><span class="truncate block">{e_name}</span></button>'
|
||||||
|
)
|
||||||
|
|
||||||
|
entries_html = '<div class="space-y-0.5">' + "".join(entry_btns) + '</div>' if entry_btns else ''
|
||||||
|
cells.append(
|
||||||
|
f'<div class="min-h-20 bg-white px-2 py-2 text-xs{extra_cls}">'
|
||||||
|
f'<div class="font-medium mb-1">{day_date.day}</div>{entries_html}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
grid = f'<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200">{"".join(cells)}</div>'
|
||||||
|
|
||||||
|
html = (
|
||||||
|
f'<div id="calendar-view-{cal_id}"'
|
||||||
|
f' sx-get="{cur_url}" sx-trigger="entryToggled from:body" sx-swap="outerHTML">'
|
||||||
|
f'{nav}'
|
||||||
|
f'<div class="rounded border bg-white">{wd_row}{grid}</div>'
|
||||||
|
f'</div>'
|
||||||
)
|
)
|
||||||
|
return _raw_html_sx(html)
|
||||||
|
|
||||||
|
|
||||||
def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str:
|
def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str:
|
||||||
@@ -157,15 +201,37 @@ def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: s
|
|||||||
|
|
||||||
|
|
||||||
def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:
|
def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:
|
||||||
"""Render the OOB nav entries swap via ~blog-nav-entries-oob defcomp."""
|
"""Render the OOB nav entries swap."""
|
||||||
entries_list = []
|
entries_list = []
|
||||||
if associated_entries and hasattr(associated_entries, "entries"):
|
if associated_entries and hasattr(associated_entries, "entries"):
|
||||||
entries_list = associated_entries.entries or []
|
entries_list = associated_entries.entries or []
|
||||||
|
|
||||||
|
has_items = bool(entries_list or calendars)
|
||||||
|
|
||||||
|
if not has_items:
|
||||||
|
return sx_call("blog-nav-entries-empty")
|
||||||
|
|
||||||
|
select_colours = (
|
||||||
|
"[.hover-capable_&]:hover:bg-yellow-300"
|
||||||
|
" aria-selected:bg-stone-500 aria-selected:text-white"
|
||||||
|
" [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500"
|
||||||
|
)
|
||||||
|
nav_cls = (
|
||||||
|
f"justify-center cursor-pointer flex flex-row items-center gap-2"
|
||||||
|
f" rounded bg-stone-200 text-black {select_colours} p-2"
|
||||||
|
)
|
||||||
|
|
||||||
post_slug = post.get("slug", "")
|
post_slug = post.get("slug", "")
|
||||||
|
|
||||||
# Extract entry data as list of dicts
|
scroll_hs = (
|
||||||
entry_data = []
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
item_parts = []
|
||||||
|
|
||||||
for entry in entries_list:
|
for entry in entries_list:
|
||||||
e_name = getattr(entry, "name", "")
|
e_name = getattr(entry, "name", "")
|
||||||
e_start = getattr(entry, "start_at", None)
|
e_start = getattr(entry, "start_at", None)
|
||||||
@@ -173,7 +239,7 @@ def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:
|
|||||||
cal_slug = getattr(entry, "calendar_slug", "")
|
cal_slug = getattr(entry, "calendar_slug", "")
|
||||||
|
|
||||||
if e_start:
|
if e_start:
|
||||||
href = (
|
entry_path = (
|
||||||
f"/{post_slug}/{cal_slug}/"
|
f"/{post_slug}/{cal_slug}/"
|
||||||
f"{e_start.year}/{e_start.month}/{e_start.day}"
|
f"{e_start.year}/{e_start.month}/{e_start.day}"
|
||||||
f"/entries/{getattr(entry, 'id', '')}/"
|
f"/entries/{getattr(entry, 'id', '')}/"
|
||||||
@@ -182,19 +248,32 @@ def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:
|
|||||||
if e_end:
|
if e_end:
|
||||||
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
|
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
|
||||||
else:
|
else:
|
||||||
href = f"/{post_slug}/{cal_slug}/"
|
entry_path = f"/{post_slug}/{cal_slug}/"
|
||||||
date_str = ""
|
date_str = ""
|
||||||
|
|
||||||
entry_data.append({"name": e_name, "href": href, "date_str": date_str})
|
item_parts.append(sx_call("calendar-entry-nav",
|
||||||
|
href=entry_path, nav_class=nav_cls, name=e_name, date_str=date_str,
|
||||||
|
))
|
||||||
|
|
||||||
# Extract calendar data as list of dicts
|
|
||||||
cal_data = []
|
|
||||||
for calendar in (calendars or []):
|
for calendar in (calendars or []):
|
||||||
cal_name = getattr(calendar, "name", "")
|
cal_name = getattr(calendar, "name", "")
|
||||||
cal_slug = getattr(calendar, "slug", "")
|
cal_slug = getattr(calendar, "slug", "")
|
||||||
cal_data.append({"name": cal_name, "href": f"/{post_slug}/{cal_slug}/"})
|
cal_path = f"/{post_slug}/{cal_slug}/"
|
||||||
|
|
||||||
return sx_call("blog-nav-entries-oob", entries=entry_data, calendars=cal_data)
|
item_parts.append(sx_call("blog-nav-calendar-item",
|
||||||
|
href=cal_path, nav_cls=nav_cls, name=cal_name,
|
||||||
|
))
|
||||||
|
|
||||||
|
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
|
||||||
|
|
||||||
|
return sx_call("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=SxExpr(items_sx) if items_sx else None, oob=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
|
|||||||
@@ -156,14 +156,10 @@ def register():
|
|||||||
csrf = generate_csrf_token()
|
csrf = generate_csrf_token()
|
||||||
|
|
||||||
def _like_btn(liked):
|
def _like_btn(liked):
|
||||||
if liked:
|
return sx_call("blog-like-toggle",
|
||||||
colour, icon, label = "text-red-600", "fa-solid fa-heart", "Unlike this post"
|
like_url=like_url,
|
||||||
else:
|
hx_headers={"X-CSRFToken": csrf},
|
||||||
colour, icon, label = "text-stone-300", "fa-regular fa-heart", "Like this post"
|
heart="\u2764\ufe0f" if liked else "\U0001f90d")
|
||||||
return sx_call("market-like-toggle-button",
|
|
||||||
colour=colour, action=like_url,
|
|
||||||
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
|
|
||||||
label=label, icon_cls=icon)
|
|
||||||
|
|
||||||
if not g.user:
|
if not g.user:
|
||||||
return sx_response(_like_btn(False), status=403)
|
return sx_response(_like_btn(False), status=403)
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
"""Blog page data service — provides serialized dicts for .sx defpages."""
|
"""Blog page data service — provides serialized dicts for .sx defpages."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from shared.sx.parser import SxExpr
|
||||||
|
|
||||||
|
|
||||||
|
def _sx_content_expr(raw: str) -> SxExpr | None:
|
||||||
|
"""Wrap non-empty sx_content as SxExpr so it serializes unquoted."""
|
||||||
|
return SxExpr(raw) if raw else None
|
||||||
|
|
||||||
|
|
||||||
class BlogPageService:
|
class BlogPageService:
|
||||||
"""Service for blog page data, callable via (service "blog-page" ...)."""
|
"""Service for blog page data, callable via (service "blog-page" ...)."""
|
||||||
@@ -424,7 +431,7 @@ class BlogPageService:
|
|||||||
"authors": authors,
|
"authors": authors,
|
||||||
"feature_image": post.get("feature_image"),
|
"feature_image": post.get("feature_image"),
|
||||||
"html_content": post.get("html", ""),
|
"html_content": post.get("html", ""),
|
||||||
"sx_content": post.get("sx_content", ""),
|
"sx_content": _sx_content_expr(post.get("sx_content", "")),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def preview_data(self, session, *, slug=None, **kw):
|
async def preview_data(self, session, *, slug=None, **kw):
|
||||||
|
|||||||
@@ -143,6 +143,80 @@
|
|||||||
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
|
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
|
||||||
edit-form delete-form))
|
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
|
;; Preview panel components
|
||||||
|
|
||||||
(defcomp ~blog-preview-panel (&key sections)
|
(defcomp ~blog-preview-panel (&key sections)
|
||||||
@@ -206,7 +280,7 @@
|
|||||||
(when is-admin
|
(when is-admin
|
||||||
(~blog-snippet-visibility-select
|
(~blog-snippet-visibility-select
|
||||||
:patch-url (get s "patch_url")
|
:patch-url (get s "patch_url")
|
||||||
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
:hx-headers {:X-CSRFToken csrf}
|
||||||
:options (<>
|
:options (<>
|
||||||
(~blog-snippet-option :value "private" :selected (= vis "private") :label "private")
|
(~blog-snippet-option :value "private" :selected (= vis "private") :label "private")
|
||||||
(~blog-snippet-option :value "shared" :selected (= vis "shared") :label "shared")
|
(~blog-snippet-option :value "shared" :selected (= vis "shared") :label "shared")
|
||||||
@@ -217,7 +291,7 @@
|
|||||||
:trigger-target "#snippets-list"
|
:trigger-target "#snippets-list"
|
||||||
:title "Delete snippet?"
|
:title "Delete snippet?"
|
||||||
:text (str "Delete \u201c" name "\u201d?")
|
:text (str "Delete \u201c" name "\u201d?")
|
||||||
:sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
:sx-headers {:X-CSRFToken csrf}
|
||||||
:cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"))))))
|
:cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"))))))
|
||||||
(or snippets (list)))))))
|
(or snippets (list)))))))
|
||||||
|
|
||||||
@@ -240,7 +314,7 @@
|
|||||||
:edit-url (get mi "edit_url")
|
:edit-url (get mi "edit_url")
|
||||||
:delete-url (get mi "delete_url")
|
:delete-url (get mi "delete_url")
|
||||||
:confirm-text (str "Remove " (get mi "label") " from the menu?")
|
:confirm-text (str "Remove " (get mi "label") " from the menu?")
|
||||||
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")))
|
:hx-headers {:X-CSRFToken csrf}))
|
||||||
(or menu-items (list)))))))
|
(or menu-items (list)))))))
|
||||||
|
|
||||||
;; Tag Groups — receives serialized tag group data from service
|
;; Tag Groups — receives serialized tag group data from service
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
(defcomp ~blog-like-button (&key like-url hx-headers heart)
|
(defcomp ~blog-like-button (&key like-url hx-headers heart)
|
||||||
(div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl"
|
(div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl"
|
||||||
(button :sx-post like-url :sx-swap "outerHTML"
|
(~blog-like-toggle :like-url like-url :hx-headers hx-headers :heart heart)))
|
||||||
:sx-headers hx-headers :class "cursor-pointer" heart)))
|
|
||||||
|
|
||||||
(defcomp ~blog-draft-status (&key publish-requested timestamp)
|
(defcomp ~blog-draft-status (&key publish-requested timestamp)
|
||||||
(<> (div :class "flex justify-center gap-2 mt-1"
|
(<> (div :class "flex justify-center gap-2 mt-1"
|
||||||
@@ -56,7 +55,7 @@
|
|||||||
(when has-like
|
(when has-like
|
||||||
(~blog-like-button
|
(~blog-like-button
|
||||||
:like-url like-url
|
:like-url like-url
|
||||||
:sx-headers (str "{\"X-CSRFToken\": \"" csrf-token "\"}")
|
:hx-headers {:X-CSRFToken csrf-token}
|
||||||
:heart (if liked "❤️" "🤍")))
|
:heart (if liked "❤️" "🤍")))
|
||||||
(a :href href :sx-get href :sx-target "#main-panel"
|
(a :href href :sx-get href :sx-target "#main-panel"
|
||||||
:sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true"
|
:sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true"
|
||||||
@@ -107,6 +106,43 @@
|
|||||||
(ul :class "flex flex-wrap gap-2 text-sm"
|
(ul :class "flex flex-wrap gap-2 text-sm"
|
||||||
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors))))))))
|
(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)
|
(defcomp ~blog-page-badges (&key has-calendar has-market)
|
||||||
(div :class "flex justify-center gap-2 mt-2"
|
(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"
|
(when has-calendar (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800"
|
||||||
|
|||||||
@@ -12,10 +12,13 @@
|
|||||||
(when publish-requested (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800" "Publish requested"))
|
(when publish-requested (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800" "Publish requested"))
|
||||||
edit))
|
edit))
|
||||||
|
|
||||||
|
(defcomp ~blog-like-toggle (&key like-url hx-headers heart)
|
||||||
|
(button :sx-post like-url :sx-swap "outerHTML"
|
||||||
|
:sx-headers hx-headers :class "cursor-pointer" heart))
|
||||||
|
|
||||||
(defcomp ~blog-detail-like (&key like-url hx-headers heart)
|
(defcomp ~blog-detail-like (&key like-url hx-headers heart)
|
||||||
(div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl"
|
(div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl"
|
||||||
(button :sx-post like-url :sx-swap "outerHTML"
|
(~blog-like-toggle :like-url like-url :hx-headers hx-headers :heart heart)))
|
||||||
:sx-headers hx-headers :class "cursor-pointer" heart)))
|
|
||||||
|
|
||||||
(defcomp ~blog-detail-excerpt (&key excerpt)
|
(defcomp ~blog-detail-excerpt (&key excerpt)
|
||||||
(div :class "w-full text-center italic text-3xl p-2" excerpt))
|
(div :class "w-full text-center italic text-3xl p-2" excerpt))
|
||||||
@@ -55,8 +58,8 @@
|
|||||||
:like (when has-user
|
:like (when has-user
|
||||||
(~blog-detail-like
|
(~blog-detail-like
|
||||||
:like-url like-url
|
:like-url like-url
|
||||||
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
:hx-headers {:X-CSRFToken csrf}
|
||||||
:heart (if liked "\u2764\ufe0f" "\U0001f90d")))
|
:heart (if liked "❤️" "🤍")))
|
||||||
:excerpt (when (not (= custom-excerpt ""))
|
:excerpt (when (not (= custom-excerpt ""))
|
||||||
(~blog-detail-excerpt :excerpt custom-excerpt))
|
(~blog-detail-excerpt :excerpt custom-excerpt))
|
||||||
:at-bar (~blog-at-bar :tags tags :authors authors)))))
|
:at-bar (~blog-at-bar :tags tags :authors authors)))))
|
||||||
|
|||||||
@@ -63,3 +63,39 @@
|
|||||||
|
|
||||||
(defcomp ~blog-filter-summary (&key text)
|
(defcomp ~blog-filter-summary (&key text)
|
||||||
(span :class "text-sm text-stone-600" 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)))))))
|
||||||
|
|||||||
@@ -24,3 +24,37 @@
|
|||||||
(defcomp ~page-search-empty (&key query)
|
(defcomp ~page-search-empty (&key query)
|
||||||
(div :class "p-3 text-center text-stone-400 border border-stone-200 rounded-md"
|
(div :class "p-3 text-center text-stone-400 border border-stone-200 rounded-md"
|
||||||
(str "No pages found matching \"" query "\"")))
|
(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)))
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
(defcomp ~blog-features-form (&key features-url calendar-checked market-checked hs-trigger)
|
(defcomp ~blog-features-form (&key features-url calendar-checked market-checked hs-trigger)
|
||||||
(form :sx-put features-url :sx-target "#features-panel" :sx-swap "outerHTML"
|
(form :sx-put features-url :sx-target "#features-panel" :sx-swap "outerHTML"
|
||||||
:sx-headers "{\"Content-Type\": \"application/json\"}" :sx-encoding "json" :class "space-y-3"
|
:sx-headers {:Content-Type "application/json"} :sx-encoding "json" :class "space-y-3"
|
||||||
(label :class "flex items-center gap-3 cursor-pointer"
|
(label :class "flex items-center gap-3 cursor-pointer"
|
||||||
(input :type "checkbox" :name "calendar" :value "true" :checked calendar-checked
|
(input :type "checkbox" :name "calendar" :value "true" :checked calendar-checked
|
||||||
:class "h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500"
|
:class "h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500"
|
||||||
@@ -140,7 +140,7 @@
|
|||||||
(~blog-associated-entry
|
(~blog-associated-entry
|
||||||
:confirm-text (get e "confirm_text")
|
:confirm-text (get e "confirm_text")
|
||||||
:toggle-url (get e "toggle_url")
|
:toggle-url (get e "toggle_url")
|
||||||
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
:hx-headers {:X-CSRFToken csrf}
|
||||||
:img (~blog-entry-image :src (get e "cal_image") :title (get e "cal_title"))
|
:img (~blog-entry-image :src (get e "cal_image") :title (get e "cal_title"))
|
||||||
:name (get e "name")
|
:name (get e "name")
|
||||||
:date-str (get e "date_str")))
|
:date-str (get e "date_str")))
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ from shared.sx.helpers import (
|
|||||||
from shared.sx.parser import SxExpr
|
from shared.sx.parser import SxExpr
|
||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
_ensure_container_nav,
|
_clear_deeper_oob, _ensure_container_nav,
|
||||||
_list_container,
|
_entry_state_badge_html, _list_container,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -27,41 +27,45 @@ def _post_nav_sx(ctx: dict) -> str:
|
|||||||
"""Post desktop nav: calendar links + container nav (markets, etc.)."""
|
"""Post desktop nav: calendar links + container nav (markets, etc.)."""
|
||||||
from quart import url_for, g
|
from quart import url_for, g
|
||||||
|
|
||||||
calendars_orm = ctx.get("calendars") or []
|
calendars = ctx.get("calendars") or []
|
||||||
select_colours = ctx.get("select_colours", "")
|
select_colours = ctx.get("select_colours", "")
|
||||||
current_cal_slug = getattr(g, "calendar_slug", None)
|
current_cal_slug = getattr(g, "calendar_slug", None)
|
||||||
|
|
||||||
calendars_data = []
|
parts = []
|
||||||
for cal in calendars_orm:
|
for cal in calendars:
|
||||||
cal_slug = getattr(cal, "slug", "") if hasattr(cal, "slug") else cal.get("slug", "")
|
cal_slug = getattr(cal, "slug", "") if hasattr(cal, "slug") else cal.get("slug", "")
|
||||||
cal_name = getattr(cal, "name", "") if hasattr(cal, "name") else cal.get("name", "")
|
cal_name = getattr(cal, "name", "") if hasattr(cal, "name") else cal.get("name", "")
|
||||||
href = url_for("calendar.get", calendar_slug=cal_slug)
|
href = url_for("calendar.get", calendar_slug=cal_slug)
|
||||||
calendars_data.append({
|
is_sel = (cal_slug == current_cal_slug)
|
||||||
"href": href, "name": cal_name,
|
parts.append(sx_call("nav-link", href=href, icon="fa fa-calendar",
|
||||||
"is-selected": True if cal_slug == current_cal_slug else None,
|
label=cal_name, select_colours=select_colours,
|
||||||
})
|
is_selected=is_sel))
|
||||||
|
# Container nav fragments (markets, etc.)
|
||||||
container_nav = ctx.get("container_nav", "") or None
|
container_nav = ctx.get("container_nav", "")
|
||||||
|
if container_nav:
|
||||||
|
parts.append(container_nav)
|
||||||
|
|
||||||
|
# Admin cog → blog admin for this post (cross-domain, no HTMX)
|
||||||
rights = ctx.get("rights") or {}
|
rights = ctx.get("rights") or {}
|
||||||
has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
|
has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
|
||||||
admin_href = None
|
|
||||||
aclass = None
|
|
||||||
if has_admin:
|
if has_admin:
|
||||||
post = ctx.get("post") or {}
|
post = ctx.get("post") or {}
|
||||||
slug = post.get("slug", "")
|
slug = post.get("slug", "")
|
||||||
styles = ctx.get("styles") or {}
|
styles = ctx.get("styles") or {}
|
||||||
nav_btn = styles.get("nav_button", "") if isinstance(styles, dict) else getattr(styles, "nav_button", "")
|
nav_btn = styles.get("nav_button", "") if isinstance(styles, dict) else getattr(styles, "nav_button", "")
|
||||||
|
select_colours = ctx.get("select_colours", "")
|
||||||
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
|
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
|
||||||
aclass = f"{nav_btn} {select_colours}".strip() or (
|
aclass = f"{nav_btn} {select_colours}".strip() or (
|
||||||
"justify-center cursor-pointer flex flex-row items-center gap-2 "
|
"justify-center cursor-pointer flex flex-row items-center gap-2 "
|
||||||
"rounded bg-stone-200 text-black p-3"
|
"rounded bg-stone-200 text-black p-3"
|
||||||
)
|
)
|
||||||
|
parts.append(
|
||||||
|
f'<div class="relative nav-group">'
|
||||||
|
f'<a href="{admin_href}" class="{aclass}">'
|
||||||
|
f'<i class="fa fa-cog" aria-hidden="true"></i></a></div>'
|
||||||
|
)
|
||||||
|
|
||||||
return sx_call("events-post-nav-from-data",
|
return "".join(parts)
|
||||||
calendars=calendars_data or None, container_nav=container_nav,
|
|
||||||
select_colours=select_colours,
|
|
||||||
has_admin=has_admin or None, admin_href=admin_href, aclass=aclass)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -115,13 +119,15 @@ def _calendar_nav_sx(ctx: dict) -> str:
|
|||||||
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
|
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
|
||||||
select_colours = ctx.get("select_colours", "")
|
select_colours = ctx.get("select_colours", "")
|
||||||
|
|
||||||
|
parts = []
|
||||||
slots_href = url_for("defpage_slots_listing", calendar_slug=cal_slug)
|
slots_href = url_for("defpage_slots_listing", calendar_slug=cal_slug)
|
||||||
admin_href = url_for("defpage_calendar_admin", calendar_slug=cal_slug) if is_admin else None
|
parts.append(sx_call("nav-link", href=slots_href, icon="fa fa-clock",
|
||||||
|
label="Slots", select_colours=select_colours))
|
||||||
return sx_call("events-calendar-nav-from-data",
|
if is_admin:
|
||||||
slots_href=slots_href, admin_href=admin_href,
|
admin_href = url_for("defpage_calendar_admin", calendar_slug=cal_slug)
|
||||||
select_colours=select_colours,
|
parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog",
|
||||||
is_admin=is_admin or None)
|
select_colours=select_colours))
|
||||||
|
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -168,21 +174,27 @@ def _day_nav_sx(ctx: dict) -> str:
|
|||||||
rights = ctx.get("rights") or {}
|
rights = ctx.get("rights") or {}
|
||||||
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
|
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
|
||||||
|
|
||||||
entries_data = []
|
parts = []
|
||||||
for entry in confirmed_entries:
|
# Confirmed entries nav (scrolling menu)
|
||||||
href = url_for(
|
if confirmed_entries:
|
||||||
"defpage_entry_detail",
|
entry_links = []
|
||||||
calendar_slug=cal_slug,
|
for entry in confirmed_entries:
|
||||||
year=day_date.year,
|
href = url_for(
|
||||||
month=day_date.month,
|
"defpage_entry_detail",
|
||||||
day=day_date.day,
|
calendar_slug=cal_slug,
|
||||||
entry_id=entry.id,
|
year=day_date.year,
|
||||||
)
|
month=day_date.month,
|
||||||
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
|
day=day_date.day,
|
||||||
end = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
|
entry_id=entry.id,
|
||||||
entries_data.append({"href": href, "name": entry.name, "time-str": f"{start}{end}"})
|
)
|
||||||
|
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
|
||||||
|
end = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
|
||||||
|
entry_links.append(sx_call("events-day-entry-link",
|
||||||
|
href=href, name=entry.name,
|
||||||
|
time_str=f"{start}{end}"))
|
||||||
|
inner = "".join(entry_links)
|
||||||
|
parts.append(sx_call("events-day-entries-nav", inner=SxExpr(inner)))
|
||||||
|
|
||||||
admin_href = None
|
|
||||||
if is_admin and day_date:
|
if is_admin and day_date:
|
||||||
admin_href = url_for(
|
admin_href = url_for(
|
||||||
"defpage_day_admin",
|
"defpage_day_admin",
|
||||||
@@ -191,10 +203,8 @@ def _day_nav_sx(ctx: dict) -> str:
|
|||||||
month=day_date.month,
|
month=day_date.month,
|
||||||
day=day_date.day,
|
day=day_date.day,
|
||||||
)
|
)
|
||||||
|
parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog"))
|
||||||
return sx_call("events-day-nav-from-data",
|
return "".join(parts)
|
||||||
entries=entries_data or None,
|
|
||||||
is_admin=is_admin or None, admin_href=admin_href)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -235,16 +245,17 @@ def _calendar_admin_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
|||||||
cal_slug = getattr(calendar, "slug", "") if calendar else ""
|
cal_slug = getattr(calendar, "slug", "") if calendar else ""
|
||||||
select_colours = ctx.get("select_colours", "")
|
select_colours = ctx.get("select_colours", "")
|
||||||
|
|
||||||
links_data = []
|
nav_parts = []
|
||||||
if cal_slug:
|
if cal_slug:
|
||||||
for endpoint, label in [
|
for endpoint, label in [
|
||||||
("defpage_slots_listing", "slots"),
|
("defpage_slots_listing", "slots"),
|
||||||
("calendar.admin.calendar_description_edit", "description"),
|
("calendar.admin.calendar_description_edit", "description"),
|
||||||
]:
|
]:
|
||||||
links_data.append({"href": url_for(endpoint, calendar_slug=cal_slug), "label": label})
|
href = url_for(endpoint, calendar_slug=cal_slug)
|
||||||
|
nav_parts.append(sx_call("nav-link", href=href, label=label,
|
||||||
|
select_colours=select_colours))
|
||||||
|
|
||||||
nav_html = sx_call("events-calendar-admin-nav-from-data",
|
nav_html = "".join(nav_parts)
|
||||||
links=links_data or None, select_colours=select_colours) if links_data else ""
|
|
||||||
return sx_call("menu-row-sx", id="calendar-admin-row", level=4,
|
return sx_call("menu-row-sx", id="calendar-admin-row", level=4,
|
||||||
link_label="admin", icon="fa fa-cog",
|
link_label="admin", icon="fa fa-cog",
|
||||||
nav=SxExpr(nav_html) if nav_html else None, child_id="calendar-admin-header-child", oob=oob)
|
nav=SxExpr(nav_html) if nav_html else None, child_id="calendar-admin-header-child", oob=oob)
|
||||||
@@ -271,36 +282,55 @@ def _markets_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
|||||||
def _calendars_main_panel_sx(ctx: dict) -> str:
|
def _calendars_main_panel_sx(ctx: dict) -> str:
|
||||||
"""Render the calendars list + create form panel."""
|
"""Render the calendars list + create form panel."""
|
||||||
from quart import url_for
|
from quart import url_for
|
||||||
from shared.utils import route_prefix
|
|
||||||
rights = ctx.get("rights") or {}
|
rights = ctx.get("rights") or {}
|
||||||
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
|
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
|
||||||
has_access = ctx.get("has_access")
|
has_access = ctx.get("has_access")
|
||||||
can_create = has_access("calendars.create_calendar") if callable(has_access) else is_admin
|
can_create = has_access("calendars.create_calendar") if callable(has_access) else is_admin
|
||||||
csrf_token = ctx.get("csrf_token")
|
csrf_token = ctx.get("csrf_token")
|
||||||
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
|
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
|
||||||
prefix = route_prefix()
|
|
||||||
|
|
||||||
calendars = ctx.get("calendars") or []
|
calendars = ctx.get("calendars") or []
|
||||||
items_data = []
|
|
||||||
|
form_html = ""
|
||||||
|
if can_create:
|
||||||
|
create_url = url_for("calendars.create_calendar")
|
||||||
|
form_html = sx_call("crud-create-form",
|
||||||
|
create_url=create_url, csrf=csrf,
|
||||||
|
errors_id="cal-create-errors", list_id="calendars-list",
|
||||||
|
placeholder="e.g. Events, Gigs, Meetings", btn_label="Add calendar")
|
||||||
|
|
||||||
|
list_html = _calendars_list_sx(ctx, calendars)
|
||||||
|
return sx_call("crud-panel",
|
||||||
|
form=SxExpr(form_html), list=SxExpr(list_html),
|
||||||
|
list_id="calendars-list")
|
||||||
|
|
||||||
|
|
||||||
|
def _calendars_list_sx(ctx: dict, calendars: list) -> str:
|
||||||
|
"""Render the calendars list items."""
|
||||||
|
from quart import url_for
|
||||||
|
from shared.utils import route_prefix
|
||||||
|
csrf_token = ctx.get("csrf_token")
|
||||||
|
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
|
||||||
|
prefix = route_prefix()
|
||||||
|
|
||||||
|
if not calendars:
|
||||||
|
return sx_call("empty-state", message="No calendars yet. Create one above.",
|
||||||
|
cls="text-gray-500 mt-4")
|
||||||
|
|
||||||
|
parts = []
|
||||||
for cal in calendars:
|
for cal in calendars:
|
||||||
cal_slug = getattr(cal, "slug", "")
|
cal_slug = getattr(cal, "slug", "")
|
||||||
cal_name = getattr(cal, "name", "")
|
cal_name = getattr(cal, "name", "")
|
||||||
items_data.append({
|
href = prefix + url_for("calendar.get", calendar_slug=cal_slug)
|
||||||
"href": prefix + url_for("calendar.get", calendar_slug=cal_slug),
|
del_url = url_for("calendar.delete", calendar_slug=cal_slug)
|
||||||
"name": cal_name, "slug": cal_slug,
|
csrf_hdr = {"X-CSRFToken": csrf}
|
||||||
"del-url": url_for("calendar.delete", calendar_slug=cal_slug),
|
parts.append(sx_call("crud-item",
|
||||||
"csrf-hdr": f'{{"X-CSRFToken":"{csrf}"}}',
|
href=href, name=cal_name, slug=cal_slug,
|
||||||
"confirm-title": "Delete calendar?",
|
del_url=del_url, csrf_hdr=csrf_hdr,
|
||||||
"confirm-text": "Entries will be hidden (soft delete)",
|
list_id="calendars-list",
|
||||||
})
|
confirm_title="Delete calendar?",
|
||||||
|
confirm_text="Entries will be hidden (soft delete)"))
|
||||||
return sx_call("events-crud-panel-from-data",
|
return "".join(parts)
|
||||||
can_create=can_create or None,
|
|
||||||
create_url=url_for("calendars.create_calendar") if can_create else None,
|
|
||||||
csrf=csrf, errors_id="cal-create-errors", list_id="calendars-list",
|
|
||||||
placeholder="e.g. Events, Gigs, Meetings", btn_label="Add calendar",
|
|
||||||
items=items_data or None,
|
|
||||||
empty_msg="No calendars yet. Create one above.")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -308,7 +338,7 @@ def _calendars_main_panel_sx(ctx: dict) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _calendar_main_panel_html(ctx: dict) -> str:
|
def _calendar_main_panel_html(ctx: dict) -> str:
|
||||||
"""Render the calendar month grid via data extraction + sx defcomp."""
|
"""Render the calendar month grid."""
|
||||||
from quart import url_for
|
from quart import url_for
|
||||||
from quart import session as qsession
|
from quart import session as qsession
|
||||||
|
|
||||||
@@ -316,6 +346,7 @@ def _calendar_main_panel_html(ctx: dict) -> str:
|
|||||||
if not calendar:
|
if not calendar:
|
||||||
return ""
|
return ""
|
||||||
cal_slug = getattr(calendar, "slug", "")
|
cal_slug = getattr(calendar, "slug", "")
|
||||||
|
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||||||
styles = ctx.get("styles") or {}
|
styles = ctx.get("styles") or {}
|
||||||
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
|
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
|
||||||
|
|
||||||
@@ -337,8 +368,35 @@ def _calendar_main_panel_html(ctx: dict) -> str:
|
|||||||
def nav_link(y, m):
|
def nav_link(y, m):
|
||||||
return url_for("calendar.get", calendar_slug=cal_slug, year=y, month=m)
|
return url_for("calendar.get", calendar_slug=cal_slug, year=y, month=m)
|
||||||
|
|
||||||
# Day cells data
|
# Month navigation arrows
|
||||||
cells_data = []
|
nav_arrows = []
|
||||||
|
for label, yr, mn in [
|
||||||
|
("\u00ab", prev_year, month),
|
||||||
|
("\u2039", prev_month_year, prev_month),
|
||||||
|
]:
|
||||||
|
href = nav_link(yr, mn)
|
||||||
|
nav_arrows.append(sx_call("events-calendar-nav-arrow",
|
||||||
|
pill_cls=pill_cls, href=href, label=label))
|
||||||
|
|
||||||
|
nav_arrows.append(sx_call("events-calendar-month-label",
|
||||||
|
month_name=month_name, year=str(year)))
|
||||||
|
|
||||||
|
for label, yr, mn in [
|
||||||
|
("\u203a", next_month_year, next_month),
|
||||||
|
("\u00bb", next_year, month),
|
||||||
|
]:
|
||||||
|
href = nav_link(yr, mn)
|
||||||
|
nav_arrows.append(sx_call("events-calendar-nav-arrow",
|
||||||
|
pill_cls=pill_cls, href=href, label=label))
|
||||||
|
|
||||||
|
# Weekday headers
|
||||||
|
wd_parts = []
|
||||||
|
for wd in weekday_names:
|
||||||
|
wd_parts.append(sx_call("events-calendar-weekday", name=wd))
|
||||||
|
wd_html = "".join(wd_parts)
|
||||||
|
|
||||||
|
# Day cells
|
||||||
|
cells = []
|
||||||
for week in weeks:
|
for week in weeks:
|
||||||
for day_cell in week:
|
for day_cell in week:
|
||||||
if isinstance(day_cell, dict):
|
if isinstance(day_cell, dict):
|
||||||
@@ -356,18 +414,24 @@ def _calendar_main_panel_html(ctx: dict) -> str:
|
|||||||
if is_today:
|
if is_today:
|
||||||
cell_cls += " ring-2 ring-blue-500 z-10 relative"
|
cell_cls += " ring-2 ring-blue-500 z-10 relative"
|
||||||
|
|
||||||
cell = {"cell-cls": cell_cls}
|
# Day number link
|
||||||
|
day_num_html = ""
|
||||||
|
day_short_html = ""
|
||||||
if day_date:
|
if day_date:
|
||||||
cell["day-str"] = day_date.strftime("%a")
|
day_href = url_for(
|
||||||
cell["day-href"] = url_for(
|
|
||||||
"calendar.day.show_day",
|
"calendar.day.show_day",
|
||||||
calendar_slug=cal_slug,
|
calendar_slug=cal_slug,
|
||||||
year=day_date.year, month=day_date.month, day=day_date.day,
|
year=day_date.year, month=day_date.month, day=day_date.day,
|
||||||
)
|
)
|
||||||
cell["day-num"] = str(day_date.day)
|
day_short_html = sx_call("events-calendar-day-short",
|
||||||
|
day_str=day_date.strftime("%a"))
|
||||||
|
day_num_html = sx_call("events-calendar-day-num",
|
||||||
|
pill_cls=pill_cls, href=day_href,
|
||||||
|
num=str(day_date.day))
|
||||||
|
|
||||||
# Entry badges for this day
|
# Entry badges for this day
|
||||||
badges = []
|
entry_badges = []
|
||||||
|
if day_date:
|
||||||
for e in month_entries:
|
for e in month_entries:
|
||||||
if e.start_at and e.start_at.date() == day_date:
|
if e.start_at and e.start_at.date() == day_date:
|
||||||
is_mine = (
|
is_mine = (
|
||||||
@@ -378,23 +442,23 @@ def _calendar_main_panel_html(ctx: dict) -> str:
|
|||||||
bg_cls = "bg-emerald-200 text-emerald-900" if is_mine else "bg-emerald-100 text-emerald-800"
|
bg_cls = "bg-emerald-200 text-emerald-900" if is_mine else "bg-emerald-100 text-emerald-800"
|
||||||
else:
|
else:
|
||||||
bg_cls = "bg-sky-100 text-sky-800" if is_mine else "bg-stone-100 text-stone-700"
|
bg_cls = "bg-sky-100 text-sky-800" if is_mine else "bg-stone-100 text-stone-700"
|
||||||
badges.append({
|
state_label = (e.state or "pending").replace("_", " ")
|
||||||
"bg-cls": bg_cls, "name": e.name,
|
entry_badges.append(sx_call("events-calendar-entry-badge",
|
||||||
"state-label": (e.state or "pending").replace("_", " "),
|
bg_cls=bg_cls, name=e.name,
|
||||||
})
|
state_label=state_label))
|
||||||
if badges:
|
|
||||||
cell["badges"] = badges
|
|
||||||
|
|
||||||
cells_data.append(cell)
|
badges_html = "(<> " + "".join(entry_badges) + ")" if entry_badges else ""
|
||||||
|
cells.append(sx_call("events-calendar-cell",
|
||||||
|
cell_cls=cell_cls, day_short=SxExpr(day_short_html),
|
||||||
|
day_num=SxExpr(day_num_html),
|
||||||
|
badges=SxExpr(badges_html) if badges_html else None))
|
||||||
|
|
||||||
return sx_call("events-calendar-grid-from-data",
|
cells_html = "(<> " + "".join(cells) + ")"
|
||||||
pill_cls=pill_cls, month_name=month_name, year=str(year),
|
arrows_html = "(<> " + "".join(nav_arrows) + ")"
|
||||||
prev_year_href=nav_link(prev_year, month),
|
wd_html = "(<> " + wd_html + ")"
|
||||||
prev_month_href=nav_link(prev_month_year, prev_month),
|
return sx_call("events-calendar-grid",
|
||||||
next_month_href=nav_link(next_month_year, next_month),
|
arrows=SxExpr(arrows_html), weekdays=SxExpr(wd_html),
|
||||||
next_year_href=nav_link(next_year, month),
|
cells=SxExpr(cells_html))
|
||||||
weekday_names=weekday_names or None,
|
|
||||||
cells=cells_data or None)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -402,7 +466,7 @@ def _calendar_main_panel_html(ctx: dict) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _day_main_panel_html(ctx: dict) -> str:
|
def _day_main_panel_html(ctx: dict) -> str:
|
||||||
"""Render the day entries table via data extraction + sx defcomp."""
|
"""Render the day entries table + add button."""
|
||||||
from quart import url_for
|
from quart import url_for
|
||||||
|
|
||||||
calendar = ctx.get("calendar")
|
calendar = ctx.get("calendar")
|
||||||
@@ -413,49 +477,21 @@ def _day_main_panel_html(ctx: dict) -> str:
|
|||||||
day = ctx.get("day")
|
day = ctx.get("day")
|
||||||
month = ctx.get("month")
|
month = ctx.get("month")
|
||||||
year = ctx.get("year")
|
year = ctx.get("year")
|
||||||
|
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||||||
styles = ctx.get("styles") or {}
|
styles = ctx.get("styles") or {}
|
||||||
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
|
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
|
||||||
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
|
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
|
||||||
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
|
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
|
||||||
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
|
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
|
||||||
|
|
||||||
rows_data = []
|
rows_html = ""
|
||||||
for entry in day_entries:
|
if day_entries:
|
||||||
entry_href = url_for(
|
row_parts = []
|
||||||
"defpage_entry_detail",
|
for entry in day_entries:
|
||||||
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=entry.id,
|
row_parts.append(_day_row_html(ctx, entry))
|
||||||
)
|
rows_html = "".join(row_parts)
|
||||||
row = {
|
else:
|
||||||
"href": entry_href, "name": entry.name,
|
rows_html = sx_call("events-day-empty-row")
|
||||||
"state-id": f"entry-state-{entry.id}",
|
|
||||||
"state": getattr(entry, "state", "pending") or "pending",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Slot/Time
|
|
||||||
slot = getattr(entry, "slot", None)
|
|
||||||
if slot:
|
|
||||||
row["slot-name"] = slot.name
|
|
||||||
row["slot-href"] = url_for("defpage_slot_detail", calendar_slug=cal_slug, slot_id=slot.id)
|
|
||||||
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
|
|
||||||
time_end = f" \u2192 {slot.time_end.strftime('%H:%M')}" if slot.time_end else ""
|
|
||||||
row["slot-time"] = f"({time_start}{time_end})"
|
|
||||||
else:
|
|
||||||
row["start"] = entry.start_at.strftime("%H:%M") if entry.start_at else ""
|
|
||||||
row["end"] = f" \u2192 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
|
|
||||||
|
|
||||||
# Cost
|
|
||||||
cost = getattr(entry, "cost", None)
|
|
||||||
row["cost-str"] = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
|
|
||||||
|
|
||||||
# Tickets
|
|
||||||
tp = getattr(entry, "ticket_price", None)
|
|
||||||
if tp is not None:
|
|
||||||
tc = getattr(entry, "ticket_count", None)
|
|
||||||
row["has-tickets"] = True
|
|
||||||
row["price-str"] = f"\u00a3{tp:.2f}"
|
|
||||||
row["count-str"] = f"{tc} tickets" if tc is not None else "Unlimited"
|
|
||||||
|
|
||||||
rows_data.append(row)
|
|
||||||
|
|
||||||
add_url = url_for(
|
add_url = url_for(
|
||||||
"calendar.day.calendar_entries.add_form",
|
"calendar.day.calendar_entries.add_form",
|
||||||
@@ -463,10 +499,74 @@ def _day_main_panel_html(ctx: dict) -> str:
|
|||||||
day=day, month=month, year=year,
|
day=day, month=month, year=year,
|
||||||
)
|
)
|
||||||
|
|
||||||
return sx_call("events-day-table-from-data",
|
return sx_call("events-day-table",
|
||||||
list_container=list_container, pre_action=pre_action,
|
list_container=list_container, rows=SxExpr(rows_html),
|
||||||
add_url=add_url, tr_cls=tr_cls, pill_cls=pill_cls,
|
pre_action=pre_action, add_url=add_url)
|
||||||
rows=rows_data or None)
|
|
||||||
|
|
||||||
|
def _day_row_html(ctx: dict, entry) -> str:
|
||||||
|
"""Render a single day table row."""
|
||||||
|
from quart import url_for
|
||||||
|
calendar = ctx.get("calendar")
|
||||||
|
cal_slug = getattr(calendar, "slug", "")
|
||||||
|
day = ctx.get("day")
|
||||||
|
month = ctx.get("month")
|
||||||
|
year = ctx.get("year")
|
||||||
|
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||||||
|
styles = ctx.get("styles") or {}
|
||||||
|
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
|
||||||
|
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
|
||||||
|
|
||||||
|
entry_href = url_for(
|
||||||
|
"defpage_entry_detail",
|
||||||
|
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=entry.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Name
|
||||||
|
name_html = sx_call("events-day-row-name",
|
||||||
|
href=entry_href, pill_cls=pill_cls, name=entry.name)
|
||||||
|
|
||||||
|
# Slot/Time
|
||||||
|
slot = getattr(entry, "slot", None)
|
||||||
|
if slot:
|
||||||
|
slot_href = url_for("defpage_slot_detail", calendar_slug=cal_slug, slot_id=slot.id)
|
||||||
|
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
|
||||||
|
time_end = f" \u2192 {slot.time_end.strftime('%H:%M')}" if slot.time_end else ""
|
||||||
|
slot_html = sx_call("events-day-row-slot",
|
||||||
|
href=slot_href, pill_cls=pill_cls, slot_name=slot.name,
|
||||||
|
time_str=f"({time_start}{time_end})")
|
||||||
|
else:
|
||||||
|
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
|
||||||
|
end = f" \u2192 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
|
||||||
|
slot_html = sx_call("events-day-row-time", start=start, end=end)
|
||||||
|
|
||||||
|
# State
|
||||||
|
state = getattr(entry, "state", "pending") or "pending"
|
||||||
|
state_badge = _entry_state_badge_html(state)
|
||||||
|
state_td = sx_call("events-day-row-state",
|
||||||
|
state_id=f"entry-state-{entry.id}", badge=state_badge)
|
||||||
|
|
||||||
|
# Cost
|
||||||
|
cost = getattr(entry, "cost", None)
|
||||||
|
cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
|
||||||
|
cost_td = sx_call("events-day-row-cost", cost_str=cost_str)
|
||||||
|
|
||||||
|
# Tickets
|
||||||
|
tp = getattr(entry, "ticket_price", None)
|
||||||
|
if tp is not None:
|
||||||
|
tc = getattr(entry, "ticket_count", None)
|
||||||
|
tc_str = f"{tc} tickets" if tc is not None else "Unlimited"
|
||||||
|
tickets_td = sx_call("events-day-row-tickets",
|
||||||
|
price_str=f"\u00a3{tp:.2f}", count_str=tc_str)
|
||||||
|
else:
|
||||||
|
tickets_td = sx_call("events-day-row-no-tickets")
|
||||||
|
|
||||||
|
actions_td = sx_call("events-day-row-actions")
|
||||||
|
|
||||||
|
return sx_call("events-day-row",
|
||||||
|
tr_cls=tr_cls, name=name_html, slot=slot_html,
|
||||||
|
state=state_td, cost=cost_td,
|
||||||
|
tickets=tickets_td, actions=actions_td)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -514,7 +614,7 @@ def _calendar_description_display_html(calendar, edit_url: str) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _markets_main_panel_html(ctx: dict) -> str:
|
def _markets_main_panel_html(ctx: dict) -> str:
|
||||||
"""Render markets list + create form panel via data extraction + sx defcomp."""
|
"""Render markets list + create form panel."""
|
||||||
from quart import url_for
|
from quart import url_for
|
||||||
rights = ctx.get("rights") or {}
|
rights = ctx.get("rights") or {}
|
||||||
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
|
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
|
||||||
@@ -523,29 +623,48 @@ def _markets_main_panel_html(ctx: dict) -> str:
|
|||||||
csrf_token = ctx.get("csrf_token")
|
csrf_token = ctx.get("csrf_token")
|
||||||
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
|
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
|
||||||
markets = ctx.get("markets") or []
|
markets = ctx.get("markets") or []
|
||||||
|
|
||||||
|
form_html = ""
|
||||||
|
if can_create:
|
||||||
|
create_url = url_for("markets.create_market")
|
||||||
|
form_html = sx_call("crud-create-form",
|
||||||
|
create_url=create_url, csrf=csrf,
|
||||||
|
errors_id="market-create-errors", list_id="markets-list",
|
||||||
|
placeholder="e.g. Farm Shop, Bakery", btn_label="Add market")
|
||||||
|
|
||||||
|
list_html = _markets_list_html(ctx, markets)
|
||||||
|
return sx_call("crud-panel",
|
||||||
|
form=SxExpr(form_html), list=SxExpr(list_html),
|
||||||
|
list_id="markets-list")
|
||||||
|
|
||||||
|
|
||||||
|
def _markets_list_html(ctx: dict, markets: list) -> str:
|
||||||
|
"""Render markets list items."""
|
||||||
|
from quart import url_for
|
||||||
|
csrf_token = ctx.get("csrf_token")
|
||||||
|
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
|
||||||
post = ctx.get("post") or {}
|
post = ctx.get("post") or {}
|
||||||
slug = post.get("slug", "")
|
slug = post.get("slug", "")
|
||||||
|
|
||||||
items_data = []
|
if not markets:
|
||||||
|
return sx_call("empty-state", message="No markets yet. Create one above.",
|
||||||
|
cls="text-gray-500 mt-4")
|
||||||
|
|
||||||
|
parts = []
|
||||||
for m in markets:
|
for m in markets:
|
||||||
m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "")
|
m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "")
|
||||||
m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
|
m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
|
||||||
items_data.append({
|
market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/")
|
||||||
"href": call_url(ctx, "market_url", f"/{slug}/{m_slug}/"),
|
del_url = url_for("markets.delete_market", market_slug=m_slug)
|
||||||
"name": m_name, "slug": m_slug,
|
csrf_hdr = {"X-CSRFToken": csrf}
|
||||||
"del-url": url_for("markets.delete_market", market_slug=m_slug),
|
parts.append(sx_call("crud-item",
|
||||||
"csrf-hdr": f'{{"X-CSRFToken":"{csrf}"}}',
|
href=market_href, name=m_name,
|
||||||
"confirm-title": "Delete market?",
|
slug=m_slug, del_url=del_url,
|
||||||
"confirm-text": "Products will be hidden (soft delete)",
|
csrf_hdr=csrf_hdr,
|
||||||
})
|
list_id="markets-list",
|
||||||
|
confirm_title="Delete market?",
|
||||||
return sx_call("events-crud-panel-from-data",
|
confirm_text="Products will be hidden (soft delete)"))
|
||||||
can_create=can_create or None,
|
return "".join(parts)
|
||||||
create_url=url_for("markets.create_market") if can_create else None,
|
|
||||||
csrf=csrf, errors_id="market-create-errors", list_id="markets-list",
|
|
||||||
placeholder="e.g. Farm Shop, Bakery", btn_label="Add market",
|
|
||||||
items=items_data or None,
|
|
||||||
empty_msg="No markets yet. Create one above.")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
"""Entry panels, cards, forms, edit/add."""
|
"""Entry panels, cards, forms, edit/add."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from markupsafe import escape
|
||||||
|
|
||||||
from shared.sx.helpers import sx_call
|
from shared.sx.helpers import sx_call
|
||||||
from shared.sx.parser import SxExpr
|
from shared.sx.parser import SxExpr
|
||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
_entry_state_badge_html,
|
_entry_state_badge_html, _ticket_state_badge_html,
|
||||||
_list_container, _view_toggle_html,
|
_list_container, _view_toggle_html,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# All events / page summary entry cards — data extraction
|
# All events / page summary entry cards
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _entry_card_data(entry, page_info: dict, pending_tickets: dict,
|
def _entry_card_html(entry, page_info: dict, pending_tickets: dict,
|
||||||
ticket_url: str, events_url_fn, *, is_page_scoped: bool = False,
|
ticket_url: str, events_url_fn, *, is_page_scoped: bool = False,
|
||||||
post: dict | None = None) -> dict:
|
post: dict | None = None) -> str:
|
||||||
"""Extract data for a single entry card (list or tile)."""
|
"""Render a list card for one event entry."""
|
||||||
|
from .tickets import _ticket_widget_html
|
||||||
pi = page_info.get(getattr(entry, "calendar_container_id", 0), {})
|
pi = page_info.get(getattr(entry, "calendar_container_id", 0), {})
|
||||||
if is_page_scoped and post:
|
if is_page_scoped and post:
|
||||||
page_slug = pi.get("slug", post.get("slug", ""))
|
page_slug = pi.get("slug", post.get("slug", ""))
|
||||||
@@ -30,103 +33,145 @@ def _entry_card_data(entry, page_info: dict, pending_tickets: dict,
|
|||||||
day_href = events_url_fn(f"/{page_slug}/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/")
|
day_href = events_url_fn(f"/{page_slug}/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/")
|
||||||
entry_href = f"{day_href}entries/{entry.id}/" if day_href else ""
|
entry_href = f"{day_href}entries/{entry.id}/" if day_href else ""
|
||||||
|
|
||||||
# Page badge (only show if different from current page title)
|
# Title (linked or plain)
|
||||||
page_badge_href = ""
|
if entry_href:
|
||||||
page_badge_title = ""
|
title_html = sx_call("events-entry-title-linked",
|
||||||
|
href=entry_href, name=entry.name)
|
||||||
|
else:
|
||||||
|
title_html = sx_call("events-entry-title-plain", name=entry.name)
|
||||||
|
|
||||||
|
# Badges
|
||||||
|
badges_html = ""
|
||||||
if page_title and (not is_page_scoped or page_title != (post or {}).get("title")):
|
if page_title and (not is_page_scoped or page_title != (post or {}).get("title")):
|
||||||
page_badge_href = events_url_fn(f"/{page_slug}/")
|
page_href = events_url_fn(f"/{page_slug}/")
|
||||||
page_badge_title = page_title
|
badges_html += sx_call("events-entry-page-badge",
|
||||||
|
href=page_href, title=page_title)
|
||||||
|
cal_name = getattr(entry, "calendar_name", "")
|
||||||
|
if cal_name:
|
||||||
|
badges_html += sx_call("events-entry-cal-badge", name=cal_name)
|
||||||
|
|
||||||
cal_name = getattr(entry, "calendar_name", "") or ""
|
# Time line
|
||||||
|
time_parts = ""
|
||||||
# Time parts
|
if day_href and not is_page_scoped:
|
||||||
date_str = entry.start_at.strftime("%a %-d %b") if entry.start_at else ""
|
time_parts += sx_call("events-entry-time-linked",
|
||||||
start_time = entry.start_at.strftime("%H:%M") if entry.start_at else ""
|
href=day_href,
|
||||||
end_time = entry.end_at.strftime("%H:%M") if entry.end_at else ""
|
date_str=entry.start_at.strftime("%a %-d %b"))
|
||||||
|
elif not is_page_scoped:
|
||||||
# Tile time string (combined)
|
time_parts += sx_call("events-entry-time-plain",
|
||||||
time_str_parts = []
|
date_str=entry.start_at.strftime("%a %-d %b"))
|
||||||
if date_str:
|
time_parts += entry.start_at.strftime("%H:%M")
|
||||||
time_str_parts.append(date_str)
|
if entry.end_at:
|
||||||
if start_time:
|
time_parts += f' \u2013 {entry.end_at.strftime("%H:%M")}'
|
||||||
time_str_parts.append(start_time)
|
|
||||||
time_str = " \u00b7 ".join(time_str_parts)
|
|
||||||
if end_time:
|
|
||||||
time_str += f" \u2013 {end_time}"
|
|
||||||
|
|
||||||
cost = getattr(entry, "cost", None)
|
cost = getattr(entry, "cost", None)
|
||||||
cost_str = f"\u00a3{cost:.2f}" if cost else None
|
cost_html = sx_call("events-entry-cost",
|
||||||
|
cost=f"\u00a3{cost:.2f}") if cost else ""
|
||||||
|
|
||||||
# Ticket widget data
|
# Ticket widget
|
||||||
tp = getattr(entry, "ticket_price", None)
|
tp = getattr(entry, "ticket_price", None)
|
||||||
has_ticket = tp is not None
|
widget_html = ""
|
||||||
ticket_data = None
|
if tp is not None:
|
||||||
if has_ticket:
|
|
||||||
qty = pending_tickets.get(entry.id, 0)
|
qty = pending_tickets.get(entry.id, 0)
|
||||||
ticket_data = {
|
widget_html = sx_call("events-entry-widget-wrapper",
|
||||||
"entry-id": str(entry.id),
|
widget=_ticket_widget_html(entry, qty, ticket_url, ctx={}))
|
||||||
"price": f"\u00a3{tp:.2f}",
|
|
||||||
"qty": qty,
|
|
||||||
"ticket-url": ticket_url,
|
|
||||||
"csrf": _get_csrf(),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return sx_call("events-entry-card",
|
||||||
"entry-href": entry_href or None,
|
title=title_html, badges=SxExpr(badges_html),
|
||||||
"name": entry.name,
|
time_parts=SxExpr(time_parts), cost=SxExpr(cost_html),
|
||||||
"day-href": day_href or None,
|
widget=SxExpr(widget_html))
|
||||||
"page-badge-href": page_badge_href or None,
|
|
||||||
"page-badge-title": page_badge_title or None,
|
|
||||||
"cal-name": cal_name or None,
|
|
||||||
"date-str": date_str,
|
|
||||||
"start-time": start_time,
|
|
||||||
"end-time": end_time or None,
|
|
||||||
"time-str": time_str,
|
|
||||||
"is-page-scoped": is_page_scoped or None,
|
|
||||||
"cost": cost_str,
|
|
||||||
"has-ticket": has_ticket or None,
|
|
||||||
"ticket-data": ticket_data,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_csrf() -> str:
|
def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict,
|
||||||
"""Get CSRF token (lazy import)."""
|
ticket_url: str, events_url_fn, *, is_page_scoped: bool = False,
|
||||||
try:
|
post: dict | None = None) -> str:
|
||||||
from flask_wtf.csrf import generate_csrf
|
"""Render a tile card for one event entry."""
|
||||||
return generate_csrf()
|
from .tickets import _ticket_widget_html
|
||||||
except Exception:
|
pi = page_info.get(getattr(entry, "calendar_container_id", 0), {})
|
||||||
return ""
|
if is_page_scoped and post:
|
||||||
|
page_slug = pi.get("slug", post.get("slug", ""))
|
||||||
|
else:
|
||||||
|
page_slug = pi.get("slug", "")
|
||||||
|
page_title = pi.get("title")
|
||||||
|
|
||||||
|
day_href = ""
|
||||||
|
if page_slug and entry.start_at:
|
||||||
|
day_href = events_url_fn(f"/{page_slug}/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/")
|
||||||
|
entry_href = f"{day_href}entries/{entry.id}/" if day_href else ""
|
||||||
|
|
||||||
def _entry_cards_data(entries, page_info, pending_tickets, ticket_url,
|
# Title
|
||||||
events_url_fn, view, *, is_page_scoped=False, post=None) -> list:
|
if entry_href:
|
||||||
"""Extract data list for entry cards with date separators."""
|
title_html = sx_call("events-entry-title-tile-linked",
|
||||||
items = []
|
href=entry_href, name=entry.name)
|
||||||
last_date = None
|
else:
|
||||||
for entry in entries:
|
title_html = sx_call("events-entry-title-tile-plain", name=entry.name)
|
||||||
if view != "tile":
|
|
||||||
entry_date = entry.start_at.strftime("%A %-d %B %Y") if entry.start_at else ""
|
# Badges
|
||||||
if entry_date != last_date:
|
badges_html = ""
|
||||||
items.append({"is-separator": True, "date-str": entry_date})
|
if page_title and (not is_page_scoped or page_title != (post or {}).get("title")):
|
||||||
last_date = entry_date
|
page_href = events_url_fn(f"/{page_slug}/")
|
||||||
items.append(_entry_card_data(
|
badges_html += sx_call("events-entry-page-badge",
|
||||||
entry, page_info, pending_tickets, ticket_url, events_url_fn,
|
href=page_href, title=page_title)
|
||||||
is_page_scoped=is_page_scoped, post=post,
|
cal_name = getattr(entry, "calendar_name", "")
|
||||||
))
|
if cal_name:
|
||||||
return items
|
badges_html += sx_call("events-entry-cal-badge", name=cal_name)
|
||||||
|
|
||||||
|
# Time
|
||||||
|
time_html = ""
|
||||||
|
if day_href:
|
||||||
|
time_html += (sx_call("events-entry-time-linked",
|
||||||
|
href=day_href,
|
||||||
|
date_str=entry.start_at.strftime("%a %-d %b"))).replace(" · ", "")
|
||||||
|
else:
|
||||||
|
time_html += entry.start_at.strftime("%a %-d %b")
|
||||||
|
time_html += f' \u00b7 {entry.start_at.strftime("%H:%M")}'
|
||||||
|
if entry.end_at:
|
||||||
|
time_html += f' \u2013 {entry.end_at.strftime("%H:%M")}'
|
||||||
|
|
||||||
|
cost = getattr(entry, "cost", None)
|
||||||
|
cost_html = sx_call("events-entry-cost",
|
||||||
|
cost=f"\u00a3{cost:.2f}") if cost else ""
|
||||||
|
|
||||||
|
# Ticket widget
|
||||||
|
tp = getattr(entry, "ticket_price", None)
|
||||||
|
widget_html = ""
|
||||||
|
if tp is not None:
|
||||||
|
qty = pending_tickets.get(entry.id, 0)
|
||||||
|
widget_html = sx_call("events-entry-tile-widget-wrapper",
|
||||||
|
widget=_ticket_widget_html(entry, qty, ticket_url, ctx={}))
|
||||||
|
|
||||||
|
return sx_call("events-entry-card-tile",
|
||||||
|
title=title_html, badges=SxExpr(badges_html),
|
||||||
|
time=SxExpr(time_html), cost=SxExpr(cost_html),
|
||||||
|
widget=SxExpr(widget_html))
|
||||||
|
|
||||||
|
|
||||||
def _entry_cards_html(entries, page_info, pending_tickets, ticket_url,
|
def _entry_cards_html(entries, page_info, pending_tickets, ticket_url,
|
||||||
events_url_fn, view, page, has_more, next_url,
|
events_url_fn, view, page, has_more, next_url,
|
||||||
*, is_page_scoped=False, post=None) -> str:
|
*, is_page_scoped=False, post=None) -> str:
|
||||||
"""Render entry cards via sx defcomp with data extraction."""
|
"""Render entry cards (list or tile) with sentinel."""
|
||||||
items = _entry_cards_data(
|
parts = []
|
||||||
entries, page_info, pending_tickets, ticket_url, events_url_fn,
|
last_date = None
|
||||||
view, is_page_scoped=is_page_scoped, post=post,
|
for entry in entries:
|
||||||
)
|
if view == "tile":
|
||||||
return sx_call("events-entry-cards-from-data",
|
parts.append(_entry_card_tile_html(
|
||||||
items=items, view=view, page=page,
|
entry, page_info, pending_tickets, ticket_url, events_url_fn,
|
||||||
has_more=has_more, next_url=next_url)
|
is_page_scoped=is_page_scoped, post=post,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
entry_date = entry.start_at.strftime("%A %-d %B %Y") if entry.start_at else ""
|
||||||
|
if entry_date != last_date:
|
||||||
|
parts.append(sx_call("events-date-separator",
|
||||||
|
date_str=entry_date))
|
||||||
|
last_date = entry_date
|
||||||
|
parts.append(_entry_card_html(
|
||||||
|
entry, page_info, pending_tickets, ticket_url, events_url_fn,
|
||||||
|
is_page_scoped=is_page_scoped, post=post,
|
||||||
|
))
|
||||||
|
|
||||||
|
if has_more:
|
||||||
|
parts.append(sx_call("sentinel-simple",
|
||||||
|
id=f"sentinel-{page}", next_url=next_url))
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -138,15 +183,23 @@ def _events_main_panel_html(ctx: dict, entries, has_more, pending_tickets, page_
|
|||||||
*, is_page_scoped=False, post=None) -> str:
|
*, is_page_scoped=False, post=None) -> str:
|
||||||
"""Render the events main panel with view toggle + cards."""
|
"""Render the events main panel with view toggle + cards."""
|
||||||
toggle = _view_toggle_html(ctx, view)
|
toggle = _view_toggle_html(ctx, view)
|
||||||
items = None
|
|
||||||
if entries:
|
if entries:
|
||||||
items = _entry_cards_data(
|
cards = _entry_cards_html(
|
||||||
entries, page_info, pending_tickets, ticket_url, events_url_fn,
|
entries, page_info, pending_tickets, ticket_url, events_url_fn,
|
||||||
view, is_page_scoped=is_page_scoped, post=post,
|
view, page, has_more, next_url,
|
||||||
|
is_page_scoped=is_page_scoped, post=post,
|
||||||
)
|
)
|
||||||
return sx_call("events-main-panel-from-data",
|
grid_cls = ("max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
|
||||||
toggle=toggle, items=items, view=view, page=page,
|
if view == "tile" else "max-w-full px-3 py-3 space-y-3")
|
||||||
has_more=has_more, next_url=next_url)
|
body = sx_call("events-grid", grid_cls=grid_cls, cards=SxExpr(cards))
|
||||||
|
else:
|
||||||
|
body = sx_call("empty-state", icon="fa fa-calendar-xmark",
|
||||||
|
message="No upcoming events",
|
||||||
|
cls="px-3 py-12 text-center text-stone-400")
|
||||||
|
|
||||||
|
return sx_call("events-main-panel-body",
|
||||||
|
toggle=toggle, body=body)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -320,16 +373,27 @@ def _entry_nav_html(ctx: dict) -> str:
|
|||||||
entry_posts = ctx.get("entry_posts") or []
|
entry_posts = ctx.get("entry_posts") or []
|
||||||
rights = ctx.get("rights") or {}
|
rights = ctx.get("rights") or {}
|
||||||
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
|
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
|
||||||
|
|
||||||
blog_url_fn = ctx.get("blog_url")
|
blog_url_fn = ctx.get("blog_url")
|
||||||
|
|
||||||
parts = []
|
parts = []
|
||||||
|
|
||||||
# Associated Posts scrolling menu (strip OOB attr for inline embedding)
|
# Associated Posts scrolling menu
|
||||||
if entry_posts:
|
if entry_posts:
|
||||||
posts_data = _entry_posts_nav_data(entry_posts, blog_url_fn)
|
post_links = ""
|
||||||
nav_html = sx_call("events-entry-posts-nav-inner-from-data", posts=posts_data or None)
|
for ep in entry_posts:
|
||||||
if nav_html:
|
slug = getattr(ep, "slug", "")
|
||||||
parts.append(nav_html.replace(' :hx-swap-oob "true"', ''))
|
title = getattr(ep, "title", "")
|
||||||
|
feat = getattr(ep, "feature_image", None)
|
||||||
|
href = blog_url_fn(f"/{slug}/") if blog_url_fn else f"/{slug}/"
|
||||||
|
if feat:
|
||||||
|
img_html = sx_call("events-post-img", src=feat, alt=title)
|
||||||
|
else:
|
||||||
|
img_html = sx_call("events-post-img-placeholder")
|
||||||
|
post_links += sx_call("events-entry-nav-post-link",
|
||||||
|
href=href, img=img_html, title=title)
|
||||||
|
parts.append((sx_call("events-entry-posts-nav-oob",
|
||||||
|
items=SxExpr(post_links))).replace(' :hx-swap-oob "true"', ''))
|
||||||
|
|
||||||
# Admin link
|
# Admin link
|
||||||
if is_admin:
|
if is_admin:
|
||||||
@@ -368,7 +432,7 @@ def _entry_title_html(entry) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _entry_options_html(entry, calendar, day, month, year) -> str:
|
def _entry_options_html(entry, calendar, day, month, year) -> str:
|
||||||
"""Render confirm/decline/provisional buttons via data extraction + sx defcomp."""
|
"""Render confirm/decline/provisional buttons based on entry state."""
|
||||||
from quart import url_for, g
|
from quart import url_for, g
|
||||||
from shared.browser.app.csrf import generate_csrf_token
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
csrf = generate_csrf_token()
|
csrf = generate_csrf_token()
|
||||||
@@ -379,30 +443,39 @@ def _entry_options_html(entry, calendar, day, month, year) -> str:
|
|||||||
cal_slug = getattr(calendar, "slug", "")
|
cal_slug = getattr(calendar, "slug", "")
|
||||||
eid = entry.id
|
eid = entry.id
|
||||||
state = getattr(entry, "state", "pending") or "pending"
|
state = getattr(entry, "state", "pending") or "pending"
|
||||||
|
target = f"#calendar_entry_options_{eid}"
|
||||||
|
|
||||||
def _btn_data(action_name, label, confirm_title, confirm_text, *, trigger_type="submit"):
|
def _make_button(action_name, label, confirm_title, confirm_text, *, trigger_type="submit"):
|
||||||
return {
|
url = url_for(
|
||||||
"url": url_for(f"calendar.day.calendar_entries.calendar_entry.{action_name}",
|
f"calendar.day.calendar_entries.calendar_entry.{action_name}",
|
||||||
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid),
|
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid,
|
||||||
"csrf": csrf, "btn-type": "button" if trigger_type == "button" else "submit",
|
)
|
||||||
"action-btn": action_btn, "confirm-title": confirm_title,
|
btn_type = "button" if trigger_type == "button" else "submit"
|
||||||
"confirm-text": confirm_text, "label": label,
|
return sx_call("events-entry-option-button",
|
||||||
"is-btn": True if trigger_type == "button" else None,
|
url=url, target=target, csrf=csrf, btn_type=btn_type,
|
||||||
}
|
action_btn=action_btn, confirm_title=confirm_title,
|
||||||
|
confirm_text=confirm_text, label=label,
|
||||||
|
is_btn=trigger_type == "button")
|
||||||
|
|
||||||
buttons = []
|
buttons_html = ""
|
||||||
if state == "provisional":
|
if state == "provisional":
|
||||||
buttons.append(_btn_data("confirm_entry", "confirm",
|
buttons_html += _make_button(
|
||||||
"Confirm entry?", "Are you sure you want to confirm this entry?"))
|
"confirm_entry", "confirm",
|
||||||
buttons.append(_btn_data("decline_entry", "decline",
|
"Confirm entry?", "Are you sure you want to confirm this entry?",
|
||||||
"Decline entry?", "Are you sure you want to decline this entry?"))
|
)
|
||||||
|
buttons_html += _make_button(
|
||||||
|
"decline_entry", "decline",
|
||||||
|
"Decline entry?", "Are you sure you want to decline this entry?",
|
||||||
|
)
|
||||||
elif state == "confirmed":
|
elif state == "confirmed":
|
||||||
buttons.append(_btn_data("provisional_entry", "provisional",
|
buttons_html += _make_button(
|
||||||
"Provisional entry?", "Are you sure you want to provisional this entry?",
|
"provisional_entry", "provisional",
|
||||||
trigger_type="button"))
|
"Provisional entry?", "Are you sure you want to provisional this entry?",
|
||||||
|
trigger_type="button",
|
||||||
|
)
|
||||||
|
|
||||||
return sx_call("events-entry-options-from-data",
|
return sx_call("events-entry-options",
|
||||||
entry_id=str(eid), buttons=buttons or None)
|
entry_id=str(eid), buttons=SxExpr(buttons_html))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -452,7 +525,7 @@ def render_entry_tickets_config(entry, calendar, day, month, year) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) -> str:
|
def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) -> str:
|
||||||
"""Render associated posts list via data extraction + sx defcomp."""
|
"""Render associated posts list with remove buttons and search input."""
|
||||||
from quart import url_for
|
from quart import url_for
|
||||||
from shared.browser.app.csrf import generate_csrf_token
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
csrf = generate_csrf_token()
|
csrf = generate_csrf_token()
|
||||||
@@ -460,46 +533,38 @@ def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) ->
|
|||||||
cal_slug = getattr(calendar, "slug", "")
|
cal_slug = getattr(calendar, "slug", "")
|
||||||
eid = entry.id
|
eid = entry.id
|
||||||
eid_s = str(eid)
|
eid_s = str(eid)
|
||||||
csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}'
|
|
||||||
|
|
||||||
posts_data = []
|
posts_html = ""
|
||||||
if entry_posts:
|
if entry_posts:
|
||||||
|
items = ""
|
||||||
for ep in entry_posts:
|
for ep in entry_posts:
|
||||||
posts_data.append({
|
ep_title = getattr(ep, "title", "")
|
||||||
"title": getattr(ep, "title", ""),
|
ep_id = getattr(ep, "id", 0)
|
||||||
"img": getattr(ep, "feature_image", None),
|
feat = getattr(ep, "feature_image", None)
|
||||||
"del-url": url_for(
|
img_html = (sx_call("events-post-img", src=feat, alt=ep_title)
|
||||||
"calendar.day.calendar_entries.calendar_entry.remove_post",
|
if feat else sx_call("events-post-img-placeholder"))
|
||||||
calendar_slug=cal_slug, day=day, month=month, year=year,
|
|
||||||
entry_id=eid, post_id=getattr(ep, "id", 0),
|
del_url = url_for(
|
||||||
),
|
"calendar.day.calendar_entries.calendar_entry.remove_post",
|
||||||
"csrf-hdr": csrf_hdr,
|
calendar_slug=cal_slug, day=day, month=month, year=year,
|
||||||
})
|
entry_id=eid, post_id=ep_id,
|
||||||
|
)
|
||||||
|
items += sx_call("events-entry-post-item",
|
||||||
|
img=img_html, title=ep_title,
|
||||||
|
del_url=del_url, entry_id=eid_s,
|
||||||
|
csrf_hdr={"X-CSRFToken": csrf})
|
||||||
|
posts_html = sx_call("events-entry-posts-list", items=SxExpr(items))
|
||||||
|
else:
|
||||||
|
posts_html = sx_call("events-entry-posts-none")
|
||||||
|
|
||||||
search_url = url_for(
|
search_url = url_for(
|
||||||
"calendar.day.calendar_entries.calendar_entry.search_posts",
|
"calendar.day.calendar_entries.calendar_entry.search_posts",
|
||||||
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid,
|
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid,
|
||||||
)
|
)
|
||||||
|
|
||||||
return sx_call("events-entry-posts-panel-from-data",
|
return sx_call("events-entry-posts-panel",
|
||||||
entry_id=eid_s, posts=posts_data or None,
|
posts=posts_html, search_url=search_url,
|
||||||
search_url=search_url)
|
entry_id=eid_s)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Entry posts nav data helper (shared by nav OOB + entry nav)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _entry_posts_nav_data(entry_posts, blog_url_fn) -> list:
|
|
||||||
"""Extract post nav data from ORM entry posts."""
|
|
||||||
if not entry_posts:
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
{"href": blog_url_fn(f"/{getattr(ep, 'slug', '')}/") if blog_url_fn else f"/{getattr(ep, 'slug', '')}/",
|
|
||||||
"title": getattr(ep, "title", ""),
|
|
||||||
"img": getattr(ep, "feature_image", None)}
|
|
||||||
for ep in entry_posts
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -507,15 +572,28 @@ def _entry_posts_nav_data(entry_posts, blog_url_fn) -> list:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def render_entry_posts_nav_oob(entry_posts) -> str:
|
def render_entry_posts_nav_oob(entry_posts) -> str:
|
||||||
"""Render OOB nav for entry posts via data extraction + sx defcomp."""
|
"""Render OOB nav for entry posts (scrolling menu)."""
|
||||||
from quart import g
|
from quart import g
|
||||||
styles = getattr(g, "styles", None) or {}
|
styles = getattr(g, "styles", None) or {}
|
||||||
nav_btn = getattr(styles, "nav_button", "") if hasattr(styles, "nav_button") else styles.get("nav_button", "")
|
nav_btn = getattr(styles, "nav_button", "") if hasattr(styles, "nav_button") else styles.get("nav_button", "")
|
||||||
blog_url_fn = getattr(g, "blog_url", None)
|
blog_url_fn = getattr(g, "blog_url", None)
|
||||||
|
|
||||||
posts_data = _entry_posts_nav_data(entry_posts, blog_url_fn)
|
if not entry_posts:
|
||||||
return sx_call("events-entry-posts-nav-oob-from-data",
|
return sx_call("events-entry-posts-nav-oob-empty")
|
||||||
nav_btn=nav_btn, posts=posts_data or None)
|
|
||||||
|
items = ""
|
||||||
|
for ep in entry_posts:
|
||||||
|
slug = getattr(ep, "slug", "")
|
||||||
|
title = getattr(ep, "title", "")
|
||||||
|
feat = getattr(ep, "feature_image", None)
|
||||||
|
href = blog_url_fn(f"/{slug}/") if blog_url_fn else f"/{slug}/"
|
||||||
|
img_html = (sx_call("events-post-img", src=feat, alt=title)
|
||||||
|
if feat else sx_call("events-post-img-placeholder"))
|
||||||
|
items += sx_call("events-entry-nav-post",
|
||||||
|
href=href, nav_btn=nav_btn,
|
||||||
|
img=img_html, title=title)
|
||||||
|
|
||||||
|
return sx_call("events-entry-posts-nav-oob", items=SxExpr(items))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -523,28 +601,31 @@ def render_entry_posts_nav_oob(entry_posts) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> str:
|
def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> str:
|
||||||
"""Render OOB nav for confirmed entries via data extraction + sx defcomp."""
|
"""Render OOB nav for confirmed entries in a day."""
|
||||||
from quart import url_for, g
|
from quart import url_for, g
|
||||||
|
|
||||||
styles = getattr(g, "styles", None) or {}
|
styles = getattr(g, "styles", None) or {}
|
||||||
nav_btn = getattr(styles, "nav_button", "") if hasattr(styles, "nav_button") else styles.get("nav_button", "")
|
nav_btn = getattr(styles, "nav_button", "") if hasattr(styles, "nav_button") else styles.get("nav_button", "")
|
||||||
cal_slug = getattr(calendar, "slug", "")
|
cal_slug = getattr(calendar, "slug", "")
|
||||||
|
|
||||||
entries_data = []
|
if not confirmed_entries:
|
||||||
if confirmed_entries:
|
return sx_call("events-day-entries-nav-oob-empty")
|
||||||
for entry in confirmed_entries:
|
|
||||||
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
|
|
||||||
end = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
|
|
||||||
entries_data.append({
|
|
||||||
"href": url_for("defpage_entry_detail", calendar_slug=cal_slug,
|
|
||||||
year=day_date.year, month=day_date.month, day=day_date.day,
|
|
||||||
entry_id=entry.id),
|
|
||||||
"name": entry.name,
|
|
||||||
"time-str": start + end,
|
|
||||||
})
|
|
||||||
|
|
||||||
return sx_call("events-day-entries-nav-oob-from-data",
|
items = ""
|
||||||
nav_btn=nav_btn, entries=entries_data or None)
|
for entry in confirmed_entries:
|
||||||
|
href = url_for(
|
||||||
|
"defpage_entry_detail",
|
||||||
|
calendar_slug=cal_slug,
|
||||||
|
year=day_date.year, month=day_date.month, day=day_date.day,
|
||||||
|
entry_id=entry.id,
|
||||||
|
)
|
||||||
|
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
|
||||||
|
end = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
|
||||||
|
items += sx_call("events-day-nav-entry",
|
||||||
|
href=href, nav_btn=nav_btn,
|
||||||
|
name=entry.name, time_str=start + end)
|
||||||
|
|
||||||
|
return sx_call("events-day-entries-nav-oob", items=SxExpr(items))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -552,7 +633,7 @@ def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def render_post_nav_entries_oob(associated_entries, calendars, post) -> str:
|
def render_post_nav_entries_oob(associated_entries, calendars, post) -> str:
|
||||||
"""Render OOB nav for associated entries and calendars via data + sx defcomp."""
|
"""Render OOB nav for associated entries and calendars of a post."""
|
||||||
from quart import g
|
from quart import g
|
||||||
from shared.infrastructure.urls import events_url
|
from shared.infrastructure.urls import events_url
|
||||||
|
|
||||||
@@ -560,9 +641,14 @@ def render_post_nav_entries_oob(associated_entries, calendars, post) -> str:
|
|||||||
nav_btn = getattr(styles, "nav_button_less_pad", "") if hasattr(styles, "nav_button_less_pad") else styles.get("nav_button_less_pad", "")
|
nav_btn = getattr(styles, "nav_button_less_pad", "") if hasattr(styles, "nav_button_less_pad") else styles.get("nav_button_less_pad", "")
|
||||||
|
|
||||||
has_entries = associated_entries and getattr(associated_entries, "entries", None)
|
has_entries = associated_entries and getattr(associated_entries, "entries", None)
|
||||||
|
has_items = has_entries or calendars
|
||||||
|
|
||||||
|
if not has_items:
|
||||||
|
return sx_call("events-post-nav-oob-empty")
|
||||||
|
|
||||||
slug = post.get("slug", "") if isinstance(post, dict) else getattr(post, "slug", "")
|
slug = post.get("slug", "") if isinstance(post, dict) else getattr(post, "slug", "")
|
||||||
|
|
||||||
entries_data = []
|
items = ""
|
||||||
if has_entries:
|
if has_entries:
|
||||||
for entry in associated_entries.entries:
|
for entry in associated_entries.entries:
|
||||||
entry_path = (
|
entry_path = (
|
||||||
@@ -570,31 +656,27 @@ def render_post_nav_entries_oob(associated_entries, calendars, post) -> str:
|
|||||||
f"{entry.start_at.year}/{entry.start_at.month}/{entry.start_at.day}/"
|
f"{entry.start_at.year}/{entry.start_at.month}/{entry.start_at.day}/"
|
||||||
f"entries/{entry.id}/"
|
f"entries/{entry.id}/"
|
||||||
)
|
)
|
||||||
|
href = events_url(entry_path)
|
||||||
time_str = entry.start_at.strftime("%b %d, %Y at %H:%M")
|
time_str = entry.start_at.strftime("%b %d, %Y at %H:%M")
|
||||||
end_str = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
|
end_str = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
|
||||||
entries_data.append({
|
items += sx_call("events-post-nav-entry",
|
||||||
"href": events_url(entry_path),
|
href=href, nav_btn=nav_btn,
|
||||||
"name": entry.name,
|
name=entry.name, time_str=time_str + end_str)
|
||||||
"time-str": time_str + end_str,
|
|
||||||
})
|
|
||||||
|
|
||||||
calendars_data = []
|
|
||||||
if calendars:
|
if calendars:
|
||||||
for cal in calendars:
|
for cal in calendars:
|
||||||
cs = getattr(cal, "slug", "")
|
cs = getattr(cal, "slug", "")
|
||||||
calendars_data.append({
|
local_href = events_url(f"/{slug}/{cs}/")
|
||||||
"href": events_url(f"/{slug}/{cs}/"),
|
items += sx_call("events-post-nav-calendar",
|
||||||
"name": cal.name,
|
href=local_href, nav_btn=nav_btn, name=cal.name)
|
||||||
})
|
|
||||||
|
|
||||||
hs = ("on load or scroll "
|
hs = ("on load or scroll "
|
||||||
"if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth "
|
"if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth "
|
||||||
"remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow "
|
"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")
|
"else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end")
|
||||||
|
|
||||||
return sx_call("events-post-nav-wrapper-from-data",
|
return sx_call("events-post-nav-wrapper",
|
||||||
nav_btn=nav_btn, entries=entries_data or None,
|
items=SxExpr(items), hyperscript=hs)
|
||||||
calendars=calendars_data or None, hyperscript=hs)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -718,36 +800,42 @@ def _entry_admin_main_panel_html(ctx: dict) -> str:
|
|||||||
|
|
||||||
def render_post_search_results(search_posts, search_query, page, total_pages,
|
def render_post_search_results(search_posts, search_query, page, total_pages,
|
||||||
entry, calendar, day, month, year) -> str:
|
entry, calendar, day, month, year) -> str:
|
||||||
"""Render post search results via data extraction + sx defcomp."""
|
"""Render post search results (replaces _types/entry/_post_search_results.html)."""
|
||||||
from quart import url_for
|
from quart import url_for
|
||||||
from shared.browser.app.csrf import generate_csrf_token
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
csrf = generate_csrf_token()
|
csrf = generate_csrf_token()
|
||||||
|
|
||||||
cal_slug = getattr(calendar, "slug", "")
|
cal_slug = getattr(calendar, "slug", "")
|
||||||
eid = entry.id
|
eid = entry.id
|
||||||
post_url = url_for("calendar.day.calendar_entries.calendar_entry.add_post",
|
|
||||||
calendar_slug=cal_slug, day=day, month=month, year=year,
|
|
||||||
entry_id=eid)
|
|
||||||
|
|
||||||
items_data = []
|
parts = []
|
||||||
for sp in search_posts:
|
for sp in search_posts:
|
||||||
items_data.append({
|
post_url = url_for("calendar.day.calendar_entries.calendar_entry.add_post",
|
||||||
"post-url": post_url, "entry-id": str(eid),
|
calendar_slug=cal_slug, day=day, month=month, year=year,
|
||||||
"csrf": csrf, "post-id": str(sp.id),
|
entry_id=eid)
|
||||||
"img": getattr(sp, "feature_image", None),
|
feat = getattr(sp, "feature_image", None)
|
||||||
"title": getattr(sp, "title", ""),
|
title = getattr(sp, "title", "")
|
||||||
})
|
if feat:
|
||||||
|
img_html = sx_call("events-post-img", src=feat, alt=title)
|
||||||
|
else:
|
||||||
|
img_html = sx_call("events-post-img-placeholder")
|
||||||
|
|
||||||
has_more = page < int(total_pages)
|
parts.append(sx_call("events-post-search-item",
|
||||||
next_url = None
|
post_url=post_url, entry_id=str(eid), csrf=csrf,
|
||||||
if has_more:
|
post_id=str(sp.id), img=img_html, title=title))
|
||||||
|
|
||||||
|
result = "".join(parts)
|
||||||
|
|
||||||
|
if page < int(total_pages):
|
||||||
next_url = url_for("calendar.day.calendar_entries.calendar_entry.search_posts",
|
next_url = url_for("calendar.day.calendar_entries.calendar_entry.search_posts",
|
||||||
calendar_slug=cal_slug, day=day, month=month, year=year,
|
calendar_slug=cal_slug, day=day, month=month, year=year,
|
||||||
entry_id=eid, q=search_query, page=page + 1)
|
entry_id=eid, q=search_query, page=page + 1)
|
||||||
|
result += sx_call("events-post-search-sentinel",
|
||||||
|
page=str(page), next_url=next_url)
|
||||||
|
elif search_posts:
|
||||||
|
result += sx_call("events-post-search-end")
|
||||||
|
|
||||||
return sx_call("events-post-search-results-from-data",
|
return result
|
||||||
items=items_data or None, page=str(page),
|
|
||||||
next_url=next_url, has_more=has_more or None)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -758,7 +846,7 @@ def render_entry_edit_form(entry, calendar, day, month, year, day_slots) -> str:
|
|||||||
"""Render entry edit form (replaces _types/entry/_edit.html)."""
|
"""Render entry edit form (replaces _types/entry/_edit.html)."""
|
||||||
from quart import url_for, g
|
from quart import url_for, g
|
||||||
from shared.browser.app.csrf import generate_csrf_token
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
from .slots import _slot_options_data, _SLOT_PICKER_JS
|
from .slots import _slot_options_html, _SLOT_PICKER_JS
|
||||||
csrf = generate_csrf_token()
|
csrf = generate_csrf_token()
|
||||||
|
|
||||||
styles = getattr(g, "styles", None) or {}
|
styles = getattr(g, "styles", None) or {}
|
||||||
@@ -774,9 +862,12 @@ def render_entry_edit_form(entry, calendar, day, month, year, day_slots) -> str:
|
|||||||
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid)
|
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid)
|
||||||
|
|
||||||
# Slot picker
|
# Slot picker
|
||||||
slots_data = _slot_options_data(day_slots, selected_slot_id=getattr(entry, "slot_id", None)) if day_slots else []
|
if day_slots:
|
||||||
slot_picker_html = sx_call("events-slot-picker-from-data",
|
options_html = _slot_options_html(day_slots, selected_slot_id=getattr(entry, "slot_id", None))
|
||||||
id=f"entry-slot-{eid}", slots=slots_data or None)
|
slot_picker_html = sx_call("events-slot-picker",
|
||||||
|
id=f"entry-slot-{eid}", options=SxExpr(options_html))
|
||||||
|
else:
|
||||||
|
slot_picker_html = sx_call("events-no-slots")
|
||||||
|
|
||||||
# Values
|
# Values
|
||||||
start_val = entry.start_at.strftime("%H:%M") if entry.start_at else ""
|
start_val = entry.start_at.strftime("%H:%M") if entry.start_at else ""
|
||||||
@@ -806,7 +897,7 @@ def render_entry_add_form(calendar, day, month, year, day_slots) -> str:
|
|||||||
"""Render entry add form (replaces _types/day/_add.html)."""
|
"""Render entry add form (replaces _types/day/_add.html)."""
|
||||||
from quart import url_for, g
|
from quart import url_for, g
|
||||||
from shared.browser.app.csrf import generate_csrf_token
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
from .slots import _slot_options_data, _SLOT_PICKER_JS
|
from .slots import _slot_options_html, _SLOT_PICKER_JS
|
||||||
csrf = generate_csrf_token()
|
csrf = generate_csrf_token()
|
||||||
|
|
||||||
styles = getattr(g, "styles", None) or {}
|
styles = getattr(g, "styles", None) or {}
|
||||||
@@ -820,9 +911,12 @@ def render_entry_add_form(calendar, day, month, year, day_slots) -> str:
|
|||||||
calendar_slug=cal_slug, day=day, month=month, year=year)
|
calendar_slug=cal_slug, day=day, month=month, year=year)
|
||||||
|
|
||||||
# Slot picker
|
# Slot picker
|
||||||
slots_data = _slot_options_data(day_slots) if day_slots else []
|
if day_slots:
|
||||||
slot_picker_html = sx_call("events-slot-picker-from-data",
|
options_html = _slot_options_html(day_slots)
|
||||||
id="entry-slot-new", slots=slots_data or None)
|
slot_picker_html = sx_call("events-slot-picker",
|
||||||
|
id="entry-slot-new", options=SxExpr(options_html))
|
||||||
|
else:
|
||||||
|
slot_picker_html = sx_call("events-no-slots")
|
||||||
|
|
||||||
html = sx_call("events-entry-add-form",
|
html = sx_call("events-entry-add-form",
|
||||||
post_url=post_url, csrf=csrf,
|
post_url=post_url, csrf=csrf,
|
||||||
@@ -850,33 +944,34 @@ def render_entry_add_button(calendar, day, month, year) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def render_fragment_container_cards(batch, post_ids, slug_map) -> str:
|
def render_fragment_container_cards(batch, post_ids, slug_map) -> str:
|
||||||
"""Render container cards entries via data extraction + sx defcomp."""
|
"""Render container cards entries (replaces fragments/container_cards_entries.html)."""
|
||||||
from shared.infrastructure.urls import events_url
|
from shared.infrastructure.urls import events_url
|
||||||
|
|
||||||
widgets_data = []
|
parts = []
|
||||||
for post_id in post_ids:
|
for post_id in post_ids:
|
||||||
|
parts.append(f"<!-- card-widget:{post_id} -->")
|
||||||
widget_entries = batch.get(post_id, [])
|
widget_entries = batch.get(post_id, [])
|
||||||
entries_data = []
|
if widget_entries:
|
||||||
for entry in widget_entries:
|
cards_html = ""
|
||||||
_post_slug = slug_map.get(post_id, "")
|
for entry in widget_entries:
|
||||||
_entry_path = (
|
_post_slug = slug_map.get(post_id, "")
|
||||||
f"/{_post_slug}/{entry.calendar_slug}/"
|
_entry_path = (
|
||||||
f"{entry.start_at.year}/{entry.start_at.month}/"
|
f"/{_post_slug}/{entry.calendar_slug}/"
|
||||||
f"{entry.start_at.day}/entries/{entry.id}/"
|
f"{entry.start_at.year}/{entry.start_at.month}/"
|
||||||
)
|
f"{entry.start_at.day}/entries/{entry.id}/"
|
||||||
time_str = entry.start_at.strftime("%H:%M")
|
)
|
||||||
if entry.end_at:
|
time_str = entry.start_at.strftime("%H:%M")
|
||||||
time_str += f" \u2013 {entry.end_at.strftime('%H:%M')}"
|
if entry.end_at:
|
||||||
entries_data.append({
|
time_str += f" \u2013 {entry.end_at.strftime('%H:%M')}"
|
||||||
"href": events_url(_entry_path),
|
cards_html += sx_call("events-frag-entry-card",
|
||||||
"name": entry.name,
|
href=events_url(_entry_path),
|
||||||
"date-str": entry.start_at.strftime("%a, %b %d"),
|
name=entry.name,
|
||||||
"time-str": time_str,
|
date_str=entry.start_at.strftime("%a, %b %d"),
|
||||||
})
|
time_str=time_str)
|
||||||
widgets_data.append({"entries": entries_data or None})
|
parts.append(sx_call("events-frag-entries-widget", cards=SxExpr(cards_html)))
|
||||||
|
parts.append(f"<!-- /card-widget:{post_id} -->")
|
||||||
|
|
||||||
return sx_call("events-frag-container-cards-from-data",
|
return "\n".join(parts)
|
||||||
widgets=widgets_data or None)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -884,23 +979,32 @@ def render_fragment_container_cards(batch, post_ids, slug_map) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def render_fragment_account_tickets(tickets) -> str:
|
def render_fragment_account_tickets(tickets) -> str:
|
||||||
"""Render account page tickets via data extraction + sx defcomp."""
|
"""Render account page tickets (replaces fragments/account_page_tickets.html)."""
|
||||||
from shared.infrastructure.urls import events_url
|
from shared.infrastructure.urls import events_url
|
||||||
|
|
||||||
tickets_data = []
|
|
||||||
if tickets:
|
if tickets:
|
||||||
|
items_html = ""
|
||||||
for ticket in tickets:
|
for ticket in tickets:
|
||||||
tickets_data.append({
|
href = events_url(f"/tickets/{ticket.code}/")
|
||||||
"href": events_url(f"/tickets/{ticket.code}/"),
|
date_str = ticket.entry_start_at.strftime("%d %b %Y, %H:%M")
|
||||||
"entry-name": ticket.entry_name,
|
cal_name = ""
|
||||||
"date-str": ticket.entry_start_at.strftime("%d %b %Y, %H:%M"),
|
if getattr(ticket, "calendar_name", None):
|
||||||
"calendar-name": getattr(ticket, "calendar_name", None) or None,
|
cal_name = f'<span>· {escape(ticket.calendar_name)}</span>'
|
||||||
"type-name": getattr(ticket, "ticket_type_name", None) or None,
|
type_name = ""
|
||||||
"state": getattr(ticket, "state", ""),
|
if getattr(ticket, "ticket_type_name", None):
|
||||||
})
|
type_name = f'<span>· {escape(ticket.ticket_type_name)}</span>'
|
||||||
|
badge_html = sx_call("status-pill",
|
||||||
|
status=getattr(ticket, "state", ""))
|
||||||
|
items_html += sx_call("events-frag-ticket-item",
|
||||||
|
href=href, entry_name=ticket.entry_name,
|
||||||
|
date_str=date_str, calendar_name=cal_name,
|
||||||
|
type_name=type_name, badge=badge_html)
|
||||||
|
body = sx_call("events-frag-tickets-list", items=SxExpr(items_html))
|
||||||
|
else:
|
||||||
|
body = sx_call("empty-state", message="No tickets yet.",
|
||||||
|
cls="text-sm text-stone-500")
|
||||||
|
|
||||||
return sx_call("events-frag-tickets-panel-from-data",
|
return sx_call("events-frag-tickets-panel", items=body)
|
||||||
tickets=tickets_data or None)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -908,18 +1012,31 @@ def render_fragment_account_tickets(tickets) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def render_fragment_account_bookings(bookings) -> str:
|
def render_fragment_account_bookings(bookings) -> str:
|
||||||
"""Render account page bookings via data extraction + sx defcomp."""
|
"""Render account page bookings (replaces fragments/account_page_bookings.html)."""
|
||||||
bookings_data = []
|
|
||||||
if bookings:
|
if bookings:
|
||||||
|
items_html = ""
|
||||||
for booking in bookings:
|
for booking in bookings:
|
||||||
bookings_data.append({
|
date_str = booking.start_at.strftime("%d %b %Y, %H:%M")
|
||||||
"name": booking.name,
|
if getattr(booking, "end_at", None):
|
||||||
"date-str": booking.start_at.strftime("%d %b %Y, %H:%M"),
|
date_str_extra = f'<span>– {escape(booking.end_at.strftime("%H:%M"))}</span>'
|
||||||
"end-time": booking.end_at.strftime("%H:%M") if getattr(booking, "end_at", None) else None,
|
else:
|
||||||
"calendar-name": getattr(booking, "calendar_name", None) or None,
|
date_str_extra = ""
|
||||||
"cost-str": str(booking.cost) if getattr(booking, "cost", None) else None,
|
cal_name = ""
|
||||||
"state": getattr(booking, "state", ""),
|
if getattr(booking, "calendar_name", None):
|
||||||
})
|
cal_name = f'<span>· {escape(booking.calendar_name)}</span>'
|
||||||
|
cost_str = ""
|
||||||
|
if getattr(booking, "cost", None):
|
||||||
|
cost_str = f'<span>· £{escape(str(booking.cost))}</span>'
|
||||||
|
badge_html = sx_call("status-pill",
|
||||||
|
status=getattr(booking, "state", ""))
|
||||||
|
items_html += sx_call("events-frag-booking-item",
|
||||||
|
name=booking.name,
|
||||||
|
date_str=date_str + date_str_extra,
|
||||||
|
calendar_name=cal_name, cost_str=cost_str,
|
||||||
|
badge=badge_html)
|
||||||
|
body = sx_call("events-frag-bookings-list", items=SxExpr(items_html))
|
||||||
|
else:
|
||||||
|
body = sx_call("empty-state", message="No bookings yet.",
|
||||||
|
cls="text-sm text-stone-500")
|
||||||
|
|
||||||
return sx_call("events-frag-bookings-panel-from-data",
|
return sx_call("events-frag-bookings-panel", items=body)
|
||||||
bookings=bookings_data or None)
|
|
||||||
|
|||||||
@@ -1,89 +1,235 @@
|
|||||||
;; Events pages — auto-mounted with absolute paths
|
;; Events pages — auto-mounted with absolute paths
|
||||||
|
;; All helpers return data dicts — markup composition in SX.
|
||||||
|
|
||||||
;; Calendar admin
|
;; Calendar admin
|
||||||
(defpage calendar-admin
|
(defpage calendar-admin
|
||||||
:path "/<slug>/<calendar_slug>/admin/"
|
:path "/<slug>/<calendar_slug>/admin/"
|
||||||
:auth :admin
|
:auth :admin
|
||||||
:layout :events-calendar-admin
|
:layout :events-calendar-admin
|
||||||
:content (calendar-admin-content calendar-slug))
|
:data (calendar-admin-data calendar-slug)
|
||||||
|
:content (~events-calendar-admin-panel
|
||||||
|
:description-content (~events-calendar-description-display
|
||||||
|
:description cal-description :edit-url desc-edit-url)
|
||||||
|
:csrf csrf :description cal-description))
|
||||||
|
|
||||||
;; Day admin
|
;; Day admin
|
||||||
(defpage day-admin
|
(defpage day-admin
|
||||||
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/admin/"
|
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/admin/"
|
||||||
:auth :admin
|
:auth :admin
|
||||||
:layout :events-day-admin
|
:layout :events-day-admin
|
||||||
:content (day-admin-content calendar-slug year month day))
|
:data (day-admin-data calendar-slug year month day)
|
||||||
|
:content (~events-day-admin-panel))
|
||||||
|
|
||||||
;; Slots listing
|
;; Slots listing
|
||||||
(defpage slots-listing
|
(defpage slots-listing
|
||||||
:path "/<slug>/<calendar_slug>/slots/"
|
:path "/<slug>/<calendar_slug>/slots/"
|
||||||
:auth :public
|
:auth :public
|
||||||
:layout :events-slots
|
:layout :events-slots
|
||||||
:content (slots-content calendar-slug))
|
:data (slots-data calendar-slug)
|
||||||
|
:content (~events-slots-table
|
||||||
|
:list-container list-container
|
||||||
|
:rows (if has-slots
|
||||||
|
(<> (map (fn (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 "name") :description (get s "description")
|
||||||
|
:flexible (get s "flexible")
|
||||||
|
:days (if (get s "has-days")
|
||||||
|
(~events-slot-days-pills :days-inner
|
||||||
|
(<> (map (fn (d) (~events-slot-day-pill :day d)) (get s "day-list"))))
|
||||||
|
(~events-slot-no-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))
|
||||||
|
slots-list))
|
||||||
|
(~events-slots-empty-row))
|
||||||
|
:pre-action pre-action :add-url add-url))
|
||||||
|
|
||||||
;; Slot detail
|
;; Slot detail
|
||||||
(defpage slot-detail
|
(defpage slot-detail
|
||||||
:path "/<slug>/<calendar_slug>/slots/<int:slot_id>/"
|
:path "/<slug>/<calendar_slug>/slots/<int:slot_id>/"
|
||||||
:auth :admin
|
:auth :admin
|
||||||
:layout :events-slot
|
:layout :events-slot
|
||||||
:content (slot-content calendar-slug slot-id))
|
:data (slot-data calendar-slug slot-id)
|
||||||
|
:content (~events-slot-panel
|
||||||
|
:slot-id slot-id-str
|
||||||
|
:list-container list-container
|
||||||
|
:days (if has-days
|
||||||
|
(~events-slot-days-pills :days-inner
|
||||||
|
(<> (map (fn (d) (~events-slot-day-pill :day d)) day-list)))
|
||||||
|
(~events-slot-no-days))
|
||||||
|
:flexible flexible
|
||||||
|
:time-str time-str :cost-str cost-str
|
||||||
|
:pre-action pre-action :edit-url edit-url))
|
||||||
|
|
||||||
;; Entry detail
|
;; Entry detail
|
||||||
(defpage entry-detail
|
(defpage entry-detail
|
||||||
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/"
|
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/"
|
||||||
:auth :admin
|
:auth :admin
|
||||||
:layout :events-entry
|
:layout :events-entry
|
||||||
:content (entry-content calendar-slug entry-id)
|
:data (entry-data calendar-slug entry-id)
|
||||||
:menu (entry-menu calendar-slug entry-id))
|
:content (~events-entry-panel
|
||||||
|
:entry-id entry-id-str :list-container list-container
|
||||||
|
:name (~events-entry-field :label "Name"
|
||||||
|
:content (~events-entry-name-field :name entry-name))
|
||||||
|
:slot (~events-entry-field :label "Slot"
|
||||||
|
:content (if has-slot
|
||||||
|
(~events-entry-slot-assigned :slot-name slot-name :flex-label flex-label)
|
||||||
|
(~events-entry-slot-none)))
|
||||||
|
:time (~events-entry-field :label "Time Period"
|
||||||
|
:content (~events-entry-time-field :time-str time-str))
|
||||||
|
:state (~events-entry-field :label "State"
|
||||||
|
:content (~events-entry-state-field :entry-id entry-id-str
|
||||||
|
:badge (~badge :cls state-badge-cls :label state-badge-label)))
|
||||||
|
:cost (~events-entry-field :label "Cost"
|
||||||
|
:content (~events-entry-cost-field :cost cost-str))
|
||||||
|
:tickets (~events-entry-field :label "Tickets"
|
||||||
|
:content (~events-entry-tickets-field :entry-id entry-id-str
|
||||||
|
:tickets-config tickets-config))
|
||||||
|
:buy buy-form
|
||||||
|
:date (~events-entry-field :label "Date"
|
||||||
|
:content (~events-entry-date-field :date-str date-str))
|
||||||
|
:posts (~events-entry-field :label "Associated Posts"
|
||||||
|
:content (~events-entry-posts-field :entry-id entry-id-str
|
||||||
|
:posts-panel posts-panel))
|
||||||
|
:options options-html
|
||||||
|
:pre-action pre-action :edit-url edit-url)
|
||||||
|
:menu entry-menu)
|
||||||
|
|
||||||
;; Entry admin
|
;; Entry admin
|
||||||
(defpage entry-admin
|
(defpage entry-admin
|
||||||
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/admin/"
|
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/admin/"
|
||||||
:auth :admin
|
:auth :admin
|
||||||
:layout :events-entry-admin
|
:layout :events-entry-admin
|
||||||
:content (entry-admin-content calendar-slug entry-id)
|
:data (entry-admin-data calendar-slug entry-id year month day)
|
||||||
:menu (admin-menu))
|
:content (~nav-link :href ticket-types-href :label "ticket_types"
|
||||||
|
:select-colours select-colours :aclass nav-btn :is-selected false)
|
||||||
|
:menu (~events-admin-placeholder-nav))
|
||||||
|
|
||||||
;; Ticket types listing
|
;; Ticket types listing
|
||||||
(defpage ticket-types-listing
|
(defpage ticket-types-listing
|
||||||
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/ticket-types/"
|
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/ticket-types/"
|
||||||
:auth :public
|
:auth :public
|
||||||
:layout :events-ticket-types
|
:layout :events-ticket-types
|
||||||
:content (ticket-types-content calendar-slug entry-id year month day)
|
:data (ticket-types-data calendar-slug entry-id year month day)
|
||||||
:menu (admin-menu))
|
:content (~events-ticket-types-table
|
||||||
|
:list-container list-container
|
||||||
|
:rows (if has-types
|
||||||
|
(<> (map (fn (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))
|
||||||
|
types-list))
|
||||||
|
(~events-ticket-types-empty-row))
|
||||||
|
:action-btn action-btn :add-url add-url)
|
||||||
|
:menu (~events-admin-placeholder-nav))
|
||||||
|
|
||||||
;; Ticket type detail
|
;; Ticket type detail
|
||||||
(defpage ticket-type-detail
|
(defpage ticket-type-detail
|
||||||
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/ticket-types/<int:ticket_type_id>/"
|
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/ticket-types/<int:ticket_type_id>/"
|
||||||
:auth :admin
|
:auth :admin
|
||||||
:layout :events-ticket-type
|
:layout :events-ticket-type
|
||||||
:content (ticket-type-content calendar-slug entry-id ticket-type-id year month day)
|
:data (ticket-type-data calendar-slug entry-id ticket-type-id year month day)
|
||||||
:menu (admin-menu))
|
:content (~events-ticket-type-panel
|
||||||
|
:ticket-id ticket-id :list-container list-container
|
||||||
|
:c1 (~events-ticket-type-col :label "Name" :value tt-name)
|
||||||
|
:c2 (~events-ticket-type-col :label "Cost" :value cost-str)
|
||||||
|
:c3 (~events-ticket-type-col :label "Count" :value count-str)
|
||||||
|
:pre-action pre-action :edit-url edit-url)
|
||||||
|
:menu (~events-admin-placeholder-nav))
|
||||||
|
|
||||||
;; My tickets
|
;; My tickets
|
||||||
(defpage my-tickets
|
(defpage my-tickets
|
||||||
:path "/tickets/"
|
:path "/tickets/"
|
||||||
:auth :public
|
:auth :public
|
||||||
:layout :root
|
:layout :root
|
||||||
:content (tickets-content))
|
:data (tickets-data)
|
||||||
|
:content (~events-tickets-panel
|
||||||
|
:list-container list-container
|
||||||
|
:has-tickets has-tickets
|
||||||
|
:cards (when has-tickets
|
||||||
|
(<> (map (fn (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 (~badge :cls (get t "badge-cls") :label (get t "badge-label"))
|
||||||
|
:code-prefix (get t "code-prefix")))
|
||||||
|
tickets-list)))))
|
||||||
|
|
||||||
;; Ticket detail
|
;; Ticket detail
|
||||||
(defpage ticket-detail
|
(defpage ticket-detail
|
||||||
:path "/tickets/<code>/"
|
:path "/tickets/<code>/"
|
||||||
:auth :public
|
:auth :public
|
||||||
:layout :root
|
:layout :root
|
||||||
:content (ticket-detail-content code))
|
:data (ticket-detail-data code)
|
||||||
|
:content (~events-ticket-detail
|
||||||
|
:list-container list-container :back-href back-href
|
||||||
|
:header-bg header-bg :entry-name entry-name
|
||||||
|
:badge (span :class (str "inline-flex items-center rounded-full px-3 py-1 text-sm font-medium " badge-cls)
|
||||||
|
badge-label)
|
||||||
|
:type-name type-name :code ticket-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 dashboard
|
;; Ticket admin dashboard
|
||||||
(defpage ticket-admin
|
(defpage ticket-admin
|
||||||
:path "/admin/tickets/"
|
:path "/admin/tickets/"
|
||||||
:auth :admin
|
:auth :admin
|
||||||
:layout :root
|
:layout :root
|
||||||
:content (ticket-admin-content))
|
:data (ticket-admin-data)
|
||||||
|
:content (~events-ticket-admin-panel
|
||||||
|
:list-container list-container
|
||||||
|
:stats (<> (map (fn (s)
|
||||||
|
(~events-ticket-admin-stat
|
||||||
|
:border (get s "border") :bg (get s "bg")
|
||||||
|
:text-cls (get s "text-cls") :label-cls (get s "label-cls")
|
||||||
|
:value (get s "value") :label (get s "label")))
|
||||||
|
admin-stats))
|
||||||
|
:lookup-url lookup-url :has-tickets has-tickets
|
||||||
|
:rows (when has-tickets
|
||||||
|
(<> (map (fn (t)
|
||||||
|
(~events-ticket-admin-row
|
||||||
|
:code (get t "code") :code-short (get t "code-short")
|
||||||
|
:entry-name (get t "entry-name")
|
||||||
|
:date (when (get t "date-str")
|
||||||
|
(~events-ticket-admin-date :date-str (get t "date-str")))
|
||||||
|
:type-name (get t "type-name")
|
||||||
|
:badge (~badge :cls (get t "badge-cls") :label (get t "badge-label"))
|
||||||
|
:action (if (get t "can-checkin")
|
||||||
|
(~events-ticket-admin-checkin-form
|
||||||
|
:checkin-url (get t "checkin-url") :code (get t "code") :csrf csrf)
|
||||||
|
(when (get t "is-checked-in")
|
||||||
|
(~events-ticket-admin-checked-in :time-str (get t "checkin-time"))))))
|
||||||
|
admin-tickets)))))
|
||||||
|
|
||||||
;; Markets
|
;; Markets
|
||||||
(defpage events-markets
|
(defpage events-markets
|
||||||
:path "/<slug>/markets/"
|
:path "/<slug>/markets/"
|
||||||
:auth :public
|
:auth :public
|
||||||
:layout :events-markets
|
:layout :events-markets
|
||||||
:content (markets-content))
|
:data (markets-data)
|
||||||
|
:content (~crud-panel
|
||||||
|
:list-id "markets-list"
|
||||||
|
:form (when can-create
|
||||||
|
(~crud-create-form :create-url create-url :csrf csrf
|
||||||
|
:errors-id "market-create-errors" :list-id "markets-list"
|
||||||
|
:placeholder "e.g. Farm Shop, Bakery" :btn-label "Add market"))
|
||||||
|
:list (if markets-list
|
||||||
|
(<> (map (fn (m)
|
||||||
|
(~crud-item :href (get m "href") :name (get m "name")
|
||||||
|
:slug (get m "slug") :del-url (get m "del-url")
|
||||||
|
:csrf-hdr (get m "csrf-hdr")
|
||||||
|
:list-id "markets-list"
|
||||||
|
:confirm-title "Delete market?"
|
||||||
|
:confirm-text "Products will be hidden (soft delete)"))
|
||||||
|
markets-list))
|
||||||
|
(~empty-state :message "No markets yet. Create one above."
|
||||||
|
:cls "text-gray-500 mt-4"))))
|
||||||
|
|||||||
@@ -1,26 +1,13 @@
|
|||||||
"""Layout registrations, page helpers, and shared hydration helpers."""
|
"""Layout registrations, page helpers, and shared hydration helpers.
|
||||||
|
|
||||||
|
All helpers return data dicts — no sx_call().
|
||||||
|
Markup composition lives entirely in .sx defpage and .sx defcomp files.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from shared.sx.helpers import sx_call
|
from shared.sx.parser import SxExpr
|
||||||
|
|
||||||
from .calendar import (
|
|
||||||
_calendar_admin_main_panel_html,
|
|
||||||
_day_admin_main_panel_html,
|
|
||||||
_markets_main_panel_html,
|
|
||||||
)
|
|
||||||
from .entries import (
|
|
||||||
_entry_main_panel_html,
|
|
||||||
_entry_nav_html,
|
|
||||||
_entry_admin_main_panel_html,
|
|
||||||
)
|
|
||||||
from .tickets import (
|
|
||||||
_tickets_main_panel_html, _ticket_detail_panel_html,
|
|
||||||
_ticket_admin_main_panel_html,
|
|
||||||
render_ticket_type_main_panel, render_ticket_types_table,
|
|
||||||
)
|
|
||||||
from .slots import render_slot_main_panel, render_slots_table
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -261,6 +248,60 @@ def _register_events_layouts() -> None:
|
|||||||
"events-markets-layout-full", "events-markets-layout-oob")
|
"events-markets-layout-full", "events-markets-layout-oob")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Badge data helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_ENTRY_STATE_CLASSES = {
|
||||||
|
"confirmed": "bg-emerald-100 text-emerald-800",
|
||||||
|
"provisional": "bg-amber-100 text-amber-800",
|
||||||
|
"ordered": "bg-sky-100 text-sky-800",
|
||||||
|
"pending": "bg-stone-100 text-stone-700",
|
||||||
|
"declined": "bg-red-100 text-red-800",
|
||||||
|
}
|
||||||
|
|
||||||
|
_TICKET_STATE_CLASSES = {
|
||||||
|
"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",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_badge_data(state: str) -> dict:
|
||||||
|
cls = _ENTRY_STATE_CLASSES.get(state, "bg-stone-100 text-stone-700")
|
||||||
|
label = state.replace("_", " ").capitalize()
|
||||||
|
return {"cls": cls, "label": label}
|
||||||
|
|
||||||
|
|
||||||
|
def _ticket_badge_data(state: str) -> dict:
|
||||||
|
cls = _TICKET_STATE_CLASSES.get(state, "bg-stone-100 text-stone-700")
|
||||||
|
label = (state or "").replace("_", " ").capitalize()
|
||||||
|
return {"cls": cls, "label": label}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Styles helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _styles_data() -> dict:
|
||||||
|
"""Extract common style classes from g.styles."""
|
||||||
|
from quart import g
|
||||||
|
styles = getattr(g, "styles", None) or {}
|
||||||
|
|
||||||
|
def _gs(attr):
|
||||||
|
return getattr(styles, attr, "") if hasattr(styles, attr) else styles.get(attr, "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"list-container": _gs("list_container"),
|
||||||
|
"pre-action": _gs("pre_action_button"),
|
||||||
|
"action-btn": _gs("action_button"),
|
||||||
|
"tr-cls": _gs("tr"),
|
||||||
|
"pill-cls": _gs("pill"),
|
||||||
|
"nav-btn": _gs("nav_button"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Page helpers
|
# Page helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -269,141 +310,468 @@ def _register_events_helpers() -> None:
|
|||||||
from shared.sx.pages import register_page_helpers
|
from shared.sx.pages import register_page_helpers
|
||||||
|
|
||||||
register_page_helpers("events", {
|
register_page_helpers("events", {
|
||||||
"calendar-admin-content": _h_calendar_admin_content,
|
"calendar-admin-data": _h_calendar_admin_data,
|
||||||
"day-admin-content": _h_day_admin_content,
|
"day-admin-data": _h_day_admin_data,
|
||||||
"slots-content": _h_slots_content,
|
"slots-data": _h_slots_data,
|
||||||
"slot-content": _h_slot_content,
|
"slot-data": _h_slot_data,
|
||||||
"entry-content": _h_entry_content,
|
"entry-data": _h_entry_data,
|
||||||
"entry-menu": _h_entry_menu,
|
"entry-admin-data": _h_entry_admin_data,
|
||||||
"entry-admin-content": _h_entry_admin_content,
|
"ticket-types-data": _h_ticket_types_data,
|
||||||
"admin-menu": _h_admin_menu,
|
"ticket-type-data": _h_ticket_type_data,
|
||||||
"ticket-types-content": _h_ticket_types_content,
|
"tickets-data": _h_tickets_data,
|
||||||
"ticket-type-content": _h_ticket_type_content,
|
"ticket-detail-data": _h_ticket_detail_data,
|
||||||
"tickets-content": _h_tickets_content,
|
"ticket-admin-data": _h_ticket_admin_data,
|
||||||
"ticket-detail-content": _h_ticket_detail_content,
|
"markets-data": _h_markets_data,
|
||||||
"ticket-admin-content": _h_ticket_admin_content,
|
|
||||||
"markets-content": _h_markets_content,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
async def _h_calendar_admin_content(calendar_slug=None, **kw):
|
# ---------------------------------------------------------------------------
|
||||||
|
# Calendar admin
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _h_calendar_admin_data(calendar_slug=None, **kw) -> dict:
|
||||||
|
from quart import url_for
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
|
||||||
await _ensure_calendar(calendar_slug)
|
await _ensure_calendar(calendar_slug)
|
||||||
await _ensure_container_nav_defpage_ctx()
|
await _ensure_container_nav_defpage_ctx()
|
||||||
from shared.sx.page import get_template_context
|
|
||||||
ctx = await get_template_context()
|
from quart import g
|
||||||
return _calendar_admin_main_panel_html(ctx)
|
calendar = getattr(g, "calendar", None)
|
||||||
|
if not calendar:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
cal_slug = getattr(calendar, "slug", "")
|
||||||
|
desc = getattr(calendar, "description", "") or ""
|
||||||
|
desc_edit_url = url_for("calendar.admin.calendar_description_edit",
|
||||||
|
calendar_slug=cal_slug)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cal-description": desc,
|
||||||
|
"csrf": csrf,
|
||||||
|
"desc-edit-url": desc_edit_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _h_day_admin_content(calendar_slug=None, year=None, month=None, day=None, **kw):
|
# ---------------------------------------------------------------------------
|
||||||
|
# Day admin
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _h_day_admin_data(calendar_slug=None, year=None, month=None,
|
||||||
|
day=None, **kw) -> dict:
|
||||||
await _ensure_calendar(calendar_slug)
|
await _ensure_calendar(calendar_slug)
|
||||||
await _ensure_container_nav_defpage_ctx()
|
await _ensure_container_nav_defpage_ctx()
|
||||||
if year is not None:
|
if year is not None:
|
||||||
await _ensure_day_data(int(year), int(month), int(day))
|
await _ensure_day_data(int(year), int(month), int(day))
|
||||||
return _day_admin_main_panel_html({})
|
return {}
|
||||||
|
|
||||||
|
|
||||||
async def _h_slots_content(calendar_slug=None, **kw):
|
# ---------------------------------------------------------------------------
|
||||||
from quart import g
|
# Slots listing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _h_slots_data(calendar_slug=None, **kw) -> dict:
|
||||||
|
from quart import g, url_for
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
from bp.slots.services.slots import list_slots as svc_list_slots
|
||||||
|
|
||||||
await _ensure_calendar(calendar_slug)
|
await _ensure_calendar(calendar_slug)
|
||||||
await _ensure_container_nav_defpage_ctx()
|
await _ensure_container_nav_defpage_ctx()
|
||||||
|
|
||||||
calendar = getattr(g, "calendar", None)
|
calendar = getattr(g, "calendar", None)
|
||||||
from bp.slots.services.slots import list_slots as svc_list_slots
|
|
||||||
slots = await svc_list_slots(g.s, calendar.id) if calendar else []
|
slots = await svc_list_slots(g.s, calendar.id) if calendar else []
|
||||||
_add_to_defpage_ctx(slots=slots)
|
_add_to_defpage_ctx(slots=slots)
|
||||||
return render_slots_table(slots, calendar)
|
|
||||||
|
styles = _styles_data()
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
cal_slug = getattr(calendar, "slug", "")
|
||||||
|
hx_select = getattr(g, "hx_select_search", "#main-panel")
|
||||||
|
csrf_hdr = {"X-CSRFToken": csrf}
|
||||||
|
add_url = url_for("calendar.slots.add_form", calendar_slug=cal_slug)
|
||||||
|
|
||||||
|
slots_list = []
|
||||||
|
for s in slots:
|
||||||
|
slot_href = url_for("defpage_slot_detail",
|
||||||
|
calendar_slug=cal_slug, slot_id=s.id)
|
||||||
|
del_url = url_for("calendar.slots.slot.slot_delete",
|
||||||
|
calendar_slug=cal_slug, slot_id=s.id)
|
||||||
|
desc = getattr(s, "description", "") or ""
|
||||||
|
days_display = getattr(s, "days_display", "\u2014")
|
||||||
|
day_list = days_display.split(", ")
|
||||||
|
has_days = bool(day_list and day_list[0] != "\u2014")
|
||||||
|
time_start = s.time_start.strftime("%H:%M") if s.time_start else ""
|
||||||
|
time_end = s.time_end.strftime("%H:%M") if s.time_end else ""
|
||||||
|
cost = getattr(s, "cost", None)
|
||||||
|
cost_str = f"{cost:.2f}" if cost is not None else ""
|
||||||
|
|
||||||
|
slots_list.append({
|
||||||
|
"name": s.name,
|
||||||
|
"description": desc,
|
||||||
|
"day-list": day_list if has_days else [],
|
||||||
|
"has-days": has_days,
|
||||||
|
"flexible": "yes" if s.flexible else "no",
|
||||||
|
"time-str": f"{time_start} - {time_end}",
|
||||||
|
"cost-str": cost_str,
|
||||||
|
"slot-href": slot_href,
|
||||||
|
"del-url": del_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"has-slots": bool(slots),
|
||||||
|
"slots-list": slots_list,
|
||||||
|
"add-url": add_url,
|
||||||
|
"csrf-hdr": csrf_hdr,
|
||||||
|
"hx-select": hx_select,
|
||||||
|
**styles,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _h_slot_content(calendar_slug=None, slot_id=None, **kw):
|
# ---------------------------------------------------------------------------
|
||||||
from quart import g, abort
|
# Slot detail
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _h_slot_data(calendar_slug=None, slot_id=None, **kw) -> dict:
|
||||||
|
from quart import g, abort, url_for
|
||||||
|
|
||||||
await _ensure_calendar(calendar_slug)
|
await _ensure_calendar(calendar_slug)
|
||||||
await _ensure_container_nav_defpage_ctx()
|
await _ensure_container_nav_defpage_ctx()
|
||||||
|
|
||||||
from bp.slot.services.slot import get_slot as svc_get_slot
|
from bp.slot.services.slot import get_slot as svc_get_slot
|
||||||
slot = await svc_get_slot(g.s, slot_id) if slot_id else None
|
slot = await svc_get_slot(g.s, slot_id) if slot_id else None
|
||||||
if not slot:
|
if not slot:
|
||||||
abort(404)
|
abort(404)
|
||||||
g.slot = slot
|
g.slot = slot
|
||||||
_add_to_defpage_ctx(slot=slot)
|
_add_to_defpage_ctx(slot=slot)
|
||||||
|
|
||||||
calendar = getattr(g, "calendar", None)
|
calendar = getattr(g, "calendar", None)
|
||||||
return render_slot_main_panel(slot, calendar)
|
styles = _styles_data()
|
||||||
|
cal_slug = getattr(calendar, "slug", "")
|
||||||
|
|
||||||
|
days_display = getattr(slot, "days_display", "\u2014")
|
||||||
|
day_list = days_display.split(", ")
|
||||||
|
has_days = bool(day_list and day_list[0] != "\u2014")
|
||||||
|
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
|
||||||
|
time_end = slot.time_end.strftime("%H:%M") if slot.time_end else ""
|
||||||
|
cost = getattr(slot, "cost", None)
|
||||||
|
cost_str = f"{cost:.2f}" if cost is not None else ""
|
||||||
|
edit_url = url_for("calendar.slots.slot.get_edit",
|
||||||
|
slot_id=slot.id, calendar_slug=cal_slug)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"slot-id-str": str(slot.id),
|
||||||
|
"day-list": day_list if has_days else [],
|
||||||
|
"has-days": has_days,
|
||||||
|
"flexible": "yes" if getattr(slot, "flexible", False) else "no",
|
||||||
|
"time-str": f"{time_start} \u2014 {time_end}",
|
||||||
|
"cost-str": cost_str,
|
||||||
|
"edit-url": edit_url,
|
||||||
|
**styles,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _h_entry_content(calendar_slug=None, entry_id=None, **kw):
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entry detail (complex — sub-panels returned as SxExpr)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _h_entry_data(calendar_slug=None, entry_id=None, **kw) -> dict:
|
||||||
|
from quart import url_for, g
|
||||||
|
from .entries import (
|
||||||
|
_entry_nav_html,
|
||||||
|
_entry_options_html,
|
||||||
|
render_entry_tickets_config,
|
||||||
|
render_entry_posts_panel,
|
||||||
|
)
|
||||||
|
from .tickets import render_buy_form
|
||||||
|
|
||||||
await _ensure_calendar(calendar_slug)
|
await _ensure_calendar(calendar_slug)
|
||||||
await _ensure_entry_context(entry_id)
|
await _ensure_entry_context(entry_id)
|
||||||
|
|
||||||
from shared.sx.page import get_template_context
|
from shared.sx.page import get_template_context
|
||||||
ctx = await get_template_context()
|
ctx = await get_template_context()
|
||||||
return _entry_main_panel_html(ctx)
|
|
||||||
|
entry = ctx.get("entry")
|
||||||
|
if not entry:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
calendar = ctx.get("calendar")
|
||||||
|
cal_slug = getattr(calendar, "slug", "") if calendar else ""
|
||||||
|
day = ctx.get("day")
|
||||||
|
month = ctx.get("month")
|
||||||
|
year = ctx.get("year")
|
||||||
|
|
||||||
|
styles = _styles_data()
|
||||||
|
eid = entry.id
|
||||||
|
state = getattr(entry, "state", "pending") or "pending"
|
||||||
|
|
||||||
|
# Simple field data
|
||||||
|
slot = getattr(entry, "slot", None)
|
||||||
|
has_slot = slot is not None
|
||||||
|
slot_name = slot.name if slot else ""
|
||||||
|
flex_label = "(flexible)" if slot and getattr(slot, "flexible", False) else "(fixed)"
|
||||||
|
start_str = entry.start_at.strftime("%H:%M") if entry.start_at else ""
|
||||||
|
end_str = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else " \u2013 open-ended"
|
||||||
|
cost = getattr(entry, "cost", None)
|
||||||
|
cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
|
||||||
|
date_str = entry.start_at.strftime("%A, %B %d, %Y") if entry.start_at else ""
|
||||||
|
badge = _entry_badge_data(state)
|
||||||
|
|
||||||
|
edit_url = url_for(
|
||||||
|
"calendar.day.calendar_entries.calendar_entry.get_edit",
|
||||||
|
entry_id=eid, calendar_slug=cal_slug,
|
||||||
|
day=day, month=month, year=year,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Complex sub-panels (pre-composed as SxExpr)
|
||||||
|
ticket_remaining = ctx.get("ticket_remaining")
|
||||||
|
ticket_sold_count = ctx.get("ticket_sold_count", 0)
|
||||||
|
user_ticket_count = ctx.get("user_ticket_count", 0)
|
||||||
|
user_ticket_counts_by_type = ctx.get("user_ticket_counts_by_type") or {}
|
||||||
|
entry_posts = ctx.get("entry_posts") or []
|
||||||
|
|
||||||
|
tickets_config = render_entry_tickets_config(entry, calendar, day, month, year)
|
||||||
|
buy_form = render_buy_form(
|
||||||
|
entry, ticket_remaining, ticket_sold_count,
|
||||||
|
user_ticket_count, user_ticket_counts_by_type,
|
||||||
|
)
|
||||||
|
posts_panel = render_entry_posts_panel(
|
||||||
|
entry_posts, entry, calendar, day, month, year,
|
||||||
|
)
|
||||||
|
options_html = _entry_options_html(entry, calendar, day, month, year)
|
||||||
|
|
||||||
|
# Entry menu (pre-composed for :menu slot)
|
||||||
|
entry_menu = _entry_nav_html(ctx)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"entry-id-str": str(eid),
|
||||||
|
"entry-name": entry.name,
|
||||||
|
"has-slot": has_slot,
|
||||||
|
"slot-name": slot_name,
|
||||||
|
"flex-label": flex_label,
|
||||||
|
"time-str": start_str + end_str,
|
||||||
|
"state-badge-cls": badge["cls"],
|
||||||
|
"state-badge-label": badge["label"],
|
||||||
|
"cost-str": cost_str,
|
||||||
|
"date-str": date_str,
|
||||||
|
"edit-url": edit_url,
|
||||||
|
"tickets-config": SxExpr(tickets_config),
|
||||||
|
"buy-form": SxExpr(buy_form) if buy_form else None,
|
||||||
|
"posts-panel": SxExpr(posts_panel),
|
||||||
|
"options-html": SxExpr(options_html),
|
||||||
|
"entry-menu": SxExpr(entry_menu) if entry_menu else None,
|
||||||
|
**styles,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _h_entry_menu(calendar_slug=None, entry_id=None, **kw):
|
# ---------------------------------------------------------------------------
|
||||||
await _ensure_calendar(calendar_slug)
|
# Entry admin
|
||||||
await _ensure_entry_context(entry_id)
|
# ---------------------------------------------------------------------------
|
||||||
from shared.sx.page import get_template_context
|
|
||||||
ctx = await get_template_context()
|
|
||||||
return _entry_nav_html(ctx)
|
|
||||||
|
|
||||||
|
async def _h_entry_admin_data(calendar_slug=None, entry_id=None,
|
||||||
|
year=None, month=None, day=None, **kw) -> dict:
|
||||||
|
from quart import url_for, g
|
||||||
|
|
||||||
async def _h_entry_admin_content(calendar_slug=None, entry_id=None, **kw):
|
|
||||||
await _ensure_calendar(calendar_slug)
|
await _ensure_calendar(calendar_slug)
|
||||||
await _ensure_container_nav_defpage_ctx()
|
await _ensure_container_nav_defpage_ctx()
|
||||||
await _ensure_entry_context(entry_id)
|
await _ensure_entry_context(entry_id)
|
||||||
|
|
||||||
|
calendar = getattr(g, "calendar", None)
|
||||||
|
entry = getattr(g, "entry", None)
|
||||||
|
if not calendar or not entry:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
cal_slug = getattr(calendar, "slug", "")
|
||||||
|
styles = _styles_data()
|
||||||
|
|
||||||
from shared.sx.page import get_template_context
|
from shared.sx.page import get_template_context
|
||||||
ctx = await get_template_context()
|
ctx = await get_template_context()
|
||||||
return _entry_admin_main_panel_html(ctx)
|
select_colours = ctx.get("select_colours", "")
|
||||||
|
|
||||||
|
ticket_types_href = url_for(
|
||||||
|
"calendar.day.calendar_entries.calendar_entry.ticket_types.get",
|
||||||
|
calendar_slug=cal_slug, entry_id=entry.id,
|
||||||
|
year=year, month=month, day=day,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ticket-types-href": ticket_types_href,
|
||||||
|
"select-colours": select_colours,
|
||||||
|
**styles,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _h_admin_menu():
|
# ---------------------------------------------------------------------------
|
||||||
return sx_call("events-admin-placeholder-nav")
|
# Ticket types listing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _h_ticket_types_data(calendar_slug=None, entry_id=None,
|
||||||
|
year=None, month=None, day=None, **kw) -> dict:
|
||||||
|
from quart import g, url_for
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
|
||||||
async def _h_ticket_types_content(calendar_slug=None, entry_id=None,
|
|
||||||
year=None, month=None, day=None, **kw):
|
|
||||||
from quart import g
|
|
||||||
await _ensure_calendar(calendar_slug)
|
await _ensure_calendar(calendar_slug)
|
||||||
await _ensure_entry(entry_id)
|
await _ensure_entry(entry_id)
|
||||||
|
|
||||||
entry = getattr(g, "entry", None)
|
entry = getattr(g, "entry", None)
|
||||||
calendar = getattr(g, "calendar", None)
|
calendar = getattr(g, "calendar", None)
|
||||||
|
|
||||||
from bp.ticket_types.services.tickets import list_ticket_types as svc_list_ticket_types
|
from bp.ticket_types.services.tickets import list_ticket_types as svc_list_ticket_types
|
||||||
ticket_types = await svc_list_ticket_types(g.s, entry.id) if entry else []
|
ticket_types = await svc_list_ticket_types(g.s, entry.id) if entry else []
|
||||||
_add_to_defpage_ctx(ticket_types=ticket_types)
|
_add_to_defpage_ctx(ticket_types=ticket_types)
|
||||||
return render_ticket_types_table(ticket_types, entry, calendar, day, month, year)
|
|
||||||
|
styles = _styles_data()
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
cal_slug = getattr(calendar, "slug", "")
|
||||||
|
hx_select = getattr(g, "hx_select_search", "#main-panel")
|
||||||
|
eid = entry.id if entry else 0
|
||||||
|
csrf_hdr = {"X-CSRFToken": csrf}
|
||||||
|
|
||||||
|
types_list = []
|
||||||
|
for tt in (ticket_types or []):
|
||||||
|
tt_href = url_for(
|
||||||
|
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get",
|
||||||
|
calendar_slug=cal_slug, year=year, month=month, day=day,
|
||||||
|
entry_id=eid, ticket_type_id=tt.id,
|
||||||
|
)
|
||||||
|
del_url = url_for(
|
||||||
|
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete",
|
||||||
|
calendar_slug=cal_slug, year=year, month=month, day=day,
|
||||||
|
entry_id=eid, ticket_type_id=tt.id,
|
||||||
|
)
|
||||||
|
cost = getattr(tt, "cost", None)
|
||||||
|
cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
|
||||||
|
|
||||||
|
types_list.append({
|
||||||
|
"tt-href": tt_href,
|
||||||
|
"tt-name": tt.name,
|
||||||
|
"cost-str": cost_str,
|
||||||
|
"count": str(tt.count),
|
||||||
|
"del-url": del_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
add_url = url_for(
|
||||||
|
"calendar.day.calendar_entries.calendar_entry.ticket_types.add_form",
|
||||||
|
calendar_slug=cal_slug, entry_id=eid, year=year, month=month, day=day,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"has-types": bool(ticket_types),
|
||||||
|
"types-list": types_list,
|
||||||
|
"add-url": add_url,
|
||||||
|
"csrf-hdr": csrf_hdr,
|
||||||
|
"hx-select": hx_select,
|
||||||
|
**styles,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _h_ticket_type_content(calendar_slug=None, entry_id=None,
|
# ---------------------------------------------------------------------------
|
||||||
ticket_type_id=None, year=None, month=None, day=None, **kw):
|
# Ticket type detail
|
||||||
from quart import g, abort
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _h_ticket_type_data(calendar_slug=None, entry_id=None,
|
||||||
|
ticket_type_id=None,
|
||||||
|
year=None, month=None, day=None, **kw) -> dict:
|
||||||
|
from quart import g, abort, url_for
|
||||||
|
|
||||||
await _ensure_calendar(calendar_slug)
|
await _ensure_calendar(calendar_slug)
|
||||||
await _ensure_entry(entry_id)
|
await _ensure_entry(entry_id)
|
||||||
|
|
||||||
from bp.ticket_type.services.ticket import get_ticket_type as svc_get_ticket_type
|
from bp.ticket_type.services.ticket import get_ticket_type as svc_get_ticket_type
|
||||||
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id) if ticket_type_id else None
|
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id) if ticket_type_id else None
|
||||||
if not ticket_type:
|
if not ticket_type:
|
||||||
abort(404)
|
abort(404)
|
||||||
g.ticket_type = ticket_type
|
g.ticket_type = ticket_type
|
||||||
_add_to_defpage_ctx(ticket_type=ticket_type)
|
_add_to_defpage_ctx(ticket_type=ticket_type)
|
||||||
|
|
||||||
entry = getattr(g, "entry", None)
|
entry = getattr(g, "entry", None)
|
||||||
calendar = getattr(g, "calendar", None)
|
calendar = getattr(g, "calendar", None)
|
||||||
return render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year)
|
styles = _styles_data()
|
||||||
|
cal_slug = getattr(calendar, "slug", "")
|
||||||
|
cost = getattr(ticket_type, "cost", None)
|
||||||
|
cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
|
||||||
|
count = getattr(ticket_type, "count", 0)
|
||||||
|
|
||||||
|
edit_url = url_for(
|
||||||
|
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_edit",
|
||||||
|
ticket_type_id=ticket_type.id, calendar_slug=cal_slug,
|
||||||
|
year=year, month=month, day=day,
|
||||||
|
entry_id=entry.id if entry else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ticket-id": str(ticket_type.id),
|
||||||
|
"tt-name": ticket_type.name,
|
||||||
|
"cost-str": cost_str,
|
||||||
|
"count-str": str(count),
|
||||||
|
"edit-url": edit_url,
|
||||||
|
**styles,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _h_tickets_content(**kw):
|
# ---------------------------------------------------------------------------
|
||||||
from quart import g
|
# My tickets
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _h_tickets_data(**kw) -> dict:
|
||||||
|
from quart import g, url_for
|
||||||
from shared.infrastructure.cart_identity import current_cart_identity
|
from shared.infrastructure.cart_identity import current_cart_identity
|
||||||
from bp.tickets.services.tickets import get_user_tickets
|
from bp.tickets.services.tickets import get_user_tickets
|
||||||
|
|
||||||
ident = current_cart_identity()
|
ident = current_cart_identity()
|
||||||
tickets = await get_user_tickets(
|
tickets = await get_user_tickets(
|
||||||
g.s,
|
g.s,
|
||||||
user_id=ident["user_id"],
|
user_id=ident["user_id"],
|
||||||
session_id=ident["session_id"],
|
session_id=ident["session_id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
from shared.sx.page import get_template_context
|
from shared.sx.page import get_template_context
|
||||||
ctx = await get_template_context()
|
ctx = await get_template_context()
|
||||||
return _tickets_main_panel_html(ctx, tickets)
|
styles = ctx.get("styles") or {}
|
||||||
|
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
|
||||||
|
|
||||||
|
tickets_list = []
|
||||||
|
for ticket in (tickets or []):
|
||||||
|
href = url_for("defpage_ticket_detail", code=ticket.code)
|
||||||
|
entry = getattr(ticket, "entry", None)
|
||||||
|
entry_name = entry.name if entry else "Unknown event"
|
||||||
|
tt = getattr(ticket, "ticket_type", None)
|
||||||
|
state = getattr(ticket, "state", "")
|
||||||
|
cal = getattr(entry, "calendar", None) if entry else None
|
||||||
|
|
||||||
|
time_str = ""
|
||||||
|
if entry and entry.start_at:
|
||||||
|
time_str = entry.start_at.strftime("%A, %B %d, %Y at %H:%M")
|
||||||
|
if entry.end_at:
|
||||||
|
time_str += f" \u2013 {entry.end_at.strftime('%H:%M')}"
|
||||||
|
|
||||||
|
badge = _ticket_badge_data(state)
|
||||||
|
tickets_list.append({
|
||||||
|
"href": href,
|
||||||
|
"entry-name": entry_name,
|
||||||
|
"type-name": tt.name if tt else None,
|
||||||
|
"time-str": time_str or None,
|
||||||
|
"cal-name": cal.name if cal else None,
|
||||||
|
"badge-cls": badge["cls"],
|
||||||
|
"badge-label": badge["label"],
|
||||||
|
"code-prefix": ticket.code[:8],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"has-tickets": bool(tickets),
|
||||||
|
"tickets-list": tickets_list,
|
||||||
|
"list-container": list_container,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _h_ticket_detail_content(code=None, **kw):
|
# ---------------------------------------------------------------------------
|
||||||
from quart import g, abort
|
# Ticket detail
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _h_ticket_detail_data(code=None, **kw) -> dict:
|
||||||
|
from quart import g, abort, url_for
|
||||||
from shared.infrastructure.cart_identity import current_cart_identity
|
from shared.infrastructure.cart_identity import current_cart_identity
|
||||||
from bp.tickets.services.tickets import get_ticket_by_code
|
from bp.tickets.services.tickets import get_ticket_by_code
|
||||||
|
|
||||||
ticket = await get_ticket_by_code(g.s, code) if code else None
|
ticket = await get_ticket_by_code(g.s, code) if code else None
|
||||||
if not ticket:
|
if not ticket:
|
||||||
abort(404)
|
abort(404)
|
||||||
@@ -417,16 +785,71 @@ async def _h_ticket_detail_content(code=None, **kw):
|
|||||||
abort(404)
|
abort(404)
|
||||||
else:
|
else:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
from shared.sx.page import get_template_context
|
from shared.sx.page import get_template_context
|
||||||
ctx = await get_template_context()
|
ctx = await get_template_context()
|
||||||
return _ticket_detail_panel_html(ctx, ticket)
|
styles = ctx.get("styles") or {}
|
||||||
|
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
|
||||||
|
|
||||||
|
entry = getattr(ticket, "entry", None)
|
||||||
|
tt = getattr(ticket, "ticket_type", None)
|
||||||
|
state = getattr(ticket, "state", "")
|
||||||
|
ticket_code = ticket.code
|
||||||
|
cal = getattr(entry, "calendar", None) if entry else None
|
||||||
|
checked_in_at = getattr(ticket, "checked_in_at", None)
|
||||||
|
|
||||||
|
bg_map = {"confirmed": "bg-emerald-50", "checked_in": "bg-blue-50",
|
||||||
|
"reserved": "bg-amber-50"}
|
||||||
|
header_bg = bg_map.get(state, "bg-stone-50")
|
||||||
|
entry_name = entry.name if entry else "Ticket"
|
||||||
|
back_href = url_for("defpage_my_tickets")
|
||||||
|
|
||||||
|
badge = _ticket_badge_data(state)
|
||||||
|
|
||||||
|
time_date = entry.start_at.strftime("%A, %B %d, %Y") if entry and entry.start_at else None
|
||||||
|
time_range = entry.start_at.strftime("%H:%M") if entry and entry.start_at else None
|
||||||
|
if time_range and entry.end_at:
|
||||||
|
time_range += f" \u2013 {entry.end_at.strftime('%H:%M')}"
|
||||||
|
|
||||||
|
tt_desc = f"{tt.name} \u2014 \u00a3{tt.cost:.2f}" if tt and getattr(tt, "cost", None) else None
|
||||||
|
checkin_str = checked_in_at.strftime("Checked in: %B %d, %Y at %H:%M") if checked_in_at else None
|
||||||
|
|
||||||
|
qr_script = (
|
||||||
|
f"(function(){{var c=document.getElementById('ticket-qr-{ticket_code}');"
|
||||||
|
"if(c&&typeof QRCode!=='undefined'){"
|
||||||
|
"var cv=document.createElement('canvas');"
|
||||||
|
f"QRCode.toCanvas(cv,'{ticket_code}',{{width:200,margin:2,color:{{dark:'#1c1917',light:'#ffffff'}}}},function(e){{if(!e)c.appendChild(cv)}});"
|
||||||
|
"}})()"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"list-container": list_container,
|
||||||
|
"back-href": back_href,
|
||||||
|
"header-bg": header_bg,
|
||||||
|
"entry-name": entry_name,
|
||||||
|
"badge-cls": badge["cls"],
|
||||||
|
"badge-label": badge["label"],
|
||||||
|
"type-name": tt.name if tt else None,
|
||||||
|
"ticket-code": ticket_code,
|
||||||
|
"time-date": time_date,
|
||||||
|
"time-range": time_range,
|
||||||
|
"cal-name": cal.name if cal else None,
|
||||||
|
"type-desc": tt_desc,
|
||||||
|
"checkin-str": checkin_str,
|
||||||
|
"qr-script": qr_script,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _h_ticket_admin_content(**kw):
|
# ---------------------------------------------------------------------------
|
||||||
from quart import g
|
# Ticket admin dashboard
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _h_ticket_admin_data(**kw) -> dict:
|
||||||
|
from quart import g, url_for
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from models.calendars import CalendarEntry, Ticket
|
from models.calendars import CalendarEntry, Ticket
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
|
||||||
result = await g.s.execute(
|
result = await g.s.execute(
|
||||||
select(Ticket)
|
select(Ticket)
|
||||||
@@ -449,20 +872,118 @@ async def _h_ticket_admin_content(**kw):
|
|||||||
reserved = await g.s.scalar(
|
reserved = await g.s.scalar(
|
||||||
select(func.count(Ticket.id)).where(Ticket.state == "reserved")
|
select(func.count(Ticket.id)).where(Ticket.state == "reserved")
|
||||||
)
|
)
|
||||||
stats = {
|
|
||||||
"total": total or 0,
|
csrf = generate_csrf_token()
|
||||||
"confirmed": confirmed or 0,
|
lookup_url = url_for("ticket_admin.lookup")
|
||||||
"checked_in": checked_in or 0,
|
|
||||||
"reserved": reserved or 0,
|
from shared.sx.page import get_template_context
|
||||||
|
ctx = await get_template_context()
|
||||||
|
styles = ctx.get("styles") or {}
|
||||||
|
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
|
||||||
|
|
||||||
|
# Stats cards data
|
||||||
|
admin_stats = []
|
||||||
|
for label, key, border, bg, text_cls in [
|
||||||
|
("Total", "total", "border-stone-200", "", "text-stone-900"),
|
||||||
|
("Confirmed", "confirmed", "border-emerald-200", "bg-emerald-50", "text-emerald-700"),
|
||||||
|
("Checked In", "checked_in", "border-blue-200", "bg-blue-50", "text-blue-700"),
|
||||||
|
("Reserved", "reserved", "border-amber-200", "bg-amber-50", "text-amber-700"),
|
||||||
|
]:
|
||||||
|
val_map = {"total": total, "confirmed": confirmed,
|
||||||
|
"checked_in": checked_in, "reserved": reserved}
|
||||||
|
val = val_map.get(key, 0) or 0
|
||||||
|
lbl_cls = text_cls.replace("700", "600").replace("900", "500") if "stone" not in text_cls else "text-stone-500"
|
||||||
|
admin_stats.append({
|
||||||
|
"border": border, "bg": bg, "text-cls": text_cls,
|
||||||
|
"label-cls": lbl_cls, "value": str(val), "label": label,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Ticket rows data
|
||||||
|
admin_tickets = []
|
||||||
|
for ticket in tickets:
|
||||||
|
entry = getattr(ticket, "entry", None)
|
||||||
|
tt = getattr(ticket, "ticket_type", None)
|
||||||
|
state = getattr(ticket, "state", "")
|
||||||
|
tcode = ticket.code
|
||||||
|
checked_in_at = getattr(ticket, "checked_in_at", None)
|
||||||
|
|
||||||
|
date_str = None
|
||||||
|
if entry and entry.start_at:
|
||||||
|
date_str = entry.start_at.strftime("%d %b %Y, %H:%M")
|
||||||
|
|
||||||
|
badge = _ticket_badge_data(state)
|
||||||
|
can_checkin = state in ("confirmed", "reserved")
|
||||||
|
is_checked_in = state == "checked_in"
|
||||||
|
checkin_url = url_for("ticket_admin.do_checkin", code=tcode) if can_checkin else None
|
||||||
|
checkin_time = checked_in_at.strftime("%H:%M") if checked_in_at else ""
|
||||||
|
|
||||||
|
admin_tickets.append({
|
||||||
|
"code": tcode,
|
||||||
|
"code-short": tcode[:12] + "...",
|
||||||
|
"entry-name": entry.name if entry else "\u2014",
|
||||||
|
"date-str": date_str,
|
||||||
|
"type-name": tt.name if tt else "\u2014",
|
||||||
|
"badge-cls": badge["cls"],
|
||||||
|
"badge-label": badge["label"],
|
||||||
|
"can-checkin": can_checkin,
|
||||||
|
"is-checked-in": is_checked_in,
|
||||||
|
"checkin-url": checkin_url,
|
||||||
|
"checkin-time": checkin_time,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"admin-stats": admin_stats,
|
||||||
|
"admin-tickets": admin_tickets,
|
||||||
|
"list-container": list_container,
|
||||||
|
"lookup-url": lookup_url,
|
||||||
|
"csrf": csrf,
|
||||||
|
"has-tickets": bool(tickets),
|
||||||
}
|
}
|
||||||
|
|
||||||
from shared.sx.page import get_template_context
|
|
||||||
ctx = await get_template_context()
|
|
||||||
return _ticket_admin_main_panel_html(ctx, tickets, stats)
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Markets
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _h_markets_data(**kw) -> dict:
|
||||||
|
from quart import url_for
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
from shared.sx.helpers import call_url
|
||||||
|
|
||||||
async def _h_markets_content(**kw):
|
|
||||||
_ensure_post_defpage_ctx()
|
_ensure_post_defpage_ctx()
|
||||||
|
|
||||||
from shared.sx.page import get_template_context
|
from shared.sx.page import get_template_context
|
||||||
ctx = await get_template_context()
|
ctx = await get_template_context()
|
||||||
return _markets_main_panel_html(ctx)
|
|
||||||
|
rights = ctx.get("rights") or {}
|
||||||
|
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
|
||||||
|
has_access = ctx.get("has_access")
|
||||||
|
can_create = has_access("markets.create_market") if callable(has_access) else is_admin
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
markets_raw = ctx.get("markets") or []
|
||||||
|
|
||||||
|
post = ctx.get("post") or {}
|
||||||
|
slug = post.get("slug", "")
|
||||||
|
csrf_hdr = {"X-CSRFToken": csrf}
|
||||||
|
|
||||||
|
markets_list = []
|
||||||
|
for m in markets_raw:
|
||||||
|
m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "")
|
||||||
|
m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
|
||||||
|
market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/")
|
||||||
|
del_url = url_for("markets.delete_market", market_slug=m_slug)
|
||||||
|
|
||||||
|
markets_list.append({
|
||||||
|
"href": market_href,
|
||||||
|
"name": m_name,
|
||||||
|
"slug": m_slug,
|
||||||
|
"del-url": del_url,
|
||||||
|
"csrf-hdr": csrf_hdr,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"can-create": can_create,
|
||||||
|
"create-url": url_for("markets.create_market") if can_create else None,
|
||||||
|
"csrf": csrf,
|
||||||
|
"markets-list": markets_list,
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
|
|
||||||
from shared.sx.helpers import sx_call
|
from shared.sx.helpers import sx_call
|
||||||
|
from shared.sx.parser import SxExpr
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
@@ -110,9 +111,9 @@ _SLOT_PICKER_JS = """\
|
|||||||
# Slot options (shared by entry edit + add forms)
|
# Slot options (shared by entry edit + add forms)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _slot_options_data(day_slots, selected_slot_id=None) -> list:
|
def _slot_options_html(day_slots, selected_slot_id=None) -> str:
|
||||||
"""Extract slot option data for sx composition."""
|
"""Build slot <option> elements."""
|
||||||
result = []
|
parts = []
|
||||||
for slot in day_slots:
|
for slot in day_slots:
|
||||||
start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
|
start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
|
||||||
end = slot.time_end.strftime("%H:%M") if slot.time_end else ""
|
end = slot.time_end.strftime("%H:%M") if slot.time_end else ""
|
||||||
@@ -126,15 +127,16 @@ def _slot_options_data(day_slots, selected_slot_id=None) -> list:
|
|||||||
label_parts.append("\u2013open-ended)")
|
label_parts.append("\u2013open-ended)")
|
||||||
if flexible:
|
if flexible:
|
||||||
label_parts.append("[flexible]")
|
label_parts.append("[flexible]")
|
||||||
result.append({
|
label = " ".join(label_parts)
|
||||||
"value": str(slot.id),
|
|
||||||
"data-start": start, "data-end": end,
|
parts.append(sx_call("events-slot-option",
|
||||||
"data-flexible": "1" if flexible else "0",
|
value=str(slot.id),
|
||||||
"data-cost": cost_str,
|
data_start=start, data_end=end,
|
||||||
"selected": "selected" if selected_slot_id == slot.id else None,
|
data_flexible="1" if flexible else "0",
|
||||||
"label": " ".join(label_parts),
|
data_cost=cost_str,
|
||||||
})
|
selected="selected" if selected_slot_id == slot.id else None,
|
||||||
return result
|
label=label))
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -167,7 +169,7 @@ def _slot_header_html(ctx: dict, *, oob: bool = False) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def render_slot_main_panel(slot, calendar, *, oob: bool = False) -> str:
|
def render_slot_main_panel(slot, calendar, *, oob: bool = False) -> str:
|
||||||
"""Render slot detail view via data extraction + sx defcomp."""
|
"""Render slot detail view."""
|
||||||
from quart import url_for, g
|
from quart import url_for, g
|
||||||
|
|
||||||
styles = getattr(g, "styles", None) or {}
|
styles = getattr(g, "styles", None) or {}
|
||||||
@@ -177,23 +179,38 @@ def render_slot_main_panel(slot, calendar, *, oob: bool = False) -> str:
|
|||||||
|
|
||||||
days_display = getattr(slot, "days_display", "\u2014")
|
days_display = getattr(slot, "days_display", "\u2014")
|
||||||
days = days_display.split(", ")
|
days = days_display.split(", ")
|
||||||
if days and days[0] == "\u2014":
|
flexible = getattr(slot, "flexible", False)
|
||||||
days = []
|
|
||||||
|
|
||||||
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
|
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
|
||||||
time_end = slot.time_end.strftime("%H:%M") if slot.time_end else ""
|
time_end = slot.time_end.strftime("%H:%M") if slot.time_end else ""
|
||||||
cost = getattr(slot, "cost", None)
|
cost = getattr(slot, "cost", None)
|
||||||
|
cost_str = f"{cost:.2f}" if cost is not None else ""
|
||||||
|
desc = getattr(slot, "description", "") or ""
|
||||||
|
|
||||||
return sx_call("events-slot-panel-from-data",
|
edit_url = url_for("calendar.slots.slot.get_edit", slot_id=slot.id, calendar_slug=cal_slug)
|
||||||
slot_id=str(slot.id), list_container=list_container,
|
|
||||||
days=days or None,
|
# Days pills
|
||||||
flexible="yes" if getattr(slot, "flexible", False) else "no",
|
if days and days[0] != "\u2014":
|
||||||
time_str=f"{time_start} \u2014 {time_end}",
|
days_inner = "".join(
|
||||||
cost_str=f"{cost:.2f}" if cost is not None else "",
|
sx_call("events-slot-day-pill", day=d) for d in days
|
||||||
pre_action=pre_action,
|
)
|
||||||
edit_url=url_for("calendar.slots.slot.get_edit", slot_id=slot.id, calendar_slug=cal_slug),
|
days_html = sx_call("events-slot-days-pills", days_inner=SxExpr(days_inner))
|
||||||
description=getattr(slot, "description", "") or "",
|
else:
|
||||||
oob=oob or None)
|
days_html = sx_call("events-slot-no-days")
|
||||||
|
|
||||||
|
sid = str(slot.id)
|
||||||
|
|
||||||
|
result = sx_call("events-slot-panel",
|
||||||
|
slot_id=sid, list_container=list_container,
|
||||||
|
days=days_html,
|
||||||
|
flexible="yes" if flexible else "no",
|
||||||
|
time_str=f"{time_start} \u2014 {time_end}",
|
||||||
|
cost_str=cost_str,
|
||||||
|
pre_action=pre_action, edit_url=edit_url)
|
||||||
|
|
||||||
|
if oob:
|
||||||
|
result += sx_call("events-slot-description-oob", description=desc)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -201,7 +218,7 @@ def render_slot_main_panel(slot, calendar, *, oob: bool = False) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def render_slots_table(slots, calendar) -> str:
|
def render_slots_table(slots, calendar) -> str:
|
||||||
"""Render slots table via data extraction + sx defcomp."""
|
"""Render slots table with rows and add button."""
|
||||||
from quart import url_for, g
|
from quart import url_for, g
|
||||||
from shared.browser.app.csrf import generate_csrf_token
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
csrf = generate_csrf_token()
|
csrf = generate_csrf_token()
|
||||||
@@ -215,34 +232,46 @@ def render_slots_table(slots, calendar) -> str:
|
|||||||
hx_select = getattr(g, "hx_select_search", "#main-panel")
|
hx_select = getattr(g, "hx_select_search", "#main-panel")
|
||||||
cal_slug = getattr(calendar, "slug", "")
|
cal_slug = getattr(calendar, "slug", "")
|
||||||
|
|
||||||
slots_data = []
|
rows_html = ""
|
||||||
if slots:
|
if slots:
|
||||||
for s in slots:
|
for s in slots:
|
||||||
|
slot_href = url_for("defpage_slot_detail", calendar_slug=cal_slug, slot_id=s.id)
|
||||||
|
del_url = url_for("calendar.slots.slot.slot_delete", calendar_slug=cal_slug, slot_id=s.id)
|
||||||
|
desc = getattr(s, "description", "") or ""
|
||||||
|
|
||||||
days_display = getattr(s, "days_display", "\u2014")
|
days_display = getattr(s, "days_display", "\u2014")
|
||||||
day_list = days_display.split(", ")
|
day_list = days_display.split(", ")
|
||||||
if day_list and day_list[0] == "\u2014":
|
if day_list and day_list[0] != "\u2014":
|
||||||
day_list = []
|
days_inner = "".join(
|
||||||
|
sx_call("events-slot-day-pill", day=d) for d in day_list
|
||||||
|
)
|
||||||
|
days_html = sx_call("events-slot-days-pills", days_inner=SxExpr(days_inner))
|
||||||
|
else:
|
||||||
|
days_html = sx_call("events-slot-no-days")
|
||||||
|
|
||||||
time_start = s.time_start.strftime("%H:%M") if s.time_start else ""
|
time_start = s.time_start.strftime("%H:%M") if s.time_start else ""
|
||||||
time_end = s.time_end.strftime("%H:%M") if s.time_end else ""
|
time_end = s.time_end.strftime("%H:%M") if s.time_end else ""
|
||||||
cost = getattr(s, "cost", None)
|
cost = getattr(s, "cost", None)
|
||||||
slots_data.append({
|
cost_str = f"{cost:.2f}" if cost is not None else ""
|
||||||
"slot-href": url_for("defpage_slot_detail", calendar_slug=cal_slug, slot_id=s.id),
|
|
||||||
"slot-name": s.name,
|
|
||||||
"description": getattr(s, "description", "") or "",
|
|
||||||
"flexible": "yes" if s.flexible else "no",
|
|
||||||
"days": day_list or None,
|
|
||||||
"time-str": f"{time_start} - {time_end}",
|
|
||||||
"cost-str": f"{cost:.2f}" if cost is not None else "",
|
|
||||||
"del-url": url_for("calendar.slots.slot.slot_delete", calendar_slug=cal_slug, slot_id=s.id),
|
|
||||||
})
|
|
||||||
|
|
||||||
return sx_call("events-slots-table-from-data",
|
rows_html += sx_call("events-slots-row",
|
||||||
list_container=list_container, slots=slots_data or None,
|
tr_cls=tr_cls, slot_href=slot_href,
|
||||||
pre_action=pre_action,
|
pill_cls=pill_cls, hx_select=hx_select,
|
||||||
add_url=url_for("calendar.slots.add_form", calendar_slug=cal_slug),
|
slot_name=s.name, description=desc,
|
||||||
tr_cls=tr_cls, pill_cls=pill_cls, action_btn=action_btn,
|
flexible="yes" if s.flexible else "no",
|
||||||
hx_select=hx_select,
|
days=days_html,
|
||||||
csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}')
|
time_str=f"{time_start} - {time_end}",
|
||||||
|
cost_str=cost_str, action_btn=action_btn,
|
||||||
|
del_url=del_url,
|
||||||
|
csrf_hdr={"X-CSRFToken": csrf})
|
||||||
|
else:
|
||||||
|
rows_html = sx_call("events-slots-empty-row")
|
||||||
|
|
||||||
|
add_url = url_for("calendar.slots.add_form", calendar_slug=cal_slug)
|
||||||
|
|
||||||
|
return sx_call("events-slots-table",
|
||||||
|
list_container=list_container, rows=SxExpr(rows_html),
|
||||||
|
pre_action=pre_action, add_url=add_url)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -250,7 +279,7 @@ def render_slots_table(slots, calendar) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def render_slot_edit_form(slot, calendar) -> str:
|
def render_slot_edit_form(slot, calendar) -> str:
|
||||||
"""Render slot edit form via data extraction + sx defcomp."""
|
"""Render slot edit form (replaces _types/slot/_edit.html)."""
|
||||||
from quart import url_for, g
|
from quart import url_for, g
|
||||||
from shared.browser.app.csrf import generate_csrf_token
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
csrf = generate_csrf_token()
|
csrf = generate_csrf_token()
|
||||||
@@ -262,25 +291,38 @@ def render_slot_edit_form(slot, calendar) -> str:
|
|||||||
cal_slug = getattr(calendar, "slug", "")
|
cal_slug = getattr(calendar, "slug", "")
|
||||||
sid = slot.id
|
sid = slot.id
|
||||||
|
|
||||||
|
put_url = url_for("calendar.slots.slot.put", calendar_slug=cal_slug, slot_id=sid)
|
||||||
|
cancel_url = url_for("calendar.slots.slot.get_view", calendar_slug=cal_slug, slot_id=sid)
|
||||||
|
|
||||||
cost = getattr(slot, "cost", None)
|
cost = getattr(slot, "cost", None)
|
||||||
|
cost_val = f"{cost:.2f}" if cost is not None else ""
|
||||||
|
start_val = slot.time_start.strftime("%H:%M") if slot.time_start else ""
|
||||||
|
end_val = slot.time_end.strftime("%H:%M") if slot.time_end else ""
|
||||||
|
desc_val = getattr(slot, "description", "") or ""
|
||||||
|
|
||||||
|
# Days checkboxes
|
||||||
day_keys = [("mon", "Mon"), ("tue", "Tue"), ("wed", "Wed"), ("thu", "Thu"),
|
day_keys = [("mon", "Mon"), ("tue", "Tue"), ("wed", "Wed"), ("thu", "Thu"),
|
||||||
("fri", "Fri"), ("sat", "Sat"), ("sun", "Sun")]
|
("fri", "Fri"), ("sat", "Sat"), ("sun", "Sun")]
|
||||||
days_data = [{"name": k, "label": lbl, "checked": getattr(slot, k, False) or None}
|
|
||||||
for k, lbl in day_keys]
|
|
||||||
all_checked = all(getattr(slot, k, False) for k, _ in day_keys)
|
all_checked = all(getattr(slot, k, False) for k, _ in day_keys)
|
||||||
|
|
||||||
return sx_call("events-slot-edit-form-from-data",
|
days_parts = [sx_call("events-day-all-checkbox",
|
||||||
|
checked="checked" if all_checked else None)]
|
||||||
|
for key, label in day_keys:
|
||||||
|
checked = getattr(slot, key, False)
|
||||||
|
days_parts.append(sx_call("events-day-checkbox",
|
||||||
|
name=key, label=label,
|
||||||
|
checked="checked" if checked else None))
|
||||||
|
days_html = "".join(days_parts)
|
||||||
|
|
||||||
|
flexible = getattr(slot, "flexible", False)
|
||||||
|
|
||||||
|
return sx_call("events-slot-edit-form",
|
||||||
slot_id=str(sid), list_container=list_container,
|
slot_id=str(sid), list_container=list_container,
|
||||||
put_url=url_for("calendar.slots.slot.put", calendar_slug=cal_slug, slot_id=sid),
|
put_url=put_url, cancel_url=cancel_url, csrf=csrf,
|
||||||
cancel_url=url_for("calendar.slots.slot.get_view", calendar_slug=cal_slug, slot_id=sid),
|
name_val=slot.name or "", cost_val=cost_val,
|
||||||
csrf=csrf,
|
start_val=start_val, end_val=end_val,
|
||||||
name_val=slot.name or "",
|
desc_val=desc_val, days=SxExpr(days_html),
|
||||||
cost_val=f"{cost:.2f}" if cost is not None else "",
|
flexible_checked="checked" if flexible else None,
|
||||||
start_val=slot.time_start.strftime("%H:%M") if slot.time_start else "",
|
|
||||||
end_val=slot.time_end.strftime("%H:%M") if slot.time_end else "",
|
|
||||||
desc_val=getattr(slot, "description", "") or "",
|
|
||||||
days_data=days_data, all_checked=all_checked or None,
|
|
||||||
flexible_checked="checked" if getattr(slot, "flexible", False) else None,
|
|
||||||
action_btn=action_btn, cancel_btn=cancel_btn)
|
action_btn=action_btn, cancel_btn=cancel_btn)
|
||||||
|
|
||||||
|
|
||||||
@@ -289,7 +331,7 @@ def render_slot_edit_form(slot, calendar) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def render_slot_add_form(calendar) -> str:
|
def render_slot_add_form(calendar) -> str:
|
||||||
"""Render slot add form via data extraction + sx defcomp."""
|
"""Render slot add form (replaces _types/slots/_add.html)."""
|
||||||
from quart import url_for, g
|
from quart import url_for, g
|
||||||
from shared.browser.app.csrf import generate_csrf_token
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
csrf = generate_csrf_token()
|
csrf = generate_csrf_token()
|
||||||
@@ -299,16 +341,23 @@ def render_slot_add_form(calendar) -> str:
|
|||||||
cancel_btn = styles.get("cancel_button", "") if isinstance(styles, dict) else getattr(styles, "cancel_button", "")
|
cancel_btn = styles.get("cancel_button", "") if isinstance(styles, dict) else getattr(styles, "cancel_button", "")
|
||||||
cal_slug = getattr(calendar, "slug", "")
|
cal_slug = getattr(calendar, "slug", "")
|
||||||
|
|
||||||
|
post_url = url_for("calendar.slots.post", calendar_slug=cal_slug)
|
||||||
|
cancel_url = url_for("calendar.slots.add_button", calendar_slug=cal_slug)
|
||||||
|
csrf_hdr = {"X-CSRFToken": csrf}
|
||||||
|
|
||||||
|
# Days checkboxes (all unchecked for add)
|
||||||
day_keys = [("mon", "Mon"), ("tue", "Tue"), ("wed", "Wed"), ("thu", "Thu"),
|
day_keys = [("mon", "Mon"), ("tue", "Tue"), ("wed", "Wed"), ("thu", "Thu"),
|
||||||
("fri", "Fri"), ("sat", "Sat"), ("sun", "Sun")]
|
("fri", "Fri"), ("sat", "Sat"), ("sun", "Sun")]
|
||||||
days_data = [{"name": k, "label": lbl} for k, lbl in day_keys]
|
days_parts = [sx_call("events-day-all-checkbox", checked=None)]
|
||||||
|
for key, label in day_keys:
|
||||||
|
days_parts.append(sx_call("events-day-checkbox", name=key, label=label, checked=None))
|
||||||
|
days_html = "".join(days_parts)
|
||||||
|
|
||||||
return sx_call("events-slot-add-form-from-data",
|
return sx_call("events-slot-add-form",
|
||||||
post_url=url_for("calendar.slots.post", calendar_slug=cal_slug),
|
post_url=post_url, csrf=csrf_hdr,
|
||||||
csrf=f'{{"X-CSRFToken": "{csrf}"}}',
|
days=SxExpr(days_html),
|
||||||
days_data=days_data,
|
|
||||||
action_btn=action_btn, cancel_btn=cancel_btn,
|
action_btn=action_btn, cancel_btn=cancel_btn,
|
||||||
cancel_url=url_for("calendar.slots.add_button", calendar_slug=cal_slug))
|
cancel_url=cancel_url)
|
||||||
|
|
||||||
|
|
||||||
def render_slot_add_button(calendar) -> str:
|
def render_slot_add_button(calendar) -> str:
|
||||||
|
|||||||
@@ -6,25 +6,49 @@ from markupsafe import escape
|
|||||||
from shared.sx.helpers import sx_call
|
from shared.sx.helpers import sx_call
|
||||||
from shared.sx.parser import SxExpr
|
from shared.sx.parser import SxExpr
|
||||||
|
|
||||||
from .utils import _list_container, _cart_icon_ctx
|
from .utils import (
|
||||||
|
_ticket_state_badge_html, _list_container, _cart_icon_oob,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Ticket widget (inline +/- for entry cards)
|
# Ticket widget (inline +/- for entry cards)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _ticket_widget_data(entry, qty: int, ticket_url: str) -> dict:
|
def _ticket_widget_html(entry, qty: int, ticket_url: str, *, ctx: dict) -> str:
|
||||||
"""Extract ticket widget data for sx composition."""
|
"""Render the inline +/- ticket widget."""
|
||||||
from shared.browser.app.csrf import generate_csrf_token
|
csrf_token_val = ""
|
||||||
|
if ctx:
|
||||||
|
ct = ctx.get("csrf_token")
|
||||||
|
csrf_token_val = ct() if callable(ct) else (ct or "")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
from flask_wtf.csrf import generate_csrf
|
||||||
|
csrf_token_val = generate_csrf()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
eid = entry.id
|
eid = entry.id
|
||||||
tp = getattr(entry, "ticket_price", 0) or 0
|
tp = getattr(entry, "ticket_price", 0) or 0
|
||||||
return {
|
tgt = f"#page-ticket-{eid}"
|
||||||
"entry_id": str(eid),
|
|
||||||
"price": f"\u00a3{tp:.2f}",
|
def _tw_form(count_val, btn_html):
|
||||||
"qty": qty,
|
return sx_call("events-tw-form",
|
||||||
"ticket_url": ticket_url,
|
ticket_url=ticket_url, target=tgt,
|
||||||
"csrf": generate_csrf_token(),
|
csrf=csrf_token_val, entry_id=str(eid),
|
||||||
}
|
count_val=str(count_val), btn=btn_html)
|
||||||
|
|
||||||
|
if qty == 0:
|
||||||
|
inner = _tw_form(1, sx_call("events-tw-cart-plus"))
|
||||||
|
else:
|
||||||
|
minus = _tw_form(qty - 1, sx_call("events-tw-minus"))
|
||||||
|
cart_icon = sx_call("events-tw-cart-icon", qty=str(qty))
|
||||||
|
plus = _tw_form(qty + 1, sx_call("events-tw-plus"))
|
||||||
|
inner = minus + cart_icon + plus
|
||||||
|
|
||||||
|
return sx_call("events-tw-widget",
|
||||||
|
entry_id=str(eid), price=f"\u00a3{tp:.2f}",
|
||||||
|
inner=SxExpr(inner))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -32,33 +56,37 @@ def _ticket_widget_data(entry, qty: int, ticket_url: str) -> dict:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _tickets_main_panel_html(ctx: dict, tickets: list) -> str:
|
def _tickets_main_panel_html(ctx: dict, tickets: list) -> str:
|
||||||
"""Render my tickets list via data extraction + sx defcomp."""
|
"""Render my tickets list."""
|
||||||
from quart import url_for
|
from quart import url_for
|
||||||
|
|
||||||
ticket_data = []
|
ticket_cards = []
|
||||||
if tickets:
|
if tickets:
|
||||||
for ticket in tickets:
|
for ticket in tickets:
|
||||||
|
href = url_for("defpage_ticket_detail", code=ticket.code)
|
||||||
entry = getattr(ticket, "entry", None)
|
entry = getattr(ticket, "entry", None)
|
||||||
|
entry_name = entry.name if entry else "Unknown event"
|
||||||
tt = getattr(ticket, "ticket_type", None)
|
tt = getattr(ticket, "ticket_type", None)
|
||||||
|
state = getattr(ticket, "state", "")
|
||||||
cal = getattr(entry, "calendar", None) if entry else None
|
cal = getattr(entry, "calendar", None) if entry else None
|
||||||
|
|
||||||
time_str = ""
|
time_str = ""
|
||||||
if entry and entry.start_at:
|
if entry and entry.start_at:
|
||||||
time_str = entry.start_at.strftime("%A, %B %d, %Y at %H:%M")
|
time_str = entry.start_at.strftime("%A, %B %d, %Y at %H:%M")
|
||||||
if entry.end_at:
|
if entry.end_at:
|
||||||
time_str += f" \u2013 {entry.end_at.strftime('%H:%M')}"
|
time_str += f" \u2013 {entry.end_at.strftime('%H:%M')}"
|
||||||
ticket_data.append({
|
|
||||||
"href": url_for("defpage_ticket_detail", code=ticket.code),
|
|
||||||
"entry-name": entry.name if entry else "Unknown event",
|
|
||||||
"type-name": tt.name if tt else None,
|
|
||||||
"time-str": time_str or None,
|
|
||||||
"cal-name": cal.name if cal else None,
|
|
||||||
"state": getattr(ticket, "state", ""),
|
|
||||||
"code-prefix": ticket.code[:8],
|
|
||||||
})
|
|
||||||
|
|
||||||
return sx_call("events-tickets-panel-from-data",
|
ticket_cards.append(sx_call("events-ticket-card",
|
||||||
|
href=href, entry_name=entry_name,
|
||||||
|
type_name=tt.name if tt else None,
|
||||||
|
time_str=time_str or None,
|
||||||
|
cal_name=cal.name if cal else None,
|
||||||
|
badge=_ticket_state_badge_html(state),
|
||||||
|
code_prefix=ticket.code[:8]))
|
||||||
|
|
||||||
|
cards_html = "".join(ticket_cards)
|
||||||
|
return sx_call("events-tickets-panel",
|
||||||
list_container=_list_container(ctx),
|
list_container=_list_container(ctx),
|
||||||
tickets=ticket_data or None)
|
has_tickets=bool(tickets), cards=SxExpr(cards_html))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -66,7 +94,7 @@ def _tickets_main_panel_html(ctx: dict, tickets: list) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _ticket_detail_panel_html(ctx: dict, ticket) -> str:
|
def _ticket_detail_panel_html(ctx: dict, ticket) -> str:
|
||||||
"""Render a single ticket detail with QR code via data + sx defcomp."""
|
"""Render a single ticket detail with QR code."""
|
||||||
from quart import url_for
|
from quart import url_for
|
||||||
|
|
||||||
entry = getattr(ticket, "entry", None)
|
entry = getattr(ticket, "entry", None)
|
||||||
@@ -77,11 +105,22 @@ def _ticket_detail_panel_html(ctx: dict, ticket) -> str:
|
|||||||
checked_in_at = getattr(ticket, "checked_in_at", None)
|
checked_in_at = getattr(ticket, "checked_in_at", None)
|
||||||
|
|
||||||
bg_map = {"confirmed": "bg-emerald-50", "checked_in": "bg-blue-50", "reserved": "bg-amber-50"}
|
bg_map = {"confirmed": "bg-emerald-50", "checked_in": "bg-blue-50", "reserved": "bg-amber-50"}
|
||||||
|
header_bg = bg_map.get(state, "bg-stone-50")
|
||||||
|
entry_name = entry.name if entry else "Ticket"
|
||||||
|
back_href = url_for("defpage_my_tickets")
|
||||||
|
|
||||||
|
# Badge with larger sizing
|
||||||
|
badge = (_ticket_state_badge_html(state)).replace('px-2 py-0.5 text-xs', 'px-3 py-1 text-sm')
|
||||||
|
|
||||||
|
# Time info
|
||||||
time_date = entry.start_at.strftime("%A, %B %d, %Y") if entry and entry.start_at else None
|
time_date = entry.start_at.strftime("%A, %B %d, %Y") if entry and entry.start_at else None
|
||||||
time_range = entry.start_at.strftime("%H:%M") if entry and entry.start_at else None
|
time_range = entry.start_at.strftime("%H:%M") if entry and entry.start_at else None
|
||||||
if time_range and entry.end_at:
|
if time_range and entry.end_at:
|
||||||
time_range += f" \u2013 {entry.end_at.strftime('%H:%M')}"
|
time_range += f" \u2013 {entry.end_at.strftime('%H:%M')}"
|
||||||
|
|
||||||
|
tt_desc = f"{tt.name} \u2014 \u00a3{tt.cost:.2f}" if tt and getattr(tt, "cost", None) else None
|
||||||
|
checkin_str = checked_in_at.strftime("Checked in: %B %d, %Y at %H:%M") if checked_in_at else None
|
||||||
|
|
||||||
qr_script = (
|
qr_script = (
|
||||||
f"(function(){{var c=document.getElementById('ticket-qr-{code}');"
|
f"(function(){{var c=document.getElementById('ticket-qr-{code}');"
|
||||||
"if(c&&typeof QRCode!=='undefined'){"
|
"if(c&&typeof QRCode!=='undefined'){"
|
||||||
@@ -90,16 +129,13 @@ def _ticket_detail_panel_html(ctx: dict, ticket) -> str:
|
|||||||
"}})()"
|
"}})()"
|
||||||
)
|
)
|
||||||
|
|
||||||
return sx_call("events-ticket-detail-from-data",
|
return sx_call("events-ticket-detail",
|
||||||
list_container=_list_container(ctx),
|
list_container=_list_container(ctx), back_href=back_href,
|
||||||
back_href=url_for("defpage_my_tickets"),
|
header_bg=header_bg, entry_name=entry_name,
|
||||||
header_bg=bg_map.get(state, "bg-stone-50"),
|
badge=SxExpr(badge), type_name=tt.name if tt else None,
|
||||||
entry_name=entry.name if entry else "Ticket",
|
|
||||||
state=state, type_name=tt.name if tt else None,
|
|
||||||
code=code, time_date=time_date, time_range=time_range,
|
code=code, time_date=time_date, time_range=time_range,
|
||||||
cal_name=cal.name if cal else None,
|
cal_name=cal.name if cal else None,
|
||||||
type_desc=f"{tt.name} \u2014 \u00a3{tt.cost:.2f}" if tt and getattr(tt, "cost", None) else None,
|
type_desc=tt_desc, checkin_str=checkin_str,
|
||||||
checkin_str=checked_in_at.strftime("Checked in: %B %d, %Y at %H:%M") if checked_in_at else None,
|
|
||||||
qr_script=qr_script)
|
qr_script=qr_script)
|
||||||
|
|
||||||
|
|
||||||
@@ -108,38 +144,62 @@ def _ticket_detail_panel_html(ctx: dict, ticket) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _ticket_admin_main_panel_html(ctx: dict, tickets: list, stats: dict) -> str:
|
def _ticket_admin_main_panel_html(ctx: dict, tickets: list, stats: dict) -> str:
|
||||||
"""Render ticket admin dashboard via data extraction + sx defcomp."""
|
"""Render ticket admin dashboard."""
|
||||||
from quart import url_for
|
from quart import url_for
|
||||||
csrf_token = ctx.get("csrf_token")
|
csrf_token = ctx.get("csrf_token")
|
||||||
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
|
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
|
||||||
|
lookup_url = url_for("ticket_admin.lookup")
|
||||||
|
|
||||||
ticket_data = []
|
# Stats cards
|
||||||
|
stats_html = ""
|
||||||
|
for label, key, border, bg, text_cls in [
|
||||||
|
("Total", "total", "border-stone-200", "", "text-stone-900"),
|
||||||
|
("Confirmed", "confirmed", "border-emerald-200", "bg-emerald-50", "text-emerald-700"),
|
||||||
|
("Checked In", "checked_in", "border-blue-200", "bg-blue-50", "text-blue-700"),
|
||||||
|
("Reserved", "reserved", "border-amber-200", "bg-amber-50", "text-amber-700"),
|
||||||
|
]:
|
||||||
|
val = stats.get(key, 0)
|
||||||
|
lbl_cls = text_cls.replace("700", "600").replace("900", "500") if "stone" not in text_cls else "text-stone-500"
|
||||||
|
stats_html += sx_call("events-ticket-admin-stat",
|
||||||
|
border=border, bg=bg, text_cls=text_cls,
|
||||||
|
label_cls=lbl_cls, value=str(val), label=label)
|
||||||
|
|
||||||
|
# Ticket rows
|
||||||
|
rows_html = ""
|
||||||
for ticket in tickets:
|
for ticket in tickets:
|
||||||
entry = getattr(ticket, "entry", None)
|
entry = getattr(ticket, "entry", None)
|
||||||
tt = getattr(ticket, "ticket_type", None)
|
tt = getattr(ticket, "ticket_type", None)
|
||||||
state = getattr(ticket, "state", "")
|
state = getattr(ticket, "state", "")
|
||||||
code = ticket.code
|
code = ticket.code
|
||||||
checked_in_at = getattr(ticket, "checked_in_at", None)
|
|
||||||
ticket_data.append({
|
|
||||||
"code": code,
|
|
||||||
"code-short": code[:12] + "...",
|
|
||||||
"entry-name": entry.name if entry else "\u2014",
|
|
||||||
"date-str": entry.start_at.strftime("%d %b %Y, %H:%M") if entry and entry.start_at else None,
|
|
||||||
"type-name": tt.name if tt else "\u2014",
|
|
||||||
"state": state,
|
|
||||||
"checkin-url": url_for("ticket_admin.do_checkin", code=code) if state in ("confirmed", "reserved") else None,
|
|
||||||
"csrf": csrf,
|
|
||||||
"checked-in-time": checked_in_at.strftime("%H:%M") if checked_in_at else None,
|
|
||||||
})
|
|
||||||
|
|
||||||
return sx_call("events-ticket-admin-panel-from-data",
|
date_html = ""
|
||||||
list_container=_list_container(ctx),
|
if entry and entry.start_at:
|
||||||
lookup_url=url_for("ticket_admin.lookup"),
|
date_html = sx_call("events-ticket-admin-date",
|
||||||
tickets=ticket_data or None,
|
date_str=entry.start_at.strftime("%d %b %Y, %H:%M"))
|
||||||
total=stats.get("total", 0),
|
|
||||||
confirmed=stats.get("confirmed", 0),
|
action_html = ""
|
||||||
checked_in=stats.get("checked_in", 0),
|
if state in ("confirmed", "reserved"):
|
||||||
reserved=stats.get("reserved", 0))
|
checkin_url = url_for("ticket_admin.do_checkin", code=code)
|
||||||
|
action_html = sx_call("events-ticket-admin-checkin-form",
|
||||||
|
checkin_url=checkin_url, code=code, csrf=csrf)
|
||||||
|
elif state == "checked_in":
|
||||||
|
checked_in_at = getattr(ticket, "checked_in_at", None)
|
||||||
|
t_str = checked_in_at.strftime("%H:%M") if checked_in_at else ""
|
||||||
|
action_html = sx_call("events-ticket-admin-checked-in",
|
||||||
|
time_str=t_str)
|
||||||
|
|
||||||
|
rows_html += sx_call("events-ticket-admin-row",
|
||||||
|
code=code, code_short=code[:12] + "...",
|
||||||
|
entry_name=entry.name if entry else "\u2014",
|
||||||
|
date=SxExpr(date_html),
|
||||||
|
type_name=tt.name if tt else "\u2014",
|
||||||
|
badge=_ticket_state_badge_html(state),
|
||||||
|
action=SxExpr(action_html))
|
||||||
|
|
||||||
|
return sx_call("events-ticket-admin-panel",
|
||||||
|
list_container=_list_container(ctx), stats=SxExpr(stats_html),
|
||||||
|
lookup_url=lookup_url, has_tickets=bool(tickets),
|
||||||
|
rows=SxExpr(rows_html))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -148,8 +208,7 @@ def _ticket_admin_main_panel_html(ctx: dict, tickets: list, stats: dict) -> str:
|
|||||||
|
|
||||||
def render_ticket_widget(entry, qty: int, ticket_url: str) -> str:
|
def render_ticket_widget(entry, qty: int, ticket_url: str) -> str:
|
||||||
"""Render the +/- ticket widget for page_summary / all_events adjust_ticket."""
|
"""Render the +/- ticket widget for page_summary / all_events adjust_ticket."""
|
||||||
data = _ticket_widget_data(entry, qty, ticket_url)
|
return _ticket_widget_html(entry, qty, ticket_url, ctx={})
|
||||||
return sx_call("events-tw-widget-from-data", **data)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -167,13 +226,20 @@ def render_checkin_result(success: bool, error: str | None, ticket) -> str:
|
|||||||
entry = getattr(ticket, "entry", None)
|
entry = getattr(ticket, "entry", None)
|
||||||
tt = getattr(ticket, "ticket_type", None)
|
tt = getattr(ticket, "ticket_type", None)
|
||||||
checked_in_at = getattr(ticket, "checked_in_at", None)
|
checked_in_at = getattr(ticket, "checked_in_at", None)
|
||||||
|
time_str = checked_in_at.strftime("%H:%M") if checked_in_at else "Just now"
|
||||||
|
|
||||||
return sx_call("events-checkin-success-row-from-data",
|
date_html = ""
|
||||||
|
if entry and entry.start_at:
|
||||||
|
date_html = sx_call("events-ticket-admin-date",
|
||||||
|
date_str=entry.start_at.strftime("%d %b %Y, %H:%M"))
|
||||||
|
|
||||||
|
return sx_call("events-checkin-success-row",
|
||||||
code=code, code_short=code[:12] + "...",
|
code=code, code_short=code[:12] + "...",
|
||||||
entry_name=entry.name if entry else "\u2014",
|
entry_name=entry.name if entry else "\u2014",
|
||||||
date_str=entry.start_at.strftime("%d %b %Y, %H:%M") if entry and entry.start_at else None,
|
date=SxExpr(date_html),
|
||||||
type_name=tt.name if tt else "\u2014",
|
type_name=tt.name if tt else "\u2014",
|
||||||
time_str=checked_in_at.strftime("%H:%M") if checked_in_at else "Just now")
|
badge=_ticket_state_badge_html("checked_in"),
|
||||||
|
time_str=time_str)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -181,7 +247,7 @@ def render_checkin_result(success: bool, error: str | None, ticket) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def render_lookup_result(ticket, error: str | None) -> str:
|
def render_lookup_result(ticket, error: str | None) -> str:
|
||||||
"""Render ticket lookup result via data extraction + sx defcomp."""
|
"""Render ticket lookup result: error div or ticket info card."""
|
||||||
from quart import url_for
|
from quart import url_for
|
||||||
from shared.browser.app.csrf import generate_csrf_token
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
|
||||||
@@ -195,21 +261,38 @@ def render_lookup_result(ticket, error: str | None) -> str:
|
|||||||
state = getattr(ticket, "state", "")
|
state = getattr(ticket, "state", "")
|
||||||
code = ticket.code
|
code = ticket.code
|
||||||
checked_in_at = getattr(ticket, "checked_in_at", None)
|
checked_in_at = getattr(ticket, "checked_in_at", None)
|
||||||
cal = getattr(entry, "calendar", None) if entry else None
|
csrf = generate_csrf_token()
|
||||||
|
|
||||||
checkin_url = None
|
# Info section
|
||||||
|
info_html = sx_call("events-lookup-info",
|
||||||
|
entry_name=entry.name if entry else "Unknown event")
|
||||||
|
if tt:
|
||||||
|
info_html += sx_call("events-lookup-type", type_name=tt.name)
|
||||||
|
if entry and entry.start_at:
|
||||||
|
info_html += sx_call("events-lookup-date",
|
||||||
|
date_str=entry.start_at.strftime("%A, %B %d, %Y at %H:%M"))
|
||||||
|
cal = getattr(entry, "calendar", None) if entry else None
|
||||||
|
if cal:
|
||||||
|
info_html += sx_call("events-lookup-cal", cal_name=cal.name)
|
||||||
|
info_html += sx_call("events-lookup-status",
|
||||||
|
badge=_ticket_state_badge_html(state), code=code)
|
||||||
|
if checked_in_at:
|
||||||
|
info_html += sx_call("events-lookup-checkin-time",
|
||||||
|
date_str=checked_in_at.strftime("%B %d, %Y at %H:%M"))
|
||||||
|
|
||||||
|
# Action area
|
||||||
|
action_html = ""
|
||||||
if state in ("confirmed", "reserved"):
|
if state in ("confirmed", "reserved"):
|
||||||
checkin_url = url_for("ticket_admin.do_checkin", code=code)
|
checkin_url = url_for("ticket_admin.do_checkin", code=code)
|
||||||
|
action_html = sx_call("events-lookup-checkin-btn",
|
||||||
|
checkin_url=checkin_url, code=code, csrf=csrf)
|
||||||
|
elif state == "checked_in":
|
||||||
|
action_html = sx_call("events-lookup-checked-in")
|
||||||
|
elif state == "cancelled":
|
||||||
|
action_html = sx_call("events-lookup-cancelled")
|
||||||
|
|
||||||
return sx_call("events-lookup-result-from-data",
|
return sx_call("events-lookup-card",
|
||||||
entry_name=entry.name if entry else "Unknown event",
|
info=SxExpr(info_html), code=code, action=SxExpr(action_html))
|
||||||
type_name=tt.name if tt else None,
|
|
||||||
date_str=entry.start_at.strftime("%A, %B %d, %Y at %H:%M") if entry and entry.start_at else None,
|
|
||||||
cal_name=cal.name if cal else None,
|
|
||||||
state=state, code=code,
|
|
||||||
checked_in_str=checked_in_at.strftime("%B %d, %Y at %H:%M") if checked_in_at else None,
|
|
||||||
checkin_url=checkin_url,
|
|
||||||
csrf=generate_csrf_token())
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -217,7 +300,7 @@ def render_lookup_result(ticket, error: str | None) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def render_entry_tickets_admin(entry, tickets: list) -> str:
|
def render_entry_tickets_admin(entry, tickets: list) -> str:
|
||||||
"""Render admin ticket table via data extraction + sx defcomp."""
|
"""Render admin ticket table for a specific entry."""
|
||||||
from quart import url_for
|
from quart import url_for
|
||||||
from shared.browser.app.csrf import generate_csrf_token
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
csrf = generate_csrf_token()
|
csrf = generate_csrf_token()
|
||||||
@@ -225,29 +308,39 @@ def render_entry_tickets_admin(entry, tickets: list) -> str:
|
|||||||
count = len(tickets)
|
count = len(tickets)
|
||||||
suffix = "s" if count != 1 else ""
|
suffix = "s" if count != 1 else ""
|
||||||
|
|
||||||
ticket_data = []
|
rows_html = ""
|
||||||
for ticket in tickets:
|
for ticket in tickets:
|
||||||
tt = getattr(ticket, "ticket_type", None)
|
tt = getattr(ticket, "ticket_type", None)
|
||||||
state = getattr(ticket, "state", "")
|
state = getattr(ticket, "state", "")
|
||||||
code = ticket.code
|
code = ticket.code
|
||||||
checked_in_at = getattr(ticket, "checked_in_at", None)
|
checked_in_at = getattr(ticket, "checked_in_at", None)
|
||||||
checkin_url = None
|
|
||||||
|
action_html = ""
|
||||||
if state in ("confirmed", "reserved"):
|
if state in ("confirmed", "reserved"):
|
||||||
checkin_url = url_for("ticket_admin.do_checkin", code=code)
|
checkin_url = url_for("ticket_admin.do_checkin", code=code)
|
||||||
ticket_data.append({
|
action_html = sx_call("events-entry-tickets-admin-checkin",
|
||||||
"code": code,
|
checkin_url=checkin_url, code=code, csrf=csrf)
|
||||||
"code-short": code[:12] + "...",
|
elif state == "checked_in":
|
||||||
"type-name": tt.name if tt else "\u2014",
|
t_str = checked_in_at.strftime("%H:%M") if checked_in_at else ""
|
||||||
"state": state,
|
action_html = sx_call("events-ticket-admin-checked-in",
|
||||||
"checkin-url": checkin_url,
|
time_str=t_str)
|
||||||
"checked-in-time": checked_in_at.strftime("%H:%M") if checked_in_at else None,
|
|
||||||
})
|
|
||||||
|
|
||||||
return sx_call("events-entry-tickets-admin-from-data",
|
rows_html += sx_call("events-entry-tickets-admin-row",
|
||||||
|
code=code, code_short=code[:12] + "...",
|
||||||
|
type_name=tt.name if tt else "\u2014",
|
||||||
|
badge=_ticket_state_badge_html(state),
|
||||||
|
action=SxExpr(action_html))
|
||||||
|
|
||||||
|
if tickets:
|
||||||
|
body_html = sx_call("events-entry-tickets-admin-table",
|
||||||
|
rows=SxExpr(rows_html))
|
||||||
|
else:
|
||||||
|
body_html = sx_call("events-entry-tickets-admin-empty")
|
||||||
|
|
||||||
|
return sx_call("events-entry-tickets-admin-panel",
|
||||||
entry_name=entry.name,
|
entry_name=entry.name,
|
||||||
count_label=f"{count} ticket{suffix}",
|
count_label=f"{count} ticket{suffix}",
|
||||||
tickets=ticket_data or None,
|
body=body_html)
|
||||||
csrf=csrf)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -290,7 +383,7 @@ def render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def render_ticket_types_table(ticket_types, entry, calendar, day, month, year) -> str:
|
def render_ticket_types_table(ticket_types, entry, calendar, day, month, year) -> str:
|
||||||
"""Render ticket types table via data extraction + sx defcomp."""
|
"""Render ticket types table with rows and add button."""
|
||||||
from quart import url_for, g
|
from quart import url_for, g
|
||||||
from shared.browser.app.csrf import generate_csrf_token
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
csrf = generate_csrf_token()
|
csrf = generate_csrf_token()
|
||||||
@@ -304,38 +397,40 @@ def render_ticket_types_table(ticket_types, entry, calendar, day, month, year) -
|
|||||||
cal_slug = getattr(calendar, "slug", "")
|
cal_slug = getattr(calendar, "slug", "")
|
||||||
eid = entry.id
|
eid = entry.id
|
||||||
|
|
||||||
types_data = []
|
rows_html = ""
|
||||||
if ticket_types:
|
if ticket_types:
|
||||||
for tt in ticket_types:
|
for tt in ticket_types:
|
||||||
|
tt_href = url_for(
|
||||||
|
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get",
|
||||||
|
calendar_slug=cal_slug, year=year, month=month, day=day,
|
||||||
|
entry_id=eid, ticket_type_id=tt.id,
|
||||||
|
)
|
||||||
|
del_url = url_for(
|
||||||
|
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete",
|
||||||
|
calendar_slug=cal_slug, year=year, month=month, day=day,
|
||||||
|
entry_id=eid, ticket_type_id=tt.id,
|
||||||
|
)
|
||||||
cost = getattr(tt, "cost", None)
|
cost = getattr(tt, "cost", None)
|
||||||
types_data.append({
|
cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
|
||||||
"tt-href": url_for(
|
|
||||||
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get",
|
rows_html += sx_call("events-ticket-types-row",
|
||||||
calendar_slug=cal_slug, year=year, month=month, day=day,
|
tr_cls=tr_cls, tt_href=tt_href,
|
||||||
entry_id=eid, ticket_type_id=tt.id,
|
pill_cls=pill_cls, hx_select=hx_select,
|
||||||
),
|
tt_name=tt.name, cost_str=cost_str,
|
||||||
"tt-name": tt.name,
|
count=str(tt.count), action_btn=action_btn,
|
||||||
"cost-str": f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00",
|
del_url=del_url,
|
||||||
"count": str(tt.count),
|
csrf_hdr={"X-CSRFToken": csrf})
|
||||||
"del-url": url_for(
|
else:
|
||||||
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete",
|
rows_html = sx_call("events-ticket-types-empty-row")
|
||||||
calendar_slug=cal_slug, year=year, month=month, day=day,
|
|
||||||
entry_id=eid, ticket_type_id=tt.id,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
add_url = url_for(
|
add_url = url_for(
|
||||||
"calendar.day.calendar_entries.calendar_entry.ticket_types.add_form",
|
"calendar.day.calendar_entries.calendar_entry.ticket_types.add_form",
|
||||||
calendar_slug=cal_slug, entry_id=eid, year=year, month=month, day=day,
|
calendar_slug=cal_slug, entry_id=eid, year=year, month=month, day=day,
|
||||||
)
|
)
|
||||||
|
|
||||||
return sx_call("events-ticket-types-table-from-data",
|
return sx_call("events-ticket-types-table",
|
||||||
list_container=list_container,
|
list_container=list_container, rows=SxExpr(rows_html),
|
||||||
ticket_types=types_data or None,
|
action_btn=action_btn, add_url=add_url)
|
||||||
action_btn=action_btn, add_url=add_url,
|
|
||||||
tr_cls=tr_cls, pill_cls=pill_cls,
|
|
||||||
hx_select=hx_select,
|
|
||||||
csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}')
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -343,22 +438,34 @@ def render_ticket_types_table(ticket_types, entry, calendar, day, month, year) -
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def render_buy_result(entry, created_tickets, remaining, cart_count) -> str:
|
def render_buy_result(entry, created_tickets, remaining, cart_count) -> str:
|
||||||
"""Render buy result card with OOB cart icon — single response component."""
|
"""Render buy result card with created tickets + OOB cart icon."""
|
||||||
from quart import url_for
|
from quart import url_for
|
||||||
|
|
||||||
tickets = [
|
cart_html = _cart_icon_oob(cart_count)
|
||||||
{"href": url_for("defpage_ticket_detail", code=t.code),
|
|
||||||
"code_short": t.code[:12] + "..."}
|
|
||||||
for t in created_tickets
|
|
||||||
]
|
|
||||||
cart_ctx = _cart_icon_ctx(cart_count)
|
|
||||||
|
|
||||||
return sx_call("events-buy-response",
|
count = len(created_tickets)
|
||||||
entry_id=str(entry.id),
|
suffix = "s" if count != 1 else ""
|
||||||
tickets=tickets,
|
|
||||||
remaining=remaining,
|
tickets_html = ""
|
||||||
my_tickets_href=url_for("defpage_my_tickets"),
|
for ticket in created_tickets:
|
||||||
**cart_ctx)
|
href = url_for("defpage_ticket_detail", code=ticket.code)
|
||||||
|
tickets_html += sx_call("events-buy-result-ticket",
|
||||||
|
href=href, code_short=ticket.code[:12] + "...")
|
||||||
|
|
||||||
|
remaining_html = ""
|
||||||
|
if remaining is not None:
|
||||||
|
r_suffix = "s" if remaining != 1 else ""
|
||||||
|
remaining_html = sx_call("events-buy-result-remaining",
|
||||||
|
text=f"{remaining} ticket{r_suffix} remaining")
|
||||||
|
|
||||||
|
my_href = url_for("defpage_my_tickets")
|
||||||
|
|
||||||
|
return cart_html + sx_call("events-buy-result",
|
||||||
|
entry_id=str(entry.id),
|
||||||
|
count_label=f"{count} ticket{suffix} reserved",
|
||||||
|
tickets=SxExpr(tickets_html),
|
||||||
|
remaining=SxExpr(remaining_html),
|
||||||
|
my_tickets_href=my_href)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -367,41 +474,90 @@ def render_buy_result(entry, created_tickets, remaining, cart_count) -> str:
|
|||||||
|
|
||||||
def render_buy_form(entry, ticket_remaining, ticket_sold_count,
|
def render_buy_form(entry, ticket_remaining, ticket_sold_count,
|
||||||
user_ticket_count, user_ticket_counts_by_type) -> str:
|
user_ticket_count, user_ticket_counts_by_type) -> str:
|
||||||
"""Render the ticket buy/adjust form — data only, .sx does layout."""
|
"""Render the ticket buy/adjust form with +/- controls."""
|
||||||
from quart import url_for
|
from quart import url_for
|
||||||
from shared.browser.app.csrf import generate_csrf_token
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
|
||||||
|
eid = entry.id
|
||||||
|
eid_s = str(eid)
|
||||||
tp = getattr(entry, "ticket_price", None)
|
tp = getattr(entry, "ticket_price", None)
|
||||||
|
state = getattr(entry, "state", "")
|
||||||
|
ticket_types = getattr(entry, "ticket_types", None) or []
|
||||||
|
|
||||||
if tp is None:
|
if tp is None:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
ticket_types_orm = getattr(entry, "ticket_types", None) or []
|
if state != "confirmed":
|
||||||
active_types = [tt for tt in ticket_types_orm if getattr(tt, "deleted_at", None) is None]
|
return sx_call("events-buy-not-confirmed", entry_id=eid_s)
|
||||||
|
|
||||||
types_data = [
|
adjust_url = url_for("tickets.adjust_quantity")
|
||||||
{"id": tt.id, "name": tt.name,
|
target = f"#ticket-buy-{eid}"
|
||||||
"cost_str": f"\u00a3{tt.cost:.2f}" if tt.cost is not None else "\u00a30.00"}
|
|
||||||
for tt in active_types
|
|
||||||
]
|
|
||||||
|
|
||||||
# String keys so .sx can look up via (get counts (str id))
|
# Info line
|
||||||
counts_by_type = {}
|
info_html = ""
|
||||||
if user_ticket_counts_by_type:
|
info_items = ""
|
||||||
counts_by_type = {str(k): v for k, v in user_ticket_counts_by_type.items()}
|
if ticket_sold_count:
|
||||||
|
info_items += sx_call("events-buy-info-sold",
|
||||||
|
count=str(ticket_sold_count))
|
||||||
|
if ticket_remaining is not None:
|
||||||
|
info_items += sx_call("events-buy-info-remaining",
|
||||||
|
count=str(ticket_remaining))
|
||||||
|
if user_ticket_count:
|
||||||
|
info_items += sx_call("events-buy-info-basket",
|
||||||
|
count=str(user_ticket_count))
|
||||||
|
if info_items:
|
||||||
|
info_html = sx_call("events-buy-info-bar", items=SxExpr(info_items))
|
||||||
|
|
||||||
return sx_call("events-buy-form",
|
active_types = [tt for tt in ticket_types if getattr(tt, "deleted_at", None) is None]
|
||||||
entry_id=entry.id,
|
|
||||||
state=getattr(entry, "state", ""),
|
body_html = ""
|
||||||
price_str=f"\u00a3{tp:.2f}",
|
if active_types:
|
||||||
adjust_url=url_for("tickets.adjust_quantity"),
|
type_items = ""
|
||||||
csrf=generate_csrf_token(),
|
for tt in active_types:
|
||||||
my_tickets_href=url_for("defpage_my_tickets"),
|
type_count = user_ticket_counts_by_type.get(tt.id, 0) if user_ticket_counts_by_type else 0
|
||||||
info_sold=ticket_sold_count or None,
|
cost_str = f"\u00a3{tt.cost:.2f}" if tt.cost is not None else "\u00a30.00"
|
||||||
info_remaining=ticket_remaining,
|
type_items += sx_call("events-buy-type-item",
|
||||||
info_basket=user_ticket_count or None,
|
type_name=tt.name, cost_str=cost_str,
|
||||||
ticket_types=types_data if types_data else None,
|
adjust_controls=_ticket_adjust_controls(csrf, adjust_url, target, eid, type_count, ticket_type_id=tt.id))
|
||||||
user_ticket_counts_by_type=counts_by_type if counts_by_type else None,
|
body_html = sx_call("events-buy-types-wrapper", items=SxExpr(type_items))
|
||||||
user_ticket_count=user_ticket_count or 0)
|
else:
|
||||||
|
qty = user_ticket_count or 0
|
||||||
|
body_html = sx_call("events-buy-default",
|
||||||
|
price_str=f"\u00a3{tp:.2f}",
|
||||||
|
adjust_controls=_ticket_adjust_controls(csrf, adjust_url, target, eid, qty))
|
||||||
|
|
||||||
|
return sx_call("events-buy-panel",
|
||||||
|
entry_id=eid_s, info=SxExpr(info_html), body=body_html)
|
||||||
|
|
||||||
|
|
||||||
|
def _ticket_adjust_controls(csrf, adjust_url, target, entry_id, count, *, ticket_type_id=None):
|
||||||
|
"""Render +/- ticket controls for buy form."""
|
||||||
|
from quart import url_for
|
||||||
|
|
||||||
|
tt_html = sx_call("events-adjust-tt-hidden",
|
||||||
|
ticket_type_id=str(ticket_type_id)) if ticket_type_id else ""
|
||||||
|
eid_s = str(entry_id)
|
||||||
|
|
||||||
|
def _adj_form(count_val, btn_html, *, extra_cls=""):
|
||||||
|
return sx_call("events-adjust-form",
|
||||||
|
adjust_url=adjust_url, target=target,
|
||||||
|
extra_cls=extra_cls, csrf=csrf,
|
||||||
|
entry_id=eid_s, tt=tt_html or None,
|
||||||
|
count_val=str(count_val), btn=btn_html)
|
||||||
|
|
||||||
|
if count == 0:
|
||||||
|
return _adj_form(1, sx_call("events-adjust-cart-plus"),
|
||||||
|
extra_cls="flex items-center")
|
||||||
|
|
||||||
|
my_tickets_href = url_for("defpage_my_tickets")
|
||||||
|
minus = _adj_form(count - 1, sx_call("events-adjust-minus"))
|
||||||
|
cart_icon = sx_call("events-adjust-cart-icon",
|
||||||
|
href=my_tickets_href, count=str(count))
|
||||||
|
plus = _adj_form(count + 1, sx_call("events-adjust-plus"))
|
||||||
|
|
||||||
|
return sx_call("events-adjust-controls",
|
||||||
|
minus=minus, cart_icon=cart_icon, plus=plus)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -411,44 +567,13 @@ def render_buy_form(entry, ticket_remaining, ticket_sold_count,
|
|||||||
def render_adjust_response(entry, ticket_remaining, ticket_sold_count,
|
def render_adjust_response(entry, ticket_remaining, ticket_sold_count,
|
||||||
user_ticket_count, user_ticket_counts_by_type,
|
user_ticket_count, user_ticket_counts_by_type,
|
||||||
cart_count) -> str:
|
cart_count) -> str:
|
||||||
"""Render ticket adjust response — single response component with OOB cart."""
|
"""Render ticket adjust response: OOB cart icon + buy form."""
|
||||||
from quart import url_for
|
cart_html = _cart_icon_oob(cart_count)
|
||||||
from shared.browser.app.csrf import generate_csrf_token
|
form_html = render_buy_form(
|
||||||
|
entry, ticket_remaining, ticket_sold_count,
|
||||||
tp = getattr(entry, "ticket_price", None)
|
user_ticket_count, user_ticket_counts_by_type,
|
||||||
if tp is None:
|
)
|
||||||
return ""
|
return cart_html + form_html
|
||||||
|
|
||||||
ticket_types_orm = getattr(entry, "ticket_types", None) or []
|
|
||||||
active_types = [tt for tt in ticket_types_orm if getattr(tt, "deleted_at", None) is None]
|
|
||||||
|
|
||||||
types_data = [
|
|
||||||
{"id": tt.id, "name": tt.name,
|
|
||||||
"cost_str": f"\u00a3{tt.cost:.2f}" if tt.cost is not None else "\u00a30.00"}
|
|
||||||
for tt in active_types
|
|
||||||
]
|
|
||||||
|
|
||||||
# String keys so .sx can look up via (get counts (str id))
|
|
||||||
counts_by_type = {}
|
|
||||||
if user_ticket_counts_by_type:
|
|
||||||
counts_by_type = {str(k): v for k, v in user_ticket_counts_by_type.items()}
|
|
||||||
|
|
||||||
cart_ctx = _cart_icon_ctx(cart_count)
|
|
||||||
|
|
||||||
return sx_call("events-adjust-response",
|
|
||||||
entry_id=entry.id,
|
|
||||||
state=getattr(entry, "state", ""),
|
|
||||||
price_str=f"\u00a3{tp:.2f}",
|
|
||||||
adjust_url=url_for("tickets.adjust_quantity"),
|
|
||||||
csrf=generate_csrf_token(),
|
|
||||||
my_tickets_href=url_for("defpage_my_tickets"),
|
|
||||||
info_sold=ticket_sold_count or None,
|
|
||||||
info_remaining=ticket_remaining,
|
|
||||||
info_basket=user_ticket_count or None,
|
|
||||||
ticket_types=types_data if types_data else None,
|
|
||||||
user_ticket_counts_by_type=counts_by_type if counts_by_type else None,
|
|
||||||
user_ticket_count=user_ticket_count or 0,
|
|
||||||
**cart_ctx)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -574,7 +699,7 @@ def render_ticket_type_add_form(entry, calendar, day, month, year) -> str:
|
|||||||
cancel_url = url_for("calendar.day.calendar_entries.calendar_entry.ticket_types.add_button",
|
cancel_url = url_for("calendar.day.calendar_entries.calendar_entry.ticket_types.add_button",
|
||||||
calendar_slug=cal_slug, entry_id=entry.id,
|
calendar_slug=cal_slug, entry_id=entry.id,
|
||||||
year=year, month=month, day=day)
|
year=year, month=month, day=day)
|
||||||
csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}'
|
csrf_hdr = {"X-CSRFToken": csrf}
|
||||||
|
|
||||||
return sx_call("events-ticket-type-add-form",
|
return sx_call("events-ticket-type-add-form",
|
||||||
post_url=post_url, csrf=csrf_hdr,
|
post_url=post_url, csrf=csrf_hdr,
|
||||||
|
|||||||
@@ -90,3 +90,16 @@
|
|||||||
(url-for "social.actor_timeline_page"
|
(url-for "social.actor_timeline_page"
|
||||||
:id (get remote-actor "id")
|
:id (get remote-actor "id")
|
||||||
:before (get (last items) "before_cursor")))))))
|
: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)))))
|
||||||
|
|||||||
@@ -40,6 +40,47 @@
|
|||||||
summary)
|
summary)
|
||||||
button))
|
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)
|
(defcomp ~federation-search-info (&key cls text)
|
||||||
(p :class cls text))
|
(p :class cls text))
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,65 @@
|
|||||||
compose)
|
compose)
|
||||||
(div :id "timeline" timeline))
|
(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 ---
|
;; --- Compose ---
|
||||||
|
|
||||||
(defcomp ~federation-compose-reply (&key reply-to)
|
(defcomp ~federation-compose-reply (&key reply-to)
|
||||||
|
|||||||
@@ -15,7 +15,13 @@
|
|||||||
:title (get __mctx "market-title")
|
:title (get __mctx "market-title")
|
||||||
:top-slug (get __mctx "top-slug")
|
:top-slug (get __mctx "top-slug")
|
||||||
:sub-div (get __mctx "sub-slug"))
|
:sub-div (get __mctx "sub-slug"))
|
||||||
:nav (get __mctx "desktop-nav")
|
:nav (~market-desktop-nav-from-data
|
||||||
|
:categories (get __mctx "categories")
|
||||||
|
:hx-select (get __mctx "hx-select")
|
||||||
|
:select-colours (get __mctx "select-colours")
|
||||||
|
:all-href (get __mctx "all-href")
|
||||||
|
:all-active (get __mctx "all-active")
|
||||||
|
:admin-href (get __mctx "admin-href"))
|
||||||
:child-id "market-header-child"
|
:child-id "market-header-child"
|
||||||
:oob (unquote oob)))))
|
:oob (unquote oob)))))
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ async def _h_page_admin_data(slug=None, **kw) -> dict:
|
|||||||
m_name = getattr(m, "name", "") or (m.get("name", "") if isinstance(m, dict) else "")
|
m_name = getattr(m, "name", "") or (m.get("name", "") if isinstance(m, dict) else "")
|
||||||
href = prefix + f"/{post_slug}/{m_slug}/"
|
href = prefix + f"/{post_slug}/{m_slug}/"
|
||||||
del_url = url_for("page_admin.delete_market", market_slug=m_slug)
|
del_url = url_for("page_admin.delete_market", market_slug=m_slug)
|
||||||
csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}'
|
csrf_hdr = {"X-CSRFToken": csrf}
|
||||||
markets.append({
|
markets.append({
|
||||||
"href": href, "name": m_name, "slug": m_slug,
|
"href": href, "name": m_name, "slug": m_slug,
|
||||||
"del-url": del_url, "csrf-hdr": csrf_hdr,
|
"del-url": del_url, "csrf-hdr": csrf_hdr,
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ def render_like_toggle_button(slug: str, liked: bool, *,
|
|||||||
return sx_call(
|
return sx_call(
|
||||||
"market-like-toggle-button",
|
"market-like-toggle-button",
|
||||||
colour=colour, action=like_url,
|
colour=colour, action=like_url,
|
||||||
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
|
hx_headers={"X-CSRFToken": csrf},
|
||||||
label=label, icon_cls=icon,
|
label=label, icon_cls=icon,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,17 @@
|
|||||||
(h2 :class "text-base sm:text-lg font-semibold" "Event tickets in this order")
|
(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)))
|
(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)
|
(defcomp ~checkout-return-content (&key summary items calendar tickets status-message)
|
||||||
(div :class "max-w-full px-1 py-1"
|
(div :class "max-w-full px-1 py-1"
|
||||||
(when summary
|
(when summary
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ def create_base_app(
|
|||||||
context_fn: Callable[[], Awaitable[dict]] | None = None,
|
context_fn: Callable[[], Awaitable[dict]] | None = None,
|
||||||
before_request_fns: Sequence[Callable[[], Awaitable[None]]] | None = None,
|
before_request_fns: Sequence[Callable[[], Awaitable[None]]] | None = None,
|
||||||
domain_services_fn: Callable[[], None] | None = None,
|
domain_services_fn: Callable[[], None] | None = None,
|
||||||
|
no_oauth: bool = False,
|
||||||
) -> Quart:
|
) -> Quart:
|
||||||
"""
|
"""
|
||||||
Create a Quart app with shared infrastructure.
|
Create a Quart app with shared infrastructure.
|
||||||
@@ -156,7 +157,7 @@ def create_base_app(
|
|||||||
# Auto-register OAuth client blueprint for non-account apps
|
# Auto-register OAuth client blueprint for non-account apps
|
||||||
# (account is the OAuth authorization server)
|
# (account is the OAuth authorization server)
|
||||||
_NO_OAUTH = {"account"}
|
_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
|
from shared.infrastructure.oauth import create_oauth_blueprint
|
||||||
app.register_blueprint(create_oauth_blueprint(name))
|
app.register_blueprint(create_oauth_blueprint(name))
|
||||||
|
|
||||||
@@ -205,7 +206,7 @@ def create_base_app(
|
|||||||
|
|
||||||
# Auth state check via grant verification + silent OAuth handshake
|
# Auth state check via grant verification + silent OAuth handshake
|
||||||
# MUST run before _load_user so stale sessions are cleared first
|
# 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
|
@app.before_request
|
||||||
async def _check_auth_state():
|
async def _check_auth_state():
|
||||||
from quart import session as qs
|
from quart import session as qs
|
||||||
@@ -341,6 +342,14 @@ def create_base_app(
|
|||||||
response.headers["HX-Preserve-Search"] = value
|
response.headers["HX-Preserve-Search"] = value
|
||||||
return response
|
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 ---
|
# --- context processor ---
|
||||||
if context_fn is not None:
|
if context_fn is not None:
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
|
|||||||
3130
shared/static/scripts/sx-browser.js
Normal file
3130
shared/static/scripts/sx-browser.js
Normal file
File diff suppressed because it is too large
Load Diff
3207
shared/static/scripts/sx-ref.js
Normal file
3207
shared/static/scripts/sx-ref.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -251,6 +251,18 @@
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Serialize a JS object as SX dict {:key "val" ...} for attribute values. */
|
||||||
|
function _serializeDict(obj) {
|
||||||
|
var parts = [];
|
||||||
|
for (var k in obj) {
|
||||||
|
if (!obj.hasOwnProperty(k)) continue;
|
||||||
|
var v = obj[k];
|
||||||
|
var vs = typeof v === "string" ? '"' + v.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"' : String(v);
|
||||||
|
parts.push(":" + k + " " + vs);
|
||||||
|
}
|
||||||
|
return "{" + parts.join(" ") + "}";
|
||||||
|
}
|
||||||
|
|
||||||
// --- Primitives ---
|
// --- Primitives ---
|
||||||
|
|
||||||
var PRIMITIVES = {};
|
var PRIMITIVES = {};
|
||||||
@@ -1274,8 +1286,8 @@
|
|||||||
kwargs[args[i].name] = sxEval(v, env);
|
kwargs[args[i].name] = sxEval(v, env);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Data arrays, dicts, etc — pass through as-is
|
// Data arrays, dicts, etc — evaluate in caller's env
|
||||||
kwargs[args[i].name] = v;
|
kwargs[args[i].name] = sxEval(v, env);
|
||||||
}
|
}
|
||||||
i += 2;
|
i += 2;
|
||||||
} else {
|
} else {
|
||||||
@@ -1420,7 +1432,7 @@
|
|||||||
} else if (attrVal === true) {
|
} else if (attrVal === true) {
|
||||||
el.setAttribute(attrName, "");
|
el.setAttribute(attrName, "");
|
||||||
} else {
|
} else {
|
||||||
el.setAttribute(attrName, String(attrVal));
|
el.setAttribute(attrName, typeof attrVal === "object" && attrVal !== null && !Array.isArray(attrVal) ? _serializeDict(attrVal) : String(attrVal));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Child
|
// Child
|
||||||
@@ -1851,7 +1863,7 @@
|
|||||||
cancelButtonText: "Cancel"
|
cancelButtonText: "Cancel"
|
||||||
}).then(function (result) {
|
}).then(function (result) {
|
||||||
if (!result.isConfirmed) return;
|
if (!result.isConfirmed) return;
|
||||||
return _doFetch(el, method, url, extraParams);
|
return _doFetch(el, verbInfo, method, url, extraParams);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!window.confirm(confirmMsg)) return Promise.resolve();
|
if (!window.confirm(confirmMsg)) return Promise.resolve();
|
||||||
@@ -1866,10 +1878,10 @@
|
|||||||
extraParams.promptValue = promptVal;
|
extraParams.promptValue = promptVal;
|
||||||
}
|
}
|
||||||
|
|
||||||
return _doFetch(el, method, url, extraParams);
|
return _doFetch(el, verbInfo, method, url, extraParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _doFetch(el, method, url, extraParams) {
|
function _doFetch(el, verbInfo, method, url, extraParams) {
|
||||||
// sx-sync: abort previous
|
// sx-sync: abort previous
|
||||||
var sync = el.getAttribute("sx-sync");
|
var sync = el.getAttribute("sx-sync");
|
||||||
if (sync && sync.indexOf("replace") >= 0) abortPrevious(el);
|
if (sync && sync.indexOf("replace") >= 0) abortPrevious(el);
|
||||||
@@ -1895,12 +1907,12 @@
|
|||||||
var cssHeader = _getSxCssHeader();
|
var cssHeader = _getSxCssHeader();
|
||||||
if (cssHeader) headers["SX-Css"] = cssHeader;
|
if (cssHeader) headers["SX-Css"] = cssHeader;
|
||||||
|
|
||||||
// Extra headers from sx-headers
|
// Extra headers from sx-headers (SX dict {:key "val"} or JSON)
|
||||||
var extraH = el.getAttribute("sx-headers");
|
var extraH = el.getAttribute("sx-headers");
|
||||||
if (extraH) {
|
if (extraH) {
|
||||||
try {
|
try {
|
||||||
var parsed = JSON.parse(extraH);
|
var parsed = extraH.charAt(0) === "{" && extraH.charAt(1) === ":" ? parse(extraH) : JSON.parse(extraH);
|
||||||
for (var k in parsed) headers[k] = parsed[k];
|
for (var k in parsed) headers[k] = String(parsed[k]);
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1974,7 +1986,7 @@
|
|||||||
var valsAttr = el.getAttribute("sx-vals");
|
var valsAttr = el.getAttribute("sx-vals");
|
||||||
if (valsAttr) {
|
if (valsAttr) {
|
||||||
try {
|
try {
|
||||||
var vals = JSON.parse(valsAttr);
|
var vals = valsAttr.charAt(0) === "{" && valsAttr.charAt(1) === ":" ? parse(valsAttr) : JSON.parse(valsAttr);
|
||||||
if (method === "GET") {
|
if (method === "GET") {
|
||||||
for (var vk in vals) {
|
for (var vk in vals) {
|
||||||
url += (url.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(vk) + "=" + encodeURIComponent(vals[vk]);
|
url += (url.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(vk) + "=" + encodeURIComponent(vals[vk]);
|
||||||
|
|||||||
@@ -41,10 +41,18 @@ Usage::
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextvars
|
||||||
import inspect
|
import inspect
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .types import Component, Keyword, Lambda, Macro, NIL, StyleValue, Symbol
|
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 .evaluator import _expand_macro, EvalError
|
||||||
from .primitives import _PRIMITIVES
|
from .primitives import _PRIMITIVES
|
||||||
from .primitives_io import IO_PRIMITIVES, RequestContext, execute_io
|
from .primitives_io import IO_PRIMITIVES, RequestContext, execute_io
|
||||||
@@ -1058,6 +1066,24 @@ async def async_eval_slot_to_sx(
|
|||||||
"""
|
"""
|
||||||
if ctx is None:
|
if ctx is None:
|
||||||
ctx = RequestContext()
|
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 expr is a component call, expand it through _aser
|
||||||
if isinstance(expr, list) and expr:
|
if isinstance(expr, list) and expr:
|
||||||
head = expr[0]
|
head = expr[0]
|
||||||
@@ -1159,12 +1185,15 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
|
|||||||
if name.startswith("html:"):
|
if name.startswith("html:"):
|
||||||
return await _aser_call(name[5:], expr[1:], env, ctx)
|
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("~"):
|
if name.startswith("~"):
|
||||||
val = env.get(name)
|
val = env.get(name)
|
||||||
if isinstance(val, Macro):
|
if isinstance(val, Macro):
|
||||||
expanded = _expand_macro(val, expr[1:], env)
|
expanded = _expand_macro(val, expr[1:], env)
|
||||||
return await _aser(expanded, env, ctx)
|
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)
|
return await _aser_call(name, expr[1:], env, ctx)
|
||||||
|
|
||||||
# Serialize-mode special/HO forms (checked BEFORE HTML_TAGS
|
# Serialize-mode special/HO forms (checked BEFORE HTML_TAGS
|
||||||
|
|||||||
@@ -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-styles" data-hash="{styles_hash}">{styles_json}</script>
|
||||||
<script type="text/sx" data-components data-hash="{component_hash}">{component_defs}</script>
|
<script type="text/sx" data-components data-hash="{component_hash}">{component_defs}</script>
|
||||||
<script type="text/sx" data-mount="body">{page_sx}</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>
|
<script src="{asset_url}/scripts/body.js?v={body_js_hash}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
@@ -627,8 +627,9 @@ def sx_page(ctx: dict, page_sx: str, *,
|
|||||||
component_hash = get_component_hash()
|
component_hash = get_component_hash()
|
||||||
|
|
||||||
# Check if client already has this version cached (via cookie)
|
# Check if client already has this version cached (via cookie)
|
||||||
|
# In dev mode, always send full source so edits are visible immediately
|
||||||
client_hash = _get_sx_comp_cookie()
|
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
|
# Client has current components cached — send empty source
|
||||||
component_defs = ""
|
component_defs = ""
|
||||||
else:
|
else:
|
||||||
@@ -675,7 +676,7 @@ def sx_page(ctx: dict, page_sx: str, *,
|
|||||||
# Style dictionary for client-side css primitive
|
# Style dictionary for client-side css primitive
|
||||||
styles_hash = _get_style_dict_hash()
|
styles_hash = _get_style_dict_hash()
|
||||||
client_styles_hash = _get_sx_styles_cookie()
|
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
|
styles_json = "" # Client has cached version
|
||||||
else:
|
else:
|
||||||
styles_json = _build_style_dict_json()
|
styles_json = _build_style_dict_json()
|
||||||
@@ -692,7 +693,7 @@ def sx_page(ctx: dict, page_sx: str, *,
|
|||||||
page_sx=page_sx,
|
page_sx=page_sx,
|
||||||
sx_css=sx_css,
|
sx_css=sx_css,
|
||||||
sx_css_classes=sx_css_classes,
|
sx_css_classes=sx_css_classes,
|
||||||
sx_js_hash=_script_hash("sx.js"),
|
sx_js_hash=_script_hash("sx-browser.js"),
|
||||||
body_js_hash=_script_hash("body.js"),
|
body_js_hash=_script_hash("body.js"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ def _render(expr: Any, env: dict[str, Any]) -> str:
|
|||||||
return ""
|
return ""
|
||||||
return _render_list(expr, env)
|
return _render_list(expr, env)
|
||||||
|
|
||||||
# --- dict → skip (data, not renderable) -------------------------------
|
# --- dict → skip (data, not renderable as HTML content) -----------------
|
||||||
if isinstance(expr, dict):
|
if isinstance(expr, dict):
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -540,6 +540,9 @@ def _render_element(tag: str, args: list, env: dict[str, Any]) -> str:
|
|||||||
parts.append(f" {attr_name}")
|
parts.append(f" {attr_name}")
|
||||||
elif attr_val is True:
|
elif attr_val is True:
|
||||||
parts.append(f" {attr_name}")
|
parts.append(f" {attr_name}")
|
||||||
|
elif isinstance(attr_val, dict):
|
||||||
|
from .parser import serialize as _sx_serialize
|
||||||
|
parts.append(f' {attr_name}="{escape_attr(_sx_serialize(attr_val))}"')
|
||||||
else:
|
else:
|
||||||
parts.append(f' {attr_name}="{escape_attr(str(attr_val))}"')
|
parts.append(f' {attr_name}="{escape_attr(str(attr_val))}"')
|
||||||
parts.append(">")
|
parts.append(">")
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ from typing import Any
|
|||||||
|
|
||||||
from .jinja_bridge import sx
|
from .jinja_bridge import sx
|
||||||
|
|
||||||
SEARCH_HEADERS_MOBILE = '{"X-Origin":"search-mobile","X-Search":"true"}'
|
SEARCH_HEADERS_MOBILE = {"X-Origin": "search-mobile", "X-Search": "true"}
|
||||||
SEARCH_HEADERS_DESKTOP = '{"X-Origin":"search-desktop","X-Search":"true"}'
|
SEARCH_HEADERS_DESKTOP = {"X-Origin": "search-desktop", "X-Search": "true"}
|
||||||
|
|
||||||
|
|
||||||
def render_page(source: str, **kwargs: Any) -> str:
|
def render_page(source: str, **kwargs: Any) -> str:
|
||||||
|
|||||||
@@ -876,10 +876,10 @@ async def _io_events_ticket_type_ctx(
|
|||||||
async def _io_market_header_ctx(
|
async def _io_market_header_ctx(
|
||||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""``(market-header-ctx)`` → dict with market header values.
|
"""``(market-header-ctx)`` → dict with market header data.
|
||||||
|
|
||||||
Pre-builds desktop-nav and mobile-nav as SxExpr strings using
|
Returns plain data (categories list, hrefs, flags) for the
|
||||||
the existing Python helper functions in sxc.pages.layouts.
|
~market-header-auto macro. Mobile nav is pre-built as SxExpr.
|
||||||
"""
|
"""
|
||||||
from quart import g, url_for
|
from quart import g, url_for
|
||||||
from shared.config import config as get_config
|
from shared.config import config as get_config
|
||||||
@@ -916,9 +916,9 @@ async def _io_market_header_ctx(
|
|||||||
"category_label": "",
|
"category_label": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Pre-build nav using existing helper functions (lazy import from market service)
|
# Build header + mobile nav data via new data-driven helpers
|
||||||
from sxc.pages.layouts import _desktop_category_nav_sx, _mobile_nav_panel_sx
|
from sxc.pages.layouts import _market_header_data, _mobile_nav_panel_sx
|
||||||
desktop_nav = _desktop_category_nav_sx(mini_ctx, categories, "", "#main-panel")
|
header_data = _market_header_data(mini_ctx)
|
||||||
mobile_nav = _mobile_nav_panel_sx(mini_ctx)
|
mobile_nav = _mobile_nav_panel_sx(mini_ctx)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -926,7 +926,12 @@ async def _io_market_header_ctx(
|
|||||||
"link-href": link_href,
|
"link-href": link_href,
|
||||||
"top-slug": "",
|
"top-slug": "",
|
||||||
"sub-slug": "",
|
"sub-slug": "",
|
||||||
"desktop-nav": SxExpr(desktop_nav) if desktop_nav else "",
|
"categories": header_data.get("categories", []),
|
||||||
|
"hx-select": header_data.get("hx-select", "#main-panel"),
|
||||||
|
"select-colours": header_data.get("select-colours", ""),
|
||||||
|
"all-href": header_data.get("all-href", ""),
|
||||||
|
"all-active": header_data.get("all-active", False),
|
||||||
|
"admin-href": header_data.get("admin-href", ""),
|
||||||
"mobile-nav": SxExpr(mobile_nav) if mobile_nav else "",
|
"mobile-nav": SxExpr(mobile_nav) if mobile_nav else "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
469
shared/sx/ref/adapter-dom.sx
Normal file
469
shared/sx/ref/adapter-dom.sx
Normal 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
|
||||||
|
;; --------------------------------------------------------------------------
|
||||||
312
shared/sx/ref/adapter-html.sx
Normal file
312
shared/sx/ref/adapter-html.sx
Normal 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
147
shared/sx/ref/adapter-sx.sx
Normal 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
384
shared/sx/ref/boot.sx
Normal 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
|
||||||
|
;; --------------------------------------------------------------------------
|
||||||
2710
shared/sx/ref/bootstrap_js.py
Normal file
2710
shared/sx/ref/bootstrap_js.py
Normal file
File diff suppressed because it is too large
Load Diff
314
shared/sx/ref/cssx.sx
Normal file
314
shared/sx/ref/cssx.sx
Normal 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
664
shared/sx/ref/engine.sx
Normal 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
770
shared/sx/ref/eval.sx
Normal 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)
|
||||||
|
;; --------------------------------------------------------------------------
|
||||||
816
shared/sx/ref/orchestration.sx
Normal file
816
shared/sx/ref/orchestration.sx
Normal 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
378
shared/sx/ref/parser.sx
Normal 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
459
shared/sx/ref/primitives.sx
Normal 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
147
shared/sx/ref/render.sx
Normal 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
|
||||||
|
;; --------------------------------------------------------------------------
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
(h1 (or site-title ""))
|
(h1 (or site-title ""))
|
||||||
(when app-label
|
(when app-label
|
||||||
(span :class "text-lg text-white/80 font-normal" 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 nav-tree nav-tree)
|
||||||
(when auth-menu auth-menu)
|
(when auth-menu auth-menu)
|
||||||
(when nav-panel nav-panel)
|
(when nav-panel nav-panel)
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
(<>
|
(<>
|
||||||
(div :id id
|
(div :id id
|
||||||
:sx-swap-oob (if oob "outerHTML" nil)
|
: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"
|
(div :class "relative nav-group"
|
||||||
(a :href link-href
|
(a :href link-href
|
||||||
:sx-get (if external nil link-href)
|
:sx-get (if external nil link-href)
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
(when selected
|
(when selected
|
||||||
(span :class "text-lg text-white/80 font-normal" selected))))))
|
(span :class "text-lg text-white/80 font-normal" selected))))))
|
||||||
(when nav
|
(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)))
|
nav)))
|
||||||
(when (and child-id (not oob))
|
(when (and child-id (not oob))
|
||||||
(div :id child-id :class "flex flex-col w-full items-center"
|
(div :id child-id :class "flex flex-col w-full items-center"
|
||||||
|
|||||||
@@ -120,6 +120,57 @@
|
|||||||
(<> auth (~header-child-sx :id "auth-header-child" :inner
|
(<> auth (~header-child-sx :id "auth-header-child" :inner
|
||||||
(<> orders (~header-child-sx :id "orders-header-child" :inner order))))))
|
(<> 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
|
;; Checkout error screens
|
||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ COPY sx/ ./sx-app-tmp/
|
|||||||
RUN cp -r sx-app-tmp/app.py sx-app-tmp/path_setup.py \
|
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/bp sx-app-tmp/sxc sx-app-tmp/services \
|
||||||
sx-app-tmp/content sx-app-tmp/__init__.py ./ 2>/dev/null || true && \
|
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
|
rm -rf sx-app-tmp
|
||||||
|
|
||||||
# Sibling models for cross-domain SQLAlchemy imports
|
# Sibling models for cross-domain SQLAlchemy imports
|
||||||
|
|||||||
24
sx/app.py
24
sx/app.py
@@ -1,10 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import os
|
||||||
import path_setup # noqa: F401
|
import path_setup # noqa: F401
|
||||||
from shared.infrastructure.factory import create_base_app
|
|
||||||
|
|
||||||
from bp import register_pages
|
from bp import register_pages
|
||||||
from services import register_domain_services
|
from services import register_domain_services
|
||||||
|
|
||||||
|
SX_STANDALONE = os.getenv("SX_STANDALONE") == "true"
|
||||||
|
|
||||||
|
|
||||||
async def sx_docs_context() -> dict:
|
async def sx_docs_context() -> dict:
|
||||||
"""SX docs app context processor — fetches cross-service fragments."""
|
"""SX docs app context processor — fetches cross-service fragments."""
|
||||||
@@ -39,11 +41,29 @@ async def sx_docs_context() -> dict:
|
|||||||
return ctx
|
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":
|
def create_app() -> "Quart":
|
||||||
|
from shared.infrastructure.factory import create_base_app
|
||||||
|
|
||||||
|
extra_kw = {}
|
||||||
|
if SX_STANDALONE:
|
||||||
|
extra_kw["no_oauth"] = True
|
||||||
|
|
||||||
app = create_base_app(
|
app = create_base_app(
|
||||||
"sx",
|
"sx",
|
||||||
context_fn=sx_docs_context,
|
context_fn=sx_standalone_context if SX_STANDALONE else sx_docs_context,
|
||||||
domain_services_fn=register_domain_services,
|
domain_services_fn=register_domain_services,
|
||||||
|
**extra_kw,
|
||||||
)
|
)
|
||||||
|
|
||||||
from sxc.pages import setup_sx_pages
|
from sxc.pages import setup_sx_pages
|
||||||
|
|||||||
@@ -901,4 +901,30 @@ def register(url_prefix: str = "/") -> Blueprint:
|
|||||||
return Response(generate(), content_type="text/event-stream",
|
return Response(generate(), content_type="text/event-stream",
|
||||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
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
|
return bp
|
||||||
|
|||||||
@@ -236,14 +236,15 @@ def _tokenize_bash(code: str) -> list[tuple[str, str]]:
|
|||||||
return tokens
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
def highlight(code: str, language: str = "lisp") -> str:
|
def highlight(code: str, language: str = "lisp"):
|
||||||
"""Highlight code in the given language. Returns sx source."""
|
"""Highlight code in the given language. Returns SxExpr for wire format."""
|
||||||
|
from shared.sx.parser import SxExpr
|
||||||
if language in ("lisp", "sx", "sexp"):
|
if language in ("lisp", "sx", "sexp"):
|
||||||
return highlight_sx(code)
|
return SxExpr(highlight_sx(code))
|
||||||
elif language in ("python", "py"):
|
elif language in ("python", "py"):
|
||||||
return highlight_python(code)
|
return SxExpr(highlight_python(code))
|
||||||
elif language in ("bash", "sh", "shell"):
|
elif language in ("bash", "sh", "shell"):
|
||||||
return highlight_bash(code)
|
return SxExpr(highlight_bash(code))
|
||||||
# Fallback: no highlighting, just escaped text
|
# Fallback: no highlighting, just escaped text
|
||||||
escaped = code.replace("\\", "\\\\").replace('"', '\\"')
|
escaped = code.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
return f'(span "{escaped}")'
|
return SxExpr(f'(span "{escaped}")')
|
||||||
|
|||||||
@@ -261,6 +261,463 @@ EDIT_ROW_DATA = [
|
|||||||
{"id": "4", "name": "Widget D", "price": "45.00", "stock": "67"},
|
{"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
|
# Reference: Attribute detail pages
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
(defcomp ~doc-placeholder (&key id)
|
(defcomp ~doc-placeholder (&key id)
|
||||||
(div :id 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"
|
(p :class "text-stone-400 italic text-sm"
|
||||||
"Trigger the demo to see the actual content."))))
|
"Trigger the demo to see the actual content."))))
|
||||||
|
|
||||||
(defcomp ~doc-oob-code (&key target-id text)
|
(defcomp ~doc-oob-code (&key target-id text)
|
||||||
(div :id target-id :sx-swap-oob "innerHTML"
|
(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"
|
(div :class "bg-stone-100 rounded p-4 mt-3"
|
||||||
(pre :class "text-sm whitespace-pre-wrap"
|
(pre :class "text-sm whitespace-pre-wrap break-words"
|
||||||
(code text)))))
|
(code text)))))
|
||||||
|
|
||||||
(defcomp ~doc-attr-table (&key title rows)
|
(defcomp ~doc-attr-table (&key title rows)
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
(h3 :class "text-xl font-semibold text-stone-700" title)
|
(h3 :class "text-xl font-semibold text-stone-700" title)
|
||||||
(div :class "overflow-x-auto rounded border border-stone-200"
|
(div :class "overflow-x-auto rounded border border-stone-200"
|
||||||
(table :class "w-full text-left text-sm"
|
(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" "Attribute")
|
||||||
(th :class "px-3 py-2 font-medium text-stone-600" "Description")
|
(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?")))
|
(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)
|
(h3 :class "text-xl font-semibold text-stone-700" title)
|
||||||
(div :class "overflow-x-auto rounded border border-stone-200"
|
(div :class "overflow-x-auto rounded border border-stone-200"
|
||||||
(table :class "w-full text-left text-sm"
|
(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" "Header")
|
||||||
(th :class "px-3 py-2 font-medium text-stone-600" "Value")
|
(th :class "px-3 py-2 font-medium text-stone-600" "Value")
|
||||||
(th :class "px-3 py-2 font-medium text-stone-600" "Description")))
|
(th :class "px-3 py-2 font-medium text-stone-600" "Description")))
|
||||||
(tbody rows)))))
|
(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"
|
(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 font-mono text-sm text-stone-500" value)
|
||||||
(td :class "px-3 py-2 text-stone-700 text-sm" description)))
|
(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"
|
(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)))
|
(td :class "px-3 py-2 text-stone-700 text-sm" description)))
|
||||||
|
|
||||||
(defcomp ~doc-two-col-table (&key title intro col1 col2 rows)
|
(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))
|
(when intro (p :class "text-stone-600 mb-6" intro))
|
||||||
(div :class "overflow-x-auto rounded border border-stone-200"
|
(div :class "overflow-x-auto rounded border border-stone-200"
|
||||||
(table :class "w-full text-left text-sm"
|
(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 col1 "Name"))
|
||||||
(th :class "px-3 py-2 font-medium text-stone-600" (or col2 "Description"))))
|
(th :class "px-3 py-2 font-medium text-stone-600" (or col2 "Description"))))
|
||||||
(tbody rows)))))
|
(tbody rows)))))
|
||||||
|
|
||||||
(defcomp ~sx-docs-label ()
|
(defcomp ~sx-docs-label ()
|
||||||
(span :class "font-mono" "(<x>)"))
|
(span :class "font-mono" "(<sx>)"))
|
||||||
|
|
||||||
(defcomp ~doc-clear-cache-btn ()
|
(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)"
|
(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
|
(~doc-headers-row
|
||||||
:name (get h "name")
|
:name (get h "name")
|
||||||
:value (get h "value")
|
:value (get h "value")
|
||||||
:description (get h "desc")))
|
:description (get h "desc")
|
||||||
|
:href (get h "href")))
|
||||||
headers))))
|
headers))))
|
||||||
|
|
||||||
;; Build two-col table from a list of {name, desc} dicts.
|
;; Build two-col table from a list of {name, desc} dicts.
|
||||||
@@ -98,7 +111,8 @@
|
|||||||
:rows (<> (map (fn (item)
|
:rows (<> (map (fn (item)
|
||||||
(~doc-two-col-row
|
(~doc-two-col-row
|
||||||
:name (get item "name")
|
:name (get item "name")
|
||||||
:description (get item "desc")))
|
:description (get item "desc")
|
||||||
|
:href (get item "href")))
|
||||||
items))))
|
items))))
|
||||||
|
|
||||||
;; Build all primitives category tables from a {category: [prim, ...]} dict.
|
;; Build all primitives category tables from a {category: [prim, ...]} dict.
|
||||||
|
|||||||
126
sx/sx/essays.sx
126
sx/sx/essays.sx
File diff suppressed because one or more lines are too long
@@ -277,7 +277,7 @@
|
|||||||
:description "sx-vals adds extra key/value pairs to the request parameters. sx-headers adds custom HTTP headers. The server echoes back what it received."
|
:description "sx-vals adds extra key/value pairs to the request parameters. sx-headers adds custom HTTP headers. The server echoes back what it received."
|
||||||
:demo-description "Click each button to see what the server receives."
|
:demo-description "Click each button to see what the server receives."
|
||||||
:demo (~vals-headers-demo)
|
:demo (~vals-headers-demo)
|
||||||
:sx-code ";; Send extra values with the request\n(button\n :sx-get \"/examples/api/echo-vals\"\n :sx-vals \"{\\\"source\\\": \\\"button\\\"}\"\n \"Send with vals\")\n\n;; Send custom headers\n(button\n :sx-get \"/examples/api/echo-headers\"\n :sx-headers \"{\\\"X-Custom-Token\\\": \\\"abc123\\\"}\"\n \"Send with headers\")"
|
:sx-code ";; Send extra values with the request\n(button\n :sx-get \"/examples/api/echo-vals\"\n :sx-vals \"{\\\"source\\\": \\\"button\\\"}\"\n \"Send with vals\")\n\n;; Send custom headers\n(button\n :sx-get \"/examples/api/echo-headers\"\n :sx-headers {:X-Custom-Token \"abc123\"}\n \"Send with headers\")"
|
||||||
:handler-code "@bp.get(\"/examples/api/echo-vals\")\nasync def api_echo_vals():\n vals = dict(request.args)\n return sx_response(\n f'(~echo-result :label \"values\" :items (...))')\n\n@bp.get(\"/examples/api/echo-headers\")\nasync def api_echo_headers():\n custom = {k: v for k, v in request.headers\n if k.startswith(\"X-\")}\n return sx_response(\n f'(~echo-result :label \"headers\" :items (...))')"
|
:handler-code "@bp.get(\"/examples/api/echo-vals\")\nasync def api_echo_vals():\n vals = dict(request.args)\n return sx_response(\n f'(~echo-result :label \"values\" :items (...))')\n\n@bp.get(\"/examples/api/echo-headers\")\nasync def api_echo_headers():\n custom = {k: v for k, v in request.headers\n if k.startswith(\"X-\")}\n return sx_response(\n f'(~echo-result :label \"headers\" :items (...))')"
|
||||||
:comp-placeholder-id "vals-comp"
|
:comp-placeholder-id "vals-comp"
|
||||||
:wire-placeholder-id "vals-wire"))
|
:wire-placeholder-id "vals-wire"))
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
(dict :label "Reference" :href "/reference/")
|
(dict :label "Reference" :href "/reference/")
|
||||||
(dict :label "Protocols" :href "/protocols/wire-format")
|
(dict :label "Protocols" :href "/protocols/wire-format")
|
||||||
(dict :label "Examples" :href "/examples/click-to-load")
|
(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)
|
(<> (map (lambda (item)
|
||||||
(~nav-link
|
(~nav-link
|
||||||
:href (get item "href")
|
:href (get item "href")
|
||||||
@@ -73,8 +75,9 @@
|
|||||||
:sub-nav sub-nav :selected selected))))
|
:sub-nav sub-nav :selected selected))))
|
||||||
|
|
||||||
(defcomp ~sx-section-layout-oob (&key section sub-label sub-href sub-nav selected)
|
(defcomp ~sx-section-layout-oob (&key section sub-label sub-href sub-nav selected)
|
||||||
(<> (~sx-sub-row :sub-label sub-label :sub-href sub-href
|
(<> (~oob-header-sx :parent-id "sx-header-child"
|
||||||
:sub-nav sub-nav :selected selected :oob true)
|
:row (~sx-sub-row :sub-label sub-label :sub-href sub-href
|
||||||
|
:sub-nav sub-nav :selected selected))
|
||||||
(~sx-header-row
|
(~sx-header-row
|
||||||
:nav (~sx-main-nav :section section)
|
:nav (~sx-main-nav :section section)
|
||||||
:oob true)
|
:oob true)
|
||||||
@@ -90,3 +93,45 @@
|
|||||||
:label "sx" :href "/" :level 1 :colour "violet"
|
:label "sx" :href "/" :level 1 :colour "violet"
|
||||||
:items (~sx-main-nav :section section))
|
:items (~sx-main-nav :section section))
|
||||||
(~root-mobile-auto)))
|
(~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))))
|
||||||
|
|||||||
@@ -55,15 +55,101 @@
|
|||||||
(dict :label "Retry" :href "/examples/retry")))
|
(dict :label "Retry" :href "/examples/retry")))
|
||||||
|
|
||||||
(define essays-nav-items (list
|
(define essays-nav-items (list
|
||||||
(dict :label "sx sucks" :href "/essays/sx-sucks")
|
(dict :label "Why S-Expressions" :href "/essays/why-sexps"
|
||||||
(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")
|
(dict :label "The htmx/React Hybrid" :href "/essays/htmx-react-hybrid"
|
||||||
(dict :label "On-Demand CSS" :href "/essays/on-demand-css")
|
:summary "How SX combines the server-driven simplicity of htmx with the component model of React.")
|
||||||
(dict :label "Client Reactivity" :href "/essays/client-reactivity")
|
(dict :label "On-Demand CSS" :href "/essays/on-demand-css"
|
||||||
(dict :label "SX Native" :href "/essays/sx-native")
|
:summary "The CSSX system: keyword atoms resolved to class names, CSS rules injected on first use.")
|
||||||
(dict :label "The SX Manifesto" :href "/essays/sx-manifesto")
|
(dict :label "Client Reactivity" :href "/essays/client-reactivity"
|
||||||
(dict :label "Tail-Call Optimization" :href "/essays/tail-call-optimization")
|
:summary "Reactive UI updates without a virtual DOM, diffing library, or build step.")
|
||||||
(dict :label "Continuations" :href "/essays/continuations")))
|
(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.
|
;; Find the current nav label for a slug by matching href suffix.
|
||||||
;; Returns the label string or nil if no match.
|
;; Returns the label string or nil if no match.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
;; Reference page layouts — receive data from Python primitives
|
;; 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)
|
(defcomp ~reference-attrs-content (&key req-table beh-table uniq-table)
|
||||||
(~doc-page :title "Attribute Reference"
|
(~doc-page :title "Attribute Reference"
|
||||||
@@ -45,6 +46,40 @@
|
|||||||
"Trigger the demo to see the raw response the server sends.")
|
"Trigger the demo to see the raw response the server sends.")
|
||||||
(~doc-placeholder :id wire-placeholder-id)))))
|
(~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)
|
(defcomp ~reference-attr-not-found (&key slug)
|
||||||
(~doc-page :title "Not Found"
|
(~doc-page :title "Not Found"
|
||||||
(p :class "text-stone-600"
|
(p :class "text-stone-600"
|
||||||
|
|||||||
330
sx/sx/specs.sx
Normal file
330
sx/sx/specs.sx
Normal 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.")))
|
||||||
@@ -16,8 +16,8 @@
|
|||||||
children))
|
children))
|
||||||
|
|
||||||
(defcomp ~doc-code (&key code)
|
(defcomp ~doc-code (&key code)
|
||||||
(div :class "bg-stone-50 border border-stone-200 rounded-lg p-4 overflow-x-auto"
|
(div :class "bg-stone-100 rounded-lg p-5 mx-auto max-w-3xl"
|
||||||
(pre :class "text-sm" (code code))))
|
(pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words" (code code))))
|
||||||
|
|
||||||
(defcomp ~doc-note (&key &rest children)
|
(defcomp ~doc-note (&key &rest children)
|
||||||
(div :class "border-l-4 border-violet-400 bg-violet-50 p-4 text-stone-700 text-sm"
|
(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"
|
(div :class "overflow-x-auto rounded border border-stone-200"
|
||||||
(table :class "w-full text-left text-sm"
|
(table :class "w-full text-left text-sm"
|
||||||
(thead
|
(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)))
|
(map (fn (h) (th :class "px-3 py-2 font-medium text-stone-600" h)) headers)))
|
||||||
(tbody
|
(tbody
|
||||||
(map (fn (row)
|
(map (fn (row)
|
||||||
@@ -61,15 +61,15 @@
|
|||||||
(defcomp ~doc-nav (&key items current)
|
(defcomp ~doc-nav (&key items current)
|
||||||
(nav :class "flex flex-wrap gap-2 mb-8"
|
(nav :class "flex flex-wrap gap-2 mb-8"
|
||||||
(map (fn (item)
|
(map (fn (item)
|
||||||
(a :href (nth 1 item)
|
(a :href (nth item 1)
|
||||||
:sx-get (nth 1 item)
|
:sx-get (nth item 1)
|
||||||
:sx-target "#main-panel"
|
:sx-target "#main-panel"
|
||||||
:sx-select "#main-panel"
|
:sx-select "#main-panel"
|
||||||
:sx-swap "outerHTML"
|
:sx-swap "outerHTML"
|
||||||
:sx-push-url "true"
|
:sx-push-url "true"
|
||||||
:class (str "px-3 py-1.5 rounded text-sm font-medium no-underline "
|
: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-violet-100 text-violet-800"
|
||||||
"bg-stone-100 text-stone-600 hover:bg-stone-200"))
|
"bg-stone-100 text-stone-600 hover:bg-stone-200"))
|
||||||
(nth 0 item)))
|
(nth item 0)))
|
||||||
items)))
|
items)))
|
||||||
|
|||||||
@@ -2,24 +2,24 @@
|
|||||||
|
|
||||||
(defcomp ~example-card (&key title description &rest children)
|
(defcomp ~example-card (&key title description &rest children)
|
||||||
(div :class "border border-stone-200 rounded-lg overflow-hidden"
|
(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)
|
(h3 :class "font-semibold text-stone-800" title)
|
||||||
(when description
|
(when description
|
||||||
(p :class "text-sm text-stone-500 mt-1" description)))
|
(p :class "text-sm text-stone-500 mt-1" description)))
|
||||||
(div :class "p-4" children)))
|
(div :class "p-4" children)))
|
||||||
|
|
||||||
(defcomp ~example-demo (&key &rest 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)
|
(defcomp ~example-source (&key code)
|
||||||
(div :class "bg-stone-50 border border-stone-200 rounded p-4 mt-3 overflow-x-auto"
|
(div :class "bg-stone-100 rounded p-5 mt-3 mx-auto max-w-3xl"
|
||||||
(pre :class "text-sm" (code code))))
|
(pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words" (code code))))
|
||||||
|
|
||||||
;; --- Click to load demo ---
|
;; --- Click to load demo ---
|
||||||
|
|
||||||
(defcomp ~click-to-load-demo ()
|
(defcomp ~click-to-load-demo ()
|
||||||
(div :class "space-y-4"
|
(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.")
|
"Click the button to load content.")
|
||||||
(button
|
(button
|
||||||
:sx-get "/examples/api/click"
|
:sx-get "/examples/api/click"
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
(button :type "submit"
|
(button :type "submit"
|
||||||
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||||
"Submit"))
|
"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.")))
|
"Submit the form to see the result.")))
|
||||||
|
|
||||||
(defcomp ~form-result (&key name)
|
(defcomp ~form-result (&key name)
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
:sx-get "/examples/api/poll"
|
:sx-get "/examples/api/poll"
|
||||||
:sx-trigger "load, every 2s"
|
:sx-trigger "load, every 2s"
|
||||||
:sx-swap "innerHTML"
|
: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...")))
|
"Loading...")))
|
||||||
|
|
||||||
(defcomp ~poll-result (&key time count)
|
(defcomp ~poll-result (&key time count)
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
(th :class "px-3 py-2 font-medium text-stone-600 w-20" "")))
|
(th :class "px-3 py-2 font-medium text-stone-600 w-20" "")))
|
||||||
(tbody :id "delete-rows"
|
(tbody :id "delete-rows"
|
||||||
(map (fn (item)
|
(map (fn (item)
|
||||||
(~delete-row :id (nth 0 item) :name (nth 1 item)))
|
(~delete-row :id (nth item 0) :name (nth item 1)))
|
||||||
items)))))
|
items)))))
|
||||||
|
|
||||||
(defcomp ~delete-row (&key id name)
|
(defcomp ~delete-row (&key id name)
|
||||||
@@ -145,10 +145,10 @@
|
|||||||
(defcomp ~oob-demo ()
|
(defcomp ~oob-demo ()
|
||||||
(div :class "space-y-4"
|
(div :class "space-y-4"
|
||||||
(div :class "grid grid-cols-2 gap-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-stone-500" "Box A")
|
||||||
(p :class "text-sm text-stone-400" "Waiting..."))
|
(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-stone-500" "Box B")
|
||||||
(p :class "text-sm text-stone-400" "Waiting...")))
|
(p :class "text-sm text-stone-400" "Waiting...")))
|
||||||
(button
|
(button
|
||||||
@@ -167,7 +167,7 @@
|
|||||||
:sx-get "/examples/api/lazy"
|
:sx-get "/examples/api/lazy"
|
||||||
:sx-trigger "load"
|
:sx-trigger "load"
|
||||||
:sx-swap "innerHTML"
|
: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 "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-3/4 mx-auto")
|
||||||
(div :class "h-4 bg-stone-200 rounded w-1/2 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."))))
|
(p :class "text-sm text-stone-400" "Messages will appear here."))))
|
||||||
|
|
||||||
(defcomp ~reset-message (&key message time)
|
(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)))
|
(str "[" time "] " message)))
|
||||||
|
|
||||||
;; --- Edit row demo ---
|
;; --- Edit row demo ---
|
||||||
@@ -344,7 +344,7 @@
|
|||||||
(th :class "px-3 py-2 font-medium text-stone-600 w-24" "")))
|
(th :class "px-3 py-2 font-medium text-stone-600 w-24" "")))
|
||||||
(tbody :id "edit-rows"
|
(tbody :id "edit-rows"
|
||||||
(map (fn (row)
|
(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)))))
|
rows)))))
|
||||||
|
|
||||||
(defcomp ~edit-row-view (&key id name price stock)
|
(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")))
|
(th :class "px-3 py-2 font-medium text-stone-600" "Status")))
|
||||||
(tbody :id "bulk-table"
|
(tbody :id "bulk-table"
|
||||||
(map (fn (u)
|
(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))))))
|
users))))))
|
||||||
|
|
||||||
(defcomp ~bulk-row (&key id name email status)
|
(defcomp ~bulk-row (&key id name email status)
|
||||||
@@ -488,7 +488,7 @@
|
|||||||
:sx-swap "innerHTML"
|
:sx-swap "innerHTML"
|
||||||
:class "px-3 py-1.5 bg-stone-600 text-white rounded text-sm hover:bg-stone-700"
|
:class "px-3 py-1.5 bg-stone-600 text-white rounded text-sm hover:bg-stone-700"
|
||||||
"Full Dashboard"))
|
"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."))))
|
(p :class "text-sm text-stone-400" "Click a button to load content."))))
|
||||||
|
|
||||||
;; --- Tabs demo ---
|
;; --- Tabs demo ---
|
||||||
@@ -525,7 +525,7 @@
|
|||||||
:sx-swap "innerHTML"
|
:sx-swap "innerHTML"
|
||||||
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||||
"Load with animation")
|
"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."))))
|
(p :class "text-stone-400" "Content will fade in here."))))
|
||||||
|
|
||||||
(defcomp ~anim-result (&key color time)
|
(defcomp ~anim-result (&key color time)
|
||||||
@@ -552,7 +552,7 @@
|
|||||||
:sx-get "/examples/api/dialog/close"
|
:sx-get "/examples/api/dialog/close"
|
||||||
:sx-target "#dialog-container"
|
:sx-target "#dialog-container"
|
||||||
:sx-swap "innerHTML")
|
: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)
|
(h3 :class "text-lg font-semibold text-stone-800" title)
|
||||||
(p :class "text-stone-600" message)
|
(p :class "text-stone-600" message)
|
||||||
(div :class "flex justify-end gap-2"
|
(div :class "flex justify-end gap-2"
|
||||||
@@ -573,23 +573,23 @@
|
|||||||
|
|
||||||
(defcomp ~keyboard-shortcuts-demo ()
|
(defcomp ~keyboard-shortcuts-demo ()
|
||||||
(div :class "space-y-4"
|
(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:")
|
(p :class "text-sm text-stone-600 font-medium mb-2" "Keyboard shortcuts:")
|
||||||
(div :class "flex gap-4"
|
(div :class "flex gap-4"
|
||||||
(div :class "flex items-center gap-1"
|
(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"))
|
(span :class "text-sm text-stone-500" "Search"))
|
||||||
(div :class "flex items-center gap-1"
|
(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"))
|
(span :class "text-sm text-stone-500" "New item"))
|
||||||
(div :class "flex items-center gap-1"
|
(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"))))
|
(span :class "text-sm text-stone-500" "Help"))))
|
||||||
(div :id "kbd-target"
|
(div :id "kbd-target"
|
||||||
:sx-get "/examples/api/keyboard?key=s"
|
:sx-get "/examples/api/keyboard?key=s"
|
||||||
:sx-trigger "keyup[key=='s'&&!event.target.matches('input,textarea')] from:body"
|
:sx-trigger "keyup[key=='s'&&!event.target.matches('input,textarea')] from:body"
|
||||||
:sx-swap "innerHTML"
|
: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..."))
|
(p :class "text-stone-400 text-sm" "Press a shortcut key..."))
|
||||||
(div :sx-get "/examples/api/keyboard?key=n"
|
(div :sx-get "/examples/api/keyboard?key=n"
|
||||||
:sx-trigger "keyup[key=='n'&&!event.target.matches('input,textarea')] from:body"
|
:sx-trigger "keyup[key=='n'&&!event.target.matches('input,textarea')] from:body"
|
||||||
@@ -675,7 +675,7 @@
|
|||||||
(button :type "submit"
|
(button :type "submit"
|
||||||
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||||
"Submit as JSON"))
|
"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.")))
|
"Submit the form to see the server echo the parsed JSON.")))
|
||||||
|
|
||||||
(defcomp ~json-result (&key body content-type)
|
(defcomp ~json-result (&key body content-type)
|
||||||
@@ -697,7 +697,7 @@
|
|||||||
:sx-vals "{\"source\": \"button\", \"version\": \"2.0\"}"
|
: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"
|
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||||
"Send with vals")
|
"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."))
|
"Click to see server-received values."))
|
||||||
(div :class "space-y-2"
|
(div :class "space-y-2"
|
||||||
(h4 :class "text-sm font-semibold text-stone-700" "sx-headers — send custom headers")
|
(h4 :class "text-sm font-semibold text-stone-700" "sx-headers — send custom headers")
|
||||||
@@ -705,10 +705,10 @@
|
|||||||
:sx-get "/examples/api/echo-headers"
|
:sx-get "/examples/api/echo-headers"
|
||||||
:sx-target "#headers-result"
|
:sx-target "#headers-result"
|
||||||
:sx-swap "innerHTML"
|
:sx-swap "innerHTML"
|
||||||
:sx-headers "{\"X-Custom-Token\": \"abc123\", \"X-Request-Source\": \"demo\"}"
|
: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"
|
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||||
"Send with headers")
|
"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."))))
|
"Click to see server-received headers."))))
|
||||||
|
|
||||||
(defcomp ~echo-result (&key label items)
|
(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"
|
: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 :class "sx-spinner w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin")
|
||||||
(span "Load slow endpoint"))
|
(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."))))
|
(p :class "text-stone-400 text-sm" "Click the button — it takes 2 seconds."))))
|
||||||
|
|
||||||
(defcomp ~loading-result (&key time)
|
(defcomp ~loading-result (&key time)
|
||||||
@@ -749,7 +749,7 @@
|
|||||||
:sx-sync "replace"
|
:sx-sync "replace"
|
||||||
:placeholder "Type to search (random delay 0.5-2s)..."
|
: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")
|
: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."))))
|
(p :class "text-sm text-stone-400" "Type to trigger requests — stale ones get aborted."))))
|
||||||
|
|
||||||
(defcomp ~sync-result (&key query delay)
|
(defcomp ~sync-result (&key query delay)
|
||||||
@@ -768,7 +768,7 @@
|
|||||||
:sx-retry "exponential:1000:8000"
|
:sx-retry "exponential:1000:8000"
|
||||||
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||||
"Call flaky endpoint")
|
"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."))))
|
(p :class "text-stone-400 text-sm" "Endpoint fails twice, succeeds on 3rd attempt."))))
|
||||||
|
|
||||||
(defcomp ~retry-result (&key attempt message)
|
(defcomp ~retry-result (&key attempt message)
|
||||||
|
|||||||
@@ -235,7 +235,7 @@
|
|||||||
(div :class "p-3 bg-amber-50 rounded text-center"
|
(div :class "p-3 bg-amber-50 rounded text-center"
|
||||||
(p :class "text-2xl font-bold text-amber-700" "$4.2k")
|
(p :class "text-2xl font-bold text-amber-700" "$4.2k")
|
||||||
(p :class "text-xs text-amber-600" "Revenue")))
|
(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)))))
|
(p :class "text-sm text-stone-500" "Last updated: " now)))))
|
||||||
|
|
||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -3,14 +3,16 @@
|
|||||||
(defcomp ~sx-hero (&key &rest children)
|
(defcomp ~sx-hero (&key &rest children)
|
||||||
(div :class "max-w-4xl mx-auto px-6 py-16 text-center"
|
(div :class "max-w-4xl mx-auto px-6 py-16 text-center"
|
||||||
(h1 :class "text-5xl font-bold text-stone-900 mb-4"
|
(h1 :class "text-5xl font-bold text-stone-900 mb-4"
|
||||||
(span :class "text-violet-600 font-mono" "(<x>)"))
|
(span :class "text-violet-600 font-mono" "(<sx>)"))
|
||||||
(p :class "text-2xl text-stone-600 mb-8"
|
(p :class "text-2xl text-stone-600 mb-4"
|
||||||
"s-expressions for the web")
|
"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"
|
(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 "
|
"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.")
|
"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"
|
(div :class "bg-stone-100 rounded-lg p-6 text-left font-mono text-sm mx-auto max-w-2xl"
|
||||||
(pre :class "leading-relaxed" children))))
|
(pre :class "leading-relaxed whitespace-pre-wrap" children))))
|
||||||
|
|
||||||
(defcomp ~sx-philosophy ()
|
(defcomp ~sx-philosophy ()
|
||||||
(div :class "max-w-4xl mx-auto px-6 py-12"
|
(div :class "max-w-4xl mx-auto px-6 py-12"
|
||||||
|
|||||||
@@ -115,6 +115,43 @@
|
|||||||
:handler-code attr-handler
|
:handler-code attr-handler
|
||||||
:wire-placeholder-id attr-wire-id)))
|
: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
|
;; Protocols section
|
||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
@@ -214,10 +251,10 @@
|
|||||||
:layout (:sx-section
|
:layout (:sx-section
|
||||||
:section "Essays"
|
:section "Essays"
|
||||||
:sub-label "Essays"
|
:sub-label "Essays"
|
||||||
:sub-href "/essays/sx-sucks"
|
:sub-href "/essays/"
|
||||||
:sub-nav (~section-nav :items essays-nav-items :current "sx sucks")
|
:sub-nav (~section-nav :items essays-nav-items :current "")
|
||||||
:selected "sx sucks")
|
:selected "")
|
||||||
:content (~essay-sx-sucks))
|
:content (~essays-index-content))
|
||||||
|
|
||||||
(defpage essay-page
|
(defpage essay-page
|
||||||
:path "/essays/<slug>"
|
:path "/essays/<slug>"
|
||||||
@@ -225,7 +262,7 @@
|
|||||||
:layout (:sx-section
|
:layout (:sx-section
|
||||||
:section "Essays"
|
:section "Essays"
|
||||||
:sub-label "Essays"
|
:sub-label "Essays"
|
||||||
:sub-href "/essays/sx-sucks"
|
:sub-href "/essays/"
|
||||||
:sub-nav (~section-nav :items essays-nav-items
|
:sub-nav (~section-nav :items essays-nav-items
|
||||||
:current (find-current essays-nav-items slug))
|
:current (find-current essays-nav-items slug))
|
||||||
:selected (or (find-current essays-nav-items slug) ""))
|
:selected (or (find-current essays-nav-items slug) ""))
|
||||||
@@ -239,4 +276,98 @@
|
|||||||
"sx-manifesto" (~essay-sx-manifesto)
|
"sx-manifesto" (~essay-sx-manifesto)
|
||||||
"tail-call-optimization" (~essay-tail-call-optimization)
|
"tail-call-optimization" (~essay-tail-call-optimization)
|
||||||
"continuations" (~essay-continuations)
|
"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)))
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ def _register_sx_helpers() -> None:
|
|||||||
"primitives-data": _primitives_data,
|
"primitives-data": _primitives_data,
|
||||||
"reference-data": _reference_data,
|
"reference-data": _reference_data,
|
||||||
"attr-detail-data": _attr_detail_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 (
|
from content.pages import (
|
||||||
REQUEST_ATTRS, BEHAVIOR_ATTRS, SX_UNIQUE_ATTRS,
|
REQUEST_ATTRS, BEHAVIOR_ATTRS, SX_UNIQUE_ATTRS,
|
||||||
REQUEST_HEADERS, RESPONSE_HEADERS,
|
REQUEST_HEADERS, RESPONSE_HEADERS,
|
||||||
EVENTS, JS_API, ATTR_DETAILS,
|
EVENTS, JS_API, ATTR_DETAILS, HEADER_DETAILS,
|
||||||
)
|
)
|
||||||
|
|
||||||
if slug == "attributes":
|
if slug == "attributes":
|
||||||
@@ -61,18 +65,22 @@ def _reference_data(slug: str) -> dict:
|
|||||||
elif slug == "headers":
|
elif slug == "headers":
|
||||||
return {
|
return {
|
||||||
"req-headers": [
|
"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
|
for n, v, d in REQUEST_HEADERS
|
||||||
],
|
],
|
||||||
"resp-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
|
for n, v, d in RESPONSE_HEADERS
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
elif slug == "events":
|
elif slug == "events":
|
||||||
|
from content.pages import EVENT_DETAILS
|
||||||
return {
|
return {
|
||||||
"events-list": [
|
"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
|
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:
|
def _attr_detail_data(slug: str) -> dict:
|
||||||
"""Return attribute detail data for a specific attribute slug.
|
"""Return attribute detail data for a specific attribute slug.
|
||||||
|
|
||||||
@@ -133,3 +196,42 @@ def _attr_detail_data(slug: str) -> dict:
|
|||||||
"attr-demo": SxExpr(f"(~{demo_name})") if demo_name else None,
|
"attr-demo": SxExpr(f"(~{demo_name})") if demo_name else None,
|
||||||
"attr-wire-id": wire_id,
|
"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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
"""SX docs layout registration — all layouts delegate to .sx defcomps."""
|
"""SX docs layout registration — all layouts delegate to .sx defcomps."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
def _register_sx_layouts() -> None:
|
def _register_sx_layouts() -> None:
|
||||||
"""Register the sx docs layout presets."""
|
"""Register the sx docs layout presets."""
|
||||||
from shared.sx.layouts import register_sx_layout
|
from shared.sx.layouts import register_sx_layout
|
||||||
|
|
||||||
register_sx_layout("sx", "sx-layout-full", "sx-layout-oob", "sx-layout-mobile")
|
if os.getenv("SX_STANDALONE") == "true":
|
||||||
register_sx_layout("sx-section", "sx-section-layout-full",
|
register_sx_layout("sx",
|
||||||
"sx-section-layout-oob", "sx-section-layout-mobile")
|
"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")
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||||
"Load server time")
|
"Load server time")
|
||||||
(div :id "ref-get-result"
|
(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.")))
|
"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"
|
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||||
"Greet"))
|
"Greet"))
|
||||||
(div :id "ref-post-result"
|
(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.")))
|
"Submit to see greeting.")))
|
||||||
|
|
||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
|
|
||||||
(defcomp ~ref-put-demo ()
|
(defcomp ~ref-put-demo ()
|
||||||
(div :id "ref-put-view"
|
(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"))
|
(span :class "text-stone-700 text-sm" "Status: " (strong "draft"))
|
||||||
(button
|
(button
|
||||||
:sx-put "/reference/api/status"
|
:sx-put "/reference/api/status"
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
|
|
||||||
(defcomp ~ref-patch-demo ()
|
(defcomp ~ref-patch-demo ()
|
||||||
(div :id "ref-patch-view" :class "space-y-2"
|
(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")))
|
(span :class "text-stone-700 text-sm" "Theme: " (strong :id "ref-patch-val" "light")))
|
||||||
(div :class "flex gap-2"
|
(div :class "flex gap-2"
|
||||||
(button :sx-patch "/reference/api/theme"
|
(button :sx-patch "/reference/api/theme"
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
(button :sx-patch "/reference/api/theme"
|
(button :sx-patch "/reference/api/theme"
|
||||||
:sx-vals "{\"theme\": \"light\"}"
|
:sx-vals "{\"theme\": \"light\"}"
|
||||||
:sx-target "#ref-patch-val" :sx-swap "innerHTML"
|
: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
|
;; sx-trigger
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
:sx-swap "innerHTML"
|
: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")
|
: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"
|
(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.")))
|
"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"
|
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
|
||||||
"Load (selecting #the-content)")
|
"Load (selecting #the-content)")
|
||||||
(div :id "ref-select-result"
|
(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.")))
|
"Only the selected fragment will appear here.")))
|
||||||
|
|
||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
@@ -242,7 +242,7 @@
|
|||||||
(p :class "text-xs text-stone-400"
|
(p :class "text-xs text-stone-400"
|
||||||
"With sync:replace, each new keystroke aborts the in-flight request.")
|
"With sync:replace, each new keystroke aborts the in-flight request.")
|
||||||
(div :id "ref-sync-result"
|
(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.")))
|
"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"
|
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
|
||||||
"Upload"))
|
"Upload"))
|
||||||
(div :id "ref-encoding-result"
|
(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.")))
|
"Select a file and submit.")))
|
||||||
|
|
||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
@@ -272,13 +272,13 @@
|
|||||||
(defcomp ~ref-headers-demo ()
|
(defcomp ~ref-headers-demo ()
|
||||||
(div :class "space-y-3"
|
(div :class "space-y-3"
|
||||||
(button :sx-get "/reference/api/echo-headers"
|
(button :sx-get "/reference/api/echo-headers"
|
||||||
:sx-headers "{\"X-Custom-Token\": \"abc123\", \"X-Request-Source\": \"demo\"}"
|
:sx-headers {:X-Custom-Token "abc123" :X-Request-Source "demo"}
|
||||||
:sx-target "#ref-headers-result"
|
:sx-target "#ref-headers-result"
|
||||||
:sx-swap "innerHTML"
|
:sx-swap "innerHTML"
|
||||||
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
|
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
|
||||||
"Send with custom headers")
|
"Send with custom headers")
|
||||||
(div :id "ref-headers-result"
|
(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.")))
|
"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"
|
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
|
||||||
"Filter"))
|
"Filter"))
|
||||||
(div :id "ref-include-result"
|
(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.")))
|
"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"
|
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
|
||||||
"Send with extra values")
|
"Send with extra values")
|
||||||
(div :id "ref-vals-result"
|
(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.")))
|
"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"
|
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
|
||||||
"Click me")
|
"Click me")
|
||||||
(div :id "ref-on-result"
|
(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.")))
|
"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"
|
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
|
||||||
"Call flaky endpoint")
|
"Call flaky endpoint")
|
||||||
(div :id "ref-retry-result"
|
(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.")))
|
"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"
|
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||||
"Hover then click (preloaded)")
|
"Hover then click (preloaded)")
|
||||||
(div :id "ref-preload-result"
|
(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.")))
|
"Hover over the button first, then click — the response is instant.")))
|
||||||
|
|
||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
@@ -461,7 +461,7 @@
|
|||||||
(input :id "ref-preserved-input" :sx-preserve "true"
|
(input :id "ref-preserved-input" :sx-preserve "true"
|
||||||
:type "text" :placeholder "Type here — preserved across swaps"
|
:type "text" :placeholder "Type here — preserved across swaps"
|
||||||
:class "w-full px-3 py-2 border border-stone-300 rounded text-sm")
|
: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."))))
|
"This text will be replaced on swap."))))
|
||||||
|
|
||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
@@ -484,7 +484,7 @@
|
|||||||
:style "display: none"
|
:style "display: none"
|
||||||
"Loading..."))
|
"Loading..."))
|
||||||
(div :id "ref-indicator-result"
|
(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).")))
|
"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"
|
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||||
"Submit"))
|
"Submit"))
|
||||||
(div :id "ref-validate-result"
|
(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.")))
|
"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.")
|
(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"
|
(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"))
|
: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."))))
|
"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"
|
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||||
"Load (replaces URL)")
|
"Load (replaces URL)")
|
||||||
(div :id "ref-replurl-result"
|
(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.")))
|
"Click to load — URL changes but no new history entry.")))
|
||||||
|
|
||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
@@ -586,7 +586,7 @@
|
|||||||
"Click (disables during request)")
|
"Click (disables during request)")
|
||||||
(span :class "text-xs text-stone-400" "Button is disabled while request is in-flight."))
|
(span :class "text-xs text-stone-400" "Button is disabled while request is in-flight."))
|
||||||
(div :id "ref-diselt-result"
|
(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.")))
|
"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"
|
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||||
"Prompt & send")
|
"Prompt & send")
|
||||||
(div :id "ref-prompt-result"
|
(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.")))
|
"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"
|
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||||
"Submit"))
|
"Submit"))
|
||||||
(div :id "ref-params-result"
|
(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.")))
|
"Only 'name' will be sent — 'secret' is filtered by sx-params.")))
|
||||||
|
|
||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
@@ -639,7 +639,155 @@
|
|||||||
:sx-sse-swap "time"
|
:sx-sse-swap "time"
|
||||||
:sx-swap "innerHTML"
|
:sx-swap "innerHTML"
|
||||||
(div :id "ref-sse-result"
|
(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..."))
|
"Connecting to SSE stream..."))
|
||||||
(p :class "text-xs text-stone-400"
|
(p :class "text-xs text-stone-400"
|
||||||
"Server pushes time updates every 2 seconds via Server-Sent Events.")))
|
"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.")))
|
||||||
|
|||||||
Reference in New Issue
Block a user