Phase 5 cleanup: remove legacy HTML components, fix nav-tree fragment
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m43s

- 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

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