A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

Add design spec for OG meta tags on publication & document pages

+116
+116
docs/superpowers/specs/2026-06-09-open-graph-meta-tags-pub-doc-design.md
··· 1 + # Open Graph meta tags for publication & document pages 2 + 3 + **Date:** 2026-06-09 4 + **Status:** Approved (brainstorm) 5 + 6 + ## Goal 7 + 8 + Extend the rich link previews (Open Graph + Twitter Cards) already shipped on the 9 + static pages to the two dynamic public reading surfaces a visitor actually 10 + shares: a **publication** (`/@handle/slug`) and a **document/article** 11 + (`/@handle/slug/rkey`). After this, both surfaces get a complete OG/Twitter tag 12 + set with a publication-branded share image. 13 + 14 + ## Current state 15 + 16 + - **Static pages** (`/`, `/lexicon`, `/preview`): full OG/Twitter set via 17 + `Base.astro` + `buildMetaTags`. Done (prior brief). 18 + - **Publication page** (`src/pages/[author]/[slug]/index.astro`): renders 19 + `<Base title description>` *without* opting out, so it already emits the full 20 + set — but always with the generic default image (`/og-default.png`) and 21 + `og:type=website`. It never uses the publication's own logo. 22 + - **Document page** (`src/pages/[author]/[slug]/[rkey].astro`): opts out 23 + (`socialMeta={false}`) because it owns a special canonical (the atproto 24 + `canonicalArticleUrl`) and `og:type=article`. It hand-rolls only four tags — 25 + `og:type`, `og:title`, `og:description`, `og:url`. **Missing:** `og:site_name`, 26 + `og:image`, all `og:image:*`, and the entire Twitter card set. This is the 27 + biggest gap: shared articles have no image and no Twitter card. 28 + 29 + ## Image source (decided) 30 + 31 + Articles have **no** cover-image field in the `site.standard.document` lexicon, 32 + and the only imagery lives inside body blocks. The agreed chain for **both** 33 + pages is: 34 + 35 + > **publication logo (`publication.icon` blob) → shared default `/og-default.png`** 36 + 37 + A publication and its articles then share one recognizable, on-brand image with 38 + near-zero complexity. 39 + 40 + **Out of scope (YAGNI):** a per-article cover-image lexicon field, scanning body 41 + blocks for a representative image, and using the author's Bluesky banner/avatar. 42 + 43 + ## Approach 44 + 45 + ### 1. `buildMetaTags` helper — `src/lib/seo/meta.ts` 46 + 47 + The dimensions `1200×630` are currently hardcoded. That is true for the default 48 + share image but a lie for a square publication logo. 49 + 50 + - Add optional `imageWidth?: number` and `imageHeight?: number` to 51 + `MetaTagInput`. 52 + - Emit `og:image:width` / `og:image:height` **only when both are present**. 53 + - Everything else is unchanged. 54 + 55 + ### 2. `Base.astro` 56 + 57 + - Add optional `imageWidth` / `imageHeight` props. 58 + - Derive defaults so existing static pages are byte-for-byte unchanged: when the 59 + resolved image is the built-in default (`/og-default.png`), use `1200/630`; 60 + when a caller passes a custom image and no dimensions, omit them. 61 + - Pass the resolved dimensions through to `buildMetaTags`. 62 + 63 + ### 3. Publication page — `src/pages/[author]/[slug]/index.astro` 64 + 65 + - Build the logo's absolute blob URL via the existing `buildGetBlobUrl` 66 + (`publication.icon`, `pdsUrl`, `did` are already in scope). 67 + - Pass `image={logoUrl ?? undefined}` and `imageAlt={publication.name}` to 68 + `Base`. No dimensions when a logo is used; when there's no logo it transparently 69 + falls back to the default image at `1200×630`. 70 + - `og:type` stays `website`. No opt-out — `Base` keeps emitting the full set. 71 + 72 + ### 4. Document page — `src/pages/[author]/[slug]/[rkey].astro` 73 + 74 + Keeps `socialMeta={false}` (it owns the atproto canonical + `og:type=article`). 75 + 76 + - Import `buildMetaTags` and `buildGetBlobUrl`. 77 + - Resolve the publication-logo blob URL, else the default `/og-default.png` 78 + resolved absolute via `new URL( image, Astro.site )`. 79 + - Call: 80 + 81 + ```ts 82 + buildMetaTags( { 83 + title, 84 + description, 85 + url: canonical, 86 + image, 87 + siteName: 'SkyPress', 88 + type: 'article', 89 + imageAlt: publication.name, 90 + } ) 91 + ``` 92 + 93 + and render the full set in the head slot — replacing the four partial tags. 94 + The existing `standard.site` link tags + canonical link stay untouched. 95 + - Result: articles gain `og:image`, `og:site_name`, and the full Twitter card. 96 + 97 + ## Testing (TDD — failing test first) 98 + 99 + - `src/lib/seo/meta.test.ts`: `og:image:width/height` emitted only when both 100 + passed; omitted otherwise. 101 + - `src/layouts/Base.meta.test.ts`: a custom image without dimensions omits the 102 + `og:image:width/height` tags; the default image still emits `1200×630`. 103 + - Source-level page assertions for the publication + document pages, matching the 104 + repo's existing pure-logic / source-pin convention (the runner is jsdom-pinned, 105 + so page rendering through `astro/container` is not viable — see the note atop 106 + `Base.meta.test.ts`): 107 + - Publication page passes `image=` (a resolved logo URL) and `imageAlt=` to 108 + `Base`. 109 + - Document page calls `buildMetaTags` with `type: 'article'` and renders the 110 + resulting tags, and no longer hand-rolls a partial set. 111 + 112 + ## Risks / notes 113 + 114 + - Blob image URLs point at the writer's PDS (an external host). OG scrapers fetch 115 + them directly — expected and fine. The logo is square, which is why we drop the 116 + `1200×630` assertion for it rather than mis-declaring dimensions.