@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.
- package/README.md +43 -41
- package/dist/config/index.mjs +1 -1
- package/dist/config/index.mjs.map +1 -1
- package/dist/i18n/routing.d.mts +2 -2
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/og-image/index.d.mts +99 -67
- package/dist/og-image/index.mjs +54 -65
- package/dist/og-image/index.mjs.map +1 -1
- package/package.json +9 -9
- package/src/og-image/README.md +127 -31
- package/src/og-image/index.ts +9 -4
- package/src/og-image/metadata-factory.ts +59 -44
- package/src/og-image/metadata.ts +75 -84
- package/src/og-image/types.ts +15 -0
- package/src/og-image/url.ts +7 -3
package/src/og-image/README.md
CHANGED
|
@@ -1,30 +1,100 @@
|
|
|
1
1
|
# @djangocfg/nextjs/og-image
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
14
|
-
↓
|
|
15
|
-
buildOgUrl(params)
|
|
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()
|
|
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
|
|
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
|
-
###
|
|
139
|
+
### Recommended
|
|
70
140
|
|
|
71
|
-
|
|
141
|
+
#### `makeMetadataFactory(config) → (page) => Metadata`
|
|
72
142
|
|
|
73
|
-
|
|
74
|
-
|
|
143
|
+
Binds a `SiteMetaConfig` once and returns a per-page factory. This is the single
|
|
144
|
+
recommended entry point.
|
|
75
145
|
|
|
76
|
-
|
|
77
|
-
|
|
146
|
+
```typescript
|
|
147
|
+
const createMetadata = makeMetadataFactory(config)
|
|
148
|
+
export const metadata = createMetadata({ title: 'About', path: '/about' })
|
|
78
149
|
```
|
|
79
150
|
|
|
80
|
-
|
|
151
|
+
**`SiteMetaConfig`**
|
|
81
152
|
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
import { createOgMetadata } from '@djangocfg/nextjs/og-image'
|
|
176
|
+
#### `createMetadata(config, page)`
|
|
86
177
|
|
|
87
|
-
|
|
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
|
-
###
|
|
180
|
+
### Low-level (escape hatches)
|
|
94
181
|
|
|
95
|
-
|
|
182
|
+
Most pages never need these — they're the primitives the factory is built on.
|
|
96
183
|
|
|
97
|
-
|
|
98
|
-
import { withOgImage } from '@djangocfg/nextjs/og-image'
|
|
184
|
+
#### `buildOgUrl(params)`
|
|
99
185
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
package/src/og-image/index.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
49
|
+
/** Path without locale prefix, e.g. "/skills". '' for home. */
|
|
37
50
|
path?: string
|
|
38
51
|
/**
|
|
39
|
-
* OG image
|
|
40
|
-
* -
|
|
41
|
-
* - `
|
|
42
|
-
* -
|
|
43
|
-
* - `
|
|
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?:
|
|
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}/${
|
|
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 =
|
|
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`
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
|
72
|
-
*
|
|
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
|
-
* //
|
|
87
|
-
* export const metadata = createMetadata({ title: '
|
|
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
|
|
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:
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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.
|
|
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)
|
package/src/og-image/metadata.ts
CHANGED
|
@@ -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
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
32
|
-
title
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
return metadata
|
|
65
|
+
if (spec === 'static') {
|
|
66
|
+
return ogImageFragment(staticUrl, fallbackTitle)
|
|
63
67
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
package/src/og-image/types.ts
CHANGED
|
@@ -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
|
package/src/og-image/url.ts
CHANGED
|
@@ -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
|
|
57
|
-
return `${
|
|
60
|
+
const origin = (base ?? resolveOgPublicBase()).replace(/\/$/, '')
|
|
61
|
+
return `${origin}/cfg/og/${b64}/`
|
|
58
62
|
}
|