# OG cards — `@sonordev/site-kit/og`

Social cards, rendered at build time by headless Chrome. Real CSS, real
webfonts, no Satori subset, nothing at runtime.

```bash
npx sonor-setup og
```

That renders the site card to `public/og.png` **and one card per route**.

## The one rule that will bite you

**Config metadata beats the file convention.** A route whose `generateMetadata`
returns `openGraph.images` overrides its own `opengraph-image` file.

So a site using per-page cards must declare **no images anywhere**:

```ts
// ✅ root layout — no images array
openGraph: { type: 'website', locale: 'en_US', siteName: 'Acme' },
twitter:   { card: 'summary_large_image' },
```

```ts
// ❌ this silently disables EVERY per-page card
openGraph: { images: ['/og.png'] },
```

That includes **`seo_pages.managed_og_image`** in Sonor. site-kit serves it into
`openGraph.images` for every managed page, so setting it suppresses that page's
card from the dashboard, with nothing in the repo to explain why. `sonor-setup
og` reads it for every route while it titles the cards and names the pages that
set one; `sonor-setup doctor --online` does the same. Offline, the doctor says
nothing about it (it used to warn on every per-page site, set or not).

The one image a page may declare is its own **per-URL card** or a **code card on
a `trailingSlash` site** (both below). Those replace the route's card on purpose.

This was verified against real builds, both ways: with images declared, all 32
routes on a real site served the same card and every generated page card was
inert; removing the declaration made each route serve its own.

A second surprise from the same investigation: **file metadata does not cascade
to child segments.** A card at `services/` is not inherited by
`services/[city]`. Dynamic segments get their own card (one file covers every
param), which the generator does automatically.

`sonor-setup og` and `sonor-setup doctor` both check this through one shared
rule (`og/wiring.ts`), so the two never disagree. The rule reads the site's
code through
`shared/source-graph.ts`, which follows imports into `lib/` helpers and monorepo
workspace packages, so `twitter.card` set in a metadata helper counts. It used
to read the root layout alone.

## Writing the card

`og.config.ts` at the site root owns the theme. Sonor's brand data is only a
zero-config seed for sites that have no config yet.

```ts
import { defineOgCard } from '@sonordev/site-kit/og'

export default defineOgCard({
  theme: { bg: '#0F172A', text: '#F8FAFC', accent: '#F59E0B', surface: '#1E293B' },
  fonts: [{ family: 'Fraunces', weights: [800] }],
  logo: '/logo-white.svg',
  layout: 'split',
  photo: { src: '/crew.jpg' },
  content: {
    kicker: 'Remodeling · Springfield',
    title: 'Built by\nneighbors',
    subtitle: 'Designed, built and installed by one local crew.',
    bar: ['Free estimate', 'example.com'],
  },
})
```

Layouts: `plate-left` (logo plate + copy), `centered`, `banner` (copy only),
`split` (copy left, photo right). In `split` a configured `logo` renders as a
small mark above the kicker.

### Copy is fitted, not guessed

The title opens at 104px and the renderer steps it down until it fits **both**
axes, stopping at a 56px legibility floor — below that a headline stops reading
at the \~300px thumbnail width platforms actually show.

If copy still doesn't fit at the floor, the command **fails** and names the
element and the overflow in pixels. That's deliberate: otherwise a card can ship
with the kicker off-canvas and the subtitle buried under the bottom bar while
the CLI prints a tick.

Rules of thumb: about 10 uppercase characters per title line, and about 29 for
the kicker. `\n` in a title is a hard break, so choose the wrap yourself rather
than leaving it to the box.

## Per-page cards

Every static route gets a card, plus one per dynamic segment. Copy comes from
Sonor's managed title and description when `SONOR_API_KEY` is set, so a card and
its search result say the same thing; otherwise the route path is titled.
Everything else is inherited from the site card, so the set reads as one family.

Hand-write the few that deserve it:

