@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 CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  ## Features
25
25
 
26
- - **Page Metadata + OG Images** — one factory for complete page metadata; static or dynamically-rendered OG images via Django's `django_ogimage` (`@djangocfg/nextjs/og-image`)
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, dynamic title-rendered card on demand. No Edge
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 → render the title onto the card
411
- export const metadata = createMetadata({ title: post.title, path, og: 'dynamic' })
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
- The dynamic URL is resolved automatically from `NEXT_PUBLIC_MEDIA_URL` → `NEXT_PUBLIC_API_URL` → `NEXT_PUBLIC_SITE_URL`. Django renders and caches the PNG — no Edge Runtime or `@vercel/og` required.
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 + static/dynamic OG images for Django's `django_ogimage` |
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 and OG images for
627
- Django's `django_ogimage` renderer. No Edge Runtime, no `@vercel/og`, no JSX
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, URL resolution, deployment examples).
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) · `'dynamic'` · `{ render }` · `{ image, alt? }`.
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' }) // static
647
- export const metadata = createMetadata({ title: post.title, path, og: 'dynamic' }) // rendered 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
- #### Low-level (escape hatches)
650
+ #### Per-page rendered cards
651
651
 
652
- `withOgImage(metadata, params)` merges a dynamic OG image into existing metadata;
653
- `createOgMetadata(params)` returns the `{ openGraph, twitter }` fragment;
654
- `buildOgUrl(params)` returns the raw URL string; `resolveOgImage(spec, …)` is the
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
- ```tsx
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
- **URL resolution** (`NEXT_PUBLIC_MEDIA_URL` → `NEXT_PUBLIC_API_URL` → `NEXT_PUBLIC_SITE_URL` → relative).
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' | 'dynamic' | {
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` against a base Metadata into a concrete og/twitter image
154
- * fragment. `staticUrl` is the site's brand image, used for the `'static'` case.
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
- * return withOgImage({ title: 'Page' }, { preset: 'DARK_BLUE' })
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 withOgImage(metadata: Metadata, params?: Omit<OGImageParams, 'title'> & {
173
- title?: string;
174
- }): Metadata;
113
+ declare function resolveOgImage(spec: OgSpec, staticUrl: string, fallbackTitle: string): Pick<Metadata, 'openGraph' | 'twitter'>;
175
114
 
