""" Shared s-expression component definitions. Loaded at app startup via ``load_shared_components()``. Each component replaces a per-service Jinja fragment template with a single reusable s-expression definition. """ from __future__ import annotations from .jinja_bridge import register_components def load_shared_components() -> None: """Register all shared s-expression components.""" register_components(_LINK_CARD) register_components(_CART_MINI) register_components(_AUTH_MENU) register_components(_ACCOUNT_NAV_ITEM) register_components(_CALENDAR_ENTRY_NAV) register_components(_CALENDAR_LINK_NAV) register_components(_MARKET_LINK_NAV) register_components(_POST_CARD) register_components(_BASE_SHELL) register_components(_ERROR_PAGE) # Phase 6: layout infrastructure register_components(_APP_SHELL) register_components(_APP_LAYOUT) register_components(_OOB_RESPONSE) register_components(_HEADER_ROW) register_components(_MENU_ROW) register_components(_NAV_LINK) register_components(_INFINITE_SCROLL) register_components(_STATUS_PILL) register_components(_SEARCH_MOBILE) register_components(_SEARCH_DESKTOP) register_components(_ORDER_SUMMARY_CARD) # --------------------------------------------------------------------------- # ~link-card # --------------------------------------------------------------------------- # Replaces: blog/templates/fragments/link_card.html # market/templates/fragments/link_card.html # events/templates/fragments/link_card.html # federation/templates/fragments/link_card.html # artdag/l1/app/templates/fragments/link_card.html # # Usage: # sexp('(~link-card :link "/post/apple/" :title "Apple" :image "/img/a.jpg")') # sexp('(~link-card :link url :title title :icon "fas fa-file-alt")', **ctx) # --------------------------------------------------------------------------- _LINK_CARD = ''' (defcomp ~link-card (&key link title image icon subtitle detail data-app) (a :href link :class "block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline" :data-fragment "link-card" :data-app data-app :data-hx-disable true (div :class "flex flex-row items-start gap-3 p-3" (if image (img :src image :alt "" :class "flex-shrink-0 w-16 h-16 rounded object-cover") (div :class "flex-shrink-0 w-16 h-16 rounded bg-stone-100 flex items-center justify-center text-stone-400" (i :class icon))) (div :class "flex-1 min-w-0" (div :class "font-medium text-stone-900 text-sm clamp-2" title) (when subtitle (div :class "text-xs text-stone-500 mt-0.5" subtitle)) (when detail (div :class "text-xs text-stone-400 mt-1" detail)))))) ''' # --------------------------------------------------------------------------- # ~cart-mini # --------------------------------------------------------------------------- # Replaces: cart/templates/fragments/cart_mini.html # # Usage: # sexp('(~cart-mini :cart-count count :blog-url burl :cart-url curl)', # count=0, burl="https://blog.rose-ash.com", curl="https://cart.rose-ash.com") # --------------------------------------------------------------------------- _CART_MINI = ''' (defcomp ~cart-mini (&key cart-count blog-url cart-url oob) (div :id "cart-mini" :hx-swap-oob oob (if (= cart-count 0) (div :class "h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0" (a :href (str blog-url "/") :class "h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1" (img :src (str blog-url "/static/img/logo.jpg") :class "h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0"))) (a :href (str cart-url "/") :class "relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700" (i :class "fa fa-shopping-cart text-5xl" :aria-hidden "true") (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))))) ''' # --------------------------------------------------------------------------- # ~auth-menu # --------------------------------------------------------------------------- # Replaces: account/templates/fragments/auth_menu.html # # Usage: # sexp('(~auth-menu :user-email email :account-url aurl)', # email="user@example.com", aurl="https://account.rose-ash.com") # --------------------------------------------------------------------------- _AUTH_MENU = ''' (defcomp ~auth-menu (&key user-email account-url) (<> (span :id "auth-menu-desktop" :class "hidden md:inline-flex" (if user-email (a :href (str account-url "/") :class "justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black" :data-close-details true (i :class "fa-solid fa-user") (span user-email)) (a :href (str account-url "/") :class "justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black" :data-close-details true (i :class "fa-solid fa-key") (span "sign in or register")))) (span :id "auth-menu-mobile" :class "block md:hidden text-md font-bold" (if user-email (a :href (str account-url "/") :data-close-details true (i :class "fa-solid fa-user") (span user-email)) (a :href (str account-url "/") (i :class "fa-solid fa-key") (span "sign in or register")))))) ''' # --------------------------------------------------------------------------- # ~account-nav-item # --------------------------------------------------------------------------- # Replaces: hardcoded HTML in cart/bp/fragments/routes.py # and orders/bp/fragments/routes.py # # Usage: # sexp('(~account-nav-item :href url :label "orders")', url=cart_url("/orders/")) # --------------------------------------------------------------------------- _ACCOUNT_NAV_ITEM = ''' (defcomp ~account-nav-item (&key href label) (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" :data-hx-disable true label))) ''' # --------------------------------------------------------------------------- # ~calendar-entry-nav # --------------------------------------------------------------------------- # Replaces: events/templates/fragments/container_nav_entries.html (per-entry) # # Usage: # sexp('(~calendar-entry-nav :href url :name name :date-str "Jan 15, 2026 at 14:00")', # url="/events/...", name="Workshop") # --------------------------------------------------------------------------- _CALENDAR_ENTRY_NAV = ''' (defcomp ~calendar-entry-nav (&key href name date-str nav-class) (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)))) ''' # --------------------------------------------------------------------------- # ~calendar-link-nav # --------------------------------------------------------------------------- # Replaces: events/templates/fragments/container_nav_calendars.html (per-calendar) # # Usage: # sexp('(~calendar-link-nav :href url :name "My Calendar")', url="/events/...") # --------------------------------------------------------------------------- _CALENDAR_LINK_NAV = ''' (defcomp ~calendar-link-nav (&key href name nav-class) (a :href href :class nav-class (i :class "fa fa-calendar" :aria-hidden "true") (div name))) ''' # --------------------------------------------------------------------------- # ~market-link-nav # --------------------------------------------------------------------------- # Replaces: market/templates/fragments/container_nav_markets.html (per-market) # # Usage: # sexp('(~market-link-nav :href url :name "Farm Shop")', url="/market/...") # --------------------------------------------------------------------------- _MARKET_LINK_NAV = ''' (defcomp ~market-link-nav (&key href name nav-class) (a :href href :class nav-class (i :class "fa fa-shopping-bag" :aria-hidden "true") (div name))) ''' # --------------------------------------------------------------------------- # ~post-card # --------------------------------------------------------------------------- # Replaces: blog/templates/_types/blog/_card.html # # A simplified s-expression version of the blog listing card. # The full card is complex (like buttons, card widgets, at_bar with tag/author # filtering). This component covers the core card structure; the at_bar and # card_widgets are passed as pre-rendered HTML via :at-bar-html and # :widgets-html kwargs for incremental migration. # # Usage: # sexp('(~post-card :title t :slug s :href h ...)', **ctx) # --------------------------------------------------------------------------- _POST_CARD = ''' (defcomp ~post-card (&key title slug href feature-image excerpt status published-at updated-at publish-requested hx-select like-html widgets-html at-bar-html) (article :class "border-b pb-6 last:border-b-0 relative" (when like-html (raw! like-html)) (a :href href :hx-get href :hx-target "#main-panel" :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" :class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden" (header :class "mb-2 text-center" (h2 :class "text-4xl font-bold text-stone-900" title) (cond (= status "draft") (begin (div :class "flex justify-center gap-2 mt-1" (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft") (when publish-requested (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested"))) (when updated-at (p :class "text-sm text-stone-500" (str "Updated: " updated-at)))) published-at (p :class "text-sm text-stone-500" (str "Published: " published-at)))) (when feature-image (div :class "mb-4" (img :src feature-image :alt "" :class "rounded-lg w-full object-cover"))) (when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt))) (when widgets-html (raw! widgets-html)) (when at-bar-html (raw! at-bar-html)))) ''' # --------------------------------------------------------------------------- # ~base-shell — full HTML document wrapper # --------------------------------------------------------------------------- # Replaces: shared/browser/templates/_types/root/index.html (the shell) # # Usage: For full-page s-expression rendering (Step 4 proof of concept) # --------------------------------------------------------------------------- _BASE_SHELL = ''' (defcomp ~base-shell (&key title asset-url &rest children) (<> (raw! "") (html :lang "en" (head (meta :charset "utf-8") (meta :name "viewport" :content "width=device-width, initial-scale=1") (title title) (style "body{margin:0;min-height:100vh;display:flex;align-items:center;" "justify-content:center;font-family:system-ui,sans-serif;" "background:#fafaf9;color:#1c1917}") (script :src "https://cdn.tailwindcss.com") (link :rel "stylesheet" :href (str asset-url "/fontawesome/css/all.min.css"))) (body :class "bg-stone-50 text-stone-900" children)))) ''' # --------------------------------------------------------------------------- # ~error-page — styled error page # --------------------------------------------------------------------------- # Replaces: shared/browser/templates/_types/root/exceptions/_.html # + base.html + 404/message.html + 404/img.html # # Usage: # sexp('(~error-page :title "Not Found" :message "NOT FOUND" :image img-url :asset-url aurl)', # img_url="/static/errors/404.gif", aurl="/static") # --------------------------------------------------------------------------- _ERROR_PAGE = ''' (defcomp ~error-page (&key title message image asset-url) (~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)) (when image (div :class "flex justify-center" (img :src image :width "300" :height "300")))))) ''' # =================================================================== # Phase 6: Layout infrastructure components # =================================================================== # --------------------------------------------------------------------------- # ~app-shell — full HTML document with all required CSS/JS assets # --------------------------------------------------------------------------- # Replaces: _types/root/index.html
... shell # # This includes htmx, hyperscript, tailwind, fontawesome, prism, and # all shared CSS/JS. ``~base-shell`` remains the lightweight error-page # shell; ``~app-shell`` is for real app pages. # # Usage: # sexp('(~app-shell :title t :asset-url a :meta-html m :body-html b)', **ctx) # --------------------------------------------------------------------------- _APP_SHELL = r''' (defcomp ~app-shell (&key title asset-url meta-html body-html body-end-html) (<> (raw! "") (html :lang "en" (head (meta :charset "utf-8") (meta :name "viewport" :content "width=device-width, initial-scale=1") (meta :name "robots" :content "index,follow") (meta :name "theme-color" :content "#ffffff") (title title) (when meta-html (raw! meta-html)) (style "@media (min-width: 768px) { .js-mobile-sentinel { display:none !important; } }") (link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/basics.css")) (link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/cards.css")) (link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/blog-content.css")) (script :src "https://unpkg.com/htmx.org@2.0.8") (script :src "https://unpkg.com/hyperscript.org@0.9.12") (script :src "https://cdn.tailwindcss.com") (link :rel "stylesheet" :href (str asset-url "/fontawesome/css/all.min.css")) (link :rel "stylesheet" :href (str asset-url "/fontawesome/css/v4-shims.min.css")) (link :href "https://unpkg.com/prismjs/themes/prism.css" :rel "stylesheet") (script :src "https://unpkg.com/prismjs/prism.js") (script :src "https://unpkg.com/prismjs/components/prism-javascript.min.js") (script :src "https://unpkg.com/prismjs/components/prism-python.min.js") (script :src "https://unpkg.com/prismjs/components/prism-bash.min.js") (script :src "https://cdn.jsdelivr.net/npm/sweetalert2@11") (script "if(matchMedia('(hover:hover) and (pointer:fine)').matches){document.documentElement.classList.add('hover-capable')}") (script "document.addEventListener('click',function(e){var t=e.target.closest('[data-close-details]');if(!t)return;var d=t.closest('details');if(d)d.removeAttribute('open')})") (style "details[data-toggle-group=\"mobile-panels\"]>summary{list-style:none}" "details[data-toggle-group=\"mobile-panels\"]>summary::-webkit-details-marker{display:none}" "@media(min-width:768px){.nav-group:focus-within .submenu,.nav-group:hover .submenu{display:block}}" "img{max-width:100%;height:auto}" ".clamp-2{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}" ".clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}" ".no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}" "details.group{overflow:hidden}details.group>summary{list-style:none}details.group>summary::-webkit-details-marker{display:none}" ".htmx-indicator{display:none}.htmx-request .htmx-indicator{display:inline-flex}" ".js-wrap.open .js-pop{display:block}.js-wrap.open .js-backdrop{display:block}")) (body :class "bg-stone-50 text-stone-900" (raw! body-html) (when body-end-html (raw! body-end-html)) (script :src (str asset-url "/scripts/body.js")))))) ''' # --------------------------------------------------------------------------- # ~app-layout — page body layout (header + filter + aside + main-panel) # --------------------------------------------------------------------------- # Replaces: _types/root/index.html body structure # # The header uses a