6 Commits

Author SHA1 Message Date
4ede0368dc Add admin preview views + fix markdown converter
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>
2026-03-02 00:50:57 +00:00
a8e06e87fb Fix extended-text/heading/quote nodes: treat as inline text when inside links
Ghost's extended-text node can appear both as a block (with children) and
inline (with text field). When used as a child of a link node, it has a
text field and should produce a text literal, not a (p ...) wrapper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:47:54 +00:00
588d240ddc Fix backfill script imports to match actual module paths
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:29:26 +00:00
aa5c251a45 Auto-bust sx.js and body.js via MD5 hash instead of manual version string
Computes file content hash at process startup, cached for lifetime.
Removes manual cache-busting instruction from CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:26:20 +00:00
7ccb463a8b Wire sx_content through full read/write pipeline
Model: add sx_content column to Post. Writer: accept sx_content in
create_post, create_page, update_post. Routes: read sx_content from form
data in new post, new page, and edit routes. Read pipeline: ghost_db
includes sx_content in public dict, detail/home views prefer sx_content
over html when available, PostDTO includes sx_content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:22:30 +00:00
341fc4cf28 Add SX block editor with Koenig-quality controls and lexical-to-sx converter
Pure s-expression block editor replacing React/Koenig: single hover + button,
slash commands, full card edit modes (image/gallery/video/audio/file/embed/
bookmark/callout/toggle/button/HTML/code), inline format toolbar, keyboard
shortcuts, drag-drop uploads, oEmbed/bookmark metadata fetching.

Includes lexical_to_sx converter for backfilling existing posts, KG card
components matching Ghost's card CSS, migration for sx_content column, and
31 converter tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:17:49 +00:00
19 changed files with 3593 additions and 9 deletions

View File

@@ -5,7 +5,6 @@ Cooperative web platform: federated content, commerce, events, and media process
## Deployment
- **Do NOT push** until explicitly told to. Pushes reload code to dev automatically.
- **Cache busting:** After editing `sx.js`, bump the `?v=` query string in `shared/sx/helpers.py` (search for `sx.js?v=`).
## Project Structure

View File

@@ -0,0 +1,20 @@
"""Add sx_content column to posts table.
Revision ID: blog_0005
Revises: blog_0004
"""
from alembic import op
import sqlalchemy as sa
revision = "blog_0005"
down_revision = "blog_0004"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("posts", sa.Column("sx_content", sa.Text(), nullable=True))
def downgrade():
op.drop_column("posts", "sx_content")

View File

