@djangocfg/nextjs 2.1.536 → 2.1.539
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 +22 -24
- package/dist/og-image/index.d.mts +16 -77
- package/dist/og-image/index.mjs +4 -51
- package/dist/og-image/index.mjs.map +1 -1
- package/package.json +9 -9
- package/src/og-image/README.md +36 -168
- package/src/og-image/index.ts +2 -3
- package/src/og-image/metadata-factory.test.ts +0 -1
- package/src/og-image/metadata-factory.ts +4 -15
- package/src/og-image/metadata.ts +10 -57
- package/src/og-image/types.ts +6 -39
- package/src/og-image/url.ts +0 -62
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
## Features
|
|
25
25
|
|
|
26
|
-
- **Page Metadata + OG Images** — one factory for complete page metadata
|
|
26
|
+
- **Page Metadata + OG Images** — one factory for complete page metadata: titles, canonical + hreflang, OpenGraph, Twitter, icons (`@djangocfg/nextjs/og-image`)
|
|
27
27
|
- **i18n** — full next-intl integration with routing, middleware, and components
|
|
28
28
|
- **PWA** — zero-config service worker and manifest
|
|
29
29
|
- **Base Next.js Config** — reusable `createBaseNextConfig()` factory for monorepos
|
|
@@ -391,8 +391,7 @@ export const GET = createHealthHandler({
|
|
|
391
391
|
|
|
392
392
|
One factory per app builds complete page `Metadata` (title, openGraph, twitter,
|
|
393
393
|
canonical + hreflang, icons). The OG image is chosen by a single `og` field —
|
|
394
|
-
static brand image by default,
|
|
395
|
-
Runtime, no `@vercel/og`:
|
|
394
|
+
the static brand image by default, or an exact URL:
|
|
396
395
|
|
|
397
396
|
```tsx
|
|
398
397
|
// app/_core/metadata.ts — bind once
|
|
@@ -407,11 +406,13 @@ export const createMetadata = makeMetadataFactory({
|
|
|
407
406
|
|
|
408
407
|
// landing page → static brand image (default)
|
|
409
408
|
export const metadata = createMetadata({ title: 'Pricing', path: '/pricing' })
|
|
410
|
-
// content page
|
|
411
|
-
export const metadata = createMetadata({ title:
|
|
409
|
+
// content page with its own image
|
|
410
|
+
export const metadata = createMetadata({ title: p.name, path, og: { image: p.image } })
|
|
412
411
|
```
|
|
413
412
|
|
|
414
|
-
|
|
413
|
+
For a card rendered per page, add an `opengraph-image` route — Next.js finds it
|
|
414
|
+
by filename and its output overrides whatever the factory set, so the factory
|
|
415
|
+
has no "dynamic" case of its own.
|
|
415
416
|
|
|
416
417
|
### Navigation Utilities
|
|
417
418
|
|
|
@@ -461,7 +462,7 @@ import { RedirectPage } from '@djangocfg/layouts/components/RedirectPage';
|
|
|
461
462
|
| `@djangocfg/nextjs/pwa/worker` | Service worker creation helpers |
|
|
462
463
|
| `@djangocfg/nextjs/sitemap` | Sitemap generation with i18n hreflang support |
|
|
463
464
|
| `@djangocfg/nextjs/health` | Health check handlers |
|
|
464
|
-
| `@djangocfg/nextjs/og-image` | Page metadata factory
|
|
465
|
+
| `@djangocfg/nextjs/og-image` | Page metadata factory — titles, canonical + hreflang, OpenGraph, Twitter, icons |
|
|
465
466
|
| `@djangocfg/nextjs/navigation` | Route definitions, menu generation, navigation helpers |
|
|
466
467
|
|
|
467
468
|
## API Reference
|
|
@@ -623,17 +624,16 @@ createHealthHandler({
|
|
|
623
624
|
|
|
624
625
|
### Page Metadata + OG Image
|
|
625
626
|
|
|
626
|
-
`@djangocfg/nextjs/og-image` builds complete page metadata
|
|
627
|
-
|
|
628
|
-
templates — Django renders and caches the PNG.
|
|
627
|
+
`@djangocfg/nextjs/og-image` builds complete page metadata: title templating,
|
|
628
|
+
canonical + hreflang alternates, OpenGraph, Twitter cards and icons.
|
|
629
629
|
|
|
630
630
|
See [`src/og-image/README.md`](./src/og-image/README.md) for full docs (the `og`
|
|
631
|
-
axis, `SiteMetaConfig`/`PageMeta` fields
|
|
631
|
+
axis, `SiteMetaConfig`/`PageMeta` fields).
|
|
632
632
|
|
|
633
633
|
#### `makeMetadataFactory(config)` — recommended
|
|
634
634
|
|
|
635
635
|
Binds a `SiteMetaConfig` once; returns a per-page factory. The OG image is set by
|
|
636
|
-
the single `og` field: `'static'` (default) · `
|
|
636
|
+
the single `og` field: `'static'` (default) · `{ image, alt? }`.
|
|
637
637
|
|
|
638
638
|
```tsx
|
|
639
639
|
import { makeMetadataFactory } from '@djangocfg/nextjs/og-image'
|
|
@@ -643,23 +643,21 @@ export const createMetadata = makeMetadataFactory({
|
|
|
643
643
|
ogImage: '/static/ogimage.png', locales: LOCALES,
|
|
644
644
|
})
|
|
645
645
|
|
|
646
|
-
export const metadata = createMetadata({ title: 'About', path: '/about' })
|
|
647
|
-
export const metadata = createMetadata({ title:
|
|
646
|
+
export const metadata = createMetadata({ title: 'About', path: '/about' }) // brand image
|
|
647
|
+
export const metadata = createMetadata({ title: p.name, og: { image: p.image } })
|
|
648
648
|
```
|
|
649
649
|
|
|
650
|
-
####
|
|
650
|
+
#### Per-page rendered cards
|
|
651
651
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
`OgSpec` decision point. Most pages use `createMetadata` and never touch these.
|
|
652
|
+
Use Next.js's `opengraph-image` file convention. It is discovered by filename
|
|
653
|
+
and overrides metadata, so this package needs no support for it — and offering
|
|
654
|
+
a second route to the same tag would only create a conflict.
|
|
656
655
|
|
|
657
|
-
|
|
658
|
-
import { buildOgUrl } from '@djangocfg/nextjs/og-image'
|
|
659
|
-
const url = buildOgUrl({ title: 'Hello', preset: 'DARK_BLUE' })
|
|
660
|
-
```
|
|
656
|
+
#### Low-level (escape hatches)
|
|
661
657
|
|
|
662
|
-
|
|
658
|
+
`resolveOgImage(spec, staticUrl, fallbackTitle)` is the `OgSpec` decision point;
|
|
659
|
+
`extractTitle(metadata)` reads a plain title out of Next.js's title shapes. Most
|
|
660
|
+
pages use `createMetadata` and never touch these.
|
|
663
661
|
|
|
664
662
|
### Navigation
|
|
665
663
|
|
|
@@ -1,41 +1,20 @@
|
|
|
1
1
|
import { Metadata } from 'next';
|
|
2
2
|
|
|
3
|
-
type OGPreset = 'DARK' | 'DARK_BLUE' | 'DARK_PURPLE' | 'DARK_GREEN' | 'DARK_ROSE' | 'LIGHT' | 'LIGHT_GRAY' | 'LIGHT_WARM' | 'LIGHT_GREEN';
|
|
4
|
-
type OGLayout = 'DEFAULT' | 'HERO' | 'ARTICLE' | 'MINIMAL';
|
|
5
3
|
/**
|
|
6
4
|
* Per-page OG image intent — the single axis that decides what the OG image is.
|
|
7
|
-
* A discriminated union so every case is explicit and type-checked:
|
|
8
5
|
*
|
|
9
6
|
* 'static' brand image from site config (the default)
|
|
10
|
-
* 'dynamic' render the page title onto the OG card
|
|
11
|
-
* { render: {...} } render with explicit preset/layout/colors
|
|
12
7
|
* { image, alt? } an exact image URL (e.g. a content screenshot)
|
|
8
|
+
*
|
|
9
|
+
* There is deliberately no "render this title onto a card" case. That was
|
|
10
|
+
* served by a Django endpoint (`/cfg/og/<base64>/`), archived 2026-09-03 —
|
|
11
|
+
* Next.js renders such cards itself via an `opengraph-image` route, which
|
|
12
|
+
* needs no metadata support because the file convention IS the registration.
|
|
13
13
|
*/
|
|
14
|
-
type OgSpec = 'static' |
|
|
15
|
-
render: Omit<OGImageParams, 'title'> & {
|
|
16
|
-
title?: string;
|
|
17
|
-
};
|
|
18
|
-
} | {
|
|
14
|
+
type OgSpec = 'static' | {
|
|
19
15
|
image: string;
|
|
20
16
|
alt?: string;
|
|
21
17
|
};
|
|
22
|
-
/** Mirrors Django OGImageParams exactly */
|
|
23
|
-
interface OGImageParams {
|
|
24
|
-
title: string;
|
|
25
|
-
description?: string;
|
|
26
|
-
locale?: string;
|
|
27
|
-
style?: 'dark' | 'light';
|
|
28
|
-
bg_color?: string;
|
|
29
|
-
bg_color2?: string;
|
|
30
|
-
text_color?: string;
|
|
31
|
-
accent_color?: string;
|
|
32
|
-
/** e.g. "1200x630" */
|
|
33
|
-
size?: string;
|
|
34
|
-
preset?: OGPreset;
|
|
35
|
-
layout?: OGLayout;
|
|
36
|
-
/** Excluded from cache key on Django side */
|
|
37
|
-
page_url?: string;
|
|
38
|
-
}
|
|
39
18
|
|
|
40
19
|
/**
|
|
41
20
|
* Site-wide identity the metadata factory needs. Config-agnostic on purpose —
|
|
@@ -51,16 +30,6 @@ interface SiteMetaConfig {
|
|
|
51
30
|
siteUrl: string;
|
|
52
31
|
/** Ready-made static brand OG image URL (absolute or root-relative). */
|
|
53
32
|
ogImage: string;
|
|
54
|
-
/**
|
|
55
|
-
* Django origin that serves the dynamic OG renderer (`/cfg/og/`), e.g.
|
|
56
|
-
* "https://api.example.com". Required for `og: 'dynamic'` / `{ render }`.
|
|
57
|
-
*
|
|
58
|
-
* - set → dynamic OG images render against this origin
|
|
59
|
-
* - `''` → no backend: dynamic specs fall back to the static brand image
|
|
60
|
-
* (use this for a frontend with no Django, so no broken URLs)
|
|
61
|
-
* - omit → fall back to env vars (MEDIA_URL → API_URL → SITE_URL)
|
|
62
|
-
*/
|
|
63
|
-
ogBackend?: string;
|
|
64
33
|
/** Optional favicon / apple-touch icons forwarded to Metadata.icons. */
|
|
65
34
|
icons?: Metadata['icons'];
|
|
66
35
|
/** Locales for hreflang alternates. Omit for single-locale sites. */
|
|
@@ -89,9 +58,10 @@ interface PageMeta {
|
|
|
89
58
|
/**
|
|
90
59
|
* What the OG image should be. Defaults to `'static'` (the brand image).
|
|
91
60
|
* - `'static'` brand image from config (default)
|
|
92
|
-
* - `'dynamic'` render the page title onto the card
|
|
93
|
-
* - `{ render: {...} }` render with explicit preset/layout/colors
|
|
94
61
|
* - `{ image, alt? }` an exact image URL (e.g. a content screenshot)
|
|
62
|
+
*
|
|
63
|
+
* For a per-page rendered card, add an `opengraph-image` route instead —
|
|
64
|
+
* Next.js finds it by filename and it overrides whatever is set here.
|
|
95
65
|
*/
|
|
96
66
|
og?: OgSpec;
|
|
97
67
|
/** Escape hatch: extra Metadata fields merged last (robots, authors, etc.). */
|
|
@@ -117,8 +87,6 @@ interface PageMeta {
|
|
|
117
87
|
*
|
|
118
88
|
* // landing page — static brand image (default)
|
|
119
89
|
* export const metadata = createMetadata({ title: 'Pricing', path: '/pricing' })
|
|
120
|
-
* // content page — title rendered onto the card
|
|
121
|
-
* export const metadata = createMetadata({ title: post.title, path, og: 'dynamic' })
|
|
122
90
|
* // content page with its own image
|
|
123
91
|
* export const metadata = createMetadata({ title: p.name, og: { image: p.image } })
|
|
124
92
|
*/
|
|
@@ -129,48 +97,19 @@ declare function createMetadata(config: SiteMetaConfig, page?: PageMeta): Metada
|
|
|
129
97
|
*/
|
|
130
98
|
declare function makeMetadataFactory(config: SiteMetaConfig): (page?: PageMeta) => Metadata;
|
|
131
99
|
|
|
132
|
-
/**
|
|
133
|
-
* Builds an absolute (or relative) OG image URL pointing to Django's
|
|
134
|
-
* django_ogimage endpoint: /cfg/og/<base64params>/
|
|
135
|
-
*
|
|
136
|
-
* The URL is suitable for use in og:image and twitter:image meta tags.
|
|
137
|
-
*
|
|
138
|
-
* @param base Optional explicit backend origin (e.g. "https://api.example.com").
|
|
139
|
-
* When omitted, it's resolved from env (MEDIA_URL → API_URL → SITE_URL → '').
|
|
140
|
-
* Pass the `ogBackend` from your site config to avoid relying on env.
|
|
141
|
-
*/
|
|
142
|
-
declare function buildOgUrl(params: OGImageParams, base?: string): string;
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Metadata fragment whose OG image is rendered by Django's django_ogimage
|
|
146
|
-
* endpoint (/cfg/og/). Low-level — most callers use `createMetadata`'s `og`
|
|
147
|
-
* field instead.
|
|
148
|
-
*/
|
|
149
|
-
declare function createOgMetadata(params: OGImageParams): Metadata;
|
|
150
100
|
/** Extract a plain title string from Next.js's title shapes. */
|
|
151
101
|
declare function extractTitle(metadata: Metadata): string;
|
|
152
102
|
/**
|
|
153
|
-
* Resolves an `OgSpec`
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* `backend` is the Django origin that serves the dynamic renderer. The dynamic
|
|
157
|
-
* cases (`'dynamic'`, `{ render }`) require it: when it's `undefined`/empty —
|
|
158
|
-
* e.g. a frontend with no Django backend — they **fall back to the static brand
|
|
159
|
-
* image** instead of emitting a broken `/cfg/og/` URL. (`undefined` still lets
|
|
160
|
-
* `buildOgUrl` fall back to env vars; pass `''` to force the static fallback.)
|
|
103
|
+
* Resolves an `OgSpec` into a concrete og/twitter image fragment. `staticUrl`
|
|
104
|
+
* is the site's brand image, used for the `'static'` case.
|
|
161
105
|
*
|
|
162
106
|
* This is the single decision point for "what is the OG image" — no other code
|
|
163
107
|
* branches on the spec.
|
|
164
|
-
*/
|
|
165
|
-
declare function resolveOgImage(spec: OgSpec, staticUrl: string, fallbackTitle: string, backend?: string): Pick<Metadata, 'openGraph' | 'twitter'>;
|
|
166
|
-
/**
|
|
167
|
-
* Merges OG image metadata (dynamic render) into existing Metadata. Kept for
|
|
168
|
-
* low-level/manual use; `createMetadata` is the recommended entry point.
|
|
169
108
|
*
|
|
170
|
-
*
|
|
109
|
+
* To render a per-page card instead, add an `opengraph-image` route: Next.js
|
|
110
|
+
* discovers it by filename and overrides whatever is set here, so it needs no
|
|
111
|
+
* support from this function.
|
|
171
112
|
*/
|
|
172
|
-
declare function
|
|
173
|
-
title?: string;
|
|
174
|
-
}): Metadata;
|
|
113
|
+
declare function resolveOgImage(spec: OgSpec, staticUrl: string, fallbackTitle: string): Pick<Metadata, 'openGraph' | 'twitter'>;
|
|
175
114
|
|
|
176
|
-
export { type
|
|
115
|
+
export { type OgSpec, type PageMeta, type SiteMetaConfig, createMetadata, extractTitle, makeMetadataFactory, resolveOgImage };
|
package/dist/og-image/index.mjs
CHANGED
|
@@ -1,27 +1,3 @@
|
|
|
1
|
-
// src/og-image/url.ts
|
|
2
|
-
function resolveOgPublicBase() {
|
|
3
|
-
if (typeof process === "undefined") return "";
|
|
4
|
-
return process.env.NEXT_PUBLIC_MEDIA_URL?.replace(/\/$/, "") ?? process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") ?? process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ?? "";
|
|
5
|
-
}
|
|
6
|
-
function encodeBase64(str) {
|
|
7
|
-
if (typeof Buffer !== "undefined") {
|
|
8
|
-
return Buffer.from(str).toString("base64url");
|
|
9
|
-
}
|
|
10
|
-
return btoa(unescape(encodeURIComponent(str))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
11
|
-
}
|
|
12
|
-
function cleanParams(params) {
|
|
13
|
-
return Object.fromEntries(
|
|
14
|
-
Object.entries(params).filter(
|
|
15
|
-
([, v]) => v !== void 0 && v !== null && v !== ""
|
|
16
|
-
)
|
|
17
|
-
);
|
|
18
|
-
}
|
|
19
|
-
function buildOgUrl(params, base) {
|
|
20
|
-
const b64 = encodeBase64(JSON.stringify(cleanParams(params)));
|
|
21
|
-
const origin = (base ?? resolveOgPublicBase()).replace(/\/$/, "");
|
|
22
|
-
return `${origin}/cfg/og/${b64}/`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
1
|
// src/og-image/metadata.ts
|
|
26
2
|
var OG_WIDTH = 1200;
|
|
27
3
|
var OG_HEIGHT = 630;
|
|
@@ -34,9 +10,6 @@ function ogImageFragment(url, alt) {
|
|
|
34
10
|
twitter: { card: "summary_large_image", images: [url] }
|
|
35
11
|
};
|
|
36
12
|
}
|
|
37
|
-
function createOgMetadata(params) {
|
|
38
|
-
return ogImageFragment(buildOgUrl(params), params.title);
|
|
39
|
-
}
|
|
40
13
|
function extractTitle(metadata) {
|
|
41
14
|
const t = metadata.title;
|
|
42
15
|
if (!t) return "";
|
|
@@ -45,28 +18,11 @@ function extractTitle(metadata) {
|
|
|
45
18
|
if (typeof t === "object" && "default" in t) return t.default;
|
|
46
19
|
return "";
|
|
47
20
|
}
|
|
48
|
-
function resolveOgImage(spec, staticUrl, fallbackTitle
|
|
49
|
-
const canRenderDynamic = backend === void 0 || backend !== "";
|
|
21
|
+
function resolveOgImage(spec, staticUrl, fallbackTitle) {
|
|
50
22
|
if (spec === "static") {
|
|
51
23
|
return ogImageFragment(staticUrl, fallbackTitle);
|
|
52
24
|
}
|
|
53
|
-
|
|
54
|
-
return canRenderDynamic ? ogImageFragment(buildOgUrl({ title: fallbackTitle }, backend), fallbackTitle) : ogImageFragment(staticUrl, fallbackTitle);
|
|
55
|
-
}
|
|
56
|
-
if ("image" in spec) {
|
|
57
|
-
return ogImageFragment(spec.image, spec.alt ?? fallbackTitle);
|
|
58
|
-
}
|
|
59
|
-
const title = spec.render.title ?? fallbackTitle;
|
|
60
|
-
return canRenderDynamic ? ogImageFragment(buildOgUrl({ ...spec.render, title }, backend), title) : ogImageFragment(staticUrl, title);
|
|
61
|
-
}
|
|
62
|
-
function withOgImage(metadata, params = {}) {
|
|
63
|
-
const title = params.title ?? extractTitle(metadata);
|
|
64
|
-
const frag = createOgMetadata({ ...params, title });
|
|
65
|
-
return {
|
|
66
|
-
...metadata,
|
|
67
|
-
openGraph: { ...metadata.openGraph, ...frag.openGraph },
|
|
68
|
-
twitter: { ...metadata.twitter, ...frag.twitter }
|
|
69
|
-
};
|
|
25
|
+
return ogImageFragment(spec.image, spec.alt ?? fallbackTitle);
|
|
70
26
|
}
|
|
71
27
|
|
|
72
28
|
// src/og-image/metadata-factory.ts
|
|
@@ -93,7 +49,7 @@ function createMetadata(config, page = {}) {
|
|
|
93
49
|
const locale = page.locale ?? config.defaultLocale ?? "en";
|
|
94
50
|
const path = page.path ?? "";
|
|
95
51
|
const ogUrl = buildUrl(config.siteUrl, config.locales?.length ? locale : void 0, path);
|
|
96
|
-
const ogImage = resolveOgImage(page.og ?? "static", config.ogImage, title
|
|
52
|
+
const ogImage = resolveOgImage(page.og ?? "static", config.ogImage, title);
|
|
97
53
|
const metadata = {
|
|
98
54
|
title,
|
|
99
55
|
description,
|
|
@@ -127,12 +83,9 @@ function makeMetadataFactory(config) {
|
|
|
127
83
|
return (page = {}) => createMetadata(config, page);
|
|
128
84
|
}
|
|
129
85
|
export {
|
|
130
|
-
buildOgUrl,
|
|
131
86
|
createMetadata,
|
|
132
|
-
createOgMetadata,
|
|
133
87
|
extractTitle,
|
|
134
88
|
makeMetadataFactory,
|
|
135
|
-
resolveOgImage
|
|
136
|
-
withOgImage
|
|
89
|
+
resolveOgImage
|
|
137
90
|
};
|
|
138
91
|
//# sourceMappingURL=index.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/og-image/url.ts","../../src/og-image/metadata.ts","../../src/og-image/metadata-factory.ts"],"sourcesContent":["import type { OGImageParams } from './types'\n\n/**\n * Resolves the base URL for og:image meta tags.\n * Must be publicly accessible by crawlers (Twitter, Facebook, etc).\n *\n * Priority:\n * 1. NEXT_PUBLIC_MEDIA_URL — explicit media domain (nginx proxy or CDN)\n * 2. NEXT_PUBLIC_API_URL — direct Django API domain\n * 3. NEXT_PUBLIC_SITE_URL — site domain (same-domain/static build)\n * 4. '' — relative URL (last resort)\n *\n * Use cases:\n * - Same domain (static build): API_URL='' → relative /cfg/og/...\n * - Split domain (standalone): API_URL=https://api.x.com → absolute\n * - Media via nginx proxy: MEDIA_URL=https://x.com, API_URL=https://api.x.com\n * - CDN: MEDIA_URL=https://cdn.x.com\n */\nfunction resolveOgPublicBase(): string {\n if (typeof process === 'undefined') return ''\n return (\n process.env.NEXT_PUBLIC_MEDIA_URL?.replace(/\\/$/, '') ??\n process.env.NEXT_PUBLIC_API_URL?.replace(/\\/$/, '') ??\n process.env.NEXT_PUBLIC_SITE_URL?.replace(/\\/$/, '') ??\n ''\n )\n}\n\nfunction encodeBase64(str: string): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(str).toString('base64url')\n }\n // Browser fallback\n return btoa(unescape(encodeURIComponent(str)))\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=+$/, '')\n}\n\nfunction cleanParams(params: OGImageParams): Record<string, unknown> {\n return Object.fromEntries(\n Object.entries(params).filter(\n ([, v]) => v !== undefined && v !== null && v !== ''\n )\n )\n}\n\n/**\n * Builds an absolute (or relative) OG image URL pointing to Django's\n * django_ogimage endpoint: /cfg/og/<base64params>/\n *\n * The URL is suitable for use in og:image and twitter:image meta tags.\n *\n * @param base Optional explicit backend origin (e.g. \"https://api.example.com\").\n * When omitted, it's resolved from env (MEDIA_URL → API_URL → SITE_URL → '').\n * Pass the `ogBackend` from your site config to avoid relying on env.\n */\nexport function buildOgUrl(params: OGImageParams, base?: string): string {\n const b64 = encodeBase64(JSON.stringify(cleanParams(params)))\n const origin = (base ?? resolveOgPublicBase()).replace(/\\/$/, '')\n return `${origin}/cfg/og/${b64}/`\n}\n","import type { Metadata } from 'next'\nimport type { OGImageParams, OgSpec } from './types'\nimport { buildOgUrl } from './url'\n\nconst OG_WIDTH = 1200\nconst OG_HEIGHT = 630\n\n/** A Next.js OG image entry. */\nfunction imageEntry(url: string, alt: string) {\n return { url, width: OG_WIDTH, height: OG_HEIGHT, alt }\n}\n\n/**\n * Builds the og/twitter image fragment for a given image URL — the one place\n * that knows the shape Next.js expects. Both static URLs and the dynamic\n * renderer funnel through here, so there's a single source of truth.\n */\nfunction ogImageFragment(url: string, alt: string): Pick<Metadata, 'openGraph' | 'twitter'> {\n return {\n openGraph: { images: [imageEntry(url, alt)] },\n twitter: { card: 'summary_large_image', images: [url] },\n }\n}\n\n/**\n * Metadata fragment whose OG image is rendered by Django's django_ogimage\n * endpoint (/cfg/og/). Low-level — most callers use `createMetadata`'s `og`\n * field instead.\n */\nexport function createOgMetadata(params: OGImageParams): Metadata {\n return ogImageFragment(buildOgUrl(params), params.title)\n}\n\n/** Extract a plain title string from Next.js's title shapes. */\nexport function extractTitle(metadata: Metadata): string {\n const t = metadata.title\n if (!t) return ''\n if (typeof t === 'string') return t\n if (typeof t === 'object' && 'absolute' in t) return (t as { absolute: string }).absolute\n if (typeof t === 'object' && 'default' in t) return (t as { default: string }).default\n return ''\n}\n\n/**\n * Resolves an `OgSpec` against a base Metadata into a concrete og/twitter image\n * fragment. `staticUrl` is the site's brand image, used for the `'static'` case.\n *\n * `backend` is the Django origin that serves the dynamic renderer. The dynamic\n * cases (`'dynamic'`, `{ render }`) require it: when it's `undefined`/empty —\n * e.g. a frontend with no Django backend — they **fall back to the static brand\n * image** instead of emitting a broken `/cfg/og/` URL. (`undefined` still lets\n * `buildOgUrl` fall back to env vars; pass `''` to force the static fallback.)\n *\n * This is the single decision point for \"what is the OG image\" — no other code\n * branches on the spec.\n */\nexport function resolveOgImage(\n spec: OgSpec,\n staticUrl: string,\n fallbackTitle: string,\n backend?: string\n): Pick<Metadata, 'openGraph' | 'twitter'> {\n const canRenderDynamic = backend === undefined || backend !== ''\n\n if (spec === 'static') {\n return ogImageFragment(staticUrl, fallbackTitle)\n }\n if (spec === 'dynamic') {\n return canRenderDynamic\n ? ogImageFragment(buildOgUrl({ title: fallbackTitle }, backend), fallbackTitle)\n : ogImageFragment(staticUrl, fallbackTitle)\n }\n if ('image' in spec) {\n return ogImageFragment(spec.image, spec.alt ?? fallbackTitle)\n }\n // { render: {...} } — dynamic with explicit params; title defaults to page title.\n const title = spec.render.title ?? fallbackTitle\n return canRenderDynamic\n ? ogImageFragment(buildOgUrl({ ...spec.render, title }, backend), title)\n : ogImageFragment(staticUrl, title)\n}\n\n/**\n * Merges OG image metadata (dynamic render) into existing Metadata. Kept for\n * low-level/manual use; `createMetadata` is the recommended entry point.\n *\n * return withOgImage({ title: 'Page' }, { preset: 'DARK_BLUE' })\n */\nexport function withOgImage(\n metadata: Metadata,\n params: Omit<OGImageParams, 'title'> & { title?: string } = {}\n): Metadata {\n const title = params.title ?? extractTitle(metadata)\n const frag = createOgMetadata({ ...params, title })\n return {\n ...metadata,\n openGraph: { ...metadata.openGraph, ...frag.openGraph },\n twitter: { ...metadata.twitter, ...frag.twitter },\n }\n}\n","import type { Metadata } from 'next'\nimport type { OgSpec } from './types'\nimport { resolveOgImage } from './metadata'\n\n/**\n * Site-wide identity the metadata factory needs. Config-agnostic on purpose —\n * the package doesn't know about any app's `settings` object, so the consumer\n * passes these once (typically derived from their own settings module).\n */\nexport interface SiteMetaConfig {\n /** Brand / app name, e.g. \"CMDOP\". Used in titles, siteName, alt text. */\n name: string\n /** Default description used when a page doesn't supply its own. */\n description: string\n /** Absolute site origin, e.g. \"https://cmdop.com\". Used for canonical/og url. */\n siteUrl: string\n /** Ready-made static brand OG image URL (absolute or root-relative). */\n ogImage: string\n /**\n * Django origin that serves the dynamic OG renderer (`/cfg/og/`), e.g.\n * \"https://api.example.com\". Required for `og: 'dynamic'` / `{ render }`.\n *\n * - set → dynamic OG images render against this origin\n * - `''` → no backend: dynamic specs fall back to the static brand image\n * (use this for a frontend with no Django, so no broken URLs)\n * - omit → fall back to env vars (MEDIA_URL → API_URL → SITE_URL)\n */\n ogBackend?: string\n /** Optional favicon / apple-touch icons forwarded to Metadata.icons. */\n icons?: Metadata['icons']\n /** Locales for hreflang alternates. Omit for single-locale sites. */\n locales?: readonly string[]\n /** Locale used for x-default hreflang (defaults to \"en\" or first locale). */\n defaultLocale?: string\n}\n\n/**\n * Per-page inputs. Only `title` is really worth setting per page; everything\n * else falls back to the site config.\n */\nexport interface PageMeta {\n /** Page title (without the brand suffix). Falls back to the site name. */\n title?: string\n /** Page description; falls back to the site description. */\n description?: string\n keywords?: string[]\n /** Current locale, used for canonical + openGraph.locale + alternates. */\n locale?: string\n /**\n * Path without locale prefix, e.g. \"/skills\" or \"/skills/foo\". Home is ''\n * (or '/'). Leading/trailing slashes are normalized, so both canonical and\n * og:url come out well-formed whichever you pass.\n */\n path?: string\n /**\n * What the OG image should be. Defaults to `'static'` (the brand image).\n * - `'static'` brand image from config (default)\n * - `'dynamic'` render the page title onto the card\n * - `{ render: {...} }` render with explicit preset/layout/colors\n * - `{ image, alt? }` an exact image URL (e.g. a content screenshot)\n */\n og?: OgSpec\n /** Escape hatch: extra Metadata fields merged last (robots, authors, etc.). */\n extra?: Metadata\n}\n\n/**\n * Joins the site origin, an optional locale segment, and a page path into one\n * absolute URL.\n *\n * This is the SINGLE place the URL shape is decided. `canonical` and\n * `openGraph.url` describe the same page, so they must be built by the same\n * function — when they were two hand-rolled template literals they drifted\n * apart (one inserted a slash the other didn't), and a page shipped a canonical\n * and an og:url pointing at two different URLs.\n *\n * Contract (see `PageMeta.path`): `path` carries its own leading slash —\n * \"/skills\", \"/skills/foo\" — and home is '' (we also tolerate '/'). Segments\n * are normalized rather than trusted, so a stray or missing slash at a call\n * site cannot produce \"site.com//skills\" or \"site.comskills\".\n */\nfunction buildUrl(siteUrl: string, locale: string | undefined, path: string): string {\n const origin = siteUrl.replace(/\\/+$/, '')\n const segments = [locale, path]\n .map((segment) => segment?.replace(/^\\/+|\\/+$/g, '') ?? '')\n .filter(Boolean)\n return segments.length ? `${origin}/${segments.join('/')}` : origin\n}\n\nfunction buildAlternates(\n config: SiteMetaConfig,\n path: string,\n locale: string\n): Metadata['alternates'] {\n if (!config.locales?.length) {\n return { canonical: buildUrl(config.siteUrl, undefined, path) }\n }\n const languages: Record<string, string> = {}\n for (const loc of config.locales) {\n languages[loc] = buildUrl(config.siteUrl, loc, path)\n }\n const xDefault =\n config.defaultLocale ?? (config.locales.includes('en') ? 'en' : config.locales[0])\n languages['x-default'] = buildUrl(config.siteUrl, xDefault, path)\n return { canonical: buildUrl(config.siteUrl, locale, path), languages }\n}\n\n/**\n * Builds a complete Next.js `Metadata` from a one-time site config plus per-page\n * inputs — title-template, description, openGraph, twitter, canonical + hreflang,\n * icons, and the OG image — so pages only declare what's unique.\n *\n * The OG image is chosen by the single `og` field (defaults to the static brand\n * image). There is exactly one way to do each thing.\n *\n * // app/_core/metadata.ts\n * export const createMetadata = makeMetadataFactory({\n * name: settings.app.name,\n * description: settings.app.description,\n * siteUrl: settings.app.siteUrl,\n * ogImage: settings.app.media.ogimage,\n * icons: { icon: settings.app.media.favicon },\n * locales: LOCALES,\n * })\n *\n * // landing page — static brand image (default)\n * export const metadata = createMetadata({ title: 'Pricing', path: '/pricing' })\n * // content page — title rendered onto the card\n * export const metadata = createMetadata({ title: post.title, path, og: 'dynamic' })\n * // content page with its own image\n * export const metadata = createMetadata({ title: p.name, og: { image: p.image } })\n */\nexport function createMetadata(config: SiteMetaConfig, page: PageMeta = {}): Metadata {\n const title = page.title ?? config.name\n const description = page.description ?? config.description\n const locale = page.locale ?? config.defaultLocale ?? 'en'\n const path = page.path ?? ''\n // Same builder as the canonical below — og:url and canonical name the same\n // page, so they are never allowed to be assembled by two different rules.\n const ogUrl = buildUrl(config.siteUrl, config.locales?.length ? locale : undefined, path)\n\n const ogImage = resolveOgImage(page.og ?? 'static', config.ogImage, title, config.ogBackend)\n\n const metadata: Metadata = {\n title,\n description,\n keywords: page.keywords,\n alternates: buildAlternates(config, path, locale),\n openGraph: {\n type: 'website',\n locale,\n url: ogUrl,\n siteName: config.name,\n title,\n description,\n ...ogImage.openGraph,\n },\n twitter: {\n title,\n description,\n ...ogImage.twitter,\n },\n icons: config.icons,\n }\n\n if (!page.extra) return metadata\n // Shallow-merge the escape hatch, but deep-merge the nested og/twitter so the\n // image we just set isn't clobbered by a partial `extra.openGraph`.\n return {\n ...metadata,\n ...page.extra,\n openGraph: { ...metadata.openGraph, ...page.extra.openGraph },\n twitter: { ...metadata.twitter, ...page.extra.twitter },\n }\n}\n\n/**\n * Curries `createMetadata` with a fixed site config so pages call a zero-config\n * factory. This is the single recommended entry point for app metadata.\n */\nexport function makeMetadataFactory(config: SiteMetaConfig) {\n return (page: PageMeta = {}): Metadata => createMetadata(config, page)\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/og-image/metadata.ts","../../src/og-image/metadata-factory.ts"],"sourcesContent":["import type { Metadata } from 'next'\nimport type { OgSpec } from './types'\n\nconst OG_WIDTH = 1200\nconst OG_HEIGHT = 630\n\n/** A Next.js OG image entry. */\nfunction imageEntry(url: string, alt: string) {\n return { url, width: OG_WIDTH, height: OG_HEIGHT, alt }\n}\n\n/**\n * Builds the og/twitter image fragment for a given image URL — the one place\n * that knows the shape Next.js expects.\n */\nfunction ogImageFragment(url: string, alt: string): Pick<Metadata, 'openGraph' | 'twitter'> {\n return {\n openGraph: { images: [imageEntry(url, alt)] },\n twitter: { card: 'summary_large_image', images: [url] },\n }\n}\n\n/** Extract a plain title string from Next.js's title shapes. */\nexport function extractTitle(metadata: Metadata): string {\n const t = metadata.title\n if (!t) return ''\n if (typeof t === 'string') return t\n if (typeof t === 'object' && 'absolute' in t) return (t as { absolute: string }).absolute\n if (typeof t === 'object' && 'default' in t) return (t as { default: string }).default\n return ''\n}\n\n/**\n * Resolves an `OgSpec` into a concrete og/twitter image fragment. `staticUrl`\n * is the site's brand image, used for the `'static'` case.\n *\n * This is the single decision point for \"what is the OG image\" — no other code\n * branches on the spec.\n *\n * To render a per-page card instead, add an `opengraph-image` route: Next.js\n * discovers it by filename and overrides whatever is set here, so it needs no\n * support from this function.\n */\nexport function resolveOgImage(\n spec: OgSpec,\n staticUrl: string,\n fallbackTitle: string\n): Pick<Metadata, 'openGraph' | 'twitter'> {\n if (spec === 'static') {\n return ogImageFragment(staticUrl, fallbackTitle)\n }\n return ogImageFragment(spec.image, spec.alt ?? fallbackTitle)\n}\n","import type { Metadata } from 'next'\nimport type { OgSpec } from './types'\nimport { resolveOgImage } from './metadata'\n\n/**\n * Site-wide identity the metadata factory needs. Config-agnostic on purpose —\n * the package doesn't know about any app's `settings` object, so the consumer\n * passes these once (typically derived from their own settings module).\n */\nexport interface SiteMetaConfig {\n /** Brand / app name, e.g. \"CMDOP\". Used in titles, siteName, alt text. */\n name: string\n /** Default description used when a page doesn't supply its own. */\n description: string\n /** Absolute site origin, e.g. \"https://cmdop.com\". Used for canonical/og url. */\n siteUrl: string\n /** Ready-made static brand OG image URL (absolute or root-relative). */\n ogImage: string\n /** Optional favicon / apple-touch icons forwarded to Metadata.icons. */\n icons?: Metadata['icons']\n /** Locales for hreflang alternates. Omit for single-locale sites. */\n locales?: readonly string[]\n /** Locale used for x-default hreflang (defaults to \"en\" or first locale). */\n defaultLocale?: string\n}\n\n/**\n * Per-page inputs. Only `title` is really worth setting per page; everything\n * else falls back to the site config.\n */\nexport interface PageMeta {\n /** Page title (without the brand suffix). Falls back to the site name. */\n title?: string\n /** Page description; falls back to the site description. */\n description?: string\n keywords?: string[]\n /** Current locale, used for canonical + openGraph.locale + alternates. */\n locale?: string\n /**\n * Path without locale prefix, e.g. \"/skills\" or \"/skills/foo\". Home is ''\n * (or '/'). Leading/trailing slashes are normalized, so both canonical and\n * og:url come out well-formed whichever you pass.\n */\n path?: string\n /**\n * What the OG image should be. Defaults to `'static'` (the brand image).\n * - `'static'` brand image from config (default)\n * - `{ image, alt? }` an exact image URL (e.g. a content screenshot)\n *\n * For a per-page rendered card, add an `opengraph-image` route instead —\n * Next.js finds it by filename and it overrides whatever is set here.\n */\n og?: OgSpec\n /** Escape hatch: extra Metadata fields merged last (robots, authors, etc.). */\n extra?: Metadata\n}\n\n/**\n * Joins the site origin, an optional locale segment, and a page path into one\n * absolute URL.\n *\n * This is the SINGLE place the URL shape is decided. `canonical` and\n * `openGraph.url` describe the same page, so they must be built by the same\n * function — when they were two hand-rolled template literals they drifted\n * apart (one inserted a slash the other didn't), and a page shipped a canonical\n * and an og:url pointing at two different URLs.\n *\n * Contract (see `PageMeta.path`): `path` carries its own leading slash —\n * \"/skills\", \"/skills/foo\" — and home is '' (we also tolerate '/'). Segments\n * are normalized rather than trusted, so a stray or missing slash at a call\n * site cannot produce \"site.com//skills\" or \"site.comskills\".\n */\nfunction buildUrl(siteUrl: string, locale: string | undefined, path: string): string {\n const origin = siteUrl.replace(/\\/+$/, '')\n const segments = [locale, path]\n .map((segment) => segment?.replace(/^\\/+|\\/+$/g, '') ?? '')\n .filter(Boolean)\n return segments.length ? `${origin}/${segments.join('/')}` : origin\n}\n\nfunction buildAlternates(\n config: SiteMetaConfig,\n path: string,\n locale: string\n): Metadata['alternates'] {\n if (!config.locales?.length) {\n return { canonical: buildUrl(config.siteUrl, undefined, path) }\n }\n const languages: Record<string, string> = {}\n for (const loc of config.locales) {\n languages[loc] = buildUrl(config.siteUrl, loc, path)\n }\n const xDefault =\n config.defaultLocale ?? (config.locales.includes('en') ? 'en' : config.locales[0])\n languages['x-default'] = buildUrl(config.siteUrl, xDefault, path)\n return { canonical: buildUrl(config.siteUrl, locale, path), languages }\n}\n\n/**\n * Builds a complete Next.js `Metadata` from a one-time site config plus per-page\n * inputs — title-template, description, openGraph, twitter, canonical + hreflang,\n * icons, and the OG image — so pages only declare what's unique.\n *\n * The OG image is chosen by the single `og` field (defaults to the static brand\n * image). There is exactly one way to do each thing.\n *\n * // app/_core/metadata.ts\n * export const createMetadata = makeMetadataFactory({\n * name: settings.app.name,\n * description: settings.app.description,\n * siteUrl: settings.app.siteUrl,\n * ogImage: settings.app.media.ogimage,\n * icons: { icon: settings.app.media.favicon },\n * locales: LOCALES,\n * })\n *\n * // landing page — static brand image (default)\n * export const metadata = createMetadata({ title: 'Pricing', path: '/pricing' })\n * // content page with its own image\n * export const metadata = createMetadata({ title: p.name, og: { image: p.image } })\n */\nexport function createMetadata(config: SiteMetaConfig, page: PageMeta = {}): Metadata {\n const title = page.title ?? config.name\n const description = page.description ?? config.description\n const locale = page.locale ?? config.defaultLocale ?? 'en'\n const path = page.path ?? ''\n // Same builder as the canonical below — og:url and canonical name the same\n // page, so they are never allowed to be assembled by two different rules.\n const ogUrl = buildUrl(config.siteUrl, config.locales?.length ? locale : undefined, path)\n\n const ogImage = resolveOgImage(page.og ?? 'static', config.ogImage, title)\n\n const metadata: Metadata = {\n title,\n description,\n keywords: page.keywords,\n alternates: buildAlternates(config, path, locale),\n openGraph: {\n type: 'website',\n locale,\n url: ogUrl,\n siteName: config.name,\n title,\n description,\n ...ogImage.openGraph,\n },\n twitter: {\n title,\n description,\n ...ogImage.twitter,\n },\n icons: config.icons,\n }\n\n if (!page.extra) return metadata\n // Shallow-merge the escape hatch, but deep-merge the nested og/twitter so the\n // image we just set isn't clobbered by a partial `extra.openGraph`.\n return {\n ...metadata,\n ...page.extra,\n openGraph: { ...metadata.openGraph, ...page.extra.openGraph },\n twitter: { ...metadata.twitter, ...page.extra.twitter },\n }\n}\n\n/**\n * Curries `createMetadata` with a fixed site config so pages call a zero-config\n * factory. This is the single recommended entry point for app metadata.\n */\nexport function makeMetadataFactory(config: SiteMetaConfig) {\n return (page: PageMeta = {}): Metadata => createMetadata(config, page)\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/nextjs",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.539",
|
|
4
4
|
"description": "Next.js server utilities: sitemap, health, OG images, contact forms, navigation, config",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nextjs",
|
|
@@ -149,9 +149,9 @@
|
|
|
149
149
|
"ai-docs": "tsx src/ai/cli.ts"
|
|
150
150
|
},
|
|
151
151
|
"peerDependencies": {
|
|
152
|
-
"@djangocfg/devtools": "^2.1.
|
|
153
|
-
"@djangocfg/i18n": "^2.1.
|
|
154
|
-
"@djangocfg/ui-core": "^2.1.
|
|
152
|
+
"@djangocfg/devtools": "^2.1.539",
|
|
153
|
+
"@djangocfg/i18n": "^2.1.539",
|
|
154
|
+
"@djangocfg/ui-core": "^2.1.539",
|
|
155
155
|
"next": "^16.2.2"
|
|
156
156
|
},
|
|
157
157
|
"peerDependenciesMeta": {
|
|
@@ -170,11 +170,11 @@
|
|
|
170
170
|
"semver": "^7.8.5"
|
|
171
171
|
},
|
|
172
172
|
"devDependencies": {
|
|
173
|
-
"@djangocfg/devtools": "^2.1.
|
|
174
|
-
"@djangocfg/i18n": "^2.1.
|
|
175
|
-
"@djangocfg/layouts": "^2.1.
|
|
176
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
177
|
-
"@djangocfg/ui-core": "^2.1.
|
|
173
|
+
"@djangocfg/devtools": "^2.1.539",
|
|
174
|
+
"@djangocfg/i18n": "^2.1.539",
|
|
175
|
+
"@djangocfg/layouts": "^2.1.539",
|
|
176
|
+
"@djangocfg/typescript-config": "^2.1.539",
|
|
177
|
+
"@djangocfg/ui-core": "^2.1.539",
|
|
178
178
|
"@types/node": "^25.9.5",
|
|
179
179
|
"@types/react": "19.2.15",
|
|
180
180
|
"@types/react-dom": "19.2.3",
|
package/src/og-image/README.md
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
# @djangocfg/nextjs/og-image
|
|
2
2
|
|
|
3
|
-
Page metadata
|
|
3
|
+
Page metadata for Next.js: title templating, canonical + hreflang alternates,
|
|
4
|
+
OpenGraph, Twitter cards and icons, from one bound factory.
|
|
4
5
|
|
|
5
|
-
**
|
|
6
|
-
|
|
6
|
+
> **Changed 2026-09-03.** This package used to build URLs for a Django-side PNG
|
|
7
|
+
> renderer (`django_ogimage`, `/cfg/og/<base64>/`). That module is archived and
|
|
8
|
+
> the `'dynamic'` / `{ render }` specs, `ogBackend`, `buildOgUrl`,
|
|
9
|
+
> `createOgMetadata`, `withOgImage` and `OGImageParams` are gone with it.
|
|
10
|
+
> Next.js renders per-page cards itself — see [Per-page cards](#per-page-cards).
|
|
7
11
|
|
|
8
12
|
---
|
|
9
13
|
|
|
10
14
|
## Quick start
|
|
11
15
|
|
|
12
|
-
One factory per app; pages declare only what's unique.
|
|
13
|
-
a single `og` field (defaults to your static brand image):
|
|
16
|
+
One factory per app; pages declare only what's unique.
|
|
14
17
|
|
|
15
18
|
```typescript
|
|
16
19
|
// app/_core/metadata.ts — bind the factory once
|
|
@@ -29,124 +32,58 @@ export const createMetadata = makeMetadataFactory({
|
|
|
29
32
|
|
|
30
33
|
// any page.tsx
|
|
31
34
|
export const metadata = createMetadata({ title: 'Pricing', path: '/pricing' })
|
|
32
|
-
// ↑
|
|
35
|
+
// ↑ brand image, canonical, hreflang, og, twitter, icons
|
|
33
36
|
```
|
|
34
37
|
|
|
35
38
|
```typescript
|
|
36
|
-
// content page — render the title onto the OG card
|
|
37
|
-
export const metadata = createMetadata({ title: post.title, path, og: 'dynamic' })
|
|
38
|
-
|
|
39
39
|
// content page with its own image (a screenshot, cover, …)
|
|
40
40
|
export const metadata = createMetadata({ title: p.name, path, og: { image: p.image, alt: p.name } })
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
### The `og` axis (`OgSpec`)
|
|
44
44
|
|
|
45
|
-
A single discriminated union decides what the OG image is — there is exactly one
|
|
46
|
-
way to express each case:
|
|
47
|
-
|
|
48
45
|
| `og` value | Result |
|
|
49
46
|
|---|---|
|
|
50
47
|
| omitted / `'static'` | the brand image from `config.ogImage` (default) |
|
|
51
|
-
| `
|
|
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: '' })
|
|
48
|
+
| `{ image, alt? }` | that exact URL |
|
|
67
49
|
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
50
|
+
Both cases resolve in `resolveOgImage` — the one place that decides what the OG
|
|
51
|
+
image is. `og:image` and `twitter:image` are always written together there,
|
|
52
|
+
because a change that set one and not the other is the failure this package
|
|
53
|
+
exists to prevent.
|
|
74
54
|
|
|
75
55
|
---
|
|
76
56
|
|
|
77
|
-
##
|
|
57
|
+
## Per-page cards
|
|
78
58
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
↓ (dynamic cases only)
|
|
82
|
-
buildOgUrl(params) — base64-encodes OGImageParams
|
|
83
|
-
↓
|
|
84
|
-
resolveOgPublicBase() — picks the right base URL from env vars
|
|
85
|
-
↓
|
|
86
|
-
<meta og:image> = "{base}/cfg/og/{b64}/"
|
|
87
|
-
|
|
88
|
-
Crawler/browser hits that URL
|
|
89
|
-
↓
|
|
90
|
-
Django OGImageRenderView — decodes params, renders PNG via PicTex
|
|
91
|
-
↓
|
|
92
|
-
FileResponse (cached in MEDIA_ROOT/ogimage/<sharded>/<key>.png)
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
Static / `{ image }` cases skip Django entirely — the URL points straight at the
|
|
96
|
-
given image.
|
|
97
|
-
|
|
98
|
-
---
|
|
59
|
+
To render a card per page — the title on a background, a generated cover —
|
|
60
|
+
use Next.js's own file convention:
|
|
99
61
|
|
|
100
|
-
|
|
62
|
+
```tsx
|
|
63
|
+
// app/blog/[slug]/opengraph-image.tsx
|
|
64
|
+
import { ImageResponse } from 'next/og'
|
|
101
65
|
|
|
102
|
-
|
|
66
|
+
export const size = { width: 1200, height: 630 }
|
|
67
|
+
export const contentType = 'image/png'
|
|
103
68
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
| 2 | `NEXT_PUBLIC_API_URL` | direct split-domain (Next.js ≠ Django domain) |
|
|
108
|
-
| 3 | `NEXT_PUBLIC_SITE_URL` | same-domain / static build |
|
|
109
|
-
| 4 | `''` (empty) | relative URLs (last resort) |
|
|
110
|
-
|
|
111
|
-
**Why not always use `API_URL`?**
|
|
112
|
-
Crawlers (Twitter, Facebook, Slack) must reach the OG image URL publicly. When nginx proxies `/media/` through the frontend domain, `API_URL` is internal — the crawler can't reach it. `MEDIA_URL` solves this explicitly.
|
|
113
|
-
|
|
114
|
-
### Deployment examples
|
|
115
|
-
|
|
116
|
-
```bash
|
|
117
|
-
# Same-domain static build (Django serves everything)
|
|
118
|
-
NEXT_PUBLIC_SITE_URL=https://example.com
|
|
119
|
-
NEXT_PUBLIC_API_URL= # empty → relative /cfg/og/...
|
|
120
|
-
|
|
121
|
-
# Split-domain standalone build
|
|
122
|
-
NEXT_PUBLIC_SITE_URL=https://example.com
|
|
123
|
-
NEXT_PUBLIC_API_URL=https://api.example.com
|
|
124
|
-
# → og:image = https://api.example.com/cfg/og/<b64>/
|
|
125
|
-
|
|
126
|
-
# Media proxied through site domain (nginx /media/ → api/media/)
|
|
127
|
-
NEXT_PUBLIC_SITE_URL=https://example.com
|
|
128
|
-
NEXT_PUBLIC_API_URL=https://api.example.com
|
|
129
|
-
NEXT_PUBLIC_MEDIA_URL=https://example.com # ← crawler hits example.com/cfg/og/...
|
|
130
|
-
|
|
131
|
-
# CDN
|
|
132
|
-
NEXT_PUBLIC_MEDIA_URL=https://cdn.example.com
|
|
69
|
+
export default async function Image({ params }) {
|
|
70
|
+
return new ImageResponse(<div style={{ /* … */ }}>{title}</div>, size)
|
|
71
|
+
}
|
|
133
72
|
```
|
|
134
73
|
|
|
74
|
+
Nothing here needs to know about it: Next.js discovers the file by name and its
|
|
75
|
+
output **overrides** whatever `createMetadata` set. That is why the factory has
|
|
76
|
+
no "dynamic" case — adding one would mean two mechanisms competing to set the
|
|
77
|
+
same tag.
|
|
78
|
+
|
|
135
79
|
---
|
|
136
80
|
|
|
137
81
|
## API
|
|
138
82
|
|
|
139
|
-
### Recommended
|
|
140
|
-
|
|
141
83
|
#### `makeMetadataFactory(config) → (page) => Metadata`
|
|
142
84
|
|
|
143
|
-
Binds a `SiteMetaConfig` once and returns a per-page factory.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
```typescript
|
|
147
|
-
const createMetadata = makeMetadataFactory(config)
|
|
148
|
-
export const metadata = createMetadata({ title: 'About', path: '/about' })
|
|
149
|
-
```
|
|
85
|
+
Binds a `SiteMetaConfig` once and returns a per-page factory. The recommended
|
|
86
|
+
entry point.
|
|
150
87
|
|
|
151
88
|
**`SiteMetaConfig`**
|
|
152
89
|
|
|
@@ -156,7 +93,6 @@ export const metadata = createMetadata({ title: 'About', path: '/about' })
|
|
|
156
93
|
| `description` | `string` | default description |
|
|
157
94
|
| `siteUrl` | `string` | absolute origin, e.g. `https://example.com` |
|
|
158
95
|
| `ogImage` | `string` | static brand OG image URL |
|
|
159
|
-
| `ogBackend?` | `string` | Django origin for the dynamic renderer (see below) |
|
|
160
96
|
| `icons?` | `Metadata['icons']` | favicon / apple-touch |
|
|
161
97
|
| `locales?` | `readonly string[]` | for hreflang; omit for single-locale |
|
|
162
98
|
| `defaultLocale?` | `string` | x-default locale (defaults `en` / first) |
|
|
@@ -175,77 +111,9 @@ export const metadata = createMetadata({ title: 'About', path: '/about' })
|
|
|
175
111
|
|
|
176
112
|
#### `createMetadata(config, page)`
|
|
177
113
|
|
|
178
|
-
The un-curried form `makeMetadataFactory` wraps. Use when you don't want a bound
|
|
179
|
-
|
|
180
|
-
### Low-level (escape hatches)
|
|
181
|
-
|
|
182
|
-
Most pages never need these — they're the primitives the factory is built on.
|
|
183
|
-
|
|
184
|
-
#### `buildOgUrl(params)`
|
|
185
|
-
|
|
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.../"
|
|
191
|
-
```
|
|
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
|
-
|
|
204
|
-
---
|
|
205
|
-
|
|
206
|
-
## OGImageParams
|
|
207
|
-
|
|
208
|
-
Mirrors Django's `OGImageParams` exactly:
|
|
209
|
-
|
|
210
|
-
```typescript
|
|
211
|
-
interface OGImageParams {
|
|
212
|
-
title: string // required
|
|
213
|
-
description?: string
|
|
214
|
-
locale?: string // for CJK/RTL font selection
|
|
215
|
-
style?: 'dark' | 'light'
|
|
216
|
-
|
|
217
|
-
// Color overrides (hex, e.g. "#1a1a2e")
|
|
218
|
-
bg_color?: string
|
|
219
|
-
bg_color2?: string // gradient second color
|
|
220
|
-
text_color?: string
|
|
221
|
-
accent_color?: string
|
|
222
|
-
|
|
223
|
-
size?: string // "1200x630" (default)
|
|
224
|
-
preset?: OGPreset // see below
|
|
225
|
-
layout?: OGLayout // see below
|
|
226
|
-
page_url?: string // excluded from Django's cache key
|
|
227
|
-
}
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
### Presets (`OGPreset`)
|
|
231
|
-
|
|
232
|
-
Dark: `DARK` `DARK_BLUE` `DARK_PURPLE` `DARK_GREEN` `DARK_ROSE`
|
|
233
|
-
Light: `LIGHT` `LIGHT_GRAY` `LIGHT_WARM` `LIGHT_GREEN`
|
|
234
|
-
|
|
235
|
-
### Layouts (`OGLayout`)
|
|
236
|
-
|
|
237
|
-
| Value | Description |
|
|
238
|
-
|---|---|
|
|
239
|
-
| `DEFAULT` | Accent bar top, brand bottom-left, content centered |
|
|
240
|
-
| `HERO` | Accent bar top, brand top-left, content bottom — larger fonts |
|
|
241
|
-
| `ARTICLE` | No accent, brand top-left, content centered |
|
|
242
|
-
| `MINIMAL` | No accent, no brand, pure content |
|
|
243
|
-
|
|
244
|
-
---
|
|
245
|
-
|
|
246
|
-
## Django side
|
|
114
|
+
The un-curried form `makeMetadataFactory` wraps. Use when you don't want a bound
|
|
115
|
+
factory.
|
|
247
116
|
|
|
248
|
-
|
|
249
|
-
Enable it by including the URLs in your Django project (handled automatically by `django-cfg`).
|
|
117
|
+
#### `resolveOgImage(spec, staticUrl, fallbackTitle)` · `extractTitle(metadata)`
|
|
250
118
|
|
|
251
|
-
|
|
119
|
+
Escape hatches for custom factories. Most pages never touch them.
|
package/src/og-image/index.ts
CHANGED
|
@@ -4,8 +4,7 @@ export { createMetadata, makeMetadataFactory } from './metadata-factory'
|
|
|
4
4
|
export type { SiteMetaConfig, PageMeta } from './metadata-factory'
|
|
5
5
|
|
|
6
6
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
7
|
-
export type {
|
|
7
|
+
export type { OgSpec } from './types'
|
|
8
8
|
|
|
9
9
|
// ── Low-level primitives (escape hatches) ────────────────────────────────────
|
|
10
|
-
export {
|
|
11
|
-
export { createOgMetadata, withOgImage, resolveOgImage, extractTitle } from './metadata'
|
|
10
|
+
export { resolveOgImage, extractTitle } from './metadata'
|
|
@@ -16,16 +16,6 @@ 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
|
|
29
19
|
/** Optional favicon / apple-touch icons forwarded to Metadata.icons. */
|
|
30
20
|
icons?: Metadata['icons']
|
|
31
21
|
/** Locales for hreflang alternates. Omit for single-locale sites. */
|
|
@@ -55,9 +45,10 @@ export interface PageMeta {
|
|
|
55
45
|
/**
|
|
56
46
|
* What the OG image should be. Defaults to `'static'` (the brand image).
|
|
57
47
|
* - `'static'` brand image from config (default)
|
|
58
|
-
* - `'dynamic'` render the page title onto the card
|
|
59
|
-
* - `{ render: {...} }` render with explicit preset/layout/colors
|
|
60
48
|
* - `{ image, alt? }` an exact image URL (e.g. a content screenshot)
|
|
49
|
+
*
|
|
50
|
+
* For a per-page rendered card, add an `opengraph-image` route instead —
|
|
51
|
+
* Next.js finds it by filename and it overrides whatever is set here.
|
|
61
52
|
*/
|
|
62
53
|
og?: OgSpec
|
|
63
54
|
/** Escape hatch: extra Metadata fields merged last (robots, authors, etc.). */
|
|
@@ -125,8 +116,6 @@ function buildAlternates(
|
|
|
125
116
|
*
|
|
126
117
|
* // landing page — static brand image (default)
|
|
127
118
|
* export const metadata = createMetadata({ title: 'Pricing', path: '/pricing' })
|
|
128
|
-
* // content page — title rendered onto the card
|
|
129
|
-
* export const metadata = createMetadata({ title: post.title, path, og: 'dynamic' })
|
|
130
119
|
* // content page with its own image
|
|
131
120
|
* export const metadata = createMetadata({ title: p.name, og: { image: p.image } })
|
|
132
121
|
*/
|
|
@@ -139,7 +128,7 @@ export function createMetadata(config: SiteMetaConfig, page: PageMeta = {}): Met
|
|
|
139
128
|
// page, so they are never allowed to be assembled by two different rules.
|
|
140
129
|
const ogUrl = buildUrl(config.siteUrl, config.locales?.length ? locale : undefined, path)
|
|
141
130
|
|
|
142
|
-
const ogImage = resolveOgImage(page.og ?? 'static', config.ogImage, title
|
|
131
|
+
const ogImage = resolveOgImage(page.og ?? 'static', config.ogImage, title)
|
|
143
132
|
|
|
144
133
|
const metadata: Metadata = {
|
|
145
134
|
title,
|
package/src/og-image/metadata.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Metadata } from 'next'
|
|
2
|
-
import type {
|
|
3
|
-
import { buildOgUrl } from './url'
|
|
2
|
+
import type { OgSpec } from './types'
|
|
4
3
|
|
|
5
4
|
const OG_WIDTH = 1200
|
|
6
5
|
const OG_HEIGHT = 630
|
|
@@ -12,8 +11,7 @@ function imageEntry(url: string, alt: string) {
|
|
|
12
11
|
|
|
13
12
|
/**
|
|
14
13
|
* Builds the og/twitter image fragment for a given image URL — the one place
|
|
15
|
-
* that knows the shape Next.js expects.
|
|
16
|
-
* renderer funnel through here, so there's a single source of truth.
|
|
14
|
+
* that knows the shape Next.js expects.
|
|
17
15
|
*/
|
|
18
16
|
function ogImageFragment(url: string, alt: string): Pick<Metadata, 'openGraph' | 'twitter'> {
|
|
19
17
|
return {
|
|
@@ -22,15 +20,6 @@ function ogImageFragment(url: string, alt: string): Pick<Metadata, 'openGraph' |
|
|
|
22
20
|
}
|
|
23
21
|
}
|
|
24
22
|
|
|
25
|
-
/**
|
|
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.
|
|
29
|
-
*/
|
|
30
|
-
export function createOgMetadata(params: OGImageParams): Metadata {
|
|
31
|
-
return ogImageFragment(buildOgUrl(params), params.title)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
23
|
/** Extract a plain title string from Next.js's title shapes. */
|
|
35
24
|
export function extractTitle(metadata: Metadata): string {
|
|
36
25
|
const t = metadata.title
|
|
@@ -42,59 +31,23 @@ export function extractTitle(metadata: Metadata): string {
|
|
|
42
31
|
}
|
|
43
32
|
|
|
44
33
|
/**
|
|
45
|
-
* Resolves an `OgSpec`
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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.)
|
|
34
|
+
* Resolves an `OgSpec` into a concrete og/twitter image fragment. `staticUrl`
|
|
35
|
+
* is the site's brand image, used for the `'static'` case.
|
|
53
36
|
*
|
|
54
37
|
* This is the single decision point for "what is the OG image" — no other code
|
|
55
38
|
* branches on the spec.
|
|
39
|
+
*
|
|
40
|
+
* To render a per-page card instead, add an `opengraph-image` route: Next.js
|
|
41
|
+
* discovers it by filename and overrides whatever is set here, so it needs no
|
|
42
|
+
* support from this function.
|
|
56
43
|
*/
|
|
57
44
|
export function resolveOgImage(
|
|
58
45
|
spec: OgSpec,
|
|
59
46
|
staticUrl: string,
|
|
60
|
-
fallbackTitle: string
|
|
61
|
-
backend?: string
|
|
47
|
+
fallbackTitle: string
|
|
62
48
|
): Pick<Metadata, 'openGraph' | 'twitter'> {
|
|
63
|
-
const canRenderDynamic = backend === undefined || backend !== ''
|
|
64
|
-
|
|
65
49
|
if (spec === 'static') {
|
|
66
50
|
return ogImageFragment(staticUrl, fallbackTitle)
|
|
67
51
|
}
|
|
68
|
-
|
|
69
|
-
return canRenderDynamic
|
|
70
|
-
? ogImageFragment(buildOgUrl({ title: fallbackTitle }, backend), fallbackTitle)
|
|
71
|
-
: ogImageFragment(staticUrl, fallbackTitle)
|
|
72
|
-
}
|
|
73
|
-
if ('image' in spec) {
|
|
74
|
-
return ogImageFragment(spec.image, spec.alt ?? fallbackTitle)
|
|
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
|
-
}
|
|
82
|
-
|
|
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 })
|
|
95
|
-
return {
|
|
96
|
-
...metadata,
|
|
97
|
-
openGraph: { ...metadata.openGraph, ...frag.openGraph },
|
|
98
|
-
twitter: { ...metadata.twitter, ...frag.twitter },
|
|
99
|
-
}
|
|
52
|
+
return ogImageFragment(spec.image, spec.alt ?? fallbackTitle)
|
|
100
53
|
}
|
package/src/og-image/types.ts
CHANGED
|
@@ -1,45 +1,12 @@
|
|
|
1
|
-
export type OGPreset =
|
|
2
|
-
| 'DARK'
|
|
3
|
-
| 'DARK_BLUE'
|
|
4
|
-
| 'DARK_PURPLE'
|
|
5
|
-
| 'DARK_GREEN'
|
|
6
|
-
| 'DARK_ROSE'
|
|
7
|
-
| 'LIGHT'
|
|
8
|
-
| 'LIGHT_GRAY'
|
|
9
|
-
| 'LIGHT_WARM'
|
|
10
|
-
| 'LIGHT_GREEN'
|
|
11
|
-
|
|
12
|
-
export type OGLayout = 'DEFAULT' | 'HERO' | 'ARTICLE' | 'MINIMAL'
|
|
13
|
-
|
|
14
1
|
/**
|
|
15
2
|
* 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
3
|
*
|
|
18
4
|
* '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
5
|
* { image, alt? } an exact image URL (e.g. a content screenshot)
|
|
6
|
+
*
|
|
7
|
+
* There is deliberately no "render this title onto a card" case. That was
|
|
8
|
+
* served by a Django endpoint (`/cfg/og/<base64>/`), archived 2026-09-03 —
|
|
9
|
+
* Next.js renders such cards itself via an `opengraph-image` route, which
|
|
10
|
+
* needs no metadata support because the file convention IS the registration.
|
|
22
11
|
*/
|
|
23
|
-
export type OgSpec =
|
|
24
|
-
| 'static'
|
|
25
|
-
| 'dynamic'
|
|
26
|
-
| { render: Omit<OGImageParams, 'title'> & { title?: string } }
|
|
27
|
-
| { image: string; alt?: string }
|
|
28
|
-
|
|
29
|
-
/** Mirrors Django OGImageParams exactly */
|
|
30
|
-
export interface OGImageParams {
|
|
31
|
-
title: string
|
|
32
|
-
description?: string
|
|
33
|
-
locale?: string
|
|
34
|
-
style?: 'dark' | 'light'
|
|
35
|
-
bg_color?: string
|
|
36
|
-
bg_color2?: string
|
|
37
|
-
text_color?: string
|
|
38
|
-
accent_color?: string
|
|
39
|
-
/** e.g. "1200x630" */
|
|
40
|
-
size?: string
|
|
41
|
-
preset?: OGPreset
|
|
42
|
-
layout?: OGLayout
|
|
43
|
-
/** Excluded from cache key on Django side */
|
|
44
|
-
page_url?: string
|
|
45
|
-
}
|
|
12
|
+
export type OgSpec = 'static' | { image: string; alt?: string }
|
package/src/og-image/url.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import type { OGImageParams } from './types'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Resolves the base URL for og:image meta tags.
|
|
5
|
-
* Must be publicly accessible by crawlers (Twitter, Facebook, etc).
|
|
6
|
-
*
|
|
7
|
-
* Priority:
|
|
8
|
-
* 1. NEXT_PUBLIC_MEDIA_URL — explicit media domain (nginx proxy or CDN)
|
|
9
|
-
* 2. NEXT_PUBLIC_API_URL — direct Django API domain
|
|
10
|
-
* 3. NEXT_PUBLIC_SITE_URL — site domain (same-domain/static build)
|
|
11
|
-
* 4. '' — relative URL (last resort)
|
|
12
|
-
*
|
|
13
|
-
* Use cases:
|
|
14
|
-
* - Same domain (static build): API_URL='' → relative /cfg/og/...
|
|
15
|
-
* - Split domain (standalone): API_URL=https://api.x.com → absolute
|
|
16
|
-
* - Media via nginx proxy: MEDIA_URL=https://x.com, API_URL=https://api.x.com
|
|
17
|
-
* - CDN: MEDIA_URL=https://cdn.x.com
|
|
18
|
-
*/
|
|
19
|
-
function resolveOgPublicBase(): string {
|
|
20
|
-
if (typeof process === 'undefined') return ''
|
|
21
|
-
return (
|
|
22
|
-
process.env.NEXT_PUBLIC_MEDIA_URL?.replace(/\/$/, '') ??
|
|
23
|
-
process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, '') ??
|
|
24
|
-
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, '') ??
|
|
25
|
-
''
|
|
26
|
-
)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function encodeBase64(str: string): string {
|
|
30
|
-
if (typeof Buffer !== 'undefined') {
|
|
31
|
-
return Buffer.from(str).toString('base64url')
|
|
32
|
-
}
|
|
33
|
-
// Browser fallback
|
|
34
|
-
return btoa(unescape(encodeURIComponent(str)))
|
|
35
|
-
.replace(/\+/g, '-')
|
|
36
|
-
.replace(/\//g, '_')
|
|
37
|
-
.replace(/=+$/, '')
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function cleanParams(params: OGImageParams): Record<string, unknown> {
|
|
41
|
-
return Object.fromEntries(
|
|
42
|
-
Object.entries(params).filter(
|
|
43
|
-
([, v]) => v !== undefined && v !== null && v !== ''
|
|
44
|
-
)
|
|
45
|
-
)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Builds an absolute (or relative) OG image URL pointing to Django's
|
|
50
|
-
* django_ogimage endpoint: /cfg/og/<base64params>/
|
|
51
|
-
*
|
|
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.
|
|
57
|
-
*/
|
|
58
|
-
export function buildOgUrl(params: OGImageParams, base?: string): string {
|
|
59
|
-
const b64 = encodeBase64(JSON.stringify(cleanParams(params)))
|
|
60
|
-
const origin = (base ?? resolveOgPublicBase()).replace(/\/$/, '')
|
|
61
|
-
return `${origin}/cfg/og/${b64}/`
|
|
62
|
-
}
|