Add admin preview views + fix markdown converter
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m31s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m31s
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,8 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
import mistune
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Registry
|
# Registry
|
||||||
@@ -435,4 +437,5 @@ def _paywall(_node: dict) -> str:
|
|||||||
@_converter("markdown")
|
@_converter("markdown")
|
||||||
def _markdown(node: dict) -> str:
|
def _markdown(node: dict) -> str:
|
||||||
md_text = node.get("markdown", "")
|
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)}")'
|
||||||
|
|||||||
@@ -200,6 +200,63 @@ def register():
|
|||||||
sx_src = await render_post_data_oob(tctx)
|
sx_src = await render_post_data_oob(tctx)
|
||||||
return sx_response(sx_src)
|
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"] = "<em>Error rendering sx</em>"
|
||||||
|
|
||||||
|
# 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"] = "<em>Error rendering lexical</em>"
|
||||||
|
|
||||||
|
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/<int:calendar_id>/")
|
@bp.get("/entries/calendar/<int:calendar_id>/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def calendar_view(slug: str, calendar_id: int):
|
async def calendar_view(slug: str, calendar_id: int):
|
||||||
|
|||||||
@@ -142,3 +142,30 @@
|
|||||||
(defcomp ~blog-tag-group-edit-main (&key edit-form delete-form)
|
(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"
|
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
|
||||||
edit-form delete-form))
|
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)))
|
||||||
|
|||||||
@@ -1377,6 +1377,68 @@ async def render_post_data_oob(ctx: dict) -> str:
|
|||||||
return oob_page_sx(oobs=admin_hdr_oob, content=content)
|
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 ----
|
# ---- Post entries ----
|
||||||
|
|
||||||
async def render_post_entries_page(ctx: dict) -> str:
|
async def render_post_entries_page(ctx: dict) -> str:
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
|
|||||||
("cart_url", f"/{slug}/admin/payments/", "payments"),
|
("cart_url", f"/{slug}/admin/payments/", "payments"),
|
||||||
("blog_url", f"/{slug}/admin/entries/", "entries"),
|
("blog_url", f"/{slug}/admin/entries/", "entries"),
|
||||||
("blog_url", f"/{slug}/admin/data/", "data"),
|
("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/edit/", "edit"),
|
||||||
("blog_url", f"/{slug}/admin/settings/", "settings"),
|
("blog_url", f"/{slug}/admin/settings/", "settings"),
|
||||||
]
|
]
|
||||||
|
|||||||
149
shared/sx/prettify.py
Normal file
149
shared/sx/prettify.py
Normal file
@@ -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 ``<pre>`` blocks with ``<span>`` 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", "]")]
|
||||||
Reference in New Issue
Block a user