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
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:
@@ -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})..."
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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})..."
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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})..."
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})..."
|
||||
|
||||
@@ -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})..."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})..."
|
||||
|
||||
@@ -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})..."
|
||||
|
||||
@@ -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})..."
|
||||
|
||||
@@ -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
69
shared/dev_watcher.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
35
shared/sexp/tests/test_parse_all.py
Normal file
35
shared/sexp/tests/test_parse_all.py
Normal 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"
|
||||
@@ -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();
|
||||
|
||||
@@ -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} \
|
||||
|
||||
Reference in New Issue
Block a user