```ts
cards: {
  '/free-estimate': {
    content: { kicker: 'Free estimate', title: 'Know the\ncost first' },
    photo: { src: '/lp/hero.jpg' },
  },
},
```

Keyed by route path or slug (`services-garage`), layered over the derived card,
so an entry only states what differs.

Managed titles carry the brand for the SERP (`About Us | Acme`); the card drops
it. It also drops the two broken suffixes managed titles turn up with: a dangling
separator with no brand after it (`About Us |`) and a domain after a comma
(`Privacy Policy, example.com`). A hyphen inside a word is never a
separator (`Walk-In Showers` stays whole).

### Dynamic routes are keyed by pattern, one `*` per level

A dynamic segment has no single URL, so its card is keyed by the route pattern:
`services/[slug]` is `/services/*`, and `services/[slug]/[metro]` is
`/services/*/*`. Slug form is `services-any` and `services-any-any`.

Depth matters because a card file lives beside each `page.tsx`, so those two
directories are two different cards. They are also the one place the generator
runs out of facts: neither pattern has managed metadata of its own, so **both
derive their copy from the nearest static ancestor** (`/services`) and come out
identical. If the deeper route deserves its own words, write them:

```ts
cards: {
  '/services/*':   { content: { title: 'What we do' } },
  '/services/*/*': { content: { kicker: 'Service areas', title: 'Near you' } },
},
```

The generator keys each depth separately, so a `/services/*/*` entry reaches
the service-by-metro pages and nothing else. A `cards` key that matches no
rendered route is reported by `sonor-setup og` (`og.cards`, a warning), since
otherwise it would match nothing, silently.

> **Writing a nested pattern in a comment.** `/services/*/*` contains `*/`,
> which **closes a `/* */` block comment early**. In TypeScript put it in a
> string, a `//` line comment, or spell the depth out in prose. It's an easy
> slip in exactly the comment that explains the pattern.

### Static routes under a dynamic segment get a card too

`properties/[slug]/about` is a static route under a dynamic one. It gets its
own card, keyed `/properties/*/about` and titled from its own name ("About",
kicker "properties"). The generator used to stop at the first dynamic segment,
which left these pages with no og:image at all.

### Per-URL cards: one per floor plan, one per community

A static file in a dynamic segment covers every param, so `/floor-plans/*` is
one card for every plan. When each page deserves its own, key `cards` by the
URL itself. `sonor-setup og` loads og.config.ts with plain Node, so it can
import the site's data only through a relative path with the `.ts` extension
and no `@/` aliases; a short list inline is often simpler:

```ts
// og.config.ts
import { defineOgCard } from '@sonordev/site-kit/og'

const floorPlans = [
  { slug: '1-bedroom', name: '1 Bedroom', image: '/living-room.webp' },
  { slug: '2-bedroom', name: '2 Bedroom', image: '/kitchen.webp' },
]

export default defineOgCard({
  // ...theme, content
  cards: {
    '/floor-plans/*': { content: { title: 'Floor\nplans' } }, // the fallback
    ...Object.fromEntries(
      floorPlans.map(plan => [
        `/floor-plans/${plan.slug}`,
        { content: { kicker: 'Floor plan', title: plan.name }, photo: { src: plan.image } },
      ]),
    ),
  },
})
```

`sonor-setup og` renders each to `public/_og/<url>.jpg` (the kit owns that
directory and clears it every run), and the page declares its own:

```ts
// app/floor-plans/[slug]/page.tsx
import { paramCardImage } from '@sonordev/site-kit/og'
import ogConfig from '../../../og.config'

export async function generateMetadata({ params }) {
  const { slug } = await params
  const card = paramCardImage(`/floor-plans/${slug}`, { config: ogConfig })
  return {
    openGraph: { ...(card && { images: [card] }) },
    twitter: { card: 'summary_large_image', ...(card && { images: [card.url] }) },
  }
}
```

