From 28c66c365005bd7816b91ade647cc9851e561cd0 Mon Sep 17 00:00:00 2001 From: giles Date: Fri, 27 Feb 2026 14:38:51 +0000 Subject: [PATCH] =?UTF-8?q?Wire=20s-expression=20rendering=20into=20live?= =?UTF-8?q?=20app=20=E2=80=94=20blog=20link-card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add setup_sexp_bridge() and load_shared_components() to factory.py so all services get s-expression support automatically - Create shared/sexp/components.py with ~link-card component definition (replaces 5 per-service Jinja link_card.html templates) - Replace blog's link-card fragment handler to use sexp() instead of render_template() — first real s-expression rendered page content Co-Authored-By: Claude Opus 4.6 --- blog/bp/fragments/routes.py | 35 +++++++++++----------- shared/infrastructure/factory.py | 4 +++ shared/sexp/components.py | 51 ++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 17 deletions(-) create mode 100644 shared/sexp/components.py diff --git a/blog/bp/fragments/routes.py b/blog/bp/fragments/routes.py index 1542698..16bfc51 100644 --- a/blog/bp/fragments/routes.py +++ b/blog/bp/fragments/routes.py @@ -10,6 +10,7 @@ from quart import Blueprint, Response, g, render_template, request from shared.infrastructure.fragments import FRAGMENT_HEADER from shared.services.navigation import get_navigation_tree +from shared.sexp.jinja_bridge import sexp def register(): @@ -57,7 +58,21 @@ def register(): _handlers["nav-tree"] = _nav_tree_handler - # --- link-card fragment --- + # --- link-card fragment (s-expression rendered) --- + def _render_blog_link_card(post, link: str) -> str: + """Render a blog link-card via the ~link-card s-expression component.""" + published = post.published_at.strftime("%d %b %Y") if post.published_at else None + return sexp( + '(~link-card :link link :title title :image image' + ' :icon "fas fa-file-alt" :subtitle excerpt' + ' :detail published :data-app "blog")', + link=link, + title=post.title, + image=post.feature_image, + excerpt=post.custom_excerpt or post.excerpt, + published=published, + ) + async def _link_card_handler(): from shared.services.registry import services from shared.infrastructure.urls import blog_url @@ -73,14 +88,7 @@ def register(): parts.append(f"") post = await services.blog.get_post_by_slug(g.s, s) if post: - parts.append(await render_template( - "fragments/link_card.html", - title=post.title, - feature_image=post.feature_image, - excerpt=post.custom_excerpt or post.excerpt, - published_at=post.published_at, - link=blog_url(f"/{post.slug}"), - )) + parts.append(_render_blog_link_card(post, blog_url(f"/{post.slug}"))) return "\n".join(parts) # Single mode @@ -89,14 +97,7 @@ def register(): post = await services.blog.get_post_by_slug(g.s, slug) if not post: return "" - return await render_template( - "fragments/link_card.html", - title=post.title, - feature_image=post.feature_image, - excerpt=post.custom_excerpt or post.excerpt, - published_at=post.published_at, - link=blog_url(f"/{post.slug}"), - ) + return _render_blog_link_card(post, blog_url(f"/{post.slug}")) _handlers["link-card"] = _link_card_handler diff --git a/shared/infrastructure/factory.py b/shared/infrastructure/factory.py index aa23814..556c7b9 100644 --- a/shared/infrastructure/factory.py +++ b/shared/infrastructure/factory.py @@ -28,6 +28,8 @@ from shared.browser.app.errors import errors from .jinja_setup import setup_jinja from .user_loader import load_current_user +from shared.sexp.jinja_bridge import setup_sexp_bridge +from shared.sexp.components import load_shared_components # Async init of config (runs once at import) @@ -104,6 +106,8 @@ def create_base_app( register_db(app) register_redis(app) setup_jinja(app) + setup_sexp_bridge(app) + load_shared_components() errors(app) # Auto-register OAuth client blueprint for non-account apps diff --git a/shared/sexp/components.py b/shared/sexp/components.py new file mode 100644 index 0000000..d4acfec --- /dev/null +++ b/shared/sexp/components.py @@ -0,0 +1,51 @@ +""" +Shared s-expression component definitions. + +Loaded at app startup via ``load_shared_components()``. Each component +replaces a per-service Jinja fragment template with a single reusable +s-expression definition. +""" + +from __future__ import annotations + +from .jinja_bridge import register_components + + +def load_shared_components() -> None: + """Register all shared s-expression components.""" + register_components(_LINK_CARD) + + +# --------------------------------------------------------------------------- +# ~link-card +# --------------------------------------------------------------------------- +# Replaces: blog/templates/fragments/link_card.html +# market/templates/fragments/link_card.html +# events/templates/fragments/link_card.html +# federation/templates/fragments/link_card.html +# artdag/l1/app/templates/fragments/link_card.html +# +# Usage: +# sexp('(~link-card :link "/post/apple/" :title "Apple" :image "/img/a.jpg")') +# sexp('(~link-card :link url :title title :icon "fas fa-file-alt")', **ctx) +# --------------------------------------------------------------------------- + +_LINK_CARD = ''' +(defcomp ~link-card (&key link title image icon subtitle detail data-app) + (a :href link + :class "block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline" + :data-fragment "link-card" + :data-app data-app + :data-hx-disable true + (div :class "flex flex-row items-start gap-3 p-3" + (if image + (img :src image :alt "" :class "flex-shrink-0 w-16 h-16 rounded object-cover") + (div :class "flex-shrink-0 w-16 h-16 rounded bg-stone-100 flex items-center justify-center text-stone-400" + (i :class icon))) + (div :class "flex-1 min-w-0" + (div :class "font-medium text-stone-900 text-sm clamp-2" title) + (when subtitle + (div :class "text-xs text-stone-500 mt-0.5" subtitle)) + (when detail + (div :class "text-xs text-stone-400 mt-1" detail)))))) +'''