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 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 10:12:03 +00:00
parent 22802bd36b
commit a643b3532d
21 changed files with 225 additions and 232 deletions

View File

@@ -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})..."

View File

@@ -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)."""

View File

@@ -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

View File

@@ -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})..."

View File

@@ -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)
# ---------------------------------------------------------------------------

View File

@@ -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})..."

View File

@@ -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,

View File

@@ -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})..."

View File

@@ -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})..."

View File

@@ -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

View File

@@ -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})..."

View File

@@ -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})..."

View File

@@ -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})..."

View File

@@ -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})..."

69
shared/dev_watcher.py Normal file
View File

@@ -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/<service>/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()

View File

@@ -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

View File

@@ -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! "<!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" :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))

View File

@@ -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 "<img" not in html
def test_widgets_and_at_bar(self):
"""Widgets and at-bar are sexp kwarg slots rendered by the client."""
html = sexp(
'(~post-card :title "T" :slug "s" :href "/"'
' :status "published" :hx-select "#mp"'
' :widgets-html "<div class=\\"widget\\">W</div>"'
' :at-bar-html "<div class=\\"at-bar\\">B</div>")',
**{
"hx-select": "#mp",
"widgets-html": '<div class="widget">W</div>',
"at-bar-html": '<div class="at-bar">B</div>',
},
' :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 "<article" in html
assert "T" in html
# ---------------------------------------------------------------------------
@@ -304,7 +300,7 @@ class TestRelationAttach:
**{"create-url": "/market/create/"},
)
assert 'href="/market/create/"' in html
assert 'hx-get="/market/create/"' in html
assert 'sx-get="/market/create/"' in html
assert "Add Market" in html
assert "fa fa-plus" in html
@@ -326,8 +322,8 @@ class TestRelationDetach:
'(~relation-detach :detach-url "/api/unrelate" :name "Farm Shop")',
**{"detach-url": "/api/unrelate"},
)
assert 'hx-delete="/api/unrelate"' in html
assert 'hx-confirm="Remove Farm Shop?"' in html
assert 'sx-delete="/api/unrelate"' in html
assert 'sx-confirm="Remove Farm Shop?"' in html
assert "fa fa-times" in html
def test_default_name(self):

View File

@@ -0,0 +1,35 @@
"""Verify every .sexpr and .sexp file in the repo parses without errors."""
import os
import pytest
from shared.sexp.parser import parse_all
def _collect_sexp_files():
"""Find all .sexpr and .sexp files under the repo root."""
repo = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)
))))
files = []
for dirpath, _dirs, filenames in os.walk(repo):
if "node_modules" in dirpath or ".git" in dirpath or "artdag" in dirpath:
continue
for fn in filenames:
if fn.endswith((".sexpr", ".sexp")):
files.append(os.path.join(dirpath, fn))
return sorted(files)
_SEXP_FILES = _collect_sexp_files()
@pytest.mark.parametrize("path", _SEXP_FILES, ids=[
os.path.relpath(p) for p in _SEXP_FILES
])
def test_parse(path):
"""Each sexp file should parse without errors."""
with open(path) as f:
source = f.read()
exprs = parse_all(source)
assert len(exprs) > 0, f"{path} produced no expressions"

View File

@@ -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();

View File

@@ -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} \