Rich social preview cards for repo links -- no dynamic OG image today
Rich social preview cards for repo (and other entity) links — no dynamic OG image today
Background
Pasting a MuseHub repo link into Facebook's post composer produces a bare-text
preview (title + "MuseHub" subtitle, no image, no stats). Pasting a GitHub
repo link produces a rich card: owner avatar, repo name, description,
contributor/issue/star/fork counts, and a colored language bar — because
GitHub dynamically generates an actual preview image server-side and serves
it via og:image.
Root cause, traced in code, not assumed: _og_tags() in
musehub/api/routes/musehub/_ui_helpers.py:38-58 already supports an
image parameter and correctly emits og:image/twitter:image when one is
given:
def _og_tags(
*, title: str, description: str = "", image: str = "",
og_type: str = "website", twitter_card: str = "summary",
) -> StrDict:
...
if image:
tags["og:image"] = image
tags["twitter:image"] = image
return tags
But the repo detail page's call site (musehub/api/routes/musehub/ui_repo.py:216)
never passes image at all:
"og_meta": _og_tags(
title=f"{owner}/{repo_slug} — MuseHub",
description=repo.description or f"Music composition repository by {owner}",
og_type="website",
),
There is currently no image-generation capability anywhere in MuseHub for social cards — not a bug in the OG-tag plumbing, a missing feature.
Goal
Repo pages (and, once proven, other shareable entities — issues, proposals,
mists, releases) serve a real og:image/twitter:image — a server-rendered
PNG showing at minimum: owner avatar, repo/entity name, description, and
relevant stats (stars/forks/issues for repos; state/author for
issues/proposals). Muse-specific flourishes worth considering once the basic
card works: domain type (code/MIDI/etc.), commit count, primary language or
domain-plugin icon — MuseHub's answer to GitHub's language bar.
"Done" (for repos, the pilot entity): pasting a https://musehub.ai/<owner>/<repo>
link into Facebook, X, LinkedIn, or Slack renders a card with a real image,
verified against at least one real platform's link-preview debugger (Facebook
Sharing Debugger, X Card Validator, or equivalent) — not just correct HTML,
since several platforms cache aggressively and some (X in particular) have
been known to silently ignore correct OG tags for undocumented reasons.
Out of Scope
- Extending rich cards to every entity type in the first pass — ship repos first, prove the pattern, then decide whether issues/proposals/mists/ releases get the same treatment as a follow-up.
- Redesigning
_og_tags()'s existing shape — it's already correct; this is about supplying it an image, not changing its contract. - Any change to
jsonld_repo()/structured data — separate concern from Open Graph, not touched here.
Phases
Phase 1 — Design: what the card looks like and how it's rendered
- Decide the rendering approach: server-side HTML→image (e.g. a headless
browser screenshot of a dedicated template) vs. a drawing library
(Pillow/
cairosvg) composing a fixed layout. Headless-browser rendering is closer to what Vercel's@vercel/ogand GitHub's own pipeline do, and makes future layout changes a CSS edit, not a redraw-from-scratch; a drawing library has fewer runtime dependencies but a much higher cost to iterate on layout. Record the decision and why before Phase 2 starts. - Design the actual layout/content for a repo card (mock or written spec): what fields, what fallback when a repo has no description/no avatar, what happens for a very long repo name or description.
- Decide the caching strategy — these images should not be regenerated on every request; decide whether to cache by repo content-hash (regenerate when name/description/stats meaningfully change) or on a TTL, and where the cache lives (object store? a dedicated table? CDN-fronted static path?).
Deliverable: a written design (this issue's body, updated) covering rendering approach, layout, and caching, reviewed before any code lands.
Phase 2 — Implement: /{owner}/{repo}/opengraph-image endpoint
OG_01— New route serving a PNG for a given repo, using Phase 1's chosen rendering approach. CorrectContent-Type: image/png.OG_02— Image includes the repo name and description (or the fallback text when description is empty) — verified by rendering and inspecting actual pixel/text content, not just that the endpoint returns 200.OG_03— Image includes owner avatar when one exists, and a sane default placeholder when it doesn't.OG_04— Image includes stats (stars/forks/issues or whatever Phase 1 decided) sourced from real repo data, not hardcoded.OG_05— Caching behavior from Phase 1's design is implemented and tested: a second request for the same repo state doesn't regenerate; a request after a meaningful change does.
Deliverable: OG_01-05 green; endpoint deployed to staging.
Phase 3 — Wire into repo pages, verify on real platforms
ui_repo.py's_og_tags()call passes the new endpoint's URL asimage.OG_06—og:image/twitter:imagepresent in the rendered repo page's<head>, pointing at an absolute URL (relative URLs are unreliable across crawlers).- Manually verify against at least one real platform's link-preview debugger (Facebook Sharing Debugger / X Card Validator) — record the result (screenshot or debugger output) in this issue. This step cannot be meaningfully unit-tested; it's the actual acceptance gate.
Deliverable: a real, verified rich preview when a staging repo URL is pasted into at least one social platform.
Acceptance Criteria
- A repo page serves a real, non-empty
og:image. - The image contains repo name, description (or fallback), owner avatar (or placeholder), and real stats — not hardcoded/placeholder data.
- Verified against at least one real platform's official preview debugger, not just HTML inspection.
- Caching strategy from Phase 1 is implemented and tested — no regenerate-on-every-request behavior.
Implementation Order
Phase 1 → Phase 2 → Phase 3. Do not start Phase 2 until Phase 1's rendering approach and caching strategy are actually decided and written down — this is exactly the kind of feature where picking the wrong rendering approach costs a full rewrite partway through.
Screenshots referenced in the background above — pasting a repo link into Facebook's post composer:
GitHub link (cgcardona/musehub-backup) — rich card: owner avatar, repo name, description, contributors/issues/stars/forks stats, colored language bar.

MuseHub link (gabriel/muse) — bare text only: no image, no stats, no avatar.

This is the concrete before/after target for Phase 3's acceptance gate.
FYI aaronrene — separately fixing a layout regression (musehub#139, viewport-locked flex shell for the two-column pages) in parallel. Scope is base.html + SCSS + page-template layout markup only — shouldn't touch anything in _ui_helpers.py, ui_repo.py's OG wiring, or the new opengraph-image endpoint you're building here. Flagging so we don't collide if we're both touching ui_repo.py's template around the same time.
Assigned to @aaronrene — tracked under master queue #138.