From b3d853ad35769d8a7603b08119cf78119320f3b0 Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 24 Feb 2026 21:44:11 +0000 Subject: [PATCH] Add Phase 5: link-card fragments, oEmbed endpoints, OG meta - fetch_fragment_batch() for N+1 avoidance with per-key Redis cache - link-card fragment handlers in blog, market, events, federation (single + batch mode) - link_card.html templates per app with content-specific previews - shared/infrastructure/oembed.py: build_oembed_response, build_og_meta, build_oembed_link_tag - GET /oembed routes on blog, market, events - og_meta + oembed_link rendering in base template - INTERNAL_URL_ARTDAG in docker-compose.yml for cross-stack fragment fetches Co-Authored-By: Claude Opus 4.6 --- blog/app.py | 30 +++++ blog/bp/fragments/routes.py | 43 +++++++ blog/templates/fragments/link_card.html | 20 ++++ docker-compose.yml | 1 + events/app.py | 29 +++++ events/bp/fragments/routes.py | 49 ++++++++ events/templates/fragments/link_card.html | 17 +++ federation/bp/fragments/routes.py | 47 ++++++++ federation/templates/fragments/link_card.html | 18 +++ market/app.py | 38 ++++++ market/bp/fragments/routes.py | 53 +++++++++ market/templates/fragments/link_card.html | 28 +++++ .../browser/templates/_types/root/index.html | 9 ++ shared/infrastructure/fragments.py | 109 +++++++++++++++++ shared/infrastructure/oembed.py | 110 ++++++++++++++++++ 15 files changed, 601 insertions(+) create mode 100644 blog/templates/fragments/link_card.html create mode 100644 events/templates/fragments/link_card.html create mode 100644 federation/templates/fragments/link_card.html create mode 100644 market/templates/fragments/link_card.html create mode 100644 shared/infrastructure/oembed.py diff --git a/blog/app.py b/blog/app.py index e59895f..ff3c753 100644 --- a/blog/app.py +++ b/blog/app.py @@ -119,6 +119,36 @@ def create_app() -> "Quart": obj.value = val return {"ok": True, "key": key, "value": val} + # --- oEmbed endpoint --- + @app.get("/oembed") + async def oembed(): + from urllib.parse import urlparse + from quart import jsonify + from shared.services.registry import services + from shared.infrastructure.urls import blog_url + from shared.infrastructure.oembed import build_oembed_response + + url = request.args.get("url", "") + if not url: + return jsonify({"error": "url parameter required"}), 400 + + parsed = urlparse(url) + slug = parsed.path.strip("/").split("/")[-1] if parsed.path else "" + if not slug: + return jsonify({"error": "could not extract slug"}), 404 + + post = await services.blog.get_post_by_slug(g.s, slug) + if not post: + return jsonify({"error": "not found"}), 404 + + resp = build_oembed_response( + title=post.title, + oembed_type="link", + thumbnail_url=post.feature_image, + url=blog_url(f"/{post.slug}"), + ) + return jsonify(resp) + # --- debug: url rules --- @app.get("/__rules") async def dump_rules(): diff --git a/blog/bp/fragments/routes.py b/blog/bp/fragments/routes.py index 07d6e67..86ac36a 100644 --- a/blog/bp/fragments/routes.py +++ b/blog/bp/fragments/routes.py @@ -46,6 +46,49 @@ def register(): _handlers["nav-tree"] = _nav_tree_handler + # --- link-card fragment --- + async def _link_card_handler(): + from shared.services.registry import services + from shared.infrastructure.urls import blog_url + + slug = request.args.get("slug", "") + keys_raw = request.args.get("keys", "") + + # Batch mode + if keys_raw: + slugs = [k.strip() for k in keys_raw.split(",") if k.strip()] + parts = [] + for s in slugs: + 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}"), + )) + return "\n".join(parts) + + # Single mode + if not slug: + return "" + 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}"), + ) + + _handlers["link-card"] = _link_card_handler + # Store handlers dict on blueprint so app code can register handlers bp._fragment_handlers = _handlers diff --git a/blog/templates/fragments/link_card.html b/blog/templates/fragments/link_card.html new file mode 100644 index 0000000..cdd2575 --- /dev/null +++ b/blog/templates/fragments/link_card.html @@ -0,0 +1,20 @@ + +
+ {% if feature_image %} + + {% else %} +
+ +
+ {% endif %} +
+
{{ title }}
+ {% if excerpt %} +
{{ excerpt }}
+ {% endif %} + {% if published_at %} +
{{ published_at.strftime('%d %b %Y') }}
+ {% endif %} +
+
+
diff --git a/docker-compose.yml b/docker-compose.yml index cb4b741..01c9a07 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,7 @@ x-app-env: &app-env INTERNAL_URL_EVENTS: http://events:8000 INTERNAL_URL_FEDERATION: http://federation:8000 INTERNAL_URL_ACCOUNT: http://account:8000 + INTERNAL_URL_ARTDAG: https://celery-artdag.rose-ash.com AP_DOMAIN: federation.rose-ash.com AP_DOMAIN_BLOG: blog.rose-ash.com AP_DOMAIN_MARKET: market.rose-ash.com diff --git a/events/app.py b/events/app.py index ac5bfe9..27a420e 100644 --- a/events/app.py +++ b/events/app.py @@ -161,6 +161,35 @@ def create_app() -> "Quart": from bp.ticket_admin.routes import register as register_ticket_admin app.register_blueprint(register_ticket_admin()) + # --- oEmbed endpoint --- + @app.get("/oembed") + async def oembed(): + from urllib.parse import urlparse + from quart import jsonify + from shared.infrastructure.urls import events_url + from shared.infrastructure.oembed import build_oembed_response + + url = request.args.get("url", "") + if not url: + return jsonify({"error": "url parameter required"}), 400 + + parsed = urlparse(url) + slug = parsed.path.strip("/").split("/")[0] if parsed.path.strip("/") else "" + if not slug: + return jsonify({"error": "could not extract slug"}), 404 + + post = await services.blog.get_post_by_slug(g.s, slug) + if not post: + return jsonify({"error": "not found"}), 404 + + resp = build_oembed_response( + title=post.title, + oembed_type="link", + thumbnail_url=post.feature_image, + url=events_url(f"/{post.slug}"), + ) + return jsonify(resp) + return app diff --git a/events/bp/fragments/routes.py b/events/bp/fragments/routes.py index 293398a..db038c1 100644 --- a/events/bp/fragments/routes.py +++ b/events/bp/fragments/routes.py @@ -125,6 +125,55 @@ def register(): _handlers["account-page"] = _account_page_handler + # --- link-card fragment: event page preview card ---------------------------- + + async def _link_card_handler(): + from shared.infrastructure.urls import events_url + + slug = request.args.get("slug", "") + keys_raw = request.args.get("keys", "") + + # Batch mode + if keys_raw: + slugs = [k.strip() for k in keys_raw.split(",") if k.strip()] + parts = [] + for s in slugs: + parts.append(f"") + post = await services.blog.get_post_by_slug(g.s, s) + if post: + calendars = await services.calendar.calendars_for_container( + g.s, "page", post.id, + ) + cal_names = ", ".join(c.name for c in calendars) if calendars else "" + parts.append(await render_template( + "fragments/link_card.html", + title=post.title, + feature_image=post.feature_image, + calendar_names=cal_names, + link=events_url(f"/{post.slug}"), + )) + return "\n".join(parts) + + # Single mode + if not slug: + return "" + post = await services.blog.get_post_by_slug(g.s, slug) + if not post: + return "" + calendars = await services.calendar.calendars_for_container( + g.s, "page", post.id, + ) + cal_names = ", ".join(c.name for c in calendars) if calendars else "" + return await render_template( + "fragments/link_card.html", + title=post.title, + feature_image=post.feature_image, + calendar_names=cal_names, + link=events_url(f"/{post.slug}"), + ) + + _handlers["link-card"] = _link_card_handler + bp._fragment_handlers = _handlers return bp diff --git a/events/templates/fragments/link_card.html b/events/templates/fragments/link_card.html new file mode 100644 index 0000000..9417330 --- /dev/null +++ b/events/templates/fragments/link_card.html @@ -0,0 +1,17 @@ + +
+ {% if feature_image %} + + {% else %} +
+ +
+ {% endif %} +
+
{{ title }}
+ {% if calendar_names %} +
{{ calendar_names }}
+ {% endif %} +
+
+
diff --git a/federation/bp/fragments/routes.py b/federation/bp/fragments/routes.py index d4e20d1..ac9a256 100644 --- a/federation/bp/fragments/routes.py +++ b/federation/bp/fragments/routes.py @@ -29,6 +29,53 @@ def register(): html = await handler() return Response(html, status=200, content_type="text/html") + # --- link-card fragment: actor profile preview card -------------------------- + + async def _link_card_handler(): + from quart import g, render_template + from shared.services.registry import services + from shared.infrastructure.urls import federation_url + + username = request.args.get("username", "") + slug = request.args.get("slug", "") + keys_raw = request.args.get("keys", "") + + # Batch mode + if keys_raw: + usernames = [k.strip() for k in keys_raw.split(",") if k.strip()] + parts = [] + for u in usernames: + parts.append(f"") + actor = await services.federation.get_actor_by_username(g.s, u) + if actor: + parts.append(await render_template( + "fragments/link_card.html", + display_name=actor.display_name, + username=actor.preferred_username, + avatar_url=None, + summary=actor.summary, + link=federation_url(f"/users/{actor.preferred_username}"), + )) + return "\n".join(parts) + + # Single mode + lookup = username or slug + if not lookup: + return "" + actor = await services.federation.get_actor_by_username(g.s, lookup) + if not actor: + return "" + return await render_template( + "fragments/link_card.html", + display_name=actor.display_name, + username=actor.preferred_username, + avatar_url=None, + summary=actor.summary, + link=federation_url(f"/users/{actor.preferred_username}"), + ) + + _handlers["link-card"] = _link_card_handler + bp._fragment_handlers = _handlers return bp diff --git a/federation/templates/fragments/link_card.html b/federation/templates/fragments/link_card.html new file mode 100644 index 0000000..357f819 --- /dev/null +++ b/federation/templates/fragments/link_card.html @@ -0,0 +1,18 @@ + +
+ {% if avatar_url %} + + {% else %} +
+ +
+ {% endif %} +
+
{{ display_name or username }}
+
@{{ username }}
+ {% if summary %} +
{{ summary }}
+ {% endif %} +
+
+
diff --git a/market/app.py b/market/app.py index c551a54..76aef1a 100644 --- a/market/app.py +++ b/market/app.py @@ -195,6 +195,44 @@ def create_app() -> "Quart": return {} return {**post_data} + # --- oEmbed endpoint --- + @app.get("/oembed") + async def oembed(): + from urllib.parse import urlparse + from quart import jsonify + from shared.models.market import Product + from shared.infrastructure.urls import market_url + from shared.infrastructure.oembed import build_oembed_response + + url = request.args.get("url", "") + if not url: + return jsonify({"error": "url parameter required"}), 400 + + parsed = urlparse(url) + # Market product URLs: /...///product// + parts = [p for p in parsed.path.strip("/").split("/") if p] + slug = "" + for i, part in enumerate(parts): + if part == "product" and i + 1 < len(parts): + slug = parts[i + 1] + break + if not slug: + return jsonify({"error": "could not extract product slug"}), 404 + + product = ( + await g.s.execute(select(Product).where(Product.slug == slug)) + ).scalar_one_or_none() + if not product: + return jsonify({"error": "not found"}), 404 + + resp = build_oembed_response( + title=product.title or slug, + oembed_type="link", + thumbnail_url=product.image, + url=market_url(f"/product/{product.slug}/"), + ) + return jsonify(resp) + return app diff --git a/market/bp/fragments/routes.py b/market/bp/fragments/routes.py index bd2bdde..91d5668 100644 --- a/market/bp/fragments/routes.py +++ b/market/bp/fragments/routes.py @@ -49,6 +49,59 @@ def register(): _handlers["container-nav"] = _container_nav_handler + # --- link-card fragment: product preview card -------------------------------- + + async def _link_card_handler(): + from sqlalchemy import select + from shared.models.market import Product + from shared.infrastructure.urls import market_url + + slug = request.args.get("slug", "") + keys_raw = request.args.get("keys", "") + + # Batch mode + if keys_raw: + slugs = [k.strip() for k in keys_raw.split(",") if k.strip()] + parts = [] + for s in slugs: + parts.append(f"") + product = ( + await g.s.execute(select(Product).where(Product.slug == s)) + ).scalar_one_or_none() + if product: + parts.append(await render_template( + "fragments/link_card.html", + title=product.title, + image=product.image, + description_short=product.description_short, + brand=product.brand, + regular_price=product.regular_price, + special_price=product.special_price, + link=market_url(f"/product/{product.slug}/"), + )) + return "\n".join(parts) + + # Single mode + if not slug: + return "" + product = ( + await g.s.execute(select(Product).where(Product.slug == slug)) + ).scalar_one_or_none() + if not product: + return "" + return await render_template( + "fragments/link_card.html", + title=product.title, + image=product.image, + description_short=product.description_short, + brand=product.brand, + regular_price=product.regular_price, + special_price=product.special_price, + link=market_url(f"/product/{product.slug}/"), + ) + + _handlers["link-card"] = _link_card_handler + bp._fragment_handlers = _handlers return bp diff --git a/market/templates/fragments/link_card.html b/market/templates/fragments/link_card.html new file mode 100644 index 0000000..c3e52a4 --- /dev/null +++ b/market/templates/fragments/link_card.html @@ -0,0 +1,28 @@ + +
+ {% if image %} + + {% else %} +
+ +
+ {% endif %} +
+
{{ title }}
+ {% if brand %} +
{{ brand }}
+ {% endif %} + {% if description_short %} +
{{ description_short }}
+ {% endif %} +
+ {% if special_price %} + £{{ "%.2f"|format(special_price) }} + £{{ "%.2f"|format(regular_price) }} + {% elif regular_price %} + £{{ "%.2f"|format(regular_price) }} + {% endif %} +
+
+
+
diff --git a/shared/browser/templates/_types/root/index.html b/shared/browser/templates/_types/root/index.html index 06094f3..d4ea0ef 100644 --- a/shared/browser/templates/_types/root/index.html +++ b/shared/browser/templates/_types/root/index.html @@ -10,6 +10,15 @@ {% include 'social/meta_site.html' %} {% endblock %} + {% if og_meta is defined and og_meta %} + {% for prop, content in og_meta %} + + {% endfor %} + {% endif %} + {% if oembed_link is defined and oembed_link %} + {{ oembed_link }} + {% endif %} + {% include '_types/root/_head.html' %} diff --git a/shared/infrastructure/fragments.py b/shared/infrastructure/fragments.py index 699a539..c078288 100644 --- a/shared/infrastructure/fragments.py +++ b/shared/infrastructure/fragments.py @@ -134,6 +134,115 @@ async def fetch_fragments( ))) +async def fetch_fragment_batch( + app_name: str, + fragment_type: str, + *, + keys: list[str], + params: dict | None = None, + ttl: int = 30, + timeout: float = _DEFAULT_TIMEOUT, + required: bool = True, +) -> dict[str, str]: + """Fetch a batched fragment keyed by multiple identifiers. + + The provider receives *keys* as a comma-separated ``keys`` query param + and returns HTML with ```` comment markers + delimiting each entry. Returns ``dict[key, html]`` with ``""`` for + missing keys. + + Individual results are cached in Redis per key. + """ + if _is_fragment_request() or not keys: + return {k: "" for k in keys} + + redis = _get_redis() + results: dict[str, str] = {} + missing: list[str] = [] + + # Build base suffix from extra params + psuffix = "" + if params: + sorted_items = sorted(params.items()) + psuffix = ":" + "&".join(f"{k}={v}" for k, v in sorted_items) + + # Check Redis for individually cached keys + for key in keys: + cache_key = f"frag:{app_name}:{fragment_type}:{key}{psuffix}" + if redis and ttl > 0: + try: + cached = await redis.get(cache_key) + if cached is not None: + results[key] = cached.decode() if isinstance(cached, bytes) else cached + continue + except Exception: + pass + missing.append(key) + + if not missing: + return results + + # Fetch missing keys in one request + fetch_params = dict(params or {}) + fetch_params["keys"] = ",".join(missing) + + try: + html = await fetch_fragment( + app_name, fragment_type, params=fetch_params, + timeout=timeout, required=required, + ) + except FragmentError: + for key in missing: + results.setdefault(key, "") + if required: + raise + return results + + # Parse response by markers + parsed = _parse_fragment_markers(html, missing) + for key in missing: + value = parsed.get(key, "") + results[key] = value + # Cache individual results + if redis and ttl > 0: + cache_key = f"frag:{app_name}:{fragment_type}:{key}{psuffix}" + try: + await redis.set(cache_key, value.encode(), ex=ttl) + except Exception: + pass + + return results + + +def _parse_fragment_markers(html: str, keys: list[str]) -> dict[str, str]: + """Split batched HTML by ```` comment markers.""" + result: dict[str, str] = {} + marker_prefix = "" + + for i, key in enumerate(keys): + start_marker = f"{marker_prefix}{key}{marker_suffix}" + start_idx = html.find(start_marker) + if start_idx == -1: + result[key] = "" + continue + content_start = start_idx + len(start_marker) + + # Find next marker or end of string + next_marker_idx = len(html) + for other_key in keys: + if other_key == key: + continue + other_marker = f"{marker_prefix}{other_key}{marker_suffix}" + idx = html.find(other_marker, content_start) + if idx != -1 and idx < next_marker_idx: + next_marker_idx = idx + + result[key] = html[content_start:next_marker_idx].strip() + + return result + + async def fetch_fragment_cached( app_name: str, fragment_type: str, diff --git a/shared/infrastructure/oembed.py b/shared/infrastructure/oembed.py new file mode 100644 index 0000000..79a58eb --- /dev/null +++ b/shared/infrastructure/oembed.py @@ -0,0 +1,110 @@ +"""oEmbed response builder and Open Graph meta helpers. + +Provides shared utilities for apps to expose oEmbed endpoints and inject +OG / Twitter Card meta tags into page ```` sections. +""" + +from __future__ import annotations + +from markupsafe import Markup + + +def build_oembed_response( + *, + title: str, + author_name: str = "", + provider_name: str = "rose-ash", + provider_url: str = "https://rose-ash.com", + oembed_type: str = "link", + thumbnail_url: str | None = None, + thumbnail_width: int | None = None, + thumbnail_height: int | None = None, + url: str | None = None, + html: str | None = None, + width: int | None = None, + height: int | None = None, +) -> dict: + """Build an oEmbed JSON response dict (spec v1.0).""" + resp: dict = { + "version": "1.0", + "type": oembed_type, + "title": title, + "provider_name": provider_name, + "provider_url": provider_url, + } + if author_name: + resp["author_name"] = author_name + if url: + resp["url"] = url + if html: + resp["html"] = html + if width is not None: + resp["width"] = width + if height is not None: + resp["height"] = height + if thumbnail_url: + resp["thumbnail_url"] = thumbnail_url + if thumbnail_width is not None: + resp["thumbnail_width"] = thumbnail_width + if thumbnail_height is not None: + resp["thumbnail_height"] = thumbnail_height + return resp + + +def build_oembed_link_tag(oembed_url: str, title: str = "") -> Markup: + """Return ```` for oEmbed discovery.""" + safe_title = Markup.escape(title) + safe_url = Markup.escape(oembed_url) + return Markup( + f'' + ) + + +def build_og_meta( + *, + title: str, + description: str = "", + image: str = "", + url: str = "", + og_type: str = "website", + site_name: str = "rose-ash", + video_url: str | None = None, + audio_url: str | None = None, + twitter_card: str | None = None, +) -> list[tuple[str, str]]: + """Build a list of ``(property/name, content)`` tuples for OG + Twitter meta. + + Templates render these as:: + + {% for prop, content in og_meta %} + + {% endfor %} + """ + tags: list[tuple[str, str]] = [] + + tags.append(("og:type", og_type)) + tags.append(("og:title", title)) + if description: + tags.append(("og:description", description)) + if url: + tags.append(("og:url", url)) + if image: + tags.append(("og:image", image)) + if site_name: + tags.append(("og:site_name", site_name)) + if video_url: + tags.append(("og:video", video_url)) + if audio_url: + tags.append(("og:audio", audio_url)) + + # Twitter Card tags + card_type = twitter_card or ("summary_large_image" if image else "summary") + tags.append(("twitter:card", card_type)) + tags.append(("twitter:title", title)) + if description: + tags.append(("twitter:description", description)) + if image: + tags.append(("twitter:image", image)) + + return tags