og-images · v1.0.0 · 2026-06-20 · sha256 07a686c804c4b07d

og-images v1.0.0A

Immutable. This exact content never changes and is served at /api/v1/blob/07a686c804c4b07d.

---
name: og-images
description: >
  Generate branded per-page Open Graph (social share) cards for a website: a 1200×630
  JPEG ≤300 KB for every page, rendered by a committed Python+Pillow script and wired
  so each page sets its own `image=` (with a default fallback) and the test suite proves
  the card exists at the right size. Use when building or auditing a site's share previews —
  "OG image", "og:image", "social share image", "share card", "WhatsApp/Slack/LinkedIn
  preview", "Twitter card image", "the picture when I share the link", "per-page OG images",
  "generate share images". For general marketing image creation/optimization use the
  `image` skill; this one is specifically the build-time, tested, per-page card generator
  for the suite's Astro starter.
metadata:
  version: 1.0.0
---

# OG images — per-page share cards

When a URL is shared (WhatsApp, Slack, iMessage, X, LinkedIn, Discord), the `og:image`
is the preview. Get it wrong and the link shares with **no picture** or a badly cropped
one — a silent conversion leak. This skill makes every page render a consistent,
on-brand **1200×630 JPEG ≤300 KB**, generated by a committed script and enforced by the
test gate.

## The three hard rules (messengers enforce them)

1. **1200×630, exactly** (1.91:1). Square images get cropped on X/Teams; off-ratio ones
   letterbox. The starter's `Base.astro` already emits `og:image:width/height` = 1200×630.
2. **≤300 KB.** WhatsApp silently **drops** previews heavier than ~300 KB — the link then
   shares with no image at all. Encode JPEG and compress to fit.
3. **Extension matches the bytes.** A PNG renamed `.jpg` trips strict scrapers. Use JPEG
   for photographic cards, PNG only for flat logo/gradient cards that still come ≤300 KB.

## How it's wired in the Astro starter

- **`scripts/generate_og_cards.py`** — the generator. Renders one card per page you list,
  plus a `default.jpg`. Run it with **`npm run og`**. Idempotent; commit the output.
- **`src/layouts/Base.astro`** — `image` prop defaults to `/images/og/default.jpg`. A page
  gets its own card by passing `image="/images/og/<slug>.jpg"`; everything else falls back
  to the default. `og:image` + `twitter:image` are derived from that one prop.
- **`tests/seo.spec.ts`** — for every page, reads the referenced card off disk and asserts
  it exists in `public/`, is ≤300 KB, a real JPEG/PNG, and exactly 1200×630. It also enforces
  that every page has its **own** card (not the generic default) and that no two pages share
  one — except an `OWN_CARD_EXEMPT` allowlist (home + utility pages, default `['/', '/privacy']`).
  Opt-out: as you add content pages, leave them OUT of the allowlist and the guard flags any
  that forgot a card. A missing/oversized/shared card **fails the build** — OG cards can't
  silently rot, and a new page can't silently ship on the generic default.

The starter ships a generated `public/images/og/default.jpg` so the suite is green from
commit 1; you replace it with your brand by editing the script and re-running `npm run og`.

## Setup for a site (two edits, one command)

1. **Fill the `BRAND` block** in `scripts/generate_og_cards.py` — `BRAND_NAME`,
   `SITE_URL_LABEL`, the background/accent/text colours (copy the hexes from
   `src/styles/global.css` / `BRAND.md`), and optionally point `LOGO` at a transparent
   **raster** PNG for a faint emblem watermark (SVG won't load — Pillow needs raster).
   The card is a **dark** share card by design: light text on a deep background reads well
   in every chat app regardless of whether the site theme is light or dark.
2. **List per-page cards** in the `PAGES` block — one `(slug, title, [subtitle, …])` per
   page that deserves its own card. Then set `image="/images/og/<slug>.jpg"` on that page's
   `<Base>`. Pages you don't list use `default.jpg` automatically.
3. **Run `npm run og`**, then `npm test`. Commit the generated JPEGs.

`npm run og -- --check` exits non-zero if any card exceeds 300 KB — wire it into CI or the
pre-push hook if you want generation (not just validation) gated.

## Prerequisite

Pillow, a local authoring dependency only (not shipped to the site or its runtime):

```bash
pip3 install Pillow
```

## Going further — photo-driven cards

The generator deliberately ships the **text-card** core (emblem + wordmark + accent bar +
title + subtitles + footer URL) because it works for any site with zero per-page art. When
a content type has a natural image — a speaker headshot, a product shot, an author avatar —
extend `build_page()` to read that image from frontmatter and composite a circular portrait
+ glow beside the text: iterate a content collection (`src/content/<type>/*.md`), render one
portrait card per entry alongside the static-page cards, and keep the same encode-under-300 KB
step. The template ships the text-card core; the portrait variant is a small, additive change.

## Don't

- **Don't hand-export cards in Figma/Canva per page.** They drift and balloon past 300 KB.
  The script is the single source of truth; re-running it is free.
- **Don't ship a square logo as `og:image`.** It crops badly. Always 1200×630.
- **Don't point `og:image` at a remote/CDN URL the repo can't verify.** Keep cards on-site
  in `public/` so `seo.spec.ts` can prove they exist and fit.