@djangocfg/nextjs 2.1.438 → 2.1.440

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,30 +1,100 @@
1
1
  # @djangocfg/nextjs/og-image
2
2
 
3
- Typed URL builder for Django's `django_ogimage` renderer.
3
+ Page metadata + typed OG image builder for Django's `django_ogimage` renderer.
4
4
 
5
5
  **No Edge Runtime. No `@vercel/og`. No JSX templates.**
6
6
  Django renders and caches the PNG — Next.js only builds the URL and injects it into metadata.
7
7
 
8
8
  ---
9
9
 
10
+ ## Quick start
11
+
12
+ One factory per app; pages declare only what's unique. The OG image is chosen by
13
+ a single `og` field (defaults to your static brand image):
14
+
15
+ ```typescript
16
+ // app/_core/metadata.ts — bind the factory once
17
+ import { makeMetadataFactory } from '@djangocfg/nextjs/og-image'
18
+ import { settings } from './settings'
19
+ import { LOCALES } from '@i18n/locales.config'
20
+
21
+ export const createMetadata = makeMetadataFactory({
22
+ name: settings.app.name,
23
+ description: settings.app.description,
24
+ siteUrl: settings.app.siteUrl,
25
+ ogImage: settings.app.media.ogimage, // static brand card (the default)
26
+ icons: { icon: settings.app.media.favicon },
27
+ locales: LOCALES, // omit for single-locale sites
28
+ })
29
+
30
+ // any page.tsx
31
+ export const metadata = createMetadata({ title: 'Pricing', path: '/pricing' })
32
+ // ↑ static brand image, canonical, hreflang, og, twitter, icons
33
+ ```
34
+
35
+ ```typescript
36
+ // content page — render the title onto the OG card
37
+ export const metadata = createMetadata({ title: post.title, path, og: 'dynamic' })
38
+
39
+ // content page with its own image (a screenshot, cover, …)
40
+ export const metadata = createMetadata({ title: p.name, path, og: { image: p.image, alt: p.name } })
41
+ ```
42
+
43
+ ### The `og` axis (`OgSpec`)
44
+
45
+ A single discriminated union decides what the OG image is — there is exactly one
46
+ way to express each case:
47
+
48
+ | `og` value | Result |
49
+ |---|---|
50
+ | omitted / `'static'` | the brand image from `config.ogImage` (default) |
51
+ | `'dynamic'` | Django renders the page **title** onto the card |
52
+ | `{ render: { preset, layout, … } }` | dynamic render with explicit params |
53
+ | `{ image, alt? }` | an exact image URL (content screenshot, cover) |
54
+
55
+ ### No backend? Dynamic falls back to static
56
+
57
+ The `'dynamic'` and `{ render }` cases need a Django renderer. Tell the factory
58
+ where it is — or that there isn't one — via `ogBackend`:
59
+
60
+ ```typescript
61
+ // has a backend → dynamic OG renders against it
62
+ makeMetadataFactory({ ..., ogBackend: 'https://api.example.com' })
63
+
64
+ // frontend with NO Django backend → og:'dynamic' silently becomes the static
65
+ // brand image, so you never emit a broken /cfg/og/ URL
66
+ makeMetadataFactory({ ..., ogBackend: '' })
67
+
68
+ // omit → resolve from env (MEDIA_URL → API_URL → SITE_URL)
69
+ makeMetadataFactory({ ... })
70
+ ```
71
+
72
+ So a static-only site can leave `og: 'dynamic'` on content pages without breaking
73
+ anything — set `ogBackend: ''` once and every dynamic spec degrades to static.
74
+
75
+ ---
76
+
10
77
  ## How It Works
11
78
 
12
79
  ```
13
- generateMetadata() / withOgImage()
14
-
15
- buildOgUrl(params) — base64-encodes OGImageParams
80
+ createMetadata({ … , og }) picks the OG image by the single `og` field
81
+ (dynamic cases only)
82
+ buildOgUrl(params) — base64-encodes OGImageParams
16
83
 
17
- resolveOgPublicBase() — picks the right base URL from env vars
84
+ resolveOgPublicBase() — picks the right base URL from env vars
18
85
 
19
86
  <meta og:image> = "{base}/cfg/og/{b64}/"
20
87
 
21
88
  Crawler/browser hits that URL
22
89
 
23
- Django OGImageRenderView — decodes params, renders PNG via PicTex
90
+ Django OGImageRenderView — decodes params, renders PNG via PicTex
24
91
 
25
92
  FileResponse (cached in MEDIA_ROOT/ogimage/<sharded>/<key>.png)
26
93
  ```