With `config`, a page with no entry gets undefined and keeps the segment card.
The URL ends in `.jpg`, so a `trailingSlash: true` site never redirects it.
Per-URL cards need `sharp` (they have to be `.jpg`); without it the command
fails those cards and says so. Their copy comes from Sonor's managed title for
that URL, like any route card. A per-URL key matches a pattern one segment per
`*`, so `/properties/maple-court/about` falls under `/properties/*/about`.

Cards are re-encoded to JPEG when `sharp` resolves — on a real 18-card site that
was 5.6 MB → 1.4 MB. Without sharp they stay PNG, which is correct, just heavier.

`--no-pages` renders only the site card.

## When cards go stale

Cards are build-time artifacts of the copy at generation time. If managed titles
change in Sonor, re-run `sonor-setup og` — nothing re-renders them automatically.
Worth adding to the same routine as a content pass.

## Per-entity cards (tier 2)

For cards that must vary per *record* and can't wait for a rebuild — one per
article published from Sonor, per event — `@sonordev/site-kit/og/route`
renders on demand with next/og. Use the metadata file convention:

```tsx
// app/article/[slug]/opengraph-image.tsx
import { createOgImage } from '@sonordev/site-kit/og/route'
import { getArticle } from '@sonordev/site-kit/articles/server'
import ogConfig from '../../../og.config'

export { size, contentType } from '@sonordev/site-kit/og/route'
export const alt = 'From the Acme article'

export default createOgImage<{ slug: string }>(async ({ slug }) => {
  const post = await getArticle(slug)
  if (!post) return null // 404
  return {
    theme: ogConfig.theme,
    kicker: 'From the publication',
    title: post.title,
    photoUrl: post.featured_image, // must be absolute
    bar: 'example.com',
  }
})
```

Next calls it with `{ params }` and wires og:image for the post by itself, and
`sonor-setup og` sees the file and leaves the folder alone. The post page passes
`images: false` to `generateArticleMetadata`, or the featured image beats the
card (the doctor flags a page that doesn't).

`createOgImageRoute` is the same card as a route handler, `GET(req, { params })`,
for a card at a URL of your own. Next doesn't wire a route handler into
metadata. It used to be the only shape, so sites wrote an adapter to use it
from `opengraph-image.tsx`; delete those for `createOgImage`.

The runtime card fits its title the way the build-time card does, from an
estimate since Satori can't measure: 104px down to the 56px floor. When the
title can't fit beside the photo even at the floor, the photo goes and the
title gets the full width. No more hand-picked "drop the photo past 40
characters". The bottom bar's text defaults to whichever of `surface`, `bg` and
`text` reads on the accent (WCAG 3:1 for large text), in that order; set
`theme.barText` to choose. It used to be `text`, which put navy on crimson. The
build-time card uses the same rule, and keeps `surface` wherever it already
read.

Satori's constraints still apply: a flexbox-only CSS subset, no external
stylesheets, images as absolute URLs or data URIs (a relative `photoUrl` is
dropped), and fonts supplied as ArrayBuffers. Reach for it only when the cards
can't be rendered at build time; per-URL cards in `og.config.ts` cover
everything `generateStaticParams` can list.

### Code cards on a `trailingSlash: true` site

Next serves a code card at `<page>/opengraph-image`, with no extension, and
`trailingSlash: true` 308-redirects that URL, so every share preview starts
with a redirect. Declare the slashed URL from the page instead:

```ts
import { codeCardImage } from '@sonordev/site-kit/og'

openGraph: { images: [codeCardImage(`/article/${slug}`)] }, // /article/x/opengraph-image/
```

`trailingSlash` defaults to the site's next.config. `sonor-setup og` and the
doctor flag a code card on a `trailingSlash` site whose page doesn't. A code card
at `opengraph-image/route.tsx` (a route handler folder) is recognised too, so
the generator no longer writes an `opengraph-image.jpg` beside it, which Next
refused to build.

## Reminder

Facebook caches aggressively. After deploying a new card, re-scrape at
<https://developers.facebook.com/tools/debug/>.
