From d7f9afff8e09139cb7e3a4ef97ea5deb429d3f08 Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 4 Mar 2026 09:24:55 +0000 Subject: [PATCH] Move home/post detail/like rendering from Python to .sx defcomps - Home page: inline shared helpers, render_to_sx("blog-home-main") - Post detail: new ~blog-post-detail-content defcomp with data from service - Like toggle: call render_to_sx("market-like-toggle-button") directly - Add post_meta_data() and post_detail_data() to BlogPageService Co-Authored-By: Claude Opus 4.6 --- blog/bp/blog/routes.py | 29 +++++++++++++-- blog/bp/post/routes.py | 56 ++++++++++++++++++++++++----- blog/services/blog_page.py | 72 ++++++++++++++++++++++++++++++++++++++ blog/sx/detail.sx | 31 ++++++++++++++++ 4 files changed, 177 insertions(+), 11 deletions(-) diff --git a/blog/bp/blog/routes.py b/blog/bp/blog/routes.py index 7d25001..eba5294 100644 --- a/blog/bp/blog/routes.py +++ b/blog/bp/blog/routes.py @@ -118,15 +118,38 @@ def register(url_prefix, title): ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total) from shared.sx.page import get_template_context - from sx.sx_components import render_home_page, render_home_oob + from shared.sx.helpers import ( + render_to_sx, root_header_sx, full_page_sx, oob_page_sx, + post_header_sx, oob_header_sx, mobile_menu_sx, + post_mobile_nav_sx, mobile_root_nav_sx, + ) + from shared.sx.parser import SxExpr + from shared.services.registry import services tctx = await get_template_context() tctx.update(ctx) + + post = ctx.get("post", {}) + content = await render_to_sx("blog-home-main", + html_content=post.get("html", ""), + sx_content=SxExpr(post.get("sx_content", "")) if post.get("sx_content") else None) + meta_data = services.get("blog_page").post_meta_data(post, ctx.get("base_title", "")) + meta = await render_to_sx("blog-meta", **meta_data) + if not is_htmx_request(): - html = await render_home_page(tctx) + root_hdr = await root_header_sx(tctx) + post_hdr = await post_header_sx(tctx) + header_rows = "(<> " + root_hdr + " " + post_hdr + ")" + menu = mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx)) + html = await full_page_sx(tctx, header_rows=header_rows, content=content, + meta=meta, menu=menu) return await make_response(html) else: - sx_src = await render_home_oob(tctx) + root_hdr = await root_header_sx(tctx) + post_hdr = await post_header_sx(tctx) + rows = "(<> " + root_hdr + " " + post_hdr + ")" + header_oob = await oob_header_sx("root-header-child", "post-header-child", rows) + sx_src = await oob_page_sx(oobs=header_oob, content=content) return sx_response(sx_src) @blogs_bp.get("/index") diff --git a/blog/bp/post/routes.py b/blog/bp/post/routes.py index 1ea2dfa..92ab4d5 100644 --- a/blog/bp/post/routes.py +++ b/blog/bp/post/routes.py @@ -105,27 +105,68 @@ def register(): @cache_page(tag="post.post_detail") async def post_detail(slug: str): from shared.sx.page import get_template_context - from sx.sx_components import render_post_page, render_post_oob + from shared.sx.helpers import ( + render_to_sx, root_header_sx, full_page_sx, oob_page_sx, + post_header_sx, oob_header_sx, mobile_menu_sx, + post_mobile_nav_sx, mobile_root_nav_sx, + ) + from shared.services.registry import services + from shared.browser.app.csrf import generate_csrf_token + from shared.utils import host_url tctx = await get_template_context() + + # Render post content via .sx defcomp + post = tctx.get("post") or {} + user = getattr(g, "user", None) + rights = tctx.get("rights") or {} + blog_url_base = host_url(url_for("blog.index")).rstrip("/index").rstrip("/") + csrf = generate_csrf_token() + svc = services.get("blog_page") + detail_data = svc.post_detail_data(post, user, rights, csrf, blog_url_base) + content = await render_to_sx("blog-post-detail-content", **detail_data) + meta_data = svc.post_meta_data(post, tctx.get("base_title", "")) + meta = await render_to_sx("blog-meta", **meta_data) + if not is_htmx_request(): - html = await render_post_page(tctx) + root_hdr = await root_header_sx(tctx) + post_hdr = await post_header_sx(tctx) + header_rows = "(<> " + root_hdr + " " + post_hdr + ")" + menu = mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx)) + html = await full_page_sx(tctx, header_rows=header_rows, content=content, + meta=meta, menu=menu) return await make_response(html) else: - sx_src = await render_post_oob(tctx) + root_hdr = await root_header_sx(tctx) + post_hdr = await post_header_sx(tctx) + rows = "(<> " + root_hdr + " " + post_hdr + ")" + header_oob = await oob_header_sx("root-header-child", "post-header-child", rows) + sx_src = await oob_page_sx(oobs=header_oob, content=content, menu= + mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx))) return sx_response(sx_src) @bp.post("/like/toggle/") @clear_cache(tag="post.post_detail", tag_scope="user") async def like_toggle(slug: str): from shared.utils import host_url - from sx.sx_components import render_like_toggle_button + from shared.sx.helpers import render_to_sx + from shared.browser.app.csrf import generate_csrf_token like_url = host_url(url_for('blog.post.like_toggle', slug=slug)) + csrf = generate_csrf_token() + + async def _like_btn(liked): + if liked: + colour, icon, label = "text-red-600", "fa-solid fa-heart", "Unlike this post" + else: + colour, icon, label = "text-stone-300", "fa-regular fa-heart", "Like this post" + return await render_to_sx("market-like-toggle-button", + colour=colour, action=like_url, + hx_headers=f'{{"X-CSRFToken": "{csrf}"}}', + label=label, icon_cls=icon) - # Get post_id from g.post_data if not g.user: - return sx_response(await render_like_toggle_button(slug, False, like_url), status=403) + return sx_response(await _like_btn(False), status=403) post_id = g.post_data["post"]["id"] user_id = g.user.id @@ -133,9 +174,8 @@ def register(): result = await call_action("likes", "toggle", payload={ "user_id": user_id, "target_type": "post", "target_id": post_id, }) - liked = result["liked"] - return sx_response(await render_like_toggle_button(slug, liked, like_url)) + return sx_response(await _like_btn(result["liked"])) @bp.get("/w//") async def widget_paginate(slug: str, widget_domain: str): diff --git a/blog/services/blog_page.py b/blog/services/blog_page.py index 247c6ee..7595a17 100644 --- a/blog/services/blog_page.py +++ b/blog/services/blog_page.py @@ -355,6 +355,78 @@ class BlogPageService: "sumup_configured": sumup_configured, } + def post_meta_data(self, post, base_title): + """Compute SEO meta tag values from post dict.""" + import re + from quart import request as req + + is_public = post.get("visibility") == "public" + is_published = post.get("status") == "published" + email_only = post.get("email_only", False) + robots = "index,follow" if (is_public and is_published and not email_only) else "noindex,nofollow" + + desc = (post.get("meta_description") or post.get("og_description") or + post.get("twitter_description") or post.get("custom_excerpt") or + post.get("excerpt") or "") + if not desc and post.get("html"): + desc = re.sub(r'<[^>]+>', '', post["html"]) + desc = desc.replace("\n", " ").replace("\r", " ").strip()[:160] + + image = (post.get("og_image") or post.get("twitter_image") or post.get("feature_image") or "") + canonical = post.get("canonical_url") or (req.url if req else "") + + post_title = post.get("meta_title") or post.get("title") or "" + page_title = f"{post_title} \u2014 {base_title}" if post_title else base_title + og_title = post.get("og_title") or page_title + tw_title = post.get("twitter_title") or page_title + is_article = not post.get("is_page") + + return { + "robots": robots, "page_title": page_title, "desc": desc, + "canonical": canonical, + "og_type": "article" if is_article else "website", + "og_title": og_title, "image": image, + "twitter_card": "summary_large_image" if image else "summary", + "twitter_title": tw_title, + } + + def post_detail_data(self, post, user, rights, csrf, blog_url_base): + """Serialize post detail view data for ~blog-post-detail-content defcomp.""" + slug = post.get("slug", "") + is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False) + user_id = getattr(user, "id", None) if user else None + + # Tags and authors + tags = [] + for t in (post.get("tags") or []): + name = t.get("name") or getattr(t, "name", "") + fi = t.get("feature_image") or getattr(t, "feature_image", None) + tags.append({"name": name, "src": fi or "", "initial": name[:1] if name else ""}) + authors = [] + for a in (post.get("authors") or []): + name = a.get("name") or getattr(a, "name", "") + img = a.get("profile_image") or getattr(a, "profile_image", None) + authors.append({"name": name, "image": img or ""}) + + return { + "slug": slug, + "is_draft": post.get("status") == "draft", + "publish_requested": post.get("publish_requested", False), + "can_edit": is_admin or (user_id is not None and post.get("user_id") == user_id), + "edit_href": f"{blog_url_base}/{slug}/admin/edit/", + "is_page": bool(post.get("is_page")), + "has_user": bool(user), + "liked": post.get("is_liked", False), + "like_url": f"{blog_url_base}/{slug}/like/toggle/", + "csrf": csrf, + "custom_excerpt": post.get("custom_excerpt") or "", + "tags": tags, + "authors": authors, + "feature_image": post.get("feature_image"), + "html_content": post.get("html", ""), + "sx_content": post.get("sx_content", ""), + } + async def preview_data(self, session, *, slug=None, **kw): """Build preview data with prettified/rendered content.""" from quart import g diff --git a/blog/sx/detail.sx b/blog/sx/detail.sx index 3ce5eb3..c7ab534 100644 --- a/blog/sx/detail.sx +++ b/blog/sx/detail.sx @@ -36,6 +36,37 @@ (when html-content (div :class "blog-content p-2" (~rich-text :html html-content))))) (div :class "pb-8"))) +;; --------------------------------------------------------------------------- +;; Data-driven composition — replaces _post_main_panel_sx +;; --------------------------------------------------------------------------- + +(defcomp ~blog-post-detail-content (&key slug is-draft publish-requested can-edit edit-href + is-page has-user liked like-url csrf + custom-excerpt tags authors + feature-image html-content sx-content) + (let* ((hx-select "#main-panel") + (draft-sx (when is-draft + (~blog-detail-draft + :publish-requested publish-requested + :edit (when can-edit + (~blog-detail-edit-link :href edit-href :hx-select hx-select))))) + (chrome-sx (when (not is-page) + (~blog-detail-chrome + :like (when has-user + (~blog-detail-like + :like-url like-url + :hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}") + :heart (if liked "\u2764\ufe0f" "\U0001f90d"))) + :excerpt (when (not (= custom-excerpt "")) + (~blog-detail-excerpt :excerpt custom-excerpt)) + :at-bar (~blog-at-bar :tags tags :authors authors))))) + (~blog-detail-main + :draft draft-sx + :chrome chrome-sx + :feature-image feature-image + :html-content html-content + :sx-content sx-content))) + (defcomp ~blog-meta (&key robots page-title desc canonical og-type og-title image twitter-card twitter-title) (<> (meta :name "robots" :content robots)