27
94
 
95
+ Static / `{ image }` cases skip Django entirely — the URL points straight at the
96
+ given image.
97
+
28
98
  ---
29
99
 
30
100
  ## URL Resolution
@@ -66,45 +136,71 @@ NEXT_PUBLIC_MEDIA_URL=https://cdn.example.com
66
136
 
67
137
  ## API
68
138
 
69
- ### `buildOgUrl(params)`
139
+ ### Recommended
70
140
 
71
- Builds the raw OG image URL string. Useful when you need the URL directly.
141
+ #### `makeMetadataFactory(config) (page) => Metadata`
72
142
 
73
- ```typescript
74
- import { buildOgUrl } from '@djangocfg/nextjs/og-image'
143
+ Binds a `SiteMetaConfig` once and returns a per-page factory. This is the single
144
+ recommended entry point.
75
145
 
76
- const url = buildOgUrl({ title: 'Hello', preset: 'DARK_BLUE' })
77
- // "https://api.example.com/cfg/og/eyJ0aXRsZSI6Ikhlb.../"
146
+ ```typescript
147
+ const createMetadata = makeMetadataFactory(config)
148
+ export const metadata = createMetadata({ title: 'About', path: '/about' })
78
149
  ```
79
150
 
80
- ### `createOgMetadata(params)`
151
+ **`SiteMetaConfig`**
81
152
 
82
- Returns a Next.js `Metadata` fragment with `openGraph.images` and `twitter.images`.
153
+ | Field | Type | Notes |
154
+ |---|---|---|
155
+ | `name` | `string` | brand name (titles, siteName, alt) |
156
+ | `description` | `string` | default description |
157
+ | `siteUrl` | `string` | absolute origin, e.g. `https://example.com` |
158
+ | `ogImage` | `string` | static brand OG image URL |
159
+ | `ogBackend?` | `string` | Django origin for the dynamic renderer (see below) |
160
+ | `icons?` | `Metadata['icons']` | favicon / apple-touch |
161
+ | `locales?` | `readonly string[]` | for hreflang; omit for single-locale |
162
+ | `defaultLocale?` | `string` | x-default locale (defaults `en` / first) |
163
+
164
+ **`PageMeta`**
165
+
166
+ | Field | Type | Notes |
167
+ |---|---|---|
168
+ | `title?` | `string` | falls back to `config.name` |
169
+ | `description?` | `string` | falls back to `config.description` |
170
+ | `keywords?` | `string[]` | |
171
+ | `locale?` | `string` | canonical + `openGraph.locale` |
172
+ | `path?` | `string` | path without locale prefix, `''` for home |
173
+ | `og?` | `OgSpec` | the OG image axis (see table above) |
174
+ | `extra?` | `Metadata` | escape hatch merged last (robots, authors, title-template, …) |
83
175
 
84
- ```typescript
85
- import { createOgMetadata } from '@djangocfg/nextjs/og-image'
176
+ #### `createMetadata(config, page)`
86
177
 
87
- export async function generateMetadata() {
88
- return createOgMetadata({ title: 'Page', preset: 'DARK_BLUE' })
89
- }
90
- // → { openGraph: { images: [{ url, width: 1200, height: 630 }] }, twitter: { ... } }
91
- ```
178
+ The un-curried form `makeMetadataFactory` wraps. Use when you don't want a bound factory.
92
179
 
93
- ### `withOgImage(metadata, params)`
180
+ ### Low-level (escape hatches)
94
181
 
95
- Merges OG image into an existing `Metadata` object. Auto-extracts `title` from metadata if not in params.
182
+ Most pages never need these they're the primitives the factory is built on.
96
183
 
