docs/2026-08-21-open-graph-cards.md

main at 58e6347eeb72 · 10 KB

Server-generated Open Graph cards

Date: 2026-08-21

Status: Implemented. Meta tags ship on every public page; the signed, content-versioned card endpoint serves repository, issue, blob, and commit cards; rasterization runs through librsvg with a committed fallback card when the binary is absent. The release image carries librsvg, fontconfig, and the pinned Geist TTFs; dynamic cards render wherever a rebuilt image runs.

What links to our pages look like today

The application renders no Open Graph or Twitter meta tags at all. A link to https://openagents.com/OpenAgentsInc/openagents.com pasted into Slack, X, iMessage, Discord, or LinkedIn renders as either a bare URL or whatever those platforms guess. Every forge page we built to be public — repository home, blobs, commits, issues — is invisible in exactly the surfaces where people share links.

What GitHub does, measured

Facts collected from live responses on 2026-08-21:

  • The repository home serves <meta property="og:image"> pointing at https://opengraph.githubassets.com/{digest}/OpenAgentsInc/openagents. The hex {digest} is derived from the card inputs, so the URL itself is the cache key: change the description and the digest changes.
  • The image is a 1200×600 RGB PNG. It shows the owner avatar, the owner/repo path, the description ("Monorepo & docs" for our sister repository), and star/fork/watch counters.
  • cache-control: public, max-age=21600, immutable on the image response.
  • The blob page serves the identical URL — same digest, same bytes. So do directory listings. A file shared on Slack looks exactly like the repository landing page: nothing about the file appears in the card.
  • An individual issue gets its own template and digest (.../{digest}/elixir-lang/elixir/issues/10000), with title, body excerpt, author, state, and comment count rendered into the image, and og:image:alt carrying the body excerpt for screen readers.

Two design lessons fall out of this. First, the card URL is content-addressed: immutability plus a long cache makes generation cost irrelevant and stale links harmless. Second, GitHub stops at the repository boundary — a file link wastes its card. That is the gap worth beating.

Goals

  1. Every public forge page emits correct og:* and twitter:* meta tags with a card that says what the page is.
  2. Cards are generated by the Phoenix application itself: no third-party service, consistent with ADR 0001.
  3. File, commit, and issue cards carry information specific to that resource — deliberately more than GitHub ships.
  4. Generation is bounded, cached, and safe on untrusted text.

Non-goals

  • Per-user personalized cards. Cards describe resources, not viewers.
  • Rendering arbitrary README screenshots or images fetched from Git content.
  • Supporting scrapers that execute JavaScript; crawlers read the initial server-rendered HTML, which LiveView provides.

Architecture

Layer 1: meta tags

Crawlers do not run JavaScript, so the tags must be in the initial SSR HTML. LiveView renders that HTML once per mount, which is exactly what a crawler fetches.

Add an optional @og assign consumed by the root layout, with a site-level default so every page produces valid tags even when a view forgets:

<meta property="og:title" content={@og.title} />
<meta property="og:description" content={@og.description} />
<meta property="og:image" content={@og.image_url} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:url" content={@og.url} />
<meta property="og:type" content={@og.type} />
<meta name="twitter:card" content="summary_large_image" />

Each public view fills the map during handle_params/3: repository home, blob, commit, issue show, docs, changelog, status, home. Views behind authentication — chat, settings, repositories management — keep the bare site card or none at all. Private repositories emit no resource-specific tags whatsoever: the meta tags must not leak the existence or description of something the visitor could not otherwise see.

Layer 2: the card endpoint

One plug-style controller serves all images:

GET /og/v/{version}/repos/{owner}/{repo}.png
GET /og/v/{version}/blobs/{owner}/{repo}/{ref}/{path...}.png
GET /og/v/{version}/commits/{owner}/{repo}/{sha}.png
GET /og/v/{version}/issues/{owner}/{repo}/{number}.png
GET /og/static/card.png

Properties of this endpoint:

  • Content-addressed version. {version} is a short SHA-256 prefix over the exact inputs (titles, counts, dates, colors). When any input changes, the page emits a new URL and every CDN entry for the old one ages out. Stale shares keep working: the controller renders the current data for any well-formed request and lets the version be advisory, the way GitHub's digest behaves in practice.
  • HMAC, not enumeration. Requests carry ?sig= computed over the path with a server-side key. Without a valid signature the endpoint 404s, so the endpoint cannot be used as an unbounded free rendering API by third parties.
  • Concealment. The controller resolves the repository through the same visibility predicate as public code browsing. A private or missing repository returns an indistinguishable 404.
  • Cache headers. cache-control: public, max-age=21600, immutable, matching what GitHub ships, plus content-type: image/png.
  • Bounded work. One rasterization per miss; the LB/CDN absorbs repeats. A concurrency-limited task queue caps worst-case load, and requests queue rather than fan out.

