Add Phase 5: link-card fragments, oEmbed endpoints, OG meta
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m48s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m48s
- 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:
30
blog/app.py
30
blog/app.py
@@ -119,6 +119,36 @@ def create_app() -> "Quart":
|
|||||||
obj.value = val
|
obj.value = val
|
||||||
return {"ok": True, "key": key, "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 ---
|
# --- debug: url rules ---
|
||||||
@app.get("/__rules")
|
@app.get("/__rules")
|
||||||
async def dump_rules():
|
async def dump_rules():
|
||||||
|
|||||||
@@ -46,6 +46,49 @@ def register():
|
|||||||
|
|
||||||
_handlers["nav-tree"] = _nav_tree_handler
|
_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"<!-- fragment:{s} -->")
|
||||||
|
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
|
# Store handlers dict on blueprint so app code can register handlers
|
||||||
bp._fragment_handlers = _handlers
|
bp._fragment_handlers = _handlers
|
||||||
|
|
||||||
|
|||||||
20
blog/templates/fragments/link_card.html
Normal file
20
blog/templates/fragments/link_card.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<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="blog" data-hx-disable>
|
||||||
|
<div class="flex flex-row items-start gap-3 p-3">
|
||||||
|
{% if feature_image %}
|
||||||
|
<img src="{{ feature_image }}" alt="" class="flex-shrink-0 w-16 h-16 rounded object-cover">
|
||||||
|
{% else %}
|
||||||
|
<div class="flex-shrink-0 w-16 h-16 rounded bg-stone-100 flex items-center justify-center text-stone-400">
|
||||||
|
<i class="fas fa-file-alt text-lg"></i>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-medium text-stone-900 text-sm clamp-2">{{ title }}</div>
|
||||||
|
{% if excerpt %}
|
||||||
|
<div class="text-xs text-stone-500 mt-1 clamp-2">{{ excerpt }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if published_at %}
|
||||||
|
<div class="text-xs text-stone-400 mt-1">{{ published_at.strftime('%d %b %Y') }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
@@ -39,6 +39,7 @@ x-app-env: &app-env
|
|||||||
INTERNAL_URL_EVENTS: http://events:8000
|
INTERNAL_URL_EVENTS: http://events:8000
|
||||||
INTERNAL_URL_FEDERATION: http://federation:8000
|
INTERNAL_URL_FEDERATION: http://federation:8000
|
||||||
INTERNAL_URL_ACCOUNT: http://account:8000
|
INTERNAL_URL_ACCOUNT: http://account:8000
|
||||||
|
INTERNAL_URL_ARTDAG: https://celery-artdag.rose-ash.com
|
||||||
AP_DOMAIN: federation.rose-ash.com
|
AP_DOMAIN: federation.rose-ash.com
|
||||||
AP_DOMAIN_BLOG: blog.rose-ash.com
|
AP_DOMAIN_BLOG: blog.rose-ash.com
|
||||||
AP_DOMAIN_MARKET: market.rose-ash.com
|
AP_DOMAIN_MARKET: market.rose-ash.com
|
||||||
|
|||||||
@@ -161,6 +161,35 @@ def create_app() -> "Quart":
|
|||||||
from bp.ticket_admin.routes import register as register_ticket_admin
|
from bp.ticket_admin.routes import register as register_ticket_admin
|
||||||
app.register_blueprint(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
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,55 @@ def register():
|
|||||||
|
|
||||||
_handlers["account-page"] = _account_page_handler
|
_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"<!-- fragment:{s} -->")
|
||||||
|
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
|
bp._fragment_handlers = _handlers
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
17
events/templates/fragments/link_card.html
Normal file
17
events/templates/fragments/link_card.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<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="events" data-hx-disable>
|
||||||
|
<div class="flex flex-row items-start gap-3 p-3">
|
||||||
|
{% if feature_image %}
|
||||||
|
<img src="{{ feature_image }}" alt="" class="flex-shrink-0 w-16 h-16 rounded object-cover">
|
||||||
|
{% else %}
|
||||||
|
<div class="flex-shrink-0 w-16 h-16 rounded bg-stone-100 flex items-center justify-center text-stone-400">
|
||||||
|
<i class="fas fa-calendar text-lg"></i>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-medium text-stone-900 text-sm clamp-2">{{ title }}</div>
|
||||||
|
{% if calendar_names %}
|
||||||
|
<div class="text-xs text-stone-500 mt-0.5">{{ calendar_names }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
@@ -29,6 +29,53 @@ def register():
|
|||||||
html = await handler()
|
html = await handler()
|
||||||
return Response(html, status=200, content_type="text/html")
|
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"<!-- fragment:{u} -->")
|
||||||
|
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
|
bp._fragment_handlers = _handlers
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
18
federation/templates/fragments/link_card.html
Normal file
18
federation/templates/fragments/link_card.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<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="federation" data-hx-disable>
|
||||||
|
<div class="flex flex-row items-center gap-3 p-3">
|
||||||
|
{% if avatar_url %}
|
||||||
|
<img src="{{ avatar_url }}" alt="" class="flex-shrink-0 w-12 h-12 rounded-full object-cover">
|
||||||
|
{% else %}
|
||||||
|
<div class="flex-shrink-0 w-12 h-12 rounded-full bg-stone-100 flex items-center justify-center text-stone-400">
|
||||||
|
<i class="fas fa-user text-lg"></i>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-medium text-stone-900 text-sm">{{ display_name or username }}</div>
|
||||||
|
<div class="text-xs text-stone-500">@{{ username }}</div>
|
||||||
|
{% if summary %}
|
||||||
|
<div class="text-xs text-stone-500 mt-1 clamp-2">{{ summary }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
@@ -195,6 +195,44 @@ def create_app() -> "Quart":
|
|||||||
return {}
|
return {}
|
||||||
return {**post_data}
|
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: /.../<page_slug>/<market_slug>/product/<slug>/
|
||||||
|
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
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,59 @@ def register():
|
|||||||
|
|
||||||
_handlers["container-nav"] = _container_nav_handler
|
_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"<!-- fragment:{s} -->")
|
||||||
|
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
|
bp._fragment_handlers = _handlers
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
28
market/templates/fragments/link_card.html
Normal file
28
market/templates/fragments/link_card.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<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="market" data-hx-disable>
|
||||||
|
<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">
|
||||||
|
{% else %}
|
||||||
|
<div class="flex-shrink-0 w-16 h-16 rounded bg-stone-100 flex items-center justify-center text-stone-400">
|
||||||
|
<i class="fas fa-shopping-bag text-lg"></i>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-medium text-stone-900 text-sm clamp-2">{{ title }}</div>
|
||||||
|
{% if brand %}
|
||||||
|
<div class="text-xs text-stone-500 mt-0.5">{{ brand }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if description_short %}
|
||||||
|
<div class="text-xs text-stone-500 mt-1 clamp-2">{{ description_short }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="text-xs mt-1">
|
||||||
|
{% if special_price %}
|
||||||
|
<span class="text-red-600 font-medium">£{{ "%.2f"|format(special_price) }}</span>
|
||||||
|
<span class="text-stone-400 line-through ml-1">£{{ "%.2f"|format(regular_price) }}</span>
|
||||||
|
{% elif regular_price %}
|
||||||
|
<span class="text-stone-700 font-medium">£{{ "%.2f"|format(regular_price) }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
@@ -10,6 +10,15 @@
|
|||||||
{% include 'social/meta_site.html' %}
|
{% include 'social/meta_site.html' %}
|
||||||
{% endblock %}
|
{% 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' %}
|
{% include '_types/root/_head.html' %}
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-stone-50 text-stone-900">
|
<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(
|
async def fetch_fragment_cached(
|
||||||
app_name: str,
|
app_name: str,
|
||||||
fragment_type: 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