Enable cross-subdomain htmx and purify layout to sexp

- Disable htmx selfRequestsOnly, add CORS headers for *.rose-ash.com
- Remove same-origin guards from ~menu-row and ~nav-link htmx attrs
- Convert ~app-layout from string-concatenated HTML to pure sexp tree
- Extract ~app-head component, replace ~app-shell with inline structure
- Convert hamburger SVG from Python HTML constant to ~hamburger sexp component
- Fix cross-domain fragment URLs (events_url, market_url)
- Fix starts-with? primitive to handle nil values
- Fix duplicate admin menu rows on OOB swaps
- Add calendar admin nav links (slots, description)
- Convert slots page from Jinja to sexp rendering
- Disable page caching in development mode
- Backfill migration to clean orphaned container_relations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 12:09:00 +00:00
parent d2f1da4944
commit eda95ec58b
14 changed files with 251 additions and 160 deletions

View File

@@ -187,7 +187,7 @@ def cache_page(
async def wrapper(*args, **kwargs):
r = get_redis()
if not r or request.method != "GET":
if not r or request.method != "GET" or current_app.config.get("NO_PAGE_CACHE"):
return await view(*args, **kwargs)
uid = get_user_id()

View File

@@ -6,6 +6,7 @@
<link rel="stylesheet" type="text/css" href="{{asset_url('styles/blog-content.css')}}">
<script src="https://unpkg.com/htmx.org@2.0.8"></script>
<meta name="htmx-config" content='{"selfRequestsOnly":false}'>
<script src="https://unpkg.com/hyperscript.org@0.9.12"></script>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="{{asset_url('fontawesome/css/all.min.css')}}">

View File

@@ -102,6 +102,10 @@ def create_base_app(
# Cache app prefix for key namespacing
app.config["CACHE_APP_PREFIX"] = name
# Disable page caching in development
env = os.getenv("ENVIRONMENT", "development")
app.config["NO_PAGE_CACHE"] = env not in ("production", "staging")
# --- infrastructure ---
register_middleware(app)
register_db(app)
@@ -276,6 +280,15 @@ def create_base_app(
response.delete_cookie("blog_session", domain=".rose-ash.com", path="/")
return response
@app.after_request
async def _cors_for_subdomains(response):
origin = request.headers.get("Origin", "")
if origin.endswith(".rose-ash.com") or origin.endswith(".localhost"):
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Headers"] = "HX-Request, HX-Target, HX-Current-URL, HX-Trigger, HX-Boosted, Content-Type"
return response
@app.after_request
async def _add_hx_preserve_search_header(response):
value = request.headers.get("X-Search")

View File

@@ -9,7 +9,7 @@ from __future__ import annotations
from typing import Any
from .jinja_bridge import sexp
from .page import HAMBURGER_HTML, SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
def call_url(ctx: dict, key: str, path: str = "/") -> str:
@@ -34,14 +34,13 @@ def root_header_html(ctx: dict, *, oob: bool = False) -> str:
return sexp(
'(~header-row :cart-mini-html cmi :blog-url bu :site-title st'
' :nav-tree-html nth :auth-menu-html amh :nav-panel-html nph'
' :hamburger-html hh :oob oob)',
' :oob oob)',
cmi=ctx.get("cart_mini_html", ""),
bu=call_url(ctx, "blog_url", ""),
st=ctx.get("base_title", ""),
nth=ctx.get("nav_tree_html", ""),
amh=ctx.get("auth_menu_html", ""),
nph=ctx.get("nav_panel_html", ""),
hh=HAMBURGER_HTML,
oob=oob,
)

View File

@@ -30,19 +30,6 @@ from typing import Any
from .jinja_bridge import sexp
# HTML constants used by layout components — kept here to avoid
# s-expression parser issues with embedded quotes in SVG.
HAMBURGER_HTML = (
'<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">'
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"'
' d="M4 6h16M4 12h16M4 18h16"/></svg>'
'<svg aria-hidden="true" viewBox="0 0 24 24"'
' 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"/></svg></div>'
)
SEARCH_HEADERS_MOBILE = '{"X-Origin":"search-mobile","X-Search":"true"}'
SEARCH_HEADERS_DESKTOP = '{"X-Origin":"search-desktop","X-Search":"true"}'
@@ -88,6 +75,11 @@ async def get_template_context(**kwargs: Any) -> dict[str, Any]:
if key not in ctx:
ctx[key] = val
# Expose request-scoped values that sexp components need
from quart import g
if "rights" not in ctx:
ctx["rights"] = getattr(g, "rights", {})
ctx.update(kwargs)
return ctx

View File

@@ -255,7 +255,9 @@ def prim_join(sep: str, coll: list) -> str:
return sep.join(str(x) for x in coll)
@register_primitive("starts-with?")
def prim_starts_with(s: str, prefix: str) -> bool:
def prim_starts_with(s, prefix: str) -> bool:
if not isinstance(s, str):
return False
return s.startswith(prefix)
@register_primitive("ends-with?")

View File

@@ -1,89 +1,76 @@
(defcomp ~app-shell (&key title asset-url meta-html body-html body-end-html)
(<>
(raw! "<!doctype html>")
(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"))))))
(defcomp ~app-head (&key title asset-url meta-html)
(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")
(meta :name "htmx-config" :content "{\"selfRequestsOnly\":false}")
(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}")))
(defcomp ~app-layout (&key title asset-url meta-html menu-colour
header-rows-html menu-html
filter-html aside-html content-html
body-end-html)
(let* ((colour (or menu-colour "sky")))
(~app-shell :title (or title "Rose Ash") :asset-url asset-url
:meta-html meta-html :body-end-html body-end-html
:body-html (str
"<div class=\"max-w-screen-2xl mx-auto py-1 px-1\">"
"<div class=\"w-full\">"
"<details class=\"group/root p-2\" data-toggle-group=\"mobile-panels\">"
"<summary>"
"<header class=\"z-50\">"
"<div id=\"root-header-summary\" class=\"flex items-start gap-2 p-1 bg-" colour "-500\">"
"<div class=\"flex flex-col w-full items-center\">"
header-rows-html
"</div>"
"</div>"
"</header>"
"</summary>"
"<div id=\"root-menu\" hx-swap-oob=\"outerHTML\" class=\"md:hidden\">"
(or menu-html "")
"</div>"
"</details>"
"</div>"
"<div id=\"filter\">"
(or filter-html "")
"</div>"
"<main id=\"root-panel\" class=\"max-w-full\">"
"<div class=\"md:min-h-0\">"
"<div class=\"flex flex-row md:h-full md:min-h-0\">"
"<aside id=\"aside\" class=\"hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3\">"
(or aside-html "")
"</aside>"
"<section id=\"main-panel\" class=\"flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport\">"
(or content-html "")
"<div class=\"pb-8\"></div>"
"</section>"
"</div>"
"</div>"
"</main>"
"</div>"))))
(<>
(raw! "<!doctype html>")
(html :lang "en"
(~app-head :title (or title "Rose Ash") :asset-url asset-url :meta-html meta-html)
(body :class "bg-stone-50 text-stone-900"
(div :class "max-w-screen-2xl mx-auto py-1 px-1"
(div :class "w-full"
(details :class "group/root p-2" :data-toggle-group "mobile-panels"
(summary
(header :class "z-50"
(div :id "root-header-summary"
:class (str "flex items-start gap-2 p-1 bg-" colour "-500")
(div :class "flex flex-col w-full items-center"
(when header-rows-html (raw! header-rows-html))))))
(div :id "root-menu" :hx-swap-oob "outerHTML" :class "md:hidden"
(when menu-html (raw! menu-html)))))
(div :id "filter"
(when filter-html (raw! filter-html)))
(main :id "root-panel" :class "max-w-full"
(div :class "md:min-h-0"
(div :class "flex flex-row md:h-full md:min-h-0"
(aside :id "aside"
:class "hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3"
(when aside-html (raw! aside-html)))
(section :id "main-panel"
:class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
(when content-html (raw! content-html))
(div :class "pb-8"))))))
(when body-end-html (raw! body-end-html))
(script :src (str asset-url "/scripts/body.js")))))))
(defcomp ~oob-response (&key oobs-html filter-html aside-html menu-html content-html)
(<>
@@ -99,9 +86,19 @@
:class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
(when content-html (raw! content-html)))))
(defcomp ~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"
(path :stroke-linecap "round" :stroke-linejoin "round" :stroke-width "2"
:d "M4 6h16M4 12h16M4 18h16"))
(svg :aria-hidden "true" :viewBox "0 0 24 24"
: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 ~header-row (&key cart-mini-html blog-url site-title
nav-tree-html auth-menu-html nav-panel-html
settings-url is-admin oob hamburger-html)
settings-url is-admin oob)
(<>
(div :id "root-row"
:hx-swap-oob (if oob "outerHTML" nil)
@@ -118,7 +115,7 @@
(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"))))
(when hamburger-html (raw! hamburger-html))))
(~hamburger)))
(div :class "block md:hidden text-md font-bold"
(when auth-menu-html (raw! auth-menu-html)))))
@@ -145,11 +142,11 @@
(when nav-html
(nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
(raw! nav-html))))
(when child-id
(when (and child-id (not oob))
(div :id child-id :class "flex flex-col w-full items-center"
(when child-html (raw! child-html)))))))
(defcomp ~nav-link (&key href hx-select label icon aclass select-colours)
(defcomp ~nav-link (&key href hx-select label icon aclass select-colours is-selected)
(div :class "relative nav-group"
(a :href href
:hx-get href
@@ -157,6 +154,7 @@
:hx-select (or hx-select "#main-panel")
:hx-swap "outerHTML"
:hx-push-url "true"
:aria-selected (when is-selected "true")
:class (or aclass
(str "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 "
(or select-colours "")))