Layer 3: rendering

Cards are laid out as SVG templates in Elixir — pure functions from a data struct to a string, trivially unit-testable — then rasterized to PNG.

Rasterization options considered:

Option Verdict
System rsvg-convert (librsvg) invoked via port Recommended. One package in the release image; deterministic output with pinned fonts; battle-tested parser.
ImageMagick Same shape as above but a much larger attack and maintenance surface than librsvg alone.
Pure-Elixir PNG encoder plus a glyph atlas Zero system dependencies, but hand-rolled shaping, wrapping, and hinting; weeks of work for visibly worse type.
Rustler NIF wrapping resvg Best fidelity and speed, but adds a NIF toolchain to every release build and rollback artifact.
Headless-Chrome sidecar service Contradicts the one-application architecture; heaviest option by far.

The port approach carries three hard requirements, which are also the security boundary:

  1. Fixed fonts. The release image ships the Geist faces we already use (TTF form for fontconfig). No user-influenced font selection, ever.
  2. Escaped, capped text. All dynamic strings pass through XML escaping, length caps, and a line-clamping layout helper before entering the template. Titles wrap at two lines with an ellipsis; paths truncate from the left, keeping the filename visible.
  3. No remote references. The SVG may not contain <image>, external CSS, or URLs the rasterizer would fetch — that closes SSRF and removes network nondeterminism. Avatars render as initial-filled discs in palette tones, not fetched bitmaps.

If the rasterizer binary is missing at runtime, the endpoint falls back to the static brand card and logs a warning. Social previews degrade; nothing errors.

Card contents

Canvas: 1200×630, dark palette from our token ladder, brand lockup bottom-left on every card, openagents.com wordmark bottom-right. This is where our cards diverge from GitHub's on purpose.

Surface Card shows
Site fallback Wordmark, one-line product description, logo mark
Repository owner/repo, description (or an honest fallback), visibility pill, open/closed issue counts, default branch, last commit date, "Imported from GitHub" provenance line when a repository import exists
Issue State pill colored by state and close reason, #number, wrapped title, author login, comment count, up to three label chips, opened date
Blob Small owner/repo context line, the full path breadcrumb wrapping across two lines with the filename kept intact, language inferred from extension, file size, line count, branch/ref
Commit Short SHA, wrapped subject, author login, files-changed count, date

The blob and commit cards are the deliberate step past GitHub: their file links render the generic repository card, while ours answer "what am I about to open?" — language, size, and where it sits in the tree — without a click. Everything shown is already served on the corresponding public page, so the cards leak nothing new; the rule is simply that a card may contain only data the anonymous page already displayed.

Testing

  • Golden-file tests pin each SVG template's output for fixture inputs, so a layout regression fails loudly.
  • Controller tests cover: PNG content type, cache headers, signature enforcement, version-mismatch behavior, and indistinguishable 404s for private and missing repositories.
  • Escaping tests feed hostile strings — angle brackets, control characters, emoji, ten-thousand-character titles — and assert bounded, escaped output.
  • The redaction scan extends to card payloads: tokens and credentials never appear in card inputs because card inputs are page-visible data only.

Delivery sequence

All three phases shipped together on 2026-08-21: the meta-tag layer, the signed endpoint with the SVG-to-PNG pipeline, and the deep-surface cards. The implementation notes below record what landed and where.

  1. Meta tags plus the static brand card. Layouts.og_tags/1 renders the block in the root layout; views without a card get honest site-level tags pointing at the committed brand PNG.
  2. Repository and issue cards. OpenAgentsWeb.OG builds cards from page data, signs content-versioned URLs, and OpenAgentsWeb.OgImageController renders them through OG.Templates + OG.Rasterizer (librsvg port, concurrency-limited, hard timeout).
  3. Blob and commit cards. The beyond-GitHub information layer: language, size, line count, and ref for files; subject, author, and changed-file count for commits.

Remaining operations work, tracked here so it cannot be forgotten:

  • The release image now carries librsvg2-bin, fontconfig, and the pinned Geist TTFs (v1.7.2, zip and per-file digests checked in the Dockerfile) with a fontconfig alias from the CSS family name "Geist Sans" to the files' internal name "Geist". Dynamic cards render on any node running a rebuilt image; deploy the rebuilt image and the staging check below goes green.
  • The staging check should assert a dynamic card URL returns bytes other than the committed fallback, proving rasterization end to end.