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

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