@djangocfg/nextjs 2.1.439 → 2.1.440
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/index.mjs +1 -1
- package/dist/config/index.mjs.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/og-image/index.d.mts +22 -3
- package/dist/og-image/index.mjs +8 -7
- package/dist/og-image/index.mjs.map +1 -1
- package/package.json +9 -9
- package/src/og-image/README.md +21 -0
- package/src/og-image/metadata-factory.ts +11 -1
- package/src/og-image/metadata.ts +16 -4
- package/src/og-image/url.ts +7 -3
|
@@ -51,6 +51,16 @@ interface SiteMetaConfig {
|
|
|
51
51
|
siteUrl: string;
|
|
52
52
|
/** Ready-made static brand OG image URL (absolute or root-relative). */
|
|
53
53
|
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;
|
|
54
64
|
/** Optional favicon / apple-touch icons forwarded to Metadata.icons. */
|
|
55
65
|
icons?: Metadata['icons'];
|
|
56
66
|
/** Locales for hreflang alternates. Omit for single-locale sites. */
|
|
@@ -120,8 +130,12 @@ declare function makeMetadataFactory(config: SiteMetaConfig): (page?: PageMeta)
|
|
|
120
130
|
* django_ogimage endpoint: /cfg/og/<base64params>/
|
|
121
131
|
*
|
|
122
132
|
* The URL is suitable for use in og:image and twitter:image meta tags.
|
|
133
|
+
*
|
|
134
|
+
* @param base Optional explicit backend origin (e.g. "https://api.example.com").
|
|
135
|
+
* When omitted, it's resolved from env (MEDIA_URL → API_URL → SITE_URL → '').
|
|
136
|
+
* Pass the `ogBackend` from your site config to avoid relying on env.
|
|
123
137
|
*/
|
|
124
|
-
declare function buildOgUrl(params: OGImageParams): string;
|
|
138
|
+
declare function buildOgUrl(params: OGImageParams, base?: string): string;
|
|
125
139
|
|
|
126
140
|
/**
|
|
127
141
|
* Metadata fragment whose OG image is rendered by Django's django_ogimage
|
|
@@ -134,12 +148,17 @@ declare function extractTitle(metadata: Metadata): string;
|
|
|
134
148
|
/**
|
|
135
149
|
* Resolves an `OgSpec` against a base Metadata into a concrete og/twitter image
|
|
136
150
|
* fragment. `staticUrl` is the site's brand image, used for the `'static'` case.
|
|
137
|
-
*
|
|
151
|
+
*
|
|
152
|
+
* `backend` is the Django origin that serves the dynamic renderer. The dynamic
|
|
153
|
+
* cases (`'dynamic'`, `{ render }`) require it: when it's `undefined`/empty —
|
|
154
|
+
* e.g. a frontend with no Django backend — they **fall back to the static brand
|
|
155
|
+
* image** instead of emitting a broken `/cfg/og/` URL. (`undefined` still lets
|
|
156
|
+
* `buildOgUrl` fall back to env vars; pass `''` to force the static fallback.)
|
|
138
157
|
*
|
|
139
158
|
* This is the single decision point for "what is the OG image" — no other code
|
|
140
159
|
* branches on the spec.
|
|
141
160
|
*/
|
|
142
|
-
declare function resolveOgImage(spec: OgSpec, staticUrl: string, fallbackTitle: string): Pick<Metadata, 'openGraph' | 'twitter'>;
|
|
161
|
+
declare function resolveOgImage(spec: OgSpec, staticUrl: string, fallbackTitle: string, backend?: string): Pick<Metadata, 'openGraph' | 'twitter'>;
|
|
143
162
|
/**
|
|
144
163
|
* Merges OG image metadata (dynamic render) into existing Metadata. Kept for
|
|
145
164
|
* low-level/manual use; `createMetadata` is the recommended entry point.
|
package/dist/og-image/index.mjs
CHANGED
|
@@ -16,10 +16,10 @@ function cleanParams(params) {
|
|
|
16
16
|
)
|
|
17
17
|
);
|
|
18
18
|
}
|
|
19
|
-
function buildOgUrl(params) {
|
|
19
|
+
function buildOgUrl(params, base) {
|
|
20
20
|
const b64 = encodeBase64(JSON.stringify(cleanParams(params)));
|
|
21
|
-
const
|
|
22
|
-
return `${
|
|
21
|
+
const origin = (base ?? resolveOgPublicBase()).replace(/\/$/, "");
|
|
22
|
+
return `${origin}/cfg/og/${b64}/`;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
// src/og-image/metadata.ts
|
|
@@ -45,18 +45,19 @@ function extractTitle(metadata) {
|
|
|
45
45
|
if (typeof t === "object" && "default" in t) return t.default;
|
|
46
46
|
return "";
|
|
47
47
|
}
|
|
48
|
-
function resolveOgImage(spec, staticUrl, fallbackTitle) {
|
|
48
|
+
function resolveOgImage(spec, staticUrl, fallbackTitle, backend) {
|
|
49
|
+
const canRenderDynamic = backend === void 0 || backend !== "";
|
|
49
50
|
if (spec === "static") {
|
|
50
51
|
return ogImageFragment(staticUrl, fallbackTitle);
|
|
51
52
|
}
|
|
52
53
|
if (spec === "dynamic") {
|
|
53
|
-
return ogImageFragment(buildOgUrl({ title: fallbackTitle }), fallbackTitle);
|
|
54
|
+
return canRenderDynamic ? ogImageFragment(buildOgUrl({ title: fallbackTitle }, backend), fallbackTitle) : ogImageFragment(staticUrl, fallbackTitle);
|
|
54
55
|
}
|
|
55
56
|
if ("image" in spec) {
|
|
56
57
|
return ogImageFragment(spec.image, spec.alt ?? fallbackTitle);
|
|
57
58
|
}
|
|
58
59
|
const title = spec.render.title ?? fallbackTitle;
|
|
59
|
-
return ogImageFragment(buildOgUrl({ ...spec.render, title }), title);
|
|
60
|
+
return canRenderDynamic ? ogImageFragment(buildOgUrl({ ...spec.render, title }, backend), title) : ogImageFragment(staticUrl, title);
|
|
60
61
|
}
|
|
61
62
|
function withOgImage(metadata, params = {}) {
|
|
62
63
|
const title = params.title ?? extractTitle(metadata);
|
|
@@ -87,7 +88,7 @@ function createMetadata(config, page = {}) {
|
|
|
87
88
|
const locale = page.locale ?? config.defaultLocale ?? "en";
|
|
88
89
|
const path = page.path ?? "";
|
|
89
90
|
const ogUrl = config.locales?.length ? `${config.siteUrl}/${locale}${path}` : `${config.siteUrl}${path}`;
|
|
90
|
-
const ogImage = resolveOgImage(page.og ?? "static", config.ogImage, title);
|
|
91
|
+
const ogImage = resolveOgImage(page.og ?? "static", config.ogImage, title, config.ogBackend);
|
|
91
92
|
const metadata = {
|
|
92
93
|
title,
|
|
93
94
|
description,
|
|
@@ -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 */\nexport function buildOgUrl(params: OGImageParams): string {\n const b64 = encodeBase64(JSON.stringify(cleanParams(params)))\n const base = resolveOgPublicBase()\n return `${base}/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 * Returns `null` only if there's genuinely nothing to set.\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): Pick<Metadata, 'openGraph' | 'twitter'> {\n if (spec === 'static') {\n return ogImageFragment(staticUrl, fallbackTitle)\n }\n if (spec === 'dynamic') {\n return ogImageFragment(buildOgUrl({ title: fallbackTitle }), 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 ogImageFragment(buildOgUrl({ ...spec.render, title }), 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 /** 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 /** Path without locale prefix, e.g. \"/skills\". '' for home. */\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\nfunction buildAlternates(\n config: SiteMetaConfig,\n path: string,\n locale: string\n): Metadata['alternates'] {\n if (!config.locales?.length) {\n return { canonical: `${config.siteUrl}/${path}`.replace(/\\/+$/, '') || config.siteUrl }\n }\n const languages: Record<string, string> = {}\n for (const loc of config.locales) {\n languages[loc] = `${config.siteUrl}/${loc}${path}`\n }\n const xDefault =\n config.defaultLocale ?? (config.locales.includes('en') ? 'en' : config.locales[0])\n languages['x-default'] = `${config.siteUrl}/${xDefault}${path}`\n return { canonical: `${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 const ogUrl = config.locales?.length\n ? `${config.siteUrl}/${locale}${path}`\n : `${config.siteUrl}${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":[]}
|
|
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 /** Path without locale prefix, e.g. \"/skills\". '' for home. */\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\nfunction buildAlternates(\n config: SiteMetaConfig,\n path: string,\n locale: string\n): Metadata['alternates'] {\n if (!config.locales?.length) {\n return { canonical: `${config.siteUrl}/${path}`.replace(/\\/+$/, '') || config.siteUrl }\n }\n const languages: Record<string, string> = {}\n for (const loc of config.locales) {\n languages[loc] = `${config.siteUrl}/${loc}${path}`\n }\n const xDefault =\n config.defaultLocale ?? (config.locales.includes('en') ? 'en' : config.locales[0])\n languages['x-default'] = `${config.siteUrl}/${xDefault}${path}`\n return { canonical: `${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 const ogUrl = config.locales?.length\n ? `${config.siteUrl}/${locale}${path}`\n : `${config.siteUrl}${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":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/nextjs",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.440",
|
|
4
4
|
"description": "Next.js server utilities: sitemap, health, OG images, contact forms, navigation, config",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nextjs",
|
|
@@ -143,9 +143,9 @@
|
|
|
143
143
|
"ai-docs": "tsx src/ai/cli.ts"
|
|
144
144
|
},
|
|
145
145
|
"peerDependencies": {
|
|
146
|
-
"@djangocfg/i18n": "^2.1.
|
|
147
|
-
"@djangocfg/monitor": "^2.1.
|
|
148
|
-
"@djangocfg/ui-core": "^2.1.
|
|
146
|
+
"@djangocfg/i18n": "^2.1.440",
|
|
147
|
+
"@djangocfg/monitor": "^2.1.440",
|
|
148
|
+
"@djangocfg/ui-core": "^2.1.440",
|
|
149
149
|
"next": "^16.2.2"
|
|
150
150
|
},
|
|
151
151
|
"peerDependenciesMeta": {
|
|
@@ -167,11 +167,11 @@
|
|
|
167
167
|
"serwist": "^9.2.3"
|
|
168
168
|
},
|
|
169
169
|
"devDependencies": {
|
|
170
|
-
"@djangocfg/i18n": "^2.1.
|
|
171
|
-
"@djangocfg/monitor": "^2.1.
|
|
172
|
-
"@djangocfg/ui-core": "^2.1.
|
|
173
|
-
"@djangocfg/layouts": "^2.1.
|
|
174
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
170
|
+
"@djangocfg/i18n": "^2.1.440",
|
|
171
|
+
"@djangocfg/monitor": "^2.1.440",
|
|
172
|
+
"@djangocfg/ui-core": "^2.1.440",
|
|
173
|
+
"@djangocfg/layouts": "^2.1.440",
|
|
174
|
+
"@djangocfg/typescript-config": "^2.1.440",
|
|
175
175
|
"@types/node": "^25.2.3",
|
|
176
176
|
"@types/react": "^19.2.15",
|
|
177
177
|
"@types/react-dom": "^19.2.3",
|
package/src/og-image/README.md
CHANGED
|
@@ -52,6 +52,26 @@ way to express each case:
|
|
|
52
52
|
| `{ render: { preset, layout, … } }` | dynamic render with explicit params |
|
|
53
53
|
| `{ image, alt? }` | an exact image URL (content screenshot, cover) |
|
|
54
54
|
|
|
55
|
+
### No backend? Dynamic falls back to static
|
|
56
|
+
|
|
57
|
+
The `'dynamic'` and `{ render }` cases need a Django renderer. Tell the factory
|
|
58
|
+
where it is — or that there isn't one — via `ogBackend`:
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
// has a backend → dynamic OG renders against it
|
|
62
|
+
makeMetadataFactory({ ..., ogBackend: 'https://api.example.com' })
|
|
63
|
+
|
|
64
|
+
// frontend with NO Django backend → og:'dynamic' silently becomes the static
|
|
65
|
+
// brand image, so you never emit a broken /cfg/og/ URL
|
|
66
|
+
makeMetadataFactory({ ..., ogBackend: '' })
|
|
67
|
+
|
|
68
|
+
// omit → resolve from env (MEDIA_URL → API_URL → SITE_URL)
|
|
69
|
+
makeMetadataFactory({ ... })
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
So a static-only site can leave `og: 'dynamic'` on content pages without breaking
|
|
73
|
+
anything — set `ogBackend: ''` once and every dynamic spec degrades to static.
|
|
74
|
+
|
|
55
75
|
---
|
|
56
76
|
|
|
57
77
|
## How It Works
|
|
@@ -136,6 +156,7 @@ export const metadata = createMetadata({ title: 'About', path: '/about' })
|
|
|
136
156
|
| `description` | `string` | default description |
|
|
137
157
|
| `siteUrl` | `string` | absolute origin, e.g. `https://example.com` |
|
|
138
158
|
| `ogImage` | `string` | static brand OG image URL |
|
|
159
|
+
| `ogBackend?` | `string` | Django origin for the dynamic renderer (see below) |
|
|
139
160
|
| `icons?` | `Metadata['icons']` | favicon / apple-touch |
|
|
140
161
|
| `locales?` | `readonly string[]` | for hreflang; omit for single-locale |
|
|
141
162
|
| `defaultLocale?` | `string` | x-default locale (defaults `en` / first) |
|
|
@@ -16,6 +16,16 @@ export interface SiteMetaConfig {
|
|
|
16
16
|
siteUrl: string
|
|
17
17
|
/** Ready-made static brand OG image URL (absolute or root-relative). */
|
|
18
18
|
ogImage: string
|
|
19
|
+
/**
|
|
20
|
+
* Django origin that serves the dynamic OG renderer (`/cfg/og/`), e.g.
|
|
21
|
+
* "https://api.example.com". Required for `og: 'dynamic'` / `{ render }`.
|
|
22
|
+
*
|
|
23
|
+
* - set → dynamic OG images render against this origin
|
|
24
|
+
* - `''` → no backend: dynamic specs fall back to the static brand image
|
|
25
|
+
* (use this for a frontend with no Django, so no broken URLs)
|
|
26
|
+
* - omit → fall back to env vars (MEDIA_URL → API_URL → SITE_URL)
|
|
27
|
+
*/
|
|
28
|
+
ogBackend?: string
|
|
19
29
|
/** Optional favicon / apple-touch icons forwarded to Metadata.icons. */
|
|
20
30
|
icons?: Metadata['icons']
|
|
21
31
|
/** Locales for hreflang alternates. Omit for single-locale sites. */
|
|
@@ -102,7 +112,7 @@ export function createMetadata(config: SiteMetaConfig, page: PageMeta = {}): Met
|
|
|
102
112
|
? `${config.siteUrl}/${locale}${path}`
|
|
103
113
|
: `${config.siteUrl}${path}`
|
|
104
114
|
|
|
105
|
-
const ogImage = resolveOgImage(page.og ?? 'static', config.ogImage, title)
|
|
115
|
+
const ogImage = resolveOgImage(page.og ?? 'static', config.ogImage, title, config.ogBackend)
|
|
106
116
|
|
|
107
117
|
const metadata: Metadata = {
|
|
108
118
|
title,
|
package/src/og-image/metadata.ts
CHANGED
|
@@ -44,7 +44,12 @@ export function extractTitle(metadata: Metadata): string {
|
|
|
44
44
|
/**
|
|
45
45
|
* Resolves an `OgSpec` against a base Metadata into a concrete og/twitter image
|
|
46
46
|
* fragment. `staticUrl` is the site's brand image, used for the `'static'` case.
|
|
47
|
-
*
|
|
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.)
|
|
48
53
|
*
|
|
49
54
|
* This is the single decision point for "what is the OG image" — no other code
|
|
50
55
|
* branches on the spec.
|
|
@@ -52,20 +57,27 @@ export function extractTitle(metadata: Metadata): string {
|
|
|
52
57
|
export function resolveOgImage(
|
|
53
58
|
spec: OgSpec,
|
|
54
59
|
staticUrl: string,
|
|
55
|
-
fallbackTitle: string
|
|
60
|
+
fallbackTitle: string,
|
|
61
|
+
backend?: string
|
|
56
62
|
): Pick<Metadata, 'openGraph' | 'twitter'> {
|
|
63
|
+
const canRenderDynamic = backend === undefined || backend !== ''
|
|
64
|
+
|
|
57
65
|
if (spec === 'static') {
|
|
58
66
|
return ogImageFragment(staticUrl, fallbackTitle)
|
|
59
67
|
}
|
|
60
68
|
if (spec === 'dynamic') {
|
|
61
|
-
return
|
|
69
|
+
return canRenderDynamic
|
|
70
|
+
? ogImageFragment(buildOgUrl({ title: fallbackTitle }, backend), fallbackTitle)
|
|
71
|
+
: ogImageFragment(staticUrl, fallbackTitle)
|
|
62
72
|
}
|
|
63
73
|
if ('image' in spec) {
|
|
64
74
|
return ogImageFragment(spec.image, spec.alt ?? fallbackTitle)
|
|
65
75
|
}
|
|
66
76
|
// { render: {...} } — dynamic with explicit params; title defaults to page title.
|
|
67
77
|
const title = spec.render.title ?? fallbackTitle
|
|
68
|
-
return
|
|
78
|
+
return canRenderDynamic
|
|
79
|
+
? ogImageFragment(buildOgUrl({ ...spec.render, title }, backend), title)
|
|
80
|
+
: ogImageFragment(staticUrl, title)
|
|
69
81
|
}
|
|
70
82
|
|
|
71
83
|
/**
|
package/src/og-image/url.ts
CHANGED
|
@@ -50,9 +50,13 @@ function cleanParams(params: OGImageParams): Record<string, unknown> {
|
|
|
50
50
|
* django_ogimage endpoint: /cfg/og/<base64params>/
|
|
51
51
|
*
|
|
52
52
|
* The URL is suitable for use in og:image and twitter:image meta tags.
|
|
53
|
+
*
|
|
54
|
+
* @param base Optional explicit backend origin (e.g. "https://api.example.com").
|
|
55
|
+
* When omitted, it's resolved from env (MEDIA_URL → API_URL → SITE_URL → '').
|
|
56
|
+
* Pass the `ogBackend` from your site config to avoid relying on env.
|
|
53
57
|
*/
|
|
54
|
-
export function buildOgUrl(params: OGImageParams): string {
|
|
58
|
+
export function buildOgUrl(params: OGImageParams, base?: string): string {
|
|
55
59
|
const b64 = encodeBase64(JSON.stringify(cleanParams(params)))
|
|
56
|
-
const
|
|
57
|
-
return `${
|
|
60
|
+
const origin = (base ?? resolveOgPublicBase()).replace(/\/$/, '')
|
|
61
|
+
return `${origin}/cfg/og/${b64}/`
|
|
58
62
|
}
|