From 4ede0368dc478200698313db83629bbdf72e0606 Mon Sep 17 00:00:00 2001 From: giles Date: Mon, 2 Mar 2026 00:50:57 +0000 Subject: [PATCH] Add admin preview views + fix markdown converter - Fix _markdown() in lexical_to_sx.py: render markdown to HTML with mistune.html() before storing in ~kg-html - Add shared/sx/prettify.py: sx_to_pretty_sx and json_to_pretty_sx produce sx AST for syntax-highlighted DOM (uses canonical serialize()) - Add preview tab to admin header nav - Add GET /preview/ route with 4 views: prettified sx, prettified lexical JSON, sx rendered HTML, lexical rendered HTML - Add ~blog-preview-panel and ~blog-preview-section components - Add syntax highlight CSS for sx/JSON tokens Co-Authored-By: Claude Opus 4.6 --- blog/bp/blog/ghost/lexical_to_sx.py | 5 +- blog/bp/post/admin/routes.py | 57 +++++++++++ blog/sx/admin.sx | 27 +++++ blog/sx/sx_components.py | 62 ++++++++++++ shared/sx/helpers.py | 1 + shared/sx/prettify.py | 149 ++++++++++++++++++++++++++++ 6 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 shared/sx/prettify.py diff --git a/blog/bp/blog/ghost/lexical_to_sx.py b/blog/bp/blog/ghost/lexical_to_sx.py index a816473..84cc673 100644 --- a/blog/bp/blog/ghost/lexical_to_sx.py +++ b/blog/bp/blog/ghost/lexical_to_sx.py @@ -14,6 +14,8 @@ from __future__ import annotations import json from typing import Callable +import mistune + # --------------------------------------------------------------------------- # Registry @@ -435,4 +437,5 @@ def _paywall(_node: dict) -> str: @_converter("markdown") def _markdown(node: dict) -> str: md_text = node.get("markdown", "") - return f'(~kg-html :html "{_esc(md_text)}")' + rendered = mistune.html(md_text) + return f'(~kg-html :html "{_esc(rendered)}")' diff --git a/blog/bp/post/admin/routes.py b/blog/bp/post/admin/routes.py index 5c6e711..5bc726d 100644 --- a/blog/bp/post/admin/routes.py +++ b/blog/bp/post/admin/routes.py @@ -200,6 +200,63 @@ def register(): sx_src = await render_post_data_oob(tctx) return sx_response(sx_src) + @bp.get("/preview/") + @require_admin + async def preview(slug: str): + from models.ghost_content import Post + from sqlalchemy import select as sa_select + + from shared.sx.page import get_template_context + from sx.sx_components import render_post_preview_page, render_post_preview_oob + + post_id = g.post_data["post"]["id"] + post = (await g.s.execute( + sa_select(Post).where(Post.id == post_id) + )).scalar_one_or_none() + + # Build the 4 preview views + preview_ctx = {} + + # 1. Prettified sx source + sx_content = getattr(post, "sx_content", None) or "" + if sx_content: + from shared.sx.prettify import sx_to_pretty_sx + preview_ctx["sx_pretty"] = sx_to_pretty_sx(sx_content) + + # 2. Prettified lexical JSON + lexical_raw = getattr(post, "lexical", None) or "" + if lexical_raw: + from shared.sx.prettify import json_to_pretty_sx + preview_ctx["json_pretty"] = json_to_pretty_sx(lexical_raw) + + # 3. SX rendered preview + if sx_content: + from shared.sx.parser import parse as sx_parse + from shared.sx.html import render as sx_html_render + from shared.sx.jinja_bridge import _COMPONENT_ENV + try: + parsed = sx_parse(sx_content) + preview_ctx["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV)) + except Exception: + preview_ctx["sx_rendered"] = "Error rendering sx" + + # 4. Lexical rendered preview + if lexical_raw: + from bp.blog.ghost.lexical_renderer import render_lexical + try: + preview_ctx["lex_rendered"] = render_lexical(lexical_raw) + except Exception: + preview_ctx["lex_rendered"] = "Error rendering lexical" + + tctx = await get_template_context() + tctx.update(preview_ctx) + if not is_htmx_request(): + html = await render_post_preview_page(tctx) + return await make_response(html) + else: + sx_src = await render_post_preview_oob(tctx) + return sx_response(sx_src) + @bp.get("/entries/calendar//") @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", "]")]