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
Browserper process (lazy init on first render; started viasync_playwrightin a thread). - Per-render isolated
pagewith viewport 1200×630. asyncio.Lockpercontent_hash(singleflight) — concurrent cache misses do not spawn duplicate renders.- Graceful browser shutdown in FastAPI
lifespanon app exit. StubOpenGraphRendererinMUSE_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 asui_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 inmain.pybeforeui_repo_routes.- Rate limit:
60/minuteper 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=forog: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 chromiumin runtime stage.
Review checklist
- [x] Rendering: Playwright + inline HTML + stub for tests
- [x] Storage:
sha256:{content_hash}via BlobBackend - [x] Crawler cache bust:
?cb=onog:image - [x] Meta tags:
propertyfor OG,namefor Twitter - [x] Stats: commits / forks / open issues (no stars)