Move blog composition from Python to .sx defcomps (Phase 7)

Convert all 8 blog page helpers from returning sx_call() strings to
returning data dicts. Defpages now use :data + :content pattern:
helpers load data, SX composes markup. Newsletter options and footer
badges composed inline with map/fn in defpage expressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 02:03:51 +00:00
parent aed4c03537
commit 1560207097
2 changed files with 156 additions and 121 deletions

View File

@@ -1,5 +1,6 @@
; Blog app defpage declarations ; Blog app defpage declarations
; Pages kept as Python: home, index, post-detail (cache_page / complex branching) ; Pages kept as Python: home, index, post-detail (cache_page / complex branching)
; All helpers return data dicts — markup composition in SX.
; --- New post/page editors --- ; --- New post/page editors ---
@@ -7,13 +8,23 @@
:path "/new/" :path "/new/"
:auth :admin :auth :admin
:layout :blog :layout :blog
:content (editor-content)) :data (editor-data)
:content (~blog-editor-content
:csrf csrf :title-placeholder title-placeholder
:create-label create-label :css-href css-href
:js-src js-src :sx-editor-js-src sx-editor-js-src
:init-js init-js))
(defpage new-page (defpage new-page
:path "/new-page/" :path "/new-page/"
:auth :admin :auth :admin
:layout :blog :layout :blog
:content (editor-page-content)) :data (editor-page-data)
:content (~blog-editor-content
:csrf csrf :title-placeholder title-placeholder
:create-label create-label :css-href css-href
:js-src js-src :sx-editor-js-src sx-editor-js-src
:init-js init-js))
; --- Post admin pages (absolute paths under /<slug>/admin/) --- ; --- Post admin pages (absolute paths under /<slug>/admin/) ---
@@ -21,37 +32,71 @@
:path "/<slug>/admin/" :path "/<slug>/admin/"
:auth :admin :auth :admin
:layout (:post-admin :selected "admin") :layout (:post-admin :selected "admin")
:content (post-admin-content slug)) :data (post-admin-data slug)
:content (~blog-admin-placeholder))
(defpage post-data (defpage post-data
:path "/<slug>/admin/data/" :path "/<slug>/admin/data/"
:auth :admin :auth :admin
:layout (:post-admin :selected "data") :layout (:post-admin :selected "data")
:content (post-data-content slug)) :data (post-data-data slug)
:content (~blog-data-table-content :tablename tablename :model-data model-data))
(defpage post-preview (defpage post-preview
:path "/<slug>/admin/preview/" :path "/<slug>/admin/preview/"
:auth :admin :auth :admin
:layout (:post-admin :selected "preview") :layout (:post-admin :selected "preview")
:content (post-preview-content slug)) :data (post-preview-data slug)
:content (~blog-preview-content
:sx-pretty sx-pretty :json-pretty json-pretty
:sx-rendered sx-rendered :lex-rendered lex-rendered))
(defpage post-entries (defpage post-entries
:path "/<slug>/admin/entries/" :path "/<slug>/admin/entries/"
:auth :admin :auth :admin
:layout (:post-admin :selected "entries") :layout (:post-admin :selected "entries")
:content (post-entries-content slug)) :data (post-entries-data slug)
:content (~blog-entries-browser-content
:entries-panel (~blog-associated-entries-from-data :entries entries :csrf csrf)
:calendars calendars))
(defpage post-settings (defpage post-settings
:path "/<slug>/admin/settings/" :path "/<slug>/admin/settings/"
:auth :post_author :auth :post_author
:layout (:post-admin :selected "settings") :layout (:post-admin :selected "settings")
:content (post-settings-content slug)) :data (post-settings-data slug)
:content (~blog-settings-form-content
:csrf csrf :updated-at updated-at :is-page is-page
:save-success save-success :slug settings-slug
:published-at published-at :featured featured
:visibility visibility :email-only email-only
:tags tags :feature-image-alt feature-image-alt
:meta-title meta-title :meta-description meta-description
:canonical-url canonical-url :og-title og-title
:og-description og-description :og-image og-image
:twitter-title twitter-title :twitter-description twitter-description
:twitter-image twitter-image :custom-template custom-template))
(defpage post-edit (defpage post-edit
:path "/<slug>/admin/edit/" :path "/<slug>/admin/edit/"
:auth :post_author :auth :post_author
:layout (:post-admin :selected "edit") :layout (:post-admin :selected "edit")
:content (post-edit-content slug)) :data (post-edit-data slug)
:content (~blog-edit-content
:csrf csrf :updated-at 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-val :lexical-json lexical-json
:has-sx has-sx :title-placeholder title-placeholder
:status status :already-emailed already-emailed
:newsletter-options (<>
(option :value "" "Select newsletter\u2026")
(map (fn (nl) (option :value (get nl "slug") (get nl "name"))) newsletters))
:footer-extra (when badges
(<> (map (fn (b) (span :class (get b "cls") (get b "text"))) badges)))
:css-href css-href :js-src js-src
:sx-editor-js-src sx-editor-js-src
:init-js init-js :save-error save-error))
; --- Settings pages (absolute paths) --- ; --- Settings pages (absolute paths) ---

