# musehub#129 — Open Graph repo cards (Phase 1 design) **Status:** Frozen — ready for Phase 2 implementation **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 must add a `{% block og_meta %}` loop 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**. 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: design changes are CSS edits, not coordinate math. | | Pillow / cairo compositing | Rejected for v1. Every layout tweak requires redrawing positions, font metrics, and truncation logic by hand. | | cairosvg-only (SVG output) | Rejected. Facebook expects raster PNG/JPEG; SVG `og:image` support is inconsistent across crawlers. | ### Implementation sketch ``` musehub/templates/musehub/opengraph/repo_card.html # self-contained HTML, inline CSS musehub/services/opengraph_renderer.py # Playwright page.screenshot() musehub/api/routes/musehub/ui_opengraph.py # route + cache orchestration ``` **Template constraints** - Fixed viewport **1200×630** (Open Graph standard). - **Inline CSS only** — embed Inter via Bunny fonts CDN (same as `base.html`) or fall back to system sans. - **No HTMX / Alpine / app.js** — static HTML for screenshot. - Dark theme aligned with site tokens (`--bg-base: #0d1117`, spectral gradient accent). **Runtime dependency** - Add `playwright` to `requirements.txt`. - Dockerfile runtime stage: `playwright install chromium` (+ minimal `apt` libs). Builder stays unchanged. - **Test seam:** `OpenGraphRenderer` protocol with `PlaywrightRenderer` (prod) and `StubRenderer` (tests return deterministic PNG bytes). CI never launches Chromium. **Avatar embedding** - Fetch owner sigil from existing route logic: `GET /avatars/handle/{owner}.svg` (always succeeds — unregistered handles get deterministic sha256-derived sigil per `ui_avatars.py:49–87`). - Rasterize SVG to PNG in-process via `cairosvg` (small dep, no browser) before embedding as `data:image/png;base64,...` in the HTML template. Avoids Playwright fetching localhost during screenshot. --- ## Decision 2 — Card layout ### Wireframe (1200×630) ``` ┌────────────────────────────────────────────────────────────────────────────┐ │ [MuseHub logo] MuseHub [domain badge] │ 72px header bar ├────────────────────────────────────────────────────────────────────────────┤ │ ┌────┐ │ │ │avatar│ gabriel/muse ← repo name, 40px semibold │ │ │ 80px│ Short description text here, up to two ← 22px, max 2 lines │ │ └────┘ lines with ellipsis… │ │ │ │ ★ — 🍴 3 ◉ 12 ⌁ 847 commits │ stats row │ (stars) (forks) (issues) (commits) │ └────────────────────────────────────────────────────────────────────────────┘ ``` ### Fields and data sources | Field | Source | Notes | | --- | --- | --- | | Owner avatar | `generate_sigil()` / identity row for `owner` | 80×80 circle clip; same visual as profile | | Repo title | `{owner}/{repo_slug}` | Monospace accent on slug optional | | Description | `repo.description` | Fallback: `Music composition repository by {owner}` (matches `ui_repo.py:218`) | | Domain badge | `repo.domain_id` | Show pill when not `code`/empty; e.g. `midi`, `mist` | | Commits | `get_repo_home_stats().total_commits` | Always shown | | Forks | `COUNT(musehub_forks WHERE source_repo_id = repo_id)` | Public forks only | | Open issues | existing nav query (`MusehubIssue.state == 'open'`) | Matches GitHub “issues” stat | | Stars | **Not in schema** | Omit in v1 (no `star_count` on `MusehubRepo`). Revisit when `musehub_stars` lands. | ### Fallbacks | Case | Behavior | | --- | --- | | Empty description | Use owner fallback string above | | Unregistered owner | Sigil from `sha256(handle)` path (already default in `ui_avatars.py`) | | Long repo name (`owner/slug` > ~40 chars) | CSS `text-overflow: ellipsis` on single line | | Long description (> ~120 chars) | Clamp to 2 lines / ~120 chars in template (`truncate` filter) | | Private repo, anonymous request | **404** (same existence-hiding as `_resolve_repo_full`) | | Repo not found | 404 | | Renderer failure | 503 + log; do not cache failures | ### `twitter:card` upgrade (Phase 3 only) When `image` is set, pass `twitter_card="summary_large_image"` in `ui_repo.py` `_og_tags()` call. Contract of `_og_tags()` unchanged — just a different argument value. --- ## Decision 3 — Caching ### Strategy: **content-hash key + object-store persistence** Regenerate only when card-visible data changes. No TTL-only invalidation (TTL is a safety net, not the primary mechanism). **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_id": "sha256:…", "layout_version": 1 } ``` `layout_version` is a module-level int bumped when the HTML template/CSS changes (forces regen without waiting for repo edits). **Storage** | Layer | Path / behavior | | --- | --- | | Object store (R2/MinIO via existing `BlobBackend`) | `og-cards/{owner}/{slug}/{content_hash}.png` | | DB metadata | **None in v1** — hash-in-URL is sufficient; avoids a migration | | Process memory | Optional LRU (size 64) of `{cache_key: bytes}` for hot repos | **Request flow** 1. Resolve repo + stats + fork/issue counts. 2. Compute `content_hash`. 3. `GET` object `og-cards/{owner}/{slug}/{content_hash}.png` from blob store. 4. **Hit** → stream PNG with `Cache-Control: public, max-age=86400, immutable` and `ETag: "{content_hash}"`. 5. **Miss** → render PNG → `PUT` to blob store → respond. **OG_05 test plan** - Patch `OpenGraphRenderer.render` with a counter. - Two requests with unchanged repo metadata → counter == 1. - Update `description` in DB → counter == 2, new `ETag`. **CDN** - Staging/prod already front R2 via Cloudflare. Same-origin URL `https://{host}/{owner}/{slug}/opengraph-image` is crawler-friendly; optional future optimization: redirect to CDN URL — not in v1. --- ## Decision 4 — Routing and wiring ### New endpoint ``` GET /{owner}/{repo_slug}/opengraph-image ``` - Register in new `ui_opengraph.py`, included in `main.py` **before** `ui_repo_routes` (same rule as `ui_symbols`). - `Content-Type: image/png` - No auth required for public repos; 404 for private/anonymous. ### Phase 3 — `ui_repo.py` wiring ```python og_image_url = ( f"{str(request.base_url).rstrip('/')}/{owner}/{repo_slug}/opengraph-image" if request.base_url.hostname in set(_settings.allowed_hosts) else f"{_settings.public_url.rstrip('/')}/{owner}/{repo_slug}/opengraph-image" ) "og_meta": _og_tags( title=f"{owner}/{repo_slug} — MuseHub", description=repo.description or f"Music composition repository by {owner}", image=og_image_url, og_type="website", twitter_card="summary_large_image", ), ``` ### Phase 3 — `base.html` head block (prerequisite for OG_06) ```html {% block og_meta %} {%- if og_meta is defined and og_meta %} {%- for key, value in og_meta.items() %} <meta property="{{ key }}" content="{{ value }}"> {%- endfor %} {%- endif %} {% endblock %} ``` Place after `<title>`, before `jsonld` block. Use `property` for both OG and Twitter keys (Facebook accepts this; matches existing `_og_tags` key names). --- ## Test matrix (Phase 2–3) | ID | Tier | Assertion | | --- | --- | --- | | OG_01 | integration | `GET /{owner}/{slug}/opengraph-image` → 200, `Content-Type: image/png`, body starts with PNG magic `\x89PNG` | | OG_02 | integration | Render with known description; decode PNG; assert description substring present (OCR-free: render HTML fixture separately and compare hash, or inject stub renderer that records template context) | | OG_03 | integration | Registered owner → avatar bytes in template context; unknown handle → sigil still rendered | | OG_04 | integration | Change `total_commits` in DB → stats value appears in template context passed to renderer | | OG_05 | integration | Renderer call count 1 on repeat request; 2 after metadata change | | OG_06 | integration | Repo home HTML contains `<meta property="og:image" content="https://…/opengraph-image">` with absolute URL | **Security** - No user-controlled HTML in card — only DB fields escaped by Jinja2 autoescape. - Avatar fetched server-side by handle (path param validated as `SlugParam`). - Private repos never leak via OG endpoint (404). --- ## Implementation order (frozen) 1. **Phase 2a** — `opengraph_renderer.py` + HTML template + `ui_opengraph.py` route (OG_01–05) 2. **Phase 2b** — Dockerfile + `requirements.txt` Playwright deps; deploy staging 3. **Phase 3a** — `base.html` og_meta block + `ui_repo.py` `image=` wiring (OG_06) 4. **Phase 3b** — Facebook Sharing Debugger on `https://staging.musehub.ai/gabriel/muse`; screenshot attached to #129 --- ## Files to touch (Phase 2–3) | File | Change | | --- | --- | | `musehub/services/opengraph_renderer.py` | New — render + stub | | `musehub/services/opengraph_cache.py` | New — hash + blob get/put | | `musehub/templates/musehub/opengraph/repo_card.html` | New — card layout | | `musehub/api/routes/musehub/ui_opengraph.py` | New — route handler | | `musehub/main.py` | Register router before `ui_repo` | | `musehub/api/routes/musehub/__init__.py` | Add `ui_opengraph` to `_DIRECT_REGISTERED` | | `musehub/templates/musehub/base.html` | Emit `og_meta` block | | `musehub/api/routes/musehub/ui_repo.py` | Pass `image=` + `twitter_card` | | `requirements.txt` | `playwright`, `cairosvg`, `pillow` | | `Dockerfile` | Chromium install in runtime stage | | `tests/test_opengraph_repo_cards.py` | New — OG_01–OG_06 | --- ## Review checklist (Gabriel) - [ ] Rendering approach (Playwright + inline HTML template) approved - [ ] Stats row: commits / forks / open issues (stars deferred) approved - [ ] Content-hash cache in object store (no DB table) approved - [ ] `base.html` og_meta emission in scope for Phase 3 approved **Phase 2 may begin when this doc is reviewed or explicitly acknowledged on #129.**