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:
20
blog/alembic/versions/0005_add_sx_content.py
Normal file
20
blog/alembic/versions/0005_add_sx_content.py
Normal 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")
|
||||||
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)}")'
|
||||||
69
blog/scripts/backfill_sx_content.py
Normal file
69
blog/scripts/backfill_sx_content.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
#!/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.sessions import get_session_factory
|
||||||
|
from blog.models.content import Post
|
||||||
|
from blog.bp.blog.ghost.lexical_to_sx import lexical_to_sx
|
||||||
|
|
||||||
|
session_factory = get_session_factory("blog")
|
||||||
|
converted = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
async with session_factory() 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()
|
||||||
146
blog/sx/kg_cards.sx
Normal file
146
blog/sx/kg_cards.sx
Normal 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-->"))
|
||||||
278
blog/tests/test_lexical_to_sx.py
Normal file
278
blog/tests/test_lexical_to_sx.py
Normal 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
|
||||||
2290
shared/static/scripts/sx-editor.js
Normal file
2290
shared/static/scripts/sx-editor.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user