18 Commits

Author SHA1 Message Date
64aa417d63 Replace JSON sx-headers with SX dict expressions, fix blog like component
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m6s
sx-headers attributes now use native SX dict format {:key val} instead of
JSON strings. Eliminates manual JSON string construction in both .sx files
and Python callers.

- sx.js: parse sx-headers/sx-vals as SX dict ({: prefix) with JSON fallback,
  add _serializeDict for dict→attribute serialization, fix verbInfo scope in
  _doFetch error handler
- html.py: serialize dict attribute values via SX serialize() not str()
- All .sx files: {:X-CSRFToken csrf} replaces (str "{\"X-CSRFToken\": ...}")
- All Python callers: {"X-CSRFToken": csrf} dict replaces f-string JSON
- Blog like: extract ~blog-like-toggle, fix POST returning wrong component,
  fix emoji escapes in .sx (parser has no \U support), fix card :hx-headers
  keyword mismatch, wrap sx_content in SxExpr for evaluation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:25:28 +00:00
2a04aaad5e Fix market header ImportError and sx docs menu bar 3 OOB insertion
- market/sx/layouts.sx: Update ~market-header-auto macro to build nav
  from data fields via ~market-desktop-nav-from-data instead of
  expecting pre-built "desktop-nav" SxExpr (removed in Phase 9)
- shared/sx/primitives_io.py: Import _market_header_data instead of
  deleted _desktop_category_nav_sx, return individual data fields
- sx/sx/layouts.sx: Fix ~sx-section-layout-oob to use ~oob-header-sx
  for inserting sub-row into always-existing container div

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:50:48 +00:00
1d59023571 Move events composition from Python to .sx defcomps (Phase 9)
Convert all 14 events page helpers from returning sx_call() strings
to returning data dicts. Defpage expressions compose SX components
with data bindings using map/fn/if/when.

Complex sub-panels (entry tickets config, buy form, posts panel,
options buttons, entry nav menu) returned as SxExpr from existing
render functions which remain for HTMX handler use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:30:46 +00:00
877e776977 Move market composition from Python to .sx defcomps (Phase 8)
Convert 5 market page helpers from returning sx_call() strings to
returning data dicts. Defpages now use :data + :content pattern.
Admin panel uses inline map/fn for CRUD item composition.
Removed market-admin-content helper (placeholder inlined in defpage).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:10:55 +00:00
1560207097 Move blog composition from Python to .sx defcomps (Phase 7)
Convert all 8 blog page helpers from returning sx_call() strings to
returning data dicts. Defpages now use :data + :content pattern:
helpers load data, SX composes markup. Newsletter options and footer
badges composed inline with map/fn in defpage expressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:10:55 +00:00
aed4c03537 Fix highlight undefined symbol by expanding component results server-side
When defpage content expressions use case/if branches that resolve to
component calls (e.g. `(case slug "intro" (~docs-intro-content) ...)`),
_aser serializes them for the client. Components containing Python-only
helpers like `highlight` then fail with "Undefined symbol" on the client.

Add _maybe_expand_component_result() which detects when the evaluated
result (SxExpr or string) is a component call starting with "(~" and
re-parses + expands it through async_eval_slot_to_sx server-side.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:52:45 +00:00
dfccd113fc Move sx docs page helpers from Python to pure SX composition (Phase 6)
Nav data, section nav, example content, reference table builders, and
all slug dispatch now live in .sx files. Python helpers reduced to
data-only returns (highlight, primitives-data, reference-data,
attr-detail-data). Deleted essays.py and utils.py entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:49:04 +00:00
b15025befd Fix highlight undefined symbol by expanding component strings server-side
Page helpers returning SX component call strings (e.g. "(~docs-intro-content)")
were sent to the client unexpanded. Components containing Python-only helpers
like `highlight` then failed with "Undefined symbol" on the client. Now
async_eval_slot_to_sx re-parses and expands these strings server-side.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:45:04 +00:00
0144220427 Move cart composition from Python to .sx defcomps (Phase 5)
- render_orders_rows: Python loop building row-pairs → ~cart-orders-rows-content
  defcomp that maps over order data and handles pagination sentinel
- render_checkout_error_page: conditional order badge composition →
  ~cart-checkout-error-from-data defcomp
- Remove unused SxExpr import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:27:54 +00:00
c71ca6754d Move blog composition from Python to .sx defcomps (Phase 4)
- Settings form: ~135 lines raw HTML → ~blog-settings-form-content defcomp
- Data introspection: ~110 lines raw HTML → ~blog-data-table-content with
  recursive ~blog-data-model-content defcomps, Python extracts ORM data only
- Preview: sx_call composition → ~blog-preview-content defcomp
- Entries browser: ~65 lines raw HTML → ~blog-entries-browser-content +
  ~blog-calendar-browser-item + ~blog-associated-entries-from-data defcomps
- Editor panels: sx_call composition in both helpers.py and renders.py →
  ~blog-editor-content and ~blog-edit-content composition defcomps
- renders.py: 178 → 25 lines (87% reduction)
- routes.py _render_associated_entries: data extraction → single sx_call

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:24:37 +00:00
e81d77437e Move market composition from Python to .sx defcomps (Phase 3)
Python sxc/pages/ functions no longer build nested sx_call chains or
reference leaf component names. Instead they extract data (URLs, prices,
CSRF, cart state) and call a single top-level composition defcomp with
pure data values. The .sx defcomps handle all component-to-component
wiring, iteration (map), and conditional rendering.

New .sx composition defcomps:
- headers.sx: ~market-header-from-data, ~market-desktop-nav-from-data,
  ~market-product-header-from-data, ~market-product-admin-header-from-data
- prices.sx: ~market-prices-header-from-data, ~market-card-price-from-data
- navigation.sx: ~market-mobile-nav-from-data
- cards.sx: ~market-product-cards-content, ~market-card-from-data,
  ~market-cards-content, ~market-landing-from-data
- detail.sx: ~market-product-detail-from-data, ~market-detail-gallery-from-data,
  ~market-detail-info-from-data
- meta.sx: ~market-product-meta-from-data
- filters.sx: ~market-desktop-filter-from-data, ~market-mobile-chips-from-data,
  ~market-mobile-filter-content-from-data, plus 6 sub-composition defcomps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:11:57 +00:00
36a0bd8577 Move sx docs markup from Python to .sx files (Phase 2)
Migrate ~2,500 lines of SX markup from Python string concatenation in
essays.py to proper .sx defcomp definitions:

- docs-content.sx: 8 defcomps for docs pages (intro, getting-started,
  components, evaluator, primitives, css, server-rendering, home)
- protocols.sx: 6 defcomps for protocol documentation pages
- essays.sx: 9 essay defcomps (pure content, no params)
- examples.sx: template defcomp receiving data values, calls highlight
  internally — Python passes raw code strings, never SX
- reference.sx: 6 defcomps for data-driven reference pages

essays.py reduced from 2,699 to 619 lines. Docs/protocol/essay
functions become one-liners returning component names. Example functions
use sx_call to pass data values to the template. Reference functions
pass data-built component trees via SxExpr.

renders.py: removed _code, _example_code, _placeholder,
_clear_components_btn (now handled by .sx templates).
helpers.py: removed inline hero code building, uses ~sx-home-content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:22:17 +00:00
4298d5be16 Fix sx docs pages leaking raw s-expressions and missing sub-row
Three issues fixed:

- async_eval_slot_to_sx (and async_eval_to_sx) was calling serialize()
  on plain strings returned by page helpers, quoting them as literals
  instead of treating them as sx source. Added str check to wrap
  directly in SxExpr.

- _render_to_sx_with_env passed layout kwargs only as env free
  variables, but _aser_component defaults all declared params to NIL
  regardless of env. Now builds the AST with extra_env entries as
  keyword args so they bind through normal param mechanism.

- _nav_items_sx returned plain str; changed to SxExpr so nav fragments
  serialize unquoted when passed as layout kwargs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:02:23 +00:00
1077fae815 Merge branch 'worktree-sx-layout-conversion' into macros
# Conflicts:
#	blog/sxc/pages/layouts.py
#	cart/sxc/pages/layouts.py
#	events/sxc/pages/helpers.py
#	events/sxc/pages/layouts.py
#	market/sxc/pages/layouts.py
#	sx/sxc/pages/layouts.py
2026-03-04 22:25:52 +00:00
57a31a3b83 Convert all 23 register_custom_layout calls to register_sx_layout across 6 services
Layout defcomps are now fully self-contained via IO-primitive auto-fetch
macros, eliminating Python layout functions that manually threaded context
values through SxExpr wrappers.

Services converted:
- Federation (1 layout): social
- Blog (7 layouts): blog, blog-settings, blog-cache, blog-snippets,
  blog-menu-items, blog-tag-groups, blog-tag-group-edit
- SX docs (2 layouts): sx, sx-section
- Cart (2 layouts): cart-page, cart-admin + orders/order-detail
- Events (9 layouts): calendar-admin, slots, slot, day-admin, entry,
  entry-admin, ticket-types, ticket-type, markets
- Market (2 layouts): market, market-admin

New IO primitives added to shared/sx/primitives_io.py:
- federation-actor-ctx, cart-page-ctx, request-view-args
- events-calendar-ctx, events-day-ctx, events-entry-ctx,
  events-slot-ctx, events-ticket-type-ctx
- market-header-ctx (pre-builds desktop/mobile nav as SxExpr)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:21:44 +00:00
1db52472e3 Fix entry url_for endpoints: use defpage_entry_detail/defpage_entry_admin after auto-mount migration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:59:08 +00:00
278ae3e8f6 Make SxExpr a str subclass, sx_call/render functions return SxExpr
SxExpr is now a str subclass so it works everywhere a plain string
does (join, isinstance, f-strings) while serialize() still emits it
unquoted. sx_call() and all internal render functions (_render_to_sx,
async_eval_to_sx, etc.) return SxExpr, eliminating the "forgot to
wrap" bug class that caused the sx_content leak and list serialization
bugs.

- Phase 0: SxExpr(str) with .source property, __add__/__radd__
- Phase 1: sx_call returns SxExpr (drop-in, all 200+ sites unchanged)
- Phase 2: async_eval_to_sx, async_eval_slot_to_sx, _render_to_sx,
  mobile_menu_sx return SxExpr; remove isinstance(str) workaround
- Phase 3: Remove ~150 redundant SxExpr() wrappings across 45 files
- Phase 4: serialize() docstring, handler return docs, ;; returns: sx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:47:00 +00:00
ad75798ab7 Fix day admin url_for endpoints: use defpage_day_admin after auto-mount migration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:52:46 +00:00
94 changed files with 5386 additions and 6101 deletions

View File

@@ -69,7 +69,7 @@ def register(url_prefix="/"):
return sx_response(sx_call(
"account-newsletter-toggle",
id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf}"}}',
hdrs={"X-CSRFToken": csrf},
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}",
checked=checked,

View File

@@ -1,4 +1,5 @@
;; Account auth-menu fragment handler
;; returns: sx
;;
;; Renders the desktop + mobile auth menu (sign-in or user link).

View File

@@ -54,7 +54,7 @@
:toggle (~account-newsletter-toggle
:id (str "nl-" nid)
:url toggle-url
:hdrs (str "{\"X-CSRFToken\": \"" csrf "\"}")
:hdrs {:X-CSRFToken csrf}
: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)
:checked checked

View File

@@ -144,7 +144,7 @@ def _render_page_search_results(pages, query, page, has_more) -> str:
items_sx = "(<> " + " ".join(items) + ")"
return sx_call("page-search-results",
items=SxExpr(items_sx),
sentinel=SxExpr(sentinel) if sentinel else None)
sentinel=sentinel or None)
def _render_menu_items_nav_oob(menu_items) -> str:
@@ -191,12 +191,12 @@ def _render_menu_items_nav_oob(menu_items) -> str:
if item_slug != "cart":
item_parts.append(sx_call("blog-nav-item-link",
href=href, hx_get=f"/{item_slug}/", selected=selected,
nav_cls=nav_button_cls, img=SxExpr(img_sx), label=label,
nav_cls=nav_button_cls, img=img_sx, label=label,
))
else:
item_parts.append(sx_call("blog-nav-item-plain",
href=href, selected=selected, nav_cls=nav_button_cls,
img=SxExpr(img_sx), label=label,
img=img_sx, label=label,
))
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""

View File

@@ -138,7 +138,7 @@ def _render_calendar_view(
e_id = getattr(e, "id", None)
e_name = esc(getattr(e, "name", ""))
t_url = toggle_url_fn(e_id)
hx_hdrs = f'{{"X-CSRFToken": "{csrf}"}}'
hx_hdrs = '{:X-CSRFToken "' + csrf + '"}'
if e_id in associated_entry_ids:
entry_btns.append(
@@ -190,54 +190,14 @@ def _render_calendar_view(
def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str:
"""Render the associated entries panel."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for as qurl
from sxc.pages.helpers import _extract_associated_entries_data
csrf = generate_csrf_token()
entry_data = _extract_associated_entries_data(
all_calendars, associated_entry_ids, post_slug)
has_entries = False
entry_items: list[str] = []
for calendar in all_calendars:
entries = getattr(calendar, "entries", []) or []
cal_name = getattr(calendar, "name", "")
cal_post = getattr(calendar, "post", None)
cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None
cal_title = getattr(cal_post, "title", "") if cal_post else ""
for entry in entries:
e_id = getattr(entry, "id", None)
if e_id not in associated_entry_ids:
continue
if getattr(entry, "deleted_at", None) is not None:
continue
has_entries = True
e_name = getattr(entry, "name", "")
e_start = getattr(entry, "start_at", None)
e_end = getattr(entry, "end_at", None)
toggle_url = host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=e_id))
img_sx = sx_call("blog-entry-image", src=cal_fi, title=cal_title)
date_str = e_start.strftime("%A, %B %d, %Y at %H:%M") if e_start else ""
if e_end:
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
entry_items.append(sx_call("blog-associated-entry",
confirm_text=f"This will remove {e_name} from this post",
toggle_url=toggle_url,
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
img=SxExpr(img_sx), name=e_name,
date_str=f"{cal_name} \u2022 {date_str}",
))
if has_entries:
content_sx = sx_call("blog-associated-entries-content",
items=SxExpr("(<> " + " ".join(entry_items) + ")"),
)
else:
content_sx = sx_call("blog-associated-entries-empty")
return sx_call("blog-associated-entries-panel", content=SxExpr(content_sx))
return sx_call("blog-associated-entries-from-data",
entries=entry_data, csrf=csrf)
def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:

View File

@@ -156,14 +156,10 @@ def register():
csrf = generate_csrf_token()
def _like_btn(liked):
if liked:
colour, icon, label = "text-red-600", "fa-solid fa-heart", "Unlike this post"
else:
colour, icon, label = "text-stone-300", "fa-regular fa-heart", "Like this post"
return sx_call("market-like-toggle-button",
colour=colour, action=like_url,
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
label=label, icon_cls=icon)
return sx_call("blog-like-toggle",
like_url=like_url,
hx_headers={"X-CSRFToken": csrf},
heart="\u2764\ufe0f" if liked else "\U0001f90d")
if not g.user:
return sx_response(_like_btn(False), status=403)

View File

@@ -1,6 +1,13 @@
"""Blog page data service — provides serialized dicts for .sx defpages."""
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:
"""Service for blog page data, callable via (service "blog-page" ...)."""
@@ -424,7 +431,7 @@ class BlogPageService:
"authors": authors,
"feature_image": post.get("feature_image"),
"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):

View File

@@ -206,7 +206,7 @@
(when is-admin
(~blog-snippet-visibility-select
:patch-url (get s "patch_url")
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:hx-headers {:X-CSRFToken csrf}
:options (<>
(~blog-snippet-option :value "private" :selected (= vis "private") :label "private")
(~blog-snippet-option :value "shared" :selected (= vis "shared") :label "shared")
@@ -217,7 +217,7 @@
:trigger-target "#snippets-list"
:title "Delete snippet?"
: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"))))))
(or snippets (list)))))))
@@ -240,7 +240,7 @@
:edit-url (get mi "edit_url")
:delete-url (get mi "delete_url")
: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)))))))
;; Tag Groups — receives serialized tag group data from service
@@ -292,3 +292,123 @@
:name (get t "name")))
(or all-tags (list))))
:delete-form (~blog-tag-group-delete-form :delete-url delete-url :csrf csrf)))
;; ---------------------------------------------------------------------------
;; Preview content composition — replaces _h_post_preview_content
;; ---------------------------------------------------------------------------
(defcomp ~blog-preview-content (&key sx-pretty json-pretty sx-rendered lex-rendered)
(let* ((sections (list)))
(if (and (not sx-pretty) (not json-pretty) (not sx-rendered) (not lex-rendered))
(~blog-preview-empty)
(~blog-preview-panel :sections
(<>
(when sx-pretty
(~blog-preview-section :title "S-Expression Source" :content sx-pretty))
(when json-pretty
(~blog-preview-section :title "Lexical JSON" :content json-pretty))
(when sx-rendered
(~blog-preview-section :title "SX Rendered"
:content (~blog-preview-rendered :html sx-rendered)))
(when lex-rendered
(~blog-preview-section :title "Lexical Rendered"
:content (~blog-preview-rendered :html lex-rendered))))))))
;; ---------------------------------------------------------------------------
;; Data introspection composition — replaces _h_post_data_content
;; ---------------------------------------------------------------------------
(defcomp ~blog-data-value-cell (&key value value-type)
(if (= value-type "nil")
(span :class "text-neutral-400" "\u2014")
(pre :class "whitespace-pre-wrap break-words break-all text-xs"
(if (or (= value-type "date") (= value-type "other"))
(code value)
value))))
(defcomp ~blog-data-scalar-table (&key columns)
(div :class "w-full overflow-x-auto sm:overflow-visible"
(table :class "w-full table-fixed text-sm border border-neutral-200 rounded-xl overflow-hidden"
(thead :class "bg-neutral-50/70"
(tr (th :class "px-3 py-2 text-left font-medium w-40 sm:w-56" "Field")
(th :class "px-3 py-2 text-left font-medium" "Value")))
(tbody
(map (lambda (col)
(tr :class "border-t border-neutral-200 align-top"
(td :class "px-3 py-2 whitespace-nowrap text-neutral-600 align-top" (get col "key"))
(td :class "px-3 py-2 align-top"
(~blog-data-value-cell :value (get col "value") :value-type (get col "type")))))
(or columns (list)))))))
(defcomp ~blog-data-relationship-item (&key index summary children)
(tr :class "border-t border-neutral-200 align-top"
(td :class "px-2 py-1 whitespace-nowrap align-top" (str index))
(td :class "px-2 py-1 align-top"
(pre :class "whitespace-pre-wrap break-words break-all text-xs"
(code summary))
(when children
(div :class "mt-2 pl-3 border-l border-neutral-200"
(~blog-data-model-content
:columns (get children "columns")
:relationships (get children "relationships")))))))
(defcomp ~blog-data-relationship (&key name cardinality class-name loaded value)
(div :class "rounded-xl border border-neutral-200"
(div :class "px-3 py-2 bg-neutral-50/70 text-sm font-medium"
"Relationship: " (span :class "font-semibold" name)
(span :class "ml-2 text-xs text-neutral-500"
cardinality " \u2192 " class-name
(when (not loaded) " \u2022 " (em "not loaded"))))
(div :class "p-3 text-sm"
(if (not value)
(span :class "text-neutral-400" "\u2014")
(if (get value "is_list")
(<>
(div :class "text-neutral-500 mb-2"
(str (get value "count") " item" (if (= (get value "count") 1) "" "s")))
(when (get value "items")
(div :class "w-full overflow-x-auto sm:overflow-visible"
(table :class "w-full table-fixed text-sm border border-neutral-200 rounded-lg overflow-hidden"
(thead :class "bg-neutral-50/70"
(tr (th :class "px-2 py-1 text-left w-10" "#")
(th :class "px-2 py-1 text-left" "Summary")))
(tbody
(map (lambda (item)
(~blog-data-relationship-item
:index (get item "index")
:summary (get item "summary")
:children (get item "children")))
(get value "items")))))))
;; Single value
(<>
(pre :class "whitespace-pre-wrap break-words break-all text-xs mb-2"
(code (get value "summary")))
(when (get value "children")
(div :class "pl-3 border-l border-neutral-200"
(~blog-data-model-content
:columns (get (get value "children") "columns")
:relationships (get (get value "children") "relationships"))))))))))
(defcomp ~blog-data-model-content (&key columns relationships)
(div :class "space-y-4"
(~blog-data-scalar-table :columns columns)
(when (not (empty? (or relationships (list))))
(div :class "space-y-3"
(map (lambda (rel)
(~blog-data-relationship
:name (get rel "name")
:cardinality (get rel "cardinality")
:class-name (get rel "class_name")
:loaded (get rel "loaded")
:value (get rel "value")))
relationships)))))
(defcomp ~blog-data-table-content (&key tablename model-data)
(if (not model-data)
(div :class "px-4 py-8 text-stone-400" "No post data available.")
(div :class "px-4 py-8"
(div :class "mb-6 text-sm text-neutral-500"
"Model: " (code "Post") " \u2022 Table: " (code tablename))
(~blog-data-model-content
:columns (get model-data "columns")
:relationships (get model-data "relationships")))))

View File

@@ -2,8 +2,7 @@
(defcomp ~blog-like-button (&key like-url hx-headers heart)
(div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl"
(button :sx-post like-url :sx-swap "outerHTML"
:sx-headers hx-headers :class "cursor-pointer" heart)))
(~blog-like-toggle :like-url like-url :hx-headers hx-headers :heart heart)))
(defcomp ~blog-draft-status (&key publish-requested timestamp)
(<> (div :class "flex justify-center gap-2 mt-1"
@@ -56,7 +55,7 @@
(when has-like
(~blog-like-button
:like-url like-url
:sx-headers (str "{\"X-CSRFToken\": \"" csrf-token "\"}")
:hx-headers {:X-CSRFToken csrf-token}
:heart (if liked "❤️" "🤍")))
(a :href href :sx-get href :sx-target "#main-panel"
:sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true"

View File

@@ -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"))
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)
(div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl"
(button :sx-post like-url :sx-swap "outerHTML"
:sx-headers hx-headers :class "cursor-pointer" heart)))
(~blog-like-toggle :like-url like-url :hx-headers hx-headers :heart heart)))
(defcomp ~blog-detail-excerpt (&key excerpt)
(div :class "w-full text-center italic text-3xl p-2" excerpt))
@@ -55,8 +58,8 @@
:like (when has-user
(~blog-detail-like
:like-url like-url
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:heart (if liked "\u2764\ufe0f" "\U0001f90d")))
:hx-headers {:X-CSRFToken csrf}
:heart (if liked "❤️" "🤍")))
:excerpt (when (not (= custom-excerpt ""))
(~blog-detail-excerpt :excerpt custom-excerpt))
:at-bar (~blog-at-bar :tags tags :authors authors)))))

View File

@@ -303,3 +303,48 @@
;; Drag over editor
".sx-drag-over { outline: 2px dashed #3b82f6; outline-offset: -2px; border-radius: 4px; }"))
;; ---------------------------------------------------------------------------
;; Editor panel composition — replaces render_editor_panel (new post/page)
;; ---------------------------------------------------------------------------
(defcomp ~blog-editor-content (&key csrf title-placeholder create-label
css-href js-src sx-editor-js-src init-js
save-error)
(~blog-editor-panel :parts
(<>
(when save-error (~blog-editor-error :error save-error))
(~blog-editor-form :csrf csrf :title-placeholder title-placeholder
:create-label create-label)
(~blog-editor-styles :css-href css-href)
(~sx-editor-styles)
(~blog-editor-scripts :js-src js-src :sx-editor-js-src sx-editor-js-src
:init-js init-js))))
;; ---------------------------------------------------------------------------
;; Edit content composition — replaces _h_post_edit_content (existing post)
;; ---------------------------------------------------------------------------
(defcomp ~blog-edit-content (&key csrf updated-at title-val excerpt-val
feature-image feature-image-caption
sx-content-val lexical-json has-sx
title-placeholder status already-emailed
newsletter-options footer-extra
css-href js-src sx-editor-js-src init-js
save-error)
(~blog-editor-panel :parts
(<>
(when save-error (~blog-editor-error :error save-error))
(~blog-editor-edit-form
:csrf csrf :updated-at updated-at
:title-val title-val :excerpt-val excerpt-val
:feature-image feature-image :feature-image-caption feature-image-caption
:sx-content-val sx-content-val :lexical-json lexical-json
:has-sx has-sx :title-placeholder title-placeholder
:status status :already-emailed already-emailed
:newsletter-options newsletter-options :footer-extra footer-extra)
(~blog-editor-publish-js :already-emailed already-emailed)
(~blog-editor-styles :css-href css-href)
(~sx-editor-styles)
(~blog-editor-scripts :js-src js-src :sx-editor-js-src sx-editor-js-src
:init-js init-js))))

View File

@@ -1,4 +1,5 @@
;; Blog link-card fragment handler
;; returns: sx
;;
;; Renders link-card(s) for blog posts by slug.
;; Supports single mode (?slug=x) and batch mode (?keys=x,y,z).

View File

@@ -1,4 +1,5 @@
;; Blog nav-tree fragment handler
;; returns: sx
;;
;; Renders the full scrollable navigation menu bar with app icons.
;; Uses nav-tree I/O primitive to fetch menu nodes from the blog DB.

View File

@@ -1,4 +1,5 @@
;; Blog layout defcomps — fully self-contained via IO primitives.
;; Registered via register_sx_layout in __init__.py.
;; --- Blog header (invisible row for blog-header-child swap target) ---
@@ -7,28 +8,161 @@
:link-label-content (div)
:child-id "blog-header-child" :oob oob))
;; --- Blog layout (root + blog header) ---
;; --- Auto-fetching settings header macro ---
(defmacro ~blog-settings-header-auto (oob)
(quasiquote
(~menu-row-sx :id "root-settings-row" :level 1
:link-href (url-for "settings.defpage_settings_home")
:link-label-content (~blog-admin-label)
:nav (~blog-settings-nav)
:child-id "root-settings-header-child"
:oob (unquote oob))))
;; --- Auto-fetching sub-settings header macro ---
(defmacro ~blog-sub-settings-header-auto (row-id child-id endpoint icon label oob)
(quasiquote
(~menu-row-sx :id (unquote row-id) :level 2
:link-href (url-for (unquote endpoint))
:link-label-content (~blog-sub-settings-label
:icon (str "fa fa-" (unquote icon))
:label (unquote label))
:child-id (unquote child-id)
:oob (unquote oob))))
;; ---------------------------------------------------------------------------
;; Blog layout (root + blog header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-layout-full ()
(<> (~root-header-auto)
(~blog-header)))
;; --- Settings layout (root + settings header) ---
(defcomp ~blog-layout-oob ()
(<> (~blog-header :oob true)
(~clear-oob-div :id "blog-header-child")
(~root-header-auto true)))
(defcomp ~settings-layout-full (&key settings-header)
;; ---------------------------------------------------------------------------
;; Settings layout (root + settings header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-settings-layout-full ()
(<> (~root-header-auto)
settings-header))
(~blog-settings-header-auto)))
;; --- Sub-settings layout (root + settings + sub row) ---
(defcomp ~blog-settings-layout-oob ()
(<> (~blog-settings-header-auto true)
(~clear-oob-div :id "root-settings-header-child")
(~root-header-auto true)))
(defcomp ~sub-settings-layout-full (&key settings-header sub-header)
(defcomp ~blog-settings-layout-mobile ()
(~blog-settings-nav))
;; ---------------------------------------------------------------------------
;; Cache layout (root + settings + cache sub-header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-cache-layout-full ()
(<> (~root-header-auto)
settings-header sub-header))
(~blog-settings-header-auto)
(~blog-sub-settings-header-auto
"cache-row" "cache-header-child"
"settings.defpage_cache_page" "refresh" "Cache")))
(defcomp ~sub-settings-layout-oob (&key settings-header-oob sub-header-oob)
(<> settings-header-oob sub-header-oob))
(defcomp ~blog-cache-layout-oob ()
(<> (~blog-sub-settings-header-auto
"cache-row" "cache-header-child"
"settings.defpage_cache_page" "refresh" "Cache" true)
(~clear-oob-div :id "cache-header-child")
(~blog-settings-header-auto true)
(~root-header-auto true)))
;; --- Settings nav links — uses (select-colours) IO primitive ---
;; ---------------------------------------------------------------------------
;; Snippets layout (root + settings + snippets sub-header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-snippets-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)
(~blog-sub-settings-header-auto
"snippets-row" "snippets-header-child"
"snippets.defpage_snippets_page" "puzzle-piece" "Snippets")))
(defcomp ~blog-snippets-layout-oob ()
(<> (~blog-sub-settings-header-auto
"snippets-row" "snippets-header-child"
"snippets.defpage_snippets_page" "puzzle-piece" "Snippets" true)
(~clear-oob-div :id "snippets-header-child")
(~blog-settings-header-auto true)
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; Menu Items layout (root + settings + menu-items sub-header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-menu-items-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)
(~blog-sub-settings-header-auto
"menu_items-row" "menu_items-header-child"
"menu_items.defpage_menu_items_page" "bars" "Menu Items")))
(defcomp ~blog-menu-items-layout-oob ()
(<> (~blog-sub-settings-header-auto
"menu_items-row" "menu_items-header-child"
"menu_items.defpage_menu_items_page" "bars" "Menu Items" true)
(~clear-oob-div :id "menu_items-header-child")
(~blog-settings-header-auto true)
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; Tag Groups layout (root + settings + tag-groups sub-header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-tag-groups-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)
(~blog-sub-settings-header-auto
"tag-groups-row" "tag-groups-header-child"
"blog.tag_groups_admin.defpage_tag_groups_page" "tags" "Tag Groups")))
(defcomp ~blog-tag-groups-layout-oob ()
(<> (~blog-sub-settings-header-auto
"tag-groups-row" "tag-groups-header-child"
"blog.tag_groups_admin.defpage_tag_groups_page" "tags" "Tag Groups" true)
(~clear-oob-div :id "tag-groups-header-child")
(~blog-settings-header-auto true)
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; Tag Group Edit layout (root + settings + tag-groups sub-header with id)
;; ---------------------------------------------------------------------------
(defcomp ~blog-tag-group-edit-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)
(~menu-row-sx :id "tag-groups-row" :level 2
:link-href (url-for "blog.tag_groups_admin.defpage_tag_group_edit"
:id (request-view-args "id"))
:link-label-content (~blog-sub-settings-label
:icon "fa fa-tags" :label "Tag Groups")
:child-id "tag-groups-header-child")))
(defcomp ~blog-tag-group-edit-layout-oob ()
(<> (~menu-row-sx :id "tag-groups-row" :level 2
:link-href (url-for "blog.tag_groups_admin.defpage_tag_group_edit"
:id (request-view-args "id"))
:link-label-content (~blog-sub-settings-label
:icon "fa fa-tags" :label "Tag Groups")
:child-id "tag-groups-header-child"
:oob true)
(~clear-oob-div :id "tag-groups-header-child")
(~blog-settings-header-auto true)
(~root-header-auto true)))
;; --- Settings nav links — uses IO primitives ---
(defcomp ~blog-settings-nav ()
(let* ((sc (select-colours))

View File

@@ -2,7 +2,7 @@
(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"
: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"
(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"
@@ -126,3 +126,167 @@
(div :id "associated-entries-list" :class "border rounded-lg p-4 bg-white"
(h3 :class "text-lg font-semibold mb-4" "Associated Entries")
content))
;; ---------------------------------------------------------------------------
;; Associated entries composition — replaces _render_associated_entries
;; ---------------------------------------------------------------------------
(defcomp ~blog-associated-entries-from-data (&key entries csrf)
(~blog-associated-entries-panel
:content (if (empty? (or entries (list)))
(~blog-associated-entries-empty)
(~blog-associated-entries-content
:items (map (lambda (e)
(~blog-associated-entry
:confirm-text (get e "confirm_text")
:toggle-url (get e "toggle_url")
:hx-headers {:X-CSRFToken csrf}
:img (~blog-entry-image :src (get e "cal_image") :title (get e "cal_title"))
:name (get e "name")
:date-str (get e "date_str")))
(or entries (list)))))))
;; ---------------------------------------------------------------------------
;; Entries browser composition — replaces _h_post_entries_content
;; ---------------------------------------------------------------------------
(defcomp ~blog-calendar-browser-item (&key name title image view-url)
(details :class "border rounded-lg bg-white" :data-toggle-group "calendar-browser"
(summary :class "p-4 cursor-pointer hover:bg-stone-50 flex items-center gap-3"
(if image
(img :src image :alt title :class "w-12 h-12 rounded object-cover flex-shrink-0")
(div :class "w-12 h-12 rounded bg-stone-200 flex-shrink-0"))
(div :class "flex-1"
(div :class "font-semibold flex items-center gap-2"
(i :class "fa fa-calendar text-stone-500") " " name)
(div :class "text-sm text-stone-600" title)))
(div :class "p-4 border-t" :sx-get view-url :sx-trigger "intersect once" :sx-swap "innerHTML"
(div :class "text-sm text-stone-400" "Loading calendar..."))))
(defcomp ~blog-entries-browser-content (&key entries-panel calendars)
(div :id "post-entries-content" :class "space-y-6 p-4"
entries-panel
(div :class "space-y-3"
(h3 :class "text-lg font-semibold" "Browse Calendars")
(if (empty? (or calendars (list)))
(div :class "text-sm text-stone-400" "No calendars found.")
(map (lambda (cal)
(~blog-calendar-browser-item
:name (get cal "name")
:title (get cal "title")
:image (get cal "image")
:view-url (get cal "view_url")))
(or calendars (list)))))))
;; ---------------------------------------------------------------------------
;; Post settings form composition — replaces _h_post_settings_content
;; ---------------------------------------------------------------------------
(defcomp ~blog-settings-field-label (&key text field-for)
(label :for field-for
:class "block text-[13px] font-medium text-stone-500 mb-[4px]" text))
(defcomp ~blog-settings-section (&key title content is-open)
(details :class "border border-stone-200 rounded-[8px] overflow-hidden" :open is-open
(summary :class "px-[16px] py-[10px] bg-stone-50 text-[14px] font-medium text-stone-600 cursor-pointer select-none hover:bg-stone-100 transition-colors"
title)
(div :class "px-[16px] py-[12px] space-y-[12px]" content)))
(defcomp ~blog-settings-form-content (&key csrf updated-at is-page save-success
slug published-at featured visibility email-only
tags feature-image-alt
meta-title meta-description canonical-url
og-title og-description og-image
twitter-title twitter-description twitter-image
custom-template)
(let* ((input-cls "w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px] bg-white text-stone-700 placeholder:text-stone-300 focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300")
(textarea-cls (str input-cls " resize-y"))
(slug-placeholder (if is-page "page-slug" "post-slug"))
(tmpl-placeholder (if is-page "custom-page.hbs" "custom-post.hbs"))
(featured-label (if is-page "Featured page" "Featured post")))
(form :method "post" :class "max-w-[640px] mx-auto pb-[48px] px-[16px]"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "updated_at" :value (or updated-at ""))
(div :class "space-y-[12px] mt-[16px]"
;; General
(~blog-settings-section :title "General" :is-open true :content
(<>
(div (~blog-settings-field-label :text "Slug" :field-for "settings-slug")
(input :type "text" :name "slug" :id "settings-slug" :value (or slug "")
:placeholder slug-placeholder :class input-cls))
(div (~blog-settings-field-label :text "Published at" :field-for "settings-published_at")
(input :type "datetime-local" :name "published_at" :id "settings-published_at"
:value (or published-at "") :class input-cls))
(div (label :class "inline-flex items-center gap-[8px] cursor-pointer"
(input :type "checkbox" :name "featured" :id "settings-featured" :checked featured
:class "rounded border-stone-300 text-stone-600 focus:ring-stone-300")
(span :class "text-[14px] text-stone-600" featured-label)))
(div (~blog-settings-field-label :text "Visibility" :field-for "settings-visibility")
(select :name "visibility" :id "settings-visibility" :class input-cls
(option :value "public" :selected (= visibility "public") "Public")
(option :value "members" :selected (= visibility "members") "Members")
(option :value "paid" :selected (= visibility "paid") "Paid")))
(div (label :class "inline-flex items-center gap-[8px] cursor-pointer"
(input :type "checkbox" :name "email_only" :id "settings-email_only" :checked email-only
:class "rounded border-stone-300 text-stone-600 focus:ring-stone-300")
(span :class "text-[14px] text-stone-600" "Email only")))))
;; Tags
(~blog-settings-section :title "Tags" :content
(div (~blog-settings-field-label :text "Tags (comma-separated)" :field-for "settings-tags")
(input :type "text" :name "tags" :id "settings-tags" :value (or tags "")
:placeholder "news, updates, featured" :class input-cls)
(p :class "text-[12px] text-stone-400 mt-[4px]" "Unknown tags will be created automatically.")))
;; Feature Image
(~blog-settings-section :title "Feature Image" :content
(div (~blog-settings-field-label :text "Alt text" :field-for "settings-feature_image_alt")
(input :type "text" :name "feature_image_alt" :id "settings-feature_image_alt"
:value (or feature-image-alt "") :placeholder "Describe the feature image" :class input-cls)))
;; SEO / Meta
(~blog-settings-section :title "SEO / Meta" :content
(<>
(div (~blog-settings-field-label :text "Meta title" :field-for "settings-meta_title")
(input :type "text" :name "meta_title" :id "settings-meta_title" :value (or meta-title "")
:placeholder "SEO title" :maxlength "300" :class input-cls)
(p :class "text-[12px] text-stone-400 mt-[2px]" "Recommended: 70 characters. Max: 300."))
(div (~blog-settings-field-label :text "Meta description" :field-for "settings-meta_description")
(textarea :name "meta_description" :id "settings-meta_description" :rows "2"
:placeholder "SEO description" :maxlength "500" :class textarea-cls
(or meta-description ""))
(p :class "text-[12px] text-stone-400 mt-[2px]" "Recommended: 156 characters."))
(div (~blog-settings-field-label :text "Canonical URL" :field-for "settings-canonical_url")
(input :type "url" :name "canonical_url" :id "settings-canonical_url"
:value (or canonical-url "") :placeholder "https://example.com/original-post" :class input-cls))))
;; Facebook / OpenGraph
(~blog-settings-section :title "Facebook / OpenGraph" :content
(<>
(div (~blog-settings-field-label :text "OG title" :field-for "settings-og_title")
(input :type "text" :name "og_title" :id "settings-og_title" :value (or og-title "") :class input-cls))
(div (~blog-settings-field-label :text "OG description" :field-for "settings-og_description")
(textarea :name "og_description" :id "settings-og_description" :rows "2" :class textarea-cls
(or og-description "")))
(div (~blog-settings-field-label :text "OG image URL" :field-for "settings-og_image")
(input :type "url" :name "og_image" :id "settings-og_image" :value (or og-image "")
:placeholder "https://..." :class input-cls))))
;; X / Twitter
(~blog-settings-section :title "X / Twitter" :content
(<>
(div (~blog-settings-field-label :text "Twitter title" :field-for "settings-twitter_title")
(input :type "text" :name "twitter_title" :id "settings-twitter_title"
:value (or twitter-title "") :class input-cls))
(div (~blog-settings-field-label :text "Twitter description" :field-for "settings-twitter_description")
(textarea :name "twitter_description" :id "settings-twitter_description" :rows "2" :class textarea-cls
(or twitter-description "")))
(div (~blog-settings-field-label :text "Twitter image URL" :field-for "settings-twitter_image")
(input :type "url" :name "twitter_image" :id "settings-twitter_image"
:value (or twitter-image "") :placeholder "https://..." :class input-cls))))
;; Advanced
(~blog-settings-section :title "Advanced" :content
(div (~blog-settings-field-label :text "Custom template" :field-for "settings-custom_template")
(input :type "text" :name "custom_template" :id "settings-custom_template"
:value (or custom-template "") :placeholder tmpl-placeholder :class input-cls))))
(div :class "flex items-center gap-[16px] mt-[24px] pt-[16px] border-t border-stone-200"
(button :type "submit"
:class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer"
"Save settings")
(when save-success
(span :class "text-[14px] text-green-600" "Saved."))))))

View File

@@ -1,5 +1,6 @@
; Blog app defpage declarations
; Pages kept as Python: home, index, post-detail (cache_page / complex branching)
; All helpers return data dicts — markup composition in SX.
; --- New post/page editors ---
@@ -7,13 +8,23 @@
:path "/new/"
:auth :admin
:layout :blog
:content (editor-content))
:data (editor-data)
:content (~blog-editor-content
:csrf csrf :title-placeholder title-placeholder
:create-label create-label :css-href css-href
:js-src js-src :sx-editor-js-src sx-editor-js-src
:init-js init-js))
(defpage new-page
:path "/new-page/"
:auth :admin
:layout :blog
:content (editor-page-content))
:data (editor-page-data)
:content (~blog-editor-content
:csrf csrf :title-placeholder title-placeholder
:create-label create-label :css-href css-href
:js-src js-src :sx-editor-js-src sx-editor-js-src
:init-js init-js))
; --- Post admin pages (absolute paths under /<slug>/admin/) ---
@@ -21,37 +32,71 @@
:path "/<slug>/admin/"
:auth :admin
:layout (:post-admin :selected "admin")
:content (post-admin-content slug))
:data (post-admin-data slug)
:content (~blog-admin-placeholder))
(defpage post-data
:path "/<slug>/admin/data/"
:auth :admin
:layout (:post-admin :selected "data")
:content (post-data-content slug))
:data (post-data-data slug)
:content (~blog-data-table-content :tablename tablename :model-data model-data))
(defpage post-preview
:path "/<slug>/admin/preview/"
:auth :admin
:layout (:post-admin :selected "preview")
:content (post-preview-content slug))
:data (post-preview-data slug)
:content (~blog-preview-content
:sx-pretty sx-pretty :json-pretty json-pretty
:sx-rendered sx-rendered :lex-rendered lex-rendered))
(defpage post-entries
:path "/<slug>/admin/entries/"
:auth :admin
:layout (:post-admin :selected "entries")
:content (post-entries-content slug))
:data (post-entries-data slug)
:content (~blog-entries-browser-content
:entries-panel (~blog-associated-entries-from-data :entries entries :csrf csrf)
:calendars calendars))
(defpage post-settings
:path "/<slug>/admin/settings/"
:auth :post_author
:layout (:post-admin :selected "settings")
:content (post-settings-content slug))
:data (post-settings-data slug)
:content (~blog-settings-form-content
:csrf csrf :updated-at updated-at :is-page is-page
:save-success save-success :slug settings-slug
:published-at published-at :featured featured
:visibility visibility :email-only email-only
:tags tags :feature-image-alt feature-image-alt
:meta-title meta-title :meta-description meta-description
:canonical-url canonical-url :og-title og-title
:og-description og-description :og-image og-image
:twitter-title twitter-title :twitter-description twitter-description
:twitter-image twitter-image :custom-template custom-template))
(defpage post-edit
:path "/<slug>/admin/edit/"
:auth :post_author
:layout (:post-admin :selected "edit")
:content (post-edit-content slug))
:data (post-edit-data slug)
:content (~blog-edit-content
:csrf csrf :updated-at updated-at
:title-val title-val :excerpt-val excerpt-val
:feature-image feature-image :feature-image-caption feature-image-caption
:sx-content-val sx-content-val :lexical-json lexical-json
:has-sx has-sx :title-placeholder title-placeholder
:status status :already-emailed already-emailed
:newsletter-options (<>
(option :value "" "Select newsletter\u2026")
(map (fn (nl) (option :value (get nl "slug") (get nl "name"))) newsletters))
:footer-extra (when badges
(<> (map (fn (b) (span :class (get b "cls") (get b "text"))) badges)))
:css-href css-href :js-src js-src
:sx-editor-js-src sx-editor-js-src
:init-js init-js :save-error save-error))
; --- Settings pages (absolute paths) ---

File diff suppressed because it is too large Load Diff

View File

@@ -1,203 +1,19 @@
"""Blog layout functions for defpage rendering."""
"""Blog layout registration — all layouts delegate to .sx defcomps."""
from __future__ import annotations
from typing import Any
# ---------------------------------------------------------------------------
# Header helpers (moved from sx_components — thin sx_call wrappers)
# ---------------------------------------------------------------------------
def _settings_header_sx(ctx: dict, *, oob: bool = False) -> str:
from shared.sx.helpers import sx_call
from shared.sx.parser import SxExpr
from quart import url_for as qurl
settings_href = qurl("settings.defpage_settings_home")
label_sx = sx_call("blog-admin-label")
nav_sx = _settings_nav_sx(ctx)
return sx_call("menu-row-sx",
id="root-settings-row", level=1,
link_href=settings_href,
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx) if nav_sx else None,
child_id="root-settings-header-child", oob=oob)
def _settings_nav_sx(ctx: dict) -> str:
from shared.sx.helpers import sx_call
return sx_call("blog-settings-nav")
def _sub_settings_header_sx(row_id: str, child_id: str, href: str,
icon: str, label: str, ctx: dict,
*, oob: bool = False, nav_sx: str = "") -> str:
from shared.sx.helpers import sx_call
from shared.sx.parser import SxExpr
label_sx = sx_call("blog-sub-settings-label",
icon=f"fa fa-{icon}", label=label)
return sx_call("menu-row-sx",
id=row_id, level=2,
link_href=href,
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx) if nav_sx else None,
child_id=child_id, oob=oob)
# ---------------------------------------------------------------------------
# Layouts
# ---------------------------------------------------------------------------
def _register_blog_layouts() -> None:
from shared.sx.layouts import register_custom_layout
register_custom_layout("blog", _blog_full, _blog_oob)
register_custom_layout("blog-settings", _settings_full, _settings_oob,
mobile_fn=_settings_mobile)
register_custom_layout("blog-cache", _cache_full, _cache_oob)
register_custom_layout("blog-snippets", _snippets_full, _snippets_oob)
register_custom_layout("blog-menu-items", _menu_items_full, _menu_items_oob)
register_custom_layout("blog-tag-groups", _tag_groups_full, _tag_groups_oob)
register_custom_layout("blog-tag-group-edit",
_tag_group_edit_full, _tag_group_edit_oob)
# --- Blog layout (root + blog header) ---
async def _blog_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env
return await render_to_sx_with_env("blog-layout-full", {})
async def _blog_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env, oob_header_sx
rows = await render_to_sx_with_env("blog-layout-full", {})
return await oob_header_sx("root-header-child", "blog-header-child", rows)
# --- Settings layout (root + settings header) ---
async def _settings_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env
from shared.sx.parser import SxExpr
return await render_to_sx_with_env("settings-layout-full", {},
settings_header=SxExpr(_settings_header_sx(ctx)))
async def _settings_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env, oob_header_sx
from shared.sx.parser import SxExpr
rows = await render_to_sx_with_env("settings-layout-full", {},
settings_header=SxExpr(_settings_header_sx(ctx)))
return await oob_header_sx("root-header-child", "root-settings-header-child", rows)
def _settings_mobile(ctx: dict, **kw: Any) -> str:
return _settings_nav_sx(ctx)
# --- Sub-settings helpers ---
async def _sub_settings_full(ctx: dict, row_id: str, child_id: str,
endpoint: str, icon: str, label: str) -> str:
from shared.sx.helpers import render_to_sx_with_env
from shared.sx.parser import SxExpr
from quart import url_for as qurl
return await render_to_sx_with_env("sub-settings-layout-full", {},
settings_header=SxExpr(_settings_header_sx(ctx)),
sub_header=SxExpr(_sub_settings_header_sx(
row_id, child_id, qurl(endpoint), icon, label, ctx)))
async def _sub_settings_oob(ctx: dict, row_id: str, child_id: str,
endpoint: str, icon: str, label: str) -> str:
from shared.sx.helpers import oob_header_sx, sx_call
from shared.sx.parser import SxExpr
from quart import url_for as qurl
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
sub_hdr = _sub_settings_header_sx(
row_id, child_id, qurl(endpoint), icon, label, ctx)
sub_oob = await oob_header_sx("root-settings-header-child", child_id, sub_hdr)
return sx_call("sub-settings-layout-oob",
settings_header_oob=SxExpr(settings_hdr_oob),
sub_header_oob=SxExpr(sub_oob))
# --- Cache ---
async def _cache_full(ctx: dict, **kw: Any) -> str:
return await _sub_settings_full(ctx, "cache-row", "cache-header-child",
"defpage_cache_page", "refresh", "Cache")
async def _cache_oob(ctx: dict, **kw: Any) -> str:
return await _sub_settings_oob(ctx, "cache-row", "cache-header-child",
"defpage_cache_page", "refresh", "Cache")
# --- Snippets ---
async def _snippets_full(ctx: dict, **kw: Any) -> str:
return await _sub_settings_full(ctx, "snippets-row", "snippets-header-child",
"defpage_snippets_page", "puzzle-piece", "Snippets")
async def _snippets_oob(ctx: dict, **kw: Any) -> str:
return await _sub_settings_oob(ctx, "snippets-row", "snippets-header-child",
"defpage_snippets_page", "puzzle-piece", "Snippets")
# --- Menu Items ---
async def _menu_items_full(ctx: dict, **kw: Any) -> str:
return await _sub_settings_full(ctx, "menu_items-row", "menu_items-header-child",
"defpage_menu_items_page", "bars", "Menu Items")
async def _menu_items_oob(ctx: dict, **kw: Any) -> str:
return await _sub_settings_oob(ctx, "menu_items-row", "menu_items-header-child",
"defpage_menu_items_page", "bars", "Menu Items")
# --- Tag Groups ---
async def _tag_groups_full(ctx: dict, **kw: Any) -> str:
return await _sub_settings_full(ctx, "tag-groups-row", "tag-groups-header-child",
"defpage_tag_groups_page", "tags", "Tag Groups")
async def _tag_groups_oob(ctx: dict, **kw: Any) -> str:
return await _sub_settings_oob(ctx, "tag-groups-row", "tag-groups-header-child",
"defpage_tag_groups_page", "tags", "Tag Groups")
# --- Tag Group Edit ---
async def _tag_group_edit_full(ctx: dict, **kw: Any) -> str:
from quart import request, url_for as qurl
from shared.sx.helpers import render_to_sx_with_env
from shared.sx.parser import SxExpr
g_id = (request.view_args or {}).get("id")
return await render_to_sx_with_env("sub-settings-layout-full", {},
settings_header=SxExpr(_settings_header_sx(ctx)),
sub_header=SxExpr(_sub_settings_header_sx(
"tag-groups-row", "tag-groups-header-child",
qurl("defpage_tag_group_edit", id=g_id),
"tags", "Tag Groups", ctx)))
async def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str:
from quart import request, url_for as qurl
from shared.sx.helpers import oob_header_sx, sx_call
from shared.sx.parser import SxExpr
g_id = (request.view_args or {}).get("id")
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
sub_hdr = _sub_settings_header_sx(
"tag-groups-row", "tag-groups-header-child",
qurl("defpage_tag_group_edit", id=g_id),
"tags", "Tag Groups", ctx)
sub_oob = await oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr)
return sx_call("sub-settings-layout-oob",
settings_header_oob=SxExpr(settings_hdr_oob),
sub_header_oob=SxExpr(sub_oob))
from shared.sx.layouts import register_sx_layout
register_sx_layout("blog", "blog-layout-full", "blog-layout-oob")
register_sx_layout("blog-settings", "blog-settings-layout-full",
"blog-settings-layout-oob", "blog-settings-layout-mobile")
register_sx_layout("blog-cache", "blog-cache-layout-full",
"blog-cache-layout-oob")
register_sx_layout("blog-snippets", "blog-snippets-layout-full",
"blog-snippets-layout-oob")
register_sx_layout("blog-menu-items", "blog-menu-items-layout-full",
"blog-menu-items-layout-oob")
register_sx_layout("blog-tag-groups", "blog-tag-groups-layout-full",
"blog-tag-groups-layout-oob")
register_sx_layout("blog-tag-group-edit", "blog-tag-group-edit-layout-full",
"blog-tag-group-edit-layout-oob")

View File

@@ -3,175 +3,23 @@ from __future__ import annotations
def render_editor_panel(save_error: str | None = None, is_page: bool = False) -> str:
"""Build the WYSIWYG editor panel HTML for new post/page creation."""
import os
from quart import url_for as qurl, current_app
"""Build the WYSIWYG editor panel for new post/page creation."""
from shared.browser.app.csrf import generate_csrf_token
from shared.sx.helpers import sx_call
from .helpers import _editor_urls, _editor_init_js
urls = _editor_urls()
csrf = generate_csrf_token()
asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "")
editor_css = asset_url_fn("scripts/editor.css")
editor_js = asset_url_fn("scripts/editor.js")
sx_editor_js = asset_url_fn("scripts/sx-editor.js")
upload_image_url = qurl("blog.editor_api.upload_image")
upload_media_url = qurl("blog.editor_api.upload_media")
upload_file_url = qurl("blog.editor_api.upload_file")
oembed_url = qurl("blog.editor_api.oembed_proxy")
snippets_url = qurl("blog.editor_api.list_snippets")
unsplash_key = os.environ.get("UNSPLASH_ACCESS_KEY", "")
title_placeholder = "Page title..." if is_page else "Post title..."
create_label = "Create Page" if is_page else "Create Post"
init_js = _editor_init_js(urls, form_id="post-new-form", has_initial_json=False)
parts: list[str] = []
if save_error:
parts.append(sx_call("blog-editor-error", error=str(save_error)))
parts.append(sx_call("blog-editor-form",
csrf=csrf, title_placeholder=title_placeholder,
return sx_call("blog-editor-content",
csrf=csrf,
title_placeholder=title_placeholder,
create_label=create_label,
))
parts.append(sx_call("blog-editor-styles", css_href=editor_css))
parts.append(sx_call("sx-editor-styles"))
init_js = (
"console.log('[EDITOR-DEBUG] init script running');\n"
"(function() {\n"
" console.log('[EDITOR-DEBUG] IIFE entered, mountEditor=', typeof window.mountEditor);\n"
" function init() {\n"
" var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;\n"
f" var uploadUrl = '{upload_image_url}';\n"
" var uploadUrls = {\n"
" image: uploadUrl,\n"
f" media: '{upload_media_url}',\n"
f" file: '{upload_file_url}',\n"
" };\n"
"\n"
" var fileInput = document.getElementById('feature-image-file');\n"
" var addBtn = document.getElementById('feature-image-add-btn');\n"
" var deleteBtn = document.getElementById('feature-image-delete-btn');\n"
" var preview = document.getElementById('feature-image-preview');\n"
" var emptyState = document.getElementById('feature-image-empty');\n"
" var filledState = document.getElementById('feature-image-filled');\n"
" var hiddenUrl = document.getElementById('feature-image-input');\n"
" var hiddenCaption = document.getElementById('feature-image-caption-input');\n"
" var captionInput = document.getElementById('feature-image-caption');\n"
" var uploading = document.getElementById('feature-image-uploading');\n"
"\n"
" function showFilled(url) {\n"
" preview.src = url;\n"
" hiddenUrl.value = url;\n"
" emptyState.classList.add('hidden');\n"
" filledState.classList.remove('hidden');\n"
" uploading.classList.add('hidden');\n"
" }\n"
"\n"
" function showEmpty() {\n"
" preview.src = '';\n"
" hiddenUrl.value = '';\n"
" hiddenCaption.value = '';\n"
" captionInput.value = '';\n"
" emptyState.classList.remove('hidden');\n"
" filledState.classList.add('hidden');\n"
" uploading.classList.add('hidden');\n"
" }\n"
"\n"
" function uploadFile(file) {\n"
" emptyState.classList.add('hidden');\n"
" uploading.classList.remove('hidden');\n"
" var fd = new FormData();\n"
" fd.append('file', file);\n"
" fetch(uploadUrl, {\n"
" method: 'POST',\n"
" body: fd,\n"
" headers: { 'X-CSRFToken': csrfToken },\n"
" })\n"
" .then(function(r) {\n"
" if (!r.ok) throw new Error('Upload failed (' + r.status + ')');\n"
" return r.json();\n"
" })\n"
" .then(function(data) {\n"
" var url = data.images && data.images[0] && data.images[0].url;\n"
" if (url) showFilled(url);\n"
" else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }\n"
" })\n"
" .catch(function(e) {\n"
" showEmpty();\n"
" alert(e.message);\n"
" });\n"
" }\n"
"\n"
" addBtn.addEventListener('click', function() { fileInput.click(); });\n"
" preview.addEventListener('click', function() { fileInput.click(); });\n"
" deleteBtn.addEventListener('click', function(e) {\n"
" e.stopPropagation();\n"
" showEmpty();\n"
" });\n"
" fileInput.addEventListener('change', function() {\n"
" if (fileInput.files && fileInput.files[0]) {\n"
" uploadFile(fileInput.files[0]);\n"
" fileInput.value = '';\n"
" }\n"
" });\n"
" captionInput.addEventListener('input', function() {\n"
" hiddenCaption.value = captionInput.value;\n"
" });\n"
"\n"
" var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');\n"
" function autoResize() {\n"
" excerpt.style.height = 'auto';\n"
" excerpt.style.height = excerpt.scrollHeight + 'px';\n"
" }\n"
" excerpt.addEventListener('input', autoResize);\n"
" autoResize();\n"
"\n"
" window.mountEditor('lexical-editor', {\n"
" initialJson: null,\n"
" csrfToken: csrfToken,\n"
" uploadUrls: uploadUrls,\n"
f" oembedUrl: '{oembed_url}',\n"
f" unsplashApiKey: '{unsplash_key}',\n"
f" snippetsUrl: '{snippets_url}',\n"
" });\n"
"\n"
" if (typeof SxEditor !== 'undefined') {\n"
" SxEditor.mount('sx-editor', {\n"
" initialSx: (document.getElementById('sx-content-input') || {}).value || null,\n"
" csrfToken: csrfToken,\n"
" uploadUrls: uploadUrls,\n"
f" oembedUrl: '{oembed_url}',\n"
" onChange: function(sx) {\n"
" document.getElementById('sx-content-input').value = sx;\n"
" }\n"
" });\n"
" }\n"
"\n"
" document.addEventListener('keydown', function(e) {\n"
" if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n"
" e.preventDefault();\n"
" document.getElementById('post-new-form').requestSubmit();\n"
" }\n"
" });\n"
" }\n"
"\n"
" if (typeof window.mountEditor === 'function') {\n"
" init();\n"
" } else {\n"
" var _t = setInterval(function() {\n"
" if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }\n"
" }, 50);\n"
" }\n"
"})();\n"
)
parts.append(sx_call("blog-editor-scripts",
js_src=editor_js,
sx_editor_js_src=sx_editor_js,
init_js=init_js))
from shared.sx.parser import SxExpr
return sx_call("blog-editor-panel",
parts=SxExpr("(<> " + " ".join(parts) + ")")) if parts else ""
css_href=urls["css_href"],
js_src=urls["js_src"],
sx_editor_js_src=urls["sx_editor_js_src"],
init_js=init_js,
save_error=save_error or None)

View File

@@ -172,6 +172,45 @@ class CartPageService:
"summary": summary,
}
async def admin_data(self, session, **kw):
"""Populate post context for cart-admin layout headers."""
from quart import g
from shared.infrastructure.fragments import fetch_fragments
post = g.page_post
slug = post.slug if post else ""
post_id = post.id if post else None
# Fetch container_nav for post header
container_nav = ""
if post_id:
nav_params = {
"container_type": "page",
"container_id": str(post_id),
"post_slug": slug,
}
events_nav, market_nav = await fetch_fragments([
("events", "container-nav", nav_params),
("market", "container-nav", nav_params),
], required=False)
container_nav = events_nav + market_nav
return {
"post": {
"id": post_id,
"slug": slug,
"title": (post.title if post else "")[:160],
"feature_image": getattr(post, "feature_image", None),
},
"container_nav": container_nav,
}
async def payments_admin_data(self, session, **kw):
"""Admin data + payments data combined for cart-payments page."""
admin = await self.admin_data(session)
payments = await self.payments_data(session)
return {**admin, **payments}
async def payments_data(self, session, **kw):
from shared.sx.page import get_template_context

View File

@@ -1,4 +1,5 @@
;; Cart account-nav-item fragment handler
;; returns: sx
;;
;; Renders the "orders" link for the account dashboard nav.

View File

@@ -1,4 +1,5 @@
;; Cart cart-mini fragment handler
;; returns: sx
;;
;; Renders the cart icon with badge (or logo when empty).

View File

@@ -1,25 +1,78 @@
;; Cart layout defcomps — fully self-contained via IO primitives.
;; Registered via register_sx_layout in __init__.py.
;; --- cart-page layout: root + cart row + page-cart row ---
;; ---------------------------------------------------------------------------
;; Auto-fetching cart page header macros
;; ---------------------------------------------------------------------------
(defcomp ~cart-page-layout-full (&key cart-row page-cart-row)
(defmacro ~cart-page-header-auto (oob)
"Cart page header: cart-row + page-cart-row using (cart-page-ctx)."
(quasiquote
(let ((__cpctx (cart-page-ctx)))
(<>
(~menu-row-sx :id "cart-row" :level 1 :colour "sky"
:link-href (get __cpctx "cart-url")
:link-label "cart" :icon "fa fa-shopping-cart"
:child-id "cart-header-child")
(~header-child-sx :id "cart-header-child"
:inner (~menu-row-sx :id "page-cart-row" :level 2 :colour "sky"
:link-href (get __cpctx "page-cart-url")
:link-label-content (~cart-page-label
:feature-image (get __cpctx "feature-image")
:title (get __cpctx "title"))
:nav (~cart-all-carts-link :href (get __cpctx "cart-url"))
:oob (unquote oob)))))))
(defmacro ~cart-page-header-oob ()
"Cart page OOB: individual oob rows."
(quasiquote
(let ((__cpctx (cart-page-ctx)))
(<>
(~menu-row-sx :id "page-cart-row" :level 2 :colour "sky"
:link-href (get __cpctx "page-cart-url")
:link-label-content (~cart-page-label
:feature-image (get __cpctx "feature-image")
:title (get __cpctx "title"))
:nav (~cart-all-carts-link :href (get __cpctx "cart-url"))
:oob true)
(~menu-row-sx :id "cart-row" :level 1 :colour "sky"
:link-href (get __cpctx "cart-url")
:link-label "cart" :icon "fa fa-shopping-cart"
:child-id "cart-header-child"
:oob true)))))
;; ---------------------------------------------------------------------------
;; cart-page layout: root + cart row + page-cart row
;; ---------------------------------------------------------------------------
(defcomp ~cart-page-layout-full ()
(<> (~root-header-auto)
(~header-child-sx
:inner (<> cart-row
(~header-child-sx :id "cart-header-child" :inner page-cart-row)))))
:inner (~cart-page-header-auto))))
(defcomp ~cart-page-layout-oob (&key root-header-oob cart-row-oob page-cart-row)
(<> (~oob-header-sx :parent-id "cart-header-child" :row page-cart-row)
cart-row-oob
root-header-oob))
(defcomp ~cart-page-layout-oob ()
(<> (~cart-page-header-oob)
(~root-header-auto true)))
;; --- cart-admin layout: root + post header + admin header ---
;; ---------------------------------------------------------------------------
;; cart-admin layout: root + post header + admin header
;; Uses (post-header-ctx) — requires :data handler to populate g._defpage_ctx
;; ---------------------------------------------------------------------------
(defcomp ~cart-admin-layout-full (&key post-header admin-header)
(defcomp ~cart-admin-layout-full (&key selected)
(<> (~root-header-auto)
post-header admin-header))
(~header-child-sx
:inner (~post-header-auto nil))))
;; --- orders-within-cart: root + auth-simple + orders ---
(defcomp ~cart-admin-layout-oob (&key selected)
(<> (~post-header-auto true)
(~oob-header-sx :parent-id "post-header-child"
:row (~post-admin-header-auto nil selected))
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; orders-within-cart: root + auth-simple + orders
;; ---------------------------------------------------------------------------
(defcomp ~cart-orders-layout-full (&key list-url)
(<> (~root-header-auto)
@@ -35,7 +88,9 @@
:row (~orders-header-row :list-url list-url))
(~root-header-auto true)))
;; --- order-detail-within-cart: root + auth-simple + orders + order ---
;; ---------------------------------------------------------------------------
;; order-detail-within-cart: root + auth-simple + orders + order
;; ---------------------------------------------------------------------------
(defcomp ~cart-order-detail-layout-full (&key list-url detail-url order-label)
(<> (~root-header-auto)
@@ -61,3 +116,21 @@
(defcomp ~cart-orders-rows (&key rows next-scroll)
(<> rows next-scroll))
;; Composition defcomp — replaces Python loop in render_orders_rows
(defcomp ~cart-orders-rows-content (&key orders detail-url-prefix page total-pages next-url)
(~cart-orders-rows
:rows (map (lambda (od)
(~order-row-pair :order od :detail-url-prefix detail-url-prefix))
(or orders (list)))
:next-scroll (if (< page total-pages)
(~infinite-scroll :url next-url :page page
:total-pages total-pages :id-prefix "orders" :colspan 5)
(~order-end-row))))
;; Composition defcomp — replaces conditional composition in render_checkout_error_page
(defcomp ~cart-checkout-error-from-data (&key msg order-id back-url)
(~checkout-error-content
:msg msg
:order (when order-id (~checkout-error-order-id :oid (str "#" order-id)))
:back-url back-url))

View File

@@ -32,12 +32,13 @@
:path "/<page_slug>/admin/"
:auth :admin
:layout :cart-admin
:data (service "cart-page" "admin-data")
:content (~cart-admin-content))
(defpage cart-payments
:path "/<page_slug>/admin/payments/"
:auth :admin
:layout (:cart-admin :selected "payments")
:data (service "cart-page" "payments-data")
:data (service "cart-page" "payments-admin-data")
:content (~cart-payments-content
:page-config page-config))

View File

@@ -1,135 +1,8 @@
"""Cart layout registration and header builders."""
"""Cart layout registration — all layouts delegate to .sx defcomps."""
from __future__ import annotations
from typing import Any
from shared.sx.parser import SxExpr
def _register_cart_layouts() -> None:
from shared.sx.layouts import register_custom_layout
register_custom_layout("cart-page", _cart_page_full, _cart_page_oob)
register_custom_layout("cart-admin", _cart_admin_full, _cart_admin_oob)
# ---------------------------------------------------------------------------
# Header helpers
# ---------------------------------------------------------------------------
def _ensure_post_ctx(ctx: dict, page_post: Any) -> dict:
"""Ensure ctx has a 'post' dict from page_post DTO."""
if ctx.get("post") or not page_post:
return ctx
return {**ctx, "post": {
"id": getattr(page_post, "id", None),
"slug": getattr(page_post, "slug", ""),
"title": getattr(page_post, "title", ""),
"feature_image": getattr(page_post, "feature_image", None),
}}
async def _ensure_container_nav(ctx: dict) -> dict:
"""Fetch container_nav if not already present."""
if ctx.get("container_nav"):
return ctx
post = ctx.get("post") or {}
post_id = post.get("id")
if not post_id:
return ctx
slug = post.get("slug", "")
from shared.infrastructure.fragments import fetch_fragments
nav_params = {
"container_type": "page",
"container_id": str(post_id),
"post_slug": slug,
}
events_nav, market_nav = await fetch_fragments([
("events", "container-nav", nav_params),
("market", "container-nav", nav_params),
], required=False)
return {**ctx, "container_nav": events_nav + market_nav}
async def _post_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
from shared.sx.helpers import post_header_sx as _shared_post_header_sx
ctx = _ensure_post_ctx(ctx, page_post)
ctx = await _ensure_container_nav(ctx)
return await _shared_post_header_sx(ctx, oob=oob)
def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str:
from shared.sx.helpers import sx_call, call_url
return sx_call(
"menu-row-sx",
id="cart-row", level=1, colour="sky",
link_href=call_url(ctx, "cart_url", "/"),
link_label="cart", icon="fa fa-shopping-cart",
child_id="cart-header-child", oob=oob,
)
def _page_cart_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
from shared.sx.helpers import sx_call, call_url
slug = page_post.slug if page_post else ""
title = ((page_post.title if page_post else None) or "")[:160]
label_sx = sx_call("cart-page-label",
feature_image=page_post.feature_image if page_post else None,
title=title)
nav_sx = sx_call("cart-all-carts-link", href=call_url(ctx, "cart_url", "/"))
return sx_call(
"menu-row-sx",
id="page-cart-row", level=2, colour="sky",
link_href=call_url(ctx, "cart_url", f"/{slug}/"),
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx), oob=oob,
)
async def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False,
selected: str = "") -> str:
from shared.sx.helpers import post_admin_header_sx
slug = page_post.slug if page_post else ""
ctx = _ensure_post_ctx(ctx, page_post)
return await post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
# ---------------------------------------------------------------------------
# Layout functions
# ---------------------------------------------------------------------------
async def _cart_page_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env
page_post = ctx.get("page_post")
env = {}
return await render_to_sx_with_env("cart-page-layout-full", env,
cart_row=SxExpr(_cart_header_sx(ctx)),
page_cart_row=SxExpr(_page_cart_header_sx(ctx, page_post)),
)
async def _cart_page_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env, root_header_sx
page_post = ctx.get("page_post")
env = {}
return await render_to_sx_with_env("cart-page-layout-oob", env,
root_header_oob=SxExpr(await root_header_sx(ctx, oob=True)),
cart_row_oob=SxExpr(_cart_header_sx(ctx, oob=True)),
page_cart_row=SxExpr(_page_cart_header_sx(ctx, page_post)),
)
async def _cart_admin_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env
page_post = ctx.get("page_post")
selected = kw.get("selected", "")
env = {}
return await render_to_sx_with_env("cart-admin-layout-full", env,
post_header=SxExpr(await _post_header_sx(ctx, page_post)),
admin_header=SxExpr(await _cart_page_admin_header_sx(ctx, page_post, selected=selected)),
)
async def _cart_admin_oob(ctx: dict, **kw: Any) -> str:
page_post = ctx.get("page_post")
selected = kw.get("selected", "")
return await _cart_page_admin_header_sx(ctx, page_post, oob=True, selected=selected)
from shared.sx.layouts import register_sx_layout
register_sx_layout("cart-page", "cart-page-layout-full", "cart-page-layout-oob")
register_sx_layout("cart-admin", "cart-admin-layout-full", "cart-admin-layout-oob")

View File

@@ -1,8 +1,6 @@
"""Cart render functions — called from bp routes."""
from __future__ import annotations
from shared.sx.parser import SxExpr
from .utils import _serialize_order, _serialize_calendar_entry
@@ -20,7 +18,7 @@ async def render_orders_page(ctx, orders, page, total_pages, search, search_coun
header_rows = await render_to_sx_with_env("cart-orders-layout-full", {},
list_url=list_url,
)
filt = sx_call("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx)))
filt = sx_call("order-list-header", search_mobile=await search_mobile_sx(ctx))
return await full_page_sx(ctx, header_rows=header_rows, filter=filt,
aside=await search_desktop_sx(ctx), content=content)
@@ -32,20 +30,10 @@ def render_orders_rows(ctx, orders, page, total_pages, url_for_fn, qs_fn):
list_url = pfx + url_for_fn("orders.list_orders")
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
order_dicts = [_serialize_order(o) for o in orders]
parts = []
for od in order_dicts:
parts.append(sx_call("order-row-pair", order=od, detail_url_prefix=detail_url_prefix))
next_scroll = ""
if page < total_pages:
next_url = list_url + qs_fn(page=page + 1)
next_scroll = sx_call("infinite-scroll", url=next_url, page=page,
total_pages=total_pages, id_prefix="orders", colspan=5)
else:
next_scroll = sx_call("order-end-row")
return sx_call("cart-orders-rows",
rows=SxExpr("(<> " + " ".join(parts) + ")"),
next_scroll=SxExpr(next_scroll),
)
next_url = list_url + qs_fn(page=page + 1) if page < total_pages else ""
return sx_call("cart-orders-rows-content",
orders=order_dicts, detail_url_prefix=detail_url_prefix,
page=page, total_pages=total_pages, next_url=next_url)
async def render_orders_oob(ctx, orders, page, total_pages, search, search_count, url_for_fn, qs_fn):
@@ -62,7 +50,7 @@ async def render_orders_oob(ctx, orders, page, total_pages, search, search_count
oobs = await render_to_sx_with_env("cart-orders-layout-oob", {},
list_url=list_url,
)
filt = sx_call("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx)))
filt = sx_call("order-list-header", search_mobile=await search_mobile_sx(ctx))
return await oob_page_sx(oobs=oobs, filter=filt, aside=await search_desktop_sx(ctx), content=content)
@@ -112,11 +100,11 @@ async def render_checkout_error_page(ctx, error=None, order=None):
from shared.sx.helpers import sx_call, render_to_sx_with_env, full_page_sx
from shared.infrastructure.urls import cart_url
err_msg = error or "Unexpected error while creating the hosted checkout session."
order_sx = sx_call("checkout-error-order-id", oid=f"#{order.id}") if order else None
hdr = await render_to_sx_with_env("layout-root-full", {})
filt = sx_call("checkout-error-header")
content = sx_call("checkout-error-content", msg=err_msg,
order=SxExpr(order_sx) if order_sx else None, back_url=cart_url("/"))
content = sx_call("cart-checkout-error-from-data",
msg=err_msg, order_id=order.id if order else None,
back_url=cart_url("/"))
return await full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)

View File

@@ -1,4 +1,5 @@
;; Events account-nav-item fragment handler
;; returns: sx
;;
;; Renders tickets + bookings links for the account dashboard nav.

View File

@@ -1,4 +1,5 @@
;; Account-page fragment handler
;; returns: sx
;;
;; Renders tickets or bookings panel for the account dashboard.
;; slug=tickets → ticket list; slug=bookings → booking list.

View File

@@ -1,4 +1,5 @@
;; Container-cards fragment handler
;; returns: sx
;;
;; Returns HTML with <!-- card-widget:ID --> comment markers so the
;; blog consumer can split per-post fragments. Each post section

View File

@@ -1,4 +1,5 @@
;; Events container-nav fragment handler
;; returns: sx
;;
;; Renders calendar entry nav items + calendar link nav items
;; for the scrollable navigation panel on blog post pages.

View File

@@ -1,4 +1,5 @@
;; Events link-card fragment handler
;; returns: sx
;;
;; Renders event page preview card(s) by slug.
;; Supports single mode (?slug=x) and batch mode (?keys=x,y,z).

View File

@@ -1,96 +1,405 @@
;; Events layout defcomps — root header via ~root-header-auto,
;; events-specific headers passed as &key params.
;; Events layout defcomps — fully self-contained via IO primitives.
;; Registered via register_sx_layout in helpers.py.
;; --- Calendar admin layout: root + post + child(admin + cal + cal-admin) ---
;; ---------------------------------------------------------------------------
;; Auto-fetching header macros — calendar, day, entry, slot, tickets
;; ---------------------------------------------------------------------------
(defcomp ~events-cal-admin-layout-full (&key post-header admin-header
calendar-header calendar-admin-header)
(defmacro ~events-calendar-header-auto (oob)
"Calendar header row using (events-calendar-ctx)."
(quasiquote
(let ((__cal (events-calendar-ctx))
(__sc (select-colours)))
(when (get __cal "slug")
(~menu-row-sx :id "calendar-row" :level 3
:link-href (url-for "calendar.get"
:calendar-slug (get __cal "slug"))
:link-label-content (~events-calendar-label
:name (get __cal "name")
:description (get __cal "description"))
:nav (<>
(~nav-link :href (url-for "defpage_slots_listing"
:calendar-slug (get __cal "slug"))
:icon "fa fa-clock" :label "Slots"
:select-colours __sc)
(let ((__rights (app-rights)))
(when (get __rights "admin")
(~nav-link :href (url-for "defpage_calendar_admin"
:calendar-slug (get __cal "slug"))
:icon "fa fa-cog"
:select-colours __sc))))
:child-id "calendar-header-child"
:oob (unquote oob))))))
(defmacro ~events-calendar-admin-header-auto (oob)
"Calendar admin header row."
(quasiquote
(let ((__cal (events-calendar-ctx))
(__sc (select-colours)))
(when (get __cal "slug")
(~menu-row-sx :id "calendar-admin-row" :level 4
:link-label "admin" :icon "fa fa-cog"
:nav (<>
(~nav-link :href (url-for "defpage_slots_listing"
:calendar-slug (get __cal "slug"))
:label "slots" :select-colours __sc)
(~nav-link :href (url-for "calendar.admin.calendar_description_edit"
:calendar-slug (get __cal "slug"))
:label "description" :select-colours __sc))
:child-id "calendar-admin-header-child"
:oob (unquote oob))))))
(defmacro ~events-day-header-auto (oob)
"Day header row using (events-day-ctx)."
(quasiquote
(let ((__day (events-day-ctx))
(__cal (events-calendar-ctx)))
(when (get __day "date-str")
(~menu-row-sx :id "day-row" :level 4
:link-href (url-for "calendar.day.show_day"
:calendar-slug (get __cal "slug")
:year (get __day "year")
:month (get __day "month")
:day (get __day "day"))
:link-label-content (~events-day-label
:date-str (get __day "date-str"))
:nav (get __day "nav")
:child-id "day-header-child"
:oob (unquote oob))))))
(defmacro ~events-day-admin-header-auto (oob)
"Day admin header row."
(quasiquote
(let ((__day (events-day-ctx))
(__cal (events-calendar-ctx)))
(when (get __day "date-str")
(~menu-row-sx :id "day-admin-row" :level 5
:link-href (url-for "defpage_day_admin"
:calendar-slug (get __cal "slug")
:year (get __day "year")
:month (get __day "month")
:day (get __day "day"))
:link-label "admin" :icon "fa fa-cog"
:child-id "day-admin-header-child"
:oob (unquote oob))))))
(defmacro ~events-entry-header-auto (oob)
"Entry header row using (events-entry-ctx)."
(quasiquote
(let ((__ectx (events-entry-ctx)))
(when (get __ectx "id")
(~menu-row-sx :id "entry-row" :level 5
:link-href (get __ectx "link-href")
:link-label-content (~events-entry-label
:entry-id (get __ectx "id")
:title (~events-entry-title :name (get __ectx "name"))
:times (~events-entry-times :time-str (get __ectx "time-str")))
:nav (get __ectx "nav")
:child-id "entry-header-child"
:oob (unquote oob))))))
(defmacro ~events-entry-admin-header-auto (oob)
"Entry admin header row."
(quasiquote
(let ((__ectx (events-entry-ctx)))
(when (get __ectx "id")
(~menu-row-sx :id "entry-admin-row" :level 6
:link-href (get __ectx "admin-href")
:link-label "admin" :icon "fa fa-cog"
:nav (when (get __ectx "is-admin")
(~nav-link :href (get __ectx "ticket-types-href")
:label "ticket_types"
:select-colours (get __ectx "select-colours")))
:child-id "entry-admin-header-child"
:oob (unquote oob))))))
(defmacro ~events-slot-header-auto (oob)
"Slot detail header row using (events-slot-ctx)."
(quasiquote
(let ((__slot (events-slot-ctx)))
(when (get __slot "name")
(~menu-row-sx :id "slot-row" :level 5
:link-label-content (~events-slot-label
:name (get __slot "name")
:description (get __slot "description"))
:child-id "slot-header-child"
:oob (unquote oob))))))
(defmacro ~events-ticket-types-header-auto (oob)
"Ticket types header row."
(quasiquote
(let ((__ectx (events-entry-ctx))
(__cal (events-calendar-ctx)))
(when (get __ectx "id")
(~menu-row-sx :id "ticket_types-row" :level 7
:link-href (get __ectx "ticket-types-href")
:link-label-content (<>
(i :class "fa fa-ticket")
(div :class "shrink-0" "ticket types"))
:nav (~events-admin-placeholder-nav)
:child-id "ticket_type-header-child"
:oob (unquote oob))))))
(defmacro ~events-ticket-type-header-auto (oob)
"Single ticket type header row using (events-ticket-type-ctx)."
(quasiquote
(let ((__tt (events-ticket-type-ctx)))
(when (get __tt "id")
(~menu-row-sx :id "ticket_type-row" :level 8
:link-href (get __tt "link-href")
:link-label-content (div :class "flex flex-col md:flex-row md:gap-2 items-center"
(div :class "flex flex-row items-center gap-2"
(i :class "fa fa-ticket")
(div :class "shrink-0" (get __tt "name"))))
:nav (~events-admin-placeholder-nav)
:child-id "ticket_type-header-child-inner"
:oob (unquote oob))))))
(defmacro ~events-markets-header-auto (oob)
"Markets section header row."
(quasiquote
(~menu-row-sx :id "markets-row" :level 3
:link-href (url-for "defpage_events_markets")
:link-label-content (~events-markets-label)
:child-id "markets-header-child"
:oob (unquote oob))))
;; ---------------------------------------------------------------------------
;; OOB clear helpers — clear deeper header rows not present at this level
;; ---------------------------------------------------------------------------
(defcomp ~events-clear-oob-cal-admin ()
"Clear OOB divs for cal-admin level (keeps down to calendar-admin)."
(<>
(~clear-oob-div :id "entry-admin-row")
(~clear-oob-div :id "entry-admin-header-child")
(~clear-oob-div :id "entry-row")
(~clear-oob-div :id "entry-header-child")
(~clear-oob-div :id "day-admin-row")
(~clear-oob-div :id "day-admin-header-child")
(~clear-oob-div :id "day-row")
(~clear-oob-div :id "day-header-child")
(~clear-oob-div :id "calendars-row")
(~clear-oob-div :id "calendars-header-child")))
(defcomp ~events-clear-oob-slot ()
"Clear OOB divs for slot level."
(<>
(~clear-oob-div :id "entry-admin-row")
(~clear-oob-div :id "entry-admin-header-child")
(~clear-oob-div :id "entry-row")
(~clear-oob-div :id "entry-header-child")
(~clear-oob-div :id "day-admin-row")
(~clear-oob-div :id "day-admin-header-child")
(~clear-oob-div :id "day-row")
(~clear-oob-div :id "day-header-child")
(~clear-oob-div :id "calendars-row")
(~clear-oob-div :id "calendars-header-child")))
(defcomp ~events-clear-oob-day-admin ()
"Clear OOB divs for day-admin level."
(<>
(~clear-oob-div :id "entry-admin-row")
(~clear-oob-div :id "entry-admin-header-child")
(~clear-oob-div :id "entry-row")
(~clear-oob-div :id "entry-header-child")
(~clear-oob-div :id "calendars-row")
(~clear-oob-div :id "calendars-header-child")))
(defcomp ~events-clear-oob-entry ()
"Clear OOB divs for entry level (public, no admin rows)."
(<>
(~clear-oob-div :id "entry-admin-row")
(~clear-oob-div :id "entry-admin-header-child")
(~clear-oob-div :id "day-admin-row")
(~clear-oob-div :id "day-admin-header-child")
(~clear-oob-div :id "calendar-admin-row")
(~clear-oob-div :id "calendar-admin-header-child")
(~clear-oob-div :id "calendars-row")
(~clear-oob-div :id "calendars-header-child")
(~clear-oob-div :id "post-admin-row")
(~clear-oob-div :id "post-admin-header-child")))
(defcomp ~events-clear-oob-entry-admin ()
"Clear OOB divs for entry-admin level."
(<>
(~clear-oob-div :id "calendars-row")
(~clear-oob-div :id "calendars-header-child")))
;; ---------------------------------------------------------------------------
;; Calendar admin layout: root + post + child(post-admin + cal + cal-admin)
;; ---------------------------------------------------------------------------
(defcomp ~events-cal-admin-layout-full ()
(<> (~root-header-auto)
post-header
(~header-child-sx :inner (<> admin-header calendar-header calendar-admin-header))))
(~header-child-sx
:inner (<> (~post-header-auto nil)
(~post-admin-header-auto nil "calendars")
(~events-calendar-header-auto nil)
(~events-calendar-admin-header-auto nil)))))
(defcomp ~events-cal-admin-layout-oob (&key admin-oob cal-oob cal-admin-oob-wrap clear-oob)
(<> admin-oob cal-oob cal-admin-oob-wrap clear-oob))
(defcomp ~events-cal-admin-layout-oob ()
(<> (~post-admin-header-auto true "calendars")
(~events-calendar-header-auto true)
(~oob-header-sx :parent-id "calendar-header-child"
:row (~events-calendar-admin-header-auto nil))
(~events-clear-oob-cal-admin)
(~root-header-auto true)))
;; --- Slots layout: same full as cal-admin ---
;; ---------------------------------------------------------------------------
;; Slots layout: same full as cal-admin
;; ---------------------------------------------------------------------------
(defcomp ~events-slots-layout-oob (&key admin-oob cal-admin-oob clear-oob)
(<> admin-oob cal-admin-oob clear-oob))
;; --- Slot detail layout: root + post + child(admin + cal + cal-admin + slot) ---
(defcomp ~events-slot-layout-full (&key post-header admin-header
calendar-header calendar-admin-header slot-header)
(defcomp ~events-slots-layout-full ()
(<> (~root-header-auto)
post-header
(~header-child-sx :inner (<> admin-header calendar-header calendar-admin-header slot-header))))
(~header-child-sx
:inner (<> (~post-header-auto nil)
(~post-admin-header-auto nil "calendars")
(~events-calendar-header-auto nil)
(~events-calendar-admin-header-auto nil)))))
(defcomp ~events-slot-layout-oob (&key admin-oob cal-admin-oob slot-oob-wrap clear-oob)
(<> admin-oob cal-admin-oob slot-oob-wrap clear-oob))
(defcomp ~events-slots-layout-oob ()
(<> (~post-admin-header-auto true "calendars")
(~events-calendar-admin-header-auto true)
(~events-clear-oob-cal-admin)
(~root-header-auto true)))
;; --- Day admin layout: root + post + child(admin + cal + day + day-admin) ---
;; ---------------------------------------------------------------------------
;; Slot detail layout: root + post + child(admin + cal + cal-admin + slot)
;; ---------------------------------------------------------------------------
(defcomp ~events-day-admin-layout-full (&key post-header admin-header
calendar-header day-header day-admin-header)
(defcomp ~events-slot-layout-full ()
(<> (~root-header-auto)
post-header
(~header-child-sx :inner (<> admin-header calendar-header day-header day-admin-header))))
(~header-child-sx
:inner (<> (~post-header-auto nil)
(~post-admin-header-auto nil "calendars")
(~events-calendar-header-auto nil)
(~events-calendar-admin-header-auto nil)
(~events-slot-header-auto nil)))))
(defcomp ~events-day-admin-layout-oob (&key admin-oob cal-oob day-admin-oob-wrap clear-oob)
(<> admin-oob cal-oob day-admin-oob-wrap clear-oob))
(defcomp ~events-slot-layout-oob ()
(<> (~post-admin-header-auto true "calendars")
(~events-calendar-admin-header-auto true)
(~oob-header-sx :parent-id "calendar-admin-header-child"
:row (~events-slot-header-auto nil))
(~events-clear-oob-slot)
(~root-header-auto true)))
;; --- Entry layout: root + child(post + cal + day + entry) ---
;; ---------------------------------------------------------------------------
;; Day admin layout: root + post + child(admin + cal + day + day-admin)
;; ---------------------------------------------------------------------------
(defcomp ~events-entry-layout-full (&key post-header calendar-header day-header entry-header)
(defcomp ~events-day-admin-layout-full ()
(<> (~root-header-auto)
(~header-child-sx :inner (<> post-header calendar-header day-header entry-header))))
(~header-child-sx
:inner (<> (~post-header-auto nil)
(~post-admin-header-auto nil "calendars")
(~events-calendar-header-auto nil)
(~events-day-header-auto nil)
(~events-day-admin-header-auto nil)))))
(defcomp ~events-entry-layout-oob (&key day-oob entry-oob-wrap clear-oob)
(<> day-oob entry-oob-wrap clear-oob))
(defcomp ~events-day-admin-layout-oob ()
(<> (~post-admin-header-auto true "calendars")
(~events-calendar-header-auto true)
(~oob-header-sx :parent-id "day-header-child"
:row (~events-day-admin-header-auto nil))
(~events-clear-oob-day-admin)
(~root-header-auto true)))
;; --- Entry admin layout: root + post + child(admin + cal + day + entry + entry-admin) ---
;; ---------------------------------------------------------------------------
;; Entry layout: root + child(post + cal + day + entry) — public, no admin
;; ---------------------------------------------------------------------------
(defcomp ~events-entry-admin-layout-full (&key post-header admin-header
calendar-header day-header
entry-header entry-admin-header)
(defcomp ~events-entry-layout-full ()
(<> (~root-header-auto)
post-header
(~header-child-sx :inner (<> admin-header calendar-header day-header
entry-header entry-admin-header))))
(~header-child-sx
:inner (<> (~post-header-auto nil)
(~events-calendar-header-auto nil)
(~events-day-header-auto nil)
(~events-entry-header-auto nil)))))
(defcomp ~events-entry-admin-layout-oob (&key admin-oob entry-oob entry-admin-oob-wrap clear-oob)
(<> admin-oob entry-oob entry-admin-oob-wrap clear-oob))
(defcomp ~events-entry-layout-oob ()
(<> (~events-day-header-auto true)
(~oob-header-sx :parent-id "day-header-child"
:row (~events-entry-header-auto nil))
(~events-clear-oob-entry)
(~root-header-auto true)))
;; --- Ticket types layout: root + child(post + cal + day + entry + entry-admin + ticket-types) ---
;; ---------------------------------------------------------------------------
;; Entry admin layout: root + post + child(admin + cal + day + entry + entry-admin)
;; ---------------------------------------------------------------------------
(defcomp ~events-ticket-types-layout-full (&key post-header calendar-header day-header
entry-header entry-admin-header
ticket-types-header)
(defcomp ~events-entry-admin-layout-full ()
(<> (~root-header-auto)
(~header-child-sx :inner (<> post-header calendar-header day-header
entry-header entry-admin-header ticket-types-header))))
(~header-child-sx
:inner (<> (~post-header-auto nil)
(~post-admin-header-auto nil "calendars")
(~events-calendar-header-auto nil)
(~events-day-header-auto nil)
(~events-entry-header-auto nil)
(~events-entry-admin-header-auto nil)))))
(defcomp ~events-ticket-types-layout-oob (&key entry-admin-oob ticket-types-oob-wrap)
(<> entry-admin-oob ticket-types-oob-wrap))
(defcomp ~events-entry-admin-layout-oob ()
(<> (~post-admin-header-auto true "calendars")
(~events-entry-header-auto true)
(~oob-header-sx :parent-id "entry-header-child"
:row (~events-entry-admin-header-auto nil))
(~events-clear-oob-entry-admin)
(~root-header-auto true)))
;; --- Ticket type detail layout: root + child(post + cal + day + entry + entry-admin + types + type) ---
;; ---------------------------------------------------------------------------
;; Ticket types layout: root + child(post + cal + day + entry + entry-admin + ticket-types)
;; ---------------------------------------------------------------------------
(defcomp ~events-ticket-type-layout-full (&key post-header calendar-header day-header
entry-header entry-admin-header
ticket-types-header ticket-type-header)
(defcomp ~events-ticket-types-layout-full ()
(<> (~root-header-auto)
(~header-child-sx :inner (<> post-header calendar-header day-header
entry-header entry-admin-header
ticket-types-header ticket-type-header))))
(~header-child-sx
:inner (<> (~post-header-auto nil)
(~events-calendar-header-auto nil)
(~events-day-header-auto nil)
(~events-entry-header-auto nil)
(~events-entry-admin-header-auto nil)
(~events-ticket-types-header-auto nil)))))
(defcomp ~events-ticket-type-layout-oob (&key ticket-types-oob ticket-type-oob-wrap)
(<> ticket-types-oob ticket-type-oob-wrap))
(defcomp ~events-ticket-types-layout-oob ()
(<> (~events-entry-admin-header-auto true)
(~oob-header-sx :parent-id "entry-admin-header-child"
:row (~events-ticket-types-header-auto nil))
(~root-header-auto true)))
;; --- Markets layout: root + child(post + markets) ---
;; ---------------------------------------------------------------------------
;; Ticket type layout: all headers down to ticket-type
;; ---------------------------------------------------------------------------
(defcomp ~events-markets-layout-full (&key post-header markets-header)
(defcomp ~events-ticket-type-layout-full ()
(<> (~root-header-auto)
(~header-child-sx :inner (<> post-header markets-header))))
(~header-child-sx
:inner (<> (~post-header-auto nil)
(~events-calendar-header-auto nil)
(~events-day-header-auto nil)
(~events-entry-header-auto nil)
(~events-entry-admin-header-auto nil)
(~events-ticket-types-header-auto nil)
(~events-ticket-type-header-auto nil)))))
(defcomp ~events-markets-layout-oob (&key post-oob markets-oob-wrap)
(<> post-oob markets-oob-wrap))
(defcomp ~events-ticket-type-layout-oob ()
(<> (~events-ticket-types-header-auto true)
(~oob-header-sx :parent-id "ticket_types-header-child"
:row (~events-ticket-type-header-auto nil))
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; Markets layout: root + child(post + markets)
;; ---------------------------------------------------------------------------
(defcomp ~events-markets-layout-full ()
(<> (~root-header-auto)
(~header-child-sx
:inner (<> (~post-header-auto nil)
(~events-markets-header-auto nil)))))
(defcomp ~events-markets-layout-oob ()
(<> (~post-header-auto true)
(~oob-header-sx :parent-id "post-header-child"
:row (~events-markets-header-auto nil))
(~root-header-auto true)))

View File

@@ -78,7 +78,7 @@ def _calendars_header_sx(ctx: dict, *, oob: bool = False) -> str:
link_href = url_for("calendars.home")
return sx_call("menu-row-sx", id="calendars-row", level=3,
link_href=link_href,
link_label_content=SxExpr(sx_call("events-calendars-label")),
link_label_content=sx_call("events-calendars-label"),
child_id="calendars-header-child", oob=oob)
@@ -104,7 +104,7 @@ def _calendar_header_sx(ctx: dict, *, oob: bool = False) -> str:
nav_html = _calendar_nav_sx(ctx)
return sx_call("menu-row-sx", id="calendar-row", level=3,
link_href=link_href, link_label_content=SxExpr(label_html),
link_href=link_href, link_label_content=label_html,
nav=SxExpr(nav_html) if nav_html else None, child_id="calendar-header-child", oob=oob)
@@ -158,7 +158,7 @@ def _day_header_sx(ctx: dict, *, oob: bool = False) -> str:
nav_html = _day_nav_sx(ctx)
return sx_call("menu-row-sx", id="day-row", level=4,
link_href=link_href, link_label_content=SxExpr(label_html),
link_href=link_href, link_label_content=label_html,
nav=SxExpr(nav_html) if nav_html else None, child_id="day-header-child", oob=oob)
@@ -180,7 +180,7 @@ def _day_nav_sx(ctx: dict) -> str:
entry_links = []
for entry in confirmed_entries:
href = url_for(
"calendar.day.calendar_entries.calendar_entry.get",
"defpage_entry_detail",
calendar_slug=cal_slug,
year=day_date.year,
month=day_date.month,
@@ -197,7 +197,7 @@ def _day_nav_sx(ctx: dict) -> str:
if is_admin and day_date:
admin_href = url_for(
"calendar.day.admin.admin",
"defpage_day_admin",
calendar_slug=cal_slug,
year=day_date.year,
month=day_date.month,
@@ -223,7 +223,7 @@ def _day_admin_header_sx(ctx: dict, *, oob: bool = False) -> str:
return ""
link_href = url_for(
"calendar.day.admin.admin",
"defpage_day_admin",
calendar_slug=cal_slug,
year=day_date.year,
month=day_date.month,
@@ -271,7 +271,7 @@ def _markets_header_sx(ctx: dict, *, oob: bool = False) -> str:
link_href = url_for("defpage_events_markets")
return sx_call("menu-row-sx", id="markets-row", level=3,
link_href=link_href,
link_label_content=SxExpr(sx_call("events-markets-label")),
link_label_content=sx_call("events-markets-label"),
child_id="markets-header-child", oob=oob)
@@ -323,7 +323,7 @@ def _calendars_list_sx(ctx: dict, calendars: list) -> str:
cal_name = getattr(cal, "name", "")
href = prefix + url_for("calendar.get", calendar_slug=cal_slug)
del_url = url_for("calendar.delete", calendar_slug=cal_slug)
csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}'
csrf_hdr = {"X-CSRFToken": csrf}
parts.append(sx_call("crud-item",
href=href, name=cal_name, slug=cal_slug,
del_url=del_url, csrf_hdr=csrf_hdr,
@@ -518,7 +518,7 @@ def _day_row_html(ctx: dict, entry) -> str:
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
entry_href = url_for(
"calendar.day.calendar_entries.calendar_entry.get",
"defpage_entry_detail",
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=entry.id,
)
@@ -544,7 +544,7 @@ def _day_row_html(ctx: dict, entry) -> str:
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=SxExpr(state_badge))
state_id=f"entry-state-{entry.id}", badge=state_badge)
# Cost
cost = getattr(entry, "cost", None)
@@ -564,9 +564,9 @@ def _day_row_html(ctx: dict, entry) -> str:
actions_td = sx_call("events-day-row-actions")
return sx_call("events-day-row",
tr_cls=tr_cls, name=SxExpr(name_html), slot=SxExpr(slot_html),
state=SxExpr(state_td), cost=SxExpr(cost_td),
tickets=SxExpr(tickets_td), actions=SxExpr(actions_td))
tr_cls=tr_cls, name=name_html, slot=slot_html,
state=state_td, cost=cost_td,
tickets=tickets_td, actions=actions_td)
# ---------------------------------------------------------------------------
@@ -598,7 +598,7 @@ def _calendar_admin_main_panel_html(ctx: dict) -> str:
description_html = _calendar_description_display_html(calendar, desc_edit_url)
return sx_call("events-calendar-admin-panel",
description_content=SxExpr(description_html), csrf=csrf,
description_content=description_html, csrf=csrf,
description=desc)
@@ -656,7 +656,7 @@ def _markets_list_html(ctx: dict, markets: list) -> str:
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)
csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}'
csrf_hdr = {"X-CSRFToken": csrf}
parts.append(sx_call("crud-item",
href=market_href, name=m_name,
slug=m_slug, del_url=del_url,

View File

@@ -73,10 +73,10 @@ def _entry_card_html(entry, page_info: dict, pending_tickets: dict,
if tp is not None:
qty = pending_tickets.get(entry.id, 0)
widget_html = sx_call("events-entry-widget-wrapper",
widget=SxExpr(_ticket_widget_html(entry, qty, ticket_url, ctx={})))
widget=_ticket_widget_html(entry, qty, ticket_url, ctx={}))
return sx_call("events-entry-card",
title=SxExpr(title_html), badges=SxExpr(badges_html),
title=title_html, badges=SxExpr(badges_html),
time_parts=SxExpr(time_parts), cost=SxExpr(cost_html),
widget=SxExpr(widget_html))
@@ -137,10 +137,10 @@ def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict,
if tp is not None:
qty = pending_tickets.get(entry.id, 0)
widget_html = sx_call("events-entry-tile-widget-wrapper",
widget=SxExpr(_ticket_widget_html(entry, qty, ticket_url, ctx={})))
widget=_ticket_widget_html(entry, qty, ticket_url, ctx={}))
return sx_call("events-entry-card-tile",
title=SxExpr(title_html), badges=SxExpr(badges_html),
title=title_html, badges=SxExpr(badges_html),
time=SxExpr(time_html), cost=SxExpr(cost_html),
widget=SxExpr(widget_html))
@@ -199,7 +199,7 @@ def _events_main_panel_html(ctx: dict, entries, has_more, pending_tickets, page_
cls="px-3 py-12 text-center text-stone-400")
return sx_call("events-main-panel-body",
toggle=SxExpr(toggle), body=SxExpr(body))
toggle=toggle, body=body)
# ---------------------------------------------------------------------------
@@ -253,7 +253,7 @@ def _entry_main_panel_html(ctx: dict) -> str:
# State
state_html = _field("State", sx_call("events-entry-state-field",
entry_id=str(eid),
badge=SxExpr(_entry_state_badge_html(state))))
badge=_entry_state_badge_html(state)))
# Cost
cost = getattr(entry, "cost", None)
@@ -284,7 +284,7 @@ def _entry_main_panel_html(ctx: dict) -> str:
entry_posts = ctx.get("entry_posts") or []
posts_html = _field("Associated Posts", sx_call("events-entry-posts-field",
entry_id=str(eid),
posts_panel=SxExpr(render_entry_posts_panel(entry_posts, entry, calendar, day, month, year))))
posts_panel=render_entry_posts_panel(entry_posts, entry, calendar, day, month, year)))
# Options and Edit Button
edit_url = url_for(
@@ -295,12 +295,12 @@ def _entry_main_panel_html(ctx: dict) -> str:
return sx_call("events-entry-panel",
entry_id=str(eid), list_container=list_container,
name=SxExpr(name_html), slot=SxExpr(slot_html),
time=SxExpr(time_html), state=SxExpr(state_html),
cost=SxExpr(cost_html), tickets=SxExpr(tickets_html),
buy=SxExpr(buy_html), date=SxExpr(date_html),
posts=SxExpr(posts_html),
options=SxExpr(_entry_options_html(entry, calendar, day, month, year)),
name=name_html, slot=slot_html,
time=time_html, state=state_html,
cost=cost_html, tickets=tickets_html,
buy=SxExpr(buy_html), date=date_html,
posts=posts_html,
options=_entry_options_html(entry, calendar, day, month, year),
pre_action=pre_action, edit_url=edit_url)
@@ -324,20 +324,20 @@ def _entry_header_html(ctx: dict, *, oob: bool = False) -> str:
year = ctx.get("year")
link_href = url_for(
"calendar.day.calendar_entries.calendar_entry.get",
"defpage_entry_detail",
calendar_slug=cal_slug,
year=year, month=month, day=day,
entry_id=entry.id,
)
label_html = sx_call("events-entry-label",
entry_id=str(entry.id),
title=SxExpr(_entry_title_html(entry)),
title=_entry_title_html(entry),
times=SxExpr(_entry_times_html(entry)))
nav_html = _entry_nav_html(ctx)
return sx_call("menu-row-sx", id="entry-row", level=5,
link_href=link_href, link_label_content=SxExpr(label_html),
link_href=link_href, link_label_content=label_html,
nav=SxExpr(nav_html) if nav_html else None, child_id="entry-header-child", oob=oob)
@@ -391,14 +391,14 @@ def _entry_nav_html(ctx: dict) -> str:
else:
img_html = sx_call("events-post-img-placeholder")
post_links += sx_call("events-entry-nav-post-link",
href=href, img=SxExpr(img_html), title=title)
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
if is_admin:
admin_url = url_for(
"calendar.day.calendar_entries.calendar_entry.admin.admin",
"defpage_entry_admin",
calendar_slug=cal_slug,
day=day, month=month, year=year,
entry_id=entry.id,
@@ -420,7 +420,7 @@ def render_entry_optioned(entry, calendar, day, month, year) -> str:
return options + sx_call("events-entry-optioned-oob",
entry_id=str(entry.id),
title=SxExpr(title), state=SxExpr(state))
title=title, state=state)
def _entry_title_html(entry) -> str:
@@ -428,7 +428,7 @@ def _entry_title_html(entry) -> str:
state = getattr(entry, "state", "pending") or "pending"
return sx_call("events-entry-title",
name=entry.name,
badge=SxExpr(_entry_state_badge_html(state)))
badge=_entry_state_badge_html(state))
def _entry_options_html(entry, calendar, day, month, year) -> str:
@@ -550,9 +550,9 @@ def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) ->
entry_id=eid, post_id=ep_id,
)
items += sx_call("events-entry-post-item",
img=SxExpr(img_html), title=ep_title,
img=img_html, title=ep_title,
del_url=del_url, entry_id=eid_s,
csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}')
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")
@@ -563,7 +563,7 @@ def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) ->
)
return sx_call("events-entry-posts-panel",
posts=SxExpr(posts_html), search_url=search_url,
posts=posts_html, search_url=search_url,
entry_id=eid_s)
@@ -591,7 +591,7 @@ def render_entry_posts_nav_oob(entry_posts) -> str:
if feat else sx_call("events-post-img-placeholder"))
items += sx_call("events-entry-nav-post",
href=href, nav_btn=nav_btn,
img=SxExpr(img_html), title=title)
img=img_html, title=title)
return sx_call("events-entry-posts-nav-oob", items=SxExpr(items))
@@ -614,7 +614,7 @@ def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> str:
items = ""
for entry in confirmed_entries:
href = url_for(
"calendar.day.calendar_entries.calendar_entry.get",
"defpage_entry_detail",
calendar_slug=cal_slug,
year=day_date.year, month=day_date.month, day=day_date.day,
entry_id=entry.id,
@@ -735,7 +735,7 @@ def _entry_admin_header_html(ctx: dict, *, oob: bool = False) -> str:
year = ctx.get("year")
link_href = url_for(
"calendar.day.calendar_entries.calendar_entry.admin.admin",
"defpage_entry_admin",
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=entry.id,
)
# Nav: ticket_types link
@@ -743,7 +743,7 @@ def _entry_admin_header_html(ctx: dict, *, oob: bool = False) -> str:
return sx_call("menu-row-sx", id="entry-admin-row", level=6,
link_href=link_href, link_label="admin", icon="fa fa-cog",
nav=SxExpr(nav_html) if nav_html else None, child_id="entry-admin-header-child", oob=oob)
nav=nav_html or None, child_id="entry-admin-header-child", oob=oob)
def _entry_admin_nav_html(ctx: dict) -> str:
@@ -822,7 +822,7 @@ def render_post_search_results(search_posts, search_query, page, total_pages,
parts.append(sx_call("events-post-search-item",
post_url=post_url, entry_id=str(eid), csrf=csrf,
post_id=str(sp.id), img=SxExpr(img_html), title=title))
post_id=str(sp.id), img=img_html, title=title))
result = "".join(parts)
@@ -858,7 +858,7 @@ def render_entry_edit_form(entry, calendar, day, month, year, day_slots) -> str:
put_url = url_for("calendar.day.calendar_entries.calendar_entry.put",
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid)
cancel_url = url_for("calendar.day.calendar_entries.calendar_entry.get",
cancel_url = url_for("defpage_entry_detail",
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid)
# Slot picker
@@ -882,7 +882,7 @@ def render_entry_edit_form(entry, calendar, day, month, year, day_slots) -> str:
html = sx_call("events-entry-edit-form",
entry_id=str(eid), list_container=list_container,
put_url=put_url, cancel_url=cancel_url, csrf=csrf,
name_val=entry.name or "", slot_picker=SxExpr(slot_picker_html),
name_val=entry.name or "", slot_picker=slot_picker_html,
start_val=start_val, end_val=end_val, cost_display=cost_display,
ticket_price_val=tp_val, ticket_count_val=tc_val,
action_btn=action_btn, cancel_btn=cancel_btn)
@@ -920,7 +920,7 @@ def render_entry_add_form(calendar, day, month, year, day_slots) -> str:
html = sx_call("events-entry-add-form",
post_url=post_url, csrf=csrf,
slot_picker=SxExpr(slot_picker_html),
slot_picker=slot_picker_html,
action_btn=action_btn, cancel_btn=cancel_btn,
cancel_url=cancel_url)
return html + _SLOT_PICKER_JS
@@ -998,13 +998,13 @@ def render_fragment_account_tickets(tickets) -> str:
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=SxExpr(badge_html))
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", items=SxExpr(body))
return sx_call("events-frag-tickets-panel", items=body)
# ---------------------------------------------------------------------------
@@ -1033,10 +1033,10 @@ def render_fragment_account_bookings(bookings) -> str:
name=booking.name,
date_str=date_str + date_str_extra,
calendar_name=cal_name, cost_str=cost_str,
badge=SxExpr(badge_html))
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", items=SxExpr(body))
return sx_call("events-frag-bookings-panel", items=body)

View File

@@ -1,89 +1,235 @@
;; Events pages — auto-mounted with absolute paths
;; All helpers return data dicts — markup composition in SX.
;; Calendar admin
(defpage calendar-admin
:path "/<slug>/<calendar_slug>/admin/"
:auth :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
(defpage day-admin
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/admin/"
:auth :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
(defpage slots-listing
:path "/<slug>/<calendar_slug>/slots/"
:auth :public
: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
(defpage slot-detail
:path "/<slug>/<calendar_slug>/slots/<int:slot_id>/"
:auth :admin
: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
(defpage entry-detail
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/"
:auth :admin
:layout :events-entry
:content (entry-content calendar-slug entry-id)
:menu (entry-menu calendar-slug entry-id))
:data (entry-data 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
(defpage entry-admin
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/admin/"
:auth :admin
:layout :events-entry-admin
:content (entry-admin-content calendar-slug entry-id)
:menu (admin-menu))
:data (entry-admin-data calendar-slug entry-id year month day)
: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
(defpage ticket-types-listing
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/ticket-types/"
:auth :public
:layout :events-ticket-types
:content (ticket-types-content calendar-slug entry-id year month day)
:menu (admin-menu))
:data (ticket-types-data calendar-slug entry-id year month day)
: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
(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>/"
:auth :admin
:layout :events-ticket-type
:content (ticket-type-content calendar-slug entry-id ticket-type-id year month day)
:menu (admin-menu))
:data (ticket-type-data calendar-slug entry-id ticket-type-id year month day)
: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
(defpage my-tickets
:path "/tickets/"
:auth :public
: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
(defpage ticket-detail
:path "/tickets/<code>/"
:auth :public
: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
(defpage ticket-admin
:path "/admin/tickets/"
:auth :admin
: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
(defpage events-markets
:path "/<slug>/markets/"
:auth :public
: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"))))

File diff suppressed because it is too large Load Diff

View File

@@ -1,288 +0,0 @@
"""Layout registration + header builders."""
from __future__ import annotations
from typing import Any
from shared.sx.parser import SxExpr
from .utils import _clear_deeper_oob, _ensure_container_nav
from .calendar import (
_post_header_sx, _calendar_header_sx, _calendar_admin_header_sx,
_day_header_sx, _day_admin_header_sx, _markets_header_sx,
)
from .entries import _entry_header_html, _entry_admin_header_html
from .slots import _slot_header_html
from .tickets import _ticket_types_header_html, _ticket_type_header_html
# ---------------------------------------------------------------------------
# Layouts
# ---------------------------------------------------------------------------
def _register_events_layouts() -> None:
from shared.sx.layouts import register_custom_layout
register_custom_layout("events-calendar-admin", _cal_admin_full, _cal_admin_oob)
register_custom_layout("events-slots", _slots_full, _slots_oob)
register_custom_layout("events-slot", _slot_full, _slot_oob)
register_custom_layout("events-day-admin", _day_admin_full, _day_admin_oob)
register_custom_layout("events-entry", _entry_full, _entry_oob)
register_custom_layout("events-entry-admin", _entry_admin_full, _entry_admin_oob)
register_custom_layout("events-ticket-types", _ticket_types_full, _ticket_types_oob)
register_custom_layout("events-ticket-type", _ticket_type_full, _ticket_type_oob)
register_custom_layout("events-markets", _markets_full, _markets_oob)
# --- Calendar admin layout (root + post + child(post-admin + calendar + cal-admin)) ---
async def _cal_admin_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx
from shared.sx.parser import SxExpr
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
return await render_to_sx_with_env("events-cal-admin-layout-full", {},
post_header=SxExpr(await _post_header_sx(ctx)),
admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")),
calendar_header=SxExpr(_calendar_header_sx(ctx)),
calendar_admin_header=SxExpr(_calendar_admin_header_sx(ctx)),
)
async def _cal_admin_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx, oob_header_sx
from shared.sx.parser import SxExpr
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
return await render_to_sx_with_env("events-cal-admin-layout-oob", {},
admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")),
cal_oob=SxExpr(_calendar_header_sx(ctx, oob=True)),
cal_admin_oob_wrap=SxExpr(await oob_header_sx("calendar-header-child",
"calendar-admin-header-child", _calendar_admin_header_sx(ctx))),
clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
"calendar-admin-row", "calendar-admin-header-child")),
)
# --- Slots layout (same full as cal-admin but different OOB) ---
async def _slots_full(ctx: dict, **kw: Any) -> str:
return await _cal_admin_full({**ctx, "is_admin_section": True}, **kw)
async def _slots_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx
from shared.sx.parser import SxExpr
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
slug = (ctx.get("post") or {}).get("slug", "")
return await render_to_sx_with_env("events-slots-layout-oob", {},
admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")),
cal_admin_oob=SxExpr(_calendar_admin_header_sx(ctx, oob=True)),
clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
"calendar-admin-row", "calendar-admin-header-child")),
)
# --- Slot detail layout (extends cal-admin with slot header) ---
async def _slot_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx
from shared.sx.parser import SxExpr
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
slug = (ctx.get("post") or {}).get("slug", "")
return await render_to_sx_with_env("events-slot-layout-full", {},
post_header=SxExpr(await _post_header_sx(ctx)),
admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")),
calendar_header=SxExpr(_calendar_header_sx(ctx)),
calendar_admin_header=SxExpr(_calendar_admin_header_sx(ctx)),
slot_header=SxExpr(_slot_header_html(ctx)),
)
async def _slot_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx, oob_header_sx
from shared.sx.parser import SxExpr
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
slug = (ctx.get("post") or {}).get("slug", "")
return await render_to_sx_with_env("events-slot-layout-oob", {},
admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")),
cal_admin_oob=SxExpr(_calendar_admin_header_sx(ctx, oob=True)),
slot_oob_wrap=SxExpr(await oob_header_sx("calendar-admin-header-child",
"slot-header-child", _slot_header_html(ctx))),
clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
"calendar-admin-row", "calendar-admin-header-child",
"slot-row", "slot-header-child")),
)
# --- Day admin layout (root + post + post-admin + child(cal + day + day-admin)) ---
async def _day_admin_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx
from shared.sx.parser import SxExpr
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
return await render_to_sx_with_env("events-day-admin-layout-full", {},
post_header=SxExpr(await _post_header_sx(ctx)),
admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")),
calendar_header=SxExpr(_calendar_header_sx(ctx)),
day_header=SxExpr(_day_header_sx(ctx)),
day_admin_header=SxExpr(_day_admin_header_sx(ctx)),
)
async def _day_admin_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx, oob_header_sx
from shared.sx.parser import SxExpr
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
return await render_to_sx_with_env("events-day-admin-layout-oob", {},
admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")),
cal_oob=SxExpr(_calendar_header_sx(ctx, oob=True)),
day_admin_oob_wrap=SxExpr(await oob_header_sx("day-header-child",
"day-admin-header-child", _day_admin_header_sx(ctx))),
clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
"day-row", "day-header-child",
"day-admin-row", "day-admin-header-child")),
)
# --- Entry layout (root + child(post + cal + day + entry), + menu) ---
async def _entry_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env
from shared.sx.parser import SxExpr
return await render_to_sx_with_env("events-entry-layout-full", {},
post_header=SxExpr(await _post_header_sx(ctx)),
calendar_header=SxExpr(_calendar_header_sx(ctx)),
day_header=SxExpr(_day_header_sx(ctx)),
entry_header=SxExpr(_entry_header_html(ctx)),
)
async def _entry_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env, oob_header_sx
from shared.sx.parser import SxExpr
return await render_to_sx_with_env("events-entry-layout-oob", {},
day_oob=SxExpr(_day_header_sx(ctx, oob=True)),
entry_oob_wrap=SxExpr(await oob_header_sx("day-header-child",
"entry-header-child", _entry_header_html(ctx))),
clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
"calendar-row", "calendar-header-child",
"day-row", "day-header-child",
"entry-row", "entry-header-child")),
)
# --- Entry admin layout (root + post + child(post-admin + cal + day + entry + entry-admin), + menu) ---
async def _entry_admin_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx
from shared.sx.parser import SxExpr
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
return await render_to_sx_with_env("events-entry-admin-layout-full", {},
post_header=SxExpr(await _post_header_sx(ctx)),
admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")),
calendar_header=SxExpr(_calendar_header_sx(ctx)),
day_header=SxExpr(_day_header_sx(ctx)),
entry_header=SxExpr(_entry_header_html(ctx)),
entry_admin_header=SxExpr(_entry_admin_header_html(ctx)),
)
async def _entry_admin_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx, oob_header_sx
from shared.sx.parser import SxExpr
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
return await render_to_sx_with_env("events-entry-admin-layout-oob", {},
admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")),
entry_oob=SxExpr(_entry_header_html(ctx, oob=True)),
entry_admin_oob_wrap=SxExpr(await oob_header_sx("entry-header-child",
"entry-admin-header-child", _entry_admin_header_html(ctx))),
clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
"day-row", "day-header-child",
"entry-row", "entry-header-child",
"entry-admin-row", "entry-admin-header-child")),
)
# --- Ticket types layout (extends entry admin with ticket-types header, + menu) ---
async def _ticket_types_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env
from shared.sx.parser import SxExpr
return await render_to_sx_with_env("events-ticket-types-layout-full", {},
post_header=SxExpr(await _post_header_sx(ctx)),
calendar_header=SxExpr(_calendar_header_sx(ctx)),
day_header=SxExpr(_day_header_sx(ctx)),
entry_header=SxExpr(_entry_header_html(ctx)),
entry_admin_header=SxExpr(_entry_admin_header_html(ctx)),
ticket_types_header=SxExpr(_ticket_types_header_html(ctx)),
)
async def _ticket_types_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env, oob_header_sx
from shared.sx.parser import SxExpr
return await render_to_sx_with_env("events-ticket-types-layout-oob", {},
entry_admin_oob=SxExpr(_entry_admin_header_html(ctx, oob=True)),
ticket_types_oob_wrap=SxExpr(await oob_header_sx("entry-admin-header-child",
"ticket_types-header-child", _ticket_types_header_html(ctx))),
)
# --- Ticket type detail layout (extends ticket types with ticket-type header, + menu) ---
async def _ticket_type_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env
from shared.sx.parser import SxExpr
return await render_to_sx_with_env("events-ticket-type-layout-full", {},
post_header=SxExpr(await _post_header_sx(ctx)),
calendar_header=SxExpr(_calendar_header_sx(ctx)),
day_header=SxExpr(_day_header_sx(ctx)),
entry_header=SxExpr(_entry_header_html(ctx)),
entry_admin_header=SxExpr(_entry_admin_header_html(ctx)),
ticket_types_header=SxExpr(_ticket_types_header_html(ctx)),
ticket_type_header=SxExpr(_ticket_type_header_html(ctx)),
)
async def _ticket_type_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env, oob_header_sx
from shared.sx.parser import SxExpr
return await render_to_sx_with_env("events-ticket-type-layout-oob", {},
ticket_types_oob=SxExpr(_ticket_types_header_html(ctx, oob=True)),
ticket_type_oob_wrap=SxExpr(await oob_header_sx("ticket_types-header-child",
"ticket_type-header-child", _ticket_type_header_html(ctx))),
)
# --- Markets layout (root + child(post + markets)) ---
async def _markets_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env
from shared.sx.parser import SxExpr
return await render_to_sx_with_env("events-markets-layout-full", {},
post_header=SxExpr(await _post_header_sx(ctx)),
markets_header=SxExpr(_markets_header_sx(ctx)),
)
async def _markets_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env, oob_header_sx
from shared.sx.parser import SxExpr
return await render_to_sx_with_env("events-markets-layout-oob", {},
post_oob=SxExpr(await _post_header_sx(ctx, oob=True)),
markets_oob_wrap=SxExpr(await oob_header_sx("post-header-child",
"markets-header-child", _markets_header_sx(ctx))),
)

View File

@@ -160,7 +160,7 @@ def _slot_header_html(ctx: dict, *, oob: bool = False) -> str:
name=slot.name, description=desc)
return sx_call("menu-row-sx", id="slot-row", level=5,
link_label_content=SxExpr(label_sx),
link_label_content=label_sx,
child_id="slot-header-child", oob=oob)
@@ -201,7 +201,7 @@ def render_slot_main_panel(slot, calendar, *, oob: bool = False) -> str:
result = sx_call("events-slot-panel",
slot_id=sid, list_container=list_container,
days=SxExpr(days_html),
days=days_html,
flexible="yes" if flexible else "no",
time_str=f"{time_start} \u2014 {time_end}",
cost_str=cost_str,
@@ -259,11 +259,11 @@ def render_slots_table(slots, calendar) -> str:
pill_cls=pill_cls, hx_select=hx_select,
slot_name=s.name, description=desc,
flexible="yes" if s.flexible else "no",
days=SxExpr(days_html),
days=days_html,
time_str=f"{time_start} - {time_end}",
cost_str=cost_str, action_btn=action_btn,
del_url=del_url,
csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}')
csrf_hdr={"X-CSRFToken": csrf})
else:
rows_html = sx_call("events-slots-empty-row")
@@ -343,7 +343,7 @@ def render_slot_add_form(calendar) -> str:
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 = f'{{"X-CSRFToken": "{csrf}"}}'
csrf_hdr = {"X-CSRFToken": csrf}
# Days checkboxes (all unchecked for add)
day_keys = [("mon", "Mon"), ("tue", "Tue"), ("wed", "Wed"), ("thu", "Thu"),

View File

@@ -36,7 +36,7 @@ def _ticket_widget_html(entry, qty: int, ticket_url: str, *, ctx: dict) -> str:
return sx_call("events-tw-form",
ticket_url=ticket_url, target=tgt,
csrf=csrf_token_val, entry_id=str(eid),
count_val=str(count_val), btn=SxExpr(btn_html))
count_val=str(count_val), btn=btn_html)
if qty == 0:
inner = _tw_form(1, sx_call("events-tw-cart-plus"))
@@ -80,7 +80,7 @@ def _tickets_main_panel_html(ctx: dict, tickets: list) -> str:
type_name=tt.name if tt else None,
time_str=time_str or None,
cal_name=cal.name if cal else None,
badge=SxExpr(_ticket_state_badge_html(state)),
badge=_ticket_state_badge_html(state),
code_prefix=ticket.code[:8]))
cards_html = "".join(ticket_cards)
@@ -193,7 +193,7 @@ def _ticket_admin_main_panel_html(ctx: dict, tickets: list, stats: dict) -> str:
entry_name=entry.name if entry else "\u2014",
date=SxExpr(date_html),
type_name=tt.name if tt else "\u2014",
badge=SxExpr(_ticket_state_badge_html(state)),
badge=_ticket_state_badge_html(state),
action=SxExpr(action_html))
return sx_call("events-ticket-admin-panel",
@@ -238,7 +238,7 @@ def render_checkin_result(success: bool, error: str | None, ticket) -> str:
entry_name=entry.name if entry else "\u2014",
date=SxExpr(date_html),
type_name=tt.name if tt else "\u2014",
badge=SxExpr(_ticket_state_badge_html("checked_in")),
badge=_ticket_state_badge_html("checked_in"),
time_str=time_str)
@@ -275,7 +275,7 @@ def render_lookup_result(ticket, error: str | None) -> str:
if cal:
info_html += sx_call("events-lookup-cal", cal_name=cal.name)
info_html += sx_call("events-lookup-status",
badge=SxExpr(_ticket_state_badge_html(state)), code=code)
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"))
@@ -328,7 +328,7 @@ def render_entry_tickets_admin(entry, tickets: list) -> str:
rows_html += sx_call("events-entry-tickets-admin-row",
code=code, code_short=code[:12] + "...",
type_name=tt.name if tt else "\u2014",
badge=SxExpr(_ticket_state_badge_html(state)),
badge=_ticket_state_badge_html(state),
action=SxExpr(action_html))
if tickets:
@@ -340,7 +340,7 @@ def render_entry_tickets_admin(entry, tickets: list) -> str:
return sx_call("events-entry-tickets-admin-panel",
entry_name=entry.name,
count_label=f"{count} ticket{suffix}",
body=SxExpr(body_html))
body=body_html)
# ---------------------------------------------------------------------------
@@ -419,7 +419,7 @@ def render_ticket_types_table(ticket_types, entry, calendar, day, month, year) -
tt_name=tt.name, cost_str=cost_str,
count=str(tt.count), action_btn=action_btn,
del_url=del_url,
csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}')
csrf_hdr={"X-CSRFToken": csrf})
else:
rows_html = sx_call("events-ticket-types-empty-row")
@@ -519,16 +519,16 @@ def render_buy_form(entry, ticket_remaining, ticket_sold_count,
cost_str = f"\u00a3{tt.cost:.2f}" if tt.cost is not None else "\u00a30.00"
type_items += sx_call("events-buy-type-item",
type_name=tt.name, cost_str=cost_str,
adjust_controls=SxExpr(_ticket_adjust_controls(csrf, adjust_url, target, eid, type_count, ticket_type_id=tt.id)))
adjust_controls=_ticket_adjust_controls(csrf, adjust_url, target, eid, type_count, ticket_type_id=tt.id))
body_html = sx_call("events-buy-types-wrapper", items=SxExpr(type_items))
else:
qty = user_ticket_count or 0
body_html = sx_call("events-buy-default",
price_str=f"\u00a3{tp:.2f}",
adjust_controls=SxExpr(_ticket_adjust_controls(csrf, adjust_url, target, eid, qty)))
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=SxExpr(body_html))
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):
@@ -543,8 +543,8 @@ def _ticket_adjust_controls(csrf, adjust_url, target, entry_id, count, *, ticket
return sx_call("events-adjust-form",
adjust_url=adjust_url, target=target,
extra_cls=extra_cls, csrf=csrf,
entry_id=eid_s, tt=SxExpr(tt_html) if tt_html else None,
count_val=str(count_val), btn=SxExpr(btn_html))
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"),
@@ -557,7 +557,7 @@ def _ticket_adjust_controls(csrf, adjust_url, target, entry_id, count, *, ticket
plus = _adj_form(count + 1, sx_call("events-adjust-plus"))
return sx_call("events-adjust-controls",
minus=SxExpr(minus), cart_icon=SxExpr(cart_icon), plus=SxExpr(plus))
minus=minus, cart_icon=cart_icon, plus=plus)
# ---------------------------------------------------------------------------
@@ -603,7 +603,7 @@ def _ticket_types_header_html(ctx: dict, *, oob: bool = False) -> str:
return sx_call("menu-row-sx", id="ticket_types-row", level=7,
link_href=link_href, link_label_content=SxExpr(label_html),
nav=SxExpr(nav_html) if nav_html else None, child_id="ticket_type-header-child", oob=oob)
nav=nav_html or None, child_id="ticket_type-header-child", oob=oob)
@@ -639,7 +639,7 @@ def _ticket_type_header_html(ctx: dict, *, oob: bool = False) -> str:
return sx_call("menu-row-sx", id="ticket_type-row", level=8,
link_href=link_href, link_label_content=SxExpr(label_html),
nav=SxExpr(nav_html) if nav_html else None, child_id="ticket_type-header-child-inner", oob=oob)
nav=nav_html or None, child_id="ticket_type-header-child-inner", oob=oob)
# ---------------------------------------------------------------------------
@@ -699,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",
calendar_slug=cal_slug, entry_id=entry.id,
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",
post_url=post_url, csrf=csrf_hdr,

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from shared.sx.helpers import sx_call
from shared.sx.parser import SxExpr
# ---------------------------------------------------------------------------
@@ -146,7 +145,7 @@ def _view_toggle_html(ctx: dict, view: str) -> str:
list_href=list_href, tile_href=tile_href,
hx_select=hx_select, list_cls=list_active,
tile_cls=tile_active, storage_key="events_view",
list_svg=SxExpr(_get_list_svg()), tile_svg=SxExpr(_get_tile_svg()))
list_svg=_get_list_svg(), tile_svg=_get_tile_svg())
def _cart_icon_oob(count: int) -> str:

View File

@@ -31,7 +31,6 @@ async def _render_choose_username(*, actor=None, error="", username=""):
from shared.browser.app.csrf import generate_csrf_token
from shared.config import config
from shared.sx.helpers import sx_call
from shared.sx.parser import SxExpr
from shared.sx.page import get_template_context
from sxc.pages.utils import _social_page
from markupsafe import escape
@@ -45,7 +44,7 @@ async def _render_choose_username(*, actor=None, error="", username=""):
content = sx_call(
"federation-choose-username",
domain=str(escape(ap_domain)),
error=SxExpr(error_sx) if error_sx else None,
error=error_sx or None,
csrf=csrf, username=str(escape(username)),
check_url=check_url,
)

View File

@@ -212,7 +212,6 @@ def register(url_prefix="/social"):
"""Re-render interaction buttons after a like/boost action."""
from shared.models.federation import APInteraction
from shared.browser.app.csrf import generate_csrf_token
from shared.sx.parser import SxExpr
from sqlalchemy import select
svc = services.federation
@@ -290,9 +289,9 @@ def register(url_prefix="/social"):
count=str(boost_count))
return sx_response(sx_call("federation-interaction-buttons",
like=SxExpr(like_form),
boost=SxExpr(boost_form),
reply=SxExpr(reply_sx) if reply_sx else None))
like=like_form,
boost=boost_form,
reply=reply_sx or None))
# -- Following / Followers pagination --------------------------------------

View File

@@ -1,4 +1,5 @@
;; Federation link-card fragment handler
;; returns: sx
;;
;; Renders actor profile preview card(s) by username.
;; Supports single mode (?slug=x or ?username=x) and batch mode (?keys=x,y,z).

View File

@@ -1,17 +1,17 @@
;; Federation layout defcomps — read ctx values from env free variables.
;; `actor` is injected into env by the layout registration in __init__.py.
;; Federation layout defcomps — fully self-contained via IO primitives.
;; Registered via register_sx_layout("social", ...) in __init__.py.
;; Full page: root header + social header in header-child
(defcomp ~social-layout-full ()
(<> (~root-header-auto)
(~header-child-sx
:inner (~federation-social-header
:nav (~federation-social-nav :actor actor)))))
:nav (~federation-social-nav :actor (federation-actor-ctx))))))
;; OOB (HTMX): social header oob + root header oob
(defcomp ~social-layout-oob ()
(<> (~oob-header-sx
:parent-id "root-header-child"
:row (~federation-social-header
:nav (~federation-social-nav :actor actor)))
:nav (~federation-social-nav :actor (federation-actor-ctx))))
(~root-header-auto true)))

View File

@@ -15,6 +15,5 @@ def _load_federation_page_files() -> None:
def _register_federation_layouts() -> None:
from shared.sx.layouts import register_custom_layout
from .utils import _social_full, _social_oob
register_custom_layout("social", _social_full, _social_oob)
from shared.sx.layouts import register_sx_layout
register_sx_layout("social", "social-layout-full", "social-layout-oob")

View File

@@ -1,8 +1,6 @@
"""Federation page utilities — serializers, actor helpers, social page builder."""
from __future__ import annotations
from typing import Any
def _serialize_actor(actor) -> dict | None:
"""Serialize an actor profile to a dict for sx defcomps."""
@@ -24,7 +22,7 @@ def _serialize_remote_actor(a) -> dict:
async def _social_page(ctx: dict, actor, *, content: str,
title: str = "Rose Ash", meta_html: str = "") -> str:
"""Build a full social page with social header."""
"""Build a full social page with social header (non-defpage routes)."""
from shared.sx.helpers import render_to_sx_with_env, full_page_sx
from markupsafe import escape
@@ -47,22 +45,3 @@ def _require_actor():
if not actor:
abort(403, "You need to choose a federation username first")
return actor
def _actor_data(ctx: dict) -> dict | None:
actor = ctx.get("actor")
if not actor:
return None
return _serialize_actor(actor)
async def _social_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env
env = {"actor": kw.get("actor") or _actor_data(ctx)}
return await render_to_sx_with_env("social-layout-full", env)
async def _social_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env
env = {"actor": kw.get("actor") or _actor_data(ctx)}
return await render_to_sx_with_env("social-layout-oob", env)

View File

@@ -228,6 +228,11 @@ def create_app() -> "Quart":
("market", "container-nav", nav_params),
], required=False)
ctx["container_nav"] = events_nav + market_nav
# Populate g._defpage_ctx for layout IO primitives
if not hasattr(g, '_defpage_ctx'):
g._defpage_ctx = {}
g._defpage_ctx.setdefault("post", post_data.get("post"))
g._defpage_ctx.setdefault("container_nav", ctx["container_nav"])
return ctx
# --- oEmbed endpoint ---

View File

@@ -95,3 +95,63 @@
(if desc-content desc-content (when desc desc)))
(if badge-content badge-content (when badge badge))))
;; ---------------------------------------------------------------------------
;; Composition defcomps — receive data lists, compose component trees
;; ---------------------------------------------------------------------------
;; Product cards grid with infinite scroll sentinels
(defcomp ~market-product-cards-content (&key products page total-pages next-url
mobile-sentinel-hs desktop-sentinel-hs)
(<>
(map (lambda (p)
(~market-product-card
:href (get p "href") :hx-select (get p "hx-select") :slug (get p "slug")
:image (get p "image") :brand (get p "brand") :brand-highlight (get p "brand-highlight")
:special-price (get p "special-price") :regular-price (get p "regular-price")
:cart-action (get p "cart-action") :quantity (get p "quantity")
:cart-href (get p "cart-href") :csrf (get p "csrf")
:title (get p "title") :has-like (get p "has-like")
:liked (get p "liked") :like-action (get p "like-action")
:labels (get p "labels") :stickers (get p "stickers")
:has-highlight (get p "has-highlight")
:search-pre (get p "search-pre") :search-mid (get p "search-mid")
:search-post (get p "search-post")))
products)
(if (< page total-pages)
(<> (~sentinel-mobile :id (str "sentinel-" page "-m") :next-url next-url
:hyperscript mobile-sentinel-hs)
(~sentinel-desktop :id (str "sentinel-" page "-d") :next-url next-url
:hyperscript desktop-sentinel-hs))
(~end-of-results))))
;; Single market card from data (handles conditional title/desc/badge)
(defcomp ~market-card-from-data (&key name description href show-badge badge-href badge-title)
(~market-market-card
:title-content (if href
(~market-market-card-title-link :href href :name name)
(~market-market-card-title :name name))
:desc-content (when description
(~market-market-card-desc :description description))
:badge-content (when (and show-badge badge-title)
(~market-market-card-badge :href badge-href :title badge-title))))
;; Market cards list with infinite scroll sentinel
(defcomp ~market-cards-content (&key markets page has-more next-url)
(<>
(map (lambda (m)
(~market-card-from-data
:name (get m "name") :description (get m "description")
:href (get m "href") :show-badge (get m "show-badge")
:badge-href (get m "badge-href") :badge-title (get m "badge-title")))
markets)
(when has-more
(~sentinel-simple :id (str "sentinel-" page) :next-url next-url))))
;; Market landing page content from data
(defcomp ~market-landing-from-data (&key excerpt feature-image html)
(~market-landing-content :inner
(<> (when excerpt (~market-landing-excerpt :text excerpt))
(when feature-image (~market-landing-image :src feature-image))
(when html (~market-landing-html :html html)))))

View File

@@ -92,3 +92,71 @@
(defcomp ~market-landing-content (&key inner)
(<> (article :class "relative w-full" inner) (div :class "pb-8")))
;; ---------------------------------------------------------------------------
;; Composition: product detail page from data
;; ---------------------------------------------------------------------------
;; Gallery section from pre-computed data
(defcomp ~market-detail-gallery-from-data (&key images labels brand like-data has-nav-buttons thumbs)
(let ((like-sx (when like-data
(~market-like-button
:form-id (get like-data "form-id") :action (get like-data "action")
:slug (get like-data "slug") :csrf (get like-data "csrf")
:icon-cls (get like-data "icon-cls")))))
(if images
(<>
(~market-detail-gallery
:inner (~market-detail-gallery-inner
:like like-sx
:image (get (first images) "src") :alt (get (first images) "alt")
:labels (when labels
(<> (map (lambda (src) (~market-label-overlay :src src)) labels)))
:brand brand)
:nav (when has-nav-buttons (~market-detail-nav-buttons)))
(when thumbs
(~market-detail-thumbs :thumbs
(<> (map (lambda (t)
(~market-detail-thumb
:title (get t "title") :src (get t "src") :alt (get t "alt")))
thumbs)))))
(~market-detail-no-image :like like-sx))))
;; Right column details from data
(defcomp ~market-detail-info-from-data (&key extras desc-short desc-html sections)
(~market-detail-right-col :inner
(<>
(when extras
(~market-detail-extras :inner
(<> (map (lambda (e)
(if (= (get e "type") "unit-price")
(~market-detail-unit-price :price (get e "value"))
(~market-detail-case-size :size (get e "value"))))
extras))))
(when (or desc-short desc-html)
(~market-detail-desc-wrapper :inner
(<> (when desc-short (~market-detail-desc-short :text desc-short))
(when desc-html (~market-detail-desc-html :html desc-html)))))
(when sections
(~market-detail-sections :items
(<> (map (lambda (s)
(~market-detail-section :title (get s "title") :html (get s "html")))
sections)))))))
;; Full product detail layout from data
(defcomp ~market-product-detail-from-data (&key images labels brand like-data
has-nav-buttons thumbs sticker-items
extras desc-short desc-html sections)
(~market-detail-layout
:gallery (~market-detail-gallery-from-data
:images images :labels labels :brand brand :like-data like-data
:has-nav-buttons has-nav-buttons :thumbs thumbs)
:stickers (when sticker-items
(~market-detail-stickers :items
(<> (map (lambda (s)
(~market-detail-sticker :src (get s "src") :name (get s "name")))
sticker-items))))
:details (~market-detail-info-from-data
:extras extras :desc-short desc-short :desc-html desc-html
:sections sections)))

View File

@@ -122,3 +122,152 @@
(defcomp ~market-mobile-chip-brand-list (&key items)
(ul items))
;; ---------------------------------------------------------------------------
;; Composition defcomps — receive data, compose filter component trees
;; ---------------------------------------------------------------------------
;; Sort option stickers from data
(defcomp ~market-filter-sort-from-data (&key items)
(~market-filter-sort-row :items
(<> (map (lambda (s)
(~market-filter-sort-item
:href (get s "href") :hx-select (get s "hx-select")
:ring-cls (get s "ring-cls") :src (get s "src") :label (get s "label")))
items))))
;; Like filter from data
(defcomp ~market-filter-like-from-data (&key href hx-select liked mobile)
(~market-filter-like
:href href :hx-select hx-select
:icon-cls (if liked "fa-solid fa-heart text-red-500" "fa-regular fa-heart text-stone-400")
:size-cls (if mobile "text-[40px]" "text-2xl")))
;; Label filter items from data
(defcomp ~market-filter-labels-from-data (&key items hx-select)
(<> (map (lambda (lb)
(~market-filter-label-item
:href (get lb "href") :hx-select hx-select
:ring-cls (get lb "ring-cls") :src (get lb "src") :name (get lb "name")))
items)))
;; Sticker filter items from data
(defcomp ~market-filter-stickers-from-data (&key items hx-select)
(~market-filter-stickers-row :items
(<> (map (lambda (st)
(~market-filter-sticker-item
:href (get st "href") :hx-select hx-select
:ring-cls (get st "ring-cls") :src (get st "src") :name (get st "name")
:count-cls (get st "count-cls") :count (get st "count")))
items))))
;; Brand filter items from data
(defcomp ~market-filter-brands-from-data (&key items hx-select)
(~market-filter-brands-panel :items
(<> (map (lambda (br)
(~market-filter-brand-item
:href (get br "href") :hx-select hx-select
:bg-cls (get br "bg-cls") :name-cls (get br "name-cls")
:name (get br "name") :count (get br "count")))
items))))
;; Subcategory selector from data
(defcomp ~market-filter-subcategories-from-data (&key items hx-select all-href current-sub)
(~market-filter-subcategory-panel :items
(<>
(~market-filter-subcategory-item
:href all-href :hx-select hx-select
:active-cls (if (not current-sub) " bg-stone-200 font-medium" "")
:name "All")
(map (lambda (sub)
(~market-filter-subcategory-item
:href (get sub "href") :hx-select hx-select
:active-cls (get sub "active-cls") :name (get sub "name")))
items))))
;; Desktop filter panel from data
(defcomp ~market-desktop-filter-from-data (&key search-sx category-label
sort-data like-data label-data
sticker-data brand-data sub-data hx-select)
(<>
search-sx
(~market-desktop-category-summary :inner
(<>
(~market-filter-category-label :label category-label)
(when sort-data (~market-filter-sort-from-data :items sort-data))
(~market-filter-like-labels-nav :inner
(<>
(~market-filter-like-from-data
:href (get like-data "href") :hx-select hx-select
:liked (get like-data "liked") :mobile false)
(when label-data
(~market-filter-labels-from-data :items label-data :hx-select hx-select))))
(when sticker-data
(~market-filter-stickers-from-data :items sticker-data :hx-select hx-select))
(when sub-data
(~market-filter-subcategories-from-data
:items (get sub-data "items") :hx-select hx-select
:all-href (get sub-data "all-href")
:current-sub (get sub-data "current-sub")))))
(~market-desktop-brand-summary
:inner (when brand-data
(~market-filter-brands-from-data :items brand-data :hx-select hx-select)))))
;; Mobile filter chips from active filter data
(defcomp ~market-mobile-chips-from-data (&key sort-chip liked-chip label-chips sticker-chips brand-chips)
(~market-mobile-chips-row :inner
(<>
(when sort-chip
(~market-mobile-chip-sort :src (get sort-chip "src") :label (get sort-chip "label")))
(when liked-chip
(~market-mobile-chip-liked :inner
(<>
(~market-mobile-chip-liked-icon)
(when (get liked-chip "count")
(~market-mobile-chip-count
:cls (get liked-chip "count-cls") :count (get liked-chip "count"))))))
(when label-chips
(~market-mobile-chip-list :items
(<> (map (lambda (lc)
(~market-mobile-chip-item :inner
(<>
(~market-mobile-chip-image :src (get lc "src") :name (get lc "name"))
(when (get lc "count")
(~market-mobile-chip-count :cls (get lc "count-cls") :count (get lc "count"))))))
label-chips))))
(when sticker-chips
(~market-mobile-chip-list :items
(<> (map (lambda (sc)
(~market-mobile-chip-item :inner
(<>
(~market-mobile-chip-image :src (get sc "src") :name (get sc "name"))
(when (get sc "count")
(~market-mobile-chip-count :cls (get sc "count-cls") :count (get sc "count"))))))
sticker-chips))))
(when brand-chips
(~market-mobile-chip-brand-list :items
(<> (map (lambda (bc)
(if (get bc "has-count")
(~market-mobile-chip-brand :name (get bc "name") :count (get bc "count"))
(~market-mobile-chip-brand-zero :name (get bc "name"))))
brand-chips)))))))
;; Mobile filter content (expanded panel) from data
(defcomp ~market-mobile-filter-content-from-data (&key sort-data like-data label-data
sticker-data brand-data clear-href hx-select)
(<>
(when sort-data (~market-filter-sort-from-data :items sort-data))
(when clear-href
(~market-mobile-clear-filters :href clear-href :hx-select hx-select))
(~market-mobile-like-labels-row :inner
(<>
(~market-filter-like-from-data
:href (get like-data "href") :hx-select hx-select
:liked (get like-data "liked") :mobile true)
(when label-data
(~market-filter-labels-from-data :items label-data :hx-select hx-select))))
(when sticker-data
(~market-filter-stickers-from-data :items sticker-data :hx-select hx-select))
(when brand-data
(~market-filter-brands-from-data :items brand-data :hx-select hx-select))))

View File

@@ -1,4 +1,5 @@
;; Market container-nav fragment handler
;; returns: sx
;;
;; Renders marketplace link nav items for blog post pages.

View File

@@ -1,4 +1,5 @@
;; Market link-card fragment handler
;; returns: sx
;;
;; Renders product preview card(s) by slug.
;; Supports single mode (?slug=x) and batch mode (?keys=x,y,z).

View File

@@ -15,3 +15,64 @@
:class "px-2 py-1 text-stone-500 hover:text-stone-700"
(i :class "fa fa-cog" :aria-hidden "true")))
;; ---------------------------------------------------------------------------
;; Composition defcomps — receive data, compose component trees
;; ---------------------------------------------------------------------------
;; Desktop category nav from pre-computed category data
(defcomp ~market-desktop-nav-from-data (&key categories hx-select select-colours
all-href all-active admin-href)
(~market-desktop-category-nav
:links (<>
(~market-category-link :href all-href :hx-select hx-select
:active all-active :select-colours select-colours :label "All")
(map (lambda (cat)
(~market-category-link
:href (get cat "href") :hx-select hx-select
:active (get cat "active") :select-colours select-colours
:label (get cat "label"))) categories))
:admin (when admin-href
(~market-admin-link :href admin-href :hx-select hx-select))))
;; Market-level header row from data
(defcomp ~market-header-from-data (&key market-title top-slug sub-slug link-href
categories hx-select select-colours
all-href all-active admin-href oob)
(~menu-row-sx :id "market-row" :level 2
:link-href link-href
:link-label-content (~market-shop-label
:title market-title :top-slug (or top-slug "") :sub-div sub-slug)
:nav (~market-desktop-nav-from-data
:categories categories :hx-select hx-select :select-colours select-colours
:all-href all-href :all-active all-active :admin-href admin-href)
:child-id "market-header-child"
:oob oob))
;; Product-level header row from data
(defcomp ~market-product-header-from-data (&key title link-href hx-select
price-data admin-href oob)
(~menu-row-sx :id "product-row" :level 3
:link-href link-href
:link-label-content (~market-product-label :title title)
:nav (<>
(~market-prices-header-from-data
:cart-id (get price-data "cart-id")
:cart-action (get price-data "cart-action")
:csrf (get price-data "csrf")
:quantity (get price-data "quantity")
:cart-href (get price-data "cart-href")
:sp-val (get price-data "sp-val") :sp-str (get price-data "sp-str")
:rp-val (get price-data "rp-val") :rp-str (get price-data "rp-str")
:rrp-str (get price-data "rrp-str"))
(when admin-href
(~market-admin-link :href admin-href :hx-select hx-select)))
:child-id "product-header-child"
:oob oob))
;; Product admin header row from data
(defcomp ~market-product-admin-header-from-data (&key link-href oob)
(~menu-row-sx :id "product-admin-row" :level 4
:link-href link-href :link-label "admin!!" :icon "fa fa-cog"
:child-id "product-admin-header-child" :oob oob))

View File

@@ -1,42 +1,111 @@
;; Market layout defcomps — root header via ~root-header-auto,
;; market-specific headers passed as &key params.
;; Market layout defcomps — fully self-contained via IO primitives.
;; Registered via register_sx_layout in layouts.py.
;; --- Browse layout: root + post header + market header ---
;; ---------------------------------------------------------------------------
;; Auto-fetching market header macro
;; ---------------------------------------------------------------------------
(defcomp ~market-browse-layout-full (&key post-header market-header)
(defmacro ~market-header-auto (oob)
"Market header row using (market-header-ctx)."
(quasiquote
(let ((__mctx (market-header-ctx)))
(~menu-row-sx :id "market-row" :level 2
:link-href (get __mctx "link-href")
:link-label-content (~market-shop-label
:title (get __mctx "market-title")
:top-slug (get __mctx "top-slug")
:sub-div (get __mctx "sub-slug"))
: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"
:oob (unquote oob)))))
;; ---------------------------------------------------------------------------
;; OOB clear helpers
;; ---------------------------------------------------------------------------
(defcomp ~market-clear-oob ()
"Clear OOB divs for browse level."
(<>
(~clear-oob-div :id "product-admin-row")
(~clear-oob-div :id "product-admin-header-child")
(~clear-oob-div :id "product-row")
(~clear-oob-div :id "product-header-child")
(~clear-oob-div :id "market-admin-row")
(~clear-oob-div :id "market-admin-header-child")
(~clear-oob-div :id "post-admin-row")
(~clear-oob-div :id "post-admin-header-child")))
(defcomp ~market-clear-oob-admin ()
"Clear OOB divs for admin level."
(<>
(~clear-oob-div :id "product-admin-row")
(~clear-oob-div :id "product-admin-header-child")
(~clear-oob-div :id "product-row")
(~clear-oob-div :id "product-header-child")))
;; ---------------------------------------------------------------------------
;; Browse layout: root + post + market (self-contained)
;; ---------------------------------------------------------------------------
(defcomp ~market-browse-layout-full ()
(<> (~root-header-auto)
(~header-child-sx :inner (<> post-header market-header))))
(~header-child-sx
:inner (<> (~post-header-auto nil)
(~market-header-auto nil)))))
(defcomp ~market-browse-layout-oob (&key oob-header post-header-oob clear-oob)
(<> oob-header post-header-oob clear-oob))
(defcomp ~market-browse-layout-oob ()
(<> (~post-header-auto true)
(~oob-header-sx :parent-id "post-header-child"
:row (~market-header-auto nil))
(~market-clear-oob)
(~root-header-auto true)))
;; --- Product layout: root + post + market + product ---
(defcomp ~market-browse-layout-mobile ()
(let ((__mctx (market-header-ctx)))
(get __mctx "mobile-nav")))
;; ---------------------------------------------------------------------------
;; Market admin layout: root + post + market + post-admin (self-contained)
;; ---------------------------------------------------------------------------
(defcomp ~market-admin-layout-full (&key selected)
(<> (~root-header-auto)
(~header-child-sx
:inner (<> (~post-header-auto nil)
(~market-header-auto nil)
(~post-admin-header-auto nil selected)))))
(defcomp ~market-admin-layout-oob (&key selected)
(<> (~market-header-auto true)
(~oob-header-sx :parent-id "market-header-child"
:row (~post-admin-header-auto nil selected))
(~market-clear-oob-admin)
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; Parameterized defcomps — used by renders.py (non-defpage routes)
;; ---------------------------------------------------------------------------
;; Product layout: root + post + market + product
(defcomp ~market-product-layout-full (&key post-header market-header product-header)
(<> (~root-header-auto)
(~header-child-sx :inner (<> post-header market-header product-header))))
;; --- Product admin layout: root + post + market + product + admin ---
;; Product admin layout: root + post + market + product + admin
(defcomp ~market-product-admin-layout-full (&key post-header market-header product-header admin-header)
(<> (~root-header-auto)
(~header-child-sx :inner (<> post-header market-header product-header admin-header))))
;; --- Market admin layout: root + post + market + market-admin ---
(defcomp ~market-admin-layout-full (&key post-header market-header admin-header)
(<> (~root-header-auto)
(~header-child-sx :inner (<> post-header market-header admin-header))))
(defcomp ~market-admin-layout-oob (&key market-header-oob admin-oob-header clear-oob)
(<> market-header-oob admin-oob-header clear-oob))
;; --- OOB wrappers ---
;; OOB wrappers
(defcomp ~market-oob-wrap (&key parts)
(<> parts))
;; --- Content wrappers ---
;; Content wrappers
(defcomp ~market-content-padded (&key content)
(<> content (div :class "pb-8")))

View File

@@ -17,3 +17,35 @@
(defcomp ~market-meta-jsonld (&key json)
(script :type "application/ld+json" (~rich-text :html json)))
;; ---------------------------------------------------------------------------
;; Composition: all product meta tags from data
;; ---------------------------------------------------------------------------
(defcomp ~market-product-meta-from-data (&key title description canonical image-url
site-title brand price price-currency
jsonld-json)
(<>
(~market-meta-title :title title)
(~market-meta-description :description description)
(when canonical (~market-meta-canonical :href canonical))
;; OpenGraph
(~market-meta-og :property "og:site_name" :content site-title)
(~market-meta-og :property "og:type" :content "product")
(~market-meta-og :property "og:title" :content title)
(~market-meta-og :property "og:description" :content description)
(when canonical (~market-meta-og :property "og:url" :content canonical))
(when image-url (~market-meta-og :property "og:image" :content image-url))
(when (and price price-currency)
(<> (~market-meta-og :property "product:price:amount" :content price)
(~market-meta-og :property "product:price:currency" :content price-currency)))
(when brand (~market-meta-og :property "product:brand" :content brand))
;; Twitter
(~market-meta-twitter :name "twitter:card"
:content (if image-url "summary_large_image" "summary"))
(~market-meta-twitter :name "twitter:title" :content title)
(~market-meta-twitter :name "twitter:description" :content description)
(when image-url (~market-meta-twitter :name "twitter:image" :content image-url))
;; JSON-LD
(~market-meta-jsonld :json jsonld-json)))

View File

@@ -61,3 +61,37 @@
(defcomp ~market-mobile-cat-details (&key open summary subs)
(details :class "group/cat py-1" :open open
summary subs))
;; ---------------------------------------------------------------------------
;; Composition: mobile nav panel from pre-computed category data
;; ---------------------------------------------------------------------------
(defcomp ~market-mobile-nav-from-data (&key categories all-href all-active hx-select select-colours)
(~market-mobile-nav-wrapper :items
(<>
(~market-mobile-all-link :href all-href :hx-select hx-select
:active all-active :select-colours select-colours)
(map (lambda (cat)
(~market-mobile-cat-details
:open (get cat "active")
:summary (~market-mobile-cat-summary
:bg-cls (if (get cat "active") " bg-stone-900 text-white hover:bg-stone-900" "")
:href (get cat "href") :hx-select hx-select
:select-colours select-colours :cat-name (get cat "name")
:count-label (str (get cat "count") " products")
:count-str (str (get cat "count"))
:chevron (~market-mobile-chevron))
:subs (if (get cat "subs")
(~market-mobile-subs-panel :links
(<> (map (lambda (sub)
(~market-mobile-sub-link
:select-colours select-colours
:active (get sub "active")
:href (get sub "href") :hx-select hx-select
:label (get sub "label")
:count-label (str (get sub "count") " products")
:count-str (str (get sub "count"))))
(get cat "subs"))))
(~market-mobile-view-all :href (get cat "href") :hx-select hx-select))))
categories))))

View File

@@ -32,3 +32,36 @@
(defcomp ~market-prices-row (&key inner)
(div :class "flex flex-row items-center justify-between md:gap-2 md:px-2" inner))
;; ---------------------------------------------------------------------------
;; Composition: prices header + cart button from data
;; ---------------------------------------------------------------------------
(defcomp ~market-prices-header-from-data (&key cart-id cart-action csrf quantity cart-href
sp-val sp-str rp-val rp-str rrp-str)
(~market-prices-row :inner
(<>
(if quantity
(~market-cart-add-quantity :cart-id cart-id :action cart-action :csrf csrf
:minus-val (str (- quantity 1)) :plus-val (str (+ quantity 1))
:quantity (str quantity) :cart-href cart-href)
(~market-cart-add-empty :cart-id cart-id :action cart-action :csrf csrf))
(when sp-val
(<> (~market-header-price-special-label)
(~market-header-price-special :price sp-str)
(when rp-val (~market-header-price-strike :price rp-str))))
(when (and (not sp-val) rp-val)
(<> (~market-header-price-regular-label)
(~market-header-price-regular :price rp-str)))
(when rrp-str (~market-header-rrp :rrp rrp-str)))))
;; Card price line from data (used in product cards)
(defcomp ~market-card-price-from-data (&key sp-val sp-str rp-val rp-str)
(~market-price-line :inner
(<>
(when sp-val
(<> (~market-price-special :price sp-str)
(when rp-val (~market-price-regular-strike :price rp-str))))
(when (and (not sp-val) rp-val)
(~market-price-regular :price rp-str)))))

View File

@@ -1,9 +1,8 @@
"""Product/market card builders."""
"""Product/market card data builders."""
from __future__ import annotations
from typing import Any
from shared.sx.parser import SxExpr
from shared.sx.helpers import sx_call
from .utils import _set_prices, _price_str
@@ -11,11 +10,11 @@ from .filters import _MOBILE_SENTINEL_HS, _DESKTOP_SENTINEL_HS
# ---------------------------------------------------------------------------
# Product card (browse grid item)
# Product card data extraction
# ---------------------------------------------------------------------------
def _product_card_sx(p: dict, ctx: dict) -> str:
"""Build a single product card for browse grid as sx call."""
def _product_card_data(p: dict, ctx: dict) -> dict:
"""Extract data for a single product card."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
from shared.utils import route_prefix
@@ -49,7 +48,8 @@ def _product_card_sx(p: dict, ctx: dict) -> str:
if raw_stickers and callable(asset_url_fn):
for s in raw_stickers:
ring = " ring-2 ring-emerald-500 rounded" if s in selected_stickers else ""
sticker_data.append({"src": asset_url_fn(f"stickers/{s}.svg"), "name": s, "ring-cls": ring})
sticker_data.append({"src": asset_url_fn(f"stickers/{s}.svg"),
"name": s, "ring-cls": ring})
# Title highlighting
title = p.get("title", "")
@@ -71,38 +71,37 @@ def _product_card_sx(p: dict, ctx: dict) -> str:
brand = p.get("brand", "")
brand_highlight = " bg-yellow-200" if brand in selected_brands else ""
kwargs = dict(
href=item_href, hx_select=hx_select, slug=slug,
image=p.get("image", ""), brand=brand, brand_highlight=brand_highlight,
special_price=sp_str, regular_price=rp_str,
cart_action=cart_action, quantity=quantity, cart_href=cart_href, csrf=csrf,
title=title,
has_like=bool(user),
)
d: dict[str, Any] = {
"href": item_href, "hx-select": hx_select, "slug": slug,
"image": p.get("image", ""), "brand": brand, "brand-highlight": brand_highlight,
"special-price": sp_str, "regular-price": rp_str,
"cart-action": cart_action, "quantity": quantity, "cart-href": cart_href, "csrf": csrf,
"title": title, "has-like": bool(user),
}
if label_srcs:
kwargs["labels"] = label_srcs
d["labels"] = label_srcs
elif labels:
kwargs["labels"] = labels
d["labels"] = labels
if user:
kwargs["liked"] = p.get("is_liked", False)
kwargs["like_action"] = url_for("market.browse.product.like_toggle", product_slug=slug)
d["liked"] = p.get("is_liked", False)
d["like-action"] = url_for("market.browse.product.like_toggle", product_slug=slug)
if sticker_data:
kwargs["stickers"] = sticker_data
d["stickers"] = sticker_data
if has_highlight:
kwargs["has_highlight"] = True
kwargs["search_pre"] = search_pre
kwargs["search_mid"] = search_mid
kwargs["search_post"] = search_post
d["has-highlight"] = True
d["search-pre"] = search_pre
d["search-mid"] = search_mid
d["search-post"] = search_post
return sx_call("market-product-card", **kwargs)
return d
def _product_cards_sx(ctx: dict) -> str:
"""S-expression wire format for product cards (client renders)."""
"""S-expression wire format for product cards — delegates to .sx defcomp."""
from shared.utils import route_prefix
prefix = route_prefix()
@@ -112,48 +111,46 @@ def _product_cards_sx(ctx: dict) -> str:
current_local_href = ctx.get("current_local_href", "/")
qs_fn = ctx.get("qs_filter")
parts = []
for p in products:
parts.append(_product_card_sx(p, ctx))
product_data = [_product_card_data(p, ctx) for p in products]
next_url = ""
if page < total_pages:
if callable(qs_fn):
next_qs = qs_fn({"page": page + 1})
else:
next_qs = f"?page={page + 1}"
next_url = prefix + current_local_href + next_qs
parts.append(sx_call("sentinel-mobile",
id=f"sentinel-{page}-m", next_url=next_url,
hyperscript=_MOBILE_SENTINEL_HS))
parts.append(sx_call("sentinel-desktop",
id=f"sentinel-{page}-d", next_url=next_url,
hyperscript=_DESKTOP_SENTINEL_HS))
else:
parts.append(sx_call("end-of-results"))
return "(<> " + " ".join(parts) + ")"
return sx_call("market-product-cards-content",
products=product_data,
page=page,
total_pages=total_pages,
next_url=next_url,
mobile_sentinel_hs=_MOBILE_SENTINEL_HS,
desktop_sentinel_hs=_DESKTOP_SENTINEL_HS)
def _like_button_sx(slug: str, liked: bool, csrf: str, ctx: dict) -> str:
"""Build the like/unlike heart button overlay as sx."""
def _like_button_data(slug: str, liked: bool, csrf: str, ctx: dict) -> dict:
"""Extract like button data."""
from quart import url_for
action = url_for("market.browse.product.like_toggle", product_slug=slug)
icon_cls = "fa-solid fa-heart text-red-500" if liked else "fa-regular fa-heart text-stone-400"
return sx_call(
"market-like-button",
form_id=f"like-{slug}", action=action, slug=slug,
csrf=csrf, icon_cls=icon_cls,
)
return {
"form-id": f"like-{slug}",
"action": action,
"slug": slug,
"csrf": csrf,
"icon-cls": icon_cls,
}
# ---------------------------------------------------------------------------
# Market cards (all markets / page markets)
# Market cards data extraction
# ---------------------------------------------------------------------------
def _market_card_sx(market: Any, page_info: dict, *, show_page_badge: bool = True,
post_slug: str = "") -> str:
"""Build a single market card as sx."""
def _market_card_data(market: Any, page_info: dict, *, show_page_badge: bool = True,
post_slug: str = "") -> dict:
"""Extract data for a single market card."""
from shared.infrastructure.urls import market_url
name = getattr(market, "name", "")
@@ -161,78 +158,60 @@ def _market_card_sx(market: Any, page_info: dict, *, show_page_badge: bool = Tru
slug = getattr(market, "slug", "")
container_id = getattr(market, "container_id", None)
href = ""
badge_href = ""
badge_title = ""
if show_page_badge and page_info:
pi = page_info.get(container_id, {})
p_slug = pi.get("slug", "")
p_title = pi.get("title", "")
market_href = market_url(f"/{p_slug}/{slug}/") if p_slug else ""
href = market_url(f"/{p_slug}/{slug}/") if p_slug else ""
if p_title:
badge_href = market_url(f"/{p_slug}/")
badge_title = p_title
else:
p_slug = post_slug
p_title = ""
market_href = market_url(f"/{post_slug}/{slug}/") if post_slug else ""
href = market_url(f"/{post_slug}/{slug}/") if post_slug else ""
title_sx = ""
if market_href:
title_sx = sx_call("market-market-card-title-link", href=market_href, name=name)
else:
title_sx = sx_call("market-market-card-title", name=name)
desc_sx = ""
if description:
desc_sx = sx_call("market-market-card-desc", description=description)
badge_sx = ""
if show_page_badge and p_title:
badge_href = market_url(f"/{p_slug}/")
badge_sx = sx_call("market-market-card-badge", href=badge_href, title=p_title)
return sx_call(
"market-market-card",
title_content=SxExpr(title_sx) if title_sx else None,
desc_content=SxExpr(desc_sx) if desc_sx else None,
badge_content=SxExpr(badge_sx) if badge_sx else None,
)
return {
"name": name,
"description": description,
"href": href,
"show-badge": show_page_badge,
"badge-href": badge_href,
"badge-title": badge_title,
}
def _market_cards_sx(markets: list, page_info: dict, page: int, has_more: bool,
next_url: str, *, show_page_badge: bool = True,
post_slug: str = "") -> str:
"""Build market cards with infinite scroll sentinel as sx."""
parts = []
for m in markets:
parts.append(_market_card_sx(m, page_info, show_page_badge=show_page_badge,
post_slug=post_slug))
if has_more:
parts.append(sx_call(
"sentinel-simple",
id=f"sentinel-{page}", next_url=next_url,
))
return "(<> " + " ".join(parts) + ")"
next_url: str, *, show_page_badge: bool = True,
post_slug: str = "") -> str:
"""Build market cards as sx — delegates to .sx defcomp."""
market_data = [_market_card_data(m, page_info, show_page_badge=show_page_badge,
post_slug=post_slug) for m in markets]
return sx_call("market-cards-content",
markets=market_data,
page=page,
has_more=has_more,
next_url=next_url)
def _markets_grid(cards_sx: str) -> str:
"""Wrap market cards in a grid as sx."""
from shared.sx.parser import SxExpr
return sx_call("market-markets-grid", cards=SxExpr(cards_sx))
def _no_markets_sx(message: str = "No markets available") -> str:
"""Empty state for markets as sx."""
return sx_call("empty-state", icon="fa fa-store", message=message,
cls="px-3 py-12 text-center text-stone-400")
cls="px-3 py-12 text-center text-stone-400")
# ---------------------------------------------------------------------------
# Market landing page
# ---------------------------------------------------------------------------
def _market_landing_content_sx(post: dict) -> str:
"""Build market landing page content as sx."""
parts: list[str] = []
if post.get("custom_excerpt"):
parts.append(sx_call("market-landing-excerpt", text=post["custom_excerpt"]))
if post.get("feature_image"):
parts.append(sx_call("market-landing-image", src=post["feature_image"]))
if post.get("html"):
parts.append(sx_call("market-landing-html", html=post["html"]))
inner = "(<> " + " ".join(parts) + ")" if parts else "(<>)"
return sx_call("market-landing-content", inner=SxExpr(inner))
"""Build market landing page content — delegates to .sx defcomp."""
return sx_call("market-landing-from-data",
excerpt=post.get("custom_excerpt") or None,
feature_image=post.get("feature_image") or None,
html=post.get("html") or None)

View File

@@ -1,4 +1,4 @@
"""Filter panel functions (mobile + desktop)."""
"""Filter panel data extraction (mobile + desktop)."""
from __future__ import annotations
from shared.sx.parser import SxExpr
@@ -77,365 +77,308 @@ _DESKTOP_SENTINEL_HS = (
# ---------------------------------------------------------------------------
# Browse filter panels (mobile + desktop)
# Filter data extraction helpers
# ---------------------------------------------------------------------------
async def _desktop_filter_sx(ctx: dict) -> str:
"""Build the desktop aside filter panel as sx."""
category_label = ctx.get("category_label", "")
sort_options = ctx.get("sort_options", [])
sort = ctx.get("sort", "")
labels = ctx.get("labels", [])
selected_labels = ctx.get("selected_labels", [])
stickers = ctx.get("stickers", [])
selected_stickers = ctx.get("selected_stickers", [])
brands = ctx.get("brands", [])
selected_brands = ctx.get("selected_brands", [])
liked = ctx.get("liked", False)
liked_count = ctx.get("liked_count", 0)
subs_local = ctx.get("subs_local", [])
top_local_href = ctx.get("top_local_href", "")
sub_slug = ctx.get("sub_slug", "")
# Search
search_sx = await search_desktop_sx(ctx)
# Category summary + sort + like + labels + stickers
cat_parts = [sx_call("market-filter-category-label", label=category_label)]
if sort_options:
cat_parts.append(_sort_stickers_sx(sort_options, sort, ctx))
like_label_parts = [_like_filter_sx(liked, liked_count, ctx)]
if labels:
like_label_parts.append(_labels_filter_sx(labels, selected_labels, ctx, prefix="nav-labels"))
like_labels_sx = "(<> " + " ".join(like_label_parts) + ")"
cat_parts.append(sx_call("market-filter-like-labels-nav", inner=SxExpr(like_labels_sx)))
if stickers:
cat_parts.append(_stickers_filter_sx(stickers, selected_stickers, ctx))
if subs_local and top_local_href:
cat_parts.append(_subcategory_selector_sx(subs_local, top_local_href, sub_slug, ctx))
cat_inner_sx = "(<> " + " ".join(cat_parts) + ")"
cat_summary = sx_call("market-desktop-category-summary", inner=SxExpr(cat_inner_sx))
# Brand filter
brand_inner = ""
if brands:
brand_inner = _brand_filter_sx(brands, selected_brands, ctx)
brand_summary = sx_call("market-desktop-brand-summary",
inner=SxExpr(brand_inner) if brand_inner else None)
return "(<> " + " ".join([search_sx, cat_summary, brand_summary]) + ")"
async def _mobile_filter_summary_sx(ctx: dict) -> str:
"""Build mobile filter summary as sx."""
asset_url_fn = ctx.get("asset_url")
sort = ctx.get("sort", "")
sort_options = ctx.get("sort_options", [])
liked = ctx.get("liked", False)
liked_count = ctx.get("liked_count", 0)
selected_labels = ctx.get("selected_labels", [])
selected_stickers = ctx.get("selected_stickers", [])
selected_brands = ctx.get("selected_brands", [])
labels = ctx.get("labels", [])
stickers = ctx.get("stickers", [])
brands = ctx.get("brands", [])
# Search bar
search_bar = await search_mobile_sx(ctx)
# Summary chips showing active filters
chip_parts: list[str] = []
if sort and sort_options:
for k, l, i in sort_options:
if k == sort and callable(asset_url_fn):
chip_parts.append(sx_call("market-mobile-chip-sort", src=asset_url_fn(i), label=l))
if liked:
liked_parts = [sx_call("market-mobile-chip-liked-icon")]
if liked_count is not None:
cls = "text-[10px] text-stone-500" if liked_count != 0 else "text-md text-red-500 font-bold"
liked_parts.append(sx_call("market-mobile-chip-count", cls=cls, count=str(liked_count)))
liked_inner = "(<> " + " ".join(liked_parts) + ")"
chip_parts.append(sx_call("market-mobile-chip-liked", inner=SxExpr(liked_inner)))
# Selected labels
if selected_labels:
label_item_parts = []
for sl in selected_labels:
for lb in labels:
if lb.get("name") == sl and callable(asset_url_fn):
li_parts = [sx_call(
"market-mobile-chip-image",
src=asset_url_fn("nav-labels/" + sl + ".svg"), name=sl,
)]
if lb.get("count") is not None:
cls = "text-[10px] text-stone-500" if lb["count"] != 0 else "text-md text-red-500 font-bold"
li_parts.append(sx_call("market-mobile-chip-count", cls=cls, count=str(lb["count"])))
li_inner = "(<> " + " ".join(li_parts) + ")"
label_item_parts.append(sx_call("market-mobile-chip-item", inner=SxExpr(li_inner)))
if label_item_parts:
label_items = "(<> " + " ".join(label_item_parts) + ")"
chip_parts.append(sx_call("market-mobile-chip-list", items=SxExpr(label_items)))
# Selected stickers
if selected_stickers:
sticker_item_parts = []
for ss in selected_stickers:
for st in stickers:
if st.get("name") == ss and callable(asset_url_fn):
si_parts = [sx_call(
"market-mobile-chip-image",
src=asset_url_fn("stickers/" + ss + ".svg"), name=ss,
)]
if st.get("count") is not None:
cls = "text-[10px] text-stone-500" if st["count"] != 0 else "text-md text-red-500 font-bold"
si_parts.append(sx_call("market-mobile-chip-count", cls=cls, count=str(st["count"])))
si_inner = "(<> " + " ".join(si_parts) + ")"
sticker_item_parts.append(sx_call("market-mobile-chip-item", inner=SxExpr(si_inner)))
if sticker_item_parts:
sticker_items = "(<> " + " ".join(sticker_item_parts) + ")"
chip_parts.append(sx_call("market-mobile-chip-list", items=SxExpr(sticker_items)))
# Selected brands
if selected_brands:
brand_item_parts = []
for b in selected_brands:
count = 0
for br in brands:
if br.get("name") == b:
count = br.get("count", 0)
if count:
brand_item_parts.append(sx_call("market-mobile-chip-brand", name=b, count=str(count)))
else:
brand_item_parts.append(sx_call("market-mobile-chip-brand-zero", name=b))
brand_items = "(<> " + " ".join(brand_item_parts) + ")"
chip_parts.append(sx_call("market-mobile-chip-brand-list", items=SxExpr(brand_items)))
chips_sx = "(<> " + " ".join(chip_parts) + ")" if chip_parts else '(<>)'
chips_row = sx_call("market-mobile-chips-row", inner=SxExpr(chips_sx))
# Full mobile filter details
def _filter_common(ctx: dict) -> tuple:
"""Extract common filter params from context."""
from shared.utils import route_prefix
prefix = route_prefix()
mobile_filter = _mobile_filter_content_sx(ctx, prefix)
return sx_call(
"market-mobile-filter-summary",
search_bar=SxExpr(search_bar),
chips=SxExpr(chips_row),
filter=SxExpr(mobile_filter),
)
def _mobile_filter_content_sx(ctx: dict, prefix: str) -> str:
"""Build the expanded mobile filter panel contents as sx."""
selected_labels = ctx.get("selected_labels", [])
selected_stickers = ctx.get("selected_stickers", [])
selected_brands = ctx.get("selected_brands", [])
current_local_href = ctx.get("current_local_href", "/")
hx_select = ctx.get("hx_select_search", "#main-panel")
sort_options = ctx.get("sort_options", [])
sort = ctx.get("sort", "")
liked = ctx.get("liked", False)
liked_count = ctx.get("liked_count", 0)
labels = ctx.get("labels", [])
stickers = ctx.get("stickers", [])
brands = ctx.get("brands", [])
search = ctx.get("search", "")
qs_fn = ctx.get("qs_filter")
parts: list[str] = []
# Sort options
if sort_options:
parts.append(_sort_stickers_sx(sort_options, sort, ctx, mobile=True))
# Clear filters button
has_filters = search or selected_labels or selected_stickers or selected_brands
if has_filters and callable(qs_fn):
clear_url = prefix + current_local_href + qs_fn({"clear_filters": True})
parts.append(sx_call("market-mobile-clear-filters", href=clear_url, hx_select=hx_select))
# Like + labels row
like_label_parts = [_like_filter_sx(liked, liked_count, ctx, mobile=True)]
if labels:
like_label_parts.append(_labels_filter_sx(labels, selected_labels, ctx, prefix="nav-labels", mobile=True))
like_labels_sx = "(<> " + " ".join(like_label_parts) + ")"
parts.append(sx_call("market-mobile-like-labels-row", inner=SxExpr(like_labels_sx)))
# Stickers
if stickers:
parts.append(_stickers_filter_sx(stickers, selected_stickers, ctx, mobile=True))
# Brands
if brands:
parts.append(_brand_filter_sx(brands, selected_brands, ctx, mobile=True))
return "(<> " + " ".join(parts) + ")" if parts else "(<>)"
def _sort_stickers_sx(sort_options: list, current_sort: str, ctx: dict, mobile: bool = False) -> str:
"""Build sort option stickers as sx."""
asset_url_fn = ctx.get("asset_url")
current_local_href = ctx.get("current_local_href", "/")
hx_select = ctx.get("hx_select_search", "#main-panel")
qs_fn = ctx.get("qs_filter")
from shared.utils import route_prefix
prefix = route_prefix()
asset_url_fn = ctx.get("asset_url")
return prefix, current_local_href, hx_select, qs_fn, asset_url_fn
item_parts: list[str] = []
def _sort_data(sort_options: list, current_sort: str, ctx: dict) -> list:
"""Extract sort option data for .sx composition."""
prefix, current_local_href, hx_select, qs_fn, asset_url_fn = _filter_common(ctx)
items = []
for k, label, icon in sort_options:
if callable(qs_fn):
href = prefix + current_local_href + qs_fn({"sort": k})
else:
href = "#"
active = (k == current_sort)
href = prefix + current_local_href + qs_fn({"sort": k}) if callable(qs_fn) else "#"
active = k == current_sort
ring = " ring-2 ring-emerald-500 rounded" if active else ""
src = asset_url_fn(icon) if callable(asset_url_fn) else icon
item_parts.append(sx_call(
"market-filter-sort-item",
href=href, hx_select=hx_select, ring_cls=ring, src=src, label=label,
))
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)"
return sx_call("market-filter-sort-row", items=SxExpr(items_sx))
items.append({"href": href, "hx-select": hx_select,
"ring-cls": ring, "src": src, "label": label})
return items
def _like_filter_sx(liked: bool, liked_count: int, ctx: dict, mobile: bool = False) -> str:
"""Build the like filter toggle as sx."""
current_local_href = ctx.get("current_local_href", "/")
hx_select = ctx.get("hx_select_search", "#main-panel")
qs_fn = ctx.get("qs_filter")
from shared.utils import route_prefix
prefix = route_prefix()
if callable(qs_fn):
href = prefix + current_local_href + qs_fn({"liked": not liked})
else:
href = "#"
icon_cls = "fa-solid fa-heart text-red-500" if liked else "fa-regular fa-heart text-stone-400"
size = "text-[40px]" if mobile else "text-2xl"
return sx_call(
"market-filter-like",
href=href, hx_select=hx_select, icon_cls=icon_cls, size_cls=size,
)
def _like_data(liked: bool, liked_count: int, ctx: dict) -> dict:
"""Extract like filter data for .sx composition."""
prefix, current_local_href, hx_select, qs_fn, _ = _filter_common(ctx)
href = prefix + current_local_href + qs_fn({"liked": not liked}) if callable(qs_fn) else "#"
return {"href": href, "hx-select": hx_select, "liked": liked}
def _labels_filter_sx(labels: list, selected: list, ctx: dict, *,
prefix: str = "nav-labels", mobile: bool = False) -> str:
"""Build label filter buttons as sx."""
asset_url_fn = ctx.get("asset_url")
current_local_href = ctx.get("current_local_href", "/")
hx_select = ctx.get("hx_select_search", "#main-panel")
qs_fn = ctx.get("qs_filter")
from shared.utils import route_prefix
rp = route_prefix()
item_parts: list[str] = []
def _label_data(labels: list, selected: list, ctx: dict, *, img_prefix: str = "nav-labels") -> list:
"""Extract label filter data for .sx composition."""
prefix, current_local_href, hx_select, qs_fn, asset_url_fn = _filter_common(ctx)
items = []
for lb in labels:
name = lb.get("name", "")
is_sel = name in selected
if callable(qs_fn):
new_sel = [s for s in selected if s != name] if is_sel else selected + [name]
href = rp + current_local_href + qs_fn({"labels": new_sel})
href = prefix + current_local_href + qs_fn({"labels": new_sel})
else:
href = "#"
ring = " ring-2 ring-emerald-500 rounded" if is_sel else ""
src = asset_url_fn(f"{prefix}/{name}.svg") if callable(asset_url_fn) else ""
item_parts.append(sx_call(
"market-filter-label-item",
href=href, hx_select=hx_select, ring_cls=ring, src=src, name=name,
))
return "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)"
src = asset_url_fn(f"{img_prefix}/{name}.svg") if callable(asset_url_fn) else ""
items.append({"href": href, "hx-select": hx_select,
"ring-cls": ring, "src": src, "name": name})
return items
def _stickers_filter_sx(stickers: list, selected: list, ctx: dict, mobile: bool = False) -> str:
"""Build sticker filter grid as sx."""
asset_url_fn = ctx.get("asset_url")
current_local_href = ctx.get("current_local_href", "/")
hx_select = ctx.get("hx_select_search", "#main-panel")
qs_fn = ctx.get("qs_filter")
from shared.utils import route_prefix
rp = route_prefix()
item_parts: list[str] = []
def _sticker_data(stickers: list, selected: list, ctx: dict) -> list:
"""Extract sticker filter data for .sx composition."""
prefix, current_local_href, hx_select, qs_fn, asset_url_fn = _filter_common(ctx)
items = []
for st in stickers:
name = st.get("name", "")
count = st.get("count", 0)
is_sel = name in selected
if callable(qs_fn):
new_sel = [s for s in selected if s != name] if is_sel else selected + [name]
href = rp + current_local_href + qs_fn({"stickers": new_sel})
href = prefix + current_local_href + qs_fn({"stickers": new_sel})
else:
href = "#"
ring = " ring-2 ring-emerald-500 rounded" if is_sel else ""
src = asset_url_fn(f"stickers/{name}.svg") if callable(asset_url_fn) else ""
cls = "text-[10px] text-stone-500" if count != 0 else "text-md text-red-500 font-bold"
item_parts.append(sx_call(
"market-filter-sticker-item",
href=href, hx_select=hx_select, ring_cls=ring,
src=src, name=name, count_cls=cls, count=str(count),
))
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)"
return sx_call("market-filter-stickers-row", items=SxExpr(items_sx))
items.append({"href": href, "hx-select": hx_select, "ring-cls": ring,
"src": src, "name": name, "count-cls": cls, "count": str(count)})
return items
def _brand_filter_sx(brands: list, selected: list, ctx: dict, mobile: bool = False) -> str:
"""Build brand filter checkboxes as sx."""
current_local_href = ctx.get("current_local_href", "/")
hx_select = ctx.get("hx_select_search", "#main-panel")
qs_fn = ctx.get("qs_filter")
from shared.utils import route_prefix
rp = route_prefix()
item_parts: list[str] = []
def _brand_data(brands: list, selected: list, ctx: dict) -> list:
"""Extract brand filter data for .sx composition."""
prefix, current_local_href, hx_select, qs_fn, _ = _filter_common(ctx)
items = []
for br in brands:
name = br.get("name", "")
count = br.get("count", 0)
is_sel = name in selected
if callable(qs_fn):
new_sel = [s for s in selected if s != name] if is_sel else selected + [name]
href = rp + current_local_href + qs_fn({"brands": new_sel})
href = prefix + current_local_href + qs_fn({"brands": new_sel})
else:
href = "#"
bg = " bg-yellow-200" if is_sel else ""
cls = "text-md" if count else "text-md text-red-500"
item_parts.append(sx_call(
"market-filter-brand-item",
href=href, hx_select=hx_select, bg_cls=bg,
name_cls=cls, name=name, count=str(count),
))
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)"
return sx_call("market-filter-brands-panel", items=SxExpr(items_sx))
items.append({"href": href, "hx-select": hx_select, "bg-cls": bg,
"name-cls": cls, "name": name, "count": str(count)})
return items
def _subcategory_selector_sx(subs: list, top_href: str, current_sub: str, ctx: dict) -> str:
"""Build subcategory vertical nav as sx."""
hx_select = ctx.get("hx_select_search", "#main-panel")
def _subcategory_data(subs: list, top_href: str, current_sub: str, ctx: dict) -> dict:
"""Extract subcategory filter data for .sx composition."""
from shared.utils import route_prefix
rp = route_prefix()
hx_select = ctx.get("hx_select_search", "#main-panel")
all_cls = " bg-stone-200 font-medium" if not current_sub else ""
all_full_href = rp + top_href
item_parts = [sx_call(
"market-filter-subcategory-item",
href=all_full_href, hx_select=hx_select, active_cls=all_cls, name="All",
)]
items = []
for sub in subs:
slug = sub.get("slug", "")
name = sub.get("name", "")
href = sub.get("href", "")
active = (slug == current_sub)
active = slug == current_sub
active_cls = " bg-stone-200 font-medium" if active else ""
full_href = rp + href
item_parts.append(sx_call(
"market-filter-subcategory-item",
href=full_href, hx_select=hx_select, active_cls=active_cls, name=name,
))
items_sx = "(<> " + " ".join(item_parts) + ")"
return sx_call("market-filter-subcategory-panel", items=SxExpr(items_sx))
items.append({"href": rp + sub.get("href", ""), "hx-select": hx_select,
"active-cls": active_cls, "name": sub.get("name", "")})
return {"items": items, "all-href": rp + top_href, "current-sub": current_sub}
# ---------------------------------------------------------------------------
# Desktop filter panel
# ---------------------------------------------------------------------------
async def _desktop_filter_sx(ctx: dict) -> str:
"""Build the desktop aside filter panel — delegates to .sx defcomp."""
hx_select = ctx.get("hx_select_search", "#main-panel")
# Search (still uses render_to_sx for shared component)
search_sx = await search_desktop_sx(ctx)
# Sort data
sort_options = ctx.get("sort_options", [])
sort = ctx.get("sort", "")
sd = _sort_data(sort_options, sort, ctx) if sort_options else None
# Like data
ld = _like_data(ctx.get("liked", False), ctx.get("liked_count", 0), ctx)
# Labels data
labels = ctx.get("labels", [])
selected_labels = ctx.get("selected_labels", [])
lab_d = _label_data(labels, selected_labels, ctx) if labels else None
# Stickers data
stickers = ctx.get("stickers", [])
selected_stickers = ctx.get("selected_stickers", [])
st_d = _sticker_data(stickers, selected_stickers, ctx) if stickers else None
# Brands data
brands = ctx.get("brands", [])
selected_brands = ctx.get("selected_brands", [])
br_d = _brand_data(brands, selected_brands, ctx) if brands else None
# Subcategory data
subs_local = ctx.get("subs_local", [])
top_local_href = ctx.get("top_local_href", "")
sub_slug = ctx.get("sub_slug", "")
sub_d = (_subcategory_data(subs_local, top_local_href, sub_slug, ctx)
if subs_local and top_local_href else None)
return sx_call("market-desktop-filter-from-data",
search_sx=SxExpr(search_sx),
category_label=ctx.get("category_label", ""),
sort_data=sd,
like_data=ld,
label_data=lab_d,
sticker_data=st_d,
brand_data=br_d,
sub_data=sub_d,
hx_select=hx_select)
# ---------------------------------------------------------------------------
# Mobile filter summary
# ---------------------------------------------------------------------------
def _mobile_chips_data(ctx: dict) -> dict:
"""Extract mobile filter chip data for .sx composition."""
asset_url_fn = ctx.get("asset_url")
sort = ctx.get("sort", "")
sort_options = ctx.get("sort_options", [])
liked = ctx.get("liked", False)
liked_count = ctx.get("liked_count", 0)
selected_labels = ctx.get("selected_labels", [])
selected_stickers = ctx.get("selected_stickers", [])
selected_brands = ctx.get("selected_brands", [])
labels = ctx.get("labels", [])
stickers = ctx.get("stickers", [])
brands = ctx.get("brands", [])
# Sort chip
sort_chip = None
if sort and sort_options:
for k, l, i in sort_options:
if k == sort and callable(asset_url_fn):
sort_chip = {"src": asset_url_fn(i), "label": l}
# Liked chip
liked_chip = None
if liked:
count_cls = ("text-[10px] text-stone-500" if liked_count != 0
else "text-md text-red-500 font-bold")
liked_chip = {"count": str(liked_count) if liked_count is not None else None,
"count-cls": count_cls}
# Label chips
label_chips = []
if selected_labels:
for sl in selected_labels:
for lb in labels:
if lb.get("name") == sl and callable(asset_url_fn):
chip: dict = {"src": asset_url_fn("nav-labels/" + sl + ".svg"), "name": sl}
if lb.get("count") is not None:
cls = ("text-[10px] text-stone-500" if lb["count"] != 0
else "text-md text-red-500 font-bold")
chip["count"] = str(lb["count"])
chip["count-cls"] = cls
label_chips.append(chip)
# Sticker chips
sticker_chips = []
if selected_stickers:
for ss in selected_stickers:
for st in stickers:
if st.get("name") == ss and callable(asset_url_fn):
chip = {"src": asset_url_fn("stickers/" + ss + ".svg"), "name": ss}
if st.get("count") is not None:
cls = ("text-[10px] text-stone-500" if st["count"] != 0
else "text-md text-red-500 font-bold")
chip["count"] = str(st["count"])
chip["count-cls"] = cls
sticker_chips.append(chip)
# Brand chips
brand_chips = []
if selected_brands:
for b in selected_brands:
count = 0
for br in brands:
if br.get("name") == b:
count = br.get("count", 0)
brand_chips.append({"name": b, "count": str(count), "has-count": bool(count)})
return {
"sort-chip": sort_chip,
"liked-chip": liked_chip,
"label-chips": label_chips or None,
"sticker-chips": sticker_chips or None,
"brand-chips": brand_chips or None,
}
def _mobile_filter_content_data(ctx: dict) -> dict:
"""Extract mobile filter expanded panel data."""
from shared.utils import route_prefix
prefix = route_prefix()
hx_select = ctx.get("hx_select_search", "#main-panel")
sort_options = ctx.get("sort_options", [])
sort = ctx.get("sort", "")
sd = _sort_data(sort_options, sort, ctx) if sort_options else None
ld = _like_data(ctx.get("liked", False), ctx.get("liked_count", 0), ctx)
labels = ctx.get("labels", [])
selected_labels = ctx.get("selected_labels", [])
lab_d = _label_data(labels, selected_labels, ctx) if labels else None
stickers = ctx.get("stickers", [])
selected_stickers = ctx.get("selected_stickers", [])
st_d = _sticker_data(stickers, selected_stickers, ctx) if stickers else None
brands = ctx.get("brands", [])
selected_brands = ctx.get("selected_brands", [])
br_d = _brand_data(brands, selected_brands, ctx) if brands else None
# Clear filters URL
clear_href = None
search = ctx.get("search", "")
has_filters = search or selected_labels or selected_stickers or selected_brands
qs_fn = ctx.get("qs_filter")
if has_filters and callable(qs_fn):
current_local_href = ctx.get("current_local_href", "/")
clear_href = prefix + current_local_href + qs_fn({"clear_filters": True})
return {
"sort-data": sd,
"like-data": ld,
"label-data": lab_d,
"sticker-data": st_d,
"brand-data": br_d,
"clear-href": clear_href,
"hx-select": hx_select,
}
async def _mobile_filter_summary_sx(ctx: dict) -> str:
"""Build mobile filter summary — delegates to .sx defcomps."""
# Search bar (still uses render_to_sx for shared component)
search_bar = await search_mobile_sx(ctx)
# Chips data
chips_data = _mobile_chips_data(ctx)
chips = sx_call("market-mobile-chips-from-data", **chips_data)
# Expanded filter content data
filter_data = _mobile_filter_content_data(ctx)
filter_content = sx_call("market-mobile-filter-content-from-data", **filter_data)
return sx_call("market-mobile-filter-summary",
search_bar=SxExpr(search_bar),
chips=SxExpr(chips),
filter=SxExpr(filter_content))

View File

@@ -1,98 +1,33 @@
"""Page helpers for market defpage system."""
"""Market page helpers — data-only.
All helpers return data values (dicts, lists) — no sx_call().
Markup composition lives entirely in .sx defpage and .sx defcomp files.
"""
from __future__ import annotations
from .cards import (
_market_cards_sx, _markets_grid, _no_markets_sx,
_market_landing_content_sx,
)
from .cards import _market_card_data
# ---------------------------------------------------------------------------
# Page admin panel (used by _h_page_admin_content)
# Registration
# ---------------------------------------------------------------------------
async def _markets_admin_panel_sx(ctx: dict) -> str:
"""Render the markets list + create form panel."""
from quart import g, url_for
from shared.services.registry import services
from shared.sx.helpers import sx_call
from shared.sx.parser import SxExpr
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("page_admin.create_market") if callable(has_access) else is_admin
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
post = ctx.get("post") or {}
post_id = post.get("id")
markets = await services.market.marketplaces_for_container(g.s, "page", post_id) if post_id else []
form_html = ""
if can_create:
create_url = url_for("page_admin.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. Suma, Craft Fair",
btn_label="Add market")
list_html = _markets_admin_list_sx(ctx, markets)
return sx_call("crud-panel",
form=SxExpr(form_html), list=SxExpr(list_html),
list_id="markets-list")
def _markets_admin_list_sx(ctx: dict, markets: list) -> str:
"""Render the markets list items."""
from quart import url_for
from shared.utils import route_prefix
from shared.sx.helpers import sx_call
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
prefix = route_prefix()
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:
m_slug = getattr(m, "slug", "") or (m.get("slug", "") if isinstance(m, dict) else "")
m_name = getattr(m, "name", "") or (m.get("name", "") if isinstance(m, dict) else "")
post_slug = (ctx.get("post") or {}).get("slug", "")
href = prefix + f"/{post_slug}/{m_slug}/"
del_url = url_for("page_admin.delete_market", market_slug=m_slug)
csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}'
parts.append(sx_call("crud-item",
href=href, name=m_name, slug=m_slug,
del_url=del_url, csrf_hdr=csrf_hdr,
list_id="markets-list",
confirm_title="Delete market?",
confirm_text="Products will be hidden (soft delete)"))
return "".join(parts)
# ===========================================================================
# Page helpers
# ===========================================================================
def _register_market_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("market", {
"all-markets-content": _h_all_markets_content,
"page-markets-content": _h_page_markets_content,
"page-admin-content": _h_page_admin_content,
"market-home-content": _h_market_home_content,
"market-admin-content": _h_market_admin_content,
"all-markets-data": _h_all_markets_data,
"page-markets-data": _h_page_markets_data,
"page-admin-data": _h_page_admin_data,
"market-home-data": _h_market_home_data,
})
async def _h_all_markets_content(**kw):
# ---------------------------------------------------------------------------
# All markets (global view)
# ---------------------------------------------------------------------------
async def _h_all_markets_data(**kw) -> dict:
from quart import g, url_for, request
from shared.utils import route_prefix
from shared.services.registry import services
@@ -116,16 +51,26 @@ async def _h_all_markets_content(**kw):
page_info[p.id] = {"title": p.title, "slug": p.slug}
if not markets:
return _no_markets_sx()
return {"no-markets": True}
prefix = route_prefix()
next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1)
market_data = [_market_card_data(m, page_info) for m in markets]
cards = _market_cards_sx(markets, page_info, page, has_more, next_url)
return _markets_grid(cards)
return {
"no-markets": False,
"market-data": market_data,
"market-page": page,
"has-more": has_more,
"next-url": next_url,
}
async def _h_page_markets_content(slug=None, **kw):
# ---------------------------------------------------------------------------
# Page markets (markets for a single page)
# ---------------------------------------------------------------------------
async def _h_page_markets_data(slug=None, **kw) -> dict:
from quart import g, url_for, request
from shared.utils import route_prefix
from shared.services.registry import services
@@ -138,31 +83,76 @@ async def _h_page_markets_content(slug=None, **kw):
post_slug = post.get("slug", "")
if not markets:
return _no_markets_sx("No markets for this page")
return {"no-markets": True}
prefix = route_prefix()
next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1)
market_data = [_market_card_data(m, {}, show_page_badge=False,
post_slug=post_slug) for m in markets]
cards = _market_cards_sx(markets, {}, page, has_more, next_url,
show_page_badge=False, post_slug=post_slug)
return _markets_grid(cards)
return {
"no-markets": False,
"market-data": market_data,
"market-page": page,
"has-more": has_more,
"next-url": next_url,
}
async def _h_page_admin_content(slug=None, **kw):
# ---------------------------------------------------------------------------
# Page admin (CRUD panel for markets under a page)
# ---------------------------------------------------------------------------
async def _h_page_admin_data(slug=None, **kw) -> dict:
from quart import g, url_for
from shared.sx.page import get_template_context
from shared.sx.helpers import sx_call
from shared.sx.parser import SxExpr
from shared.services.registry import services
from shared.browser.app.csrf import generate_csrf_token
from shared.utils import route_prefix
ctx = await get_template_context()
content = await _markets_admin_panel_sx(ctx)
return sx_call("market-admin-content-wrap", inner=SxExpr(content))
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("page_admin.create_market") if callable(has_access) else is_admin
csrf = generate_csrf_token()
post = ctx.get("post") or {}
post_id = post.get("id")
post_slug = post.get("slug", "")
markets_raw = await services.market.marketplaces_for_container(g.s, "page", post_id) if post_id else []
prefix = route_prefix()
markets = []
for m in markets_raw:
m_slug = getattr(m, "slug", "") or (m.get("slug", "") 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}/"
del_url = url_for("page_admin.delete_market", market_slug=m_slug)
csrf_hdr = {"X-CSRFToken": csrf}
markets.append({
"href": href, "name": m_name, "slug": m_slug,
"del-url": del_url, "csrf-hdr": csrf_hdr,
})
return {
"can-create": can_create,
"create-url": url_for("page_admin.create_market") if can_create else None,
"csrf": csrf,
"admin-markets": markets,
}
def _h_market_home_content(page_slug=None, market_slug=None, **kw):
# ---------------------------------------------------------------------------
# Market landing page
# ---------------------------------------------------------------------------
def _h_market_home_data(page_slug=None, market_slug=None, **kw) -> dict:
from quart import g
post_data = getattr(g, "post_data", {})
post = post_data.get("post", {})
return _market_landing_content_sx(post)
def _h_market_admin_content(page_slug=None, market_slug=None, **kw):
return '"market admin"'
return {
"excerpt": post.get("custom_excerpt") or None,
"feature-image": post.get("feature_image") or None,
"html": post.get("html") or None,
}

View File

@@ -1,333 +1,202 @@
"""Layout registration + header builders."""
"""Layout registration + header data builders."""
from __future__ import annotations
from typing import Any
from shared.sx.helpers import sx_call
from shared.sx.parser import SxExpr
from shared.sx.helpers import (
sx_call,
post_header_sx as _post_header_sx,
post_admin_header_sx,
oob_header_sx as _oob_header_sx,
header_child_sx,
)
from .utils import _set_prices, _price_str, _clear_deeper_oob
from .utils import _set_prices, _price_str
# ---------------------------------------------------------------------------
# Header helpers
# Header data extraction — pure data, no component references
# ---------------------------------------------------------------------------
def _market_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the market-level header row as sx call string."""
from quart import url_for
market_title = ctx.get("market_title", "")
top_slug = ctx.get("top_slug", "")
sub_slug = ctx.get("sub_slug", "")
hx_select_search = ctx.get("hx_select_search", "#main-panel")
label_sx = sx_call(
"market-shop-label",
title=market_title, top_slug=top_slug or "",
sub_div=sub_slug or None,
)
link_href = url_for("defpage_market_home")
# Build desktop nav from categories
categories = ctx.get("categories", {})
qs = ctx.get("qs", "")
nav_sx = _desktop_category_nav_sx(ctx, categories, qs, hx_select_search)
return sx_call(
"menu-row-sx",
id="market-row", level=2,
link_href=link_href, link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx) if nav_sx else None,
child_id="market-header-child", oob=oob,
)
def _desktop_category_nav_sx(ctx: dict, categories: dict, qs: str,
hx_select: str) -> str:
"""Build desktop category navigation links as sx."""
def _market_header_data(ctx: dict) -> dict:
"""Extract market header data for .sx composition."""
from quart import url_for
from shared.utils import route_prefix
prefix = route_prefix()
category_label = ctx.get("category_label", "")
hx_select = ctx.get("hx_select_search", "#main-panel")
select_colours = ctx.get("select_colours", "")
rights = ctx.get("rights", {})
qs = ctx.get("qs", "")
categories = ctx.get("categories", {})
all_href = prefix + url_for("market.browse.browse_all") + qs
all_active = (category_label == "All Products")
link_parts = [sx_call(
"market-category-link",
href=all_href, hx_select=hx_select, active=all_active,
select_colours=select_colours, label="All",
)]
cat_data = []
for cat, data in categories.items():
cat_href = prefix + url_for("market.browse.browse_top", top_slug=data["slug"]) + qs
cat_active = (cat == category_label)
link_parts.append(sx_call(
"market-category-link",
href=cat_href, hx_select=hx_select, active=cat_active,
select_colours=select_colours, label=cat,
))
cat_data.append({
"href": cat_href,
"active": cat == ctx.get("category_label", ""),
"label": cat,
})
links_sx = "(<> " + " ".join(link_parts) + ")"
admin_sx = ""
admin_href = ""
if rights and rights.get("admin"):
admin_href = prefix + url_for("defpage_market_admin")
admin_sx = sx_call("market-admin-link", href=admin_href, hx_select=hx_select)
return sx_call("market-desktop-category-nav",
links=SxExpr(links_sx),
admin=SxExpr(admin_sx) if admin_sx else None)
return {
"market-title": ctx.get("market_title", ""),
"top-slug": ctx.get("top_slug", ""),
"sub-slug": ctx.get("sub_slug", ""),
"link-href": url_for("defpage_market_home"),
"categories": cat_data,
"hx-select": hx_select,
"select-colours": select_colours,
"all-href": all_href,
"all-active": ctx.get("category_label", "") == "All Products",
"admin-href": admin_href,
}
def _product_header_sx(ctx: dict, d: dict, *, oob: bool = False) -> str:
"""Build the product-level header row as sx call string."""
def _market_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build market header as sx — delegates to .sx defcomp."""
data = _market_header_data(ctx)
return sx_call("market-header-from-data", oob=oob, **data)
def _product_header_data(ctx: dict, d: dict) -> dict:
"""Extract product header data for .sx composition."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
slug = d.get("slug", "")
title = d.get("title", "")
hx_select_search = ctx.get("hx_select_search", "#main-panel")
link_href = url_for("market.browse.product.product_detail", product_slug=slug)
label_sx = sx_call("market-product-label", title=title)
# Prices in nav area
pr = _set_prices(d)
hx_select = ctx.get("hx_select_search", "#main-panel")
cart = ctx.get("cart", [])
prices_nav = _prices_header_sx(d, pr, cart, slug, ctx)
rights = ctx.get("rights", {})
nav_parts = [prices_nav]
if rights and rights.get("admin"):
admin_href = url_for("market.browse.product.admin", product_slug=slug)
nav_parts.append(sx_call("market-admin-link", href=admin_href, hx_select=hx_select_search))
nav_sx = "(<> " + " ".join(nav_parts) + ")"
return sx_call(
"menu-row-sx",
id="product-row", level=3,
link_href=link_href, link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx), child_id="product-header-child", oob=oob,
)
def _prices_header_sx(d: dict, pr: dict, cart: list, slug: str, ctx: dict) -> str:
"""Build prices + add-to-cart for product header row as sx."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
cart_action = url_for("market.browse.product.cart", product_slug=slug)
cart_url_fn = ctx.get("cart_url")
# Add-to-cart button
quantity = sum(ci.quantity for ci in cart if ci.product.slug == slug) if cart else 0
add_sx = _cart_add_sx(slug, quantity, cart_action, csrf, cart_url_fn)
parts = [add_sx]
sp_val, rp_val = pr.get("sp_val"), pr.get("rp_val")
if sp_val:
parts.append(sx_call("market-header-price-special-label"))
parts.append(sx_call("market-header-price-special",
price=_price_str(sp_val, pr["sp_raw"], pr["sp_cur"])))
if rp_val:
parts.append(sx_call("market-header-price-strike",
price=_price_str(rp_val, pr["rp_raw"], pr["rp_cur"])))
elif rp_val:
parts.append(sx_call("market-header-price-regular-label"))
parts.append(sx_call("market-header-price-regular",
price=_price_str(rp_val, pr["rp_raw"], pr["rp_cur"])))
# Price data
pr = _set_prices(d)
sp_str = _price_str(pr["sp_val"], pr["sp_raw"], pr["sp_cur"]) if pr["sp_val"] else ""
rp_str = _price_str(pr["rp_val"], pr["rp_raw"], pr["rp_cur"]) if pr["rp_val"] else ""
# RRP
rrp_str = ""
rrp_raw = d.get("rrp_raw")
rrp_val = d.get("rrp")
case_size = d.get("case_size_count") or 1
if rrp_raw and rrp_val:
rrp_str = f"{rrp_raw[0]}{rrp_val * case_size:.2f}"
parts.append(sx_call("market-header-rrp", rrp=rrp_str))
inner_sx = "(<> " + " ".join(parts) + ")"
return sx_call("market-prices-row", inner=SxExpr(inner_sx))
def _cart_add_sx(slug: str, quantity: int, action: str, csrf: str,
cart_url_fn: Any = None) -> str:
"""Build add-to-cart button or quantity controls as sx."""
if not quantity:
return sx_call(
"market-cart-add-empty",
cart_id=f"cart-{slug}", action=action, csrf=csrf,
)
# Cart state
csrf = generate_csrf_token()
cart_action = url_for("market.browse.product.cart", product_slug=slug)
quantity = sum(ci.quantity for ci in cart if ci.product.slug == slug) if cart else 0
cart_url_fn = ctx.get("cart_url")
cart_href = cart_url_fn("/") if callable(cart_url_fn) else "/"
return sx_call(
"market-cart-add-quantity",
cart_id=f"cart-{slug}", action=action, csrf=csrf,
minus_val=str(quantity - 1), plus_val=str(quantity + 1),
quantity=str(quantity), cart_href=cart_href,
)
admin_href = ""
if rights and rights.get("admin"):
admin_href = url_for("market.browse.product.admin", product_slug=slug)
return {
"title": d.get("title", ""),
"link-href": url_for("market.browse.product.product_detail", product_slug=slug),
"hx-select": hx_select,
"price-data": {
"cart-id": f"cart-{slug}",
"cart-action": cart_action,
"csrf": csrf,
"quantity": quantity,
"cart-href": cart_href,
"sp-val": pr["sp_val"] or "",
"sp-str": sp_str,
"rp-val": pr["rp_val"] or "",
"rp-str": rp_str,
"rrp-str": rrp_str,
},
"admin-href": admin_href,
}
def _product_header_sx(ctx: dict, d: dict, *, oob: bool = False) -> str:
"""Build product header as sx — delegates to .sx defcomp."""
data = _product_header_data(ctx, d)
return sx_call("market-product-header-from-data", oob=oob, **data)
def _product_admin_header_sx(ctx: dict, d: dict, *, oob: bool = False) -> str:
"""Build product admin header as sx — delegates to .sx defcomp."""
from quart import url_for
slug = d.get("slug", "")
link_href = url_for("market.browse.product.admin", product_slug=slug)
return sx_call("market-product-admin-header-from-data",
link_href=link_href, oob=oob)
# ---------------------------------------------------------------------------
# Mobile nav panel
# Mobile nav panel data extraction
# ---------------------------------------------------------------------------
def _mobile_nav_panel_sx(ctx: dict) -> str:
"""Build mobile nav panel with category accordion as sx."""
def _mobile_nav_data(ctx: dict) -> dict:
"""Extract mobile nav panel data for .sx composition."""
from quart import url_for
from shared.utils import route_prefix
prefix = route_prefix()
categories = ctx.get("categories", {})
qs = ctx.get("qs", "")
category_label = ctx.get("category_label", "")
top_slug = ctx.get("top_slug", "")
sub_slug = ctx.get("sub_slug", "")
hx_select = ctx.get("hx_select_search", "#main-panel")
select_colours = ctx.get("select_colours", "")
all_href = prefix + url_for("market.browse.browse_all") + qs
all_active = (category_label == "All Products")
item_parts = [sx_call(
"market-mobile-all-link",
href=all_href, hx_select=hx_select, active=all_active,
select_colours=select_colours,
)]
cat_data = []
for cat, data in categories.items():
cat_slug = data.get("slug", "")
cat_active = (top_slug == cat_slug.lower() if top_slug else False)
cat_active = top_slug == cat_slug.lower() if top_slug else False
cat_href = prefix + url_for("market.browse.browse_top", top_slug=cat_slug) + qs
bg_cls = " bg-stone-900 text-white hover:bg-stone-900" if cat_active else ""
chevron_sx = sx_call("market-mobile-chevron")
cat_count = data.get("count", 0)
summary_sx = sx_call(
"market-mobile-cat-summary",
bg_cls=bg_cls, href=cat_href, hx_select=hx_select,
select_colours=select_colours, cat_name=cat,
count_label=f"{cat_count} products", count_str=str(cat_count),
chevron=SxExpr(chevron_sx),
)
subs = data.get("subs", [])
subs_sx = ""
sub_data = []
if subs:
sub_link_parts = []
for sub in subs:
sub_href = prefix + url_for("market.browse.browse_sub", top_slug=cat_slug, sub_slug=sub["slug"]) + qs
sub_active = (cat_active and sub_slug == sub.get("slug"))
sub_href = prefix + url_for("market.browse.browse_sub",
top_slug=cat_slug, sub_slug=sub["slug"]) + qs
sub_active = cat_active and sub_slug == sub.get("slug")
sub_label = sub.get("html_label") or sub.get("name", "")
sub_count = sub.get("count", 0)
sub_link_parts.append(sx_call(
"market-mobile-sub-link",
select_colours=select_colours, active=sub_active,
href=sub_href, hx_select=hx_select, label=sub_label,
count_label=f"{sub_count} products", count_str=str(sub_count),
))
sub_links_sx = "(<> " + " ".join(sub_link_parts) + ")"
subs_sx = sx_call("market-mobile-subs-panel", links=SxExpr(sub_links_sx))
else:
view_href = prefix + url_for("market.browse.browse_top", top_slug=cat_slug) + qs
subs_sx = sx_call("market-mobile-view-all", href=view_href, hx_select=hx_select)
sub_data.append({
"href": sub_href,
"active": sub_active,
"label": sub_label,
"count": sub.get("count", 0),
})
item_parts.append(sx_call(
"market-mobile-cat-details",
open=cat_active or None,
summary=SxExpr(summary_sx),
subs=SxExpr(subs_sx),
))
cat_data.append({
"name": cat,
"href": cat_href,
"active": cat_active,
"count": data.get("count", 0),
"subs": sub_data if sub_data else None,
})
items_sx = "(<> " + " ".join(item_parts) + ")"
return sx_call("market-mobile-nav-wrapper", items=SxExpr(items_sx))
return {
"categories": cat_data,
"all-href": all_href,
"all-active": ctx.get("category_label", "") == "All Products",
"hx-select": hx_select,
"select-colours": select_colours,
}
def _mobile_nav_panel_sx(ctx: dict) -> str:
"""Build mobile nav panel as sx — delegates to .sx defcomp."""
data = _mobile_nav_data(ctx)
return sx_call("market-mobile-nav-from-data", **data)
# ---------------------------------------------------------------------------
# Product admin header
# Layout registration — all layouts delegate to .sx defcomps
# ---------------------------------------------------------------------------
def _product_admin_header_sx(ctx: dict, d: dict, *, oob: bool = False) -> str:
"""Build product admin header row as sx."""
from quart import url_for
slug = d.get("slug", "")
link_href = url_for("market.browse.product.admin", product_slug=slug)
return sx_call(
"menu-row-sx",
id="product-admin-row", level=4,
link_href=link_href, link_label="admin!!", icon="fa fa-cog",
child_id="product-admin-header-child", oob=oob,
)
# ---------------------------------------------------------------------------
# Market admin header
# ---------------------------------------------------------------------------
async def _market_admin_header_sx(ctx: dict, *, oob: bool = False, selected: str = "") -> str:
"""Build market admin header row -- delegates to shared helper."""
slug = (ctx.get("post") or {}).get("slug", "")
return await post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
# ===========================================================================
# Layout registration
# ===========================================================================
def _register_market_layouts() -> None:
from shared.sx.layouts import register_custom_layout
register_custom_layout("market", _market_full, _market_oob, _market_mobile)
register_custom_layout("market-admin", _market_admin_full, _market_admin_oob)
async def _market_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env
return await render_to_sx_with_env("market-browse-layout-full", {},
post_header=SxExpr(await _post_header_sx(ctx)),
market_header=SxExpr(_market_header_sx(ctx)))
async def _market_oob(ctx: dict, **kw: Any) -> str:
oob_hdr = await _oob_header_sx("post-header-child", "market-header-child",
_market_header_sx(ctx))
return sx_call("market-browse-layout-oob",
oob_header=SxExpr(oob_hdr),
post_header_oob=SxExpr(await _post_header_sx(ctx, oob=True)),
clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
"market-row", "market-header-child")))
def _market_mobile(ctx: dict, **kw: Any) -> str:
return _mobile_nav_panel_sx(ctx)
async def _market_admin_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env
selected = kw.get("selected", "")
return await render_to_sx_with_env("market-admin-layout-full", {},
post_header=SxExpr(await _post_header_sx(ctx)),
market_header=SxExpr(_market_header_sx(ctx)),
admin_header=SxExpr(await _market_admin_header_sx(ctx, selected=selected)))
async def _market_admin_oob(ctx: dict, **kw: Any) -> str:
selected = kw.get("selected", "")
return sx_call("market-admin-layout-oob",
market_header_oob=SxExpr(_market_header_sx(ctx, oob=True)),
admin_oob_header=SxExpr(await _oob_header_sx("market-header-child", "market-admin-header-child",
await _market_admin_header_sx(ctx, selected=selected))),
clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
"market-row", "market-header-child",
"market-admin-row", "market-admin-header-child")))
from shared.sx.layouts import register_sx_layout
register_sx_layout("market",
"market-browse-layout-full", "market-browse-layout-oob",
"market-browse-layout-mobile")
register_sx_layout("market-admin",
"market-admin-layout-full", "market-admin-layout-oob")

View File

@@ -1,4 +1,5 @@
;; Market app defpage declarations.
;; All helpers return data dicts — markup composition in SX.
;;
;; all-markets-index: / — global view across all pages
;; page-markets-index: /<slug>/ — markets for a single page
@@ -10,28 +11,64 @@
:path "/"
:auth :public
:layout :root
:content (all-markets-content))
:data (all-markets-data)
:content (if no-markets
(~empty-state :icon "fa fa-store" :message "No markets available"
:cls "px-3 py-12 text-center text-stone-400")
(~market-markets-grid
:cards (~market-cards-content
:markets market-data :page market-page
:has-more has-more :next-url next-url))))
(defpage page-markets-index
:path "/<slug>/"
:auth :public
:layout :post
:content (page-markets-content))
:data (page-markets-data)
:content (if no-markets
(~empty-state :message "No markets for this page"
:cls "px-3 py-12 text-center text-stone-400")
(~market-markets-grid
:cards (~market-cards-content
:markets market-data :page market-page
:has-more has-more :next-url next-url))))
(defpage page-admin
:path "/<slug>/admin/"
:auth :admin
:layout (:post-admin :selected "markets")
:content (page-admin-content))
:data (page-admin-data)
:content (~market-admin-content-wrap
:inner (~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. Suma, Craft Fair" :btn-label "Add market"))
:list (if admin-markets
(<> (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)"))
admin-markets))
(~empty-state
:message "No markets yet. Create one above."
:cls "text-gray-500 mt-4")))))
(defpage market-home
:path "/<page_slug>/<market_slug>/"
:auth :public
:layout :market
:content (market-home-content))
:data (market-home-data)
:content (~market-landing-from-data
:excerpt excerpt :feature-image feature-image :html html))
(defpage market-admin
:path "/<page_slug>/<market_slug>/admin/"
:auth :admin
:layout (:market-admin :selected "markets")
:content (market-admin-content))
:content "market admin")

View File

@@ -11,7 +11,7 @@ from shared.sx.helpers import (
full_page_sx, oob_page_sx,
)
from .utils import _set_prices, _price_str, _clear_deeper_oob, _product_detail_sx, _product_meta_sx
from .utils import _clear_deeper_oob, _product_detail_sx, _product_meta_sx
from .cards import _product_cards_sx, _market_cards_sx
from .filters import _desktop_filter_sx, _mobile_filter_summary_sx
from .layouts import (
@@ -36,9 +36,7 @@ async def render_browse_page(ctx: dict) -> str:
content = _product_grid(cards)
from shared.sx.helpers import render_to_sx_with_env
hdr = await render_to_sx_with_env("market-browse-layout-full", {},
post_header=SxExpr(await _post_header_sx(ctx)),
market_header=SxExpr(_market_header_sx(ctx)))
hdr = await render_to_sx_with_env("market-browse-layout-full", {})
menu = _mobile_nav_panel_sx(ctx)
filter_sx = await _mobile_filter_summary_sx(ctx)
aside_sx = await _desktop_filter_sx(ctx)
@@ -52,13 +50,8 @@ async def render_browse_oob(ctx: dict) -> str:
cards = _product_cards_sx(ctx)
content = _product_grid(cards)
oob_hdr = await _oob_header_sx("post-header-child", "market-header-child",
_market_header_sx(ctx))
oobs = sx_call("market-browse-layout-oob",
oob_header=SxExpr(oob_hdr),
post_header_oob=SxExpr(await _post_header_sx(ctx, oob=True)),
clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
"market-row", "market-header-child")))
# Layout handles all OOB headers via auto-fetch macros
oobs = sx_call("market-browse-layout-oob")
menu = _mobile_nav_panel_sx(ctx)
filter_sx = await _mobile_filter_summary_sx(ctx)
aside_sx = await _desktop_filter_sx(ctx)
@@ -83,9 +76,9 @@ async def render_product_page(ctx: dict, d: dict) -> str:
from shared.sx.helpers import render_to_sx_with_env
hdr = await render_to_sx_with_env("market-product-layout-full", {},
post_header=SxExpr(await _post_header_sx(ctx)),
market_header=SxExpr(_market_header_sx(ctx)),
product_header=SxExpr(_product_header_sx(ctx, d)))
post_header=await _post_header_sx(ctx),
market_header=_market_header_sx(ctx),
product_header=_product_header_sx(ctx, d))
return await full_page_sx(ctx, header_rows=hdr, content=content, meta=meta)
@@ -114,10 +107,10 @@ async def render_product_admin_page(ctx: dict, d: dict) -> str:
from shared.sx.helpers import render_to_sx_with_env
hdr = await render_to_sx_with_env("market-product-admin-layout-full", {},
post_header=SxExpr(await _post_header_sx(ctx)),
market_header=SxExpr(_market_header_sx(ctx)),
product_header=SxExpr(_product_header_sx(ctx, d)),
admin_header=SxExpr(_product_admin_header_sx(ctx, d)))
post_header=await _post_header_sx(ctx),
market_header=_market_header_sx(ctx),
product_header=_product_header_sx(ctx, d),
admin_header=_product_admin_header_sx(ctx, d))
return await full_page_sx(ctx, header_rows=hdr, content=content)
@@ -196,7 +189,7 @@ def render_like_toggle_button(slug: str, liked: bool, *,
return sx_call(
"market-like-toggle-button",
colour=colour, action=like_url,
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
hx_headers={"X-CSRFToken": csrf},
label=label, icon_cls=icon,
)
@@ -207,7 +200,7 @@ def render_cart_added_response(cart: list, item: Any, d: dict) -> str:
Returns OOB fragments: cart-mini icon + product add/remove buttons + cart item row.
"""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for, g
from quart import url_for
from shared.infrastructure.urls import cart_url as _cart_url
csrf = generate_csrf_token()
@@ -243,7 +236,7 @@ def render_cart_added_response(cart: list, item: Any, d: dict) -> str:
add_sx = sx_call(
"market-cart-add-oob",
id=f"cart-add-{slug}",
inner=SxExpr(cart_add),
inner=cart_add,
)
return "(<> " + cart_mini + " " + add_sx + ")"

View File

@@ -1,9 +1,6 @@
"""Price helpers, OOB helpers, product detail/meta builders."""
"""Price helpers, OOB helpers, product detail/meta data builders."""
from __future__ import annotations
from typing import Any
from shared.sx.parser import SxExpr
from shared.sx.helpers import sx_call
@@ -54,31 +51,14 @@ def _set_prices(item: dict) -> dict:
rp_val=rp_val, rp_raw=rp_raw, rp_cur=rp_cur)
def _card_price_sx(p: dict) -> str:
"""Build price line for product card as sx call."""
pr = _set_prices(p)
sp_str = _price_str(pr["sp_val"], pr["sp_raw"], pr["sp_cur"])
rp_str = _price_str(pr["rp_val"], pr["rp_raw"], pr["rp_cur"])
parts: list[str] = []
if pr["sp_val"]:
parts.append(sx_call("market-price-special", price=sp_str))
if pr["rp_val"]:
parts.append(sx_call("market-price-regular-strike", price=rp_str))
elif pr["rp_val"]:
parts.append(sx_call("market-price-regular", price=rp_str))
inner = "(<> " + " ".join(parts) + ")" if parts else None
return sx_call("market-price-line", inner=SxExpr(inner) if inner else None)
# ---------------------------------------------------------------------------
# Product detail page content
# Product detail data extraction
# ---------------------------------------------------------------------------
def _product_detail_sx(d: dict, ctx: dict) -> str:
"""Build product detail main panel content as sx."""
from quart import url_for
def _product_detail_data(d: dict, ctx: dict) -> dict:
"""Extract product detail page data for .sx composition."""
from shared.browser.app.csrf import generate_csrf_token
from .cards import _like_button_sx
from .cards import _like_button_data
asset_url_fn = ctx.get("asset_url")
user = ctx.get("user")
@@ -91,132 +71,70 @@ def _product_detail_sx(d: dict, ctx: dict) -> str:
brand = d.get("brand", "")
slug = d.get("slug", "")
# Gallery
if images:
# Like button
like_sx = ""
if user:
like_sx = _like_button_sx(slug, liked_by_current_user, csrf, ctx)
# Like button data
like_data = None
if user:
like_data = _like_button_data(slug, liked_by_current_user, csrf, ctx)
# Main image + labels
label_parts: list[str] = []
if callable(asset_url_fn):
for l in labels:
label_parts.append(sx_call(
"market-label-overlay",
src=asset_url_fn("labels/" + l + ".svg"),
))
labels_sx = "(<> " + " ".join(label_parts) + ")" if label_parts else None
# Label overlay URLs
label_urls = []
if callable(asset_url_fn):
label_urls = [asset_url_fn("labels/" + l + ".svg") for l in labels]
gallery_inner = sx_call(
"market-detail-gallery-inner",
like=SxExpr(like_sx) if like_sx else None,
image=images[0], alt=d.get("title", ""),
labels=SxExpr(labels_sx) if labels_sx else None,
brand=brand,
)
# Image data
image_data = [{"src": u, "alt": d.get("title", "")} for u in images] if images else []
# Prev/next buttons
nav_buttons = ""
if len(images) > 1:
nav_buttons = sx_call("market-detail-nav-buttons")
# Thumbnail data
thumb_data = []
if len(images) > 1:
for i, u in enumerate(images):
thumb_data.append({"title": f"Image {i+1}", "src": u, "alt": f"thumb {i+1}"})
gallery_sx = sx_call(
"market-detail-gallery",
inner=SxExpr(gallery_inner),
nav=SxExpr(nav_buttons) if nav_buttons else None,
)
# Thumbnails
gallery_parts = [gallery_sx]
if len(images) > 1:
thumb_parts = []
for i, u in enumerate(images):
thumb_parts.append(sx_call(
"market-detail-thumb",
title=f"Image {i+1}", src=u, alt=f"thumb {i+1}",
))
thumbs_sx = "(<> " + " ".join(thumb_parts) + ")"
gallery_parts.append(sx_call("market-detail-thumbs", thumbs=SxExpr(thumbs_sx)))
gallery_final = "(<> " + " ".join(gallery_parts) + ")"
else:
like_sx = ""
if user:
like_sx = _like_button_sx(slug, liked_by_current_user, csrf, ctx)
gallery_final = sx_call("market-detail-no-image",
like=SxExpr(like_sx) if like_sx else None)
# Stickers below gallery
stickers_sx = ""
# Sticker items
sticker_items = []
if stickers and callable(asset_url_fn):
sticker_parts = []
for s in stickers:
sticker_parts.append(sx_call(
"market-detail-sticker",
src=asset_url_fn("stickers/" + s + ".svg"), name=s,
))
sticker_items_sx = "(<> " + " ".join(sticker_parts) + ")"
stickers_sx = sx_call("market-detail-stickers", items=SxExpr(sticker_items_sx))
sticker_items.append({"src": asset_url_fn("stickers/" + s + ".svg"), "name": s})
# Right column: prices, description, sections
pr = _set_prices(d)
detail_parts: list[str] = []
# Unit price / case size extras
extra_parts: list[str] = []
# Extras (unit price, case size)
extras = []
ppu = d.get("price_per_unit") or d.get("price_per_unit_raw")
if ppu:
extra_parts.append(sx_call(
"market-detail-unit-price",
price=_price_str(d.get("price_per_unit"), d.get("price_per_unit_raw"), d.get("price_per_unit_currency")),
))
extras.append({
"type": "unit-price",
"value": _price_str(d.get("price_per_unit"), d.get("price_per_unit_raw"),
d.get("price_per_unit_currency")),
})
if d.get("case_size_raw"):
extra_parts.append(sx_call("market-detail-case-size", size=d["case_size_raw"]))
if extra_parts:
extras_sx = "(<> " + " ".join(extra_parts) + ")"
detail_parts.append(sx_call("market-detail-extras", inner=SxExpr(extras_sx)))
extras.append({"type": "case-size", "value": d["case_size_raw"]})
# Description
desc_short = d.get("description_short")
desc_html_val = d.get("description_html")
if desc_short or desc_html_val:
desc_parts: list[str] = []
if desc_short:
desc_parts.append(sx_call("market-detail-desc-short", text=desc_short))
if desc_html_val:
desc_parts.append(sx_call("market-detail-desc-html", html=desc_html_val))
desc_inner = "(<> " + " ".join(desc_parts) + ")"
detail_parts.append(sx_call("market-detail-desc-wrapper", inner=SxExpr(desc_inner)))
return {
"images": image_data or None,
"labels": label_urls or None,
"brand": brand,
"like-data": like_data,
"has-nav-buttons": len(images) > 1,
"thumbs": thumb_data or None,
"sticker-items": sticker_items or None,
"extras": extras or None,
"desc-short": d.get("description_short") or None,
"desc-html": d.get("description_html") or None,
"sections": d.get("sections") or None,
}
# Sections (expandable)
sections = d.get("sections", [])
if sections:
sec_parts = []
for sec in sections:
sec_parts.append(sx_call(
"market-detail-section",
title=sec.get("title", ""), html=sec.get("html", ""),
))
sec_items_sx = "(<> " + " ".join(sec_parts) + ")"
detail_parts.append(sx_call("market-detail-sections", items=SxExpr(sec_items_sx)))
details_inner_sx = "(<> " + " ".join(detail_parts) + ")" if detail_parts else "(<>)"
details_sx = sx_call("market-detail-right-col", inner=SxExpr(details_inner_sx))
return sx_call(
"market-detail-layout",
gallery=SxExpr(gallery_final),
stickers=SxExpr(stickers_sx) if stickers_sx else None,
details=SxExpr(details_sx),
)
def _product_detail_sx(d: dict, ctx: dict) -> str:
"""Build product detail content — delegates to .sx defcomp."""
data = _product_detail_data(d, ctx)
return sx_call("market-product-detail-from-data", **data)
# ---------------------------------------------------------------------------
# Product meta (OpenGraph, JSON-LD)
# Product meta data extraction
# ---------------------------------------------------------------------------
def _product_meta_sx(d: dict, ctx: dict) -> str:
"""Build product meta tags as sx (auto-hoisted to <head> by sx.js)."""
def _product_meta_data(d: dict, ctx: dict) -> dict:
"""Extract product meta/SEO data for .sx composition."""
import json
from quart import request
@@ -231,36 +149,8 @@ def _product_meta_sx(d: dict, ctx: dict) -> str:
brand = d.get("brand", "")
sku = d.get("sku", "")
price = d.get("special_price") or d.get("regular_price") or d.get("rrp")
price_currency = d.get("special_price_currency") or d.get("regular_price_currency") or d.get("rrp_currency")
parts = [sx_call("market-meta-title", title=title)]
parts.append(sx_call("market-meta-description", description=description))
if canonical:
parts.append(sx_call("market-meta-canonical", href=canonical))
# OpenGraph
site_title = ctx.get("base_title", "")
parts.append(sx_call("market-meta-og", property="og:site_name", content=site_title))
parts.append(sx_call("market-meta-og", property="og:type", content="product"))
parts.append(sx_call("market-meta-og", property="og:title", content=title))
parts.append(sx_call("market-meta-og", property="og:description", content=description))
if canonical:
parts.append(sx_call("market-meta-og", property="og:url", content=canonical))
if image_url:
parts.append(sx_call("market-meta-og", property="og:image", content=image_url))
if price and price_currency:
parts.append(sx_call("market-meta-og", property="product:price:amount", content=f"{price:.2f}"))
parts.append(sx_call("market-meta-og", property="product:price:currency", content=price_currency))
if brand:
parts.append(sx_call("market-meta-og", property="product:brand", content=brand))
# Twitter
card_type = "summary_large_image" if image_url else "summary"
parts.append(sx_call("market-meta-twitter", name="twitter:card", content=card_type))
parts.append(sx_call("market-meta-twitter", name="twitter:title", content=title))
parts.append(sx_call("market-meta-twitter", name="twitter:description", content=description))
if image_url:
parts.append(sx_call("market-meta-twitter", name="twitter:image", content=image_url))
price_currency = (d.get("special_price_currency") or d.get("regular_price_currency")
or d.get("rrp_currency"))
# JSON-LD
jsonld = {
@@ -282,6 +172,21 @@ def _product_meta_sx(d: dict, ctx: dict) -> str:
"url": canonical,
"availability": "https://schema.org/InStock",
}
parts.append(sx_call("market-meta-jsonld", json=json.dumps(jsonld)))
return "(<> " + " ".join(parts) + ")"
return {
"title": title,
"description": description,
"canonical": canonical or None,
"image-url": image_url or None,
"site-title": ctx.get("base_title", ""),
"brand": brand or None,
"price": f"{price:.2f}" if price and price_currency else None,
"price-currency": price_currency if price else None,
"jsonld-json": json.dumps(jsonld),
}
def _product_meta_sx(d: dict, ctx: dict) -> str:
"""Build product meta tags — delegates to .sx defcomp."""
data = _product_meta_data(d, ctx)
return sx_call("market-product-meta-from-data", **data)

View File

@@ -45,7 +45,7 @@ async def _render_checkout_return(ctx: dict, order=None, status: str = "",
else:
img = sx_call("order-item-no-image")
item_parts.append(sx_call("order-item-row",
href=product_url, img=SxExpr(img),
href=product_url, img=img,
title=item.product_title or "Unknown product",
pid=f"Product ID: {item.product_id}",
qty=f"Qty: {item.quantity}",
@@ -109,11 +109,11 @@ async def _render_checkout_return(ctx: dict, order=None, status: str = "",
status_msg = sx_call("checkout-return-paid")
content = sx_call("checkout-return-content",
summary=SxExpr(summary),
items=SxExpr(items) if items else None,
calendar=SxExpr(calendar) if calendar else None,
tickets=SxExpr(tickets) if tickets else None,
status_message=SxExpr(status_msg) if status_msg else None,
summary=summary,
items=items or None,
calendar=calendar or None,
tickets=tickets or None,
status_message=status_msg or None,
)
account_url = call_url(ctx, "account_url", "")

View File

@@ -71,7 +71,6 @@ def register() -> Blueprint:
if not hosted_url:
from shared.sx.page import get_template_context
from shared.sx.helpers import sx_call, root_header_sx, header_child_sx, full_page_sx, call_url
from shared.sx.parser import SxExpr
from shared.infrastructure.urls import cart_url
tctx = await get_template_context()
account_url = call_url(tctx, "account_url", "")
@@ -82,7 +81,7 @@ def register() -> Blueprint:
content = sx_call(
"checkout-error-content",
msg="No hosted checkout URL returned from SumUp when trying to reopen payment.",
order=SxExpr(order_sx),
order=order_sx,
back_url=cart_url("/"),
)
html = await full_page_sx(tctx, header_rows=hdr, filter=filt, content=content)

View File

@@ -1,4 +1,5 @@
;; Orders account-nav-item fragment handler
;; returns: sx
;;
;; Renders the "orders" link for the account dashboard nav.

View File

@@ -1,4 +1,5 @@
;; Relations container-nav fragment handler
;; returns: sx
;;
;; Generic navigation fragment driven by the relation registry.
;; Renders nav items for all related entities of a container.

View File

@@ -251,6 +251,18 @@
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 ---
var PRIMITIVES = {};
@@ -1420,7 +1432,7 @@
} else if (attrVal === true) {
el.setAttribute(attrName, "");
} else {
el.setAttribute(attrName, String(attrVal));
el.setAttribute(attrName, typeof attrVal === "object" && attrVal !== null && !Array.isArray(attrVal) ? _serializeDict(attrVal) : String(attrVal));
}
} else {
// Child
@@ -1851,7 +1863,7 @@
cancelButtonText: "Cancel"
}).then(function (result) {
if (!result.isConfirmed) return;
return _doFetch(el, method, url, extraParams);
return _doFetch(el, verbInfo, method, url, extraParams);
});
}
if (!window.confirm(confirmMsg)) return Promise.resolve();
@@ -1866,10 +1878,10 @@
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
var sync = el.getAttribute("sx-sync");
if (sync && sync.indexOf("replace") >= 0) abortPrevious(el);
@@ -1895,12 +1907,12 @@
var cssHeader = _getSxCssHeader();
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");
if (extraH) {
try {
var parsed = JSON.parse(extraH);
for (var k in parsed) headers[k] = parsed[k];
var parsed = extraH.charAt(0) === "{" && extraH.charAt(1) === ":" ? parse(extraH) : JSON.parse(extraH);
for (var k in parsed) headers[k] = String(parsed[k]);
} catch (e) { /* ignore */ }
}
@@ -1974,7 +1986,7 @@
var valsAttr = el.getAttribute("sx-vals");
if (valsAttr) {
try {
var vals = JSON.parse(valsAttr);
var vals = valsAttr.charAt(0) === "{" && valsAttr.charAt(1) === ":" ? parse(valsAttr) : JSON.parse(valsAttr);
if (method === "GET") {
for (var vk in vals) {
url += (url.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(vk) + "=" + encodeURIComponent(vals[vk]);

View File

@@ -1010,10 +1010,37 @@ async def async_eval_to_sx(
ctx = RequestContext()
result = await _aser(expr, env, ctx)
if isinstance(result, SxExpr):
return result.source
return result
if result is None or result is NIL:
return ""
return serialize(result)
return SxExpr("")
if isinstance(result, str):
return SxExpr(result)
return SxExpr(serialize(result))
async def _maybe_expand_component_result(
result: Any,
env: dict[str, Any],
ctx: RequestContext,
) -> Any:
"""If *result* is a component call (SxExpr or string starting with
``(~``), re-parse and expand it server-side.
This ensures Python-only helpers (e.g. ``highlight``) inside the
component body are evaluated on the server rather than being
serialized for the client where they don't exist.
"""
raw = None
if isinstance(result, SxExpr):
raw = str(result).strip()
elif isinstance(result, str):
raw = result.strip()
if raw and raw.startswith("(~"):
from .parser import parse_all
parsed = parse_all(raw)
if parsed:
return await async_eval_slot_to_sx(parsed[0], env, ctx)
return result
async def async_eval_slot_to_sx(
@@ -1039,10 +1066,12 @@ async def async_eval_slot_to_sx(
if isinstance(comp, Component):
result = await _aser_component(comp, expr[1:], env, ctx)
if isinstance(result, SxExpr):
return result.source
return result
if result is None or result is NIL:
return ""
return serialize(result)
return SxExpr("")
if isinstance(result, str):
return SxExpr(result)
return SxExpr(serialize(result))
else:
import logging
logging.getLogger("sx.eval").error(
@@ -1055,15 +1084,18 @@ async def async_eval_slot_to_sx(
)
# Fall back to normal async_eval_to_sx
result = await _aser(expr, env, ctx)
# If the result is a component call (from case/if/let branches or
# page helpers returning strings), re-parse and expand it server-side
# so that Python-only helpers like ``highlight`` in the component body
# get evaluated here, not on the client.
result = await _maybe_expand_component_result(result, env, ctx)
if isinstance(result, SxExpr):
return result.source
if result is None or result is NIL:
return ""
# Page helpers return SX source strings from render_to_sx() —
# pass through directly instead of quoting via serialize().
if isinstance(result, str):
return result
return serialize(result)
if result is None or result is NIL:
return SxExpr("")
if isinstance(result, str):
return SxExpr(result)
return SxExpr(serialize(result))
async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
@@ -1071,10 +1103,10 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
for everything else."""
if isinstance(expr, (int, float, bool)):
return expr
if isinstance(expr, str):
return expr
if isinstance(expr, SxExpr):
return expr
if isinstance(expr, str):
return expr
if expr is None or expr is NIL:
return NIL

View File

@@ -111,16 +111,19 @@ async def execute_handler(
service_name: str,
args: dict[str, str] | None = None,
) -> str:
"""Execute a declarative handler and return rendered sx/HTML string.
"""Execute a declarative handler and return SX wire format (``SxExpr``).
Uses the async evaluator+renderer so I/O primitives (``query``,
``service``, ``request-arg``, etc.) are awaited inline within
control flow — no collect-then-substitute limitations.
Uses the async evaluator so I/O primitives (``query``, ``service``,
``request-arg``, etc.) are awaited inline within control flow.
Returns ``SxExpr`` — pre-built sx source. Callers like
``fetch_fragment`` check ``content-type: text/sx`` and wrap the
response in ``SxExpr`` when consuming cross-service fragments.
1. Build env from component env + handler closure
2. Bind handler params from args (typically request.args)
3. Evaluate + render via async_render (handles I/O inline)
4. Return rendered string
3. Evaluate via ``async_eval_to_sx`` (I/O inline, components serialized)
4. Return ``SxExpr`` wire format
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval_to_sx

View File

@@ -74,10 +74,10 @@ async def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
)
def mobile_menu_sx(*sections: str) -> str:
def mobile_menu_sx(*sections: str) -> SxExpr:
"""Assemble mobile menu from pre-built sections (deepest first)."""
parts = [s for s in sections if s]
return "(<> " + " ".join(parts) + ")" if parts else ""
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
async def mobile_root_nav_sx(ctx: dict) -> str:
@@ -96,13 +96,13 @@ async def mobile_root_nav_sx(ctx: dict) -> str:
# Shared nav-item builders — used by BOTH desktop headers and mobile menus
# ---------------------------------------------------------------------------
async def _post_nav_items_sx(ctx: dict) -> str:
async def _post_nav_items_sx(ctx: dict) -> SxExpr:
"""Build post-level nav items (container_nav + admin cog). Shared by
``post_header_sx`` (desktop) and ``post_mobile_nav_sx`` (mobile)."""
post = ctx.get("post") or {}
slug = post.get("slug", "")
if not slug:
return ""
return SxExpr("")
parts: list[str] = []
page_cart_count = ctx.get("page_cart_count", 0)
if page_cart_count and page_cart_count > 0:
@@ -130,11 +130,11 @@ async def _post_nav_items_sx(ctx: dict) -> str:
is_admin_page=is_admin_page or None)
if admin_nav:
parts.append(admin_nav)
return "(<> " + " ".join(parts) + ")" if parts else ""
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
async def _post_admin_nav_items_sx(ctx: dict, slug: str,
selected: str = "") -> str:
selected: str = "") -> SxExpr:
"""Build post-admin nav items (calendars, markets, etc.). Shared by
``post_admin_header_sx`` (desktop) and mobile menu."""
select_colours = ctx.get("select_colours", "")
@@ -158,7 +158,7 @@ async def _post_admin_nav_items_sx(ctx: dict, slug: str,
parts.append(await _render_to_sx("nav-link", href=href, label=label,
select_colours=select_colours,
is_selected=is_sel or None))
return "(<> " + " ".join(parts) + ")" if parts else ""
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
# ---------------------------------------------------------------------------
@@ -177,7 +177,7 @@ async def post_mobile_nav_sx(ctx: dict) -> str:
label=title,
href=call_url(ctx, "blog_url", f"/{slug}/"),
level=1,
items=SxExpr(nav),
items=nav,
)
@@ -220,8 +220,8 @@ async def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> st
return await _render_to_sx("menu-row-sx",
id="post-row", level=1,
link_href=link_href,
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx) if nav_sx else None,
link_label_content=label_sx,
nav=nav_sx,
child_id="post-header-child",
child=SxExpr(child) if child else None,
oob=oob, external=True,
@@ -244,8 +244,8 @@ async def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
return await _render_to_sx("menu-row-sx",
id="post-admin-row", level=2,
link_href=admin_href,
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx) if nav_sx else None,
link_label_content=label_sx,
nav=nav_sx,
child_id="post-admin-header-child", oob=oob,
)
@@ -347,12 +347,23 @@ async def _render_to_sx_with_env(__name: str, extra_env: dict, **kwargs: Any) ->
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval_slot_to_sx
from .types import Symbol, Keyword, NIL as _NIL
# Build AST with extra_env entries as keyword args so _aser_component
# binds them as params (otherwise it defaults all params to NIL).
comp_sym = Symbol(__name if __name.startswith("~") else f"~{__name}")
ast: list = [comp_sym]
for k, v in extra_env.items():
ast.append(Keyword(k))
ast.append(v if v is not None else _NIL)
for k, v in kwargs.items():
ast.append(Keyword(k.replace("_", "-")))
ast.append(v if v is not None else _NIL)
ast = _build_component_ast(__name, **kwargs)
env = dict(get_component_env())
env.update(extra_env)
ctx = _get_request_context()
return await async_eval_slot_to_sx(ast, env, ctx)
return SxExpr(await async_eval_slot_to_sx(ast, env, ctx))
async def _render_to_sx(__name: str, **kwargs: Any) -> str:
@@ -371,7 +382,7 @@ async def _render_to_sx(__name: str, **kwargs: Any) -> str:
ast = _build_component_ast(__name, **kwargs)
env = dict(get_component_env())
ctx = _get_request_context()
return await async_eval_to_sx(ast, env, ctx)
return SxExpr(await async_eval_to_sx(ast, env, ctx))
# Backwards-compat alias — layout infrastructure still imports this.
@@ -420,7 +431,7 @@ def sx_call(component_name: str, **kwargs: Any) -> str:
parts.append("(list " + " ".join(items) + ")")
else:
parts.append(serialize(val))
return "(" + " ".join(parts) + ")"
return SxExpr("(" + " ".join(parts) + ")")

View File

@@ -206,7 +206,7 @@ def _render(expr: Any, env: dict[str, Any]) -> str:
return ""
return _render_list(expr, env)
# --- dict → skip (data, not renderable) -------------------------------
# --- dict → skip (data, not renderable as HTML content) -----------------
if isinstance(expr, dict):
return ""
@@ -540,6 +540,9 @@ def _render_element(tag: str, args: list, env: dict[str, Any]) -> str:
parts.append(f" {attr_name}")
elif attr_val is True:
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:
parts.append(f' {attr_name}="{escape_attr(str(attr_val))}"')
parts.append(">")

View File

@@ -30,8 +30,8 @@ from typing import Any
from .jinja_bridge import sx
SEARCH_HEADERS_MOBILE = '{"X-Origin":"search-mobile","X-Search":"true"}'
SEARCH_HEADERS_DESKTOP = '{"X-Origin":"search-desktop","X-Search":"true"}'
SEARCH_HEADERS_MOBILE = {"X-Origin": "search-mobile", "X-Search": "true"}
SEARCH_HEADERS_DESKTOP = {"X-Origin": "search-desktop", "X-Search": "true"}
def render_page(source: str, **kwargs: Any) -> str:

View File

@@ -25,31 +25,37 @@ from .types import Keyword, Symbol, NIL
# SxExpr — pre-built sx source marker
# ---------------------------------------------------------------------------
class SxExpr:
class SxExpr(str):
"""Pre-built sx source that serialize() outputs unquoted.
``SxExpr`` is a ``str`` subclass, so it works everywhere a plain
string does (join, startswith, f-strings, isinstance checks). The
only difference: ``serialize()`` emits it unquoted instead of
wrapping it in double-quotes.
Use this to nest sx call strings inside other sx_call() invocations
without them being quoted as strings::
sx_call("parent", child=SxExpr(sx_call("child", x=1)))
sx_call("parent", child=sx_call("child", x=1))
# => (~parent :child (~child :x 1))
"""
__slots__ = ("source",)
def __init__(self, source: str):
self.source = source
def __new__(cls, source: str = "") -> "SxExpr":
return str.__new__(cls, source)
@property
def source(self) -> str:
"""The raw SX source string (backward compat)."""
return str.__str__(self)
def __repr__(self) -> str:
return f"SxExpr({self.source!r})"
def __str__(self) -> str:
return self.source
return f"SxExpr({str.__repr__(self)})"
def __add__(self, other: object) -> "SxExpr":
return SxExpr(self.source + str(other))
return SxExpr(str.__add__(self, str(other)))
def __radd__(self, other: object) -> "SxExpr":
return SxExpr(str(other) + self.source)
return SxExpr(str.__add__(str(other), self))
# ---------------------------------------------------------------------------
@@ -283,7 +289,26 @@ def _parse_map(tok: Tokenizer) -> dict[str, Any]:
# ---------------------------------------------------------------------------
def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
"""Serialize a value back to s-expression text."""
"""Serialize a value back to s-expression text.
Type dispatch order (first match wins):
- ``SxExpr`` → emitted unquoted (pre-built sx source)
- ``list`` → ``(head ...)`` (s-expression list)
- ``Symbol`` → bare name
- ``Keyword`` → ``:name``
- ``str`` → ``"quoted"`` (with escapes)
- ``bool`` → ``true`` / ``false``
- ``int/float`` → numeric literal
- ``None/NIL`` → ``nil``
- ``dict`` → ``{:key val ...}``
List serialization conventions (for ``sx_call`` kwargs):
- ``(list ...)`` — data array: client gets iterable for map/filter
- ``(<> ...)`` — rendered content: client treats as DocumentFragment
- ``(head ...)`` — AST: head is called as function (never use for data)
"""
if isinstance(expr, SxExpr):
return expr.source

View File

@@ -50,6 +50,15 @@ IO_PRIMITIVES: frozenset[str] = frozenset({
"select-colours",
"account-nav-ctx",
"app-rights",
"federation-actor-ctx",
"request-view-args",
"cart-page-ctx",
"events-calendar-ctx",
"events-day-ctx",
"events-entry-ctx",
"events-slot-ctx",
"events-ticket-type-ctx",
"market-header-ctx",
})
@@ -557,6 +566,376 @@ async def _io_post_header_ctx(
return result
async def _io_cart_page_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(cart-page-ctx)`` → dict with cart page header values.
Reads ``g.page_post`` (set by cart's before_request) and returns
slug, title, feature-image, and cart-url for the page cart header.
"""
from quart import g
from .types import NIL
from shared.infrastructure.urls import app_url
page_post = getattr(g, "page_post", None)
if not page_post:
return {"slug": "", "title": "", "feature-image": NIL, "cart-url": "/"}
slug = getattr(page_post, "slug", "") or ""
title = (getattr(page_post, "title", "") or "")[:160]
feature_image = getattr(page_post, "feature_image", None) or NIL
return {
"slug": slug,
"title": title,
"feature-image": feature_image,
"page-cart-url": app_url("cart", f"/{slug}/"),
"cart-url": app_url("cart", "/"),
}
async def _io_federation_actor_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any] | None:
"""``(federation-actor-ctx)`` → serialized actor dict or None.
Reads ``g._social_actor`` (set by federation social blueprint's
before_request hook) and serializes to a dict for .sx components.
"""
from quart import g
actor = getattr(g, "_social_actor", None)
if not actor:
return None
return {
"id": actor.id,
"preferred_username": actor.preferred_username,
"display_name": getattr(actor, "display_name", None),
"icon_url": getattr(actor, "icon_url", None),
"actor_url": getattr(actor, "actor_url", ""),
}
async def _io_request_view_args(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> Any:
"""``(request-view-args "key")`` → request.view_args[key]."""
if not args:
raise ValueError("request-view-args requires a key")
from quart import request
key = str(args[0])
return (request.view_args or {}).get(key)
async def _io_events_calendar_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(events-calendar-ctx)`` → dict with events calendar header values.
Reads ``g.calendar`` or ``g._defpage_ctx["calendar"]`` and returns
slug, name, description for the calendar header row.
"""
from quart import g
cal = getattr(g, "calendar", None)
if not cal:
dctx = getattr(g, "_defpage_ctx", None) or {}
cal = dctx.get("calendar")
if not cal:
return {"slug": ""}
return {
"slug": getattr(cal, "slug", "") or "",
"name": getattr(cal, "name", "") or "",
"description": getattr(cal, "description", "") or "",
}
async def _io_events_day_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(events-day-ctx)`` → dict with events day header values.
Reads ``g.day_date``, ``g.calendar``, confirmed entries from
``g._defpage_ctx``. Pre-builds the confirmed entries nav as SxExpr.
"""
from quart import g, url_for
from .types import NIL
from .parser import SxExpr
dctx = getattr(g, "_defpage_ctx", None) or {}
cal = getattr(g, "calendar", None) or dctx.get("calendar")
day_date = dctx.get("day_date") or getattr(g, "day_date", None)
if not cal or not day_date:
return {"date-str": ""}
cal_slug = getattr(cal, "slug", "") or ""
# Build confirmed entries nav
confirmed = dctx.get("confirmed_entries") or []
rights = getattr(g, "rights", None) or {}
is_admin = (
rights.get("admin", False)
if isinstance(rights, dict)
else getattr(rights, "admin", False)
)
from .helpers import sx_call
nav_parts: list[str] = []
if confirmed:
entry_links = []
for entry in confirmed:
href = url_for(
"calendar.day.calendar_entries.calendar_entry.get",
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 ""
)
entry_links.append(sx_call(
"events-day-entry-link",
href=href, name=entry.name, time_str=f"{start}{end}",
))
inner = "".join(entry_links)
nav_parts.append(sx_call(
"events-day-entries-nav", inner=SxExpr(inner),
))
if is_admin and day_date:
admin_href = url_for(
"defpage_day_admin", calendar_slug=cal_slug,
year=day_date.year, month=day_date.month, day=day_date.day,
)
nav_parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog"))
return {
"date-str": day_date.strftime("%A %d %B %Y"),
"year": day_date.year,
"month": day_date.month,
"day": day_date.day,
"nav": SxExpr("".join(nav_parts)) if nav_parts else NIL,
}
async def _io_events_entry_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(events-entry-ctx)`` → dict with events entry header values.
Reads ``g.entry``, ``g.calendar``, and entry_posts from
``g._defpage_ctx``. Pre-builds entry nav (posts + admin link) as SxExpr.
"""
from quart import g, url_for
from .types import NIL
from .parser import SxExpr
dctx = getattr(g, "_defpage_ctx", None) or {}
cal = getattr(g, "calendar", None) or dctx.get("calendar")
entry = getattr(g, "entry", None) or dctx.get("entry")
if not cal or not entry:
return {"id": ""}
cal_slug = getattr(cal, "slug", "") or ""
day = dctx.get("day")
month = dctx.get("month")
year = dctx.get("year")
# Times
start = entry.start_at
end = entry.end_at
time_str = ""
if start:
time_str = start.strftime("%H:%M")
if end:
time_str += f" \u2192 {end.strftime('%H:%M')}"
link_href = url_for(
"calendar.day.calendar_entries.calendar_entry.get",
calendar_slug=cal_slug,
year=year, month=month, day=day, entry_id=entry.id,
)
# Build nav: associated posts + admin link
entry_posts = dctx.get("entry_posts") or []
rights = getattr(g, "rights", None) or {}
is_admin = (
rights.get("admin", False)
if isinstance(rights, dict)
else getattr(rights, "admin", False)
)
from .helpers import sx_call
from shared.infrastructure.urls import app_url
nav_parts: list[str] = []
if entry_posts:
post_links = ""
for ep in entry_posts:
ep_slug = getattr(ep, "slug", "")
ep_title = getattr(ep, "title", "")
feat = getattr(ep, "feature_image", None)
href = app_url("blog", f"/{ep_slug}/")
if feat:
img_html = sx_call("events-post-img", src=feat, alt=ep_title)
else:
img_html = sx_call("events-post-img-placeholder")
post_links += sx_call(
"events-entry-nav-post-link",
href=href, img=SxExpr(img_html), title=ep_title,
)
nav_parts.append(
sx_call("events-entry-posts-nav-oob", items=SxExpr(post_links))
.replace(' :hx-swap-oob "true"', '')
)
if is_admin:
admin_url = url_for(
"calendar.day.calendar_entries.calendar_entry.admin.admin",
calendar_slug=cal_slug,
day=day, month=month, year=year, entry_id=entry.id,
)
nav_parts.append(sx_call("events-entry-admin-link", href=admin_url))
# Entry admin nav (ticket_types link)
admin_href = url_for(
"calendar.day.calendar_entries.calendar_entry.admin.admin",
calendar_slug=cal_slug,
day=day, month=month, year=year, entry_id=entry.id,
) if is_admin else ""
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,
)
from quart import current_app
select_colours = current_app.jinja_env.globals.get("select_colours", "")
return {
"id": str(entry.id),
"name": entry.name or "",
"time-str": time_str,
"link-href": link_href,
"nav": SxExpr("".join(nav_parts)) if nav_parts else NIL,
"admin-href": admin_href,
"ticket-types-href": ticket_types_href,
"is-admin": is_admin,
"select-colours": select_colours,
}
async def _io_events_slot_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(events-slot-ctx)`` → dict with events slot header values."""
from quart import g
dctx = getattr(g, "_defpage_ctx", None) or {}
slot = getattr(g, "slot", None) or dctx.get("slot")
if not slot:
return {"name": ""}
return {
"name": getattr(slot, "name", "") or "",
"description": getattr(slot, "description", "") or "",
}
async def _io_events_ticket_type_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(events-ticket-type-ctx)`` → dict with ticket type header values."""
from quart import g, url_for
dctx = getattr(g, "_defpage_ctx", None) or {}
cal = getattr(g, "calendar", None) or dctx.get("calendar")
entry = getattr(g, "entry", None) or dctx.get("entry")
ticket_type = getattr(g, "ticket_type", None) or dctx.get("ticket_type")
if not cal or not entry or not ticket_type:
return {"id": ""}
cal_slug = getattr(cal, "slug", "") or ""
day = dctx.get("day")
month = dctx.get("month")
year = dctx.get("year")
link_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=entry.id, ticket_type_id=ticket_type.id,
)
return {
"id": str(ticket_type.id),
"name": getattr(ticket_type, "name", "") or "",
"link-href": link_href,
}
async def _io_market_header_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(market-header-ctx)`` → dict with market header data.
Returns plain data (categories list, hrefs, flags) for the
~market-header-auto macro. Mobile nav is pre-built as SxExpr.
"""
from quart import g, url_for
from shared.config import config as get_config
from .parser import SxExpr
cfg = get_config()
market_title = cfg.get("market_title", "")
link_href = url_for("defpage_market_home")
# Get categories if market is loaded
market = getattr(g, "market", None)
categories = {}
if market:
from bp.browse.services.nav import get_nav
nav_data = await get_nav(g.s, market_id=market.id)
categories = nav_data.get("cats", {})
# Build minimal ctx for existing helper functions
select_colours = getattr(g, "select_colours", "")
if not select_colours:
from quart import current_app
select_colours = current_app.jinja_env.globals.get("select_colours", "")
rights = getattr(g, "rights", None) or {}
mini_ctx: dict[str, Any] = {
"market_title": market_title,
"top_slug": "",
"sub_slug": "",
"categories": categories,
"qs": "",
"hx_select_search": "#main-panel",
"select_colours": select_colours,
"rights": rights,
"category_label": "",
}
# Build header + mobile nav data via new data-driven helpers
from sxc.pages.layouts import _market_header_data, _mobile_nav_panel_sx
header_data = _market_header_data(mini_ctx)
mobile_nav = _mobile_nav_panel_sx(mini_ctx)
return {
"market-title": market_title,
"link-href": link_href,
"top-slug": "",
"sub-slug": "",
"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 "",
}
_IO_HANDLERS: dict[str, Any] = {
"frag": _io_frag,
"query": _io_query,
@@ -578,4 +957,13 @@ _IO_HANDLERS: dict[str, Any] = {
"select-colours": _io_select_colours,
"account-nav-ctx": _io_account_nav_ctx,
"app-rights": _io_app_rights,
"federation-actor-ctx": _io_federation_actor_ctx,
"request-view-args": _io_request_view_args,
"cart-page-ctx": _io_cart_page_ctx,
"events-calendar-ctx": _io_events_calendar_ctx,
"events-day-ctx": _io_events_day_ctx,
"events-entry-ctx": _io_events_entry_ctx,
"events-slot-ctx": _io_events_slot_ctx,
"events-ticket-type-ctx": _io_events_ticket_type_ctx,
"market-header-ctx": _io_market_header_ctx,
}

115
sx/sx/docs-content.sx Normal file
View File

@@ -0,0 +1,115 @@
;; Docs page content — fully self-contained, no Python intermediaries
(defcomp ~sx-home-content ()
(div :id "main-content"
(~sx-hero (highlight "(div :class \"p-4 bg-white rounded shadow\"\n (h1 :class \"text-2xl font-bold\" \"Hello\")\n (button :sx-get \"/api/data\"\n :sx-target \"#result\"\n \"Load data\"))" "lisp"))
(~sx-philosophy)
(~sx-how-it-works)
(~sx-credits)))
(defcomp ~docs-introduction-content ()
(~doc-page :title "Introduction"
(~doc-section :title "What is sx?" :id "what"
(p :class "text-stone-600"
"sx is an s-expression language for building web UIs. It combines htmx's server-first hypermedia approach with React's component model. The server sends s-expression source code over the wire. The client parses, evaluates, and renders it to DOM.")
(p :class "text-stone-600"
"The same evaluator runs on both server (Python) and client (JavaScript). Components defined once render identically in both environments."))
(~doc-section :title "Design decisions" :id "design"
(p :class "text-stone-600"
"HTML elements are first-class: (div :class \"card\" (p \"hello\")) renders exactly what you'd expect. Components use defcomp with keyword parameters and optional children. The evaluator supports let bindings, conditionals, lambda, map/filter/reduce, and ~80 primitives.")
(p :class "text-stone-600"
"sx replaces the pattern of shipping a JS framework + build step + client-side router + state management library just to render some server data. For most applications, sx eliminates the need for JavaScript entirely — htmx attributes handle interactivity, hyperscript handles small behaviours, and the server handles everything else."))
(~doc-section :title "What sx is not" :id "not"
(ul :class "space-y-2 text-stone-600"
(li "Not a general-purpose programming language — it's a UI rendering language")
(li "Not a full Lisp — it has macros and TCO, but no continuations or call/cc")
(li "Not production-hardened at scale — it runs one website")))))
(defcomp ~docs-getting-started-content ()
(~doc-page :title "Getting Started"
(~doc-section :title "Minimal example" :id "minimal"
(p :class "text-stone-600"
"An sx response is s-expression source code with content type text/sx:")
(~doc-code :code (highlight "(div :class \"p-4 bg-white rounded\"\n (h1 :class \"text-2xl font-bold\" \"Hello, world!\")\n (p \"This is rendered from an s-expression.\"))" "lisp"))
(p :class "text-stone-600"
"Add sx-get to any element to make it fetch and render sx:"))
(~doc-section :title "Hypermedia attributes" :id "attrs"
(p :class "text-stone-600"
"Like htmx, sx adds attributes to HTML elements to trigger HTTP requests:")
(~doc-code :code (highlight "(button\n :sx-get \"/api/data\"\n :sx-target \"#result\"\n :sx-swap \"innerHTML\"\n \"Load data\")" "lisp"))
(p :class "text-stone-600"
"sx-get, sx-post, sx-put, sx-delete, sx-patch — all work the same way. The response is parsed as sx and rendered into the target element."))))
(defcomp ~docs-components-content ()
(~doc-page :title "Components"
(~doc-section :title "defcomp" :id "defcomp"
(p :class "text-stone-600"
"Components are defined with defcomp. They take keyword parameters and optional children:")
(~doc-code :code (highlight "(defcomp ~card (&key title subtitle &rest children)\n (div :class \"border rounded p-4\"\n (h2 :class \"font-bold\" title)\n (when subtitle (p :class \"text-stone-500\" subtitle))\n (div :class \"mt-3\" children)))" "lisp"))
(p :class "text-stone-600"
"Use components with the ~ prefix:")
(~doc-code :code (highlight "(~card :title \"My Card\" :subtitle \"A description\"\n (p \"First child\")\n (p \"Second child\"))" "lisp")))
(~doc-section :title "Component caching" :id "caching"
(p :class "text-stone-600"
"Component definitions are sent in a <script type=\"text/sx\" data-components> block. The client caches them in localStorage keyed by a content hash. On subsequent page loads, the client sends an SX-Components header listing what it has. The server only sends definitions the client is missing.")
(p :class "text-stone-600"
"This means the first page load sends all component definitions (~5-15KB). Subsequent navigations send zero component bytes — just the page content."))
(~doc-section :title "Parameters" :id "params"
(p :class "text-stone-600"
"&key declares keyword parameters. &rest children captures remaining positional arguments. Missing parameters evaluate to nil. Components always receive all declared parameters — use (when param ...) or (if param ... ...) to handle optional values."))))
(defcomp ~docs-evaluator-content ()
(~doc-page :title "Evaluator"
(~doc-section :title "Special forms" :id "special"
(p :class "text-stone-600"
"Special forms have lazy evaluation — arguments are not evaluated before the form runs:")
(~doc-code :code (highlight ";; Conditionals\n(if condition then-expr else-expr)\n(when condition body...)\n(cond (test1 body1) (test2 body2) (else default))\n\n;; Bindings\n(let ((name value) (name2 value2)) body...)\n(define name value)\n\n;; Functions\n(lambda (x y) (+ x y))\n(fn (x) (* x x))\n\n;; Sequencing\n(do expr1 expr2 expr3)\n(begin expr1 expr2)\n\n;; Threading\n(-> value (fn1 arg) (fn2 arg))" "lisp")))
(~doc-section :title "Higher-order forms" :id "higher"
(p :class "text-stone-600"
"These operate on collections with function arguments:")
(~doc-code :code (highlight "(map (fn (x) (* x 2)) (list 1 2 3)) ;; => (2 4 6)\n(filter (fn (x) (> x 2)) (list 1 2 3 4 5)) ;; => (3 4 5)\n(reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3)) ;; => 6\n(some (fn (x) (> x 3)) (list 1 2 3 4)) ;; => true\n(every? (fn (x) (> x 0)) (list 1 2 3)) ;; => true" "lisp")))))
(defcomp ~docs-primitives-content (&key prims)
(~doc-page :title "Primitives"
(~doc-section :title "Built-in functions" :id "builtins"
(p :class "text-stone-600"
"sx provides ~80 built-in pure functions. They work identically on server (Python) and client (JavaScript).")
(div :class "space-y-6" prims))))
(defcomp ~docs-css-content ()
(~doc-page :title "On-Demand CSS"
(~doc-section :title "How it works" :id "how"
(p :class "text-stone-600"
"sx scans every response for CSS class names used in :class attributes. It looks up only those classes in a pre-parsed Tailwind CSS registry and ships just the rules that are needed. No build step. No purging. No unused CSS.")
(p :class "text-stone-600"
"On the first page load, the full set of used classes is embedded in a <style> block. A hash of the class set is stored. On subsequent navigations, the client sends the hash in the SX-Css header. The server computes the diff and sends only new rules via SX-Css-Add and a <style data-sx-css> block."))
(~doc-section :title "The protocol" :id "protocol"
(~doc-code :code (highlight "# First page load:\nGET / HTTP/1.1\n\nHTTP/1.1 200 OK\nContent-Type: text/html\n# Full CSS in <style id=\"sx-css\"> + hash in <meta name=\"sx-css-classes\">\n\n# Subsequent navigation:\nGET /about HTTP/1.1\nSX-Css: a1b2c3d4\n\nHTTP/1.1 200 OK\nContent-Type: text/sx\nSX-Css-Hash: e5f6g7h8\nSX-Css-Add: bg-blue-500,text-white,rounded-lg\n# Only new rules in <style data-sx-css>" "bash")))
(~doc-section :title "Advantages" :id "advantages"
(ul :class "space-y-2 text-stone-600"
(li "Zero build step — no Tailwind CLI, no PostCSS, no purging")
(li "Exact CSS — never ships a rule that isn't used on the page")
(li "Incremental — subsequent navigations only ship new rules")
(li "Component-aware — pre-scans component definitions at registration time")))
(~doc-section :title "Disadvantages" :id "disadvantages"
(ul :class "space-y-2 text-stone-600"
(li "Requires the full Tailwind CSS file loaded in memory at startup (~4MB parsed)")
(li "Regex-based class scanning — can miss dynamically constructed class names")
(li "No @apply support — classes must be used directly")
(li "Tied to Tailwind's utility class naming conventions")))))
(defcomp ~docs-server-rendering-content ()
(~doc-page :title "Server Rendering"
(~doc-section :title "Python API" :id "python"
(p :class "text-stone-600"
"The server-side sx library provides several entry points for rendering:")
(~doc-code :code (highlight "from shared.sx.helpers import sx_page, sx_response, sx_call\nfrom shared.sx.parser import SxExpr\n\n# Build a component call from Python kwargs\nsx_call(\"card\", title=\"Hello\", subtitle=\"World\")\n\n# Return an sx wire-format response\nreturn sx_response(sx_call(\"card\", title=\"Hello\"))\n\n# Return a full HTML page shell with sx boot\nreturn sx_page(ctx, page_sx)" "python")))
(~doc-section :title "sx_call" :id "sx-call"
(p :class "text-stone-600"
"sx_call converts Python kwargs to an s-expression component call. Snake_case becomes kebab-case. SxExpr values are inlined without quoting. None becomes nil. Bools become true/false."))
(~doc-section :title "sx_response" :id "sx-response"
(p :class "text-stone-600"
"sx_response returns a Quart Response with content type text/sx. It prepends missing component definitions, scans for CSS classes, and sets SX-Css-Hash and SX-Css-Add headers."))
(~doc-section :title "sx_page" :id "sx-page"
(p :class "text-stone-600"
"sx_page returns a minimal HTML document that boots the page from sx source. The browser loads component definitions and page sx from inline <script> tags, then sx.js renders everything client-side. CSS rules are pre-scanned and injected."))))

View File

@@ -63,3 +63,49 @@
(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)"
:class "text-xs text-stone-400 hover:text-stone-600 border border-stone-200 rounded px-2 py-1 transition-colors"
"Clear component cache"))
;; ---------------------------------------------------------------------------
;; Data-driven table builders — replace Python sx_call() composition
;; ---------------------------------------------------------------------------
;; Build attr table from a list of {name, desc, exists, href} dicts.
;; Replaces _attr_table_sx() in utils.py.
(defcomp ~doc-attr-table-from-data (&key title attrs)
(~doc-attr-table :title title
:rows (<> (map (fn (a)
(~doc-attr-row
:attr (get a "name")
:description (get a "desc")
:exists (get a "exists")
:href (get a "href")))
attrs))))
;; Build headers table from a list of {name, value, desc} dicts.
;; Replaces _headers_table_sx() in utils.py.
(defcomp ~doc-headers-table-from-data (&key title headers)
(~doc-headers-table :title title
:rows (<> (map (fn (h)
(~doc-headers-row
:name (get h "name")
:value (get h "value")
:description (get h "desc")))
headers))))
;; Build two-col table from a list of {name, desc} dicts.
;; Replaces the _reference_events_sx / _reference_js_api_sx builders.
(defcomp ~doc-two-col-table-from-data (&key title intro col1 col2 items)
(~doc-two-col-table :title title :intro intro :col1 col1 :col2 col2
:rows (<> (map (fn (item)
(~doc-two-col-row
:name (get item "name")
:description (get item "desc")))
items))))
;; Build all primitives category tables from a {category: [prim, ...]} dict.
;; Replaces _primitives_section_sx() in utils.py.
(defcomp ~doc-primitives-tables (&key primitives)
(<> (map (fn (cat)
(~doc-primitives-table
:category cat
:primitives (get primitives cat)))
(keys primitives))))

28
sx/sx/essays.sx Normal file

File diff suppressed because one or more lines are too long

316
sx/sx/examples-content.sx Normal file
View File

@@ -0,0 +1,316 @@
;; Example page defcomps — one per example.
;; Each calls ~example-page-content with static string data.
;; Replaces all _example_*_sx() builders in essays.py.
(defcomp ~example-click-to-load ()
(~example-page-content
:title "Click to Load"
:description "The simplest sx interaction: click a button, fetch content from the server, swap it in."
:demo-description "Click the button to load server-rendered content."
:demo (~click-to-load-demo)
:sx-code "(button\n :sx-get \"/examples/api/click\"\n :sx-target \"#click-result\"\n :sx-swap \"innerHTML\"\n \"Load content\")"
:handler-code "@bp.get(\"/examples/api/click\")\nasync def api_click():\n now = datetime.now().strftime(...)\n return sx_response(\n f'(~click-result :time \"{now}\")')"
:comp-placeholder-id "click-comp"
:wire-placeholder-id "click-wire"
:wire-note "The server responds with content-type text/sx. New CSS rules are prepended as a style tag. Clear the component cache to see component definitions included in the wire response."))
(defcomp ~example-form-submission ()
(~example-page-content
:title "Form Submission"
:description "Forms with sx-post submit via AJAX and swap the response into a target."
:demo-description "Enter a name and submit."
:demo (~form-demo)
:sx-code "(form\n :sx-post \"/examples/api/form\"\n :sx-target \"#form-result\"\n :sx-swap \"innerHTML\"\n (input :type \"text\" :name \"name\")\n (button :type \"submit\" \"Submit\"))"
:handler-code "@bp.post(\"/examples/api/form\")\nasync def api_form():\n form = await request.form\n name = form.get(\"name\", \"\")\n return sx_response(\n f'(~form-result :name \"{name}\")')"
:comp-placeholder-id "form-comp"
:wire-placeholder-id "form-wire"))
(defcomp ~example-polling ()
(~example-page-content
:title "Polling"
:description "Use sx-trigger with \"every\" to poll the server at regular intervals."
:demo-description "This div polls the server every 2 seconds."
:demo (~polling-demo)
:sx-code "(div\n :sx-get \"/examples/api/poll\"\n :sx-trigger \"load, every 2s\"\n :sx-swap \"innerHTML\"\n \"Loading...\")"
:handler-code "@bp.get(\"/examples/api/poll\")\nasync def api_poll():\n poll_count[\"n\"] += 1\n now = datetime.now().strftime(\"%H:%M:%S\")\n count = min(poll_count[\"n\"], 10)\n return sx_response(\n f'(~poll-result :time \"{now}\" :count {count})')"
:comp-placeholder-id "poll-comp"
:wire-placeholder-id "poll-wire"
:wire-note "Updates every 2 seconds — watch the time and count change."))
(defcomp ~example-delete-row ()
(~example-page-content
:title "Delete Row"
:description "sx-delete with sx-swap \"outerHTML\" and an empty response removes the row from the DOM."
:demo-description "Click delete to remove a row. Uses sx-confirm for confirmation."
:demo (~delete-demo :items (list
(list "1" "Implement dark mode")
(list "2" "Fix login bug")
(list "3" "Write documentation")
(list "4" "Deploy to production")
(list "5" "Add unit tests")))
:sx-code "(button\n :sx-delete \"/api/delete/1\"\n :sx-target \"#row-1\"\n :sx-swap \"outerHTML\"\n :sx-confirm \"Delete this item?\"\n \"delete\")"
:handler-code "@bp.delete(\"/examples/api/delete/<item_id>\")\nasync def api_delete(item_id: str):\n # Empty response — outerHTML swap removes the row\n return Response(\"\", status=200,\n content_type=\"text/sx\")"
:comp-placeholder-id "delete-comp"
:wire-placeholder-id "delete-wire"
:wire-note "Empty body — outerHTML swap replaces the target element with nothing."))
(defcomp ~example-inline-edit ()
(~example-page-content
:title "Inline Edit"
:description "Click edit to swap a display view for an edit form. Save swaps back."
:demo-description "Click edit, modify the text, save or cancel."
:demo (~inline-edit-demo)
:sx-code ";; View mode — shows text + edit button\n(~inline-view :value \"some text\")\n\n;; Edit mode — returned by server on click\n(~inline-edit-form :value \"some text\")"
:handler-code "@bp.get(\"/examples/api/edit\")\nasync def api_edit_form():\n value = request.args.get(\"value\", \"\")\n return sx_response(\n f'(~inline-edit-form :value \"{value}\")')\n\n@bp.post(\"/examples/api/edit\")\nasync def api_edit_save():\n form = await request.form\n value = form.get(\"value\", \"\")\n return sx_response(\n f'(~inline-view :value \"{value}\")')"
:comp-placeholder-id "edit-comp"
:comp-heading "Components"
:handler-heading "Server handlers"
:wire-placeholder-id "edit-wire"))
(defcomp ~example-oob-swaps ()
(~example-page-content
:title "Out-of-Band Swaps"
:description "sx-swap-oob lets a single response update multiple elements anywhere in the DOM."
:demo-description "One request updates both Box A (via sx-target) and Box B (via sx-swap-oob)."
:demo (~oob-demo)
:sx-code ";; Button targets Box A\n(button\n :sx-get \"/examples/api/oob\"\n :sx-target \"#oob-box-a\"\n :sx-swap \"innerHTML\"\n \"Update both boxes\")"
:handler-code "@bp.get(\"/examples/api/oob\")\nasync def api_oob():\n now = datetime.now().strftime(\"%H:%M:%S\")\n return sx_response(\n f'(<>'\n f' (p \"Box A updated at {now}\")'\n f' (div :id \"oob-box-b\"'\n f' :sx-swap-oob \"innerHTML\"'\n f' (p \"Box B updated at {now}\")))')"
:wire-placeholder-id "oob-wire"
:wire-note "The fragment contains both the main content and an OOB element. sx.js splits them: main content goes to sx-target, OOB elements find their targets by ID."))
(defcomp ~example-lazy-loading ()
(~example-page-content
:title "Lazy Loading"
:description "Use sx-trigger=\"load\" to fetch content as soon as the element enters the DOM. Great for deferring expensive content below the fold."
:demo-description "Content loads automatically when the page renders."
:demo (~lazy-loading-demo)
:sx-code "(div\n :sx-get \"/examples/api/lazy\"\n :sx-trigger \"load\"\n :sx-swap \"innerHTML\"\n (div :class \"animate-pulse\" \"Loading...\"))"
:handler-code "@bp.get(\"/examples/api/lazy\")\nasync def api_lazy():\n now = datetime.now().strftime(...)\n return sx_response(\n f'(~lazy-result :time \"{now}\")')"
:comp-placeholder-id "lazy-comp"
:wire-placeholder-id "lazy-wire"))
(defcomp ~example-infinite-scroll ()
(~example-page-content
:title "Infinite Scroll"
:description "A sentinel element at the bottom uses sx-trigger=\"intersect once\" to load the next page when scrolled into view. Each response appends items and a new sentinel."
:demo-description "Scroll down in the container to load more items (5 pages total)."
:demo (~infinite-scroll-demo)
:sx-code "(div :id \"scroll-sentinel\"\n :sx-get \"/examples/api/scroll?page=2\"\n :sx-trigger \"intersect once\"\n :sx-target \"#scroll-items\"\n :sx-swap \"beforeend\"\n \"Loading more...\")"
:handler-code "@bp.get(\"/examples/api/scroll\")\nasync def api_scroll():\n page = int(request.args.get(\"page\", 2))\n items = [f\"Item {i}\" for i in range(...)]\n # Include next sentinel if more pages\n return sx_response(items_sx + sentinel_sx)"
:comp-placeholder-id "scroll-comp"
:wire-placeholder-id "scroll-wire"))
(defcomp ~example-progress-bar ()
(~example-page-content
:title "Progress Bar"
:description "Start a server-side job, then poll for progress using sx-trigger=\"load delay:500ms\" on each response. The bar fills up and stops when complete."
:demo-description "Click start to begin a simulated job."
:demo (~progress-bar-demo)
:sx-code ";; Start the job\n(button\n :sx-post \"/examples/api/progress/start\"\n :sx-target \"#progress-target\"\n :sx-swap \"innerHTML\")\n\n;; Each response re-polls via sx-trigger=\"load\"\n(div :sx-get \"/api/progress/status?job=ID\"\n :sx-trigger \"load delay:500ms\"\n :sx-target \"#progress-target\"\n :sx-swap \"innerHTML\")"
:handler-code "@bp.post(\"/examples/api/progress/start\")\nasync def api_progress_start():\n job_id = str(uuid4())[:8]\n _jobs[job_id] = 0\n return sx_response(\n f'(~progress-status :percent 0 :job-id \"{job_id}\")')"
:comp-placeholder-id "progress-comp"
:wire-placeholder-id "progress-wire"))
(defcomp ~example-active-search ()
(~example-page-content
:title "Active Search"
:description "An input with sx-trigger=\"keyup delay:300ms changed\" debounces keystrokes and only fires when the value changes. The server filters a list of programming languages."
:demo-description "Type to search through 20 programming languages."
:demo (~active-search-demo)
:sx-code "(input :type \"text\" :name \"q\"\n :sx-get \"/examples/api/search\"\n :sx-trigger \"keyup delay:300ms changed\"\n :sx-target \"#search-results\"\n :sx-swap \"innerHTML\"\n :placeholder \"Search...\")"
:handler-code "@bp.get(\"/examples/api/search\")\nasync def api_search():\n q = request.args.get(\"q\", \"\").lower()\n results = [l for l in LANGUAGES if q in l.lower()]\n return sx_response(\n f'(~search-results :items (...) :query \"{q}\")')"
:comp-placeholder-id "search-comp"
:wire-placeholder-id "search-wire"))
(defcomp ~example-inline-validation ()
(~example-page-content
:title "Inline Validation"
:description "Validate an email field on blur. The server checks format and whether it is taken, returning green or red feedback inline."
:demo-description "Enter an email and click away (blur) to validate."
:demo (~inline-validation-demo)
:sx-code "(input :type \"text\" :name \"email\"\n :sx-get \"/examples/api/validate\"\n :sx-trigger \"blur\"\n :sx-target \"#email-feedback\"\n :sx-swap \"innerHTML\"\n :placeholder \"user@example.com\")"
:handler-code "@bp.get(\"/examples/api/validate\")\nasync def api_validate():\n email = request.args.get(\"email\", \"\")\n if \"@\" not in email:\n return sx_response('(~validation-error ...)')\n return sx_response('(~validation-ok ...)')"
:comp-placeholder-id "validate-comp"
:wire-placeholder-id "validate-wire"))
(defcomp ~example-value-select ()
(~example-page-content
:title "Value Select"
:description "Two linked selects: pick a category and the second select updates with matching items via sx-get."
:demo-description "Select a category to populate the item dropdown."
:demo (~value-select-demo)
:sx-code "(select :name \"category\"\n :sx-get \"/examples/api/values\"\n :sx-trigger \"change\"\n :sx-target \"#value-items\"\n :sx-swap \"innerHTML\"\n (option \"Languages\")\n (option \"Frameworks\")\n (option \"Databases\"))"
:handler-code "@bp.get(\"/examples/api/values\")\nasync def api_values():\n cat = request.args.get(\"category\", \"\")\n items = VALUE_SELECT_DATA.get(cat, [])\n return sx_response(\n f'(~value-options :items (list ...))')"
:comp-placeholder-id "values-comp"
:wire-placeholder-id "values-wire"))
(defcomp ~example-reset-on-submit ()
(~example-page-content
:title "Reset on Submit"
:description "Use sx-on:afterSwap=\"this.reset()\" to clear form inputs after a successful submission."
:demo-description "Submit a message — the input resets after each send."
:demo (~reset-on-submit-demo)
:sx-code "(form :id \"reset-form\"\n :sx-post \"/examples/api/reset-submit\"\n :sx-target \"#reset-result\"\n :sx-swap \"innerHTML\"\n :sx-on:afterSwap \"this.reset()\"\n (input :type \"text\" :name \"message\")\n (button :type \"submit\" \"Send\"))"
:handler-code "@bp.post(\"/examples/api/reset-submit\")\nasync def api_reset_submit():\n form = await request.form\n msg = form.get(\"message\", \"\")\n return sx_response(\n f'(~reset-message :message \"{msg}\" :time \"...\")')"
:comp-placeholder-id "reset-comp"
:wire-placeholder-id "reset-wire"))
(defcomp ~example-edit-row ()
(~example-page-content
:title "Edit Row"
:description "Click edit to replace a table row with input fields. Save or cancel swaps back the display row. Uses sx-include to gather form values from the row."
:demo-description "Click edit on any row to modify it inline."
:demo (~edit-row-demo :rows (list
(list "1" "Widget A" "19.99" "142")
(list "2" "Widget B" "24.50" "89")
(list "3" "Widget C" "12.00" "305")
(list "4" "Widget D" "45.00" "67")))
:sx-code "(button\n :sx-get \"/examples/api/editrow/1\"\n :sx-target \"#erow-1\"\n :sx-swap \"outerHTML\"\n \"edit\")\n\n;; Save sends form data via POST\n(button\n :sx-post \"/examples/api/editrow/1\"\n :sx-target \"#erow-1\"\n :sx-swap \"outerHTML\"\n :sx-include \"#erow-1\"\n \"save\")"
:handler-code "@bp.get(\"/examples/api/editrow/<id>\")\nasync def api_editrow_form(id):\n row = EDIT_ROW_DATA[id]\n return sx_response(\n f'(~edit-row-form :id ... :name ...)')\n\n@bp.post(\"/examples/api/editrow/<id>\")\nasync def api_editrow_save(id):\n form = await request.form\n return sx_response(\n f'(~edit-row-view :id ... :name ...)')"
:comp-placeholder-id "editrow-comp"
:wire-placeholder-id "editrow-wire"))
(defcomp ~example-bulk-update ()
(~example-page-content
:title "Bulk Update"
:description "Select rows with checkboxes and use Activate/Deactivate buttons. sx-include gathers checkbox values from the form."
:demo-description "Check some rows, then click Activate or Deactivate."
:demo (~bulk-update-demo :users (list
(list "1" "Alice Chen" "alice@example.com" "active")
(list "2" "Bob Rivera" "bob@example.com" "inactive")
(list "3" "Carol Zhang" "carol@example.com" "active")
(list "4" "Dan Okafor" "dan@example.com" "inactive")
(list "5" "Eve Larsson" "eve@example.com" "active")))
:sx-code "(button\n :sx-post \"/examples/api/bulk?action=activate\"\n :sx-target \"#bulk-table\"\n :sx-swap \"innerHTML\"\n :sx-include \"#bulk-form\"\n \"Activate\")"
:handler-code "@bp.post(\"/examples/api/bulk\")\nasync def api_bulk():\n action = request.args.get(\"action\")\n form = await request.form\n ids = form.getlist(\"ids\")\n # Update matching users\n return sx_response(updated_rows)"
:comp-placeholder-id "bulk-comp"
:wire-placeholder-id "bulk-wire"))
(defcomp ~example-swap-positions ()
(~example-page-content
:title "Swap Positions"
:description "Demonstrates different swap modes: beforeend appends, afterbegin prepends, and none skips the main swap while still processing OOB updates."
:demo-description "Try each button to see different swap behaviours."
:demo (~swap-positions-demo)
:sx-code ";; Append to end\n(button :sx-post \"/api/swap-log?mode=beforeend\"\n :sx-target \"#swap-log\" :sx-swap \"beforeend\"\n \"Add to End\")\n\n;; Prepend to start\n(button :sx-post \"/api/swap-log?mode=afterbegin\"\n :sx-target \"#swap-log\" :sx-swap \"afterbegin\"\n \"Add to Start\")\n\n;; No swap — OOB counter update only\n(button :sx-post \"/api/swap-log?mode=none\"\n :sx-target \"#swap-log\" :sx-swap \"none\"\n \"Silent Ping\")"
:handler-code "@bp.post(\"/examples/api/swap-log\")\nasync def api_swap_log():\n mode = request.args.get(\"mode\")\n # OOB counter updates on every request\n oob = f'(span :id \"swap-counter\" :sx-swap-oob \"innerHTML\" \"Count: {n}\")'\n return sx_response(entry + oob)"
:wire-placeholder-id "swap-wire"))
(defcomp ~example-select-filter ()
(~example-page-content
:title "Select Filter"
:description "sx-select lets the client pick a specific section from the server response by CSS selector. The server always returns the full dashboard — the client filters."
:demo-description "Different buttons select different parts of the same server response."
:demo (~select-filter-demo)
:sx-code ";; Pick just the stats section from the response\n(button\n :sx-get \"/examples/api/dashboard\"\n :sx-target \"#filter-target\"\n :sx-swap \"innerHTML\"\n :sx-select \"#dash-stats\"\n \"Stats Only\")\n\n;; No sx-select — get the full response\n(button\n :sx-get \"/examples/api/dashboard\"\n :sx-target \"#filter-target\"\n :sx-swap \"innerHTML\"\n \"Full Dashboard\")"
:handler-code "@bp.get(\"/examples/api/dashboard\")\nasync def api_dashboard():\n # Returns header + stats + footer\n # Client uses sx-select to pick sections\n return sx_response(\n '(<> (div :id \"dash-header\" ...) '\n ' (div :id \"dash-stats\" ...) '\n ' (div :id \"dash-footer\" ...))')"
:wire-placeholder-id "filter-wire"))
(defcomp ~example-tabs ()
(~example-page-content
:title "Tabs"
:description "Tab navigation using sx-push-url to update the browser URL. Back/forward buttons navigate between previously visited tabs."
:demo-description "Click tabs to switch content. Watch the browser URL change."
:demo (~tabs-demo)
:sx-code "(button\n :sx-get \"/examples/api/tabs/tab1\"\n :sx-target \"#tab-content\"\n :sx-swap \"innerHTML\"\n :sx-push-url \"/examples/tabs?tab=tab1\"\n \"Overview\")"
:handler-code "@bp.get(\"/examples/api/tabs/<tab>\")\nasync def api_tabs(tab: str):\n content = TAB_CONTENT[tab]\n return sx_response(content)"
:wire-placeholder-id "tabs-wire"))
(defcomp ~example-animations ()
(~example-page-content
:title "Animations"
:description "CSS animations play on swap. The component injects a style tag with a keyframe animation and applies the class. Each click picks a random background colour."
:demo-description "Click to swap in content with a fade-in animation."
:demo (~animations-demo)
:sx-code "(button\n :sx-get \"/examples/api/animate\"\n :sx-target \"#anim-target\"\n :sx-swap \"innerHTML\"\n \"Load with animation\")\n\n;; Component uses CSS animation class\n(defcomp ~anim-result (&key color time)\n (div :class \"sx-fade-in ...\"\n (style \".sx-fade-in { animation: sxFadeIn 0.5s }\")\n (p \"Faded in!\")))"
:handler-code "@bp.get(\"/examples/api/animate\")\nasync def api_animate():\n colors = [\"bg-violet-100\", \"bg-emerald-100\", ...]\n color = random.choice(colors)\n return sx_response(\n f'(~anim-result :color \"{color}\" :time \"{now}\")')"
:comp-placeholder-id "anim-comp"
:wire-placeholder-id "anim-wire"))
(defcomp ~example-dialogs ()
(~example-page-content
:title "Dialogs"
:description "Open a modal dialog by swapping in the dialog component. Close by swapping in empty content. Pure sx — no JavaScript library needed."
:demo-description "Click to open a modal dialog."
:demo (~dialogs-demo)
:sx-code "(button\n :sx-get \"/examples/api/dialog\"\n :sx-target \"#dialog-container\"\n :sx-swap \"innerHTML\"\n \"Open Dialog\")\n\n;; Dialog closes by swapping empty content\n(button\n :sx-get \"/examples/api/dialog/close\"\n :sx-target \"#dialog-container\"\n :sx-swap \"innerHTML\"\n \"Close\")"
:handler-code "@bp.get(\"/examples/api/dialog\")\nasync def api_dialog():\n return sx_response(\n '(~dialog-modal :title \"Confirm\"'\n ' :message \"Are you sure?\")')\n\n@bp.get(\"/examples/api/dialog/close\")\nasync def api_dialog_close():\n return sx_response(\"\")"
:comp-placeholder-id "dialog-comp"
:wire-placeholder-id "dialog-wire"))
(defcomp ~example-keyboard-shortcuts ()
(~example-page-content
:title "Keyboard Shortcuts"
:description "Use sx-trigger with keyup event filters and from:body to listen for global keyboard shortcuts. The filter prevents firing when typing in inputs."
:demo-description "Press s, n, or h on your keyboard."
:demo (~keyboard-shortcuts-demo)
:sx-code "(div :id \"kbd-target\"\n :sx-get \"/examples/api/keyboard?key=s\"\n :sx-trigger \"keyup[key=='s'&&!event.target.matches('input,textarea')] from:body\"\n :sx-swap \"innerHTML\"\n \"Press a shortcut key...\")"
:handler-code "@bp.get(\"/examples/api/keyboard\")\nasync def api_keyboard():\n key = request.args.get(\"key\", \"\")\n actions = {\"s\": \"Search\", \"n\": \"New item\", \"h\": \"Help\"}\n return sx_response(\n f'(~kbd-result :key \"{key}\" :action \"{actions[key]}\")')"
:comp-placeholder-id "kbd-comp"
:wire-placeholder-id "kbd-wire"))
(defcomp ~example-put-patch ()
(~example-page-content
:title "PUT / PATCH"
:description "sx-put replaces the entire resource. This example shows a profile card with an Edit All button that sends a PUT with all fields."
:demo-description "Click Edit All to replace the full profile via PUT."
:demo (~put-patch-demo :name "Ada Lovelace" :email "ada@example.com" :role "Engineer")
:sx-code ";; Replace entire resource\n(form :sx-put \"/examples/api/putpatch\"\n :sx-target \"#pp-target\" :sx-swap \"innerHTML\"\n (input :name \"name\") (input :name \"email\")\n (button \"Save All (PUT)\"))"
:handler-code "@bp.put(\"/examples/api/putpatch\")\nasync def api_put():\n form = await request.form\n # Full replacement\n return sx_response('(~pp-view ...)')"
:comp-placeholder-id "pp-comp"
:wire-placeholder-id "pp-wire"))
(defcomp ~example-json-encoding ()
(~example-page-content
:title "JSON Encoding"
:description "Use sx-encoding=\"json\" to send form data as a JSON body instead of URL-encoded form data. The server echoes back what it received."
:demo-description "Submit the form and see the JSON body the server received."
:demo (~json-encoding-demo)
:sx-code "(form\n :sx-post \"/examples/api/json-echo\"\n :sx-target \"#json-result\"\n :sx-swap \"innerHTML\"\n :sx-encoding \"json\"\n (input :name \"name\" :value \"Ada\")\n (input :type \"number\" :name \"age\" :value \"36\")\n (button \"Submit as JSON\"))"
:handler-code "@bp.post(\"/examples/api/json-echo\")\nasync def api_json_echo():\n data = await request.get_json()\n body = json.dumps(data, indent=2)\n ct = request.content_type\n return sx_response(\n f'(~json-result :body \"{body}\" :content-type \"{ct}\")')"
:comp-placeholder-id "json-comp"
:wire-placeholder-id "json-wire"))
(defcomp ~example-vals-and-headers ()
(~example-page-content
:title "Vals & Headers"
: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 (~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\")"
: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"
:wire-placeholder-id "vals-wire"))
(defcomp ~example-loading-states ()
(~example-page-content
:title "Loading States"
:description "sx.js adds the .sx-request CSS class to any element that has an active request. Use pure CSS to show spinners, disable buttons, or change opacity during loading."
:demo-description "Click the button — it shows a spinner during the 2-second request."
:demo (~loading-states-demo)
:sx-code ";; .sx-request class added during request\n(style \".sx-loading-btn.sx-request {\n opacity: 0.7; pointer-events: none; }\n.sx-loading-btn.sx-request .sx-spinner {\n display: inline-block; }\n.sx-loading-btn .sx-spinner {\n display: none; }\")\n\n(button :class \"sx-loading-btn\"\n :sx-get \"/examples/api/slow\"\n :sx-target \"#loading-result\"\n (span :class \"sx-spinner animate-spin\" \"...\")\n \"Load slow endpoint\")"
:handler-code "@bp.get(\"/examples/api/slow\")\nasync def api_slow():\n await asyncio.sleep(2)\n return sx_response(\n f'(~loading-result :time \"{now}\")')"
:comp-placeholder-id "loading-comp"
:wire-placeholder-id "loading-wire"))
(defcomp ~example-sync-replace ()
(~example-page-content
:title "Request Abort"
:description "sx-sync=\"replace\" aborts any in-flight request before sending a new one. This prevents stale responses from overwriting newer ones, even with random server delays."
:demo-description "Type quickly — only the latest result appears despite random 0.5-2s server delays."
:demo (~sync-replace-demo)
:sx-code "(input :type \"text\" :name \"q\"\n :sx-get \"/examples/api/slow-search\"\n :sx-trigger \"keyup delay:200ms changed\"\n :sx-target \"#sync-result\"\n :sx-swap \"innerHTML\"\n :sx-sync \"replace\"\n \"Type to search...\")"
:handler-code "@bp.get(\"/examples/api/slow-search\")\nasync def api_slow_search():\n delay = random.uniform(0.5, 2.0)\n await asyncio.sleep(delay)\n q = request.args.get(\"q\", \"\")\n return sx_response(\n f'(~sync-result :query \"{q}\" :delay \"{delay_ms}\")')"
:comp-placeholder-id "sync-comp"
:wire-placeholder-id "sync-wire"))
(defcomp ~example-retry ()
(~example-page-content
:title "Retry"
:description "sx-retry=\"exponential:1000:8000\" retries failed requests with exponential backoff starting at 1s up to 8s. The endpoint fails the first 2 attempts and succeeds on the 3rd."
:demo-description "Click the button — watch it retry automatically after failures."
:demo (~retry-demo)
:sx-code "(button\n :sx-get \"/examples/api/flaky\"\n :sx-target \"#retry-result\"\n :sx-swap \"innerHTML\"\n :sx-retry \"exponential:1000:8000\"\n \"Call flaky endpoint\")"
:handler-code "@bp.get(\"/examples/api/flaky\")\nasync def api_flaky():\n _flaky[\"n\"] += 1\n if _flaky[\"n\"] % 3 != 0:\n return Response(\"\", status=503)\n return sx_response(\n f'(~retry-result :attempt {n} ...)')"
:comp-placeholder-id "retry-comp"
:wire-placeholder-id "retry-wire"))

58
sx/sx/examples.sx Normal file
View File

@@ -0,0 +1,58 @@
;; Example page template and reference index
;; Template receives data values (code strings, titles), calls highlight internally.
(defcomp ~example-page-content (&key title description demo-description demo
sx-code sx-lang handler-code handler-lang
comp-placeholder-id wire-placeholder-id wire-note
comp-heading handler-heading)
(~doc-page :title title
(p :class "text-stone-600 mb-6" description)
(~example-card :title "Demo" :description demo-description
(~example-demo demo))
(h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")
(~example-source :code (highlight sx-code (if sx-lang sx-lang "lisp")))
(when comp-placeholder-id
(<>
(h3 :class "text-lg font-semibold text-stone-700 mt-6"
(if comp-heading comp-heading "Component"))
(~doc-placeholder :id comp-placeholder-id)))
(h3 :class "text-lg font-semibold text-stone-700 mt-6"
(if handler-heading handler-heading "Server handler"))
(~example-source :code (highlight handler-code (if handler-lang handler-lang "python")))
(div :class "flex items-center justify-between mt-6"
(h3 :class "text-lg font-semibold text-stone-700" "Wire response")
(~doc-clear-cache-btn))
(when wire-note
(p :class "text-stone-500 text-sm mb-2" wire-note))
(when wire-placeholder-id
(~doc-placeholder :id wire-placeholder-id))))
(defcomp ~reference-index-content ()
(~doc-page :title "Reference"
(p :class "text-stone-600 mb-6"
"Complete reference for the sx client library.")
(div :class "grid gap-4 sm:grid-cols-2"
(a :href "/reference/attributes"
:sx-get "/reference/attributes" :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
:class "block p-5 rounded-lg border border-stone-200 hover:border-violet-300 hover:shadow-sm transition-all no-underline"
(h3 :class "text-lg font-semibold text-violet-700 mb-1" "Attributes")
(p :class "text-stone-600 text-sm" "All sx attributes — request verbs, behavior modifiers, and sx-unique features."))
(a :href "/reference/headers"
:sx-get "/reference/headers" :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
:class "block p-5 rounded-lg border border-stone-200 hover:border-violet-300 hover:shadow-sm transition-all no-underline"
(h3 :class "text-lg font-semibold text-violet-700 mb-1" "Headers")
(p :class "text-stone-600 text-sm" "Custom HTTP headers used to coordinate between the sx client and server."))
(a :href "/reference/events"
:sx-get "/reference/events" :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
:class "block p-5 rounded-lg border border-stone-200 hover:border-violet-300 hover:shadow-sm transition-all no-underline"
(h3 :class "text-lg font-semibold text-violet-700 mb-1" "Events")
(p :class "text-stone-600 text-sm" "DOM events fired during the sx request lifecycle."))
(a :href "/reference/js-api"
:sx-get "/reference/js-api" :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
:class "block p-5 rounded-lg border border-stone-200 hover:border-violet-300 hover:shadow-sm transition-all no-underline"
(h3 :class "text-lg font-semibold text-violet-700 mb-1" "JS API")
(p :class "text-stone-600 text-sm" "JavaScript functions for parsing, evaluating, and rendering s-expressions.")))))

View File

@@ -1,17 +1,93 @@
;; SX docs layout defcomps — root header via ~root-header-auto,
;; sx-specific headers passed as &key params.
;; SX docs layout defcomps — fully self-contained via IO primitives.
;; Registered via register_sx_layout in __init__.py.
;; --- SX home layout: root + sx menu row ---
;; --- Main nav defcomp: static nav items from MAIN_NAV ---
;; @css aria-selected:bg-violet-200 aria-selected:text-violet-900
(defcomp ~sx-layout-full (&key sx-row)
(defcomp ~sx-main-nav (&key section)
(let* ((sc "aria-selected:bg-violet-200 aria-selected:text-violet-900")
(items (list
(dict :label "Docs" :href "/docs/introduction")
(dict :label "Reference" :href "/reference/")
(dict :label "Protocols" :href "/protocols/wire-format")
(dict :label "Examples" :href "/examples/click-to-load")
(dict :label "Essays" :href "/essays/sx-sucks"))))
(<> (map (lambda (item)
(~nav-link
:href (get item "href")
:label (get item "label")
:is-selected (when (= (get item "label") section) "true")
:select-colours sc))
items))))
;; --- SX header row ---
(defcomp ~sx-header-row (&key nav child oob)
(~menu-row-sx :id "sx-row" :level 1 :colour "violet"
:link-href "/" :link-label "sx"
:link-label-content (~sx-docs-label)
:nav nav
:child-id "sx-header-child"
:child child
:oob oob))
;; --- Sub-row for section pages ---
(defcomp ~sx-sub-row (&key sub-label sub-href sub-nav selected oob)
(~menu-row-sx :id "sx-sub-row" :level 2 :colour "violet"
:link-href sub-href :link-label sub-label
:selected selected
:nav sub-nav
:oob oob))
;; ---------------------------------------------------------------------------
;; SX home layout (root + sx header)
;; ---------------------------------------------------------------------------
(defcomp ~sx-layout-full (&key section)
(<> (~root-header-auto)
sx-row))
(~sx-header-row :nav (~sx-main-nav :section section))))
(defcomp ~sx-layout-oob (&key root-header sx-row)
(<> root-header sx-row))
(defcomp ~sx-layout-oob (&key section)
(<> (~sx-header-row
:nav (~sx-main-nav :section section)
:oob true)
(~clear-oob-div :id "sx-header-child")
(~root-header-auto true)))
;; --- SX section layout: root + sx row (with child sub-row) ---
(defcomp ~sx-layout-mobile (&key section)
(<> (~mobile-menu-section
:label "sx" :href "/" :level 1 :colour "violet"
:items (~sx-main-nav :section section))
(~root-mobile-auto)))
(defcomp ~sx-section-layout-full (&key sx-row)
;; ---------------------------------------------------------------------------
;; SX section layout (root + sx header + sub-row)
;; ---------------------------------------------------------------------------
(defcomp ~sx-section-layout-full (&key section sub-label sub-href sub-nav selected)
(<> (~root-header-auto)
sx-row))
(~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-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)
(~root-header-auto true)))
(defcomp ~sx-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))
(~root-mobile-auto)))

87
sx/sx/nav-data.sx Normal file
View File

@@ -0,0 +1,87 @@
;; Navigation data and section-nav component for sx docs.
;; Replaces Python nav tuples from content/pages.py and _nav_items_sx() from utils.py.
;; @css aria-selected:bg-violet-200 aria-selected:text-violet-900
(define docs-nav-items (list
(dict :label "Introduction" :href "/docs/introduction")
(dict :label "Getting Started" :href "/docs/getting-started")
(dict :label "Components" :href "/docs/components")
(dict :label "Evaluator" :href "/docs/evaluator")
(dict :label "Primitives" :href "/docs/primitives")
(dict :label "CSS" :href "/docs/css")
(dict :label "Server Rendering" :href "/docs/server-rendering")))
(define reference-nav-items (list
(dict :label "Attributes" :href "/reference/attributes")
(dict :label "Headers" :href "/reference/headers")
(dict :label "Events" :href "/reference/events")
(dict :label "JS API" :href "/reference/js-api")))
(define protocols-nav-items (list
(dict :label "Wire Format" :href "/protocols/wire-format")
(dict :label "Fragments" :href "/protocols/fragments")
(dict :label "Resolver I/O" :href "/protocols/resolver-io")
(dict :label "Internal Services" :href "/protocols/internal-services")
(dict :label "ActivityPub" :href "/protocols/activitypub")
(dict :label "Future" :href "/protocols/future")))
(define examples-nav-items (list
(dict :label "Click to Load" :href "/examples/click-to-load")
(dict :label "Form Submission" :href "/examples/form-submission")
(dict :label "Polling" :href "/examples/polling")
(dict :label "Delete Row" :href "/examples/delete-row")
(dict :label "Inline Edit" :href "/examples/inline-edit")
(dict :label "OOB Swaps" :href "/examples/oob-swaps")
(dict :label "Lazy Loading" :href "/examples/lazy-loading")
(dict :label "Infinite Scroll" :href "/examples/infinite-scroll")
(dict :label "Progress Bar" :href "/examples/progress-bar")
(dict :label "Active Search" :href "/examples/active-search")
(dict :label "Inline Validation" :href "/examples/inline-validation")
(dict :label "Value Select" :href "/examples/value-select")
(dict :label "Reset on Submit" :href "/examples/reset-on-submit")
(dict :label "Edit Row" :href "/examples/edit-row")
(dict :label "Bulk Update" :href "/examples/bulk-update")
(dict :label "Swap Positions" :href "/examples/swap-positions")
(dict :label "Select Filter" :href "/examples/select-filter")
(dict :label "Tabs" :href "/examples/tabs")
(dict :label "Animations" :href "/examples/animations")
(dict :label "Dialogs" :href "/examples/dialogs")
(dict :label "Keyboard Shortcuts" :href "/examples/keyboard-shortcuts")
(dict :label "PUT / PATCH" :href "/examples/put-patch")
(dict :label "JSON Encoding" :href "/examples/json-encoding")
(dict :label "Vals & Headers" :href "/examples/vals-and-headers")
(dict :label "Loading States" :href "/examples/loading-states")
(dict :label "Request Abort" :href "/examples/sync-replace")
(dict :label "Retry" :href "/examples/retry")))
(define essays-nav-items (list
(dict :label "sx sucks" :href "/essays/sx-sucks")
(dict :label "Why S-Expressions" :href "/essays/why-sexps")
(dict :label "The htmx/React Hybrid" :href "/essays/htmx-react-hybrid")
(dict :label "On-Demand CSS" :href "/essays/on-demand-css")
(dict :label "Client Reactivity" :href "/essays/client-reactivity")
(dict :label "SX Native" :href "/essays/sx-native")
(dict :label "The SX Manifesto" :href "/essays/sx-manifesto")
(dict :label "Tail-Call Optimization" :href "/essays/tail-call-optimization")
(dict :label "Continuations" :href "/essays/continuations")))
;; Find the current nav label for a slug by matching href suffix.
;; Returns the label string or nil if no match.
(define find-current
(fn (items slug)
(when slug
(some (fn (item)
(when (ends-with? (get item "href") slug)
(get item "label")))
items))))
;; Generic section nav — builds nav links from a list of items.
;; Replaces _nav_items_sx() and all section-specific nav builders in utils.py.
(defcomp ~section-nav (&key items current)
(<> (map (fn (item)
(~nav-link
:href (get item "href")
:label (get item "label")
:is-selected (when (= (get item "label") current) "true")
:select-colours "aria-selected:bg-violet-200 aria-selected:text-violet-900"))
items)))

98
sx/sx/protocols.sx Normal file
View File

@@ -0,0 +1,98 @@
;; Protocol documentation pages — fully self-contained
(defcomp ~protocol-wire-format-content ()
(~doc-page :title "Wire Format"
(~doc-section :title "The text/sx content type" :id "content-type"
(p :class "text-stone-600"
"sx responses use content type text/sx. The body is s-expression source code. The client parses and evaluates it, then renders the result into the DOM.")
(~doc-code :code (highlight "HTTP/1.1 200 OK\nContent-Type: text/sx\nSX-Css-Hash: a1b2c3d4\n\n(div :class \"p-4\"\n (~card :title \"Hello\"))" "bash")))
(~doc-section :title "Request lifecycle" :id "lifecycle"
(p :class "text-stone-600"
"1. User interacts with an element that has sx-get/sx-post/etc.")
(p :class "text-stone-600"
"2. sx.js fires sx:beforeRequest, then sends the HTTP request with SX-Request: true header.")
(p :class "text-stone-600"
"3. Server builds s-expression tree, scans CSS classes, prepends missing component definitions.")
(p :class "text-stone-600"
"4. Client receives text/sx response, parses it, evaluates it, renders to DOM.")
(p :class "text-stone-600"
"5. sx.js fires sx:afterSwap and sx:afterSettle.")
(p :class "text-stone-600"
"6. Any sx-swap-oob elements are swapped into their targets elsewhere in the DOM."))
(~doc-section :title "Component definitions" :id "components"
(p :class "text-stone-600"
"On full page loads, component definitions are in <script type=\"text/sx\" data-components>. On subsequent sx requests, missing definitions are prepended to the response body. The client caches definitions in localStorage keyed by a content hash."))))
(defcomp ~protocol-fragments-content ()
(~doc-page :title "Cross-Service Fragments"
(~doc-section :title "Fragment protocol" :id "protocol"
(p :class "text-stone-600"
"Rose Ash runs as independent microservices. Each service can expose HTML or sx fragments that other services compose into their pages. Fragment endpoints return text/sx or text/html.")
(p :class "text-stone-600"
"The frag resolver is an I/O primitive in the render tree:")
(~doc-code :code (highlight "(frag \"blog\" \"link-card\" :slug \"hello\")" "lisp")))
(~doc-section :title "SxExpr wrapping" :id "wrapping"
(p :class "text-stone-600"
"When a fragment returns text/sx, the response is wrapped in an SxExpr and embedded directly in the render tree. When it returns text/html, it's wrapped in a ~rich-text component that inserts the HTML via raw!. This allows transparent composition across service boundaries."))
(~doc-section :title "fetch_fragments()" :id "fetch"
(p :class "text-stone-600"
"The Python helper fetch_fragments() fetches multiple fragments in parallel via asyncio.gather(). Fragments are cached in Redis with short TTLs. Each fragment request is HMAC-signed for authentication."))))
(defcomp ~protocol-resolver-io-content ()
(~doc-page :title "Resolver I/O"
(~doc-section :title "Async I/O primitives" :id "primitives"
(p :class "text-stone-600"
"The sx resolver identifies I/O nodes in the render tree, groups them, executes them in parallel via asyncio.gather(), and substitutes results back in.")
(p :class "text-stone-600"
"I/O primitives:")
(ul :class "space-y-2 text-stone-600"
(li (span :class "font-mono text-violet-700" "frag") " — fetch a cross-service fragment")
(li (span :class "font-mono text-violet-700" "query") " — read data from another service")
(li (span :class "font-mono text-violet-700" "action") " — execute a write on another service")
(li (span :class "font-mono text-violet-700" "current-user") " — resolve the current authenticated user")))
(~doc-section :title "Execution model" :id "execution"
(p :class "text-stone-600"
"The render tree is walked to find I/O nodes. All nodes at the same depth are gathered and executed in parallel. Results replace the I/O nodes in the tree. The walk continues until no more I/O nodes are found. This typically completes in 1-2 passes."))))
(defcomp ~protocol-internal-services-content ()
(~doc-page :title "Internal Services"
(~doc-note
(p "Honest note: the internal service protocol is JSON, not sx. Sx is the composition layer on top. The protocols below are the plumbing underneath."))
(~doc-section :title "HMAC-signed HTTP" :id "hmac"
(p :class "text-stone-600"
"Services communicate via HMAC-signed HTTP requests with short timeouts:")
(ul :class "space-y-2 text-stone-600 font-mono text-sm"
(li "GET /internal/data/{query} — read data (3s timeout)")
(li "POST /internal/actions/{action} — execute write (5s timeout)")
(li "POST /internal/inbox — ActivityPub-shaped event delivery")))
(~doc-section :title "fetch_data / call_action" :id "fetch"
(p :class "text-stone-600"
"Python helpers fetch_data() and call_action() handle HMAC signing, serialization, and error handling. They resolve service URLs from environment variables (INTERNAL_URL_BLOG, etc) and fall back to public URLs in development."))))
(defcomp ~protocol-activitypub-content ()
(~doc-page :title "ActivityPub"
(~doc-note
(p "Honest note: ActivityPub wire format is JSON-LD, not sx. This documents how AP integrates with the sx rendering layer."))
(~doc-section :title "AP activities" :id "activities"
(p :class "text-stone-600"
"Rose Ash services communicate cross-domain writes via ActivityPub-shaped activities. Each service has a virtual actor. Activities are JSON-LD objects sent to /internal/inbox endpoints. RSA signatures authenticate the sender."))
(~doc-section :title "Event bus" :id "bus"
(p :class "text-stone-600"
"The event bus dispatches activities to registered handlers. Handlers are async functions that process the activity and may trigger side effects. The bus runs as a background processor in each service."))))
(defcomp ~protocol-future-content ()
(~doc-page :title "Future Possibilities"
(~doc-note
(p "This page is speculative. Nothing here is implemented. It documents ideas that may or may not happen."))
(~doc-section :title "Custom protocol schemes" :id "schemes"
(p :class "text-stone-600"
"sx:// and sxs:// as custom URI schemes for content addressing or deep linking. An sx:// URI could resolve to an sx expression from a federated registry. This is technically feasible but practically unnecessary for a single-site deployment."))
(~doc-section :title "Sx as AP serialization" :id "ap-sx"
(p :class "text-stone-600"
"ActivityPub objects could be serialized as s-expressions instead of JSON-LD. S-expressions are more compact and easier to parse. The practical barrier: the entire AP ecosystem expects JSON-LD."))
(~doc-section :title "Sx-native federation" :id "federation"
(p :class "text-stone-600"
"Federated services could exchange sx fragments directly — render a remote user's profile card by fetching its sx source from their server. This requires trust and standardization that doesn't exist yet."))
(~doc-section :title "Realistic assessment" :id "realistic"
(p :class "text-stone-600"
"The most likely near-term improvement is sx:// deep linking for client-side component resolution. Everything else requires ecosystem adoption that one project can't drive alone."))))

51
sx/sx/reference.sx Normal file
View File

@@ -0,0 +1,51 @@
;; Reference page layouts — receive data from Python primitives
(defcomp ~reference-attrs-content (&key req-table beh-table uniq-table)
(~doc-page :title "Attribute Reference"
(p :class "text-stone-600 mb-6"
"sx attributes mirror htmx where possible. This table shows all available attributes and their status.")
(div :class "space-y-8"
req-table
beh-table
uniq-table)))
(defcomp ~reference-headers-content (&key req-table resp-table)
(~doc-page :title "Headers"
(p :class "text-stone-600 mb-6"
"sx uses custom HTTP headers to coordinate between client and server.")
(div :class "space-y-8"
req-table
resp-table)))
(defcomp ~reference-events-content (&key table)
(~doc-page :title "Events"
table))
(defcomp ~reference-js-api-content (&key table)
(~doc-page :title "JavaScript API"
table))
(defcomp ~reference-attr-detail-content (&key title description demo
example-code handler-code wire-placeholder-id)
(~doc-page :title title
(p :class "text-stone-600 mb-6" description)
(when demo
(~example-card :title "Demo"
(~example-demo demo)))
(h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")
(~example-source :code (highlight example-code "lisp"))
(when handler-code
(<>
(h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")
(~example-source :code (highlight handler-code "lisp"))))
(when wire-placeholder-id
(<>
(h3 :class "text-lg font-semibold text-stone-700 mt-6" "Wire response")
(p :class "text-stone-500 text-sm mb-2"
"Trigger the demo to see the raw response the server sends.")
(~doc-placeholder :id wire-placeholder-id)))))
(defcomp ~reference-attr-not-found (&key slug)
(~doc-page :title "Not Found"
(p :class "text-stone-600"
(str "No documentation found for \"" slug "\"."))))

View File

@@ -705,7 +705,7 @@
:sx-get "/examples/api/echo-headers"
:sx-target "#headers-result"
: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"
"Send with headers")
(div :id "headers-result" :class "p-3 rounded bg-stone-50 text-sm text-stone-400"

View File

@@ -1,5 +1,6 @@
;; SX docs app — declarative page definitions
;; These replace the GET route handlers in routes.py
;; All content dispatched via case + direct component references.
;; Navigation built from SX data (nav-data.sx), no Python intermediaries.
;; ---------------------------------------------------------------------------
;; Home page
@@ -9,7 +10,7 @@
:path "/"
:auth :public
:layout :sx
:content (home-content))
:content (~sx-home-content))
;; ---------------------------------------------------------------------------
;; Docs section
@@ -22,9 +23,9 @@
:section "Docs"
:sub-label "Docs"
:sub-href "/docs/introduction"
:sub-nav (docs-nav "Introduction")
:sub-nav (~section-nav :items docs-nav-items :current "Introduction")
:selected "Introduction")
:content (docs-content "introduction"))
:content (~docs-introduction-content))
(defpage docs-page
:path "/docs/<slug>"
@@ -33,9 +34,19 @@
:section "Docs"
:sub-label "Docs"
:sub-href "/docs/introduction"
:sub-nav (docs-nav (find-current DOCS_NAV slug))
:selected (or (find-current DOCS_NAV slug) ""))
:content (docs-content slug))
:sub-nav (~section-nav :items docs-nav-items
:current (find-current docs-nav-items slug))
:selected (or (find-current docs-nav-items slug) ""))
:content (case slug
"introduction" (~docs-introduction-content)
"getting-started" (~docs-getting-started-content)
"components" (~docs-components-content)
"evaluator" (~docs-evaluator-content)
"primitives" (~docs-primitives-content
:prims (~doc-primitives-tables :primitives (primitives-data)))
"css" (~docs-css-content)
"server-rendering" (~docs-server-rendering-content)
:else (~docs-introduction-content)))
;; ---------------------------------------------------------------------------
;; Reference section
@@ -48,9 +59,9 @@
:section "Reference"
:sub-label "Reference"
:sub-href "/reference/"
:sub-nav (reference-nav "")
:sub-nav (~section-nav :items reference-nav-items :current "")
:selected "")
:content (reference-index-content))
:content (~reference-index-content))
(defpage reference-page
:path "/reference/<slug>"
@@ -59,9 +70,30 @@
:section "Reference"
:sub-label "Reference"
:sub-href "/reference/"
:sub-nav (reference-nav (find-current REFERENCE_NAV slug))
:selected (or (find-current REFERENCE_NAV slug) ""))
:content (reference-content slug))
:sub-nav (~section-nav :items reference-nav-items
:current (find-current reference-nav-items slug))
:selected (or (find-current reference-nav-items slug) ""))
:data (reference-data slug)
:content (case slug
"attributes" (~reference-attrs-content
:req-table (~doc-attr-table-from-data :title "Request Attributes" :attrs req-attrs)
:beh-table (~doc-attr-table-from-data :title "Behavior Attributes" :attrs beh-attrs)
:uniq-table (~doc-attr-table-from-data :title "Unique to sx" :attrs uniq-attrs))
"headers" (~reference-headers-content
:req-table (~doc-headers-table-from-data :title "Request Headers" :headers req-headers)
:resp-table (~doc-headers-table-from-data :title "Response Headers" :headers resp-headers))
"events" (~reference-events-content
:table (~doc-two-col-table-from-data
:intro "sx fires custom DOM events at various points in the request lifecycle."
:col1 "Event" :col2 "Description" :items events-list))
"js-api" (~reference-js-api-content
:table (~doc-two-col-table-from-data
:intro "The client-side sx.js library exposes a public API for programmatic use."
:col1 "Method" :col2 "Description" :items js-api-list))
:else (~reference-attrs-content
:req-table (~doc-attr-table-from-data :title "Request Attributes" :attrs req-attrs)
:beh-table (~doc-attr-table-from-data :title "Behavior Attributes" :attrs beh-attrs)
:uniq-table (~doc-attr-table-from-data :title "Unique to sx" :attrs uniq-attrs))))
(defpage reference-attr-detail
:path "/reference/attributes/<slug>"
@@ -70,9 +102,18 @@
:section "Reference"
:sub-label "Reference"
:sub-href "/reference/"
:sub-nav (reference-nav "Attributes")
:sub-nav (~section-nav :items reference-nav-items :current "Attributes")
:selected "Attributes")
:content (reference-attr-detail slug))
:data (attr-detail-data slug)
:content (if attr-not-found
(~reference-attr-not-found :slug slug)
(~reference-attr-detail-content
:title attr-title
:description attr-description
:demo attr-demo
:example-code attr-example
:handler-code attr-handler
:wire-placeholder-id attr-wire-id)))
;; ---------------------------------------------------------------------------
;; Protocols section
@@ -85,9 +126,9 @@
:section "Protocols"
:sub-label "Protocols"
:sub-href "/protocols/wire-format"
:sub-nav (protocols-nav "Wire Format")
:sub-nav (~section-nav :items protocols-nav-items :current "Wire Format")
:selected "Wire Format")
:content (protocol-content "wire-format"))
:content (~protocol-wire-format-content))
(defpage protocol-page
:path "/protocols/<slug>"
@@ -96,9 +137,17 @@
:section "Protocols"
:sub-label "Protocols"
:sub-href "/protocols/wire-format"
:sub-nav (protocols-nav (find-current PROTOCOLS_NAV slug))
:selected (or (find-current PROTOCOLS_NAV slug) ""))
:content (protocol-content slug))
:sub-nav (~section-nav :items protocols-nav-items
:current (find-current protocols-nav-items slug))
:selected (or (find-current protocols-nav-items slug) ""))
:content (case slug
"wire-format" (~protocol-wire-format-content)
"fragments" (~protocol-fragments-content)
"resolver-io" (~protocol-resolver-io-content)
"internal-services" (~protocol-internal-services-content)
"activitypub" (~protocol-activitypub-content)
"future" (~protocol-future-content)
:else (~protocol-wire-format-content)))
;; ---------------------------------------------------------------------------
;; Examples section
@@ -111,9 +160,9 @@
:section "Examples"
:sub-label "Examples"
:sub-href "/examples/click-to-load"
:sub-nav (examples-nav "Click to Load")
:sub-nav (~section-nav :items examples-nav-items :current "Click to Load")
:selected "Click to Load")
:content (examples-content "click-to-load"))
:content (~example-click-to-load))
(defpage examples-page
:path "/examples/<slug>"
@@ -122,9 +171,38 @@
:section "Examples"
:sub-label "Examples"
:sub-href "/examples/click-to-load"
:sub-nav (examples-nav (find-current EXAMPLES_NAV slug))
:selected (or (find-current EXAMPLES_NAV slug) ""))
:content (examples-content slug))
:sub-nav (~section-nav :items examples-nav-items
:current (find-current examples-nav-items slug))
:selected (or (find-current examples-nav-items slug) ""))
:content (case slug
"click-to-load" (~example-click-to-load)
"form-submission" (~example-form-submission)
"polling" (~example-polling)
"delete-row" (~example-delete-row)
"inline-edit" (~example-inline-edit)
"oob-swaps" (~example-oob-swaps)
"lazy-loading" (~example-lazy-loading)
"infinite-scroll" (~example-infinite-scroll)
"progress-bar" (~example-progress-bar)
"active-search" (~example-active-search)
"inline-validation" (~example-inline-validation)
"value-select" (~example-value-select)
"reset-on-submit" (~example-reset-on-submit)
"edit-row" (~example-edit-row)
"bulk-update" (~example-bulk-update)
"swap-positions" (~example-swap-positions)
"select-filter" (~example-select-filter)
"tabs" (~example-tabs)
"animations" (~example-animations)
"dialogs" (~example-dialogs)
"keyboard-shortcuts" (~example-keyboard-shortcuts)
"put-patch" (~example-put-patch)
"json-encoding" (~example-json-encoding)
"vals-and-headers" (~example-vals-and-headers)
"loading-states" (~example-loading-states)
"sync-replace" (~example-sync-replace)
"retry" (~example-retry)
:else (~example-click-to-load)))
;; ---------------------------------------------------------------------------
;; Essays section
@@ -137,9 +215,9 @@
:section "Essays"
:sub-label "Essays"
:sub-href "/essays/sx-sucks"
:sub-nav (essays-nav "sx sucks")
:sub-nav (~section-nav :items essays-nav-items :current "sx sucks")
:selected "sx sucks")
:content (essay-content "sx-sucks"))
:content (~essay-sx-sucks))
(defpage essay-page
:path "/essays/<slug>"
@@ -148,6 +226,17 @@
:section "Essays"
:sub-label "Essays"
:sub-href "/essays/sx-sucks"
:sub-nav (essays-nav (find-current ESSAYS_NAV slug))
:selected (or (find-current ESSAYS_NAV slug) ""))
:content (essay-content slug))
:sub-nav (~section-nav :items essays-nav-items
:current (find-current essays-nav-items slug))
:selected (or (find-current essays-nav-items slug) ""))
:content (case slug
"sx-sucks" (~essay-sx-sucks)
"why-sexps" (~essay-why-sexps)
"htmx-react-hybrid" (~essay-htmx-react-hybrid)
"on-demand-css" (~essay-on-demand-css)
"client-reactivity" (~essay-client-reactivity)
"sx-native" (~essay-sx-native)
"sx-manifesto" (~essay-sx-manifesto)
"tail-call-optimization" (~essay-tail-call-optimization)
"continuations" (~essay-continuations)
:else (~essay-sx-sucks)))

File diff suppressed because it is too large Load Diff

View File

@@ -1,136 +1,135 @@
"""Public partials and page helper registration for sx docs."""
"""Page helper registration for sx docs.
All helpers return data values (dicts, lists) — no sx_call(), no SxExpr.
Markup composition lives entirely in .sx files.
"""
from __future__ import annotations
from .essays import (
_docs_content_sx, _reference_content_sx, _protocol_content_sx,
_examples_content_sx, _essay_content_sx,
_reference_index_sx, _reference_attr_detail_sx,
)
from .utils import _docs_nav_sx, _reference_nav_sx, _protocols_nav_sx, _examples_nav_sx, _essays_nav_sx
from content.highlight import highlight
def home_content_sx() -> str:
"""Home page content as sx wire format."""
hero_code = highlight('(div :class "p-4 bg-white rounded shadow"\n'
' (h1 :class "text-2xl font-bold" "Hello")\n'
' (button :sx-get "/api/data"\n'
' :sx-target "#result"\n'
' "Load data"))', "lisp")
return (
f'(section :id "main-panel"'
f' :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"'
f' (div :id "main-content"'
f' (~sx-hero {hero_code})'
f' (~sx-philosophy)'
f' (~sx-how-it-works)'
f' (~sx-credits)))'
)
async def docs_content_partial_sx(slug: str) -> str:
"""Docs content as sx wire format."""
inner = await _docs_content_sx(slug)
return (
f'(section :id "main-panel"'
f' :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"'
f' {inner})'
)
async def reference_content_partial_sx(slug: str) -> str:
inner = await _reference_content_sx(slug)
return (
f'(section :id "main-panel"'
f' :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"'
f' {inner})'
)
async def protocol_content_partial_sx(slug: str) -> str:
inner = await _protocol_content_sx(slug)
return (
f'(section :id "main-panel"'
f' :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"'
f' {inner})'
)
async def examples_content_partial_sx(slug: str) -> str:
inner = await _examples_content_sx(slug)
return (
f'(section :id "main-panel"'
f' :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"'
f' {inner})'
)
async def essay_content_partial_sx(slug: str) -> str:
inner = await _essay_content_sx(slug)
return (
f'(section :id "main-panel"'
f' :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"'
f' {inner})'
)
def _register_sx_helpers() -> None:
"""Register Python content builder functions as page helpers."""
"""Register Python data helpers as page helpers."""
from shared.sx.pages import register_page_helpers
from content.highlight import highlight as _highlight
from content.pages import (
DOCS_NAV, REFERENCE_NAV, PROTOCOLS_NAV,
EXAMPLES_NAV, ESSAYS_NAV,
)
def _find_current(nav_list, slug, match_fn=None):
"""Find the current nav label for a slug."""
if match_fn:
return match_fn(nav_list, slug)
for label, href in nav_list:
if href.endswith(slug):
return label
return None
def _home_content():
"""Build home page content (uses highlight for hero code block)."""
hero_code = _highlight(
'(div :class "p-4 bg-white rounded shadow"\n'
' (h1 :class "text-2xl font-bold" "Hello")\n'
' (button :sx-get "/api/data"\n'
' :sx-target "#result"\n'
' "Load data"))', "lisp")
return (
f'(div :id "main-content"'
f' (~sx-hero {hero_code})'
f' (~sx-philosophy)'
f' (~sx-how-it-works)'
f' (~sx-credits))'
)
register_page_helpers("sx", {
# Content builders
"home-content": _home_content,
"docs-content": _docs_content_sx,
"reference-content": _reference_content_sx,
"reference-index-content": _reference_index_sx,
"reference-attr-detail": _reference_attr_detail_sx,
"protocol-content": _protocol_content_sx,
"examples-content": _examples_content_sx,
"essay-content": _essay_content_sx,
"highlight": _highlight,
# Nav builders
"docs-nav": _docs_nav_sx,
"reference-nav": _reference_nav_sx,
"protocols-nav": _protocols_nav_sx,
"examples-nav": _examples_nav_sx,
"essays-nav": _essays_nav_sx,
# Nav data (for current label lookup)
"DOCS_NAV": DOCS_NAV,
"REFERENCE_NAV": REFERENCE_NAV,
"PROTOCOLS_NAV": PROTOCOLS_NAV,
"EXAMPLES_NAV": EXAMPLES_NAV,
"ESSAYS_NAV": ESSAYS_NAV,
# Utility
"find-current": _find_current,
"primitives-data": _primitives_data,
"reference-data": _reference_data,
"attr-detail-data": _attr_detail_data,
})
def _primitives_data() -> dict:
"""Return the PRIMITIVES dict for the primitives docs page."""
from content.pages import PRIMITIVES
return PRIMITIVES
def _reference_data(slug: str) -> dict:
"""Return reference table data for a given slug.
Returns a dict whose keys become SX env bindings:
- attributes: req-attrs, beh-attrs, uniq-attrs
- headers: req-headers, resp-headers
- events: events-list
- js-api: js-api-list
"""
from content.pages import (
REQUEST_ATTRS, BEHAVIOR_ATTRS, SX_UNIQUE_ATTRS,
REQUEST_HEADERS, RESPONSE_HEADERS,
EVENTS, JS_API, ATTR_DETAILS,
)
if slug == "attributes":
return {
"req-attrs": [
{"name": a, "desc": d, "exists": e,
"href": f"/reference/attributes/{a}" if e and a in ATTR_DETAILS else None}
for a, d, e in REQUEST_ATTRS
],
"beh-attrs": [
{"name": a, "desc": d, "exists": e,
"href": f"/reference/attributes/{a}" if e and a in ATTR_DETAILS else None}
for a, d, e in BEHAVIOR_ATTRS
],
"uniq-attrs": [
{"name": a, "desc": d, "exists": e,
"href": f"/reference/attributes/{a}" if e and a in ATTR_DETAILS else None}
for a, d, e in SX_UNIQUE_ATTRS
],
}
elif slug == "headers":
return {
"req-headers": [
{"name": n, "value": v, "desc": d}
for n, v, d in REQUEST_HEADERS
],
"resp-headers": [
{"name": n, "value": v, "desc": d}
for n, v, d in RESPONSE_HEADERS
],
}
elif slug == "events":
return {
"events-list": [
{"name": n, "desc": d}
for n, d in EVENTS
],
}
elif slug == "js-api":
return {
"js-api-list": [
{"name": n, "desc": d}
for n, d in JS_API
],
}
# Default — return attrs data for fallback
return {
"req-attrs": [
{"name": a, "desc": d, "exists": e,
"href": f"/reference/attributes/{a}" if e and a in ATTR_DETAILS else None}
for a, d, e in REQUEST_ATTRS
],
"beh-attrs": [
{"name": a, "desc": d, "exists": e,
"href": f"/reference/attributes/{a}" if e and a in ATTR_DETAILS else None}
for a, d, e in BEHAVIOR_ATTRS
],
"uniq-attrs": [
{"name": a, "desc": d, "exists": e,
"href": f"/reference/attributes/{a}" if e and a in ATTR_DETAILS else None}
for a, d, e in SX_UNIQUE_ATTRS
],
}
def _attr_detail_data(slug: str) -> dict:
"""Return attribute detail data for a specific attribute slug.
Returns a dict whose keys become SX env bindings:
- attr-title, attr-description, attr-example, attr-handler
- attr-demo (component call or None)
- attr-wire-id (wire placeholder id or None)
- attr-not-found (truthy if not found)
"""
from content.pages import ATTR_DETAILS
from shared.sx.helpers import SxExpr
detail = ATTR_DETAILS.get(slug)
if not detail:
return {"attr-not-found": True}
demo_name = detail.get("demo")
wire_id = None
if "handler" in detail:
wire_id = f"ref-wire-{slug.replace(':', '-').replace('*', 'star')}"
return {
"attr-not-found": None,
"attr-title": slug,
"attr-description": detail["description"],
"attr-example": detail["example"],
"attr-handler": detail.get("handler"),
"attr-demo": SxExpr(f"(~{demo_name})") if demo_name else None,
"attr-wire-id": wire_id,
}

View File

@@ -1,112 +1,11 @@
"""Layout registration and header/mobile functions for sx docs."""
"""SX docs layout registration — all layouts delegate to .sx defcomps."""
from __future__ import annotations
from typing import Any
from .utils import _main_nav_sx, _sx_header_sx, _sub_row_sx
def _register_sx_layouts() -> None:
"""Register the sx docs layout presets."""
from shared.sx.layouts import register_custom_layout
from shared.sx.layouts import register_sx_layout
register_custom_layout("sx", _sx_full_headers, _sx_oob_headers, _sx_mobile)
register_custom_layout("sx-section", _sx_section_full_headers, _sx_section_oob_headers, _sx_section_mobile)
async def _sx_full_headers(ctx: dict, **kw: Any) -> str:
"""Full headers for sx home page: root + sx menu row."""
from shared.sx.helpers import render_to_sx_with_env
from shared.sx.parser import SxExpr
main_nav = _main_nav_sx(kw.get("section"))
sx_row = _sx_header_sx(main_nav)
return await render_to_sx_with_env("sx-layout-full", {},
sx_row=SxExpr(sx_row))
async def _sx_oob_headers(ctx: dict, **kw: Any) -> str:
"""OOB headers for sx home page."""
from shared.sx.helpers import render_to_sx_with_env, oob_header_sx
from shared.sx.parser import SxExpr
main_nav = _main_nav_sx(kw.get("section"))
sx_row = _sx_header_sx(main_nav)
rows = await render_to_sx_with_env("sx-layout-full", {},
sx_row=SxExpr(sx_row))
return await oob_header_sx("root-header-child", "sx-header-child", rows)
async def _sx_section_full_headers(ctx: dict, **kw: Any) -> str:
"""Full headers for sx section pages: root + sx row + sub row."""
from shared.sx.helpers import render_to_sx_with_env
from shared.sx.parser import SxExpr
section = kw.get("section", "")
sub_label = kw.get("sub_label", section)
sub_href = kw.get("sub_href", "/")
sub_nav = kw.get("sub_nav", "")
selected = kw.get("selected", "")
main_nav = _main_nav_sx(section)
sub_row = _sub_row_sx(sub_label, sub_href, sub_nav, selected)
sx_row = _sx_header_sx(main_nav, child=sub_row)
return await render_to_sx_with_env("sx-section-layout-full", {},
sx_row=SxExpr(sx_row))
async def _sx_section_oob_headers(ctx: dict, **kw: Any) -> str:
"""OOB headers for sx section pages."""
from shared.sx.helpers import render_to_sx_with_env, oob_header_sx
from shared.sx.parser import SxExpr
section = kw.get("section", "")
sub_label = kw.get("sub_label", section)
sub_href = kw.get("sub_href", "/")
sub_nav = kw.get("sub_nav", "")
selected = kw.get("selected", "")
main_nav = _main_nav_sx(section)
sub_row = _sub_row_sx(sub_label, sub_href, sub_nav, selected)
sx_row = _sx_header_sx(main_nav, child=sub_row)
rows = await render_to_sx_with_env("sx-section-layout-full", {},
sx_row=SxExpr(sx_row))
return await oob_header_sx("root-header-child", "sx-header-child", rows)
async def _sx_mobile(ctx: dict, **kw: Any) -> str:
"""Mobile menu for sx home page: main nav + root."""
from shared.sx.helpers import (
mobile_menu_sx, mobile_root_nav_sx, sx_call, SxExpr,
)
main_nav = _main_nav_sx(kw.get("section"))
return mobile_menu_sx(
sx_call("mobile-menu-section",
label="sx", href="/", level=1, colour="violet",
items=SxExpr(main_nav)),
await mobile_root_nav_sx(ctx),
)
async def _sx_section_mobile(ctx: dict, **kw: Any) -> str:
"""Mobile menu for sx section pages: sub nav + main nav + root."""
from shared.sx.helpers import (
mobile_menu_sx, mobile_root_nav_sx, sx_call, SxExpr,
)
section = kw.get("section", "")
sub_label = kw.get("sub_label", section)
sub_href = kw.get("sub_href", "/")
sub_nav = kw.get("sub_nav", "")
main_nav = _main_nav_sx(section)
parts = []
if sub_nav:
parts.append(sx_call("mobile-menu-section",
label=sub_label, href=sub_href, level=2, colour="violet",
items=SxExpr(sub_nav)))
parts.append(sx_call("mobile-menu-section",
label="sx", href="/", level=1, colour="violet",
items=SxExpr(main_nav)))
parts.append(await mobile_root_nav_sx(ctx))
return mobile_menu_sx(*parts)
register_sx_layout("sx", "sx-layout-full", "sx-layout-oob", "sx-layout-mobile")
register_sx_layout("sx-section", "sx-section-layout-full",
"sx-section-layout-oob", "sx-section-layout-mobile")

View File

@@ -1,26 +1,6 @@
"""Public render/utility functions called from bp routes."""
from __future__ import annotations
from content.highlight import highlight
def _code(code: str, language: str = "lisp") -> str:
"""Build a ~doc-code component with highlighted content."""
highlighted = highlight(code, language)
return f'(~doc-code :code {highlighted})'
def _example_code(code: str, language: str = "lisp") -> str:
"""Build an ~example-source component with highlighted content."""
highlighted = highlight(code, language)
return f'(~example-source :code {highlighted})'
def _placeholder(div_id: str) -> str:
"""Empty placeholder that will be filled by OOB swap on interaction."""
from shared.sx.helpers import sx_call
return sx_call("doc-placeholder", id=div_id)
def _component_source_text(*names: str) -> str:
"""Get defcomp source text for named components."""
@@ -47,12 +27,6 @@ def _oob_code(target_id: str, text: str) -> str:
return sx_call("doc-oob-code", target_id=target_id, text=text)
def _clear_components_btn() -> str:
"""Button that clears the client-side component cache (localStorage + in-memory)."""
from shared.sx.helpers import sx_call
return sx_call("doc-clear-cache-btn")
def _full_wire_text(sx_src: str, *comp_names: str) -> str:
"""Build the full wire response text showing component defs + CSS note + sx source.

View File

@@ -1,116 +0,0 @@
"""Shared utility functions for sx docs pages."""
from __future__ import annotations
from shared.sx.helpers import (
sx_call, SxExpr,
)
def _nav_items_sx(items: list[tuple[str, str]], current: str | None = None) -> str:
"""Build nav link items as sx."""
parts = []
for label, href in items:
parts.append(sx_call("nav-link",
href=href, label=label,
is_selected="true" if current == label else None,
select_colours="aria-selected:bg-violet-200 aria-selected:text-violet-900",
))
return "(<> " + " ".join(parts) + ")"
def _doc_nav_sx(items: list[tuple[str, str]], current: str) -> str:
"""Build the in-page doc navigation pills."""
items_sx = " ".join(
f'(list "{label}" "{href}")'
for label, href in items
)
return sx_call("doc-nav", items=SxExpr(f"(list {items_sx})"), current=current)
def _attr_table_sx(title: str, attrs: list[tuple[str, str, bool]]) -> str:
"""Build an attribute reference table."""
from content.pages import ATTR_DETAILS
rows = []
for attr, desc, exists in attrs:
href = f"/reference/attributes/{attr}" if exists and attr in ATTR_DETAILS else None
rows.append(sx_call("doc-attr-row", attr=attr, description=desc,
exists="true" if exists else None,
href=href))
rows_sx = "(<> " + " ".join(rows) + ")"
return sx_call("doc-attr-table", title=title, rows=SxExpr(rows_sx))
def _headers_table_sx(title: str, headers: list[tuple[str, str, str]]) -> str:
"""Build a headers reference table."""
rows = []
for name, value, desc in headers:
rows.append(sx_call("doc-headers-row",
name=name, value=value, description=desc))
rows_sx = "(<> " + " ".join(rows) + ")"
return sx_call("doc-headers-table", title=title, rows=SxExpr(rows_sx))
def _primitives_section_sx() -> str:
"""Build the primitives section."""
from content.pages import PRIMITIVES
parts = []
for category, prims in PRIMITIVES.items():
prims_sx = " ".join(f'"{p}"' for p in prims)
parts.append(sx_call("doc-primitives-table",
category=category,
primitives=SxExpr(f"(list {prims_sx})")))
return " ".join(parts)
def _sx_header_sx(nav: str | None = None, *, child: str | None = None) -> str:
"""Build the sx docs menu-row."""
label_sx = sx_call("sx-docs-label")
return sx_call("menu-row-sx",
id="sx-row", level=1, colour="violet",
link_href="/", link_label="sx",
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav) if nav else None,
child_id="sx-header-child",
child=SxExpr(child) if child else None,
)
def _docs_nav_sx(current: str | None = None) -> str:
from content.pages import DOCS_NAV
return _nav_items_sx(DOCS_NAV, current)
def _reference_nav_sx(current: str | None = None) -> str:
from content.pages import REFERENCE_NAV
return _nav_items_sx(REFERENCE_NAV, current)
def _protocols_nav_sx(current: str | None = None) -> str:
from content.pages import PROTOCOLS_NAV
return _nav_items_sx(PROTOCOLS_NAV, current)
def _examples_nav_sx(current: str | None = None) -> str:
from content.pages import EXAMPLES_NAV
return _nav_items_sx(EXAMPLES_NAV, current)
def _essays_nav_sx(current: str | None = None) -> str:
from content.pages import ESSAYS_NAV
return _nav_items_sx(ESSAYS_NAV, current)
def _main_nav_sx(current_section: str | None = None) -> str:
from content.pages import MAIN_NAV
return _nav_items_sx(MAIN_NAV, current_section)
def _sub_row_sx(sub_label: str, sub_href: str, sub_nav: str,
selected: str = "") -> str:
"""Build the level-2 sub-section menu-row."""
return sx_call("menu-row-sx",
id="sx-sub-row", level=2, colour="violet",
link_href=sub_href, link_label=sub_label,
selected=selected or None,
nav=SxExpr(sub_nav),
)

View File

@@ -272,7 +272,7 @@
(defcomp ~ref-headers-demo ()
(div :class "space-y-3"
(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-swap "innerHTML"
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"

View File

@@ -5,7 +5,7 @@ import os
from datetime import datetime
from shared.sx.jinja_bridge import load_service_components
from shared.sx.helpers import sx_call, SxExpr, render_to_sx_with_env, full_page_sx
from shared.sx.helpers import sx_call, render_to_sx_with_env, full_page_sx
# Load test-specific .sx components at import time
load_service_components(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
@@ -98,7 +98,7 @@ async def render_dashboard_page_sx(ctx: dict, result: dict | None,
inner = sx_call("test-results-partial",
summary_data=summary_data, sections=sections, has_failures=has_failures)
content = sx_call("test-results-wrap", running=running, inner=SxExpr(inner))
content = sx_call("test-results-wrap", running=running, inner=inner)
hdr = await render_to_sx_with_env("test-layout-full", {},
services=_service_list(),
active_service=active_service,
@@ -126,7 +126,7 @@ async def render_results_partial_sx(result: dict | None, running: bool,
inner = sx_call("test-results-partial",
summary_data=summary_data, sections=sections, has_failures=has_failures)
return sx_call("test-results-wrap", running=running, inner=SxExpr(inner))
return sx_call("test-results-wrap", running=running, inner=inner)
async def render_test_detail_page_sx(ctx: dict, test: dict) -> str: