/")
@require_admin
async def calendar_view(slug: str, calendar_id: int):
diff --git a/blog/sx/admin.sx b/blog/sx/admin.sx
index 9e39740..85d2f98 100644
--- a/blog/sx/admin.sx
+++ b/blog/sx/admin.sx
@@ -142,3 +142,30 @@
(defcomp ~blog-tag-group-edit-main (&key edit-form delete-form)
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
edit-form delete-form))
+
+;; Preview panel components
+
+(defcomp ~blog-preview-panel (&key sections)
+ (div :class "max-w-4xl mx-auto px-4 py-6 space-y-4"
+ (style "
+ .sx-pretty, .json-pretty { font-family: monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; }
+ .sx-list, .json-obj, .json-arr { display: block; }
+ .sx-paren { color: #64748b; }
+ .sx-sym { color: #0369a1; }
+ .sx-kw { color: #7c3aed; }
+ .sx-str { color: #15803d; }
+ .sx-num { color: #c2410c; }
+ .sx-bool { color: #b91c1c; font-weight: 600; }
+ .json-brace, .json-bracket { color: #64748b; }
+ .json-key { color: #7c3aed; }
+ .json-str { color: #15803d; }
+ .json-num { color: #c2410c; }
+ .json-lit { color: #b91c1c; font-weight: 600; }
+ .json-field { display: block; }
+ ")
+ sections))
+
+(defcomp ~blog-preview-section (&key title content)
+ (details :class "border rounded bg-white"
+ (summary :class "cursor-pointer px-4 py-3 font-medium text-sm bg-stone-100 hover:bg-stone-200 select-none" title)
+ (div :class "p-4 overflow-x-auto text-xs" content)))
diff --git a/blog/sx/sx_components.py b/blog/sx/sx_components.py
index f7e8be0..e635480 100644
--- a/blog/sx/sx_components.py
+++ b/blog/sx/sx_components.py
@@ -1377,6 +1377,68 @@ async def render_post_data_oob(ctx: dict) -> str:
return oob_page_sx(oobs=admin_hdr_oob, content=content)
+# ---- Post preview ----
+
+def _preview_main_panel_sx(ctx: dict) -> str:
+ """Build the preview panel with 4 expandable sections."""
+ sections: list[str] = []
+
+ # 1. Prettified SX source
+ sx_pretty = ctx.get("sx_pretty", "")
+ if sx_pretty:
+ sections.append(sx_call("blog-preview-section",
+ title="S-Expression Source",
+ content=SxExpr(sx_pretty),
+ ))
+
+ # 2. Prettified Lexical JSON
+ json_pretty = ctx.get("json_pretty", "")
+ if json_pretty:
+ sections.append(sx_call("blog-preview-section",
+ title="Lexical JSON",
+ content=SxExpr(json_pretty),
+ ))
+
+ # 3. SX rendered preview
+ sx_rendered = ctx.get("sx_rendered", "")
+ if sx_rendered:
+ rendered_sx = f'(div :class "blog-content prose max-w-none" (raw! {sx_serialize(sx_rendered)}))'
+ sections.append(sx_call("blog-preview-section",
+ title="SX Rendered",
+ content=SxExpr(rendered_sx),
+ ))
+
+ # 4. Lexical rendered preview
+ lex_rendered = ctx.get("lex_rendered", "")
+ if lex_rendered:
+ rendered_sx = f'(div :class "blog-content prose max-w-none" (raw! {sx_serialize(lex_rendered)}))'
+ sections.append(sx_call("blog-preview-section",
+ title="Lexical Rendered",
+ content=SxExpr(rendered_sx),
+ ))
+
+ if not sections:
+ return '(div :class "p-8 text-stone-500" "No content to preview.")'
+
+ inner = " ".join(sections)
+ return sx_call("blog-preview-panel", sections=SxExpr(f"(<> {inner})"))
+
+
+async def render_post_preview_page(ctx: dict) -> str:
+ root_hdr = root_header_sx(ctx)
+ post_hdr = _post_header_sx(ctx)
+ admin_hdr = _post_admin_header_sx(ctx, selected="preview")
+ header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
+ content = _preview_main_panel_sx(ctx)
+ return full_page_sx(ctx, header_rows=header_rows, content=content)
+
+
+async def render_post_preview_oob(ctx: dict) -> str:
+ admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="preview")
+ content = _preview_main_panel_sx(ctx)
+ return oob_page_sx(oobs=admin_hdr_oob, content=content)
+
+
# ---- Post entries ----
async def render_post_entries_page(ctx: dict) -> str:
diff --git a/shared/sx/helpers.py b/shared/sx/helpers.py
index ddb2b7b..69f48bb 100644
--- a/shared/sx/helpers.py
+++ b/shared/sx/helpers.py
@@ -205,6 +205,7 @@ def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
("cart_url", f"/{slug}/admin/payments/", "payments"),
("blog_url", f"/{slug}/admin/entries/", "entries"),
("blog_url", f"/{slug}/admin/data/", "data"),
+ ("blog_url", f"/{slug}/admin/preview/", "preview"),
("blog_url", f"/{slug}/admin/edit/", "edit"),
("blog_url", f"/{slug}/admin/settings/", "settings"),
]
diff --git a/shared/sx/prettify.py b/shared/sx/prettify.py
new file mode 100644
index 0000000..2bc44f9
--- /dev/null
+++ b/shared/sx/prettify.py
@@ -0,0 +1,149 @@
+"""
+Prettifiers that produce s-expression source for syntax-highlighted DOM.
+
+``sx_to_pretty_sx(source)`` — parse sx, emit sx that renders as coloured DOM.
+``json_to_pretty_sx(json_str)`` — parse JSON, emit sx that renders as coloured DOM.
+
+The output is *not* HTML — it's sx source that, when evaluated and rendered by
+the sx engine, produces ```` blocks with ```` elements carrying
+CSS classes for syntax highlighting.
+"""
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from .parser import parse, serialize
+from .types import Keyword, Symbol, NIL
+
+
+# ---------------------------------------------------------------------------
+# Helpers — build sx AST (lists), then serialize once at the end
+# ---------------------------------------------------------------------------
+
+def _span(cls: str, text: str) -> list:
+ """Build an sx AST node: (span :class "cls" "text")."""
+ return [Symbol("span"), Keyword("class"), cls, text]
+
+
+def _str_display(cls: str, value: str) -> list:
+ """Build a span showing a quoted string value.
+
+ Uses curly quotes so the display delimiters don't conflict with
+ sx string syntax.
+ """
+ return [Symbol("span"), Keyword("class"), cls,
+ [Symbol("span"), Keyword("class"), f"{cls}-q", "\u201c"],
+ value,
+ [Symbol("span"), Keyword("class"), f"{cls}-q", "\u201d"]]
+
+
+# ---------------------------------------------------------------------------
+# S-expression prettifier
+# ---------------------------------------------------------------------------
+
+def sx_to_pretty_sx(source: str) -> str:
+ """Parse *source* as sx and return sx source that renders as highlighted DOM."""
+ try:
+ expr = parse(source)
+ except Exception:
+ return serialize([Symbol("pre"), Keyword("class"), "sx-pretty", source])
+ inner = _sx_node(expr, depth=0)
+ return serialize([Symbol("pre"), Keyword("class"), "sx-pretty", inner])
+
+
+def _sx_node(expr: Any, depth: int) -> list:
+ """Recursively convert a parsed sx value to an sx AST for pretty display."""
+ if isinstance(expr, list):
+ if not expr:
+ return [_span("sx-paren", "("), _span("sx-paren", ")")]
+ return _sx_list(expr, depth)
+ if isinstance(expr, Symbol):
+ return _span("sx-sym", expr.name)
+ if isinstance(expr, Keyword):
+ return _span("sx-kw", f":{expr.name}")
+ if isinstance(expr, str):
+ return _str_display("sx-str", expr)
+ if isinstance(expr, bool):
+ return _span("sx-bool", "true" if expr else "false")
+ if isinstance(expr, (int, float)):
+ return _span("sx-num", str(expr))
+ if expr is None or expr is NIL:
+ return _span("sx-sym", "nil")
+ return _span("sx-sym", str(expr))
+
+
+def _sx_list(items: list, depth: int) -> list:
+ """Format a list as a prettified sx AST node."""
+ children: list = []
+ for item in items:
+ children.append(_sx_node(item, depth + 1))
+ indent_style = f"margin-left: {depth * 16}px"
+ return [Symbol("div"), Keyword("class"), "sx-list",
+ Keyword("style"), indent_style,
+ _span("sx-paren", "("),
+ *children,
+ _span("sx-paren", ")")]
+
+
+# ---------------------------------------------------------------------------
+# JSON prettifier
+# ---------------------------------------------------------------------------
+
+def json_to_pretty_sx(json_str: str) -> str:
+ """Parse *json_str* as JSON and return sx source that renders as highlighted DOM."""
+ try:
+ data = json.loads(json_str)
+ except (json.JSONDecodeError, TypeError):
+ return serialize([Symbol("pre"), Keyword("class"), "json-pretty",
+ json_str or ""])
+ inner = _json_node(data, depth=0)
+ return serialize([Symbol("pre"), Keyword("class"), "json-pretty", inner])
+
+
+def _json_node(val: Any, depth: int) -> list:
+ """Recursively convert a JSON value to an sx AST for pretty display."""
+ if isinstance(val, dict):
+ return _json_object(val, depth)
+ if isinstance(val, list):
+ return _json_array(val, depth)
+ if isinstance(val, str):
+ return _str_display("json-str", val)
+ if isinstance(val, bool):
+ return _span("json-lit", "true" if val else "false")
+ if val is None:
+ return _span("json-lit", "null")
+ if isinstance(val, (int, float)):
+ return _span("json-num", str(val))
+ return _span("json-str", str(val))
+
+
+def _json_object(obj: dict, depth: int) -> list:
+ if not obj:
+ return [_span("json-brace", "{"), _span("json-brace", "}")]
+ indent_style = f"margin-left: {depth * 16}px"
+ fields: list = []
+ for key, val in obj.items():
+ key_node = _str_display("json-key", key)
+ val_node = _json_node(val, depth + 1)
+ fields.append([Symbol("div"), Keyword("class"), "json-field",
+ key_node, ": ", val_node])
+ return [Symbol("div"), Keyword("class"), "json-obj",
+ Keyword("style"), indent_style,
+ _span("json-brace", "{"),
+ *fields,
+ _span("json-brace", "}")]
+
+
+def _json_array(arr: list, depth: int) -> list:
+ if not arr:
+ return [_span("json-bracket", "["), _span("json-bracket", "]")]
+ indent_style = f"margin-left: {depth * 16}px"
+ items: list = []
+ for item in arr:
+ items.append(_json_node(item, depth + 1))
+ return [Symbol("div"), Keyword("class"), "json-arr",
+ Keyword("style"), indent_style,
+ _span("json-bracket", "["),
+ *items,
+ _span("json-bracket", "]")]