# 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 `` tags. Phase 3 adds `{% block og_meta %}` in ``. | | Image generation | None exists today | Facebook/X today scrape only `` (`{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): ```json { "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) ```html {% 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)