og-images · v1.0.0 · 2026-06-20 · sha256 e3df95d6c2b04e5d
og-images v1.0.0A
Immutable. This exact content never changes and is served at /api/v1/blob/e3df95d6c2b04e5d.
--- 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"`; a page with no `image=` falls back to the default — which only **exempt utility pages** should do (the test below enforces it). `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 content page. Then set `image="/images/og/<slug>.jpg"` on that page's `<Base>`. Only **exempt utility pages** (home, privacy, …) may fall back to `default.jpg`; every other content page must be listed here and wired to its own card, or `seo.spec.ts` fails it. 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 ``` On **Windows**, run authoring/build inside **WSL2** — the suite's recommended Windows environment (`SETUP.md`), where `python3` and the bash helpers all work. Running natively is fine too: `py -m pip install Pillow`, and `npm run og` already falls back `python3 → py -3 → python`. ## 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.