Fix missing SxExpr wraps in events + pretty-print sx in dev mode + multi-expr render

- Wrap 15 call sites in events/sx_components.py where sx-generating
  functions were passed as plain strings to sx_call(), causing raw
  s-expression source to leak into the rendered page.

- Add dev-mode pretty-printing (RELOAD=true) for sx responses and
  full page sx source — indented output in Network tab and View Source.

- Fix Sx.render to handle multiple top-level expressions by falling
  back to parseAll and returning a DocumentFragment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 20:29:22 +00:00
parent 8aedbc9e62
commit ed30f88f05
3 changed files with 91 additions and 21 deletions

View File

@@ -791,7 +791,7 @@ def _tickets_main_panel_html(ctx: dict, tickets: list) -> str:
type_name=tt.name if tt else None,
time_str=time_str or None,
cal_name=cal.name if cal else None,
badge=_ticket_state_badge_html(state),
badge=SxExpr(_ticket_state_badge_html(state)),
code_prefix=ticket.code[:8]))
cards_html = "".join(ticket_cards)
@@ -904,7 +904,7 @@ def _ticket_admin_main_panel_html(ctx: dict, tickets: list, stats: dict) -> str:
entry_name=entry.name if entry else "\u2014",
date=SxExpr(date_html),
type_name=tt.name if tt else "\u2014",
badge=_ticket_state_badge_html(state),
badge=SxExpr(_ticket_state_badge_html(state)),
action=SxExpr(action_html))
return sx_call("events-ticket-admin-panel",
@@ -973,7 +973,7 @@ def _entry_card_html(entry, page_info: dict, pending_tickets: dict,
if tp is not None:
qty = pending_tickets.get(entry.id, 0)
widget_html = sx_call("events-entry-widget-wrapper",
widget=_ticket_widget_html(entry, qty, ticket_url, ctx={}))
widget=SxExpr(_ticket_widget_html(entry, qty, ticket_url, ctx={})))
return sx_call("events-entry-card",
title=SxExpr(title_html), badges=SxExpr(badges_html),
@@ -1036,7 +1036,7 @@ def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict,
if tp is not None:
qty = pending_tickets.get(entry.id, 0)
widget_html = sx_call("events-entry-tile-widget-wrapper",
widget=_ticket_widget_html(entry, qty, ticket_url, ctx={}))
widget=SxExpr(_ticket_widget_html(entry, qty, ticket_url, ctx={})))
return sx_call("events-entry-card-tile",
title=SxExpr(title_html), badges=SxExpr(badges_html),
@@ -1154,7 +1154,7 @@ def _view_toggle_html(ctx: dict, view: str) -> str:
list_href=list_href, tile_href=tile_href,
hx_select=hx_select, list_cls=list_active,
tile_cls=tile_active, storage_key="events_view",
list_svg=_get_list_svg(), tile_svg=_get_tile_svg())
list_svg=SxExpr(_get_list_svg()), tile_svg=SxExpr(_get_tile_svg()))
def _events_main_panel_html(ctx: dict, entries, has_more, pending_tickets, page_info,
@@ -1619,7 +1619,7 @@ def render_checkin_result(success: bool, error: str | None, ticket) -> str:
entry_name=entry.name if entry else "\u2014",
date=SxExpr(date_html),
type_name=tt.name if tt else "\u2014",
badge=_ticket_state_badge_html("checked_in"),
badge=SxExpr(_ticket_state_badge_html("checked_in")),
time_str=time_str)
@@ -1656,7 +1656,7 @@ def render_lookup_result(ticket, error: str | None) -> str:
if cal:
info_html += sx_call("events-lookup-cal", cal_name=cal.name)
info_html += sx_call("events-lookup-status",
badge=_ticket_state_badge_html(state), code=code)
badge=SxExpr(_ticket_state_badge_html(state)), code=code)
if checked_in_at:
info_html += sx_call("events-lookup-checkin-time",
date_str=checked_in_at.strftime("%B %d, %Y at %H:%M"))
@@ -1709,7 +1709,7 @@ def render_entry_tickets_admin(entry, tickets: list) -> str:
rows_html += sx_call("events-entry-tickets-admin-row",
code=code, code_short=code[:12] + "...",
type_name=tt.name if tt else "\u2014",
badge=_ticket_state_badge_html(state),
badge=SxExpr(_ticket_state_badge_html(state)),
action=SxExpr(action_html))
if tickets:
@@ -1783,7 +1783,7 @@ def _entry_main_panel_html(ctx: dict) -> str:
# State
state_html = _field("State", sx_call("events-entry-state-field",
entry_id=str(eid),
badge=_entry_state_badge_html(state)))
badge=SxExpr(_entry_state_badge_html(state))))
# Cost
cost = getattr(entry, "cost", None)
@@ -1794,7 +1794,7 @@ def _entry_main_panel_html(ctx: dict) -> str:
# Ticket Configuration (admin)
tickets_html = _field("Tickets", sx_call("events-entry-tickets-field",
entry_id=str(eid),
tickets_config=render_entry_tickets_config(entry, calendar, day, month, year)))
tickets_config=SxExpr(render_entry_tickets_config(entry, calendar, day, month, year))))
# Buy Tickets (public-facing)
ticket_remaining = ctx.get("ticket_remaining")
@@ -1814,7 +1814,7 @@ def _entry_main_panel_html(ctx: dict) -> str:
entry_posts = ctx.get("entry_posts") or []
posts_html = _field("Associated Posts", sx_call("events-entry-posts-field",
entry_id=str(eid),
posts_panel=render_entry_posts_panel(entry_posts, entry, calendar, day, month, year)))
posts_panel=SxExpr(render_entry_posts_panel(entry_posts, entry, calendar, day, month, year))))
# Options and Edit Button
edit_url = url_for(
@@ -1830,7 +1830,7 @@ def _entry_main_panel_html(ctx: dict) -> str:
cost=SxExpr(cost_html), tickets=SxExpr(tickets_html),
buy=SxExpr(buy_html), date=SxExpr(date_html),
posts=SxExpr(posts_html),
options=_entry_options_html(entry, calendar, day, month, year),
options=SxExpr(_entry_options_html(entry, calendar, day, month, year)),
pre_action=pre_action, edit_url=edit_url)
@@ -1861,8 +1861,8 @@ def _entry_header_html(ctx: dict, *, oob: bool = False) -> str:
)
label_html = sx_call("events-entry-label",
entry_id=str(entry.id),
title=_entry_title_html(entry),
times=_entry_times_html(entry))
title=SxExpr(_entry_title_html(entry)),
times=SxExpr(_entry_times_html(entry)))
nav_html = _entry_nav_html(ctx)
@@ -1988,7 +1988,7 @@ def _entry_title_html(entry) -> str:
state = getattr(entry, "state", "pending") or "pending"
return sx_call("events-entry-title",
name=entry.name,
badge=_entry_state_badge_html(state))
badge=SxExpr(_entry_state_badge_html(state)))
def _entry_options_html(entry, calendar, day, month, year) -> str:
@@ -2578,13 +2578,13 @@ def render_buy_form(entry, ticket_remaining, ticket_sold_count,
cost_str = f"\u00a3{tt.cost:.2f}" if tt.cost is not None else "\u00a30.00"
type_items += sx_call("events-buy-type-item",
type_name=tt.name, cost_str=cost_str,
adjust_controls=_ticket_adjust_controls(csrf, adjust_url, target, eid, type_count, ticket_type_id=tt.id))
adjust_controls=SxExpr(_ticket_adjust_controls(csrf, adjust_url, target, eid, type_count, ticket_type_id=tt.id)))
body_html = sx_call("events-buy-types-wrapper", items=SxExpr(type_items))
else:
qty = user_ticket_count or 0
body_html = sx_call("events-buy-default",
price_str=f"\u00a3{tp:.2f}",
adjust_controls=_ticket_adjust_controls(csrf, adjust_url, target, eid, qty))
adjust_controls=SxExpr(_ticket_adjust_controls(csrf, adjust_url, target, eid, qty)))
return sx_call("events-buy-panel",
entry_id=eid_s, info=SxExpr(info_html), body=SxExpr(body_html))
@@ -3491,7 +3491,7 @@ def render_fragment_account_tickets(tickets) -> str:
items_html += sx_call("events-frag-ticket-item",
href=href, entry_name=ticket.entry_name,
date_str=date_str, calendar_name=cal_name,
type_name=type_name, badge=badge_html)
type_name=type_name, badge=SxExpr(badge_html))
body = sx_call("events-frag-tickets-list", items=SxExpr(items_html))
else:
body = sx_call("empty-state", message="No tickets yet.",
@@ -3526,7 +3526,7 @@ def render_fragment_account_bookings(bookings) -> str:
name=booking.name,
date_str=date_str + date_str_extra,
calendar_name=cal_name, cost_str=cost_str,
badge=badge_html)
badge=SxExpr(badge_html))
body = sx_call("events-frag-bookings-list", items=SxExpr(items_html))
else:
body = sx_call("empty-state", message="No bookings yet.",

View File

@@ -1262,9 +1262,23 @@
// DOM Renderer
render: function (exprOrText, extraEnv) {
var expr = typeof exprOrText === "string" ? parse(exprOrText) : exprOrText;
var env = extraEnv ? merge({}, _componentEnv, extraEnv) : _componentEnv;
return renderDOM(expr, env);
if (typeof exprOrText === "string") {
// Try single expression first; fall back to multi-expression fragment
try {
return renderDOM(parse(exprOrText), env);
} catch (e) {
var exprs = parseAll(exprOrText);
if (exprs.length === 0) throw e;
var frag = document.createDocumentFragment();
for (var i = 0; i < exprs.length; i++) {
var node = renderDOM(exprs[i], env);
if (node) frag.appendChild(node);
}
return frag;
}
}
return renderDOM(exprOrText, env);
},
// String Renderer (matches Python html.render output)

View File

@@ -431,6 +431,10 @@ def sx_response(source_or_component: str, status: int = 200,
if new_rules:
body = f'<style data-sx-css>{new_rules}</style>\n{body}'
# Dev mode: pretty-print sx source for readable Network tab responses
if _is_dev_mode():
body = _pretty_print_sx_body(body)
resp = Response(body, status=status, content_type="text/sx")
if new_classes:
resp.headers["SX-Css-Add"] = ",".join(sorted(new_classes))
@@ -545,6 +549,14 @@ def sx_page(ctx: dict, page_sx: str, *,
title = ctx.get("base_title", "Rose Ash")
csrf = _get_csrf_token()
# Dev mode: pretty-print page sx for readable View Source
if _is_dev_mode() and page_sx and page_sx.startswith("("):
from .parser import parse as _parse, serialize as _serialize
try:
page_sx = _serialize(_parse(page_sx), pretty=True)
except Exception:
pass
return _SX_PAGE_TEMPLATE.format(
title=_html_escape(title),
asset_url=asset_url,
@@ -592,6 +604,50 @@ def _get_sx_comp_cookie() -> str:
return ""
def _is_dev_mode() -> bool:
"""Check if running in dev mode (RELOAD=true)."""
import os
return os.getenv("RELOAD") == "true"
def _pretty_print_sx_body(body: str) -> str:
"""Pretty-print the sx portion of a response body, preserving HTML blocks."""
import re
from .parser import parse_all as _parse_all, serialize as _serialize
# Split HTML prefix blocks (<style>, <script>) from the sx tail
# These are always at the start, each on its own line
parts: list[str] = []
rest = body
while rest.startswith("<"):
end = rest.find(">", rest.find("</")) + 1
if end <= 0:
break
# Find end of the closing tag
tag_match = re.match(r'<(style|script)[^>]*>[\s\S]*?</\1>', rest)
if tag_match:
parts.append(tag_match.group(0))
rest = rest[tag_match.end():].lstrip("\n")
else:
break
sx_source = rest.strip()
if not sx_source or sx_source[0] != "(":
return body
try:
exprs = _parse_all(sx_source)
if len(exprs) == 1:
parts.append(_serialize(exprs[0], pretty=True))
else:
# Multiple top-level expressions — indent each
pretty_parts = [_serialize(expr, pretty=True) for expr in exprs]
parts.append("\n\n".join(pretty_parts))
return "\n\n".join(parts)
except Exception:
return body
def _html_escape(s: str) -> str:
"""Minimal HTML escaping for attribute values."""
return (s.replace("&", "&amp;")