97
- ```typescript
98
- import { withOgImage } from '@djangocfg/nextjs/og-image'
184
+ #### `buildOgUrl(params)`
99
185
 
100
- export async function generateMetadata(): Promise<Metadata> {
101
- return withOgImage(
102
- { title: 'Product Page', description: 'Buy now' },
103
- { preset: 'DARK_BLUE', layout: 'HERO' }
104
- )
105
- }
186
+ Raw OG image URL string for the dynamic renderer.
187
+
188
+ ```typescript
189
+ const url = buildOgUrl({ title: 'Hello', preset: 'DARK_BLUE' })
190
+ // → "https://api.example.com/cfg/og/eyJ0aXRsZSI6Ikhlb.../"
106
191
  ```
107
192
 
193
+ #### `createOgMetadata(params)` · `withOgImage(metadata, params)`
194
+
195
+ `createOgMetadata` returns a `{ openGraph, twitter }` image fragment for the
196
+ dynamic renderer; `withOgImage` merges that fragment into an existing `Metadata`
197
+ (auto-extracting `title`). Both are for manual metadata assembly outside the factory.
198
+
199
+ #### `resolveOgImage(spec, staticUrl, fallbackTitle)`
200
+
201
+ The single decision point that turns an `OgSpec` into an image fragment — exported
202
+ for advanced custom factories.
203
+
108
204
  ---
109
205
 
110
206
  ## OGImageParams
@@ -1,6 +1,11 @@
1
- export type { OGImageParams, OGPreset, OGLayout } from './types'
2
- export { buildOgUrl } from './url'
3
- export { createOgMetadata, withOgImage } from './metadata'
4
- export type { WithOgImageOptions } from './metadata'
1
+ // ── Recommended API ──────────────────────────────────────────────────────────
2
+ // Bind the factory once per app, then call it from each page.
5
3
  export { createMetadata, makeMetadataFactory } from './metadata-factory'
6
4
  export type { SiteMetaConfig, PageMeta } from './metadata-factory'
5
+
6
+ // ── Types ────────────────────────────────────────────────────────────────────
7
+ export type { OGImageParams, OGPreset, OGLayout, OgSpec } from './types'
8
+
9
+ // ── Low-level primitives (escape hatches) ────────────────────────────────────
10
+ export { buildOgUrl } from './url'
11
+ export { createOgMetadata, withOgImage, resolveOgImage, extractTitle } from './metadata'
@@ -1,6 +1,6 @@
1
1
  import type { Metadata } from 'next'
2
- import type { OGImageParams } from './types'
3
- import { withOgImage, type WithOgImageOptions } from './metadata'
2
+ import type { OgSpec } from './types'
3
+ import { resolveOgImage } from './metadata'
4
4
 
5
5
  /**
6
6
  * Site-wide identity the metadata factory needs. Config-agnostic on purpose —
@@ -16,34 +16,48 @@ export interface SiteMetaConfig {
16
16
  siteUrl: string
17
17
  /** Ready-made static brand OG image URL (absolute or root-relative). */
18
18
  ogImage: string
19
+ /**
20
+ * Django origin that serves the dynamic OG renderer (`/cfg/og/`), e.g.
21
+ * "https://api.example.com". Required for `og: 'dynamic'` / `{ render }`.
22
+ *
23
+ * - set → dynamic OG images render against this origin
24
+ * - `''` → no backend: dynamic specs fall back to the static brand image
25
+ * (use this for a frontend with no Django, so no broken URLs)
26
+ * - omit → fall back to env vars (MEDIA_URL → API_URL → SITE_URL)
27
+ */
28
+ ogBackend?: string
19
29
  /** Optional favicon / apple-touch icons forwarded to Metadata.icons. */
20
30
  icons?: Metadata['icons']
21
- /** Locales for hreflang alternates (first is treated as x-default if no enDefault). */
31
+ /** Locales for hreflang alternates. Omit for single-locale sites. */
22
32
  locales?: readonly string[]
23
33
  /** Locale used for x-default hreflang (defaults to "en" or first locale). */
24
34
  defaultLocale?: string
25
35
  }
26
36
 
27
- /** Per-page inputs. Everything is optional except what makes the page unique. */
37
+ /**
38
+ * Per-page inputs. Only `title` is really worth setting per page; everything
39
+ * else falls back to the site config.
40
+ */
28
41
  export interface PageMeta {
29
- /** Page title (without the brand suffix the template adds it). */
42
+ /** Page title (without the brand suffix). Falls back to the site name. */
30
43
  title?: string
31
44
  /** Page description; falls back to the site description. */
32
45
  description?: string
33
46
  keywords?: string[]
34
47
  /** Current locale, used for canonical + openGraph.locale + alternates. */
35
48
  locale?: string
36
- /** Path without locale prefix, e.g. "/skills". '' for home. Drives alternates. */
49
+ /** Path without locale prefix, e.g. "/skills". '' for home. */
37
50
  path?: string
38
51
  /**
39
- * OG image control, forwarded to `withOgImage`:
40
- * - omitted → static brand image (`config.ogImage`) — the safe default
41
- * - `true` → dynamic render from the page title
42
- * - OGImageParams → dynamic render with explicit preset/layout/colors
43
- * - `false` no image (inherit whatever the layout set)
44
- * - string → explicit image URL
52
+ * What the OG image should be. Defaults to `'static'` (the brand image).
53
+ * - `'static'` brand image from config (default)
54
+ * - `'dynamic'` render the page title onto the card
55
+ * - `{ render: {...} }` render with explicit preset/layout/colors
56
+ * - `{ image, alt? }` an exact image URL (e.g. a content screenshot)
45
57
  */
46
- og?: boolean | string | Omit<OGImageParams, 'title'>
58
+ og?: OgSpec
59
+ /** Escape hatch: extra Metadata fields merged last (robots, authors, etc.). */
60
+ extra?: Metadata
47
61
  }
48
62
 
49
63
  function buildAlternates(
@@ -52,26 +66,25 @@ function buildAlternates(
52
66
  locale: string
53
67
  ): Metadata['alternates'] {
54
68
  if (!config.locales?.length) {
55
- return { canonical: `${config.siteUrl}/${locale}${path}` }
69
+ return { canonical: `${config.siteUrl}/${path}`.replace(/\/+$/, '') || config.siteUrl }
56
70
  }
57
71
  const languages: Record<string, string> = {}
58
72
  for (const loc of config.locales) {
59
73
  languages[loc] = `${config.siteUrl}/${loc}${path}`
60
74
  }
61
- const xDefault = config.defaultLocale ?? (config.locales.includes('en') ? 'en' : config.locales[0])
75
+ const xDefault =
76
+ config.defaultLocale ?? (config.locales.includes('en') ? 'en' : config.locales[0])
62
77
  languages['x-default'] = `${config.siteUrl}/${xDefault}${path}`
63
78
  return { canonical: `${config.siteUrl}/${locale}${path}`, languages }
64
79
  }
65
80
 
66
81
  /**
67
- * Builds a complete Next.js `Metadata` object from a one-time site config plus
68
- * per-page inputs — title-template, description, openGraph, twitter, canonical
69
- * + hreflang alternates, and icons — so pages only declare what's unique.
82
+ * Builds a complete Next.js `Metadata` from a one-time site config plus per-page
83
+ * inputs — title-template, description, openGraph, twitter, canonical + hreflang,
84
+ * icons, and the OG image — so pages only declare what's unique.
70
85
  *
71
- * OG image defaults to the static brand image (`config.ogImage`); opt into the
72
- * dynamic renderer per page with `og: true` / `og: { preset: 'DARK_BLUE' }`.
73
- *
74
- * Bind the config once in your app, then call the bound fn from each page:
86
+ * The OG image is chosen by the single `og` field (defaults to the static brand
87
+ * image). There is exactly one way to do each thing.
75
88
  *
76
89
  * // app/_core/metadata.ts
77
90
  * export const createMetadata = makeMetadataFactory({
@@ -83,16 +96,25 @@ function buildAlternates(
83
96
  * locales: LOCALES,
84
97
  * })
85
98
  *
86
- * // any page.tsx
87
- * export const metadata = createMetadata({ title: 'Skills', path: '/skills' })
99
+ * // landing page — static brand image (default)
100
+ * export const metadata = createMetadata({ title: 'Pricing', path: '/pricing' })
101
+ * // content page — title rendered onto the card
102
+ * export const metadata = createMetadata({ title: post.title, path, og: 'dynamic' })
103
+ * // content page with its own image
104
+ * export const metadata = createMetadata({ title: p.name, og: { image: p.image } })
88
105
  */
89
106
  export function createMetadata(config: SiteMetaConfig, page: PageMeta = {}): Metadata {
90
107
  const title = page.title ?? config.name
91
108
  const description = page.description ?? config.description
92
109
  const locale = page.locale ?? config.defaultLocale ?? 'en'
93
110
  const path = page.path ?? ''
111
+ const ogUrl = config.locales?.length
112
+ ? `${config.siteUrl}/${locale}${path}`
113
+ : `${config.siteUrl}${path}`
114
+
115
+ const ogImage = resolveOgImage(page.og ?? 'static', config.ogImage, title, config.ogBackend)
94
116
 
95
- const base: Metadata = {
117
+ const metadata: Metadata = {
96
118
  title,
97
119
  description,
98
120
  keywords: page.keywords,
@@ -100,41 +122,34 @@ export function createMetadata(config: SiteMetaConfig, page: PageMeta = {}): Met
100
122
  openGraph: {
101
123
  type: 'website',
102
124
  locale,
103
- url: `${config.siteUrl}/${locale}${path}`,
125
+ url: ogUrl,
104
126
  siteName: config.name,
105
127
  title,
106
128
  description,
129
+ ...ogImage.openGraph,
107
130
  },
108
131
  twitter: {
109
- card: 'summary_large_image',
110
132
  title,
111
133
  description,
134
+ ...ogImage.twitter,
112
135
  },
113
136
  icons: config.icons,
114
137
  }
115
138
 
116
- // Resolve the `og` shorthand into withOgImage options.
117
- let ogOptions: WithOgImageOptions
118
- if (page.og === undefined || page.og === false) {
119
- // Default & explicit-off both map to the static brand image. (false on the
120
- // factory means "don't render dynamic" — but we still want a brand image,
121
- // so we point at the static one. Use og:false at the page only if you've
122
- // set images elsewhere; here static is the friendly default.)
123
- ogOptions = { image: config.ogImage }
124
- } else if (page.og === true) {
125
- ogOptions = {} // dynamic, title auto-extracted
126
- } else if (typeof page.og === 'string') {
127
- ogOptions = { image: page.og }
128
- } else {
129
- ogOptions = page.og // OGImageParams → dynamic with overrides
139
+ if (!page.extra) return metadata
140
+ // Shallow-merge the escape hatch, but deep-merge the nested og/twitter so the
141
+ // image we just set isn't clobbered by a partial `extra.openGraph`.
142
+ return {
143
+ ...metadata,
144
+ ...page.extra,
145
+ openGraph: { ...metadata.openGraph, ...page.extra.openGraph },
146
+ twitter: { ...metadata.twitter, ...page.extra.twitter },
130
147
  }
131
-
132
- return withOgImage(base, ogOptions)
133
148
  }
134
149
 
135
150
  /**
136
151
  * Curries `createMetadata` with a fixed site config so pages call a zero-config
137
- * factory. See the docstring on `createMetadata` for the usage pattern.
152
+ * factory. This is the single recommended entry point for app metadata.
138
153
  */
139
154
  export function makeMetadataFactory(config: SiteMetaConfig) {
140
155
  return (page: PageMeta = {}): Metadata => createMetadata(config, page)
@@ -1,109 +1,100 @@
1
1
  import type { Metadata } from 'next'
2
- import type { OGImageParams } from './types'
2
+ import type { OGImageParams, OgSpec } from './types'
3
3
  import { buildOgUrl } from './url'
4
4
 
5
+ const OG_WIDTH = 1200
6
+ const OG_HEIGHT = 630
7
+
8
+ /** A Next.js OG image entry. */
9
+ function imageEntry(url: string, alt: string) {
10
+ return { url, width: OG_WIDTH, height: OG_HEIGHT, alt }
11
+ }
12
+
5
13
  /**
6
- * Creates Next.js Metadata fragment with og:image and twitter:image
7
- * pointing to Django's django_ogimage renderer.
8
- *
9
- * Usage in generateMetadata():
10
- * return createOgMetadata({ title: 'Page', preset: 'DARK_BLUE' })
14
+ * Builds the og/twitter image fragment for a given image URL — the one place
15
+ * that knows the shape Next.js expects. Both static URLs and the dynamic
16
+ * renderer funnel through here, so there's a single source of truth.
11
17
  */
12
- export function createOgMetadata(params: OGImageParams): Metadata {
13
- const url = buildOgUrl(params)
14
- const image = { url, width: 1200, height: 630, alt: params.title }
18
+ function ogImageFragment(url: string, alt: string): Pick<Metadata, 'openGraph' | 'twitter'> {
15
19
  return {
16
- openGraph: {
17
- images: [image],
18
- },
19
- twitter: {
20
- card: 'summary_large_image',
21
- images: [url],
22
- },
20
+ openGraph: { images: [imageEntry(url, alt)] },
21
+ twitter: { card: 'summary_large_image', images: [url] },
23
22
  }
24
23
  }
25
24
 
26
25
  /**
27
- * Options for `withOgImage`. Extends the dynamic-renderer params with an
28
- * `image` opt-out so a page can keep its own (static) OG image instead of the
29
- * dynamically-rendered one.
26
+ * Metadata fragment whose OG image is rendered by Django's django_ogimage
27
+ * endpoint (/cfg/og/). Low-level most callers use `createMetadata`'s `og`
28
+ * field instead.
30
29
  */
31
- export type WithOgImageOptions = Omit<OGImageParams, 'title'> & {
32
- title?: string
33
- /**
34
- * Controls the OG image source:
35
- * - omitted / `true` dynamic render via Django's /cfg/og/ (default)
36
- * - `false` → don't touch images; the page/layout's own og:image
37
- * is preserved (use for a ready-made static brand image)
38
- * - `string` → use this exact URL as a static og/twitter image
39
- */
40
- image?: boolean | string
30
+ export function createOgMetadata(params: OGImageParams): Metadata {
31
+ return ogImageFragment(buildOgUrl(params), params.title)
32
+ }
33
+
34
+ /** Extract a plain title string from Next.js's title shapes. */
35
+ export function extractTitle(metadata: Metadata): string {
36
+ const t = metadata.title
37
+ if (!t) return ''
38
+ if (typeof t === 'string') return t
39
+ if (typeof t === 'object' && 'absolute' in t) return (t as { absolute: string }).absolute
40
+ if (typeof t === 'object' && 'default' in t) return (t as { default: string }).default
41
+ return ''
41
42
  }
42
43
 
43
44
  /**
44
- * Merges og:image metadata into an existing Metadata object.
45
+ * Resolves an `OgSpec` against a base Metadata into a concrete og/twitter image
46
+ * fragment. `staticUrl` is the site's brand image, used for the `'static'` case.
45
47
  *
46
- * Usage:
47
- * // dynamic (default)
48
- * return withOgImage({ title: 'Page', description: '...' }, { preset: 'DARK_BLUE' })
49
- * // keep the page's own static image
50
- * return withOgImage(meta, { preset: 'DARK_BLUE', image: false })
51
- * // explicit static image
52
- * return withOgImage(meta, { image: '/static/ogimage.png' })
48
+ * `backend` is the Django origin that serves the dynamic renderer. The dynamic
49
+ * cases (`'dynamic'`, `{ render }`) require it: when it's `undefined`/empty —
50
+ * e.g. a frontend with no Django backend they **fall back to the static brand
51
+ * image** instead of emitting a broken `/cfg/og/` URL. (`undefined` still lets
52
+ * `buildOgUrl` fall back to env vars; pass `''` to force the static fallback.)
53
+ *
54
+ * This is the single decision point for "what is the OG image" no other code
55
+ * branches on the spec.
53
56
  */
54
- export function withOgImage(
55
- metadata: Metadata,
56
- params: WithOgImageOptions = {}
57
- ): Metadata {
58
- const { image, ...ogParams } = params
57
+ export function resolveOgImage(
58
+ spec: OgSpec,
59
+ staticUrl: string,
60
+ fallbackTitle: string,
61
+ backend?: string
62
+ ): Pick<Metadata, 'openGraph' | 'twitter'> {
63
+ const canRenderDynamic = backend === undefined || backend !== ''
59
64
 
60
- // Opt-out: leave the incoming metadata's images untouched.
61
- if (image === false) {
62
- return metadata
65
+ if (spec === 'static') {
66
+ return ogImageFragment(staticUrl, fallbackTitle)
63
67
  }
64
-
65
- // Explicit static URL: set og/twitter images without hitting the renderer.
66
- if (typeof image === 'string') {
67
- return {
68
- ...metadata,
69
- openGraph: {
70
- ...metadata.openGraph,
71
- images: [{ url: image, width: 1200, height: 630, alt: extractTitle(metadata) }],
72
- },
73
- twitter: {
74
- card: 'summary_large_image',
75
- ...metadata.twitter,
76
- images: [image],
77
- },
78
- }
68
+ if (spec === 'dynamic') {
69
+ return canRenderDynamic
70
+ ? ogImageFragment(buildOgUrl({ title: fallbackTitle }, backend), fallbackTitle)
71
+ : ogImageFragment(staticUrl, fallbackTitle)
79
72
  }
80
-
81
- // Default: dynamic render. Auto-extract title; params.title overrides.
82
- const resolvedParams: OGImageParams = {
83
- title: extractTitle(metadata),
84
- ...ogParams,
73
+ if ('image' in spec) {
74
+ return ogImageFragment(spec.image, spec.alt ?? fallbackTitle)
85
75
  }
76
+ // { render: {...} } — dynamic with explicit params; title defaults to page title.
77
+ const title = spec.render.title ?? fallbackTitle
78
+ return canRenderDynamic
79
+ ? ogImageFragment(buildOgUrl({ ...spec.render, title }, backend), title)
80
+ : ogImageFragment(staticUrl, title)
81
+ }
86
82
 
87
- const ogMeta = createOgMetadata(resolvedParams)
88
-
83
+ /**
84
+ * Merges OG image metadata (dynamic render) into existing Metadata. Kept for
85
+ * low-level/manual use; `createMetadata` is the recommended entry point.
86
+ *
87
+ * return withOgImage({ title: 'Page' }, { preset: 'DARK_BLUE' })
88
+ */
89
+ export function withOgImage(
90
+ metadata: Metadata,
91
+ params: Omit<OGImageParams, 'title'> & { title?: string } = {}
92
+ ): Metadata {
93
+ const title = params.title ?? extractTitle(metadata)
94
+ const frag = createOgMetadata({ ...params, title })
89
95
  return {
90
96
  ...metadata,
91
- openGraph: {
92
- ...metadata.openGraph,
93
- ...ogMeta.openGraph,
94
- },
95
- twitter: {
96
- ...metadata.twitter,
97
- ...ogMeta.twitter,
98
- },
97
+ openGraph: { ...metadata.openGraph, ...frag.openGraph },
98
+ twitter: { ...metadata.twitter, ...frag.twitter },
99
99
  }
100
100
  }
101
-
102
- function extractTitle(metadata: Metadata): string {
103
- const t = metadata.title
104
- if (!t) return ''
105
- if (typeof t === 'string') return t
106
- if (typeof t === 'object' && 'absolute' in t) return (t as { absolute: string }).absolute
107
- if (typeof t === 'object' && 'default' in t) return (t as { default: string }).default
108
- return ''
109
- }
@@ -11,6 +11,21 @@ export type OGPreset =
11
11
 
12
12
  export type OGLayout = 'DEFAULT' | 'HERO' | 'ARTICLE' | 'MINIMAL'
13
13
 
14
+ /**
15
+ * Per-page OG image intent — the single axis that decides what the OG image is.
16
+ * A discriminated union so every case is explicit and type-checked:
17
+ *
18
+ * 'static' brand image from site config (the default)
19
+ * 'dynamic' render the page title onto the OG card
20
+ * { render: {...} } render with explicit preset/layout/colors
21
+ * { image, alt? } an exact image URL (e.g. a content screenshot)
22
+ */
23
+ export type OgSpec =
24
+ | 'static'
25
+ | 'dynamic'
26
+ | { render: Omit<OGImageParams, 'title'> & { title?: string } }
27
+ | { image: string; alt?: string }
28
+
14
29
  /** Mirrors Django OGImageParams exactly */
15
30
  export interface OGImageParams {
16
31
  title: string
@@ -50,9 +50,13 @@ function cleanParams(params: OGImageParams): Record<string, unknown> {
50
50
  * django_ogimage endpoint: /cfg/og/<base64params>/
51
51
  *
52
52
  * The URL is suitable for use in og:image and twitter:image meta tags.
53
+ *
54
+ * @param base Optional explicit backend origin (e.g. "https://api.example.com").
55
+ * When omitted, it's resolved from env (MEDIA_URL → API_URL → SITE_URL → '').
56
+ * Pass the `ogBackend` from your site config to avoid relying on env.
53
57
  */
54
- export function buildOgUrl(params: OGImageParams): string {
58
+ export function buildOgUrl(params: OGImageParams, base?: string): string {
55
59
  const b64 = encodeBase64(JSON.stringify(cleanParams(params)))
56
- const base = resolveOgPublicBase()
57
- return `${base}/cfg/og/${b64}/`
60
+ const origin = (base ?? resolveOgPublicBase()).replace(/\/$/, '')
61
+ return `${origin}/cfg/og/${b64}/`
58
62
  }