"""Blog page helpers — async functions available in .sx defpage expressions.""" from __future__ import annotations from typing import Any # --------------------------------------------------------------------------- # Shared hydration helpers # --------------------------------------------------------------------------- def _add_to_defpage_ctx(**kwargs: Any) -> None: from quart import g if not hasattr(g, '_defpage_ctx'): g._defpage_ctx = {} g._defpage_ctx.update(kwargs) async def _ensure_post_data(slug: str | None) -> None: """Load post data and set g.post_data + defpage context. Replicates post bp's hydrate_post_data + context_processor. """ from quart import g, abort if hasattr(g, 'post_data') and g.post_data: await _inject_post_context(g.post_data) return if not slug: abort(404) from bp.post.services.post_data import post_data is_admin = bool((g.get("rights") or {}).get("admin")) p_data = await post_data(slug, g.s, include_drafts=True) if not p_data: abort(404) # Draft access control if p_data["post"].get("status") != "published": if is_admin: pass elif g.user and p_data["post"].get("user_id") == g.user.id: pass else: abort(404) g.post_data = p_data g.post_slug = slug await _inject_post_context(p_data) async def _inject_post_context(p_data: dict) -> None: """Add post context_processor data to defpage context.""" from shared.config import config from shared.infrastructure.fragments import fetch_fragment from shared.infrastructure.data_client import fetch_data from shared.contracts.dtos import CartSummaryDTO, dto_from_dict from shared.infrastructure.cart_identity import current_cart_identity db_post_id = p_data["post"]["id"] post_slug = p_data["post"]["slug"] container_nav = await fetch_fragment("relations", "container-nav", params={ "container_type": "page", "container_id": str(db_post_id), "post_slug": post_slug, }) ctx: dict = { **p_data, "base_title": config()["title"], "container_nav": container_nav, } if p_data["post"].get("is_page"): ident = current_cart_identity() summary_params: dict = {"page_slug": post_slug} if ident["user_id"] is not None: summary_params["user_id"] = ident["user_id"] if ident["session_id"] is not None: summary_params["session_id"] = ident["session_id"] raw_summary = await fetch_data( "cart", "cart-summary", params=summary_params, required=False, ) page_summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO() ctx["page_cart_count"] = ( page_summary.count + page_summary.calendar_count + page_summary.ticket_count ) ctx["page_cart_total"] = float( page_summary.total + page_summary.calendar_total + page_summary.ticket_total ) _add_to_defpage_ctx(**ctx) # --------------------------------------------------------------------------- # Rendering helpers (moved from sx_components) # --------------------------------------------------------------------------- def _raw_html_sx(html: str) -> str: """Wrap raw HTML in (raw! "...") so it's valid inside sx source.""" from shared.sx.parser import serialize as sx_serialize if not html: return "" return "(raw! " + sx_serialize(html) + ")" # --------------------------------------------------------------------------- # Page helpers (async functions available in .sx defpage expressions) # --------------------------------------------------------------------------- def _register_blog_helpers() -> None: from shared.sx.pages import register_page_helpers register_page_helpers("blog", { "editor-content": _h_editor_content, "editor-page-content": _h_editor_page_content, "post-admin-content": _h_post_admin_content, "post-data-content": _h_post_data_content, "post-preview-content": _h_post_preview_content, "post-entries-content": _h_post_entries_content, "post-settings-content": _h_post_settings_content, "post-edit-content": _h_post_edit_content, }) # --- Editor helpers --- def _h_editor_content(**kw): from .renders import render_editor_panel return render_editor_panel() def _h_editor_page_content(**kw): from .renders import render_editor_panel return render_editor_panel(is_page=True) # --- Post admin helpers --- async def _h_post_admin_content(slug=None, **kw): await _ensure_post_data(slug) from shared.sx.helpers import sx_call return sx_call("blog-admin-placeholder") async def _h_post_data_content(slug=None, **kw): await _ensure_post_data(slug) from quart import g from markupsafe import escape as esc original_post = getattr(g, "post_data", {}).get("original_post") if original_post is None: return _raw_html_sx('
No post data available.
') tablename = getattr(original_post, "__tablename__", "?") def _render_scalar_table(obj): rows = [] for col in obj.__mapper__.columns: key = col.key if key == "_sa_instance_state": continue val = getattr(obj, key, None) if val is None: val_html = '\u2014' elif hasattr(val, "isoformat"): val_html = f'
{esc(val.isoformat())}
' elif isinstance(val, str): val_html = f'
{esc(val)}
' else: val_html = f'
{esc(str(val))}
' rows.append( f'' f'{esc(key)}' f'{val_html}' ) return ( '
' '' '' '' '' '' + "".join(rows) + '
FieldValue
' ) def _render_model(obj, depth=0, max_depth=2): parts = [_render_scalar_table(obj)] rel_parts = [] for rel in obj.__mapper__.relationships: rel_name = rel.key loaded = rel_name in obj.__dict__ value = getattr(obj, rel_name, None) if loaded else None cardinality = "many" if rel.uselist else "one" cls_name = rel.mapper.class_.__name__ loaded_label = "" if loaded else " \u2022 not loaded" inner = "" if value is None: inner = '\u2014' elif rel.uselist: items = list(value) if value else [] inner = f'
{len(items)} item{"" if len(items) == 1 else "s"}
' if items and depth < max_depth: sub_rows = [] for i, it in enumerate(items, 1): ident_parts = [] for k in ("id", "ghost_id", "uuid", "slug", "name", "title"): if k in it.__mapper__.c: v = getattr(it, k, "") ident_parts.append(f"{k}={v}") summary = " \u2022 ".join(ident_parts) if ident_parts else str(it) child_html = "" if depth < max_depth: child_html = f'
{_render_model(it, depth + 1, max_depth)}
' else: child_html = '
\u2026max depth reached\u2026
' sub_rows.append( f'' f'{i}' f'
{esc(summary)}
{child_html}' ) inner += ( '
' '' '' '' + "".join(sub_rows) + '
#Summary
' ) else: child = value ident_parts = [] for k in ("id", "ghost_id", "uuid", "slug", "name", "title"): if k in child.__mapper__.c: v = getattr(child, k, "") ident_parts.append(f"{k}={v}") summary = " \u2022 ".join(ident_parts) if ident_parts else str(child) inner = f'
{esc(summary)}
' if depth < max_depth: inner += f'
{_render_model(child, depth + 1, max_depth)}
' else: inner += '
\u2026max depth reached\u2026
' rel_parts.append( f'
' f'
' f'Relationship: {esc(rel_name)}' f' {cardinality} \u2192 {esc(cls_name)}{loaded_label}
' f'
{inner}
' ) if rel_parts: parts.append('
' + "".join(rel_parts) + '
') return '
' + "".join(parts) + '
' html = ( f'
' f'
Model: Post \u2022 Table: {esc(tablename)}
' f'{_render_model(original_post, 0, 2)}
' ) return _raw_html_sx(html) async def _h_post_preview_content(slug=None, **kw): await _ensure_post_data(slug) from quart import g from shared.services.registry import services from shared.sx.helpers import sx_call from shared.sx.parser import SxExpr preview = await services.blog_page.preview_data(g.s) sections: list[str] = [] if preview.get("sx_pretty"): sections.append(sx_call("blog-preview-section", title="S-Expression Source", content=SxExpr(preview["sx_pretty"]))) if preview.get("json_pretty"): sections.append(sx_call("blog-preview-section", title="Lexical JSON", content=SxExpr(preview["json_pretty"]))) if preview.get("sx_rendered"): rendered_sx = sx_call("blog-preview-rendered", html=preview["sx_rendered"]) sections.append(sx_call("blog-preview-section", title="SX Rendered", content=rendered_sx)) if preview.get("lex_rendered"): rendered_sx = sx_call("blog-preview-rendered", html=preview["lex_rendered"]) sections.append(sx_call("blog-preview-section", title="Lexical Rendered", content=rendered_sx)) if not sections: return sx_call("blog-preview-empty") inner = " ".join(sections) return sx_call("blog-preview-panel", sections=SxExpr(f"(<> {inner})")) async def _h_post_entries_content(slug=None, **kw): await _ensure_post_data(slug) from quart import g, url_for as qurl from sqlalchemy import select from markupsafe import escape as esc from shared.models.calendars import Calendar from shared.utils import host_url from bp.post.services.entry_associations import get_post_entry_ids from bp.post.admin.routes import _render_associated_entries post_id = g.post_data["post"]["id"] post_slug = g.post_data["post"]["slug"] associated_entry_ids = await get_post_entry_ids(post_id) result = await g.s.execute( select(Calendar) .where(Calendar.deleted_at.is_(None)) .order_by(Calendar.name.asc()) ) all_calendars = result.scalars().all() for calendar in all_calendars: await g.s.refresh(calendar, ["entries", "post"]) # Associated entries list assoc_html = _render_associated_entries(all_calendars, associated_entry_ids, post_slug) # Calendar browser cal_items: list[str] = [] for cal in all_calendars: cal_post = getattr(cal, "post", None) cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None cal_title = esc(getattr(cal_post, "title", "")) if cal_post else "" cal_name = esc(getattr(cal, "name", "")) cal_view_url = host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal.id)) img_html = ( f'{cal_title}' if cal_fi else '
' ) cal_items.append( f'
' f'' f'{img_html}' f'
' f'
{cal_name}
' f'
{cal_title}
' f'
' f'
' f'
Loading calendar...
' f'
' ) if cal_items: browser_html = ( '

