# 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() %}
{%- endfor %}
{%- endif %}
{% endblock %}
```
Place after ``, 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 `` 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.**