gabriel / musehub public
opengraph-repo-cards-design.md markdown
215 lines 7.9 KB
Raw
sha256:6ad6d62107bbd6940c52d63327966b8a65c6391f4a3da6cd9e7548ba4b38bc48 feat(musehub#129): OG repo preview cards — endpoint, cache,… Human minor ⚠ breaking 14 days ago

musehub#129 — Open Graph repo cards (Phase 1 design)

Status: Phase 1 amended — Phase 2/3 implementation in progress
Branch: feat/opengraph-repo-cards
Issue: https://staging.musehub.ai/gabriel/musehub/issues/129


Problem (verified in code)

Layer State
_og_tags() (_ui_helpers.py:38–58) Supports image; emits og:image / twitter:image when set
ui_repo.py:216 Never passes image
base.html Does not render og_meta at all — handlers pass the dict but templates never emit <meta> tags. Phase 3 adds {% block og_meta %} in <head>.
Image generation None exists today

Facebook/X today scrape only <title> ({owner}/{slug} — MuseHub — MuseHub), producing bare-text previews.


Goal

GET /{owner}/{repo_slug}/opengraph-image returns a 1200×630 PNG suitable for og:image / twitter:image. Repo home pages reference it with an absolute HTTPS URL plus a cache-bust query param. Verified on at least one platform debugger (Facebook Sharing Debugger or X Card Validator).

Out of scope (first pass): issues, proposals, mists, releases; _og_tags() contract changes; jsonld_repo() changes.


Decision 1 — Rendering approach

Chosen: HTML template → headless Chromium screenshot (Playwright)

Option Verdict
Playwright HTML→PNG Selected. Layout is a Jinja2 template with inline CSS (no app.css dependency). Matches GitHub / Vercel @vercel/og iteration model.
Pillow / cairo compositing Rejected for v1. High layout iteration cost.
cairosvg-only (SVG output) Used only to rasterize owner sigil SVG → PNG for embedding in the HTML card.

Runtime lifecycle (amended)

  • One shared Browser per process (lazy init on first render; started via sync_playwright in a thread).
  • Per-render isolated page with viewport 1200×630.
  • asyncio.Lock per content_hash (singleflight) — concurrent cache misses do not spawn duplicate renders.
  • Graceful browser shutdown in FastAPI lifespan on app exit.
  • StubOpenGraphRenderer in MUSE_ENV=test — CI never launches Chromium.

Font strategy (amended)

  • Inter loaded from Bunny fonts CDN with page.set_content(..., wait_until="networkidle") before screenshot.
  • System sans fallback in CSS if CDN unreachable.

Avatar embedding

  • Call generate_sigil() directly (same as ui_avatars.py).
  • Rasterize via cairosvg → base64 PNG in HTML — no localhost fetch during screenshot.

Decision 2 — Card layout

Wireframe (1200×630)

┌────────────────────────────────────────────────────────────────────────────┐
│ [MuseHub logo]  MuseHub                                    [domain badge]  │
├────────────────────────────────────────────────────────────────────────────┤
│  ┌────┐                                                                    │
│  │avatar│  gabriel/muse                                                    │
│  │ 80px│  Short description, up to two lines with ellipsis                 │
│  └────┘                                                                    │
│                                                                            │
│  ⌁ 847 commits   🍴 3 forks   ◉ 12 issues open                            │
└────────────────────────────────────────────────────────────────────────────┘

Fields and data sources

Field Source
Owner avatar generate_sigil() + identity row for owner
Repo title {owner}/{repo_slug}
Description repo.description or fallback Music composition repository by {owner}
Domain badge repo.domain_id when not code/empty
Commits get_repo_home_stats().total_commits
Forks list_repo_forks_flat().total (public forks only)
Open issues MusehubIssue.state == 'open' count
Stars Omitted — no star_count on MusehubRepo yet

Fallbacks

Case Behavior
Empty description Owner fallback string
Unregistered owner sha256(handle) sigil (per ui_avatars.py)
Long repo name CSS text-overflow: ellipsis
Long description ~120 chars / 2 lines
Private repo, anonymous 404
Renderer failure 503; do not cache failures

Decision 3 — Caching

Strategy: content-hash key + BlobBackend persistence

Amended storage path: BlobBackend only supports objects/{object_id} where object_id is sha256:<64-hex>. OG PNGs are stored as:

object_id = sha256:{content_hash_hex}
S3 key    = objects/sha256:{content_hash_hex}

No og-cards/ prefix (unlike mpacks/). Hash inputs include owner, slug, and all visible fields — owner/slug need not appear in the storage key.

Hash inputs (canonical JSON, sorted keys, SHA-256):

{
  "v": 1,
  "owner": "gabriel",
  "slug": "muse",
  "description": "…",
  "domain_id": "code",
  "total_commits": 847,
  "fork_count": 3,
  "open_issue_count": 12,
  "owner_identity_hex": "…",
  "identity_type": "human",
  "layout_version": 1
}

Request flow: compute hash → get_backend().get(object_id) → on miss, singleflight render → put(object_id, png).

Orphan PNGs: old hashes remain in object store when metadata changes; acceptable at current scale.

Crawler cache busting (amended — Phase 3)

Facebook caches og:image by URL. Stable URL serves new bytes invisibly to crawlers.

Phase 3 og:image URL:

https://{host}/{owner}/{slug}/opengraph-image?cb={content_hash[:12]}

repo_page computes the same hash from data already gathered (plus fork count). Endpoint ignores ?cb= for routing.


Decision 4 — Routing and wiring

Endpoint

GET /{owner}/{repo_slug}/opengraph-image
  • ui_opengraph.py, registered in main.py before ui_repo_routes.
  • Rate limit: 60/minute per IP (OPENGRAPH_LIMIT).
  • Content-Type: image/png, Cache-Control: public, max-age=86400, immutable, ETag: "{content_hash}".

Phase 3 — base.html meta tags (amended)

{% block og_meta %}
{%- if og_meta is defined and og_meta %}
{%- for key, value in og_meta.items() %}
  {%- if key.startswith('twitter:') %}
  <meta name="{{ key }}" content="{{ value }}">
  {%- else %}
  <meta property="{{ key }}" content="{{ value }}">
  {%- endif %}
{%- endfor %}
{%- endif %}
{% endblock %}

Phase 3 — ui_repo.py

  • Pass image= with absolute URL + ?cb= hash.
  • twitter_card="summary_large_image".
  • Optional: url= for og:url (page canonical URL).

Test matrix

ID Assertion
OG_01 200, image/png, PNG magic bytes
OG_02 Stub renderer records template context with description text
OG_03 Avatar context present for registered and unregistered owners
OG_04 Stats in template context reflect DB values
OG_05 Renderer call count 1 on repeat; 2 after metadata change
OG_06 Repo HTML has og:image meta with absolute URL and ?cb= param

Docker amendments

  • Runtime apt-get: libcairo2 (cairosvg), Playwright Chromium system deps.
  • PLAYWRIGHT_BROWSERS_PATH=/ms-playwright, installed as root, chown musehub.
  • playwright install chromium in runtime stage.

Review checklist

  • [x] Rendering: Playwright + inline HTML + stub for tests
  • [x] Storage: sha256:{content_hash} via BlobBackend
  • [x] Crawler cache bust: ?cb= on og:image
  • [x] Meta tags: property for OG, name for Twitter
  • [x] Stats: commits / forks / open issues (no stars)
File History 2 commits
sha256:6ad6d62107bbd6940c52d63327966b8a65c6391f4a3da6cd9e7548ba4b38bc48 feat(musehub#129): OG repo preview cards — endpoint, cache,… Human minor 14 days ago
sha256:0521dce9aca20da6ab4d9ebee6de75ab876bd82cddbc6bd4897d1ac6ecd11d3d docs(musehub#129): freeze Phase 1 OG repo card design Human 14 days ago