Add SX editor to post edit page, prevent sx_content clearing on save

- Add sx_content to _post_to_edit_dict so edit page receives existing content
- Add SX/Koenig editor tabs, sx-editor mount point, and SxEditor.mount init
- Only pass sx_content to writer_update when form field is present (prevents
  accidental clearing when editing via Koenig-only path)
- Add csrf_exempt to example API POST/DELETE/PUT demo endpoints
- Add defpage infrastructure (pages.py, layouts.py) and sx docs page definitions
- Add defhandler definitions for example API handlers (examples.sx)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 10:23:33 +00:00
parent 0af07f9f2e
commit a8c0741f54
8 changed files with 1194 additions and 5 deletions

View File

@@ -23,6 +23,7 @@ def _post_to_edit_dict(post) -> dict:
d: dict = {}
for col in (
"id", "slug", "title", "html", "plaintext", "lexical", "mobiledoc",
"sx_content",
"feature_image", "feature_image_alt", "feature_image_caption",
"excerpt", "custom_excerpt", "visibility", "status", "featured",
"is_page", "email_only", "canonical_url",
@@ -595,6 +596,10 @@ def register():
effective_status = status
sx_content_raw = form.get("sx_content", "").strip() or None
# Build optional kwargs — only pass sx_content if the form field was present
extra_kw: dict = {}
if "sx_content" in form:
extra_kw["sx_content"] = sx_content_raw
try:
post = await writer_update(
g.s,
@@ -606,7 +611,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,
**extra_kw,
)
except OptimisticLockError:
return redirect(

View File

@@ -1755,6 +1755,7 @@ def _post_edit_content_sx(ctx: dict) -> str:
asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "")
editor_css = asset_url_fn("scripts/editor.css")
editor_js = asset_url_fn("scripts/editor.js")
sx_editor_js = asset_url_fn("scripts/sx-editor.js")
upload_image_url = qurl("blog.editor_api.upload_image")
upload_media_url = qurl("blog.editor_api.upload_media")
@@ -1773,6 +1774,7 @@ def _post_edit_content_sx(ctx: dict) -> str:
updated_at = esc(ghost_post.get("updated_at") or "")
status = ghost_post.get("status") or "draft"
lexical_json = ghost_post.get("lexical") or '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}'
sx_content = ghost_post.get("sx_content") or ""
already_emailed = bool(ghost_post and ghost_post.get("email") and (ghost_post["email"] if isinstance(ghost_post["email"], dict) else {}).get("status"))
# For ORM objects the email may be an object
@@ -1799,6 +1801,7 @@ def _post_edit_content_sx(ctx: dict) -> str:
form_parts.append(f'<input type="hidden" name="csrf_token" value="{csrf}">')
form_parts.append(f'<input type="hidden" name="updated_at" value="{updated_at}">')
form_parts.append('<input type="hidden" id="lexical-json-input" name="lexical" value="">')
form_parts.append(f'<input type="hidden" id="sx-content-input" name="sx_content" value="{esc(sx_content)}">')
form_parts.append(f'<input type="hidden" id="feature-image-input" name="feature_image" value="{esc(feature_image)}">')
form_parts.append(f'<input type="hidden" id="feature-image-caption-input" name="feature_image_caption" value="{esc(feature_image_caption)}">')
@@ -1830,8 +1833,24 @@ def _post_edit_content_sx(ctx: dict) -> str:
f' class="w-full text-[18px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed">{excerpt_val}</textarea>'
)
# Editor mount point
form_parts.append('<div id="lexical-editor" class="relative w-full bg-transparent"></div>')
# Editor tabs: SX (primary) and Koenig (legacy)
has_sx = bool(sx_content)
sx_active = 'text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent'
sx_inactive = 'text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'
form_parts.append(
'<div class="flex gap-[4px] mb-[8px] border-b border-stone-200">'
f'<button type="button" id="editor-tab-sx" class="px-[12px] py-[6px] text-[13px] font-medium {sx_active if has_sx else sx_inactive}"'
""" onclick="document.getElementById('sx-editor').style.display='block';document.getElementById('lexical-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-koenig').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'" """
'>SX Editor</button>'
f'<button type="button" id="editor-tab-koenig" class="px-[12px] py-[6px] text-[13px] font-medium {sx_inactive if has_sx else sx_active}"'
""" onclick="document.getElementById('lexical-editor').style.display='block';document.getElementById('sx-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-sx').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'" """
'>Koenig (Legacy)</button>'
'</div>'
)
# SX editor mount point
form_parts.append(f'<div id="sx-editor" class="relative w-full bg-transparent" style="{"" if has_sx else "display:none"}"></div>')
# Koenig editor mount point
form_parts.append(f'<div id="lexical-editor" class="relative w-full bg-transparent" style="{"display:none" if has_sx else ""}"></div>')
# Initial lexical JSON
form_parts.append(f'<script id="lexical-initial-data" type="application/json">{lexical_json}</script>')
@@ -1902,8 +1921,10 @@ def _post_edit_content_sx(ctx: dict) -> str:
'</script>'
)
# Editor CSS + styles
# Editor CSS + styles + SX editor styles
from shared.sx.helpers import sx_call
parts.append(f'<link rel="stylesheet" href="{esc(editor_css)}">')
parts.append(sx_call("sx-editor-styles"))
parts.append(
'<style>'
'#lexical-editor { display: flow-root; }'
@@ -1912,8 +1933,13 @@ def _post_edit_content_sx(ctx: dict) -> str:
'</style>'
)
# Editor JS + init
# Initial sx content for SX editor
sx_initial_escaped = sx_content.replace('\\', '\\\\').replace("'", "\\'").replace('\n', '\\n').replace('\r', '')
parts.append(f"<script>window.__SX_INITIAL__ = '{sx_initial_escaped}' || null;</script>")
# Editor JS + SX editor JS + init
parts.append(f'<script src="{esc(editor_js)}"></script>')
parts.append(f'<script src="{esc(sx_editor_js)}"></script>')
parts.append(
'<script>'
'(function() {'
@@ -1991,6 +2017,17 @@ def _post_edit_content_sx(ctx: dict) -> str:
f" unsplashApiKey: '{unsplash_key}',"
f" snippetsUrl: '{snippets_url}',"
' });'
" if (typeof SxEditor !== 'undefined') {"
" SxEditor.mount('sx-editor', {"
" initialSx: window.__SX_INITIAL__ || null,"
' csrfToken: csrfToken,'
' uploadUrls: uploadUrls,'
f" oembedUrl: '{oembed_url}',"
' onChange: function(sx) {'
" document.getElementById('sx-content-input').value = sx;"
' }'
' });'
' }'
" document.addEventListener('keydown', function(e) {"
" if ((e.ctrlKey || e.metaKey) && e.key === 's') {"
" e.preventDefault(); document.getElementById('post-edit-form').requestSubmit();"