From 15602070973cf51e758e39cc58c2ca12c23f6be6 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 5 Mar 2026 02:03:51 +0000 Subject: [PATCH] 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 --- blog/sxc/pages/blog.sx | 61 +++++++++-- blog/sxc/pages/helpers.py | 216 ++++++++++++++++++-------------------- 2 files changed, 156 insertions(+), 121 deletions(-) diff --git a/blog/sxc/pages/blog.sx b/blog/sxc/pages/blog.sx index b3998ae..2c01e8c 100644 --- a/blog/sxc/pages/blog.sx +++ b/blog/sxc/pages/blog.sx @@ -1,5 +1,6 @@ ; Blog app defpage declarations ; 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 --- @@ -7,13 +8,23 @@ :path "/new/" :auth :admin :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 :path "/new-page/" :auth :admin :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 //admin/) --- @@ -21,37 +32,71 @@ :path "//admin/" :auth :admin :layout (:post-admin :selected "admin") - :content (post-admin-content slug)) + :data (post-admin-data slug) + :content (~blog-admin-placeholder)) (defpage post-data :path "//admin/data/" :auth :admin :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 :path "//admin/preview/" :auth :admin :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 :path "//admin/entries/" :auth :admin :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 :path "//admin/settings/" :auth :post_author :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 :path "//admin/edit/" :auth :post_author :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) --- diff --git a/blog/sxc/pages/helpers.py b/blog/sxc/pages/helpers.py index a89b493..b8ef82e 100644 --- a/blog/sxc/pages/helpers.py +++ b/blog/sxc/pages/helpers.py @@ -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 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: @@ -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: 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-data": _h_editor_data, + "editor-page-data": _h_editor_page_data, + "post-admin-data": _h_post_admin_data, + "post-data-data": _h_post_data_data, + "post-preview-data": _h_post_preview_data, + "post-entries-data": _h_post_entries_data, + "post-settings-data": _h_post_settings_data, + "post-edit-data": _h_post_edit_data, }) @@ -264,52 +268,51 @@ def _editor_urls() -> dict: } -def _h_editor_content(**kw): - """New post editor panel.""" - from shared.sx.helpers import sx_call +def _h_editor_data(**kw) -> dict: + """New post editor — return data for ~blog-editor-content.""" from shared.browser.app.csrf import generate_csrf_token urls = _editor_urls() csrf = generate_csrf_token() init_js = _editor_init_js(urls, form_id="post-new-form", has_initial_json=False) - return sx_call("blog-editor-content", - csrf=csrf, - title_placeholder="Post title...", - create_label="Create Post", - css_href=urls["css_href"], - js_src=urls["js_src"], - sx_editor_js_src=urls["sx_editor_js_src"], - init_js=init_js) + return { + "csrf": csrf, + "title-placeholder": "Post title...", + "create-label": "Create Post", + "css-href": urls["css_href"], + "js-src": urls["js_src"], + "sx-editor-js-src": urls["sx_editor_js_src"], + "init-js": init_js, + } -def _h_editor_page_content(**kw): - """New page editor panel.""" - from shared.sx.helpers import sx_call +def _h_editor_page_data(**kw) -> dict: + """New page editor — return data for ~blog-editor-content.""" from shared.browser.app.csrf import generate_csrf_token urls = _editor_urls() csrf = generate_csrf_token() init_js = _editor_init_js(urls, form_id="post-new-form", has_initial_json=False) - return sx_call("blog-editor-content", - csrf=csrf, - title_placeholder="Page title...", - create_label="Create Page", - css_href=urls["css_href"], - js_src=urls["js_src"], - sx_editor_js_src=urls["sx_editor_js_src"], - init_js=init_js) + return { + "csrf": csrf, + "title-placeholder": "Page title...", + "create-label": "Create Page", + "css-href": urls["css_href"], + "js-src": urls["js_src"], + "sx-editor-js-src": urls["sx_editor_js_src"], + "init-js": init_js, + } # --------------------------------------------------------------------------- # 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) - from shared.sx.helpers import sx_call - return sx_call("blog-admin-placeholder") + return {} # --------------------------------------------------------------------------- @@ -388,40 +391,38 @@ def _obj_summary(obj) -> str: 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) from quart import g - from shared.sx.helpers import sx_call original_post = getattr(g, "post_data", {}).get("original_post") if original_post is None: - return sx_call("blog-data-table-content") + return {"tablename": None, "model-data": None} tablename = getattr(original_post, "__tablename__", "?") model_data = _extract_model_data(original_post, 0, 2) - return sx_call("blog-data-table-content", - tablename=tablename, model_data=model_data) + return {"tablename": tablename, "model-data": model_data} # --------------------------------------------------------------------------- # 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) from quart import g from shared.services.registry import services - from shared.sx.helpers import sx_call - from shared.sx.parser import SxExpr + from shared.sx.helpers import SxExpr preview = await services.blog_page.preview_data(g.s) - return sx_call("blog-preview-content", - 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, - sx_rendered=preview.get("sx_rendered") or None, - lex_rendered=preview.get("lex_rendered") or None) + return { + "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, + "sx-rendered": preview.get("sx_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 -async def _h_post_entries_content(slug=None, **kw): +async def _h_post_entries_data(slug=None, **kw) -> dict: await _ensure_post_data(slug) from quart import g from sqlalchemy import select 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 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"]) csrf = generate_csrf_token() - entry_data = _extract_associated_entries_data( + entries = _extract_associated_entries_data( 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", - entries=entry_data, csrf=csrf) - - return sx_call("blog-entries-browser-content", - entries_panel=SxExpr(entries_panel), - calendars=calendar_data) + return {"entries": entries, "calendars": calendars, "csrf": csrf} # --------------------------------------------------------------------------- # 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) from quart import g, request 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 shared.sx.helpers import sx_call from bp.post.admin.routes import _post_to_edit_dict 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_val = pub_at[:16] if pub_at else "" - return sx_call("blog-settings-form-content", - csrf=csrf, - updated_at=gp.get("updated_at") or "", - is_page=is_page, - save_success=save_success, - slug=gp.get("slug") or "", - published_at=pub_at_val, - featured=bool(gp.get("featured")), - visibility=gp.get("visibility") or "public", - email_only=bool(gp.get("email_only")), - tags=tag_names, - feature_image_alt=gp.get("feature_image_alt") or "", - meta_title=gp.get("meta_title") or "", - meta_description=gp.get("meta_description") or "", - canonical_url=gp.get("canonical_url") or "", - og_title=gp.get("og_title") or "", - og_description=gp.get("og_description") or "", - og_image=gp.get("og_image") or "", - twitter_title=gp.get("twitter_title") or "", - twitter_description=gp.get("twitter_description") or "", - twitter_image=gp.get("twitter_image") or "", - custom_template=gp.get("custom_template") or "") + return { + "csrf": csrf, + "updated-at": gp.get("updated_at") or "", + "is-page": is_page, + "save-success": save_success, + "settings-slug": gp.get("slug") or "", + "published-at": pub_at_val, + "featured": bool(gp.get("featured")), + "visibility": gp.get("visibility") or "public", + "email-only": bool(gp.get("email_only")), + "tags": tag_names, + "feature-image-alt": gp.get("feature_image_alt") or "", + "meta-title": gp.get("meta_title") or "", + "meta-description": gp.get("meta_description") or "", + "canonical-url": gp.get("canonical_url") or "", + "og-title": gp.get("og_title") or "", + "og-description": gp.get("og_description") or "", + "og-image": gp.get("og_image") or "", + "twitter-title": gp.get("twitter_title") or "", + "twitter-description": gp.get("twitter_description") or "", + "twitter-image": gp.get("twitter_image") 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 -async def _h_post_edit_content(slug=None, **kw): +async def _h_post_edit_data(slug=None, **kw) -> dict: await _ensure_post_data(slug) from quart import g, request as qrequest 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 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"] @@ -678,36 +670,34 @@ async def _h_post_edit_content(slug=None, **kw): 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) + ")") + # Return newsletter data as list of dicts (composed in SX) + nl_options = _extract_newsletter_options(newsletters) - # 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 badges = _extract_footer_badges(ghost_post, post, save_success, 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) - return sx_call("blog-edit-content", - 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, - 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) + return { + "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, + "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, + }