176
- export { type OGImageParams, type OGLayout, type OGPreset, type OgSpec, type PageMeta, type SiteMetaConfig, buildOgUrl, createMetadata, createOgMetadata, extractTitle, makeMetadataFactory, resolveOgImage, withOgImage };
115
+ export { type OgSpec, type PageMeta, type SiteMetaConfig, createMetadata, extractTitle, makeMetadataFactory, resolveOgImage };
@@ -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, backend) {
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
- if (spec === "dynamic") {
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, config.ogBackend);
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":";AAkBA,SAAS,sBAA8B;AACrC,MAAI,OAAO,YAAY,YAAa,QAAO;AAC3C,SACE,QAAQ,IAAI,uBAAuB,QAAQ,OAAO,EAAE,KACpD,QAAQ,IAAI,qBAAqB,QAAQ,OAAO,EAAE,KAClD,QAAQ,IAAI,sBAAsB,QAAQ,OAAO,EAAE,KACnD;AAEJ;AAEA,SAAS,aAAa,KAAqB;AACzC,MAAI,OAAO,WAAW,aAAa;AACjC,WAAO,OAAO,KAAK,GAAG,EAAE,SAAS,WAAW;AAAA,EAC9C;AAEA,SAAO,KAAK,SAAS,mBAAmB,GAAG,CAAC,CAAC,EAC1C,QAAQ,OAAO,GAAG,EAClB,QAAQ,OAAO,GAAG,EAClB,QAAQ,OAAO,EAAE;AACtB;AAEA,SAAS,YAAY,QAAgD;AACnE,SAAO,OAAO;AAAA,IACZ,OAAO,QAAQ,MAAM,EAAE;AAAA,MACrB,CAAC,CAAC,EAAE,CAAC,MAAM,MAAM,UAAa,MAAM,QAAQ,MAAM;AAAA,IACpD;AAAA,EACF;AACF;AAYO,SAAS,WAAW,QAAuB,MAAuB;AACvE,QAAM,MAAM,aAAa,KAAK,UAAU,YAAY,MAAM,CAAC,CAAC;AAC5D,QAAM,UAAU,QAAQ,oBAAoB,GAAG,QAAQ,OAAO,EAAE;AAChE,SAAO,GAAG,MAAM,WAAW,GAAG;AAChC;;;ACzDA,IAAM,WAAW;AACjB,IAAM,YAAY;AAGlB,SAAS,WAAW,KAAa,KAAa;AAC5C,SAAO,EAAE,KAAK,OAAO,UAAU,QAAQ,WAAW,IAAI;AACxD;AAOA,SAAS,gBAAgB,KAAa,KAAsD;AAC1F,SAAO;AAAA,IACL,WAAW,EAAE,QAAQ,CAAC,WAAW,KAAK,GAAG,CAAC,EAAE;AAAA,IAC5C,SAAS,EAAE,MAAM,uBAAuB,QAAQ,CAAC,GAAG,EAAE;AAAA,EACxD;AACF;AAOO,SAAS,iBAAiB,QAAiC;AAChE,SAAO,gBAAgB,WAAW,MAAM,GAAG,OAAO,KAAK;AACzD;AAGO,SAAS,aAAa,UAA4B;AACvD,QAAM,IAAI,SAAS;AACnB,MAAI,CAAC,EAAG,QAAO;AACf,MAAI,OAAO,MAAM,SAAU,QAAO;AAClC,MAAI,OAAO,MAAM,YAAY,cAAc,EAAG,QAAQ,EAA2B;AACjF,MAAI,OAAO,MAAM,YAAY,aAAa,EAAG,QAAQ,EAA0B;AAC/E,SAAO;AACT;AAeO,SAAS,eACd,MACA,WACA,eACA,SACyC;AACzC,QAAM,mBAAmB,YAAY,UAAa,YAAY;AAE9D,MAAI,SAAS,UAAU;AACrB,WAAO,gBAAgB,WAAW,aAAa;AAAA,EACjD;AACA,MAAI,SAAS,WAAW;AACtB,WAAO,mBACH,gBAAgB,WAAW,EAAE,OAAO,cAAc,GAAG,OAAO,GAAG,aAAa,IAC5E,gBAAgB,WAAW,aAAa;AAAA,EAC9C;AACA,MAAI,WAAW,MAAM;AACnB,WAAO,gBAAgB,KAAK,OAAO,KAAK,OAAO,aAAa;AAAA,EAC9D;AAEA,QAAM,QAAQ,KAAK,OAAO,SAAS;AACnC,SAAO,mBACH,gBAAgB,WAAW,EAAE,GAAG,KAAK,QAAQ,MAAM,GAAG,OAAO,GAAG,KAAK,IACrE,gBAAgB,WAAW,KAAK;AACtC;AAQO,SAAS,YACd,UACA,SAA4D,CAAC,GACnD;AACV,QAAM,QAAQ,OAAO,SAAS,aAAa,QAAQ;AACnD,QAAM,OAAO,iBAAiB,EAAE,GAAG,QAAQ,MAAM,CAAC;AAClD,SAAO;AAAA,IACL,GAAG;AAAA,IACH,WAAW,EAAE,GAAG,SAAS,WAAW,GAAG,KAAK,UAAU;AAAA,IACtD,SAAS,EAAE,GAAG,SAAS,SAAS,GAAG,KAAK,QAAQ;AAAA,EAClD;AACF;;;AClBA,SAAS,SAAS,SAAiB,QAA4B,MAAsB;AACnF,QAAM,SAAS,QAAQ,QAAQ,QAAQ,EAAE;AACzC,QAAM,WAAW,CAAC,QAAQ,IAAI,EAC3B,IAAI,CAAC,YAAY,SAAS,QAAQ,cAAc,EAAE,KAAK,EAAE,EACzD,OAAO,OAAO;AACjB,SAAO,SAAS,SAAS,GAAG,MAAM,IAAI,SAAS,KAAK,GAAG,CAAC,KAAK;AAC/D;AAEA,SAAS,gBACP,QACA,MACA,QACwB;AACxB,MAAI,CAAC,OAAO,SAAS,QAAQ;AAC3B,WAAO,EAAE,WAAW,SAAS,OAAO,SAAS,QAAW,IAAI,EAAE;AAAA,EAChE;AACA,QAAM,YAAoC,CAAC;AAC3C,aAAW,OAAO,OAAO,SAAS;AAChC,cAAU,GAAG,IAAI,SAAS,OAAO,SAAS,KAAK,IAAI;AAAA,EACrD;AACA,QAAM,WACJ,OAAO,kBAAkB,OAAO,QAAQ,SAAS,IAAI,IAAI,OAAO,OAAO,QAAQ,CAAC;AAClF,YAAU,WAAW,IAAI,SAAS,OAAO,SAAS,UAAU,IAAI;AAChE,SAAO,EAAE,WAAW,SAAS,OAAO,SAAS,QAAQ,IAAI,GAAG,UAAU;AACxE;AA2BO,SAAS,eAAe,QAAwB,OAAiB,CAAC,GAAa;AACpF,QAAM,QAAQ,KAAK,SAAS,OAAO;AACnC,QAAM,cAAc,KAAK,eAAe,OAAO;AAC/C,QAAM,SAAS,KAAK,UAAU,OAAO,iBAAiB;AACtD,QAAM,OAAO,KAAK,QAAQ;AAG1B,QAAM,QAAQ,SAAS,OAAO,SAAS,OAAO,SAAS,SAAS,SAAS,QAAW,IAAI;AAExF,QAAM,UAAU,eAAe,KAAK,MAAM,UAAU,OAAO,SAAS,OAAO,OAAO,SAAS;AAE3F,QAAM,WAAqB;AAAA,IACzB;AAAA,IACA;AAAA,IACA,UAAU,KAAK;AAAA,IACf,YAAY,gBAAgB,QAAQ,MAAM,MAAM;AAAA,IAChD,WAAW;AAAA,MACT,MAAM;AAAA,MACN;AAAA,MACA,KAAK;AAAA,MACL,UAAU,OAAO;AAAA,MACjB;AAAA,MACA;AAAA,MACA,GAAG,QAAQ;AAAA,IACb;AAAA,IACA,SAAS;AAAA,MACP;AAAA,MACA;AAAA,MACA,GAAG,QAAQ;AAAA,IACb;AAAA,IACA,OAAO,OAAO;AAAA,EAChB;AAEA,MAAI,CAAC,KAAK,MAAO,QAAO;AAGxB,SAAO;AAAA,IACL,GAAG;AAAA,IACH,GAAG,KAAK;AAAA,IACR,WAAW,EAAE,GAAG,SAAS,WAAW,GAAG,KAAK,MAAM,UAAU;AAAA,IAC5D,SAAS,EAAE,GAAG,SAAS,SAAS,GAAG,KAAK,MAAM,QAAQ;AAAA,EACxD;AACF;AAMO,SAAS,oBAAoB,QAAwB;AAC1D,SAAO,CAAC,OAAiB,CAAC,MAAgB,eAAe,QAAQ,IAAI;AACvE;","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.536",
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.536",
153
- "@djangocfg/i18n": "^2.1.536",
154
- "@djangocfg/ui-core": "^2.1.536",
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.536",
174
- "@djangocfg/i18n": "^2.1.536",
175
- "@djangocfg/layouts": "^2.1.536",
176
- "@djangocfg/typescript-config": "^2.1.536",
177
- "@djangocfg/ui-core": "^2.1.536",
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",
@@ -1,16 +1,19 @@
1
1
  # @djangocfg/nextjs/og-image
2
2
 
3
- Page metadata + typed OG image builder for Django's `django_ogimage` renderer.
3
+ Page metadata for Next.js: title templating, canonical + hreflang alternates,
4
+ OpenGraph, Twitter cards and icons, from one bound factory.
4
5
 
5
- **No Edge Runtime. No `@vercel/og`. No JSX templates.**
6
- Django renders and caches the PNG — Next.js only builds the URL and injects it into metadata.
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. The OG image is chosen by
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
- // ↑ static brand image, canonical, hreflang, og, twitter, icons
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
- | `'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: '' })
48
+ | `{ image, alt? }` | that exact URL |
67
49
 
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.
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
- ## How It Works
57
+ ## Per-page cards
78
58
 
79
- ```
80
- createMetadata({ … , og }) — picks the OG image by the single `og` field
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
- ## URL Resolution
62
+ ```tsx
63
+ // app/blog/[slug]/opengraph-image.tsx
64
+ import { ImageResponse } from 'next/og'
101
65
 
102
- The public base URL (used in `og:image` meta tags) is resolved in priority order:
66
+ export const size = { width: 1200, height: 630 }
67
+ export const contentType = 'image/png'
103
68
 
104
- | Priority | Variable | Use case |
105
- |---|---|---|
106
- | 1 | `NEXT_PUBLIC_MEDIA_URL` | nginx proxies `/media/` from `SITE_URL` to `API_URL`; or CDN |
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. This is the single
144
- recommended entry point.
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 factory.
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
- The endpoint is registered by `django_ogimage` at `/cfg/og/<b64params>/`.
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
- Cache lives at `MEDIA_ROOT/ogimage/<key[:2]>/<key[2:4]>/<key>.png` — sharded, `Cache-Control: public, max-age=86400`.
119
+ Escape hatches for custom factories. Most pages never touch them.
@@ -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 { OGImageParams, OGPreset, OGLayout, OgSpec } from './types'
7
+ export type { OgSpec } from './types'
8
8
 
9
9
  // ── Low-level primitives (escape hatches) ────────────────────────────────────
10
- export { buildOgUrl } from './url'
11
- export { createOgMetadata, withOgImage, resolveOgImage, extractTitle } from './metadata'
10
+ export { resolveOgImage, extractTitle } from './metadata'
@@ -21,7 +21,6 @@ const BASE: SiteMetaConfig = {
21
21
  description: 'Capability catalogue',
22
22
  siteUrl: 'https://marketplace.cmdop.com',
23
23
  ogImage: '/opengraph-image',
24
- ogBackend: '',
25
24
  }
26
25
 
27
26
  const LOCALIZED: SiteMetaConfig = {
@@ -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, config.ogBackend)
131
+ const ogImage = resolveOgImage(page.og ?? 'static', config.ogImage, title)
143
132
 
144
133
  const metadata: Metadata = {
145
134
  title,
@@ -1,6 +1,5 @@
1
1
  import type { Metadata } from 'next'
2
- import type { OGImageParams, OgSpec } from './types'
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. Both static URLs and the dynamic
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` against a base Metadata into a concrete og/twitter image
46
- * fragment. `staticUrl` is the site's brand image, used for the `'static'` case.
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
- if (spec === 'dynamic') {
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
  }
@@ -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 }
@@ -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
- }