From a643b3532de91d1146d2c3db84b16a45e22c10b5 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 1 Mar 2026 10:12:03 +0000 Subject: [PATCH] Phase 5 cleanup: remove legacy HTML components, fix nav-tree fragment - Remove old raw! layout components (~app-head, ~app-layout, ~oob-response, ~header-row, ~menu-row, ~oob-header, ~header-child) from layout.sexp - Convert nav-tree fragment from Jinja HTML to sexp source, fixing the "Unexpected character: ." parse error caused by HTML leaking into sexp - Add _as_sexp() helper to safely coerce HTML fragments to ~rich-text - Fix federation/sexp/search.sexpr extra closing paren - Remove dead _html() wrappers from blog and account sexp_components - Remove stale render import from cart sexp_components - Add dev_watcher.py to auto-reload on .sexp/.sexpr/.js/.css changes - Add test_parse_all.py to parse-check all 59 sexpr/sexp files - Fix test assertions for sx- attribute prefix (was hx-) - Add sexp.js version logging for cache debugging Co-Authored-By: Claude Opus 4.6 --- account/entrypoint.sh | 1 + account/sexp/sexp_components.py | 6 -- blog/bp/fragments/routes.py | 88 +++++++++++++--- blog/entrypoint.sh | 1 + blog/sexp/sexp_components.py | 22 ---- cart/entrypoint.sh | 1 + cart/sexp/sexp_components.py | 2 +- events/entrypoint.sh | 1 + federation/entrypoint.sh | 1 + federation/sexp/search.sexpr | 2 +- likes/entrypoint.sh | 1 + market/entrypoint.sh | 1 + orders/entrypoint.sh | 1 + relations/entrypoint.sh | 1 + shared/dev_watcher.py | 69 ++++++++++++ shared/sexp/helpers.py | 38 ++++--- shared/sexp/templates/layout.sexp | 151 --------------------------- shared/sexp/tests/test_components.py | 28 +++-- shared/sexp/tests/test_parse_all.py | 35 +++++++ shared/static/scripts/sexp.js | 6 +- test/entrypoint.sh | 1 + 21 files changed, 225 insertions(+), 232 deletions(-) create mode 100644 shared/dev_watcher.py create mode 100644 shared/sexp/tests/test_parse_all.py diff --git a/account/entrypoint.sh b/account/entrypoint.sh index 3fa6a27..b6bbcae 100755 --- a/account/entrypoint.sh +++ b/account/entrypoint.sh @@ -54,6 +54,7 @@ fi RELOAD_FLAG="" if [[ "${RELOAD:-}" == "true" ]]; then RELOAD_FLAG="--reload" + python3 -m shared.dev_watcher & echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..." else echo "Starting Hypercorn (${APP_MODULE:-app:app})..." diff --git a/account/sexp/sexp_components.py b/account/sexp/sexp_components.py index 19f2339..b8abd4c 100644 --- a/account/sexp/sexp_components.py +++ b/account/sexp/sexp_components.py @@ -371,12 +371,6 @@ async def render_check_email_page(ctx: dict) -> str: # Public API: Fragment renderers for POST handlers # --------------------------------------------------------------------------- -def render_newsletter_toggle_html(un) -> str: - """Render a newsletter toggle switch for POST response.""" - from shared.browser.app.csrf import generate_csrf_token - return _newsletter_toggle_sexp(un, lambda p: f"/newsletter/{un.newsletter_id}/toggle/" if "/toggle/" in p else p, - generate_csrf_token()) - def render_newsletter_toggle(un) -> str: """Render a newsletter toggle switch for POST response (uses account_url).""" diff --git a/blog/bp/fragments/routes.py b/blog/bp/fragments/routes.py index 597c893..76a297c 100644 --- a/blog/bp/fragments/routes.py +++ b/blog/bp/fragments/routes.py @@ -28,32 +28,86 @@ def register(): if handler is None: return Response("", status=200, content_type="text/sexp") result = await handler() - # nav-tree still returns HTML (Jinja template) for now - ct = "text/html" if fragment_type == "nav-tree" else "text/sexp" - return Response(result, status=200, content_type=ct) + return Response(result, status=200, content_type="text/sexp") - # --- nav-tree fragment (still Jinja for now — complex template) --- + # --- nav-tree fragment — returns sexp source --- async def _nav_tree_handler(): + from shared.sexp.helpers import sexp_call, SexpExpr + from shared.infrastructure.urls import ( + blog_url, cart_url, market_url, events_url, + federation_url, account_url, artdag_url, + ) + app_name = request.args.get("app_name", "") path = request.args.get("path", "/") first_seg = path.strip("/").split("/")[0] menu_items = list(await get_navigation_tree(g.s)) - class _NavItem: - __slots__ = ("slug", "label", "feature_image") - def __init__(self, slug, label, feature_image=None): - self.slug = slug - self.label = label - self.feature_image = feature_image + app_slugs = { + "cart": cart_url("/"), + "market": market_url("/"), + "events": events_url("/"), + "federation": federation_url("/"), + "account": account_url("/"), + "artdag": artdag_url("/"), + } - menu_items.append(_NavItem("artdag", "art-dag")) + nav_cls = "whitespace-nowrap flex items-center gap-2 rounded p-2 text-sm" - return await render_template( - "fragments/nav_tree.html", - menu_items=menu_items, - frag_app_name=app_name, - frag_first_seg=first_seg, - ) + item_sexps = [] + for item in menu_items: + href = app_slugs.get(item.slug, blog_url(f"/{item.slug}/")) + selected = "true" if (item.slug == first_seg + or item.slug == app_name) else "false" + img = sexp_call("blog-nav-item-image", + src=getattr(item, "feature_image", None), + label=getattr(item, "label", item.slug)) + item_sexps.append(sexp_call( + "blog-nav-item-plain", + href=href, selected=selected, nav_cls=nav_cls, + img=SexpExpr(img), label=getattr(item, "label", item.slug), + )) + + # artdag link + href = artdag_url("/") + selected = "true" if ("artdag" == first_seg + or "artdag" == app_name) else "false" + img = sexp_call("blog-nav-item-image", src=None, label="art-dag") + item_sexps.append(sexp_call( + "blog-nav-item-plain", + href=href, selected=selected, nav_cls=nav_cls, + img=SexpExpr(img), label="art-dag", + )) + + if not item_sexps: + return sexp_call("blog-nav-empty", + wrapper_id="menu-items-nav-wrapper") + + items_frag = "(<> " + " ".join(item_sexps) + ")" + + arrow_cls = "scrolling-menu-arrow-menu-items-container" + container_id = "menu-items-container" + left_hs = ("on click set #" + container_id + + ".scrollLeft to #" + container_id + ".scrollLeft - 200") + scroll_hs = ("on scroll " + "set cls to '" + arrow_cls + "' " + "set arrows to document.getElementsByClassName(cls) " + "set show to (window.innerWidth >= 640 and " + "my.scrollWidth > my.clientWidth) " + "repeat for arrow in arrows " + "if show remove .hidden from arrow add .flex to arrow " + "else add .hidden to arrow remove .flex from arrow end " + "end") + right_hs = ("on click set #" + container_id + + ".scrollLeft to #" + container_id + ".scrollLeft + 200") + + return sexp_call("blog-nav-wrapper", + arrow_cls=arrow_cls, + container_id=container_id, + left_hs=left_hs, + scroll_hs=scroll_hs, + right_hs=right_hs, + items=SexpExpr(items_frag)) _handlers["nav-tree"] = _nav_tree_handler diff --git a/blog/entrypoint.sh b/blog/entrypoint.sh index 45c03a0..3212746 100755 --- a/blog/entrypoint.sh +++ b/blog/entrypoint.sh @@ -54,6 +54,7 @@ fi RELOAD_FLAG="" if [[ "${RELOAD:-}" == "true" ]]; then RELOAD_FLAG="--reload" + python3 -m shared.dev_watcher & echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..." else echo "Starting Hypercorn (${APP_MODULE:-app:app})..." diff --git a/blog/sexp/sexp_components.py b/blog/sexp/sexp_components.py index 1827eba..cc3d9e3 100644 --- a/blog/sexp/sexp_components.py +++ b/blog/sexp/sexp_components.py @@ -387,10 +387,6 @@ def _page_card_sexp(page: dict, ctx: dict) -> str: excerpt=excerpt, ) -def _page_card_html(page: dict, ctx: dict) -> str: - """Single page card (HTML, kept for backwards compat).""" - return _page_card_sexp(page, ctx) - def _view_toggle_sexp(ctx: dict) -> str: """View toggle bar (list/tile) for desktop.""" @@ -547,10 +543,6 @@ def _action_buttons_sexp(ctx: dict) -> str: inner=SexpExpr(inner) if inner else None, ) -def _action_buttons_html(ctx: dict) -> str: - """New Post/Page + Drafts toggle buttons (HTML, kept for backwards compat).""" - return _action_buttons_sexp(ctx) - def _tag_groups_filter_sexp(ctx: dict) -> str: """Tag group filter bar as sexp.""" @@ -591,10 +583,6 @@ def _tag_groups_filter_sexp(ctx: dict) -> str: items = "(<> " + " ".join(li_parts) + ")" return sexp_call("blog-filter-nav", items=SexpExpr(items)) -def _tag_groups_filter_html(ctx: dict) -> str: - """Tag group filter bar (HTML, kept for backwards compat).""" - return _tag_groups_filter_sexp(ctx) - def _authors_filter_sexp(ctx: dict) -> str: """Author filter bar as sexp.""" @@ -628,10 +616,6 @@ def _authors_filter_sexp(ctx: dict) -> str: items = "(<> " + " ".join(li_parts) + ")" return sexp_call("blog-filter-nav", items=SexpExpr(items)) -def _authors_filter_html(ctx: dict) -> str: - """Author filter bar (HTML, kept for backwards compat).""" - return _authors_filter_sexp(ctx) - def _tag_groups_filter_summary_sexp(ctx: dict) -> str: """Mobile filter summary for tag groups (sexp).""" @@ -649,9 +633,6 @@ def _tag_groups_filter_summary_sexp(ctx: dict) -> str: return "" return sexp_call("blog-filter-summary", text=", ".join(names)) -def _tag_groups_filter_summary_html(ctx: dict) -> str: - """Mobile filter summary for tag groups (HTML, kept for backwards compat).""" - return _tag_groups_filter_summary_sexp(ctx) def _authors_filter_summary_sexp(ctx: dict) -> str: @@ -670,9 +651,6 @@ def _authors_filter_summary_sexp(ctx: dict) -> str: return "" return sexp_call("blog-filter-summary", text=", ".join(names)) -def _authors_filter_summary_html(ctx: dict) -> str: - """Mobile filter summary for authors (HTML, kept for backwards compat).""" - return _authors_filter_summary_sexp(ctx) # --------------------------------------------------------------------------- diff --git a/cart/entrypoint.sh b/cart/entrypoint.sh index 7957f13..b22a705 100755 --- a/cart/entrypoint.sh +++ b/cart/entrypoint.sh @@ -54,6 +54,7 @@ fi RELOAD_FLAG="" if [[ "${RELOAD:-}" == "true" ]]; then RELOAD_FLAG="--reload" + python3 -m shared.dev_watcher & echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..." else echo "Starting Hypercorn (${APP_MODULE:-app:app})..." diff --git a/cart/sexp/sexp_components.py b/cart/sexp/sexp_components.py index cbffbee..a8e825c 100644 --- a/cart/sexp/sexp_components.py +++ b/cart/sexp/sexp_components.py @@ -10,7 +10,7 @@ import os from typing import Any from markupsafe import escape -from shared.sexp.jinja_bridge import render, load_service_components +from shared.sexp.jinja_bridge import load_service_components from shared.sexp.helpers import ( call_url, root_header_sexp, post_admin_header_sexp, post_header_sexp as _shared_post_header_sexp, diff --git a/events/entrypoint.sh b/events/entrypoint.sh index 2925443..b4d18bc 100755 --- a/events/entrypoint.sh +++ b/events/entrypoint.sh @@ -54,6 +54,7 @@ fi RELOAD_FLAG="" if [[ "${RELOAD:-}" == "true" ]]; then RELOAD_FLAG="--reload" + python3 -m shared.dev_watcher & echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..." else echo "Starting Hypercorn (${APP_MODULE:-app:app})..." diff --git a/federation/entrypoint.sh b/federation/entrypoint.sh index 608f925..814e6bd 100755 --- a/federation/entrypoint.sh +++ b/federation/entrypoint.sh @@ -54,6 +54,7 @@ fi RELOAD_FLAG="" if [[ "${RELOAD:-}" == "true" ]]; then RELOAD_FLAG="--reload" + python3 -m shared.dev_watcher & echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..." else echo "Starting Hypercorn (${APP_MODULE:-app:app})..." diff --git a/federation/sexp/search.sexpr b/federation/sexp/search.sexpr index 81b725c..8d85be9 100644 --- a/federation/sexp/search.sexpr +++ b/federation/sexp/search.sexpr @@ -28,7 +28,7 @@ (form :method "post" :action action :sx-post action :sx-target "closest article" :sx-swap "outerHTML" (input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "actor_url" :value actor-url) - (button :type "submit" :class "text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700" label))))) + (button :type "submit" :class "text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700" label)))) (defcomp ~federation-actor-card (&key cls id avatar name username domain summary button) (article :class cls :id id diff --git a/likes/entrypoint.sh b/likes/entrypoint.sh index 8ee3e61..db4c5ff 100755 --- a/likes/entrypoint.sh +++ b/likes/entrypoint.sh @@ -54,6 +54,7 @@ fi RELOAD_FLAG="" if [[ "${RELOAD:-}" == "true" ]]; then RELOAD_FLAG="--reload" + python3 -m shared.dev_watcher & echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..." else echo "Starting Hypercorn (${APP_MODULE:-app:app})..." diff --git a/market/entrypoint.sh b/market/entrypoint.sh index 49ee26b..02c12b1 100755 --- a/market/entrypoint.sh +++ b/market/entrypoint.sh @@ -54,6 +54,7 @@ fi RELOAD_FLAG="" if [[ "${RELOAD:-}" == "true" ]]; then RELOAD_FLAG="--reload" + python3 -m shared.dev_watcher & echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..." else echo "Starting Hypercorn (${APP_MODULE:-app:app})..." diff --git a/orders/entrypoint.sh b/orders/entrypoint.sh index c55d614..a5a9ca7 100755 --- a/orders/entrypoint.sh +++ b/orders/entrypoint.sh @@ -54,6 +54,7 @@ fi RELOAD_FLAG="" if [[ "${RELOAD:-}" == "true" ]]; then RELOAD_FLAG="--reload" + python3 -m shared.dev_watcher & echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..." else echo "Starting Hypercorn (${APP_MODULE:-app:app})..." diff --git a/relations/entrypoint.sh b/relations/entrypoint.sh index dcb4cb7..b6b5cc1 100755 --- a/relations/entrypoint.sh +++ b/relations/entrypoint.sh @@ -54,6 +54,7 @@ fi RELOAD_FLAG="" if [[ "${RELOAD:-}" == "true" ]]; then RELOAD_FLAG="--reload" + python3 -m shared.dev_watcher & echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..." else echo "Starting Hypercorn (${APP_MODULE:-app:app})..." diff --git a/shared/dev_watcher.py b/shared/dev_watcher.py new file mode 100644 index 0000000..0a7a4c4 --- /dev/null +++ b/shared/dev_watcher.py @@ -0,0 +1,69 @@ +"""Watch non-Python files and trigger Hypercorn reload. + +Hypercorn --reload only watches .py files. This script watches .sexp, +.sexpr, .js, and .css files and touches a sentinel .py file when they +change, causing Hypercorn to restart. + +Usage (from entrypoint.sh, before exec hypercorn): + python3 -m shared.dev_watcher & +""" + +import os +import time +import sys + +WATCH_EXTENSIONS = {".sexp", ".sexpr", ".js", ".css"} +SENTINEL = os.path.join(os.path.dirname(__file__), "_reload_sentinel.py") +POLL_INTERVAL = 1.5 # seconds + + +def _collect_mtimes(roots): + mtimes = {} + for root in roots: + for dirpath, _dirs, files in os.walk(root): + for fn in files: + ext = os.path.splitext(fn)[1] + if ext in WATCH_EXTENSIONS: + path = os.path.join(dirpath, fn) + try: + mtimes[path] = os.path.getmtime(path) + except OSError: + pass + return mtimes + + +def main(): + # Watch /app/shared and /app//sexp plus static dirs + roots = [] + for entry in os.listdir("/app"): + full = os.path.join("/app", entry) + if os.path.isdir(full): + roots.append(full) + if not roots: + roots = ["/app"] + + # Ensure sentinel exists + if not os.path.exists(SENTINEL): + with open(SENTINEL, "w") as f: + f.write("# reload sentinel\n") + + prev = _collect_mtimes(roots) + while True: + time.sleep(POLL_INTERVAL) + curr = _collect_mtimes(roots) + changed = [] + for path, mtime in curr.items(): + if path not in prev or prev[path] != mtime: + changed.append(path) + if changed: + names = ", ".join(os.path.basename(p) for p in changed[:3]) + if len(changed) > 3: + names += f" (+{len(changed) - 3} more)" + print(f"[dev_watcher] Changed: {names} — triggering reload", + flush=True) + os.utime(SENTINEL, None) + prev = curr + + +if __name__ == "__main__": + main() diff --git a/shared/sexp/helpers.py b/shared/sexp/helpers.py index a08c31b..0a7bd3b 100644 --- a/shared/sexp/helpers.py +++ b/shared/sexp/helpers.py @@ -36,19 +36,36 @@ def get_asset_url(ctx: dict) -> str: # Sexp-native helper functions — return sexp source (not HTML) # --------------------------------------------------------------------------- +def _as_sexp(val: Any) -> SexpExpr | None: + """Coerce a fragment value to SexpExpr. + + If *val* is already a ``SexpExpr`` (from a ``text/sexp`` fragment), + return it as-is. If it's a non-empty string (HTML from a + ``text/html`` fragment), wrap it in ``~rich-text``. Otherwise + return ``None``. + """ + if not val: + return None + if isinstance(val, SexpExpr): + return val + html = str(val) + escaped = html.replace("\\", "\\\\").replace('"', '\\"') + return SexpExpr(f'(~rich-text :html "{escaped}")') + + def root_header_sexp(ctx: dict, *, oob: bool = False) -> str: """Build the root header row as a sexp call string.""" rights = ctx.get("rights") or {} is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False) settings_url = call_url(ctx, "blog_url", "/settings/") if is_admin else "" return sexp_call("header-row-sx", - cart_mini=ctx.get("cart_mini") and SexpExpr(str(ctx.get("cart_mini"))), + cart_mini=_as_sexp(ctx.get("cart_mini")), blog_url=call_url(ctx, "blog_url", ""), site_title=ctx.get("base_title", ""), app_label=ctx.get("app_label", ""), - nav_tree=ctx.get("nav_tree") and SexpExpr(str(ctx.get("nav_tree"))), - auth_menu=ctx.get("auth_menu") and SexpExpr(str(ctx.get("auth_menu"))), - nav_panel=ctx.get("nav_panel") and SexpExpr(str(ctx.get("nav_panel"))), + nav_tree=_as_sexp(ctx.get("nav_tree")), + auth_menu=_as_sexp(ctx.get("auth_menu")), + nav_panel=_as_sexp(ctx.get("nav_panel")), settings_url=settings_url, is_admin=is_admin, oob=oob, @@ -285,19 +302,6 @@ def sexp_response(source_or_component: str, status: int = 200, return resp -def oob_page(ctx: dict, *, oobs_html: str = "", - filter_html: str = "", aside_html: str = "", - content_html: str = "", menu_html: str = "") -> str: - """Render an OOB response with standard swap targets.""" - return render( - "oob-response", - oobs_html=oobs_html, - filter_html=filter_html, - aside_html=aside_html, - menu_html=menu_html, - content_html=content_html, - ) - # --------------------------------------------------------------------------- # Sexp wire-format full page shell diff --git a/shared/sexp/templates/layout.sexp b/shared/sexp/templates/layout.sexp index 0d06514..b9e5beb 100644 --- a/shared/sexp/templates/layout.sexp +++ b/shared/sexp/templates/layout.sexp @@ -1,77 +1,3 @@ -(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")) - (meta :name "csrf-token" :content "") - (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}" - ".sx-indicator{display:none}.sx-request .sx-indicator{display:inline-flex}" - ".sx-error .sx-indicator{display:none}.sx-loading .sx-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"))) - (<> - (raw! "") - (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" :sx-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/sexp.js")) - (script :src (str asset-url "/scripts/body.js"))))))) - (defcomp ~app-body (&key header-rows filter aside menu content) (div :class "max-w-screen-2xl mx-auto py-1 px-1" (div :class "w-full" @@ -97,21 +23,6 @@ (when content content) (div :class "pb-8"))))))) -(defcomp ~oob-response (&key oobs-html filter-html aside-html menu-html content-html) - (<> - (when oobs-html (raw! oobs-html)) - (div :id "filter" :sx-swap-oob "outerHTML" - (when filter-html (raw! filter-html))) - (aside :id "aside" :sx-swap-oob "outerHTML" - :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))) - (div :id "root-menu" :sx-swap-oob "outerHTML" :class "md:hidden" - (when menu-html (raw! menu-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))))) - -;; Sexp-native OOB response — accepts nested sexp expressions, no raw! (defcomp ~oob-sexp (&key oobs filter aside menu content) (<> (when oobs oobs) @@ -136,56 +47,6 @@ :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 app-label - nav-tree-html auth-menu-html nav-panel-html - settings-url is-admin oob) - (<> - (div :id "root-row" - :sx-swap-oob (if oob "outerHTML" nil) - :class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-500" - (div :class "w-full flex flex-row items-top" - (when cart-mini-html (raw! cart-mini-html)) - (div :class "font-bold text-5xl flex-1" - (a :href (or blog-url "/") :class "flex justify-center md:justify-start items-baseline gap-2" - (h1 (or site-title "")))) - (nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0" - (when nav-tree-html (raw! nav-tree-html)) - (when auth-menu-html (raw! auth-menu-html)) - (when nav-panel-html (raw! nav-panel-html)) - (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))) - (div :class "block md:hidden text-md font-bold" - (when auth-menu-html (raw! auth-menu-html))))) - -(defcomp ~menu-row (&key id level colour link-href link-label link-label-html icon - hx-select nav-html child-id child-html oob external) - (let* ((c (or colour "sky")) - (lv (or level 1)) - (shade (str (- 500 (* lv 100))))) - (<> - (div :id id - :sx-swap-oob (if oob "outerHTML" nil) - :class (str "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-" c "-" shade) - (div :class "relative nav-group" - (a :href link-href - :sx-get (if external nil link-href) - :sx-target (if external nil "#main-panel") - :sx-select (if external nil (or hx-select "#main-panel")) - :sx-swap (if external nil "outerHTML") - :sx-push-url (if external nil "true") - :class "w-full whitespace-normal flex items-center gap-2 font-bold text-2xl px-3 py-2" - (when icon (i :class icon :aria-hidden "true")) - (if link-label-html (raw! link-label-html) - (when link-label (div link-label))))) - (when nav-html - (nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0" - (raw! nav-html)))) - (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 ~post-label (&key feature-image title) (<> (when feature-image (img :src feature-image :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0")) @@ -196,15 +57,6 @@ (i :class "fa fa-shopping-cart" :aria-hidden "true") (span count))) -(defcomp ~oob-header (&key parent-id child-id row-html) - (div :id parent-id :sx-swap-oob "outerHTML" :class "w-full" - (div :class "w-full" (raw! row-html) - (div :id child-id)))) - -(defcomp ~header-child (&key id inner-html) - (div :id (or id "root-header-child") :class "w-full" (raw! inner-html))) - -;; Sexp-native header-row — accepts nested sexp expressions, no raw! (defcomp ~header-row-sx (&key cart-mini blog-url site-title app-label nav-tree auth-menu nav-panel settings-url is-admin oob) @@ -228,7 +80,6 @@ (div :class "block md:hidden text-md font-bold" (when auth-menu auth-menu)))) -;; Sexp-native menu-row — accepts nested sexp expressions, no raw! (defcomp ~menu-row-sx (&key id level colour link-href link-label link-label-content icon hx-select nav child-id child oob external) (let* ((c (or colour "sky")) @@ -256,13 +107,11 @@ (div :id child-id :class "flex flex-col w-full items-center" (when child child)))))) -;; Sexp-native oob-header — accepts nested sexp expression, no raw! (defcomp ~oob-header-sx (&key parent-id child-id row) (div :id parent-id :sx-swap-oob "outerHTML" :class "w-full" (div :class "w-full" row (div :id child-id)))) -;; Sexp-native header-child — accepts nested sexp expression, no raw! (defcomp ~header-child-sx (&key id inner) (div :id (or id "root-header-child") :class "w-full" inner)) diff --git a/shared/sexp/tests/test_components.py b/shared/sexp/tests/test_components.py index 719f308..c58fa8f 100644 --- a/shared/sexp/tests/test_components.py +++ b/shared/sexp/tests/test_components.py @@ -43,13 +43,13 @@ class TestCartMini: html = sexp( '(~cart-mini :cart-count 0 :blog-url "" :cart-url "" :oob "true")', ) - assert 'hx-swap-oob="true"' in html + assert 'sx-swap-oob="true"' in html def test_no_oob_when_nil(self): html = sexp( '(~cart-mini :cart-count 0 :blog-url "" :cart-url "")', ) - assert "hx-swap-oob" not in html + assert "sx-swap-oob" not in html # --------------------------------------------------------------------------- @@ -105,7 +105,7 @@ class TestAccountNavItem: assert 'href="/orders/"' in html assert ">orders<" in html assert "nav-group" in html - assert "data-hx-disable" in html + assert "sx-disable" in html def test_custom_label(self): html = sexp( @@ -212,19 +212,15 @@ class TestPostCard: assert "W"' - ' :at-bar-html "
B
")', - **{ - "hx-select": "#mp", - "widgets-html": '
W
', - "at-bar-html": '
B
', - }, + ' :status "published" :hx-select "#mp")', + **{"hx-select": "#mp"}, ) - assert 'class="widget"' in html - assert 'class="at-bar"' in html + # Basic render without widgets/at-bar should still work + assert " 0, f"{path} produced no expressions" diff --git a/shared/static/scripts/sexp.js b/shared/static/scripts/sexp.js index 86ef189..3741e8f 100644 --- a/shared/static/scripts/sexp.js +++ b/shared/static/scripts/sexp.js @@ -159,7 +159,8 @@ return new Symbol(name); } - throw parseErr("Unexpected character: " + ch, this); + var ctx = this.text.substring(Math.max(0, this.pos - 40), this.pos + 40); + throw parseErr("Unexpected character: " + ch + " | context: «" + ctx.replace(/\n/g, "\\n") + "»", this); }; function isDigit(c) { return c >= "0" && c <= "9"; } @@ -1949,8 +1950,11 @@ // Auto-init in browser // ========================================================================= + Sexp.VERSION = "2026-03-01a"; + if (typeof document !== "undefined") { var init = function () { + console.log("[sexp.js] v" + Sexp.VERSION + " init"); Sexp.processScripts(); Sexp.hydrate(); SxEngine.process(); diff --git a/test/entrypoint.sh b/test/entrypoint.sh index e9b3bb3..951752a 100755 --- a/test/entrypoint.sh +++ b/test/entrypoint.sh @@ -16,6 +16,7 @@ fi RELOAD_FLAG="" if [[ "${RELOAD:-}" == "true" ]]; then RELOAD_FLAG="--reload" + python3 -m shared.dev_watcher & fi PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-app:app}" \ --bind 0.0.0.0:${PORT:-8000} \