···11+# Open Graph meta tags for publication & document pages
22+33+**Date:** 2026-06-09
44+**Status:** Approved (brainstorm)
55+66+## Goal
77+88+Extend the rich link previews (Open Graph + Twitter Cards) already shipped on the
99+static pages to the two dynamic public reading surfaces a visitor actually
1010+shares: a **publication** (`/@handle/slug`) and a **document/article**
1111+(`/@handle/slug/rkey`). After this, both surfaces get a complete OG/Twitter tag
1212+set with a publication-branded share image.
1313+1414+## Current state
1515+1616+- **Static pages** (`/`, `/lexicon`, `/preview`): full OG/Twitter set via
1717+ `Base.astro` + `buildMetaTags`. Done (prior brief).
1818+- **Publication page** (`src/pages/[author]/[slug]/index.astro`): renders
1919+ `<Base title description>` *without* opting out, so it already emits the full
2020+ set — but always with the generic default image (`/og-default.png`) and
2121+ `og:type=website`. It never uses the publication's own logo.
2222+- **Document page** (`src/pages/[author]/[slug]/[rkey].astro`): opts out
2323+ (`socialMeta={false}`) because it owns a special canonical (the atproto
2424+ `canonicalArticleUrl`) and `og:type=article`. It hand-rolls only four tags —
2525+ `og:type`, `og:title`, `og:description`, `og:url`. **Missing:** `og:site_name`,
2626+ `og:image`, all `og:image:*`, and the entire Twitter card set. This is the
2727+ biggest gap: shared articles have no image and no Twitter card.
2828+2929+## Image source (decided)
3030+3131+Articles have **no** cover-image field in the `site.standard.document` lexicon,
3232+and the only imagery lives inside body blocks. The agreed chain for **both**
3333+pages is:
3434+3535+> **publication logo (`publication.icon` blob) → shared default `/og-default.png`**
3636+3737+A publication and its articles then share one recognizable, on-brand image with
3838+near-zero complexity.
3939+4040+**Out of scope (YAGNI):** a per-article cover-image lexicon field, scanning body
4141+blocks for a representative image, and using the author's Bluesky banner/avatar.
4242+4343+## Approach
4444+4545+### 1. `buildMetaTags` helper — `src/lib/seo/meta.ts`
4646+4747+The dimensions `1200×630` are currently hardcoded. That is true for the default
4848+share image but a lie for a square publication logo.
4949+5050+- Add optional `imageWidth?: number` and `imageHeight?: number` to
5151+ `MetaTagInput`.
5252+- Emit `og:image:width` / `og:image:height` **only when both are present**.
5353+- Everything else is unchanged.
5454+5555+### 2. `Base.astro`
5656+5757+- Add optional `imageWidth` / `imageHeight` props.
5858+- Derive defaults so existing static pages are byte-for-byte unchanged: when the
5959+ resolved image is the built-in default (`/og-default.png`), use `1200/630`;
6060+ when a caller passes a custom image and no dimensions, omit them.
6161+- Pass the resolved dimensions through to `buildMetaTags`.
6262+6363+### 3. Publication page — `src/pages/[author]/[slug]/index.astro`
6464+6565+- Build the logo's absolute blob URL via the existing `buildGetBlobUrl`
6666+ (`publication.icon`, `pdsUrl`, `did` are already in scope).
6767+- Pass `image={logoUrl ?? undefined}` and `imageAlt={publication.name}` to
6868+ `Base`. No dimensions when a logo is used; when there's no logo it transparently
6969+ falls back to the default image at `1200×630`.
7070+- `og:type` stays `website`. No opt-out — `Base` keeps emitting the full set.
7171+7272+### 4. Document page — `src/pages/[author]/[slug]/[rkey].astro`
7373+7474+Keeps `socialMeta={false}` (it owns the atproto canonical + `og:type=article`).
7575+7676+- Import `buildMetaTags` and `buildGetBlobUrl`.
7777+- Resolve the publication-logo blob URL, else the default `/og-default.png`
7878+ resolved absolute via `new URL( image, Astro.site )`.
7979+- Call:
8080+8181+ ```ts
8282+ buildMetaTags( {
8383+ title,
8484+ description,
8585+ url: canonical,
8686+ image,
8787+ siteName: 'SkyPress',
8888+ type: 'article',
8989+ imageAlt: publication.name,
9090+ } )
9191+ ```
9292+9393+ and render the full set in the head slot — replacing the four partial tags.
9494+ The existing `standard.site` link tags + canonical link stay untouched.
9595+- Result: articles gain `og:image`, `og:site_name`, and the full Twitter card.
9696+9797+## Testing (TDD — failing test first)
9898+9999+- `src/lib/seo/meta.test.ts`: `og:image:width/height` emitted only when both
100100+ passed; omitted otherwise.
101101+- `src/layouts/Base.meta.test.ts`: a custom image without dimensions omits the
102102+ `og:image:width/height` tags; the default image still emits `1200×630`.
103103+- Source-level page assertions for the publication + document pages, matching the
104104+ repo's existing pure-logic / source-pin convention (the runner is jsdom-pinned,
105105+ so page rendering through `astro/container` is not viable — see the note atop
106106+ `Base.meta.test.ts`):
107107+ - Publication page passes `image=` (a resolved logo URL) and `imageAlt=` to
108108+ `Base`.
109109+ - Document page calls `buildMetaTags` with `type: 'article'` and renders the
110110+ resulting tags, and no longer hand-rolls a partial set.
111111+112112+## Risks / notes
113113+114114+- Blob image URLs point at the writer's PDS (an external host). OG scrapers fetch
115115+ them directly — expected and fine. The logo is square, which is why we drop the
116116+ `1200×630` assertion for it rather than mis-declaring dimensions.