@@ -0,0 +1,441 @@
"""
Lexical JSON → s-expression converter.
Mirrors lexical_renderer.py's registry/dispatch pattern but produces sx source
instead of HTML. Used for backfilling existing posts and on-the-fly conversion
when editing pre-migration posts in the SX editor.
Public API
----------
lexical_to_sx(doc) Lexical JSON (dict or string) → sx source string
"""
from __future__ import annotations
import json
from typing import Callable
import mistune
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
_CONVERTERS: dict[str, Callable[[dict], str]] = {}
def _converter(node_type: str):
"""Decorator — register a function as the converter for *node_type*."""
def decorator(fn: Callable[[dict], str]) -> Callable[[dict], str]:
_CONVERTERS[node_type] = fn
return fn
return decorator
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def lexical_to_sx(doc: dict | str) -> str:
"""Convert a Lexical JSON document to an sx source string."""
if isinstance(doc, str):
doc = json.loads(doc)
root = doc.get("root", doc)
children = root.get("children", [])
parts = [_convert_node(c) for c in children]
parts = [p for p in parts if p]
if not parts:
return '(<> (p ""))'
if len(parts) == 1:
return parts[0]
return "(<>\n " + "\n ".join(parts) + ")"
# ---------------------------------------------------------------------------
# Core dispatch
# ---------------------------------------------------------------------------
def _convert_node(node: dict) -> str:
node_type = node.get("type", "")
converter = _CONVERTERS.get(node_type)
if converter:
return converter(node)
return ""
def _convert_children(children: list[dict]) -> str:
"""Convert children to inline sx content (for text nodes)."""
parts = [_convert_node(c) for c in children]
return " ".join(p for p in parts if p)
def _esc(s: str) -> str:
"""Escape a string for sx double-quoted literals."""
return s.replace("\\", "\\\\").replace('"', '\\"')
# ---------------------------------------------------------------------------
# Text format bitmask
# ---------------------------------------------------------------------------
_FORMAT_BOLD = 1
_FORMAT_ITALIC = 2
_FORMAT_STRIKETHROUGH = 4
_FORMAT_UNDERLINE = 8
_FORMAT_CODE = 16
_FORMAT_SUBSCRIPT = 32
_FORMAT_SUPERSCRIPT = 64
_FORMAT_WRAPPERS: list[tuple[int, str]] = [
(_FORMAT_BOLD, "strong"),
(_FORMAT_ITALIC, "em"),
(_FORMAT_STRIKETHROUGH, "s"),
(_FORMAT_UNDERLINE, "u"),
(_FORMAT_CODE, "code"),
(_FORMAT_SUBSCRIPT, "sub"),
(_FORMAT_SUPERSCRIPT, "sup"),
]
def _wrap_format(text_sx: str, fmt: int) -> str:
for mask, tag in _FORMAT_WRAPPERS:
if fmt & mask:
text_sx = f"({tag} {text_sx})"
return text_sx
# ---------------------------------------------------------------------------
# Tier 1 — text nodes
# ---------------------------------------------------------------------------
@_converter("text")
def _text(node: dict) -> str:
text = node.get("text", "")
if not text:
return ""
sx = f'"{_esc(text)}"'
fmt = node.get("format", 0)
if isinstance(fmt, int) and fmt:
sx = _wrap_format(sx, fmt)
return sx
@_converter("linebreak")
def _linebreak(_node: dict) -> str:
return '"\\n"'
@_converter("tab")
def _tab(_node: dict) -> str:
return '"\\t"'
@_converter("paragraph")
def _paragraph(node: dict) -> str:
inner = _convert_children(node.get("children", []))
if not inner:
inner = '""'
return f"(p {inner})"
@_converter("extended-text")
def _extended_text(node: dict) -> str:
# extended-text can be block-level (with children) or inline (with text).
# When it has a "text" field, treat it as a plain text node.
if "text" in node:
return _text(node)
return _paragraph(node)
@_converter("heading")
def _heading(node: dict) -> str:
tag = node.get("tag", "h2")
inner = _convert_children(node.get("children", []))
if not inner:
inner = '""'
return f"({tag} {inner})"
@_converter("extended-heading")
def _extended_heading(node: dict) -> str:
if "text" in node:
return _text(node)
return _heading(node)
@_converter("quote")
def _quote(node: dict) -> str:
inner = _convert_children(node.get("children", []))
return f"(blockquote {inner})" if inner else '(blockquote "")'
@_converter("extended-quote")
def _extended_quote(node: dict) -> str:
if "text" in node:
return _text(node)
return _quote(node)
@_converter("link")
def _link(node: dict) -> str:
href = node.get("url", "")
inner = _convert_children(node.get("children", []))
if not inner:
inner = f'"{_esc(href)}"'
return f'(a :href "{_esc(href)}" {inner})'
@_converter("autolink")
def _autolink(node: dict) -> str:
return _link(node)
@_converter("at-link")
def _at_link(node: dict) -> str:
return _link(node)
@_converter("list")
def _list(node: dict) -> str:
tag = "ol" if node.get("listType") == "number" else "ul"
inner = _convert_children(node.get("children", []))
return f"({tag} {inner})" if inner else f"({tag})"
@_converter("listitem")
def _listitem(node: dict) -> str:
inner = _convert_children(node.get("children", []))
return f"(li {inner})" if inner else '(li "")'
@_converter("horizontalrule")
def _horizontalrule(_node: dict) -> str:
return "(hr)"
@_converter("code")
def _code(node: dict) -> str:
inner = _convert_children(node.get("children", []))
return f"(code {inner})" if inner else ""
@_converter("codeblock")
def _codeblock(node: dict) -> str:
lang = node.get("language", "")
code = node.get("code", "")
lang_attr = f' :class "language-{_esc(lang)}"' if lang else ""
return f'(pre (code{lang_attr} "{_esc(code)}"))'
@_converter("code-highlight")
def _code_highlight(node: dict) -> str:
text = node.get("text", "")
return f'"{_esc(text)}"' if text else ""
# ---------------------------------------------------------------------------
# Tier 2 — common cards
# ---------------------------------------------------------------------------
@_converter("image")
def _image(node: dict) -> str:
src = node.get("src", "")
alt = node.get("alt", "")
caption = node.get("caption", "")
width = node.get("cardWidth", "") or node.get("width", "")
href = node.get("href", "")
parts = [f':src "{_esc(src)}"']
if alt:
parts.append(f':alt "{_esc(alt)}"')
if caption:
parts.append(f':caption "{_esc(caption)}"')
if width:
parts.append(f':width "{_esc(width)}"')
if href:
parts.append(f':href "{_esc(href)}"')
return "(~kg-image " + " ".join(parts) + ")"
@_converter("gallery")
def _gallery(node: dict) -> str:
images = node.get("images", [])
if not images:
return ""
# Group images into rows of 3 (matching lexical_renderer.py)
rows = []
for i in range(0, len(images), 3):
row_imgs = images[i:i + 3]
row_items = []
for img in row_imgs:
item_parts = [f'"src" "{_esc(img.get("src", ""))}"']
if img.get("alt"):
item_parts.append(f'"alt" "{_esc(img["alt"])}"')
if img.get("caption"):
item_parts.append(f'"caption" "{_esc(img["caption"])}"')
row_items.append("(dict " + " ".join(item_parts) + ")")
rows.append("(list " + " ".join(row_items) + ")")
images_sx = "(list " + " ".join(rows) + ")"
caption = node.get("caption", "")
caption_attr = f' :caption "{_esc(caption)}"' if caption else ""
return f"(~kg-gallery :images {images_sx}{caption_attr})"
@_converter("html")
def _html_card(node: dict) -> str:
raw = node.get("html", "")
return f'(~kg-html :html "{_esc(raw)}")'
@_converter("embed")
def _embed(node: dict) -> str:
embed_html = node.get("html", "")
caption = node.get("caption", "")
parts = [f':html "{_esc(embed_html)}"']
if caption:
parts.append(f':caption "{_esc(caption)}"')
return "(~kg-embed " + " ".join(parts) + ")"
@_converter("bookmark")
def _bookmark(node: dict) -> str:
url = node.get("url", "")
meta = node.get("metadata", {})
parts = [f':url "{_esc(url)}"']
title = meta.get("title", "") or node.get("title", "")
if title:
parts.append(f':title "{_esc(title)}"')
desc = meta.get("description", "") or node.get("description", "")
if desc:
parts.append(f':description "{_esc(desc)}"')
icon = meta.get("icon", "") or node.get("icon", "")
if icon:
parts.append(f':icon "{_esc(icon)}"')
author = meta.get("author", "") or node.get("author", "")
if author:
parts.append(f':author "{_esc(author)}"')
publisher = meta.get("publisher", "") or node.get("publisher", "")
if publisher:
parts.append(f':publisher "{_esc(publisher)}"')
thumbnail = meta.get("thumbnail", "") or node.get("thumbnail", "")
if thumbnail:
parts.append(f':thumbnail "{_esc(thumbnail)}"')
caption = node.get("caption", "")
if caption:
parts.append(f':caption "{_esc(caption)}"')
return "(~kg-bookmark " + " ".join(parts) + ")"
@_converter("callout")
def _callout(node: dict) -> str:
color = node.get("backgroundColor", "grey")
emoji = node.get("calloutEmoji", "")
inner = _convert_children(node.get("children", []))
parts = [f':color "{_esc(color)}"']
if emoji:
parts.append(f':emoji "{_esc(emoji)}"')
if inner:
parts.append(f':content {inner}')
return "(~kg-callout " + " ".join(parts) + ")"
@_converter("button")
def _button(node: dict) -> str:
text = node.get("buttonText", "")
url = node.get("buttonUrl", "")
alignment = node.get("alignment", "center")
return f'(~kg-button :url "{_esc(url)}" :text "{_esc(text)}" :alignment "{_esc(alignment)}")'
@_converter("toggle")
def _toggle(node: dict) -> str:
heading = node.get("heading", "")
inner = _convert_children(node.get("children", []))
content_attr = f" :content {inner}" if inner else ""
return f'(~kg-toggle :heading "{_esc(heading)}"{content_attr})'
@_converter("audio")
def _audio(node: dict) -> str:
src = node.get("src", "")
title = node.get("title", "")
duration = node.get("duration", 0)
thumbnail = node.get("thumbnailSrc", "")
duration_min = int(duration) // 60
duration_sec = int(duration) % 60
duration_str = f"{duration_min}:{duration_sec:02d}"
parts = [f':src "{_esc(src)}"']
if title:
parts.append(f':title "{_esc(title)}"')
parts.append(f':duration "{duration_str}"')
if thumbnail:
parts.append(f':thumbnail "{_esc(thumbnail)}"')
return "(~kg-audio " + " ".join(parts) + ")"
@_converter("video")
def _video(node: dict) -> str:
src = node.get("src", "")
caption = node.get("caption", "")
width = node.get("cardWidth", "")
thumbnail = node.get("thumbnailSrc", "") or node.get("customThumbnailSrc", "")
loop = node.get("loop", False)
parts = [f':src "{_esc(src)}"']
if caption:
parts.append(f':caption "{_esc(caption)}"')
if width:
parts.append(f':width "{_esc(width)}"')
if thumbnail:
parts.append(f':thumbnail "{_esc(thumbnail)}"')
if loop:
parts.append(":loop true")
return "(~kg-video " + " ".join(parts) + ")"
@_converter("file")
def _file(node: dict) -> str:
src = node.get("src", "")
filename = node.get("fileName", "")
title = node.get("title", "") or filename
file_size = node.get("fileSize", 0)
caption = node.get("caption", "")
# Format size
size_str = ""
if file_size:
kb = file_size / 1024
if kb < 1024:
size_str = f"{kb:.0f} KB"
else:
size_str = f"{kb / 1024:.1f} MB"
parts = [f':src "{_esc(src)}"']
if filename:
parts.append(f':filename "{_esc(filename)}"')
if title:
parts.append(f':title "{_esc(title)}"')
if size_str:
parts.append(f':filesize "{size_str}"')
if caption:
parts.append(f':caption "{_esc(caption)}"')
return "(~kg-file " + " ".join(parts) + ")"
@_converter("paywall")
def _paywall(_node: dict) -> str:
return "(~kg-paywall)"
@_converter("markdown")
def _markdown(node: dict) -> str:
md_text = node.get("markdown", "")
rendered = mistune.html(md_text)
return f'(~kg-html :html "{_esc(rendered)}")'

