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 <head> - INTERNAL_URL_ARTDAG in docker-compose.yml for cross-stack fragment fetches Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,15 @@
|
||||
{% include 'social/meta_site.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% if og_meta is defined and og_meta %}
|
||||
{% for prop, content in og_meta %}
|
||||
<meta property="{{ prop }}" content="{{ content }}">
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if oembed_link is defined and oembed_link %}
|
||||
{{ oembed_link }}
|
||||
{% endif %}
|
||||
|
||||
{% include '_types/root/_head.html' %}
|
||||
</head>
|
||||
<body class="bg-stone-50 text-stone-900">
|
||||
|
||||
@@ -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 ``<!-- fragment:{key} -->`` 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 <!-- fragment:{key} --> 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 ``<!-- fragment:{key} -->`` comment markers."""
|
||||
result: dict[str, str] = {}
|
||||
marker_prefix = "<!-- fragment:"
|
||||
marker_suffix = " -->"
|
||||
|
||||
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,
|
||||
|
||||
110
shared/infrastructure/oembed.py
Normal file
110
shared/infrastructure/oembed.py
Normal file
@@ -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 ``<head>`` 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 ``<link rel="alternate" ...>`` for oEmbed discovery."""
|
||||
safe_title = Markup.escape(title)
|
||||
safe_url = Markup.escape(oembed_url)
|
||||
return Markup(
|
||||
f'<link rel="alternate" type="application/json+oembed"'
|
||||
f' href="{safe_url}" title="{safe_title}">'
|
||||
)
|
||||
|
||||
|
||||
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 %}
|
||||
<meta property="{{ prop }}" content="{{ content }}">
|
||||
{% 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
|
||||
Reference in New Issue
Block a user