View File

@@ -1,11 +1,15 @@
"""Blog page helpers — async functions available in .sx defpage expressions.""" """Blog page helpers — async functions available in .sx defpage expressions.
All helpers return data values (dicts, lists) — no sx_call().
Markup composition lives entirely in .sx defpage and .sx defcomp files.
"""
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Shared hydration helpers # Shared hydration helpers (kept for auth/g._defpage_ctx side effects)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _add_to_defpage_ctx(**kwargs: Any) -> None: def _add_to_defpage_ctx(**kwargs: Any) -> None:
@@ -95,20 +99,20 @@ async def _inject_post_context(p_data: dict) -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Page helpers (async functions available in .sx defpage expressions) # Registration
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _register_blog_helpers() -> None: def _register_blog_helpers() -> None:
from shared.sx.pages import register_page_helpers from shared.sx.pages import register_page_helpers
register_page_helpers("blog", { register_page_helpers("blog", {
"editor-content": _h_editor_content, "editor-data": _h_editor_data,
"editor-page-content": _h_editor_page_content, "editor-page-data": _h_editor_page_data,
"post-admin-content": _h_post_admin_content, "post-admin-data": _h_post_admin_data,
"post-data-content": _h_post_data_content, "post-data-data": _h_post_data_data,
"post-preview-content": _h_post_preview_content, "post-preview-data": _h_post_preview_data,
"post-entries-content": _h_post_entries_content, "post-entries-data": _h_post_entries_data,
"post-settings-content": _h_post_settings_content, "post-settings-data": _h_post_settings_data,
"post-edit-content": _h_post_edit_content, "post-edit-data": _h_post_edit_data,
}) })
@@ -264,52 +268,51 @@ def _editor_urls() -> dict:
} }
def _h_editor_content(**kw): def _h_editor_data(**kw) -> dict:
"""New post editor panel.""" """New post editor — return data for ~blog-editor-content."""
from shared.sx.helpers import sx_call
from shared.browser.app.csrf import generate_csrf_token from shared.browser.app.csrf import generate_csrf_token
urls = _editor_urls() urls = _editor_urls()
csrf = generate_csrf_token() csrf = generate_csrf_token()
init_js = _editor_init_js(urls, form_id="post-new-form", has_initial_json=False) init_js = _editor_init_js(urls, form_id="post-new-form", has_initial_json=False)
return sx_call("blog-editor-content", return {
csrf=csrf, "csrf": csrf,
title_placeholder="Post title...", "title-placeholder": "Post title...",
create_label="Create Post", "create-label": "Create Post",
css_href=urls["css_href"], "css-href": urls["css_href"],
js_src=urls["js_src"], "js-src": urls["js_src"],
sx_editor_js_src=urls["sx_editor_js_src"], "sx-editor-js-src": urls["sx_editor_js_src"],
init_js=init_js) "init-js": init_js,
}
def _h_editor_page_content(**kw): def _h_editor_page_data(**kw) -> dict:
"""New page editor panel.""" """New page editor — return data for ~blog-editor-content."""
from shared.sx.helpers import sx_call
from shared.browser.app.csrf import generate_csrf_token from shared.browser.app.csrf import generate_csrf_token
urls = _editor_urls() urls = _editor_urls()
csrf = generate_csrf_token() csrf = generate_csrf_token()
init_js = _editor_init_js(urls, form_id="post-new-form", has_initial_json=False) init_js = _editor_init_js(urls, form_id="post-new-form", has_initial_json=False)
return sx_call("blog-editor-content", return {
csrf=csrf, "csrf": csrf,
title_placeholder="Page title...", "title-placeholder": "Page title...",
create_label="Create Page", "create-label": "Create Page",
css_href=urls["css_href"], "css-href": urls["css_href"],
js_src=urls["js_src"], "js-src": urls["js_src"],
sx_editor_js_src=urls["sx_editor_js_src"], "sx-editor-js-src": urls["sx_editor_js_src"],
init_js=init_js) "init-js": init_js,
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Post admin helpers # Post admin helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _h_post_admin_content(slug=None, **kw): async def _h_post_admin_data(slug=None, **kw) -> dict:
await _ensure_post_data(slug) await _ensure_post_data(slug)
from shared.sx.helpers import sx_call return {}
return sx_call("blog-admin-placeholder")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -388,40 +391,38 @@ def _obj_summary(obj) -> str:
return str(esc(" \u2022 ".join(ident_parts) if ident_parts else str(obj))) return str(esc(" \u2022 ".join(ident_parts) if ident_parts else str(obj)))
async def _h_post_data_content(slug=None, **kw): async def _h_post_data_data(slug=None, **kw) -> dict:
await _ensure_post_data(slug) await _ensure_post_data(slug)
from quart import g from quart import g
from shared.sx.helpers import sx_call
original_post = getattr(g, "post_data", {}).get("original_post") original_post = getattr(g, "post_data", {}).get("original_post")
if original_post is None: if original_post is None:
return sx_call("blog-data-table-content") return {"tablename": None, "model-data": None}
tablename = getattr(original_post, "__tablename__", "?") tablename = getattr(original_post, "__tablename__", "?")
model_data = _extract_model_data(original_post, 0, 2) model_data = _extract_model_data(original_post, 0, 2)
return sx_call("blog-data-table-content", return {"tablename": tablename, "model-data": model_data}
tablename=tablename, model_data=model_data)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Preview content # Preview content
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _h_post_preview_content(slug=None, **kw): async def _h_post_preview_data(slug=None, **kw) -> dict:
await _ensure_post_data(slug) await _ensure_post_data(slug)
from quart import g from quart import g
from shared.services.registry import services from shared.services.registry import services
from shared.sx.helpers import sx_call from shared.sx.helpers import SxExpr
from shared.sx.parser import SxExpr
preview = await services.blog_page.preview_data(g.s) preview = await services.blog_page.preview_data(g.s)
return sx_call("blog-preview-content", return {
sx_pretty=SxExpr(preview["sx_pretty"]) if preview.get("sx_pretty") else None, "sx-pretty": SxExpr(preview["sx_pretty"]) if preview.get("sx_pretty") else None,
json_pretty=SxExpr(preview["json_pretty"]) if preview.get("json_pretty") else None, "json-pretty": SxExpr(preview["json_pretty"]) if preview.get("json_pretty") else None,
sx_rendered=preview.get("sx_rendered") or None, "sx-rendered": preview.get("sx_rendered") or None,
lex_rendered=preview.get("lex_rendered") or None) "lex-rendered": preview.get("lex_rendered") or None,
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -493,13 +494,11 @@ def _extract_calendar_browser_data(all_calendars, post_slug: str) -> list:
return calendars return calendars
async def _h_post_entries_content(slug=None, **kw): async def _h_post_entries_data(slug=None, **kw) -> dict:
await _ensure_post_data(slug) await _ensure_post_data(slug)
from quart import g from quart import g
from sqlalchemy import select from sqlalchemy import select
from shared.models.calendars import Calendar from shared.models.calendars import Calendar
from shared.sx.helpers import sx_call
from shared.sx.parser import SxExpr
from shared.browser.app.csrf import generate_csrf_token from shared.browser.app.csrf import generate_csrf_token
from bp.post.services.entry_associations import get_post_entry_ids from bp.post.services.entry_associations import get_post_entry_ids
@@ -516,30 +515,24 @@ async def _h_post_entries_content(slug=None, **kw):
await g.s.refresh(calendar, ["entries", "post"]) await g.s.refresh(calendar, ["entries", "post"])
csrf = generate_csrf_token() csrf = generate_csrf_token()
entry_data = _extract_associated_entries_data( entries = _extract_associated_entries_data(
all_calendars, associated_entry_ids, post_slug) all_calendars, associated_entry_ids, post_slug)
calendar_data = _extract_calendar_browser_data(all_calendars, post_slug) calendars = _extract_calendar_browser_data(all_calendars, post_slug)
entries_panel = sx_call("blog-associated-entries-from-data", return {"entries": entries, "calendars": calendars, "csrf": csrf}
entries=entry_data, csrf=csrf)
return sx_call("blog-entries-browser-content",
entries_panel=SxExpr(entries_panel),
calendars=calendar_data)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Settings form # Settings form
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _h_post_settings_content(slug=None, **kw): async def _h_post_settings_data(slug=None, **kw) -> dict:
await _ensure_post_data(slug) await _ensure_post_data(slug)
from quart import g, request from quart import g, request
from models.ghost_content import Post from models.ghost_content import Post
from sqlalchemy import select as sa_select from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from shared.browser.app.csrf import generate_csrf_token from shared.browser.app.csrf import generate_csrf_token
from shared.sx.helpers import sx_call
from bp.post.admin.routes import _post_to_edit_dict from bp.post.admin.routes import _post_to_edit_dict
post_id = g.post_data["post"]["id"] post_id = g.post_data["post"]["id"]
@@ -570,28 +563,29 @@ async def _h_post_settings_content(slug=None, **kw):
pub_at = gp.get("published_at") or "" pub_at = gp.get("published_at") or ""
pub_at_val = pub_at[:16] if pub_at else "" pub_at_val = pub_at[:16] if pub_at else ""
return sx_call("blog-settings-form-content", return {
csrf=csrf, "csrf": csrf,
updated_at=gp.get("updated_at") or "", "updated-at": gp.get("updated_at") or "",
is_page=is_page, "is-page": is_page,
save_success=save_success, "save-success": save_success,
slug=gp.get("slug") or "", "settings-slug": gp.get("slug") or "",
published_at=pub_at_val, "published-at": pub_at_val,
featured=bool(gp.get("featured")), "featured": bool(gp.get("featured")),
visibility=gp.get("visibility") or "public", "visibility": gp.get("visibility") or "public",
email_only=bool(gp.get("email_only")), "email-only": bool(gp.get("email_only")),
tags=tag_names, "tags": tag_names,
feature_image_alt=gp.get("feature_image_alt") or "", "feature-image-alt": gp.get("feature_image_alt") or "",
meta_title=gp.get("meta_title") or "", "meta-title": gp.get("meta_title") or "",
meta_description=gp.get("meta_description") or "", "meta-description": gp.get("meta_description") or "",
canonical_url=gp.get("canonical_url") or "", "canonical-url": gp.get("canonical_url") or "",
og_title=gp.get("og_title") or "", "og-title": gp.get("og_title") or "",
og_description=gp.get("og_description") or "", "og-description": gp.get("og_description") or "",
og_image=gp.get("og_image") or "", "og-image": gp.get("og_image") or "",
twitter_title=gp.get("twitter_title") or "", "twitter-title": gp.get("twitter_title") or "",
twitter_description=gp.get("twitter_description") or "", "twitter-description": gp.get("twitter_description") or "",
twitter_image=gp.get("twitter_image") or "", "twitter-image": gp.get("twitter_image") or "",
custom_template=gp.get("custom_template") or "") "custom-template": gp.get("custom_template") or "",
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -629,7 +623,7 @@ def _extract_footer_badges(ghost_post: dict, post: dict, save_success: bool,
return badges return badges
async def _h_post_edit_content(slug=None, **kw): async def _h_post_edit_data(slug=None, **kw) -> dict:
await _ensure_post_data(slug) await _ensure_post_data(slug)
from quart import g, request as qrequest from quart import g, request as qrequest
from models.ghost_content import Post from models.ghost_content import Post
@@ -637,8 +631,6 @@ async def _h_post_edit_content(slug=None, **kw):
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from shared.infrastructure.data_client import fetch_data from shared.infrastructure.data_client import fetch_data
from shared.browser.app.csrf import generate_csrf_token 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 from bp.post.admin.routes import _post_to_edit_dict
post_id = g.post_data["post"]["id"] post_id = g.post_data["post"]["id"]
@@ -678,36 +670,34 @@ async def _h_post_edit_content(slug=None, **kw):
title_placeholder = "Page title..." if is_page else "Post title..." title_placeholder = "Page title..." if is_page else "Post title..."
# Newsletter options as SX fragment # Return newsletter data as list of dicts (composed in SX)
nl_parts = ['(option :value "" "Select newsletter\u2026")'] nl_options = _extract_newsletter_options(newsletters)
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 # Return footer badge data as list of dicts (composed in SX)
publish_requested = bool(qrequest.args.get("publish_requested")) if hasattr(qrequest, 'args') else False publish_requested = bool(qrequest.args.get("publish_requested")) if hasattr(qrequest, 'args') else False
badges = _extract_footer_badges(ghost_post, post, save_success, badges = _extract_footer_badges(ghost_post, post, save_success,
publish_requested, already_emailed) publish_requested, already_emailed)
if badges:
badge_parts = [f'(span :class "{b["cls"]}" {sx_serialize(b["text"])})'
for b in badges]
footer_extra_sx = SxExpr("(<> " + " ".join(badge_parts) + ")")
else:
footer_extra_sx = None
init_js = _editor_init_js(urls, form_id="post-edit-form", has_initial_json=True) init_js = _editor_init_js(urls, form_id="post-edit-form", has_initial_json=True)
return sx_call("blog-edit-content", return {
csrf=csrf, updated_at=str(updated_at), "csrf": csrf,
title_val=title_val, excerpt_val=excerpt_val, "updated-at": str(updated_at),
feature_image=feature_image, "title-val": title_val,
feature_image_caption=feature_image_caption, "excerpt-val": excerpt_val,
sx_content_val=sx_content, lexical_json=lexical_json, "feature-image": feature_image,
has_sx=has_sx, title_placeholder=title_placeholder, "feature-image-caption": feature_image_caption,
status=status, already_emailed=already_emailed, "sx-content-val": sx_content,
newsletter_options=nl_opts_sx, footer_extra=footer_extra_sx, "lexical-json": lexical_json,
css_href=urls["css_href"], js_src=urls["js_src"], "has-sx": has_sx,
sx_editor_js_src=urls["sx_editor_js_src"], "title-placeholder": title_placeholder,
init_js=init_js, save_error=save_error or None) "status": status,
"already-emailed": already_emailed,
"newsletters": nl_options,
"badges": badges,
"css-href": urls["css_href"],
"js-src": urls["js_src"],
"sx-editor-js-src": urls["sx_editor_js_src"],
"init-js": init_js,
"save-error": save_error or None,
}