View File

@@ -63,6 +63,7 @@ def _post_to_public(p: Post) -> Dict[str, Any]:
"slug": p.slug,
"title": p.title,
"html": p.html,
"sx_content": p.sx_content,
"is_page": p.is_page,
"excerpt": p.custom_excerpt or p.excerpt,
"custom_excerpt": p.custom_excerpt,

View File

@@ -265,6 +265,7 @@ def register(url_prefix, title):
return await make_response(html, 400)
# Create directly in db_blog
sx_content_raw = form.get("sx_content", "").strip() or None
post = await writer_create(
g.s,
title=title,
@@ -274,6 +275,7 @@ def register(url_prefix, title):
feature_image=feature_image or None,
custom_excerpt=custom_excerpt or None,
feature_image_caption=feature_image_caption or None,
sx_content=sx_content_raw,
)
await g.s.flush()
@@ -337,6 +339,7 @@ def register(url_prefix, title):
return await make_response(html, 400)
# Create directly in db_blog
sx_content_raw = form.get("sx_content", "").strip() or None
page = await writer_create_page(
g.s,
title=title,
@@ -346,6 +349,7 @@ def register(url_prefix, title):
feature_image=feature_image or None,
custom_excerpt=custom_excerpt or None,
feature_image_caption=feature_image_caption or None,
sx_content=sx_content_raw,
)
await g.s.flush()

