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:
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
|
||||
Reference in New Issue
Block a user