Browse Calendars

' + "".join(cal_items) + '
' ) else: browser_html = '

Browse Calendars

No calendars found.
' return ( _raw_html_sx('
') + assoc_html + _raw_html_sx(browser_html + '
') ) async def _h_post_settings_content(slug=None, **kw): await _ensure_post_data(slug) from quart import g, request from markupsafe import escape as esc from models.ghost_content import Post from sqlalchemy import select as sa_select from sqlalchemy.orm import selectinload from shared.browser.app.csrf import generate_csrf_token from bp.post.admin.routes import _post_to_edit_dict post_id = g.post_data["post"]["id"] post = (await g.s.execute( sa_select(Post) .where(Post.id == post_id) .options(selectinload(Post.tags)) )).scalar_one_or_none() ghost_post = _post_to_edit_dict(post) if post else {} save_success = request.args.get("saved") == "1" csrf = generate_csrf_token() p = g.post_data.get("post", {}) if hasattr(g, "post_data") else {} is_page = p.get("is_page", False) gp = ghost_post def field_label(text, field_for=None): for_attr = f' for="{field_for}"' if field_for else '' return f'{esc(text)}' input_cls = ('w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px] ' 'bg-white text-stone-700 placeholder:text-stone-300 ' 'focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300') textarea_cls = input_cls + ' resize-y' def text_input(name, value='', placeholder='', input_type='text', maxlength=None): ml = f' maxlength="{maxlength}"' if maxlength else '' return (f'') def textarea_input(name, value='', placeholder='', rows=3, maxlength=None): ml = f' maxlength="{maxlength}"' if maxlength else '' return (f'') def checkbox_input(name, checked=False, label=''): chk = ' checked' if checked else '' return (f'') def section(title, content, is_open=False): open_attr = ' open' if is_open else '' return (f'
' f'{esc(title)}' f'
{content}
') # General section slug_placeholder = 'page-slug' if is_page else 'post-slug' pub_at = gp.get("published_at") or "" pub_at_val = pub_at[:16] if pub_at else "" vis = gp.get("visibility") or "public" vis_opts = "".join( f'' for v, l in [("public", "Public"), ("members", "Members"), ("paid", "Paid")] ) general = ( f'
{field_label("Slug", "settings-slug")}{text_input("slug", gp.get("slug") or "", slug_placeholder)}
' f'
{field_label("Published at", "settings-published_at")}' f'
' f'
{checkbox_input("featured", gp.get("featured"), "Featured page" if is_page else "Featured post")}
' f'
{field_label("Visibility", "settings-visibility")}' f'
' f'
{checkbox_input("email_only", gp.get("email_only"), "Email only")}
' ) # Tags tags = gp.get("tags") or [] if tags: tag_names = ", ".join(getattr(t, "name", t.get("name", "") if isinstance(t, dict) else str(t)) for t in tags) else: tag_names = "" tags_sec = ( f'
{field_label("Tags (comma-separated)", "settings-tags")}' f'{text_input("tags", tag_names, "news, updates, featured")}' f'