View File

@@ -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"] = "<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>/")
@require_admin
async def calendar_view(slug: str, calendar_id: int):
@@ -562,6 +619,7 @@ def register():
elif status and status != current_status:
effective_status = status
sx_content_raw = form.get("sx_content", "").strip() or None
try:
post = await writer_update(
g.s,
@@ -573,6 +631,7 @@ def register():
custom_excerpt=custom_excerpt or None,
feature_image_caption=feature_image_caption or None,
status=effective_status,
sx_content=sx_content_raw,
)
except OptimisticLockError:
return redirect(

View File

@@ -60,6 +60,7 @@ class Post(Base):
plaintext: Mapped[Optional[str]] = mapped_column(Text())
mobiledoc: Mapped[Optional[str]] = mapped_column(Text())
lexical: Mapped[Optional[str]] = mapped_column(Text())
sx_content: Mapped[Optional[str]] = mapped_column(Text())
feature_image: Mapped[Optional[str]] = mapped_column(Text())
feature_image_alt: Mapped[Optional[str]] = mapped_column(Text())

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""
Backfill sx_content from lexical JSON for all posts that have lexical but no sx_content.
Usage:
python -m blog.scripts.backfill_sx_content [--dry-run]
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
async def backfill(dry_run: bool = False) -> int:
from shared.db.session import get_session
from models.ghost_content import Post
from bp.blog.ghost.lexical_to_sx import lexical_to_sx
converted = 0
errors = 0
async with get_session() as sess:
stmt = select(Post).where(
and_(
Post.lexical.isnot(None),
Post.lexical != "",
(Post.sx_content.is_(None)) | (Post.sx_content == ""),
)
)
result = await sess.execute(stmt)
posts = result.scalars().all()
print(f"Found {len(posts)} posts to convert")
for post in posts:
try:
sx = lexical_to_sx(post.lexical)
if dry_run:
print(f" [DRY RUN] {post.slug}: {len(sx)} chars")
else:
post.sx_content = sx
print(f" Converted: {post.slug} ({len(sx)} chars)")
converted += 1
except Exception as e:
print(f" ERROR: {post.slug}: {e}", file=sys.stderr)
errors += 1
if not dry_run:
await sess.commit()
print(f"\nDone: {converted} converted, {errors} errors")
return converted
def main():
parser = argparse.ArgumentParser(description="Backfill sx_content from lexical JSON")
parser.add_argument("--dry-run", action="store_true", help="Don't write to database")
args = parser.parse_args()
asyncio.run(backfill(dry_run=args.dry_run))
if __name__ == "__main__":
main()

View File

@@ -19,6 +19,7 @@ def _post_to_dto(post: Post) -> PostDTO:
is_page=post.is_page,
feature_image=post.feature_image,
html=post.html,
sx_content=post.sx_content,
excerpt=post.excerpt,
custom_excerpt=post.custom_excerpt,
published_at=post.published_at,

View File

@@ -207,6 +207,7 @@ async def create_post(
feature_image_caption: str | None = None,
tag_names: list[str] | None = None,
is_page: bool = False,
sx_content: str | None = None,
) -> Post:
"""Create a new post or page directly in db_blog."""
html, plaintext, reading_time = _render_and_extract(lexical_json)
@@ -217,6 +218,7 @@ async def create_post(
title=title or "Untitled",
slug=slug,
lexical=lexical_json if isinstance(lexical_json, str) else json.dumps(lexical_json),
sx_content=sx_content,
html=html,
plaintext=plaintext,
reading_time=reading_time,
@@ -281,6 +283,7 @@ async def create_page(
custom_excerpt: str | None = None,
feature_image_caption: str | None = None,
tag_names: list[str] | None = None,
sx_content: str | None = None,
) -> Post:
"""Create a new page. Convenience wrapper around create_post."""
return await create_post(
@@ -294,6 +297,7 @@ async def create_page(
feature_image_caption=feature_image_caption,
tag_names=tag_names,
is_page=True,
sx_content=sx_content,
)
@@ -308,6 +312,7 @@ async def update_post(
custom_excerpt: str | None = ..., # type: ignore[assignment]
feature_image_caption: str | None = ..., # type: ignore[assignment]
status: str | None = None,
sx_content: str | None = ..., # type: ignore[assignment]
) -> Post:
"""Update post content. Optimistic lock via expected_updated_at.
@@ -342,6 +347,9 @@ async def update_post(
if title is not None:
post.title = title
if sx_content is not _SENTINEL:
post.sx_content = sx_content
if feature_image is not _SENTINEL:
post.feature_image = feature_image
if custom_excerpt is not _SENTINEL:

View File

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

View File

@@ -25,13 +25,15 @@
excerpt
(div :class "hidden md:block" at-bar)))
(defcomp ~blog-detail-main (&key draft chrome feature-image html-content)
(defcomp ~blog-detail-main (&key draft chrome feature-image html-content sx-content)
(<> (article :class "relative"
draft
chrome
(when feature-image (div :class "mb-3 flex justify-center"
(img :src feature-image :alt "" :class "rounded-lg w-full md:w-3/4 object-cover")))
(when html-content (div :class "blog-content p-2" (~rich-text :html html-content))))
(if sx-content
(div :class "blog-content p-2" sx-content)
(when html-content (div :class "blog-content p-2" (~rich-text :html html-content)))))
(div :class "pb-8")))
(defcomp ~blog-meta (&key robots page-title desc canonical og-type og-title image twitter-card twitter-title)
@@ -50,5 +52,8 @@
(meta :name "twitter:description" :content desc)
(when image (meta :name "twitter:image" :content image))))
(defcomp ~blog-home-main (&key html-content)
(article :class "relative" (div :class "blog-content p-2" (~rich-text :html html-content))))
(defcomp ~blog-home-main (&key html-content sx-content)
(article :class "relative"
(if sx-content
(div :class "blog-content p-2" sx-content)
(div :class "blog-content p-2" (~rich-text :html html-content)))))

146
blog/sx/kg_cards.sx Normal file
View File

@@ -0,0 +1,146 @@
;; KG card components — Ghost/Koenig-compatible card rendering
;; Produces same HTML structure as lexical_renderer.py so cards.css works unchanged.
;; Used by both display pipeline and block editor.
;; @css kg-card kg-image-card kg-width-wide kg-width-full kg-gallery-card kg-gallery-container kg-gallery-row kg-gallery-image kg-embed-card kg-bookmark-card kg-bookmark-container kg-bookmark-content kg-bookmark-title kg-bookmark-description kg-bookmark-metadata kg-bookmark-icon kg-bookmark-author kg-bookmark-publisher kg-bookmark-thumbnail kg-callout-card kg-callout-emoji kg-callout-text kg-button-card kg-btn kg-btn-accent kg-toggle-card kg-toggle-heading kg-toggle-heading-text kg-toggle-card-icon kg-toggle-content kg-audio-card kg-audio-thumbnail kg-audio-player-container kg-audio-title kg-audio-player kg-audio-play-icon kg-audio-current-time kg-audio-time kg-audio-seek-slider kg-audio-playback-rate kg-audio-unmute-icon kg-audio-volume-slider kg-video-card kg-video-container kg-file-card kg-file-card-container kg-file-card-contents kg-file-card-title kg-file-card-filesize kg-file-card-icon kg-file-card-caption kg-align-center kg-align-left kg-callout-card-grey kg-callout-card-white kg-callout-card-blue kg-callout-card-green kg-callout-card-yellow kg-callout-card-red kg-callout-card-pink kg-callout-card-purple kg-callout-card-accent placeholder
;; ---------------------------------------------------------------------------
;; Image card
;; ---------------------------------------------------------------------------
(defcomp ~kg-image (&key src alt caption width href)
(figure :class (str "kg-card kg-image-card"
(if (= width "wide") " kg-width-wide"
(if (= width "full") " kg-width-full" "")))
(if href
(a :href href (img :src src :alt (or alt "") :loading "lazy"))
(img :src src :alt (or alt "") :loading "lazy"))
(when caption (figcaption caption))))
;; ---------------------------------------------------------------------------
;; Gallery card
;; ---------------------------------------------------------------------------
(defcomp ~kg-gallery (&key images caption)
(figure :class "kg-card kg-gallery-card kg-width-wide"
(div :class "kg-gallery-container"
(map (lambda (row)
(div :class "kg-gallery-row"
(map (lambda (img-data)
(figure :class "kg-gallery-image"
(img :src (get img-data "src") :alt (or (get img-data "alt") "") :loading "lazy")
(when (get img-data "caption") (figcaption (get img-data "caption")))))
row)))
images))
(when caption (figcaption caption))))
;; ---------------------------------------------------------------------------
;; HTML card (raw HTML injection)
;; ---------------------------------------------------------------------------
(defcomp ~kg-html (&key html)
(~rich-text :html html))
;; ---------------------------------------------------------------------------
;; Embed card
;; ---------------------------------------------------------------------------
(defcomp ~kg-embed (&key html caption)
(figure :class "kg-card kg-embed-card"
(~rich-text :html html)
(when caption (figcaption caption))))
;; ---------------------------------------------------------------------------
;; Bookmark card
;; ---------------------------------------------------------------------------
(defcomp ~kg-bookmark (&key url title description icon author publisher thumbnail caption)
(figure :class "kg-card kg-bookmark-card"
(a :class "kg-bookmark-container" :href url
(div :class "kg-bookmark-content"
(div :class "kg-bookmark-title" (or title ""))
(div :class "kg-bookmark-description" (or description ""))
(when (or icon author publisher)
(span :class "kg-bookmark-metadata"
(when icon (img :class "kg-bookmark-icon" :src icon :alt ""))
(when author (span :class "kg-bookmark-author" author))
(when publisher (span :class "kg-bookmark-publisher" publisher)))))
(when thumbnail
(div :class "kg-bookmark-thumbnail"
(img :src thumbnail :alt ""))))
(when caption (figcaption caption))))
;; ---------------------------------------------------------------------------
;; Callout card
;; ---------------------------------------------------------------------------
(defcomp ~kg-callout (&key color emoji content)
(div :class (str "kg-card kg-callout-card kg-callout-card-" (or color "grey"))
(when emoji (div :class "kg-callout-emoji" emoji))
(div :class "kg-callout-text" (or content ""))))
;; ---------------------------------------------------------------------------
;; Button card
;; ---------------------------------------------------------------------------
(defcomp ~kg-button (&key url text alignment)
(div :class (str "kg-card kg-button-card kg-align-" (or alignment "center"))
(a :href url :class "kg-btn kg-btn-accent" (or text ""))))
;; ---------------------------------------------------------------------------
;; Toggle card (accordion)
;; ---------------------------------------------------------------------------
(defcomp ~kg-toggle (&key heading content)
(div :class "kg-card kg-toggle-card" :data-kg-toggle-state "close"
(div :class "kg-toggle-heading"
(h4 :class "kg-toggle-heading-text" (or heading ""))
(button :class "kg-toggle-card-icon"
(~rich-text :html "<svg viewBox=\"0 0 14 14\"><path d=\"M7 0a.5.5 0 0 1 .5.5v6h6a.5.5 0 1 1 0 1h-6v6a.5.5 0 1 1-1 0v-6h-6a.5.5 0 0 1 0-1h6v-6A.5.5 0 0 1 7 0Z\" fill=\"currentColor\"/></svg>")))
(div :class "kg-toggle-content" (or content ""))))
;; ---------------------------------------------------------------------------
;; Audio card
;; ---------------------------------------------------------------------------
(defcomp ~kg-audio (&key src title duration thumbnail)
(div :class "kg-card kg-audio-card"
(if thumbnail
(img :src thumbnail :alt "audio-thumbnail" :class "kg-audio-thumbnail")
(div :class "kg-audio-thumbnail placeholder"
(~rich-text :html "<svg viewBox=\"0 0 24 24\"><path d=\"M2 12C2 6.48 6.48 2 12 2s10 4.48 10 10-4.48 10-10 10S2 17.52 2 12zm7.5 5.25L16 12 9.5 6.75v10.5z\" fill=\"currentColor\"/></svg>")))
(div :class "kg-audio-player-container"
(div :class "kg-audio-title" (or title ""))
(div :class "kg-audio-player"
(button :class "kg-audio-play-icon"
(~rich-text :html "<svg viewBox=\"0 0 24 24\"><path d=\"M8 5v14l11-7z\" fill=\"currentColor\"/></svg>"))
(div :class "kg-audio-current-time" "0:00")
(div :class "kg-audio-time" (str "/ " (or duration "0:00")))
(input :type "range" :class "kg-audio-seek-slider" :max "100" :value "0")
(button :class "kg-audio-playback-rate" "1×")
(button :class "kg-audio-unmute-icon"
(~rich-text :html "<svg viewBox=\"0 0 24 24\"><path d=\"M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z\" fill=\"currentColor\"/></svg>"))
(input :type "range" :class "kg-audio-volume-slider" :max "100" :value "100")))
(audio :src src :preload "metadata")))
;; ---------------------------------------------------------------------------
;; Video card
;; ---------------------------------------------------------------------------
(defcomp ~kg-video (&key src caption width thumbnail loop)
(figure :class (str "kg-card kg-video-card"
(if (= width "wide") " kg-width-wide"
(if (= width "full") " kg-width-full" "")))
(div :class "kg-video-container"
(video :src src :controls true :preload "metadata"
:poster (or thumbnail nil) :loop (or loop nil)))
(when caption (figcaption caption))))
;; ---------------------------------------------------------------------------
;; File card
;; ---------------------------------------------------------------------------
(defcomp ~kg-file (&key src filename title filesize caption)
(div :class "kg-card kg-file-card"
(a :class "kg-file-card-container" :href src :download (or filename "")
(div :class "kg-file-card-contents"
(div :class "kg-file-card-title" (or title filename ""))
(when filesize (div :class "kg-file-card-filesize" filesize)))
(div :class "kg-file-card-icon"
(~rich-text :html "<svg viewBox=\"0 0 24 24\"><path d=\"M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z\" fill=\"currentColor\"/></svg>")))
(when caption (div :class "kg-file-card-caption" caption))))
;; ---------------------------------------------------------------------------
;; Paywall marker
;; ---------------------------------------------------------------------------
(defcomp ~kg-paywall ()
(~rich-text :html "<!--members-only-->"))

View File

@@ -716,11 +716,13 @@ def _post_main_panel_sx(ctx: dict) -> str:
fi = post.get("feature_image")
html_content = post.get("html", "")
sx_content = post.get("sx_content", "")
return sx_call("blog-detail-main",
draft=SxExpr(draft_sx) if draft_sx else None,
chrome=SxExpr(chrome_sx) if chrome_sx else None,
feature_image=fi, html_content=html_content,
sx_content=SxExpr(sx_content) if sx_content else None,
)
@@ -770,10 +772,13 @@ def _post_meta_sx(ctx: dict) -> str:
# ---------------------------------------------------------------------------
def _home_main_panel_sx(ctx: dict) -> str:
"""Home page content — renders the Ghost page HTML."""
"""Home page content — renders the Ghost page HTML or sx_content."""
post = ctx.get("post") or {}
html = post.get("html", "")
return sx_call("blog-home-main", html_content=html)
sx_content = post.get("sx_content", "")
return sx_call("blog-home-main",
html_content=html,
sx_content=SxExpr(sx_content) if sx_content else None)
# ---------------------------------------------------------------------------
@@ -1372,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:

View File

@@ -0,0 +1,278 @@
"""Unit tests for the Lexical JSON → sx converter."""
from __future__ import annotations
import sys
import os
import pytest
# The lexical_to_sx module is standalone (only depends on json).
# Import it directly to avoid pulling in the full blog app.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "bp", "blog", "ghost"))
from lexical_to_sx import lexical_to_sx
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _doc(*children):
"""Wrap children in a minimal Lexical document."""
return {"root": {"children": list(children)}}
def _text(s, fmt=0):
return {"type": "text", "text": s, "format": fmt}
def _paragraph(*children):
return {"type": "paragraph", "children": list(children)}
def _heading(tag, *children):
return {"type": "heading", "tag": tag, "children": list(children)}
def _link(url, *children):
return {"type": "link", "url": url, "children": list(children)}
# ---------------------------------------------------------------------------
# Basic text
# ---------------------------------------------------------------------------
class TestBasicText:
def test_empty_doc(self):
result = lexical_to_sx(_doc())
assert result == '(<> (p ""))'
def test_single_paragraph(self):
result = lexical_to_sx(_doc(_paragraph(_text("Hello"))))
assert result == '(p "Hello")'
def test_two_paragraphs(self):
result = lexical_to_sx(_doc(
_paragraph(_text("Hello")),
_paragraph(_text("World")),
))
assert "(p " in result
assert '"Hello"' in result
assert '"World"' in result
def test_heading(self):
result = lexical_to_sx(_doc(_heading("h2", _text("Title"))))
assert result == '(h2 "Title")'
def test_h3(self):
result = lexical_to_sx(_doc(_heading("h3", _text("Sub"))))
assert result == '(h3 "Sub")'
# ---------------------------------------------------------------------------
# Formatting
# ---------------------------------------------------------------------------
class TestFormatting:
def test_bold(self):
result = lexical_to_sx(_doc(_paragraph(_text("hi", 1))))
assert "(strong " in result
def test_italic(self):
result = lexical_to_sx(_doc(_paragraph(_text("hi", 2))))
assert "(em " in result
def test_strikethrough(self):
result = lexical_to_sx(_doc(_paragraph(_text("hi", 4))))
assert "(s " in result
def test_bold_italic(self):
result = lexical_to_sx(_doc(_paragraph(_text("hi", 3))))
assert "(strong " in result
assert "(em " in result
def test_code(self):
result = lexical_to_sx(_doc(_paragraph(_text("x", 16))))
assert "(code " in result
# ---------------------------------------------------------------------------
# Links
# ---------------------------------------------------------------------------
class TestLinks:
def test_link(self):
result = lexical_to_sx(_doc(
_paragraph(_link("https://example.com", _text("click")))
))
assert '(a :href "https://example.com"' in result
assert '"click"' in result
# ---------------------------------------------------------------------------
# Lists
# ---------------------------------------------------------------------------
class TestLists:
def test_unordered_list(self):
result = lexical_to_sx(_doc({
"type": "list", "listType": "bullet",
"children": [
{"type": "listitem", "children": [_text("one")]},
{"type": "listitem", "children": [_text("two")]},
]
}))
assert "(ul " in result
assert "(li " in result
assert '"one"' in result
def test_ordered_list(self):
result = lexical_to_sx(_doc({
"type": "list", "listType": "number",
"children": [
{"type": "listitem", "children": [_text("first")]},
]
}))
assert "(ol " in result
# ---------------------------------------------------------------------------
# Block elements
# ---------------------------------------------------------------------------
class TestBlocks:
def test_hr(self):
result = lexical_to_sx(_doc({"type": "horizontalrule"}))
assert result == "(hr)"
def test_quote(self):
result = lexical_to_sx(_doc({
"type": "quote", "children": [_text("wisdom")]
}))
assert '(blockquote "wisdom")' == result
def test_codeblock(self):
result = lexical_to_sx(_doc({
"type": "codeblock", "code": "print('hi')", "language": "python"
}))
assert '(pre (code :class "language-python"' in result
assert "print" in result
# ---------------------------------------------------------------------------
# Cards
# ---------------------------------------------------------------------------
class TestCards:
def test_image(self):
result = lexical_to_sx(_doc({
"type": "image", "src": "photo.jpg", "alt": "test"
}))
assert '(~kg-image :src "photo.jpg" :alt "test")' == result
def test_image_wide_with_caption(self):
result = lexical_to_sx(_doc({
"type": "image", "src": "p.jpg", "alt": "",
"cardWidth": "wide", "caption": "Fig 1"
}))
assert ':width "wide"' in result
assert ':caption "Fig 1"' in result
def test_bookmark(self):
result = lexical_to_sx(_doc({
"type": "bookmark", "url": "https://example.com",
"metadata": {"title": "Example", "description": "A site"}
}))
assert "(~kg-bookmark " in result
assert ':url "https://example.com"' in result
assert ':title "Example"' in result
def test_callout(self):
result = lexical_to_sx(_doc({
"type": "callout", "backgroundColor": "blue",
"calloutEmoji": "💡",
"children": [_text("Note")]
}))
assert "(~kg-callout " in result
assert ':color "blue"' in result
def test_button(self):
result = lexical_to_sx(_doc({
"type": "button", "buttonText": "Click",
"buttonUrl": "https://example.com"
}))
assert "(~kg-button " in result
assert ':text "Click"' in result
def test_toggle(self):
result = lexical_to_sx(_doc({
"type": "toggle", "heading": "FAQ",
"children": [_text("Answer")]
}))
assert "(~kg-toggle " in result
assert ':heading "FAQ"' in result
def test_html(self):
result = lexical_to_sx(_doc({
"type": "html", "html": "<div>custom</div>"
}))
assert "(~kg-html " in result
def test_embed(self):
result = lexical_to_sx(_doc({
"type": "embed", "html": "<iframe></iframe>",
"caption": "Video"
}))
assert "(~kg-embed " in result
assert ':caption "Video"' in result
def test_video(self):
result = lexical_to_sx(_doc({
"type": "video", "src": "v.mp4", "cardWidth": "wide"
}))
assert "(~kg-video " in result
assert ':width "wide"' in result
def test_audio(self):
result = lexical_to_sx(_doc({
"type": "audio", "src": "s.mp3", "title": "Song", "duration": 195
}))
assert "(~kg-audio " in result
assert ':duration "3:15"' in result
def test_file(self):
result = lexical_to_sx(_doc({
"type": "file", "src": "f.pdf", "fileName": "doc.pdf",
"fileSize": 2100000
}))
assert "(~kg-file " in result
assert ':filename "doc.pdf"' in result
assert "MB" in result
def test_paywall(self):
result = lexical_to_sx(_doc({"type": "paywall"}))
assert result == "(~kg-paywall)"
# ---------------------------------------------------------------------------
# Escaping
# ---------------------------------------------------------------------------
class TestEscaping:
def test_quotes_in_text(self):
result = lexical_to_sx(_doc(_paragraph(_text('He said "hello"'))))
assert '\\"hello\\"' in result
def test_backslash_in_text(self):
result = lexical_to_sx(_doc(_paragraph(_text("a\\b"))))
assert "a\\\\b" in result
# ---------------------------------------------------------------------------
# JSON string input
# ---------------------------------------------------------------------------
class TestJsonString:
def test_string_input(self):
import json
doc = _doc(_paragraph(_text("test")))
result = lexical_to_sx(json.dumps(doc))
assert '(p "test")' == result

View File

@@ -87,6 +87,7 @@ class PostDTO:
is_page: bool = False
feature_image: str | None = None
html: str | None = None
sx_content: str | None = None
excerpt: str | None = None
custom_excerpt: str | None = None
published_at: datetime | None = None

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,8 @@ page elements (headers, search, etc.) from template context.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
from typing import Any
from markupsafe import escape
@@ -203,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"),
]
@@ -482,8 +485,8 @@ details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.
<body class="bg-stone-50 text-stone-900">
<script type="text/sx" data-components>{component_defs}</script>
<script type="text/sx" data-mount="body">{page_sx}</script>
<script src="{asset_url}/scripts/sx.js?v=20260301d"></script>
<script src="{asset_url}/scripts/body.js"></script>
<script src="{asset_url}/scripts/sx.js?v={sx_js_hash}"></script>
<script src="{asset_url}/scripts/body.js?v={body_js_hash}"></script>
</body>
</html>"""
@@ -544,9 +547,25 @@ def sx_page(ctx: dict, page_sx: str, *,
page_sx=page_sx,
sx_css=sx_css,
sx_css_classes=sx_css_classes,
sx_js_hash=_script_hash("sx.js"),
body_js_hash=_script_hash("body.js"),
)
_SCRIPT_HASH_CACHE: dict[str, str] = {}
def _script_hash(filename: str) -> str:
"""Compute MD5 hash of a static script file, cached for process lifetime."""
if filename not in _SCRIPT_HASH_CACHE:
try:
data = (Path("static") / "scripts" / filename).read_bytes()
_SCRIPT_HASH_CACHE[filename] = hashlib.md5(data).hexdigest()[:8]
except Exception:
_SCRIPT_HASH_CACHE[filename] = "dev"
return _SCRIPT_HASH_CACHE[filename]
def _get_csrf_token() -> str:
"""Get the CSRF token from the current request context."""
try:

149
shared/sx/prettify.py Normal file
View 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", "]")]