Rename all 1,169 components to path-based names with namespace support

Component names now reflect filesystem location using / as path separator
and : as namespace separator for shared components:
  ~sx-header → ~layouts/header
  ~layout-app-body → ~shared:layout/app-body
  ~blog-admin-dashboard → ~admin/dashboard

209 files, 4,941 replacements across all services.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 22:00:12 +00:00
parent de80d921e9
commit b0920a1121
209 changed files with 4620 additions and 4620 deletions

View File

@@ -62,7 +62,7 @@ def _sx_error_page(errnum: str, message: str, image: str | None = None) -> str:
from shared.sx.page import render_page
return render_page(
'(~error-page :title title :message message :image image :asset-url "/static")',
'(~shared:pages/error-page :title title :message message :image image :asset-url "/static")',
title=f"{errnum} Error",
message=message,
image=image,

View File

@@ -170,7 +170,7 @@ def compute_all_deps(env: dict[str, Any]) -> None:
def scan_components_from_sx(source: str) -> set[str]:
"""Extract component names referenced in SX source text.
Returns names with ~ prefix, e.g. {"~card", "~nav-link"}.
Returns names with ~ prefix, e.g. {"~card", "~shared:layout/nav-link"}.
"""
if _use_ref():
from .ref.sx_ref import scan_components_from_source as _ref_sc

View File

@@ -90,7 +90,7 @@ def mobile_menu_sx(*sections: str) -> SxExpr:
async def mobile_root_nav_sx(ctx: dict) -> str:
"""Root-level mobile nav via ~mobile-root-nav component."""
"""Root-level mobile nav via ~shared:layout/mobile-root-nav component."""
nav_tree = ctx.get("nav_tree") or ""
auth_menu = ctx.get("auth_menu") or ""
if not nav_tree and not auth_menu:
@@ -263,7 +263,7 @@ async def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
"""Wrap a header row sx in an OOB swap.
child_id is accepted for call-site compatibility but no longer used —
the child placeholder is created by ~menu-row-sx itself.
the child placeholder is created by ~shared:layout/menu-row-sx itself.
"""
return await _render_to_sx("oob-header-sx",
parent_id=parent_id,
@@ -636,7 +636,7 @@ def sx_response(source: str, status: int = 200,
# ---------------------------------------------------------------------------
# Sx wire-format full page shell
# ---------------------------------------------------------------------------
# The page shell is defined as ~sx-page-shell in shared/sx/templates/shell.sx
# The page shell is defined as ~shared:shell/sx-page-shell in shared/sx/templates/shell.sx
# and rendered via render_to_html. No HTML string templates in Python.
@@ -780,7 +780,7 @@ async def sx_page(ctx: dict, page_sx: str, *,
renders everything client-side. CSS rules are scanned from the sx
source and component defs, then injected as a <style> block.
The shell is rendered from the ~sx-page-shell SX component
The shell is rendered from the ~shared:shell/sx-page-shell SX component
(shared/sx/templates/shell.sx).
"""
from .jinja_bridge import components_for_page, css_classes_for_page

View File

@@ -6,7 +6,7 @@ can coexist during incremental migration:
**Jinja → s-expression** (use s-expression components inside Jinja templates)::
{{ sx('(~link-card :slug "apple" :title "Apple")') | safe }}
{{ sx('(~shared:fragments/link-card :slug "apple" :title "Apple")') | safe }}
**S-expression → Jinja** (embed Jinja output inside s-expressions)::
@@ -220,7 +220,7 @@ def register_components(sx_source: str) -> None:
Typically called at app startup::
register_components('''
(defcomp ~link-card (&key link title image icon)
(defcomp ~shared:fragments/link-card (&key link title image icon)
(a :href link :class "block rounded ..."
(div :class "flex ..."
(if image
@@ -269,7 +269,7 @@ def sx(source: str, **kwargs: Any) -> str:
Keyword arguments are merged into the evaluation environment,
so Jinja context variables can be passed through::
{{ sx('(~link-card :title title :slug slug)',
{{ sx('(~shared:fragments/link-card :title title :slug slug)',
title=post.title, slug=post.slug) | safe }}
This is a synchronous function — suitable for Jinja globals.

View File

@@ -15,7 +15,7 @@ Usage::
# Error pages (no context needed)
html = render_page(
'(~error-page :title "Not Found" :message "NOT FOUND" :image img :asset-url aurl)',
'(~shared:pages/error-page :title "Not Found" :message "NOT FOUND" :image img :asset-url aurl)',
image="/static/errors/404.gif",
asset_url="/static",
)

View File

@@ -179,9 +179,9 @@ async def _eval_slot(expr: Any, env: dict, ctx: Any) -> str:
def _replace_suspense_sexp(sx: str, stream_id: str, replacement: str) -> str:
"""Replace a rendered ~suspense div in SX source with replacement content.
"""Replace a rendered ~shared:pages/suspense div in SX source with replacement content.
After _eval_slot, ~suspense expands to:
After _eval_slot, ~shared:pages/suspense expands to:
(div :id "sx-suspense-{id}" :data-suspense "{id}" :style "display:contents" ...)
This finds the balanced s-expression containing :data-suspense "{id}" and
replaces it with the given replacement string.
@@ -277,7 +277,7 @@ async def execute_page(
if page_def.shell_expr is not None:
shell_sx = await _eval_slot(page_def.shell_expr, env, ctx)
# Replace each rendered suspense div with resolved content.
# _eval_slot expands ~suspense into:
# _eval_slot expands ~shared:pages/suspense into:
# (div :id "sx-suspense-X" :data-suspense "X" :style "display:contents" ...)
# We find the balanced s-expr containing :data-suspense "X" and replace it.
for stream_id, chunk_sx in chunks:
@@ -534,16 +534,16 @@ async def execute_page_streaming(
# Render to HTML so [data-suspense] elements are real DOM immediately.
# No dependency on sx-browser.js boot timing for the initial shell.
suspense_header_sx = f'(~suspense :id "stream-headers" :fallback {header_fallback})'
suspense_header_sx = f'(~shared:pages/suspense :id "stream-headers" :fallback {header_fallback})'
# When :shell is provided, it renders directly as the content slot
# (it contains its own ~suspense for the data-dependent part).
# (it contains its own ~shared:pages/suspense for the data-dependent part).
# Otherwise, wrap the entire :content in a single suspense.
if page_def.shell_expr is not None:
shell_content_sx = await _eval_slot(page_def.shell_expr, env, ctx)
suspense_content_sx = shell_content_sx
else:
suspense_content_sx = f'(~suspense :id "stream-content" :fallback {fallback_sx})'
suspense_content_sx = f'(~shared:pages/suspense :id "stream-content" :fallback {fallback_sx})'
initial_page_html = await _helpers_render_to_html("app-body",
header_rows=SxExpr(suspense_header_sx),
@@ -551,7 +551,7 @@ async def execute_page_streaming(
)
# Include layout component refs + page content so the scan picks up
# their transitive deps (e.g. ~cart-mini, ~auth-menu in headers).
# their transitive deps (e.g. ~shared:fragments/cart-mini, ~auth-menu in headers).
layout_refs = ""
if layout is not None and hasattr(layout, "component_names"):
layout_refs = " ".join(f"({n})" for n in layout.component_names)
@@ -561,14 +561,14 @@ async def execute_page_streaming(
shell_ref = ""
if page_def.shell_expr is not None:
shell_ref = sx_serialize(page_def.shell_expr)
page_sx_for_scan = f'(<> {layout_refs} {content_ref} {shell_ref} (~app-body :header-rows {suspense_header_sx} :content {suspense_content_sx}))'
page_sx_for_scan = f'(<> {layout_refs} {content_ref} {shell_ref} (~shared:layout/app-body :header-rows {suspense_header_sx} :content {suspense_content_sx}))'
shell, tail = sx_page_streaming_parts(
tctx, initial_page_html, page_sx=page_sx_for_scan,
)
# Capture component env + extras scanner while we still have context.
# Resolved SX may reference components not in the initial scan
# (e.g. ~cart-mini from IO-generated header content).
# (e.g. ~shared:fragments/cart-mini from IO-generated header content).
from .jinja_bridge import components_for_page as _comp_scan
from quart import current_app as _ca
_service = _ca.name

View File

@@ -6,26 +6,26 @@
;; ---------------------------------------------------------------------------
;; Auth section nav items (newsletters link + account_nav slot)
(defcomp ~auth-nav-items (&key (account-url :as string?) (select-colours :as string?) account-nav)
(defcomp ~shared:auth/nav-items (&key (account-url :as string?) (select-colours :as string?) account-nav)
(<>
(~nav-link :href (str (or account-url "") "/newsletters/")
(~shared:layout/nav-link :href (str (or account-url "") "/newsletters/")
:label "newsletters"
:select-colours (or select-colours ""))
(when account-nav account-nav)))
;; Auth header row — wraps ~menu-row-sx for account section
(defcomp ~auth-header-row (&key (account-url :as string?) (select-colours :as string?) account-nav (oob :as boolean?))
(~menu-row-sx :id "auth-row" :level 1 :colour "sky"
;; Auth header row — wraps ~shared:layout/menu-row-sx for account section
(defcomp ~shared:auth/header-row (&key (account-url :as string?) (select-colours :as string?) account-nav (oob :as boolean?))
(~shared:layout/menu-row-sx :id "auth-row" :level 1 :colour "sky"
:link-href (str (or account-url "") "/")
:link-label "account" :icon "fa-solid fa-user"
:nav (~auth-nav-items :account-url account-url
:nav (~shared:auth/nav-items :account-url account-url
:select-colours select-colours
:account-nav account-nav)
:child-id "auth-header-child" :oob oob))
;; Auth header row without nav (for cart service)
(defcomp ~auth-header-row-simple (&key (account-url :as string?) (oob :as boolean?))
(~menu-row-sx :id "auth-row" :level 1 :colour "sky"
(defcomp ~shared:auth/header-row-simple (&key (account-url :as string?) (oob :as boolean?))
(~shared:layout/menu-row-sx :id "auth-row" :level 1 :colour "sky"
:link-href (str (or account-url "") "/")
:link-label "account" :icon "fa-solid fa-user"
:child-id "auth-header-child" :oob oob))
@@ -34,26 +34,26 @@
;; Expands inline (defmacro) so IO calls resolve in _aser mode.
(defmacro ~auth-header-row-auto (oob)
(quasiquote
(~auth-header-row :account-url (app-url "account" "")
(~shared:auth/header-row :account-url (app-url "account" "")
:select-colours (select-colours)
:account-nav (account-nav-ctx)
:oob (unquote oob))))
(defmacro ~auth-header-row-simple-auto (oob)
(quasiquote
(~auth-header-row-simple :account-url (app-url "account" "")
(~shared:auth/header-row-simple :account-url (app-url "account" "")
:oob (unquote oob))))
;; Auto-fetching auth nav items — for mobile menus
(defmacro ~auth-nav-items-auto ()
(quasiquote
(~auth-nav-items :account-url (app-url "account" "")
(~shared:auth/nav-items :account-url (app-url "account" "")
:select-colours (select-colours)
:account-nav (account-nav-ctx))))
;; Orders header row
(defcomp ~orders-header-row (&key (list-url :as string))
(~menu-row-sx :id "orders-row" :level 2 :colour "sky"
(defcomp ~shared:auth/orders-header-row (&key (list-url :as string))
(~shared:layout/menu-row-sx :id "orders-row" :level 2 :colour "sky"
:link-href list-url :link-label "Orders" :icon "fa fa-gbp"
:child-id "orders-header-child"))
@@ -61,12 +61,12 @@
;; Auth forms — login flow, check email
;; ---------------------------------------------------------------------------
(defcomp ~auth-error-banner (&key (error :as string?))
(defcomp ~shared:auth/error-banner (&key (error :as string?))
(when error
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
error)))
(defcomp ~auth-login-form (&key error (action :as string) (csrf-token :as string) (email :as string?))
(defcomp ~shared:auth/login-form (&key error (action :as string) (csrf-token :as string) (email :as string?))
(div :class "py-8 max-w-md mx-auto"
(h1 :class "text-2xl font-bold mb-6" "Sign in")
error
@@ -80,12 +80,12 @@
:class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
"Send magic link"))))
(defcomp ~auth-check-email-error (&key (error :as string?))
(defcomp ~shared:auth/check-email-error (&key (error :as string?))
(when error
(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4"
error)))
(defcomp ~auth-check-email (&key (email :as string) error)
(defcomp ~shared:auth/check-email (&key (email :as string) error)
(div :class "py-8 max-w-md mx-auto text-center"
(h1 :class "text-2xl font-bold mb-4" "Check your email")
(p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong email) ".")

View File

@@ -1,4 +1,4 @@
(defcomp ~post-card (&key (title :as string) (slug :as string) (href :as string) (feature-image :as string?)
(defcomp ~shared:cards/post-card (&key (title :as string) (slug :as string) (href :as string) (feature-image :as string?)
(excerpt :as string?) (status :as string?) (published-at :as string?) (updated-at :as string?)
(publish-requested :as boolean?) (hx-select :as string?) like widgets at-bar)
(article :class "border-b pb-6 last:border-b-0 relative"
@@ -31,13 +31,13 @@
(when widgets widgets)
(when at-bar at-bar)))
(defcomp ~order-summary-card (&key (order-id :as string) (created-at :as string?) (description :as string?)
(defcomp ~shared:cards/order-summary-card (&key (order-id :as string) (created-at :as string?) (description :as string?)
(status :as string?) (currency :as string?) (total-amount :as string?))
(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2 text-xs sm:text-sm text-stone-800"
(p (span :class "font-medium" "Order ID:") " " (span :class "font-mono" (str "#" order-id)))
(p (span :class "font-medium" "Created:") " " (or created-at "\u2014"))
(p (span :class "font-medium" "Description:") " " (or description "\u2013"))
(p (span :class "font-medium" "Status:") " " (~status-pill :status (or status "pending") :size "[11px]"))
(p (span :class "font-medium" "Status:") " " (~shared:controls/status-pill :status (or status "pending") :size "[11px]"))
(p (span :class "font-medium" "Currency:") " " (or currency "GBP"))
(p (span :class "font-medium" "Total:") " "
(if total-amount

View File

@@ -1,4 +1,4 @@
(defcomp ~search-mobile (&key (current-local-href :as string) (search :as string?) (search-count :as number?)
(defcomp ~shared:controls/search-mobile (&key (current-local-href :as string) (search :as string?) (search-count :as number?)
(hx-select :as string?) (search-headers-mobile :as string?))
(div :id "search-mobile-wrapper"
:class "flex flex-row gap-2 items-center flex-1 min-w-0 pr-2"
@@ -21,7 +21,7 @@
:class (if (not search-count) "text-xl text-red-500" "")
(when search (str search-count)))))
(defcomp ~search-desktop (&key (current-local-href :as string) (search :as string?) (search-count :as number?)
(defcomp ~shared:controls/search-desktop (&key (current-local-href :as string) (search :as string?) (search-count :as number?)
(hx-select :as string?) (search-headers-desktop :as string?))
(div :id "search-desktop-wrapper"
:class "flex flex-row gap-2 items-center"
@@ -44,7 +44,7 @@
:class (if (not search-count) "text-xl text-red-500" "")
(when search (str search-count)))))
(defcomp ~mobile-filter (&key filter-summary action-buttons filter-details)
(defcomp ~shared:controls/mobile-filter (&key filter-summary action-buttons filter-details)
(details :class "group/filter p-2 md:hidden" :data-toggle-group "mobile-panels"
(summary :class "bg-white/90"
(div :class "flex flex-row items-start"
@@ -64,7 +64,7 @@
(div :id "filter-details-mobile" :style "display:contents"
(when filter-details filter-details))))
(defcomp ~infinite-scroll (&key (url :as string) (page :as number) (total-pages :as number)
(defcomp ~shared:controls/infinite-scroll (&key (url :as string) (page :as number) (total-pages :as number)
(id-prefix :as string) (colspan :as number))
(if (< page total-pages)
(tr :id (str id-prefix "-sentinel-" page)
@@ -85,7 +85,7 @@
(tr (td :colspan colspan :class "px-3 py-4 text-center text-xs text-stone-400"
"End of results"))))
(defcomp ~status-pill (&key (status :as string?) (size :as string?))
(defcomp ~shared:controls/status-pill (&key (status :as string?) (size :as string?))
(let* ((s (or status "pending"))
(lower (lower s))
(sz (or size "xs"))

View File

@@ -1,4 +1,4 @@
(defcomp ~link-card (&key (link :as string) (title :as string) (image :as string?) (icon :as string?)
(defcomp ~shared:fragments/link-card (&key (link :as string) (title :as string) (image :as string?) (icon :as string?)
(subtitle :as string?) (detail :as string?) (data-app :as string?))
(a :href link
:class "block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline"
@@ -17,7 +17,7 @@
(when detail
(div :class "text-xs text-stone-400 mt-1" detail))))))
(defcomp ~cart-mini (&key (cart-count :as number) (blog-url :as string) (cart-url :as string) (oob :as string?))
(defcomp ~shared:fragments/cart-mini (&key (cart-count :as number) (blog-url :as string) (cart-url :as string) (oob :as string?))
(div :id "cart-mini"
:sx-swap-oob oob
(if (= cart-count 0)
@@ -34,7 +34,7 @@
(span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5"
cart-count)))))
(defcomp ~auth-menu (&key (user-email :as string?) (account-url :as string))
(defcomp ~shared:fragments/auth-menu (&key (user-email :as string?) (account-url :as string))
(<>
(span :id "auth-menu-desktop" :class "hidden md:inline-flex"
(if user-email
@@ -66,7 +66,7 @@
(i :class "fa-solid fa-key")
(span "sign in or register"))))))
(defcomp ~account-nav-item (&key (href :as string) (label :as string))
(defcomp ~shared:fragments/account-nav-item (&key (href :as string) (label :as string))
(div :class "relative nav-group"
(a :href href
:class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"

View File

@@ -1,4 +1,4 @@
(defcomp ~app-body (&key header-rows filter aside menu content)
(defcomp ~shared:layout/app-body (&key header-rows filter aside menu content)
(div :class "max-w-screen-2xl mx-auto py-1 px-1"
(when header-rows
(div :class "w-full"
@@ -24,7 +24,7 @@
(when content content)
(div :class "pb-8")))))))
(defcomp ~oob-sx (&key oobs filter aside menu content)
(defcomp ~shared:layout/oob-sx (&key oobs filter aside menu content)
(<>
(when oobs oobs)
(div :id "filter" :sx-swap-oob "outerHTML"
@@ -38,7 +38,7 @@
:class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
(when content content))))
(defcomp ~hamburger ()
(defcomp ~shared:layout/hamburger ()
(div :class "md:hidden bg-stone-200 rounded"
(svg :class "h-12 w-12 transition-transform group-open/root:hidden block self-start"
:viewBox "0 0 24 24" :fill "none" :stroke "currentColor"
@@ -48,17 +48,17 @@
:class "w-12 h-12 rotate-180 transition-transform group-open/root:block hidden self-start"
(path :d "M6 9l6 6 6-6" :fill "currentColor"))))
(defcomp ~post-label (&key (feature-image :as string?) (title :as string))
(defcomp ~shared:layout/post-label (&key (feature-image :as string?) (title :as string))
(<> (when feature-image
(img :src feature-image :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))
(span title)))
(defcomp ~page-cart-badge (&key (href :as string) (count :as string))
(defcomp ~shared:layout/page-cart-badge (&key (href :as string) (count :as string))
(a :href href :class "relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition"
(i :class "fa fa-shopping-cart" :aria-hidden "true")
(span count)))
(defcomp ~header-row-sx (&key cart-mini (blog-url :as string?) (site-title :as string?)
(defcomp ~shared:layout/header-row-sx (&key cart-mini (blog-url :as string?) (site-title :as string?)
(app-label :as string?) nav-tree auth-menu nav-panel
(settings-url :as string?) (is-admin :as boolean?) (oob :as boolean?))
(<>
@@ -79,13 +79,13 @@
(when (and is-admin settings-url)
(a :href settings-url :class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
(i :class "fa fa-cog" :aria-hidden "true"))))
(~hamburger)))
(~shared:layout/hamburger)))
(div :class "block md:hidden text-md font-bold"
(when auth-menu auth-menu))))
; @css bg-sky-400 bg-sky-300 bg-sky-200 bg-sky-100 bg-violet-400 bg-violet-300 bg-violet-200 bg-violet-100
; @css aria-selected:bg-violet-200 aria-selected:text-violet-900 aria-selected:bg-stone-500 aria-selected:text-white
(defcomp ~menu-row-sx (&key (id :as string) (level :as number?) (colour :as string?)
(defcomp ~shared:layout/menu-row-sx (&key (id :as string) (level :as number?) (colour :as string?)
(link-href :as string) (link-label :as string?) link-label-content
(icon :as string?) (selected :as string?) (hx-select :as string?)
nav (child-id :as string?) child (oob :as boolean?) (external :as boolean?))
@@ -117,11 +117,11 @@
(div :id child-id :class "flex flex-col w-full items-center"
(when child child))))))
(defcomp ~oob-header-sx (&key (parent-id :as string) row)
(defcomp ~shared:layout/oob-header-sx (&key (parent-id :as string) row)
(div :id parent-id :sx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
row))
(defcomp ~header-child-sx (&key (id :as string?) inner)
(defcomp ~shared:layout/header-child-sx (&key (id :as string?) inner)
(div :id (or id "root-header-child") :class "flex flex-col w-full items-center" inner))
;; ---------------------------------------------------------------------------
@@ -129,7 +129,7 @@
;; ---------------------------------------------------------------------------
;; Labelled section: colour bar header + vertical nav items
(defcomp ~mobile-menu-section (&key (label :as string) (href :as string?) (colour :as string?)
(defcomp ~shared:layout/mobile-menu-section (&key (label :as string) (href :as string?) (colour :as string?)
(level :as number?) items)
(let* ((c (or colour "sky"))
(lv (or level 1))
@@ -143,7 +143,7 @@
items))))
;; Root-level mobile nav: site nav items + auth links
(defcomp ~mobile-root-nav (&key nav-tree auth-menu)
(defcomp ~shared:layout/mobile-root-nav (&key nav-tree auth-menu)
(<>
(when nav-tree
(div :class "flex flex-col gap-2 p-3 text-sm" nav-tree))
@@ -156,27 +156,27 @@
;; nested component calls in _aser are serialized without expansion.
;; ---------------------------------------------------------------------------
(defcomp ~root-header (&key cart-mini (blog-url :as string?) (site-title :as string?)
(defcomp ~shared:layout/root-header (&key cart-mini (blog-url :as string?) (site-title :as string?)
(app-label :as string?) nav-tree auth-menu nav-panel
(settings-url :as string?) (is-admin :as boolean?) (oob :as boolean?))
(~header-row-sx :cart-mini cart-mini :blog-url blog-url :site-title site-title
(~shared:layout/header-row-sx :cart-mini cart-mini :blog-url blog-url :site-title site-title
:app-label app-label :nav-tree nav-tree :auth-menu auth-menu
:nav-panel nav-panel :settings-url settings-url :is-admin is-admin
:oob oob))
(defcomp ~root-mobile (&key nav-tree auth-menu)
(~mobile-root-nav :nav-tree nav-tree :auth-menu auth-menu))
(defcomp ~shared:layout/root-mobile (&key nav-tree auth-menu)
(~shared:layout/mobile-root-nav :nav-tree nav-tree :auth-menu auth-menu))
;; ---------------------------------------------------------------------------
;; Auto-fetching header/mobile macros — use IO primitives to self-populate.
;; These expand inline so IO calls resolve in _aser mode within layout bodies.
;; Replaces the 10-parameter ~root-header boilerplate in layout defcomps.
;; Replaces the 10-parameter ~shared:layout/root-header boilerplate in layout defcomps.
;; ---------------------------------------------------------------------------
(defmacro ~root-header-auto (oob)
(quasiquote
(let ((__rhctx (root-header-ctx)))
(~header-row-sx :cart-mini (get __rhctx "cart-mini")
(~shared:layout/header-row-sx :cart-mini (get __rhctx "cart-mini")
:blog-url (get __rhctx "blog-url")
:site-title (get __rhctx "site-title")
:app-label (get __rhctx "app-label")
@@ -190,7 +190,7 @@
(defmacro ~root-mobile-auto ()
(quasiquote
(let ((__rhctx (root-header-ctx)))
(~mobile-root-nav :nav-tree (get __rhctx "nav-tree")
(~shared:layout/mobile-root-nav :nav-tree (get __rhctx "nav-tree")
:auth-menu (get __rhctx "auth-menu")))))
;; ---------------------------------------------------------------------------
@@ -198,31 +198,31 @@
;; These use ~root-header-auto / ~root-mobile-auto macros (IO primitives).
;; ---------------------------------------------------------------------------
(defcomp ~layout-root-full ()
(defcomp ~shared:layout/root-full ()
(~root-header-auto))
(defcomp ~layout-root-oob ()
(~oob-header-sx :parent-id "root-header-child"
(defcomp ~shared:layout/root-oob ()
(~shared:layout/oob-header-sx :parent-id "root-header-child"
:row (~root-header-auto true)))
(defcomp ~layout-root-mobile ()
(defcomp ~shared:layout/root-mobile ()
(~root-mobile-auto))
;; Post layout — root + post header
(defcomp ~layout-post-full ()
(defcomp ~shared:layout/post-full ()
(<> (~root-header-auto)
(~header-child-sx :inner (~post-header-auto))))
(~shared:layout/header-child-sx :inner (~post-header-auto))))
(defcomp ~layout-post-oob ()
(defcomp ~shared:layout/post-oob ()
(<> (~post-header-auto true)
(~oob-header-sx :parent-id "post-header-child" :row "")))
(~shared:layout/oob-header-sx :parent-id "post-header-child" :row "")))
(defcomp ~layout-post-mobile ()
(defcomp ~shared:layout/post-mobile ()
(let ((__phctx (post-header-ctx))
(__rhctx (root-header-ctx)))
(<>
(when (get __phctx "slug")
(~mobile-menu-section
(~shared:layout/mobile-menu-section
:label (slice (get __phctx "title") 0 40)
:href (get __phctx "link-href")
:level 1
@@ -230,35 +230,35 @@
(~root-mobile-auto))))
;; Post-admin layout — root + post header with nested admin row
(defcomp ~layout-post-admin-full (&key (selected :as string?))
(defcomp ~shared:layout/post-admin-full (&key (selected :as string?))
(let ((__admin-hdr (~post-admin-header-auto nil selected)))
(<> (~root-header-auto)
(~header-child-sx
(~shared:layout/header-child-sx
:inner (~post-header-auto nil)))))
(defcomp ~layout-post-admin-oob (&key (selected :as string?))
(defcomp ~shared:layout/post-admin-oob (&key (selected :as string?))
(<> (~post-header-auto true)
(~oob-header-sx :parent-id "post-header-child"
(~shared:layout/oob-header-sx :parent-id "post-header-child"
:row (~post-admin-header-auto nil selected))))
(defcomp ~layout-post-admin-mobile (&key (selected :as string?))
(defcomp ~shared:layout/post-admin-mobile (&key (selected :as string?))
(let ((__phctx (post-header-ctx)))
(<>
(when (get __phctx "slug")
(~mobile-menu-section
(~shared:layout/mobile-menu-section
:label "admin"
:href (get __phctx "admin-href")
:level 2
:items (~post-admin-nav-auto selected)))
(when (get __phctx "slug")
(~mobile-menu-section
(~shared:layout/mobile-menu-section
:label (slice (get __phctx "title") 0 40)
:href (get __phctx "link-href")
:level 1
:items (~post-nav-auto)))
(~root-mobile-auto))))
(defcomp ~error-content (&key (errnum :as string) (message :as string) (image :as string?))
(defcomp ~shared:layout/error-content (&key (errnum :as string) (message :as string) (image :as string?))
(div :class "text-center p-8 max-w-lg mx-auto"
(div :class "font-bold text-2xl md:text-4xl text-red-500 mb-4" errnum)
(div :class "text-stone-600 mb-4" message)
@@ -266,7 +266,7 @@
(div :class "flex justify-center"
(img :src image :width "300" :height "300")))))
(defcomp ~clear-oob-div (&key (id :as string))
(defcomp ~shared:layout/clear-oob-div (&key (id :as string))
(div :id id :sx-swap-oob "outerHTML"))
;; ---------------------------------------------------------------------------
@@ -280,12 +280,12 @@
(when (get __phctx "slug")
(<>
(when (> (get __phctx "page-cart-count") 0)
(~page-cart-badge :href (get __phctx "cart-href")
(~shared:layout/page-cart-badge :href (get __phctx "cart-href")
:count (str (get __phctx "page-cart-count"))))
(when (get __phctx "container-nav")
(~container-nav-wrapper :content (get __phctx "container-nav")))
(~shared:layout/container-nav-wrapper :content (get __phctx "container-nav")))
(when (get __phctx "is-admin")
(~admin-cog-button :href (get __phctx "admin-href")
(~shared:layout/admin-cog-button :href (get __phctx "admin-href")
:is-admin-page (get __phctx "is-admin-page"))))))))
(defmacro ~post-header-auto (oob)
@@ -293,9 +293,9 @@
(quasiquote
(let ((__phctx (post-header-ctx)))
(when (get __phctx "slug")
(~menu-row-sx :id "post-row" :level 1
(~shared:layout/menu-row-sx :id "post-row" :level 1
:link-href (get __phctx "link-href")
:link-label-content (~post-label
:link-label-content (~shared:layout/post-label
:feature-image (get __phctx "feature-image")
:title (get __phctx "title"))
:nav (~post-nav-auto)
@@ -310,28 +310,28 @@
(let ((__slug (get __phctx "slug"))
(__sc (get __phctx "select-colours")))
(<>
(~nav-link :href (app-url "events" (str "/" __slug "/admin/"))
(~shared:layout/nav-link :href (app-url "events" (str "/" __slug "/admin/"))
:label "calendars" :select-colours __sc
:is-selected (when (= (unquote selected) "calendars") "true"))
(~nav-link :href (app-url "market" (str "/" __slug "/admin/"))
(~shared:layout/nav-link :href (app-url "market" (str "/" __slug "/admin/"))
:label "markets" :select-colours __sc
:is-selected (when (= (unquote selected) "markets") "true"))
(~nav-link :href (app-url "cart" (str "/" __slug "/admin/payments/"))
(~shared:layout/nav-link :href (app-url "cart" (str "/" __slug "/admin/payments/"))
:label "payments" :select-colours __sc
:is-selected (when (= (unquote selected) "payments") "true"))
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/entries/"))
(~shared:layout/nav-link :href (app-url "blog" (str "/" __slug "/admin/entries/"))
:label "entries" :select-colours __sc
:is-selected (when (= (unquote selected) "entries") "true"))
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/data/"))
(~shared:layout/nav-link :href (app-url "blog" (str "/" __slug "/admin/data/"))
:label "data" :select-colours __sc
:is-selected (when (= (unquote selected) "data") "true"))
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/preview/"))
(~shared:layout/nav-link :href (app-url "blog" (str "/" __slug "/admin/preview/"))
:label "preview" :select-colours __sc
:is-selected (when (= (unquote selected) "preview") "true"))
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/edit/"))
(~shared:layout/nav-link :href (app-url "blog" (str "/" __slug "/admin/edit/"))
:label "edit" :select-colours __sc
:is-selected (when (= (unquote selected) "edit") "true"))
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/settings/"))
(~shared:layout/nav-link :href (app-url "blog" (str "/" __slug "/admin/settings/"))
:label "settings" :select-colours __sc
:is-selected (when (= (unquote selected) "settings") "true"))))))))
@@ -340,9 +340,9 @@
(quasiquote
(let ((__phctx (post-header-ctx)))
(when (get __phctx "slug")
(~menu-row-sx :id "post-admin-row" :level 2
(~shared:layout/menu-row-sx :id "post-admin-row" :level 2
:link-href (get __phctx "admin-href")
:link-label-content (~post-admin-label
:link-label-content (~shared:layout/post-admin-label
:selected (unquote selected))
:nav (~post-admin-nav-auto (unquote selected))
:child-id "post-admin-header-child"
@@ -352,27 +352,27 @@
;; Shared nav helpers — used by post_header_sx / post_admin_header_sx
;; ---------------------------------------------------------------------------
(defcomp ~container-nav-wrapper (&key content)
(defcomp ~shared:layout/container-nav-wrapper (&key content)
(div :id "entries-calendars-nav-wrapper"
:class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
content))
; @css justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 !bg-stone-500 !text-white
(defcomp ~admin-cog-button (&key (href :as string) (is-admin-page :as boolean?))
(defcomp ~shared:layout/admin-cog-button (&key (href :as string) (is-admin-page :as boolean?))
(div :class "relative nav-group"
(a :href href
:class (str "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 "
(if is-admin-page "!bg-stone-500 !text-white" ""))
(i :class "fa fa-cog" :aria-hidden "true"))))
(defcomp ~post-admin-label (&key (selected :as string?))
(defcomp ~shared:layout/post-admin-label (&key (selected :as string?))
(<>
(i :class "fa fa-shield-halved" :aria-hidden "true")
" admin"
(when selected
(span :class "text-white" selected))))
(defcomp ~nav-link (&key (href :as string) (hx-select :as string?) (label :as string?) (icon :as string?)
(defcomp ~shared:layout/nav-link (&key (href :as string) (hx-select :as string?) (label :as string?) (icon :as string?)
(aclass :as string?) (select-colours :as string?) (is-selected :as string?))
(div :class "relative nav-group"
(a :href href

View File

@@ -2,33 +2,33 @@
;; The single place where raw! lives — for CMS content (Ghost post body,
;; product descriptions, etc.) that arrives as pre-rendered HTML.
(defcomp ~rich-text (&key (html :as string))
(defcomp ~shared:misc/rich-text (&key (html :as string))
(raw! html))
(defcomp ~error-inline (&key (message :as string))
(defcomp ~shared:misc/error-inline (&key (message :as string))
(div :class "text-red-600 text-sm" message))
(defcomp ~notification-badge (&key (count :as number))
(defcomp ~shared:misc/notification-badge (&key (count :as number))
(span :class "bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5" count))
(defcomp ~cache-cleared (&key (time-str :as string))
(defcomp ~shared:misc/cache-cleared (&key (time-str :as string))
(span :class "text-green-600 font-bold" "Cache cleared at " time-str))
(defcomp ~error-list (&key (items :as list?))
(defcomp ~shared:misc/error-list (&key (items :as list?))
(ul :class "list-disc pl-5 space-y-1 text-sm text-red-600"
(when items items)))
(defcomp ~error-list-item (&key (message :as string))
(defcomp ~shared:misc/error-list-item (&key (message :as string))
(li message))
(defcomp ~fragment-error (&key (service :as string))
(defcomp ~shared:misc/fragment-error (&key (service :as string))
(p :class "text-sm text-red-600" "Service " (b service) " is unavailable."))
(defcomp ~htmx-sentinel (&key (id :as string) (hx-get :as string) (hx-trigger :as string)
(defcomp ~shared:misc/htmx-sentinel (&key (id :as string) (hx-get :as string) (hx-trigger :as string)
(hx-swap :as string) (class :as string?) extra-attrs)
(div :id id :sx-get hx-get :sx-trigger hx-trigger :sx-swap hx-swap :class class))
(defcomp ~nav-group-link (&key (href :as string) (hx-select :as string?) (nav-class :as string?) (label :as string))
(defcomp ~shared:misc/nav-group-link (&key (href :as string) (hx-select :as string?) (nav-class :as string?) (label :as string))
(div :class "relative nav-group"
(a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML"
@@ -39,7 +39,7 @@
;; Shared sentinel components — infinite scroll triggers
;; ---------------------------------------------------------------------------
(defcomp ~sentinel-mobile (&key (id :as string) (next-url :as string) (hyperscript :as string?))
(defcomp ~shared:misc/sentinel-mobile (&key (id :as string) (next-url :as string) (hyperscript :as string?))
(div :id id :class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinelmobile:retry"
:sx-swap "outerHTML" :_ hyperscript
@@ -50,7 +50,7 @@
(i :class "fa fa-exclamation-triangle text-2xl")
(p :class "mt-2" "Loading failed \u2014 retrying\u2026"))))
(defcomp ~sentinel-desktop (&key (id :as string) (next-url :as string) (hyperscript :as string?))
(defcomp ~shared:misc/sentinel-desktop (&key (id :as string) (next-url :as string) (hyperscript :as string?))
(div :id id :class "hidden md:block h-4 opacity-0 pointer-events-none"
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinel:retry"
:sx-swap "outerHTML" :_ hyperscript
@@ -59,20 +59,20 @@
(div :class "animate-spin h-6 w-6 border-2 border-stone-300 border-t-stone-600 rounded-full"))
(div :class "js-neterr hidden text-center py-2 text-stone-400 text-sm" "Retry\u2026")))
(defcomp ~sentinel-simple (&key (id :as string) (next-url :as string))
(defcomp ~shared:misc/sentinel-simple (&key (id :as string) (next-url :as string))
(div :id id :class "h-4 opacity-0 pointer-events-none"
:sx-get next-url :sx-trigger "intersect once delay:250ms" :sx-swap "outerHTML"
:role "status" :aria-hidden "true"
(div :class "text-center text-xs text-stone-400" "loading...")))
(defcomp ~end-of-results (&key (cls :as string?))
(defcomp ~shared:misc/end-of-results (&key (cls :as string?))
(div :class (or cls "col-span-full mt-4 text-center text-xs text-stone-400") "End of results"))
;; ---------------------------------------------------------------------------
;; Shared empty state — icon + message + optional action
;; ---------------------------------------------------------------------------
(defcomp ~empty-state (&key (icon :as string?) (message :as string) (cls :as string?) &rest children)
(defcomp ~shared:misc/empty-state (&key (icon :as string?) (message :as string) (cls :as string?) &rest children)
(div :class (or cls "p-8 text-center text-stone-400")
(when icon (div (i :class (str icon " text-4xl mb-2") :aria-hidden "true")))
(p message)
@@ -82,7 +82,7 @@
;; Shared badge — inline pill with configurable colours
;; ---------------------------------------------------------------------------
(defcomp ~badge (&key (label :as string) (cls :as string?))
(defcomp ~shared:misc/badge (&key (label :as string) (cls :as string?))
(span :class (str "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium " (or cls "bg-stone-100 text-stone-700"))
label))
@@ -90,7 +90,7 @@
;; Shared delete button with confirm dialog
;; ---------------------------------------------------------------------------
(defcomp ~delete-btn (&key (url :as string) (trigger-target :as string) (title :as string?)
(defcomp ~shared:misc/delete-btn (&key (url :as string) (trigger-target :as string) (title :as string?)
(text :as string?) (confirm-text :as string?) (cancel-text :as string?)
(sx-headers :as string?) (cls :as string?))
(button :type "button"
@@ -110,7 +110,7 @@
;; Shared price display — special + regular with strikethrough
;; ---------------------------------------------------------------------------
(defcomp ~price (&key (special-price :as string?) (regular-price :as string?))
(defcomp ~shared:misc/price (&key (special-price :as string?) (regular-price :as string?))
(div :class "mt-1 flex items-baseline gap-2 justify-center"
(when special-price (div :class "text-lg font-semibold text-emerald-700" special-price))
(when (and special-price regular-price) (div :class "text-sm line-through text-stone-500" regular-price))
@@ -120,7 +120,7 @@
;; Shared image-or-placeholder
;; ---------------------------------------------------------------------------
(defcomp ~img-or-placeholder (&key (src :as string?) (alt :as string?) (size-cls :as string?)
(defcomp ~shared:misc/img-or-placeholder (&key (src :as string?) (alt :as string?) (size-cls :as string?)
(placeholder-icon :as string?) (placeholder-text :as string?))
(if src
(img :src src :alt (or alt "") :class (or size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0"))
@@ -133,35 +133,35 @@
;; Shared view toggle — list/tile view switcher
;; ---------------------------------------------------------------------------
(defcomp ~list-svg ()
(defcomp ~shared:misc/list-svg ()
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
:stroke "currentColor" :stroke-width "2"
(path :stroke-linecap "round" :stroke-linejoin "round" :d "M4 6h16M4 12h16M4 18h16")))
(defcomp ~tile-svg ()
(defcomp ~shared:misc/tile-svg ()
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
:stroke "currentColor" :stroke-width "2"
(path :stroke-linecap "round" :stroke-linejoin "round"
:d "M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z")))
(defcomp ~view-toggle (&key (list-href :as string) (tile-href :as string) (hx-select :as string?)
(defcomp ~shared:misc/view-toggle (&key (list-href :as string) (tile-href :as string) (hx-select :as string?)
(list-cls :as string?) (tile-cls :as string?) (storage-key :as string?)
list-svg tile-svg)
(div :class "hidden md:flex justify-end px-3 pt-3 gap-1"
(a :href list-href :sx-get list-href :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true" :class (str "p-1.5 rounded " list-cls) :title "List view"
:_ (str "on click js localStorage.removeItem('" (or storage-key "view") "') end")
(or list-svg (~list-svg)))
(or list-svg (~shared:misc/list-svg)))
(a :href tile-href :sx-get tile-href :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true" :class (str "p-1.5 rounded " tile-cls) :title "Tile view"
:_ (str "on click js localStorage.setItem('" (or storage-key "view") "','tile') end")
(or tile-svg (~tile-svg)))))
(or tile-svg (~shared:misc/tile-svg)))))
;; ---------------------------------------------------------------------------
;; Shared CRUD admin panel — for calendars, markets, etc.
;; ---------------------------------------------------------------------------
(defcomp ~crud-create-form (&key (create-url :as string) (csrf :as string) (errors-id :as string?)
(defcomp ~shared:misc/crud-create-form (&key (create-url :as string) (csrf :as string) (errors-id :as string?)
(list-id :as string?) (placeholder :as string?) (label :as string?)
(btn-label :as string?))
(<>
@@ -177,12 +177,12 @@
:placeholder (or placeholder "Name")))
(button :type "submit" :class "border rounded px-3 py-2" (or btn-label "Add")))))
(defcomp ~crud-panel (&key form list (list-id :as string?))
(defcomp ~shared:misc/crud-panel (&key form list (list-id :as string?))
(section :class "p-4"
form
(div :id (or list-id "crud-list") :class "mt-6" list)))
(defcomp ~crud-item (&key (href :as string) (name :as string) (slug :as string) (del-url :as string)
(defcomp ~shared:misc/crud-item (&key (href :as string) (name :as string) (slug :as string) (del-url :as string)
(csrf-hdr :as string) (list-id :as string?) (confirm-title :as string?)
(confirm-text :as string?))
(div :class "mt-6 border rounded-lg p-4"
@@ -206,7 +206,7 @@
;; checkout prefix) used by blog, events, and cart admin panels.
;; ---------------------------------------------------------------------------
(defcomp ~sumup-settings-form (&key (update-url :as string) (csrf :as string?) (merchant-code :as string?)
(defcomp ~shared:misc/sumup-settings-form (&key (update-url :as string) (csrf :as string?) (merchant-code :as string?)
(placeholder :as string?) (input-cls :as string?)
(sumup-configured :as boolean?) (checkout-prefix :as string?)
(panel-id :as string?) (sx-select :as string?))
@@ -241,7 +241,7 @@
;; Shared avatar — image or initial-letter placeholder
;; ---------------------------------------------------------------------------
(defcomp ~avatar (&key (src :as string?) (cls :as string?) (initial :as string?))
(defcomp ~shared:misc/avatar (&key (src :as string?) (cls :as string?) (initial :as string?))
(if src
(img :src src :alt "" :class cls)
(div :class cls initial)))
@@ -250,7 +250,7 @@
;; Shared scroll-nav wrapper — horizontal scrollable nav with arrows
;; ---------------------------------------------------------------------------
(defcomp ~scroll-nav-wrapper (&key (wrapper-id :as string) (container-id :as string) (arrow-cls :as string?)
(defcomp ~shared:misc/scroll-nav-wrapper (&key (wrapper-id :as string) (container-id :as string) (arrow-cls :as string?)
(left-hs :as string?) (scroll-hs :as string?) (right-hs :as string?)
items (oob :as boolean?))
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"

View File

@@ -1,24 +1,24 @@
;; Blog navigation components
(defcomp ~blog-nav-empty (&key wrapper-id)
(defcomp ~shared:nav/blog-nav-empty (&key wrapper-id)
(div :id wrapper-id :sx-swap-oob "outerHTML"))
(defcomp ~blog-nav-item-link (&key href hx-get selected nav-cls img label)
(defcomp ~shared:nav/blog-nav-item-link (&key href hx-get selected nav-cls img label)
(div (a :href href :sx-get hx-get :sx-target "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
:aria-selected selected :class nav-cls
img (span label))))
(defcomp ~blog-nav-item-plain (&key href selected nav-cls img label)
(defcomp ~shared:nav/blog-nav-item-plain (&key href selected nav-cls img label)
(div (a :href href :aria-selected selected :class nav-cls
img (span label))))
;; Nav entries
(defcomp ~blog-nav-entries-empty ()
(defcomp ~shared:nav/blog-nav-entries-empty ()
(div :id "entries-calendars-nav-wrapper" :sx-swap-oob "true"))
(defcomp ~blog-nav-calendar-item (&key href nav-cls name)
(defcomp ~shared:nav/blog-nav-calendar-item (&key href nav-cls name)
(a :href href :class nav-cls
(i :class "fa fa-calendar" :aria-hidden "true")
(div name)))

View File

@@ -1,11 +1,11 @@
(defcomp ~calendar-entry-nav (&key (href :as string) (name :as string) (date-str :as string) (nav-class :as string?))
(defcomp ~shared:navigation/calendar-entry-nav (&key (href :as string) (name :as string) (date-str :as string) (nav-class :as string?))
(a :href href :class nav-class
(div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0")
(div :class "flex-1 min-w-0"
(div :class "font-medium truncate" name)
(div :class "text-xs text-stone-600 truncate" date-str))))
(defcomp ~calendar-link-nav (&key (href :as string) (name :as string) (nav-class :as string?)
(defcomp ~shared:navigation/calendar-link-nav (&key (href :as string) (name :as string) (nav-class :as string?)
(is-selected :as string?) (select-colours :as string?))
(a :href href
:sx-get href
@@ -18,13 +18,13 @@
(i :class "fa fa-calendar" :aria-hidden "true")
(span name)))
(defcomp ~market-link-nav (&key (href :as string) (name :as string) (nav-class :as string?)
(defcomp ~shared:navigation/market-link-nav (&key (href :as string) (name :as string) (nav-class :as string?)
(select-colours :as string?))
(a :href href :class (str (or nav-class "") " " (or select-colours ""))
(i :class "fa fa-shopping-bag" :aria-hidden "true")
(span name)))
(defcomp ~relation-nav (&key (href :as string) (name :as string) (icon :as string?)
(defcomp ~shared:navigation/relation-nav (&key (href :as string) (name :as string) (icon :as string?)
(nav-class :as string?) (relation-type :as string?))
(a :href href :class (or nav-class "flex items-center gap-3 rounded-lg py-2 px-3 text-sm text-stone-700 hover:bg-stone-100 transition-colors")
(when icon

View File

@@ -6,7 +6,7 @@
;; Order table rows
;; ---------------------------------------------------------------------------
(defcomp ~order-row-desktop (&key (oid :as string) (created :as string) (desc :as string) (total :as string)
(defcomp ~shared:orders/row-desktop (&key (oid :as string) (created :as string) (desc :as string) (total :as string)
(pill :as string) (status :as string) (url :as string))
(tr :class "hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60"
(td :class "px-3 py-2 align-top" (span :class "font-mono text-[11px] sm:text-xs" oid))
@@ -17,7 +17,7 @@
(td :class "px-3 py-0.5 align-top text-right"
(a :href url :class "inline-flex items-center px-3 py-1.5 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" "View"))))
(defcomp ~order-row-mobile (&key (oid :as string) (created :as string) (total :as string)
(defcomp ~shared:orders/row-mobile (&key (oid :as string) (created :as string) (total :as string)
(pill :as string) (status :as string) (url :as string))
(tr :class "sm:hidden border-t border-stone-100"
(td :colspan "5" :class "px-3 py-3"
@@ -30,16 +30,16 @@
(div :class "font-medium text-stone-800" total)
(a :href url :class "inline-flex items-center px-2 py-1 text-[11px] rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition shrink-0" "View"))))))
(defcomp ~order-end-row ()
(defcomp ~shared:orders/end-row ()
(tr (td :colspan "5" :class "px-3 py-4 text-center text-xs text-stone-400"
(~end-of-results :cls "text-center text-xs text-stone-400"))))
(~shared:misc/end-of-results :cls "text-center text-xs text-stone-400"))))
(defcomp ~order-empty-state ()
(defcomp ~shared:orders/empty-state ()
(div :class "max-w-full px-3 py-3 space-y-3"
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-4 sm:p-6 text-sm text-stone-700"
"No orders yet.")))
(defcomp ~order-table (&key rows)
(defcomp ~shared:orders/table (&key rows)
(div :class "max-w-full px-3 py-3 space-y-3"
(div :class "overflow-x-auto rounded-2xl border border-stone-200 bg-white/80"
(table :class "min-w-full text-xs sm:text-sm"
@@ -53,7 +53,7 @@
(th :class "px-3 py-2 text-left font-medium" "")))
(tbody rows)))))
(defcomp ~order-list-header (&key search-mobile)
(defcomp ~shared:orders/list-header (&key search-mobile)
(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"
(div :class "space-y-1"
(p :class "text-xs sm:text-sm text-stone-600" "Recent orders placed via the checkout."))
@@ -63,13 +63,13 @@
;; Order detail panels
;; ---------------------------------------------------------------------------
(defcomp ~order-item-image (&key (src :as string) (alt :as string))
(defcomp ~shared:orders/item-image (&key (src :as string) (alt :as string))
(img :src src :alt alt :class "w-full h-full object-contain object-center" :loading "lazy" :decoding "async"))
(defcomp ~order-item-no-image ()
(defcomp ~shared:orders/item-no-image ()
(div :class "w-full h-full flex items-center justify-center text-[9px] text-stone-400" "No image"))
(defcomp ~order-item-row (&key (href :as string) img (title :as string) (pid :as string)
(defcomp ~shared:orders/item-row (&key (href :as string) img (title :as string) (pid :as string)
(qty :as string) (price :as string))
(li (a :class "w-full py-2 flex gap-3" :href href
(div :class "w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden" img)
@@ -81,12 +81,12 @@
(p qty)
(p price))))))
(defcomp ~order-items-panel (&key items)
(defcomp ~shared:orders/items-panel (&key items)
(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6"
(h2 :class "text-sm sm:text-base font-semibold mb-3" "Items")
(ul :class "divide-y divide-stone-100 text-xs sm:text-sm" items)))
(defcomp ~order-calendar-entry (&key (name :as string) (pill :as string) (status :as string)
(defcomp ~shared:orders/calendar-entry (&key (name :as string) (pill :as string) (status :as string)
(date-str :as string) (cost :as string))
(li :class "px-4 py-3 flex items-start justify-between text-sm"
(div (div :class "font-medium flex items-center gap-2"
@@ -94,19 +94,19 @@
(div :class "text-xs text-stone-500" date-str))
(div :class "ml-4 font-medium" cost)))
(defcomp ~order-calendar-section (&key items)
(defcomp ~shared:orders/calendar-section (&key items)
(section :class "mt-6 space-y-3"
(h2 :class "text-base sm:text-lg font-semibold" "Calendar bookings in this order")
(ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" items)))
(defcomp ~order-detail-panel (&key summary items calendar)
(defcomp ~shared:orders/detail-panel (&key summary items calendar)
(div :class "max-w-full px-3 py-3 space-y-4" summary items calendar))
(defcomp ~order-pay-btn (&key (url :as string))
(defcomp ~shared:orders/pay-btn (&key (url :as string))
(a :href url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"
(i :class "fa fa-credit-card mr-2" :aria-hidden "true") "Open payment page"))
(defcomp ~order-detail-filter (&key (info :as string) (list-url :as string) (recheck-url :as string)
(defcomp ~shared:orders/detail-filter (&key (info :as string) (list-url :as string) (recheck-url :as string)
(csrf :as string) pay)
(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"
(div :class "space-y-1"
@@ -120,43 +120,43 @@
(i :class "fa-solid fa-rotate mr-2" :aria-hidden "true") "Re-check status"))
pay)))
(defcomp ~order-detail-header-stack (&key auth orders order)
(~header-child-sx :inner
(<> auth (~header-child-sx :id "auth-header-child" :inner
(<> orders (~header-child-sx :id "orders-header-child" :inner order))))))
(defcomp ~shared:orders/detail-header-stack (&key auth orders order)
(~shared:layout/header-child-sx :inner
(<> auth (~shared:layout/header-child-sx :id "auth-header-child" :inner
(<> orders (~shared:layout/header-child-sx :id "orders-header-child" :inner order))))))
;; ---------------------------------------------------------------------------
;; Data-driven order rows (replaces Python loop)
;; ---------------------------------------------------------------------------
(defcomp ~order-rows-from-data (&key (orders :as list?) (page :as number) (total-pages :as number)
(defcomp ~shared:orders/rows-from-data (&key (orders :as list?) (page :as number) (total-pages :as number)
(next-url :as string?))
(<>
(map (lambda (o)
(<>
(~order-row-desktop :oid (get o "oid") :created (get o "created")
(~shared:orders/row-desktop :oid (get o "oid") :created (get o "created")
:desc (get o "desc") :total (get o "total")
:pill (get o "pill_desktop") :status (get o "status") :url (get o "url"))
(~order-row-mobile :oid (get o "oid") :created (get o "created")
(~shared:orders/row-mobile :oid (get o "oid") :created (get o "created")
:total (get o "total") :pill (get o "pill_mobile")
:status (get o "status") :url (get o "url"))))
(or orders (list)))
(if next-url
(~infinite-scroll :url next-url :page page :total-pages total-pages
(~shared:controls/infinite-scroll :url next-url :page page :total-pages total-pages
:id-prefix "orders" :colspan 5)
(~order-end-row))))
(~shared:orders/end-row))))
;; ---------------------------------------------------------------------------
;; Data-driven order items (replaces Python loop)
;; ---------------------------------------------------------------------------
(defcomp ~order-items-from-data (&key (items :as list?))
(~order-items-panel
(defcomp ~shared:orders/items-from-data (&key (items :as list?))
(~shared:orders/items-panel
:items (<> (map (lambda (item)
(let* ((img (if (get item "product_image")
(~order-item-image :src (get item "product_image") :alt (or (get item "product_title") "Product image"))
(~order-item-no-image))))
(~order-item-row
(~shared:orders/item-image :src (get item "product_image") :alt (or (get item "product_title") "Product image"))
(~shared:orders/item-no-image))))
(~shared:orders/item-row
:href (get item "href") :img img
:title (or (get item "product_title") "Unknown product")
:pid (str "Product ID: " (get item "product_id"))
@@ -168,10 +168,10 @@
;; Data-driven calendar entries (replaces Python loop)
;; ---------------------------------------------------------------------------
(defcomp ~order-calendar-from-data (&key (entries :as list?))
(~order-calendar-section
(defcomp ~shared:orders/calendar-from-data (&key (entries :as list?))
(~shared:orders/calendar-section
:items (<> (map (lambda (e)
(~order-calendar-entry
(~shared:orders/calendar-entry
:name (get e "name") :pill (get e "pill")
:status (get e "status") :date-str (get e "date_str")
:cost (get e "cost")))
@@ -186,7 +186,7 @@
;; ---------------------------------------------------------------------------
;; Status pill class mapping
(defcomp ~order-status-pill-cls (&key (status :as string?))
(defcomp ~shared:orders/status-pill-cls (&key (status :as string?))
(let* ((sl (lower (or status ""))))
(cond
((= sl "paid") "border-emerald-300 bg-emerald-50 text-emerald-700")
@@ -194,46 +194,46 @@
(true "border-stone-300 bg-stone-50 text-stone-700"))))
;; Single order row pair (desktop + mobile) — takes serialized order data dict
(defcomp ~order-row-pair (&key (order :as dict) (detail-url-prefix :as string))
(defcomp ~shared:orders/row-pair (&key (order :as dict) (detail-url-prefix :as string))
(let* ((status (or (get order "status") "pending"))
(pill-base (~order-status-pill-cls :status status))
(pill-base (~shared:orders/status-pill-cls :status status))
(oid (str "#" (get order "id")))
(created (or (get order "created_at_formatted") "\u2014"))
(desc (or (get order "description") ""))
(total (str (or (get order "currency") "GBP") " " (or (get order "total_formatted") "0.00")))
(url (str detail-url-prefix (get order "id") "/")))
(<>
(~order-row-desktop
(~shared:orders/row-desktop
:oid oid :created created :desc desc :total total
:pill (str "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs " pill-base)
:status status :url url)
(~order-row-mobile
(~shared:orders/row-mobile
:oid oid :created created :total total
:pill (str "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] " pill-base)
:status status :url url))))
;; Assembled orders list content
(defcomp ~orders-list-content (&key (orders :as list) (page :as number) (total-pages :as number)
(defcomp ~shared:orders/list-content (&key (orders :as list) (page :as number) (total-pages :as number)
(rows-url :as string) (detail-url-prefix :as string))
(if (empty? orders)
(~order-empty-state)
(~order-table
(~shared:orders/empty-state)
(~shared:orders/table
:rows (<>
(map (lambda (order)
(~order-row-pair :order order :detail-url-prefix detail-url-prefix))
(~shared:orders/row-pair :order order :detail-url-prefix detail-url-prefix))
orders)
(if (< page total-pages)
(~infinite-scroll
(~shared:controls/infinite-scroll
:url (str rows-url "?page=" (inc page))
:page page :total-pages total-pages
:id-prefix "orders" :colspan 5)
(~order-end-row))))))
(~shared:orders/end-row))))))
;; Assembled order detail content — replaces Python _order_main_sx
(defcomp ~order-detail-content (&key (order :as dict) (calendar-entries :as list?))
(defcomp ~shared:orders/detail-content (&key (order :as dict) (calendar-entries :as list?))
(let* ((items (get order "items")))
(~order-detail-panel
:summary (~order-summary-card
(~shared:orders/detail-panel
:summary (~shared:cards/order-summary-card
:order-id (get order "id")
:created-at (get order "created_at_formatted")
:description (get order "description")
@@ -241,21 +241,21 @@
:currency (get order "currency")
:total-amount (get order "total_formatted"))
:items (when (not (empty? (or items (list))))
(~order-items-panel
(~shared:orders/items-panel
:items (map (lambda (item)
(~order-item-row
(~shared:orders/item-row
:href (get item "product_url")
:img (if (get item "product_image")
(~order-item-image :src (get item "product_image")
(~shared:orders/item-image :src (get item "product_image")
:alt (or (get item "product_title") "Product image"))
(~order-item-no-image))
(~shared:orders/item-no-image))
:title (or (get item "product_title") "Unknown product")
:pid (str "Product ID: " (get item "product_id"))
:qty (str "Qty: " (get item "quantity"))
:price (str (or (get item "currency") (get order "currency") "GBP") " " (or (get item "unit_price_formatted") "0.00"))))
items)))
:calendar (when (not (empty? (or calendar-entries (list))))
(~order-calendar-section
(~shared:orders/calendar-section
:items (map (lambda (e)
(let* ((st (or (get e "state") ""))
(pill (cond
@@ -263,7 +263,7 @@
((= st "provisional") "bg-amber-100 text-amber-800")
((= st "ordered") "bg-blue-100 text-blue-800")
(true "bg-stone-100 text-stone-700"))))
(~order-calendar-entry
(~shared:orders/calendar-entry
:name (get e "name")
:pill (str "inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium " pill)
:status (upper (slice st 0 1))
@@ -272,33 +272,33 @@
calendar-entries))))))
;; Assembled order detail filter — replaces Python _order_filter_sx
(defcomp ~order-detail-filter-content (&key (order :as dict) (list-url :as string) (recheck-url :as string)
(defcomp ~shared:orders/detail-filter-content (&key (order :as dict) (list-url :as string) (recheck-url :as string)
(pay-url :as string) (csrf :as string))
(let* ((status (or (get order "status") "pending"))
(created (or (get order "created_at_formatted") "\u2014")))
(~order-detail-filter
(~shared:orders/detail-filter
:info (str "Placed " created " \u00b7 Status: " status)
:list-url list-url
:recheck-url recheck-url
:csrf csrf
:pay (when (!= status "paid")
(~order-pay-btn :url pay-url)))))
(~shared:orders/pay-btn :url pay-url)))))
;; ---------------------------------------------------------------------------
;; Checkout return components
;; ---------------------------------------------------------------------------
(defcomp ~checkout-return-header (&key (status :as string))
(defcomp ~shared:orders/checkout-return-header (&key (status :as string))
(header :class "mb-6 sm:mb-8"
(h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Payment complete")
(p :class "text-xs sm:text-sm text-stone-600"
(str "Your checkout session is " status "."))))
(defcomp ~checkout-return-missing ()
(defcomp ~shared:orders/checkout-return-missing ()
(div :class "max-w-full px-3 py-3 space-y-4"
(p :class "text-sm text-stone-600" "Order not found.")))
(defcomp ~checkout-return-ticket (&key (name :as string) (pill :as string) (state :as string)
(defcomp ~shared:orders/checkout-return-ticket (&key (name :as string) (pill :as string) (state :as string)
(type-name :as string?) (date-str :as string) (code :as string?)
(price :as string))
(li :class "px-4 py-3 flex items-start justify-between text-sm"
@@ -310,23 +310,23 @@
(when code (div :class "font-mono text-xs text-stone-400" code)))
(div :class "ml-4 font-medium" price)))
(defcomp ~checkout-return-tickets (&key items)
(defcomp ~shared:orders/checkout-return-tickets (&key items)
(section :class "mt-6 space-y-3"
(h2 :class "text-base sm:text-lg font-semibold" "Tickets")
(ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" items)))
(defcomp ~checkout-return-failed (&key (order-id :as string?))
(defcomp ~shared:orders/checkout-return-failed (&key (order-id :as string?))
(div :class "rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900"
(p :class "font-medium" "Payment failed")
(p "Please try again or contact support."
(when order-id (span " Order #" (str order-id))))))
(defcomp ~checkout-return-paid ()
(defcomp ~shared:orders/checkout-return-paid ()
(div :class "rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900"
(p :class "font-medium" "Payment successful!")
(p "Your order has been confirmed.")))
(defcomp ~checkout-return-content (&key summary items calendar tickets status-message)
(defcomp ~shared:orders/checkout-return-content (&key summary items calendar tickets status-message)
(div :class "max-w-full px-3 py-3 space-y-4"
status-message summary items calendar tickets))
@@ -334,15 +334,15 @@
;; Checkout error screens
;; ---------------------------------------------------------------------------
(defcomp ~checkout-error-header ()
(defcomp ~shared:orders/checkout-error-header ()
(header :class "mb-6 sm:mb-8"
(h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Checkout error")
(p :class "text-xs sm:text-sm text-stone-600" "We tried to start your payment with SumUp but hit a problem.")))
(defcomp ~checkout-error-order-id (&key (oid :as string))
(defcomp ~shared:orders/checkout-error-order-id (&key (oid :as string))
(p :class "text-xs text-rose-800/80" "Order ID: " (span :class "font-mono" oid)))
(defcomp ~checkout-error-content (&key (msg :as string) order (back-url :as string))
(defcomp ~shared:orders/checkout-error-content (&key (msg :as string) order (back-url :as string))
(div :class "max-w-full px-3 py-3 space-y-4"
(div :class "rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2"
(p :class "font-medium" "Something went wrong.")

View File

@@ -1,4 +1,4 @@
(defcomp ~base-shell (&key (title :as string) (asset-url :as string) &rest children)
(defcomp ~shared:pages/base-shell (&key (title :as string) (asset-url :as string) &rest children)
(<>
(raw! "<!doctype html>")
(html :lang "en"
@@ -23,14 +23,14 @@
;; <script>__sxResolve("id", "(resolved sx ...)")</script>
;; ---------------------------------------------------------------------------
(defcomp ~suspense (&key (id :as string) fallback &rest children)
(defcomp ~shared:pages/suspense (&key (id :as string) fallback &rest children)
(div :id (str "sx-suspense-" id)
:data-suspense id
:style "display:contents"
(if (not (empty? children)) children fallback)))
(defcomp ~error-page (&key (title :as string) (message :as string) (image :as string?) (asset-url :as string))
(~base-shell :title title :asset-url asset-url
(defcomp ~shared:pages/error-page (&key (title :as string) (message :as string) (image :as string?) (asset-url :as string))
(~shared:pages/base-shell :title title :asset-url asset-url
(div :class "text-center p-8 max-w-lg mx-auto"
(div :class "font-bold text-2xl md:text-4xl text-red-500 mb-4"
(div message))

View File

@@ -1,4 +1,4 @@
(defcomp ~relation-attach (&key (create-url :as string) (label :as string?) (icon :as string?))
(defcomp ~shared:relations/attach (&key (create-url :as string) (label :as string?) (icon :as string?))
(a :href create-url
:sx-get create-url
:sx-target "#main-panel"
@@ -8,7 +8,7 @@
(when icon (i :class icon))
(span (or label "Add"))))
(defcomp ~relation-detach (&key (detach-url :as string) (name :as string?))
(defcomp ~shared:relations/detach (&key (detach-url :as string) (name :as string?))
(button :sx-delete detach-url
:sx-confirm (str "Remove " (or name "this item") "?")
:class "text-red-500 hover:text-red-700 text-sm p-1 rounded hover:bg-red-50 transition-colors"

View File

@@ -11,7 +11,7 @@
;; - Pre-rendered meta HTML from callers
;; ---------------------------------------------------------------------------
(defcomp ~sx-page-shell (&key (title :as string) (meta-html :as string?) (csrf :as string)
(defcomp ~shared:shell/sx-page-shell (&key (title :as string) (meta-html :as string?) (csrf :as string)
(sx-css :as string?) (sx-css-classes :as string?)
(component-hash :as string?) (component-defs :as string?)
(pages-sx :as string?) (page-sx :as string?)

View File

@@ -14,13 +14,13 @@ def _load_components():
# ---------------------------------------------------------------------------
# ~cart-mini
# ~shared:fragments/cart-mini
# ---------------------------------------------------------------------------
class TestCartMini:
def test_empty_cart_shows_logo(self):
html = sx(
'(~cart-mini :cart-count cart-count :blog-url blog-url :cart-url cart-url)',
'(~shared:fragments/cart-mini :cart-count cart-count :blog-url blog-url :cart-url cart-url)',
**{"cart-count": 0, "blog-url": "https://blog.example.com/", "cart-url": "https://cart.example.com/"},
)
assert 'id="cart-mini"' in html
@@ -30,7 +30,7 @@ class TestCartMini:
def test_nonempty_cart_shows_badge(self):
html = sx(
'(~cart-mini :cart-count cart-count :blog-url blog-url :cart-url cart-url)',
'(~shared:fragments/cart-mini :cart-count cart-count :blog-url blog-url :cart-url cart-url)',
**{"cart-count": 3, "blog-url": "https://blog.example.com/", "cart-url": "https://cart.example.com/"},
)
assert 'id="cart-mini"' in html
@@ -41,13 +41,13 @@ class TestCartMini:
def test_oob_attribute(self):
html = sx(
'(~cart-mini :cart-count 0 :blog-url "" :cart-url "" :oob "true")',
'(~shared:fragments/cart-mini :cart-count 0 :blog-url "" :cart-url "" :oob "true")',
)
assert 'sx-swap-oob="true"' in html
def test_no_oob_when_nil(self):
html = sx(
'(~cart-mini :cart-count 0 :blog-url "" :cart-url "")',
'(~shared:fragments/cart-mini :cart-count 0 :blog-url "" :cart-url "")',
)
assert "sx-swap-oob" not in html
@@ -94,13 +94,13 @@ class TestAuthMenu:
# ---------------------------------------------------------------------------
# ~account-nav-item
# ~shared:fragments/account-nav-item
# ---------------------------------------------------------------------------
class TestAccountNavItem:
def test_renders_link(self):
html = sx(
'(~account-nav-item :href "/orders/" :label "orders")',
'(~shared:fragments/account-nav-item :href "/orders/" :label "orders")',
)
assert 'href="/orders/"' in html
assert ">orders<" in html
@@ -109,19 +109,19 @@ class TestAccountNavItem:
def test_custom_label(self):
html = sx(
'(~account-nav-item :href "/cart/orders/" :label "my orders")',
'(~shared:fragments/account-nav-item :href "/cart/orders/" :label "my orders")',
)
assert ">my orders<" in html
# ---------------------------------------------------------------------------
# ~calendar-entry-nav
# ~shared:navigation/calendar-entry-nav
# ---------------------------------------------------------------------------
class TestCalendarEntryNav:
def test_renders_entry(self):
html = sx(
'(~calendar-entry-nav :href "/events/entry/1/" :name "Workshop" :date-str "Jan 15, 2026 at 14:00" :nav-class "btn")',
'(~shared:navigation/calendar-entry-nav :href "/events/entry/1/" :name "Workshop" :date-str "Jan 15, 2026 at 14:00" :nav-class "btn")',
**{"date-str": "Jan 15, 2026 at 14:00", "nav-class": "btn"},
)
assert 'href="/events/entry/1/"' in html
@@ -130,13 +130,13 @@ class TestCalendarEntryNav:
# ---------------------------------------------------------------------------
# ~calendar-link-nav
# ~shared:navigation/calendar-link-nav
# ---------------------------------------------------------------------------
class TestCalendarLinkNav:
def test_renders_calendar_link(self):
html = sx(
'(~calendar-link-nav :href "/events/cal/" :name "Art Events" :nav-class "btn")',
'(~shared:navigation/calendar-link-nav :href "/events/cal/" :name "Art Events" :nav-class "btn")',
**{"nav-class": "btn"},
)
assert 'href="/events/cal/"' in html
@@ -145,13 +145,13 @@ class TestCalendarLinkNav:
# ---------------------------------------------------------------------------
# ~market-link-nav
# ~shared:navigation/market-link-nav
# ---------------------------------------------------------------------------
class TestMarketLinkNav:
def test_renders_market_link(self):
html = sx(
'(~market-link-nav :href "/market/farm/" :name "Farm Shop" :nav-class "btn")',
'(~shared:navigation/market-link-nav :href "/market/farm/" :name "Farm Shop" :nav-class "btn")',
**{"nav-class": "btn"},
)
assert 'href="/market/farm/"' in html
@@ -160,13 +160,13 @@ class TestMarketLinkNav:
# ---------------------------------------------------------------------------
# ~post-card
# ~shared:cards/post-card
# ---------------------------------------------------------------------------
class TestPostCard:
def test_basic_card(self):
html = sx(
'(~post-card :title "Hello World" :slug "hello" :href "/hello/"'
'(~shared:cards/post-card :title "Hello World" :slug "hello" :href "/hello/"'
' :feature-image "/img/hello.jpg" :excerpt "A test post"'
' :status "published" :published-at "15 Jan 2026"'
' :hx-select "#main-panel")',
@@ -184,7 +184,7 @@ class TestPostCard:
def test_draft_status(self):
html = sx(
'(~post-card :title "Draft" :slug "draft" :href "/draft/"'
'(~shared:cards/post-card :title "Draft" :slug "draft" :href "/draft/"'
' :status "draft" :updated-at "15 Jan 2026"'
' :hx-select "#main-panel")',
**{"hx-select": "#main-panel", "updated-at": "15 Jan 2026"},
@@ -195,7 +195,7 @@ class TestPostCard:
def test_draft_with_publish_requested(self):
html = sx(
'(~post-card :title "Pending" :slug "pending" :href "/pending/"'
'(~shared:cards/post-card :title "Pending" :slug "pending" :href "/pending/"'
' :status "draft" :publish-requested true'
' :hx-select "#main-panel")',
**{"hx-select": "#main-panel", "publish-requested": True},
@@ -205,7 +205,7 @@ class TestPostCard:
def test_no_image(self):
html = sx(
'(~post-card :title "No Img" :slug "no-img" :href "/no-img/"'
'(~shared:cards/post-card :title "No Img" :slug "no-img" :href "/no-img/"'
' :status "published" :hx-select "#main-panel")',
**{"hx-select": "#main-panel"},
)
@@ -214,7 +214,7 @@ class TestPostCard:
def test_widgets_and_at_bar(self):
"""Widgets and at-bar are sx kwarg slots rendered by the client."""
html = sx(
'(~post-card :title "T" :slug "s" :href "/"'
'(~shared:cards/post-card :title "T" :slug "s" :href "/"'
' :status "published" :hx-select "#mp")',
**{"hx-select": "#mp"},
)
@@ -224,13 +224,13 @@ class TestPostCard:
# ---------------------------------------------------------------------------
# ~base-shell and ~error-page
# ~shared:pages/base-shell and ~shared:pages/error-page
# ---------------------------------------------------------------------------
class TestBaseShell:
def test_renders_full_page(self):
html = sx(
'(~base-shell :title "Test" :asset-url "/static" (p "Hello"))',
'(~shared:pages/base-shell :title "Test" :asset-url "/static" (p "Hello"))',
**{"asset-url": "/static"},
)
assert "<!doctype html>" in html
@@ -243,7 +243,7 @@ class TestBaseShell:
class TestErrorPage:
def test_404_page(self):
html = sx(
'(~error-page :title "404 Error" :message "NOT FOUND" :image "/static/errors/404.gif" :asset-url "/static")',
'(~shared:pages/error-page :title "404 Error" :message "NOT FOUND" :image "/static/errors/404.gif" :asset-url "/static")',
**{"asset-url": "/static"},
)
assert "<!doctype html>" in html
@@ -253,7 +253,7 @@ class TestErrorPage:
def test_error_page_no_image(self):
html = sx(
'(~error-page :title "500 Error" :message "SERVER ERROR" :asset-url "/static")',
'(~shared:pages/error-page :title "500 Error" :message "SERVER ERROR" :asset-url "/static")',
**{"asset-url": "/static"},
)
assert "SERVER ERROR" in html
@@ -261,13 +261,13 @@ class TestErrorPage:
# ---------------------------------------------------------------------------
# ~relation-nav
# ~shared:navigation/relation-nav
# ---------------------------------------------------------------------------
class TestRelationNav:
def test_renders_link(self):
html = sx(
'(~relation-nav :href "/market/farm/" :name "Farm Shop" :icon "fa fa-shopping-bag")',
'(~shared:navigation/relation-nav :href "/market/farm/" :name "Farm Shop" :icon "fa fa-shopping-bag")',
)
assert 'href="/market/farm/"' in html
assert "Farm Shop" in html
@@ -275,7 +275,7 @@ class TestRelationNav:
def test_no_icon(self):
html = sx(
'(~relation-nav :href "/cal/" :name "Events")',
'(~shared:navigation/relation-nav :href "/cal/" :name "Events")',
)
assert 'href="/cal/"' in html
assert "Events" in html
@@ -283,20 +283,20 @@ class TestRelationNav:
def test_custom_nav_class(self):
html = sx(
'(~relation-nav :href "/" :name "X" :nav-class "custom-class")',
'(~shared:navigation/relation-nav :href "/" :name "X" :nav-class "custom-class")',
**{"nav-class": "custom-class"},
)
assert 'class="custom-class"' in html
# ---------------------------------------------------------------------------
# ~relation-attach
# ~shared:relations/attach
# ---------------------------------------------------------------------------
class TestRelationAttach:
def test_renders_button(self):
html = sx(
'(~relation-attach :create-url "/market/create/" :label "Add Market" :icon "fa fa-plus")',
'(~shared:relations/attach :create-url "/market/create/" :label "Add Market" :icon "fa fa-plus")',
**{"create-url": "/market/create/"},
)
assert 'href="/market/create/"' in html
@@ -306,20 +306,20 @@ class TestRelationAttach:
def test_default_label(self):
html = sx(
'(~relation-attach :create-url "/create/")',
'(~shared:relations/attach :create-url "/create/")',
**{"create-url": "/create/"},
)
assert "Add" in html
# ---------------------------------------------------------------------------
# ~relation-detach
# ~shared:relations/detach
# ---------------------------------------------------------------------------
class TestRelationDetach:
def test_renders_button(self):
html = sx(
'(~relation-detach :detach-url "/api/unrelate" :name "Farm Shop")',
'(~shared:relations/detach :detach-url "/api/unrelate" :name "Farm Shop")',
**{"detach-url": "/api/unrelate"},
)
assert 'sx-delete="/api/unrelate"' in html
@@ -328,7 +328,7 @@ class TestRelationDetach:
def test_default_name(self):
html = sx(
'(~relation-detach :detach-url "/api/unrelate")',
'(~shared:relations/detach :detach-url "/api/unrelate")',
**{"detach-url": "/api/unrelate"},
)
assert "this item" in html
@@ -343,7 +343,7 @@ class TestRenderPage:
from shared.sx.page import render_page
html = render_page(
'(~error-page :title "Test" :message "MSG" :asset-url "/s")',
'(~shared:pages/error-page :title "Test" :message "MSG" :asset-url "/s")',
**{"asset-url": "/s"},
)
assert "<!doctype html>" in html

View File

@@ -33,10 +33,10 @@ def make_env(*sx_sources: str) -> dict:
class TestScanAst:
def test_simple_component_ref(self):
env = make_env('(defcomp ~card (&key title) (div (~badge :label title)))')
env = make_env('(defcomp ~card (&key title) (div (~shared:misc/badge :label title)))')
comp = env["~card"]
refs = _scan_ast(comp.body)
assert refs == {"~badge"}
assert refs == {"~shared:misc/badge"}
def test_no_refs(self):
env = make_env('(defcomp ~plain (&key text) (div :class "p-4" text))')
@@ -77,11 +77,11 @@ class TestScanAst:
class TestTransitiveDeps:
def test_direct_dep(self):
env = make_env(
'(defcomp ~card (&key) (div (~badge)))',
'(defcomp ~badge (&key) (span ""))',
'(defcomp ~card (&key) (div (~shared:misc/badge)))',
'(defcomp ~shared:misc/badge (&key) (span ""))',
)
deps = transitive_deps("~card", env)
assert deps == {"~badge"}
assert deps == {"~shared:misc/badge"}
def test_transitive(self):
env = make_env(
@@ -115,11 +115,11 @@ class TestTransitiveDeps:
def test_without_tilde_prefix(self):
env = make_env(
'(defcomp ~card (&key) (div (~badge)))',
'(defcomp ~badge (&key) (span ""))',
'(defcomp ~card (&key) (div (~shared:misc/badge)))',
'(defcomp ~shared:misc/badge (&key) (span ""))',
)
deps = transitive_deps("card", env)
assert deps == {"~badge"}
assert deps == {"~shared:misc/badge"}
# ---------------------------------------------------------------------------
@@ -130,13 +130,13 @@ class TestComputeAllDeps:
def test_sets_deps_on_components(self):
env = make_env(
'(defcomp ~page (&key) (div (~card)))',
'(defcomp ~card (&key) (div (~badge)))',
'(defcomp ~badge (&key) (span ""))',
'(defcomp ~card (&key) (div (~shared:misc/badge)))',
'(defcomp ~shared:misc/badge (&key) (span ""))',
)
compute_all_deps(env)
assert env["~page"].deps == {"~card", "~badge"}
assert env["~card"].deps == {"~badge"}
assert env["~badge"].deps == set()
assert env["~page"].deps == {"~card", "~shared:misc/badge"}
assert env["~card"].deps == {"~shared:misc/badge"}
assert env["~shared:misc/badge"].deps == set()
# ---------------------------------------------------------------------------
@@ -145,9 +145,9 @@ class TestComputeAllDeps:
class TestScanComponentsFromSx:
def test_basic(self):
source = '(~card :title "hi" (~badge :label "new"))'
source = '(~card :title "hi" (~shared:misc/badge :label "new"))'
refs = scan_components_from_sx(source)
assert refs == {"~card", "~badge"}
assert refs == {"~card", "~shared:misc/badge"}
def test_no_components(self):
source = '(div :class "p-4" (p "hello"))'
@@ -162,8 +162,8 @@ class TestScanComponentsFromSx:
class TestComponentsNeeded:
def test_page_with_deps(self):
env = make_env(
'(defcomp ~page-layout (&key) (div (~nav) (~footer)))',
'(defcomp ~nav (&key) (nav "nav"))',
'(defcomp ~page-layout (&key) (div (~plans/environment-images/nav) (~footer)))',
'(defcomp ~plans/environment-images/nav (&key) (nav "nav"))',
'(defcomp ~footer (&key) (footer "footer"))',
'(defcomp ~unused (&key) (div "not needed"))',
)
@@ -171,6 +171,6 @@ class TestComponentsNeeded:
page_sx = '(~page-layout)'
needed = components_needed(page_sx, env)
assert "~page-layout" in needed
assert "~nav" in needed
assert "~plans/environment-images/nav" in needed
assert "~footer" in needed
assert "~unused" not in needed

View File

@@ -228,8 +228,8 @@ class TestRawHtml:
class TestComponents:
def test_basic_component(self):
env = {}
evaluate(parse('(defcomp ~badge (&key label) (span :class "badge" label))'), env)
html = render(parse('(~badge :label "New")'), env)
evaluate(parse('(defcomp ~shared:misc/badge (&key label) (span :class "badge" label))'), env)
html = render(parse('(~shared:misc/badge :label "New")'), env)
assert html == '<span class="badge">New</span>'
def test_component_with_children(self):

View File

@@ -87,7 +87,7 @@ class TestScanIoRefs:
assert refs == set()
def test_component_ref_not_io(self):
"""Component references (~name) should not appear as IO refs."""
"""Component references (~plans/content-addressed-components/name) should not appear as IO refs."""
env = make_env(
'(defcomp ~page (&key) (div (~card :title "hi")))',
'(defcomp ~card (&key title) (div title))',
@@ -119,8 +119,8 @@ class TestTransitiveIoRefs:
def test_transitive_io_through_dep(self):
"""IO ref in a dependency should propagate to the parent."""
env = make_env(
'(defcomp ~page (&key) (div (~nav)))',
'(defcomp ~nav (&key) (nav (app-url "/home")))',
'(defcomp ~page (&key) (div (~plans/environment-images/nav)))',
'(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/home")))',
)
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
assert refs == {"app-url"}
@@ -157,7 +157,7 @@ class TestTransitiveIoRefs:
def test_without_tilde_prefix(self):
"""Should auto-add ~ prefix when not provided."""
env = make_env(
'(defcomp ~nav (&key) (nav (app-url "/")))',
'(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/")))',
)
refs = _transitive_io_refs_fallback("nav", env, IO_NAMES)
assert refs == {"app-url"}
@@ -187,13 +187,13 @@ class TestTransitiveIoRefs:
class TestComputeAllIoRefs:
def test_sets_io_refs_on_components(self):
env = make_env(
'(defcomp ~page (&key) (div (~nav) (fetch-data "x")))',
'(defcomp ~nav (&key) (nav (app-url "/")))',
'(defcomp ~page (&key) (div (~plans/environment-images/nav) (fetch-data "x")))',
'(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/")))',
'(defcomp ~card (&key title) (div title))',
)
_compute_all_io_refs_fallback(env, IO_NAMES)
assert env["~page"].io_refs == {"fetch-data", "app-url"}
assert env["~nav"].io_refs == {"app-url"}
assert env["~plans/environment-images/nav"].io_refs == {"app-url"}
assert env["~card"].io_refs == set()
def test_pure_components_get_empty_set(self):
@@ -284,8 +284,8 @@ class TestSxRefIoFunctions:
def test_transitive_io_refs(self):
from shared.sx.ref.sx_ref import transitive_io_refs
env = make_env(
'(defcomp ~page (&key) (div (~nav)))',
'(defcomp ~nav (&key) (nav (app-url "/")))',
'(defcomp ~page (&key) (div (~plans/environment-images/nav)))',
'(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/")))',
)
refs = transitive_io_refs("~page", env, list(IO_NAMES))
assert set(refs) == {"app-url"}
@@ -299,13 +299,13 @@ class TestSxRefIoFunctions:
def test_compute_all_io_refs(self):
from shared.sx.ref.sx_ref import compute_all_io_refs as ref_compute
env = make_env(
'(defcomp ~page (&key) (div (~nav) (fetch-data "x")))',
'(defcomp ~nav (&key) (nav (app-url "/")))',
'(defcomp ~page (&key) (div (~plans/environment-images/nav) (fetch-data "x")))',
'(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/")))',
'(defcomp ~card (&key) (div "pure"))',
)
ref_compute(env, list(IO_NAMES))
page_refs = env["~page"].io_refs
nav_refs = env["~nav"].io_refs
nav_refs = env["~plans/environment-images/nav"].io_refs
card_refs = env["~card"].io_refs
assert "fetch-data" in page_refs
assert "app-url" in page_refs
@@ -385,8 +385,8 @@ class TestFallbackVsRefParity:
def test_parity_mixed(self):
self._check_parity(
'(defcomp ~layout (&key) (div (~nav) (~content) (~footer)))',
'(defcomp ~nav (&key) (nav (app-url "/")))',
'(defcomp ~layout (&key) (div (~plans/environment-images/nav) (~content) (~footer)))',
'(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/")))',
'(defcomp ~content (&key) (main "pure content"))',
'(defcomp ~footer (&key) (footer (config "name")))',
)

View File

@@ -74,12 +74,12 @@ class TestIoDepsSerialization:
def test_multiple_io_deps_collected(self):
"""Multiple distinct IO primitives from different components are unioned."""
env = make_env(
'(defcomp ~nav (&key) (nav (app-url "/")))',
'(defcomp ~page (&key) (div (~nav) (config "key")))',
'(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/")))',
'(defcomp ~page (&key) (div (~plans/environment-images/nav) (config "key")))',
)
_compute_all_io_refs_fallback(env, IO_NAMES)
deps = {"~nav", "~page"}
deps = {"~plans/environment-images/nav", "~page"}
io_deps: set[str] = set()
for dep_name in deps:
comp = env.get(dep_name)

View File

@@ -57,10 +57,10 @@ class TestSx:
class TestComponents:
def test_register_and_use(self):
register_components('''
(defcomp ~badge (&key label)
(defcomp ~shared:misc/badge (&key label)
(span :class "badge" label))
''')
html = sx('(~badge :label "New")')
html = sx('(~shared:misc/badge :label "New")')
assert html == '<span class="badge">New</span>'
def test_multiple_components(self):
@@ -112,7 +112,7 @@ class TestLinkCard:
def setup_method(self):
_COMPONENT_ENV.clear()
register_components('''
(defcomp ~link-card (&key link title image icon brand)
(defcomp ~shared:fragments/link-card (&key link title image icon brand)
(a :href link
:class "block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline"
(div :class "flex flex-row items-start gap-3 p-3"
@@ -128,7 +128,7 @@ class TestLinkCard:
def test_with_image(self):
html = sx('''
(~link-card
(~shared:fragments/link-card
:link "/products/apple/"
:title "Apple"
:image "/img/apple.jpg"
@@ -140,7 +140,7 @@ class TestLinkCard:
def test_without_image(self):
html = sx('''
(~link-card
(~shared:fragments/link-card
:link "/posts/hello/"
:title "Hello World"
:icon "fas fa-file-alt")
@@ -152,7 +152,7 @@ class TestLinkCard:
def test_with_brand(self):
html = sx('''
(~link-card
(~shared:fragments/link-card
:link "/p/x/"
:title "Widget"
:image "/img/w.jpg"
@@ -162,7 +162,7 @@ class TestLinkCard:
def test_without_brand(self):
html = sx('''
(~link-card
(~shared:fragments/link-card
:link "/p/x/"
:title "Widget"
:image "/img/w.jpg")
@@ -173,7 +173,7 @@ class TestLinkCard:
def test_kwargs_from_python(self):
"""Pass data from Python (like a route handler would)."""
html = sx(
'(~link-card :link link :title title :image image :icon "fas fa-box")',
'(~shared:fragments/link-card :link link :title title :image image :icon "fas fa-box")',
link="/products/banana/",
title="Banana",
image="/img/banana.jpg",

View File

@@ -708,7 +708,7 @@ class TestParityDeps:
def test_scan_components_from_sx(self):
from shared.sx.deps import _scan_components_from_sx_fallback
from shared.sx.ref.sx_ref import scan_components_from_source as ref_sc
source = '(~card :title "hi" (~badge :label "new"))'
source = '(~card :title "hi" (~shared:misc/badge :label "new"))'
hw = _scan_components_from_sx_fallback(source)
ref = set(ref_sc(source))
assert hw == ref
@@ -718,13 +718,13 @@ class TestParityDeps:
from shared.sx.ref.sx_ref import compute_all_io_refs as ref_cio
io_names = {"highlight", "app-url", "config", "fetch-data"}
hw_env, ref_env = self._make_envs(
'(defcomp ~page (&key) (div (~nav) (fetch-data "x")))',
'(defcomp ~nav (&key) (nav (app-url "/")))',
'(defcomp ~page (&key) (div (~plans/environment-images/nav) (fetch-data "x")))',
'(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/")))',
'(defcomp ~pure (&key) (div "hello"))',
)
_compute_all_io_refs_fallback(hw_env, io_names)
ref_cio(ref_env, list(io_names))
for key in ("~page", "~nav", "~pure"):
for key in ("~page", "~plans/environment-images/nav", "~pure"):
hw_refs = hw_env[key].io_refs or set()
ref_refs = ref_env[key].io_refs
assert set(hw_refs) == set(ref_refs), f"IO refs mismatch for {key}"

View File

@@ -224,7 +224,7 @@ class TestBuildPagesSx:
from shared.sx.helpers import _sx_literal
from shared.sx.parser import parse_all
content = '(~doc-page :title "Hello \\"World\\"")'
content = '(~docs/page :title "Hello \\"World\\"")'
entry = (
"{:name " + _sx_literal("test")
+ " :content " + _sx_literal(content)

View File

@@ -142,9 +142,9 @@ class TestComponents:
assert html == '<div class="box"><p>inside</p></div>'
def test_component_with_conditional(self):
comp = '(defcomp ~badge (&key show label) (when show (span label)))'
assert _js_render('(~badge :show true :label "ok")', comp) == '<span>ok</span>'
assert _js_render('(~badge :show false :label "ok")', comp) == ''
comp = '(defcomp ~shared:misc/badge (&key show label) (when show (span label)))'
assert _js_render('(~shared:misc/badge :show true :label "ok")', comp) == '<span>ok</span>'
assert _js_render('(~shared:misc/badge :show false :label "ok")', comp) == ''
def test_nested_components(self):
comps = """

View File

@@ -156,7 +156,7 @@ class Macro:
@dataclass
class Component:
"""A reusable UI component defined via ``(defcomp ~name (&key ...) body)``.
"""A reusable UI component defined via ``(defcomp ~plans/content-addressed-components/name (&key ...) body)``.
Components are like lambdas but accept keyword arguments and support
a ``children`` rest parameter.
@@ -196,7 +196,7 @@ class Component:
@dataclass
class Island:
"""A reactive UI component defined via ``(defisland ~name (&key ...) body)``.
"""A reactive UI component defined via ``(defisland ~plans/content-addressed-components/name (&key ...) body)``.
Islands are like components but create a reactive boundary. Inside an
island, signals are tracked — deref subscribes DOM nodes to signals.

View File

@@ -33,7 +33,7 @@ def _clean_env():
class TestRender:
def test_basic_render(self):
register_components('(defcomp ~badge (&key label) (span :class "badge" label))')
register_components('(defcomp ~shared:misc/badge (&key label) (span :class "badge" label))')
html = render("badge", label="New")
assert html == '<span class="badge">New</span>'