gabriel / musehub public
opengraph-repo-cards-design.md markdown
268 lines 11.9 KB
Raw
sha256:0521dce9aca20da6ab4d9ebee6de75ab876bd82cddbc6bd4897d1ac6ecd11d3d docs(musehub#129): freeze Phase 1 OG repo card design Human 14 days ago

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 <meta> tags. Phase 3 must add a {% block og_meta %} loop 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. 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):

{
  "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

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)

{% 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 2aopengraph_renderer.py + HTML template + ui_opengraph.py route (OG_01–05)
  2. Phase 2b — Dockerfile + requirements.txt Playwright deps; deploy staging
  3. Phase 3abase.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.

File History 1 commit
sha256:0521dce9aca20da6ab4d9ebee6de75ab876bd82cddbc6bd4897d1ac6ecd11d3d docs(musehub#129): freeze Phase 1 OG repo card design Human 14 days ago