Unknown tags will be created automatically.

' ) fi_sec = f'
{field_label("Alt text", "settings-feature_image_alt")}{text_input("feature_image_alt", gp.get("feature_image_alt") or "", "Describe the feature image")}
' seo_sec = ( f'
{field_label("Meta title", "settings-meta_title")}{text_input("meta_title", gp.get("meta_title") or "", "SEO title", maxlength=300)}' f'

Recommended: 70 characters. Max: 300.

' f'
{field_label("Meta description", "settings-meta_description")}{textarea_input("meta_description", gp.get("meta_description") or "", "SEO description", rows=2, maxlength=500)}' f'

Recommended: 156 characters.

' f'
{field_label("Canonical URL", "settings-canonical_url")}{text_input("canonical_url", gp.get("canonical_url") or "", "https://example.com/original-post", input_type="url")}
' ) og_sec = ( f'
{field_label("OG title", "settings-og_title")}{text_input("og_title", gp.get("og_title") or "")}
' f'
{field_label("OG description", "settings-og_description")}{textarea_input("og_description", gp.get("og_description") or "", rows=2)}
' f'
{field_label("OG image URL", "settings-og_image")}{text_input("og_image", gp.get("og_image") or "", "https://...", input_type="url")}
' ) tw_sec = ( f'
{field_label("Twitter title", "settings-twitter_title")}{text_input("twitter_title", gp.get("twitter_title") or "")}
' f'
{field_label("Twitter description", "settings-twitter_description")}{textarea_input("twitter_description", gp.get("twitter_description") or "", rows=2)}
' f'
{field_label("Twitter image URL", "settings-twitter_image")}{text_input("twitter_image", gp.get("twitter_image") or "", "https://...", input_type="url")}
' ) tmpl_placeholder = 'custom-page.hbs' if is_page else 'custom-post.hbs' adv_sec = f'
{field_label("Custom template", "settings-custom_template")}{text_input("custom_template", gp.get("custom_template") or "", tmpl_placeholder)}
' sections = ( section("General", general, is_open=True) + section("Tags", tags_sec) + section("Feature Image", fi_sec) + section("SEO / Meta", seo_sec) + section("Facebook / OpenGraph", og_sec) + section("X / Twitter", tw_sec) + section("Advanced", adv_sec) ) saved_html = 'Saved.' if save_success else '' html = ( f'
' f'' f'' f'
{sections}
' f'
' f'' f'{saved_html}
' ) return _raw_html_sx(html) async def _h_post_edit_content(slug=None, **kw): await _ensure_post_data(slug) import os from quart import g, request as qrequest, url_for as qurl, current_app from models.ghost_content import Post from sqlalchemy import select as sa_select from sqlalchemy.orm import selectinload from shared.infrastructure.data_client import fetch_data from shared.browser.app.csrf import generate_csrf_token from shared.sx.helpers import sx_call from shared.sx.parser import SxExpr, serialize as sx_serialize from bp.post.admin.routes import _post_to_edit_dict post_id = g.post_data["post"]["id"] db_post = (await g.s.execute( sa_select(Post) .where(Post.id == post_id) .options(selectinload(Post.tags)) )).scalar_one_or_none() ghost_post = _post_to_edit_dict(db_post) if db_post else {} save_success = qrequest.args.get("saved") == "1" save_error = qrequest.args.get("error", "") raw_newsletters = await fetch_data("account", "newsletters", required=False) or [] from types import SimpleNamespace newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters] csrf = generate_csrf_token() 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") upload_file_url = qurl("blog.editor_api.upload_file") oembed_url = qurl("blog.editor_api.oembed_proxy") snippets_url = qurl("blog.editor_api.list_snippets") unsplash_key = os.environ.get("UNSPLASH_ACCESS_KEY", "") post = g.post_data.get("post", {}) if hasattr(g, "post_data") else {} is_page = post.get("is_page", False) feature_image = ghost_post.get("feature_image") or "" feature_image_caption = ghost_post.get("feature_image_caption") or "" title_val = ghost_post.get("title") or "" excerpt_val = ghost_post.get("custom_excerpt") or "" updated_at = 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 "" has_sx = bool(sx_content) already_emailed = bool(ghost_post and ghost_post.get("email") and (ghost_post["email"] if isinstance(ghost_post["email"], dict) else {}).get("status")) email_obj = ghost_post.get("email") if email_obj and not isinstance(email_obj, dict): already_emailed = bool(getattr(email_obj, "status", None)) title_placeholder = "Page title..." if is_page else "Post title..." # Newsletter options as SX fragment nl_parts = ['(option :value "" "Select newsletter\u2026")'] for nl in newsletters: nl_slug = sx_serialize(getattr(nl, "slug", "")) nl_name = sx_serialize(getattr(nl, "name", "")) nl_parts.append(f"(option :value {nl_slug} {nl_name})") nl_opts_sx = SxExpr("(<> " + " ".join(nl_parts) + ")") # Footer extra badges as SX fragment badge_parts: list[str] = [] if save_success: badge_parts.append('(span :class "text-[14px] text-green-600" "Saved.")') publish_requested = qrequest.args.get("publish_requested") if hasattr(qrequest, 'args') else None if publish_requested: badge_parts.append('(span :class "text-[14px] text-blue-600" "Publish requested \u2014 an admin will review.")') if post.get("publish_requested"): badge_parts.append('(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")') if already_emailed: nl_name = "" newsletter = ghost_post.get("newsletter") if newsletter: nl_name = getattr(newsletter, "name", "") if not isinstance(newsletter, dict) else newsletter.get("name", "") suffix = f" to {nl_name}" if nl_name else "" badge_parts.append(f'(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800" "Emailed{suffix}")') footer_extra_sx = SxExpr("(<> " + " ".join(badge_parts) + ")") if badge_parts else None parts: list[str] = [] if save_error: parts.append(sx_call("blog-editor-error", error=save_error)) parts.append(sx_call("blog-editor-edit-form", csrf=csrf, updated_at=str(updated_at), title_val=title_val, excerpt_val=excerpt_val, feature_image=feature_image, feature_image_caption=feature_image_caption, sx_content_val=sx_content, lexical_json=lexical_json, has_sx=has_sx, title_placeholder=title_placeholder, status=status, already_emailed=already_emailed, newsletter_options=nl_opts_sx, footer_extra=footer_extra_sx, )) parts.append(sx_call("blog-editor-publish-js", already_emailed=already_emailed)) parts.append(sx_call("blog-editor-styles", css_href=editor_css)) parts.append(sx_call("sx-editor-styles")) init_js = ( '(function() {' " function applyEditorFontSize() {" " document.documentElement.style.fontSize = '62.5%';" " document.body.style.fontSize = '1.6rem';" ' }' " function restoreDefaultFontSize() {" " document.documentElement.style.fontSize = '';" " document.body.style.fontSize = '';" ' }' ' applyEditorFontSize();' " document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {" " if (e.detail.target && e.detail.target.id === 'main-panel') {" ' restoreDefaultFontSize();' " document.body.removeEventListener('htmx:beforeSwap', cleanup);" ' }' ' });' ' function init() {' " var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;" f" var uploadUrl = '{upload_image_url}';" ' var uploadUrls = {' ' image: uploadUrl,' f" media: '{upload_media_url}'," f" file: '{upload_file_url}'," ' };' " var fileInput = document.getElementById('feature-image-file');" " var addBtn = document.getElementById('feature-image-add-btn');" " var deleteBtn = document.getElementById('feature-image-delete-btn');" " var preview = document.getElementById('feature-image-preview');" " var emptyState = document.getElementById('feature-image-empty');" " var filledState = document.getElementById('feature-image-filled');" " var hiddenUrl = document.getElementById('feature-image-input');" " var hiddenCaption = document.getElementById('feature-image-caption-input');" " var captionInput = document.getElementById('feature-image-caption');" " var uploading = document.getElementById('feature-image-uploading');" ' function showFilled(url) {' ' preview.src = url; hiddenUrl.value = url;' " emptyState.classList.add('hidden'); filledState.classList.remove('hidden'); uploading.classList.add('hidden');" ' }' ' function showEmpty() {' " preview.src = ''; hiddenUrl.value = ''; hiddenCaption.value = ''; captionInput.value = '';" " emptyState.classList.remove('hidden'); filledState.classList.add('hidden'); uploading.classList.add('hidden');" ' }' ' function uploadFile(file) {' " emptyState.classList.add('hidden'); uploading.classList.remove('hidden');" " var fd = new FormData(); fd.append('file', file);" " fetch(uploadUrl, { method: 'POST', body: fd, headers: { 'X-CSRFToken': csrfToken } })" " .then(function(r) { if (!r.ok) throw new Error('Upload failed (' + r.status + ')'); return r.json(); })" ' .then(function(data) {' ' var url = data.images && data.images[0] && data.images[0].url;' " if (url) showFilled(url); else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }" ' })' ' .catch(function(e) { showEmpty(); alert(e.message); });' ' }' " addBtn.addEventListener('click', function() { fileInput.click(); });" " preview.addEventListener('click', function() { fileInput.click(); });" " deleteBtn.addEventListener('click', function(e) { e.stopPropagation(); showEmpty(); });" " fileInput.addEventListener('change', function() {" ' if (fileInput.files && fileInput.files[0]) { uploadFile(fileInput.files[0]); fileInput.value = \'\'; }' ' });' " captionInput.addEventListener('input', function() { hiddenCaption.value = captionInput.value; });" " var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');" " function autoResize() { excerpt.style.height = 'auto'; excerpt.style.height = excerpt.scrollHeight + 'px'; }" " excerpt.addEventListener('input', autoResize); autoResize();" ' var dataEl = document.getElementById(\'lexical-initial-data\');' ' var initialJson = dataEl ? dataEl.textContent.trim() : null;' ' if (initialJson) { var hidden = document.getElementById(\'lexical-json-input\'); if (hidden) hidden.value = initialJson; }' " window.mountEditor('lexical-editor', {" ' initialJson: initialJson,' ' csrfToken: csrfToken,' ' uploadUrls: uploadUrls,' f" oembedUrl: '{oembed_url}'," f" unsplashApiKey: '{unsplash_key}'," f" snippetsUrl: '{snippets_url}'," ' });' " if (typeof SxEditor !== 'undefined') {" " SxEditor.mount('sx-editor', {" " initialSx: (document.getElementById('sx-content-input') || {}).value || 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();" ' }' ' });' ' }' " if (typeof window.mountEditor === 'function') { init(); }" ' else { var _t = setInterval(function() {' " if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }" ' }, 50); }' '})();' ) parts.append(sx_call("blog-editor-scripts", js_src=editor_js, sx_editor_js_src=sx_editor_js, init_js=init_js)) return sx_call("blog-editor-panel", parts=SxExpr("(<> " + " ".join(parts) + ")"))