Externalize sexp to .sexpr files + render() API
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
Replace all 676 inline sexp() string calls across 7 services with render(component_name, **kwargs) calls backed by 46 external .sexpr component definition files (587 defcomps total). - Add render() function to shared/sexp/jinja_bridge.py - Add load_service_components() helper and update load_sexp_dir() for *.sexpr - Update parser keyword regex to support HTMX hx-on::event syntax - Convert remaining inline HTML in route files to render() calls - Add shared/sexp/templates/misc.sexp for cross-service utility components Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -138,16 +138,12 @@ def errors(app):
|
||||
messages = getattr(e, "messages", [str(e)])
|
||||
|
||||
if request.headers.get("HX-Request") == "true":
|
||||
# Build a little styled <ul><li>...</li></ul> snippet
|
||||
lis = "".join(
|
||||
f"<li>{escape(m)}</li>"
|
||||
from shared.sexp.jinja_bridge import render as render_comp
|
||||
items = "".join(
|
||||
render_comp("error-list-item", message=str(escape(m)))
|
||||
for m in messages if m
|
||||
)
|
||||
html = (
|
||||
"<ul class='list-disc pl-5 space-y-1 text-sm text-red-600'>"
|
||||
f"{lis}"
|
||||
"</ul>"
|
||||
)
|
||||
html = render_comp("error-list", items_html=items)
|
||||
return await make_response(html, status)
|
||||
|
||||
# Non-HTMX: show a nicer page with error messages
|
||||
@@ -164,8 +160,9 @@ def errors(app):
|
||||
# Extract service name from "Fragment account/auth-menu failed: ..."
|
||||
service = msg.split("/")[0].replace("Fragment ", "") if "/" in msg else "unknown"
|
||||
if request.headers.get("HX-Request") == "true":
|
||||
from shared.sexp.jinja_bridge import render as render_comp
|
||||
return await make_response(
|
||||
f"<p class='text-sm text-red-600'>Service <b>{escape(service)}</b> is unavailable.</p>",
|
||||
render_comp("fragment-error", service=str(escape(service))),
|
||||
503,
|
||||
)
|
||||
# Raw HTML — cannot use render_template here because the context
|
||||
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .jinja_bridge import sexp
|
||||
from .jinja_bridge import render
|
||||
from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
|
||||
|
||||
|
||||
@@ -31,43 +31,39 @@ def get_asset_url(ctx: dict) -> str:
|
||||
|
||||
def root_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the root header row HTML."""
|
||||
return sexp(
|
||||
'(~header-row :cart-mini-html cmi :blog-url bu :site-title st'
|
||||
' :nav-tree-html nth :auth-menu-html amh :nav-panel-html nph'
|
||||
' :oob oob)',
|
||||
cmi=ctx.get("cart_mini_html", ""),
|
||||
bu=call_url(ctx, "blog_url", ""),
|
||||
st=ctx.get("base_title", ""),
|
||||
nth=ctx.get("nav_tree_html", ""),
|
||||
amh=ctx.get("auth_menu_html", ""),
|
||||
nph=ctx.get("nav_panel_html", ""),
|
||||
return render(
|
||||
"header-row",
|
||||
cart_mini_html=ctx.get("cart_mini_html", ""),
|
||||
blog_url=call_url(ctx, "blog_url", ""),
|
||||
site_title=ctx.get("base_title", ""),
|
||||
nav_tree_html=ctx.get("nav_tree_html", ""),
|
||||
auth_menu_html=ctx.get("auth_menu_html", ""),
|
||||
nav_panel_html=ctx.get("nav_panel_html", ""),
|
||||
oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def search_mobile_html(ctx: dict) -> str:
|
||||
"""Build mobile search input HTML."""
|
||||
return sexp(
|
||||
'(~search-mobile :current-local-href clh :search s :search-count sc'
|
||||
' :hx-select hs :search-headers-mobile shm)',
|
||||
clh=ctx.get("current_local_href", "/"),
|
||||
s=ctx.get("search", ""),
|
||||
sc=ctx.get("search_count", ""),
|
||||
hs=ctx.get("hx_select", "#main-panel"),
|
||||
shm=SEARCH_HEADERS_MOBILE,
|
||||
return render(
|
||||
"search-mobile",
|
||||
current_local_href=ctx.get("current_local_href", "/"),
|
||||
search=ctx.get("search", ""),
|
||||
search_count=ctx.get("search_count", ""),
|
||||
hx_select=ctx.get("hx_select", "#main-panel"),
|
||||
search_headers_mobile=SEARCH_HEADERS_MOBILE,
|
||||
)
|
||||
|
||||
|
||||
def search_desktop_html(ctx: dict) -> str:
|
||||
"""Build desktop search input HTML."""
|
||||
return sexp(
|
||||
'(~search-desktop :current-local-href clh :search s :search-count sc'
|
||||
' :hx-select hs :search-headers-desktop shd)',
|
||||
clh=ctx.get("current_local_href", "/"),
|
||||
s=ctx.get("search", ""),
|
||||
sc=ctx.get("search_count", ""),
|
||||
hs=ctx.get("hx_select", "#main-panel"),
|
||||
shd=SEARCH_HEADERS_DESKTOP,
|
||||
return render(
|
||||
"search-desktop",
|
||||
current_local_href=ctx.get("current_local_href", "/"),
|
||||
search=ctx.get("search", ""),
|
||||
search_count=ctx.get("search_count", ""),
|
||||
hx_select=ctx.get("hx_select", "#main-panel"),
|
||||
search_headers_desktop=SEARCH_HEADERS_DESKTOP,
|
||||
)
|
||||
|
||||
|
||||
@@ -76,19 +72,17 @@ def full_page(ctx: dict, *, header_rows_html: str,
|
||||
content_html: str = "", menu_html: str = "",
|
||||
body_end_html: str = "", meta_html: str = "") -> str:
|
||||
"""Render a full app page with the standard layout."""
|
||||
return sexp(
|
||||
'(~app-layout :title t :asset-url au :meta-html mh'
|
||||
' :header-rows-html hrh :menu-html muh :filter-html fh'
|
||||
' :aside-html ash :content-html ch :body-end-html beh)',
|
||||
t=ctx.get("base_title", "Rose Ash"),
|
||||
au=get_asset_url(ctx),
|
||||
mh=meta_html,
|
||||
hrh=header_rows_html,
|
||||
muh=menu_html,
|
||||
fh=filter_html,
|
||||
ash=aside_html,
|
||||
ch=content_html,
|
||||
beh=body_end_html,
|
||||
return render(
|
||||
"app-layout",
|
||||
title=ctx.get("base_title", "Rose Ash"),
|
||||
asset_url=get_asset_url(ctx),
|
||||
meta_html=meta_html,
|
||||
header_rows_html=header_rows_html,
|
||||
menu_html=menu_html,
|
||||
filter_html=filter_html,
|
||||
aside_html=aside_html,
|
||||
content_html=content_html,
|
||||
body_end_html=body_end_html,
|
||||
)
|
||||
|
||||
|
||||
@@ -96,12 +90,11 @@ 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 sexp(
|
||||
'(~oob-response :oobs-html oh :filter-html fh :aside-html ash'
|
||||
' :menu-html mh :content-html ch)',
|
||||
oh=oobs_html,
|
||||
fh=filter_html,
|
||||
ash=aside_html,
|
||||
mh=menu_html,
|
||||
ch=content_html,
|
||||
return render(
|
||||
"oob-response",
|
||||
oobs_html=oobs_html,
|
||||
filter_html=filter_html,
|
||||
aside_html=aside_html,
|
||||
menu_html=menu_html,
|
||||
content_html=content_html,
|
||||
)
|
||||
|
||||
@@ -24,9 +24,9 @@ import glob
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from .types import NIL, Symbol
|
||||
from .types import NIL, Component, Keyword, Symbol
|
||||
from .parser import parse
|
||||
from .html import render as html_render
|
||||
from .html import render as html_render, _render_component
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -44,12 +44,22 @@ def get_component_env() -> dict[str, Any]:
|
||||
|
||||
|
||||
def load_sexp_dir(directory: str) -> None:
|
||||
"""Load all .sexp files from a directory and register components."""
|
||||
for filepath in sorted(glob.glob(os.path.join(directory, "*.sexp"))):
|
||||
"""Load all .sexp and .sexpr files from a directory and register components."""
|
||||
for filepath in sorted(
|
||||
glob.glob(os.path.join(directory, "*.sexp"))
|
||||
+ glob.glob(os.path.join(directory, "*.sexpr"))
|
||||
):
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
register_components(f.read())
|
||||
|
||||
|
||||
def load_service_components(service_dir: str) -> None:
|
||||
"""Load service-specific s-expression components from {service_dir}/sexp/."""
|
||||
sexp_dir = os.path.join(service_dir, "sexp")
|
||||
if os.path.isdir(sexp_dir):
|
||||
load_sexp_dir(sexp_dir)
|
||||
|
||||
|
||||
def register_components(sexp_source: str) -> None:
|
||||
"""Parse and evaluate s-expression component definitions into the
|
||||
shared environment.
|
||||
@@ -96,6 +106,28 @@ def sexp(source: str, **kwargs: Any) -> str:
|
||||
return html_render(expr, env)
|
||||
|
||||
|
||||
def render(component_name: str, **kwargs: Any) -> str:
|
||||
"""Call a registered component by name with Python kwargs.
|
||||
|
||||
Automatically converts Python snake_case to sexp kebab-case.
|
||||
No sexp strings needed — just a function call.
|
||||
"""
|
||||
name = component_name if component_name.startswith("~") else f"~{component_name}"
|
||||
comp = _COMPONENT_ENV.get(name)
|
||||
if not isinstance(comp, Component):
|
||||
raise ValueError(f"Unknown component: {name}")
|
||||
|
||||
env = dict(_COMPONENT_ENV)
|
||||
args: list[Any] = []
|
||||
for key, val in kwargs.items():
|
||||
kw_name = key.replace("_", "-")
|
||||
args.append(Keyword(kw_name))
|
||||
args.append(val)
|
||||
env[kw_name] = val
|
||||
|
||||
return _render_component(comp, args, env)
|
||||
|
||||
|
||||
async def sexp_async(source: str, **kwargs: Any) -> str:
|
||||
"""Async version of ``sexp()`` — resolves I/O primitives (frag, query)
|
||||
before rendering.
|
||||
@@ -144,4 +176,5 @@ def setup_sexp_bridge(app: Any) -> None:
|
||||
- ``sexp_async(source, **kwargs)`` — async render (with I/O resolution)
|
||||
"""
|
||||
app.jinja_env.globals["sexp"] = sexp
|
||||
app.jinja_env.globals["render"] = render
|
||||
app.jinja_env.globals["sexp_async"] = sexp_async
|
||||
|
||||
@@ -46,7 +46,7 @@ class Tokenizer:
|
||||
COMMENT = re.compile(r";[^\n]*")
|
||||
STRING = re.compile(r'"(?:[^"\\]|\\.)*"')
|
||||
NUMBER = re.compile(r"-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?")
|
||||
KEYWORD = re.compile(r":[a-zA-Z_][a-zA-Z0-9_>-]*")
|
||||
KEYWORD = re.compile(r":[a-zA-Z_][a-zA-Z0-9_>:-]*")
|
||||
# Symbols may start with alpha, _, or common operator chars, plus ~ for components,
|
||||
# <> for the fragment symbol, and & for &key/&rest.
|
||||
SYMBOL = re.compile(r"[a-zA-Z_~*+\-><=/!?&][a-zA-Z0-9_~*+\-><=/!?.:&]*")
|
||||
|
||||
30
shared/sexp/templates/misc.sexp
Normal file
30
shared/sexp/templates/misc.sexp
Normal file
@@ -0,0 +1,30 @@
|
||||
;; Miscellaneous shared components for Phase 3 conversion
|
||||
|
||||
(defcomp ~error-inline (&key message)
|
||||
(div :class "text-red-600 text-sm" (raw! message)))
|
||||
|
||||
(defcomp ~notification-badge (&key count)
|
||||
(span :class "bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5" (raw! count)))
|
||||
|
||||
(defcomp ~cache-cleared (&key time-str)
|
||||
(span :class "text-green-600 font-bold" "Cache cleared at " (raw! time-str)))
|
||||
|
||||
(defcomp ~error-list (&key items-html)
|
||||
(ul :class "list-disc pl-5 space-y-1 text-sm text-red-600"
|
||||
(raw! items-html)))
|
||||
|
||||
(defcomp ~error-list-item (&key message)
|
||||
(li (raw! message)))
|
||||
|
||||
(defcomp ~fragment-error (&key service)
|
||||
(p :class "text-sm text-red-600" "Service " (b (raw! service)) " is unavailable."))
|
||||
|
||||
(defcomp ~htmx-sentinel (&key id hx-get hx-trigger hx-swap class extra-attrs)
|
||||
(div :id id :hx-get hx-get :hx-trigger hx-trigger :hx-swap hx-swap :class class))
|
||||
|
||||
(defcomp ~nav-group-link (&key href hx-select nav-class label)
|
||||
(div :class "relative nav-group"
|
||||
(a :href href :hx-get href :hx-target "#main-panel"
|
||||
:hx-select hx-select :hx-swap "outerHTML"
|
||||
:hx-push-url "true" :class nav-class
|
||||
(raw! label))))
|
||||
Reference in New Issue
Block a user