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>
This commit is contained in:
430
blog/bp/blog/ghost/lexical_to_sx.py
Normal file
430
blog/bp/blog/ghost/lexical_to_sx.py
Normal file
@@ -0,0 +1,430 @@
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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:
|
||||
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:
|
||||
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:
|
||||
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", "")
|
||||
return f'(~kg-html :html "{_esc(md_text)}")'
|
||||
Reference in New Issue
Block a user