@djangocfg/nextjs 2.1.438 → 2.1.439

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,11 @@
1
- export type { OGImageParams, OGPreset, OGLayout } from './types'
2
- export { buildOgUrl } from './url'
3
- export { createOgMetadata, withOgImage } from './metadata'
4
- export type { WithOgImageOptions } from './metadata'
1
+ // ── Recommended API ──────────────────────────────────────────────────────────
2
+ // Bind the factory once per app, then call it from each page.
5
3
  export { createMetadata, makeMetadataFactory } from './metadata-factory'
6
4
  export type { SiteMetaConfig, PageMeta } from './metadata-factory'
5
+
6
+ // ── Types ────────────────────────────────────────────────────────────────────
7
+ export type { OGImageParams, OGPreset, OGLayout, OgSpec } from './types'
8
+
9
+ // ── Low-level primitives (escape hatches) ────────────────────────────────────
10
+ export { buildOgUrl } from './url'
11
+ export { createOgMetadata, withOgImage, resolveOgImage, extractTitle } from './metadata'
@@ -1,6 +1,6 @@
1
1
  import type { Metadata } from 'next'
2
- import type { OGImageParams } from './types'
3
- import { withOgImage, type WithOgImageOptions } from './metadata'
2
+ import type { OgSpec } from './types'
3
+ import { resolveOgImage } from './metadata'
4
4
 
5
5
  /**
6
6
  * Site-wide identity the metadata factory needs. Config-agnostic on purpose —
@@ -18,32 +18,36 @@ export interface SiteMetaConfig {
18
18
  ogImage: string
19
19
  /** Optional favicon / apple-touch icons forwarded to Metadata.icons. */
20
20
  icons?: Metadata['icons']
21
- /** Locales for hreflang alternates (first is treated as x-default if no enDefault). */
21
+ /** Locales for hreflang alternates. Omit for single-locale sites. */
22
22
  locales?: readonly string[]
23
23
  /** Locale used for x-default hreflang (defaults to "en" or first locale). */
24
24
  defaultLocale?: string
25
25
  }
26
26
 
27
- /** Per-page inputs. Everything is optional except what makes the page unique. */
27
+ /**
28
+ * Per-page inputs. Only `title` is really worth setting per page; everything
29
+ * else falls back to the site config.
30
+ */
28
31
  export interface PageMeta {
29
- /** Page title (without the brand suffix the template adds it). */
32
+ /** Page title (without the brand suffix). Falls back to the site name. */
30
33
  title?: string
31
34
  /** Page description; falls back to the site description. */
32
35
  description?: string
33
36
  keywords?: string[]
34
37
  /** Current locale, used for canonical + openGraph.locale + alternates. */
35
38
  locale?: string
36
- /** Path without locale prefix, e.g. "/skills". '' for home. Drives alternates. */
39
+ /** Path without locale prefix, e.g. "/skills". '' for home. */
37
40
  path?: string
38
41
  /**
39
- * OG image control, forwarded to `withOgImage`:
40
- * - omitted → static brand image (`config.ogImage`) — the safe default
41
- * - `true` → dynamic render from the page title
42
- * - OGImageParams → dynamic render with explicit preset/layout/colors
43
- * - `false` no image (inherit whatever the layout set)
44
- * - string → explicit image URL
42
+ * What the OG image should be. Defaults to `'static'` (the brand image).
43
+ * - `'static'` brand image from config (default)
44
+ * - `'dynamic'` render the page title onto the card
45
+ * - `{ render: {...} }` render with explicit preset/layout/colors
46
+ * - `{ image, alt? }` an exact image URL (e.g. a content screenshot)
45
47
  */
46
- og?: boolean | string | Omit<OGImageParams, 'title'>
48
+ og?: OgSpec
49
+ /** Escape hatch: extra Metadata fields merged last (robots, authors, etc.). */
50
+ extra?: Metadata
47
51
  }
48
52
 
49
53
  function buildAlternates(
@@ -52,26 +56,25 @@ function buildAlternates(
52
56
  locale: string
53
57
  ): Metadata['alternates'] {
54
58
  if (!config.locales?.length) {
55
- return { canonical: `${config.siteUrl}/${locale}${path}` }
59
+ return { canonical: `${config.siteUrl}/${path}`.replace(/\/+$/, '') || config.siteUrl }
56
60
  }
57
61
  const languages: Record<string, string> = {}
58
62
  for (const loc of config.locales) {
59
63
  languages[loc] = `${config.siteUrl}/${loc}${path}`
60
64
  }
61
- const xDefault = config.defaultLocale ?? (config.locales.includes('en') ? 'en' : config.locales[0])
65
+ const xDefault =
66
+ config.defaultLocale ?? (config.locales.includes('en') ? 'en' : config.locales[0])
62
67
  languages['x-default'] = `${config.siteUrl}/${xDefault}${path}`
63
68
  return { canonical: `${config.siteUrl}/${locale}${path}`, languages }
64
69
  }
65
70
 
66
71
  /**
67
- * Builds a complete Next.js `Metadata` object from a one-time site config plus
68
- * per-page inputs — title-template, description, openGraph, twitter, canonical
69
- * + hreflang alternates, and icons — so pages only declare what's unique.
70
- *
71
- * OG image defaults to the static brand image (`config.ogImage`); opt into the
72
- * dynamic renderer per page with `og: true` / `og: { preset: 'DARK_BLUE' }`.
72
+ * Builds a complete Next.js `Metadata` from a one-time site config plus per-page
73
+ * inputs — title-template, description, openGraph, twitter, canonical + hreflang,
74
+ * icons, and the OG image — so pages only declare what's unique.
73
75
  *
74
- * Bind the config once in your app, then call the bound fn from each page:
76
+ * The OG image is chosen by the single `og` field (defaults to the static brand
77
+ * image). There is exactly one way to do each thing.
75
78
  *
76
79
  * // app/_core/metadata.ts
77
80
  * export const createMetadata = makeMetadataFactory({
@@ -83,16 +86,25 @@ function buildAlternates(
83
86
  * locales: LOCALES,
84
87
  * })
85
88
  *
86
- * // any page.tsx
87
- * export const metadata = createMetadata({ title: 'Skills', path: '/skills' })
89
+ * // landing page — static brand image (default)
90
+ * export const metadata = createMetadata({ title: 'Pricing', path: '/pricing' })
91
+ * // content page — title rendered onto the card
92
+ * export const metadata = createMetadata({ title: post.title, path, og: 'dynamic' })
93
+ * // content page with its own image
94
+ * export const metadata = createMetadata({ title: p.name, og: { image: p.image } })
88
95
  */
89
96
  export function createMetadata(config: SiteMetaConfig, page: PageMeta = {}): Metadata {
90
97
  const title = page.title ?? config.name
91
98
  const description = page.description ?? config.description
92
99
  const locale = page.locale ?? config.defaultLocale ?? 'en'
93
100
  const path = page.path ?? ''
101
+ const ogUrl = config.locales?.length
102
+ ? `${config.siteUrl}/${locale}${path}`
103
+ : `${config.siteUrl}${path}`
94
104
 
95
- const base: Metadata = {
105
+ const ogImage = resolveOgImage(page.og ?? 'static', config.ogImage, title)
106
+
107
+ const metadata: Metadata = {
96
108
  title,
97
109
  description,
98
110
  keywords: page.keywords,
@@ -100,41 +112,34 @@ export function createMetadata(config: SiteMetaConfig, page: PageMeta = {}): Met
100
112
  openGraph: {
101
113
  type: 'website',
102
114
  locale,
103
- url: `${config.siteUrl}/${locale}${path}`,
115
+ url: ogUrl,
104
116
  siteName: config.name,
105
117
  title,
106
118
  description,
119
+ ...ogImage.openGraph,
107
120
  },
108
121
  twitter: {
109
- card: 'summary_large_image',
110
122
  title,
111
123
  description,
124
+ ...ogImage.twitter,
112
125
  },
113
126
  icons: config.icons,
114
127
  }
115
128
 
116
- // Resolve the `og` shorthand into withOgImage options.
117
- let ogOptions: WithOgImageOptions
118
- if (page.og === undefined || page.og === false) {
119
- // Default & explicit-off both map to the static brand image. (false on the
120
- // factory means "don't render dynamic" — but we still want a brand image,
121
- // so we point at the static one. Use og:false at the page only if you've
122
- // set images elsewhere; here static is the friendly default.)
123
- ogOptions = { image: config.ogImage }
124
- } else if (page.og === true) {
125
- ogOptions = {} // dynamic, title auto-extracted
126
- } else if (typeof page.og === 'string') {
127
- ogOptions = { image: page.og }
128
- } else {
129
- ogOptions = page.og // OGImageParams → dynamic with overrides
129
+ if (!page.extra) return metadata
130
+ // Shallow-merge the escape hatch, but deep-merge the nested og/twitter so the
131
+ // image we just set isn't clobbered by a partial `extra.openGraph`.
132
+ return {
133
+ ...metadata,
134
+ ...page.extra,
135
+ openGraph: { ...metadata.openGraph, ...page.extra.openGraph },
136
+ twitter: { ...metadata.twitter, ...page.extra.twitter },
130
137
  }
131
-
132
- return withOgImage(base, ogOptions)
133
138
  }
134
139
 
135
140
  /**
136
141
  * Curries `createMetadata` with a fixed site config so pages call a zero-config
137
- * factory. See the docstring on `createMetadata` for the usage pattern.
142
+ * factory. This is the single recommended entry point for app metadata.
138
143
  */
139
144
  export function makeMetadataFactory(config: SiteMetaConfig) {
140
145
  return (page: PageMeta = {}): Metadata => createMetadata(config, page)
@@ -1,109 +1,88 @@
1
1
  import type { Metadata } from 'next'
2
- import type { OGImageParams } from './types'
2
+ import type { OGImageParams, OgSpec } from './types'
3
3
  import { buildOgUrl } from './url'
4
4
 
5
+ const OG_WIDTH = 1200
6
+ const OG_HEIGHT = 630
7
+
8
+ /** A Next.js OG image entry. */
9
+ function imageEntry(url: string, alt: string) {
10
+ return { url, width: OG_WIDTH, height: OG_HEIGHT, alt }
11
+ }
12
+
5
13
  /**
6
- * Creates Next.js Metadata fragment with og:image and twitter:image
7
- * pointing to Django's django_ogimage renderer.
8
- *
9
- * Usage in generateMetadata():
10
- * return createOgMetadata({ title: 'Page', preset: 'DARK_BLUE' })
14
+ * Builds the og/twitter image fragment for a given image URL — the one place
15
+ * that knows the shape Next.js expects. Both static URLs and the dynamic
16
+ * renderer funnel through here, so there's a single source of truth.
11
17
  */
12
- export function createOgMetadata(params: OGImageParams): Metadata {
13
- const url = buildOgUrl(params)
14
- const image = { url, width: 1200, height: 630, alt: params.title }
18
+ function ogImageFragment(url: string, alt: string): Pick<Metadata, 'openGraph' | 'twitter'> {
15
19
  return {
16
- openGraph: {
17
- images: [image],
18
- },
19
- twitter: {
20
- card: 'summary_large_image',
21
- images: [url],
22
- },
20
+ openGraph: { images: [imageEntry(url, alt)] },
21
+ twitter: { card: 'summary_large_image', images: [url] },
23
22
  }
24
23
  }
25
24
 
26
25
  /**
27
- * Options for `withOgImage`. Extends the dynamic-renderer params with an
28
- * `image` opt-out so a page can keep its own (static) OG image instead of the
29
- * dynamically-rendered one.
26
+ * Metadata fragment whose OG image is rendered by Django's django_ogimage
27
+ * endpoint (/cfg/og/). Low-level most callers use `createMetadata`'s `og`
28
+ * field instead.
30
29
  */
31
- export type WithOgImageOptions = Omit<OGImageParams, 'title'> & {
32
- title?: string
33
- /**
34
- * Controls the OG image source:
35
- * - omitted / `true` dynamic render via Django's /cfg/og/ (default)
36
- * - `false` → don't touch images; the page/layout's own og:image
37
- * is preserved (use for a ready-made static brand image)
38
- * - `string` → use this exact URL as a static og/twitter image
39
- */
40
- image?: boolean | string
30
+ export function createOgMetadata(params: OGImageParams): Metadata {
31
+ return ogImageFragment(buildOgUrl(params), params.title)
32
+ }
33
+
34
+ /** Extract a plain title string from Next.js's title shapes. */
35
+ export function extractTitle(metadata: Metadata): string {
36
+ const t = metadata.title
37
+ if (!t) return ''
38
+ if (typeof t === 'string') return t
39
+ if (typeof t === 'object' && 'absolute' in t) return (t as { absolute: string }).absolute
40
+ if (typeof t === 'object' && 'default' in t) return (t as { default: string }).default
41
+ return ''
41
42
  }
42
43
 
43
44
  /**
44
- * Merges og:image metadata into an existing Metadata object.
45
+ * Resolves an `OgSpec` against a base Metadata into a concrete og/twitter image
46
+ * fragment. `staticUrl` is the site's brand image, used for the `'static'` case.
47
+ * Returns `null` only if there's genuinely nothing to set.
45
48
  *
46
- * Usage:
47
- * // dynamic (default)
48
- * return withOgImage({ title: 'Page', description: '...' }, { preset: 'DARK_BLUE' })
49
- * // keep the page's own static image
50
- * return withOgImage(meta, { preset: 'DARK_BLUE', image: false })
51
- * // explicit static image
52
- * return withOgImage(meta, { image: '/static/ogimage.png' })
49
+ * This is the single decision point for "what is the OG image" — no other code
50
+ * branches on the spec.
53
51
  */
54
- export function withOgImage(
55
- metadata: Metadata,
56
- params: WithOgImageOptions = {}
57
- ): Metadata {
58
- const { image, ...ogParams } = params
59
-
60
- // Opt-out: leave the incoming metadata's images untouched.
61
- if (image === false) {
62
- return metadata
52
+ export function resolveOgImage(
53
+ spec: OgSpec,
54
+ staticUrl: string,
55
+ fallbackTitle: string
56
+ ): Pick<Metadata, 'openGraph' | 'twitter'> {
57
+ if (spec === 'static') {
58
+ return ogImageFragment(staticUrl, fallbackTitle)
63
59
  }
64
-
65
- // Explicit static URL: set og/twitter images without hitting the renderer.
66
- if (typeof image === 'string') {
67
- return {
68
- ...metadata,
69
- openGraph: {
70
- ...metadata.openGraph,
71
- images: [{ url: image, width: 1200, height: 630, alt: extractTitle(metadata) }],
72
- },
73
- twitter: {
74
- card: 'summary_large_image',
75
- ...metadata.twitter,
76
- images: [image],
77
- },
78
- }
60
+ if (spec === 'dynamic') {
61
+ return ogImageFragment(buildOgUrl({ title: fallbackTitle }), fallbackTitle)
79
62
  }
80
-
81
- // Default: dynamic render. Auto-extract title; params.title overrides.
82
- const resolvedParams: OGImageParams = {
83
- title: extractTitle(metadata),
84
- ...ogParams,
63
+ if ('image' in spec) {
64
+ return ogImageFragment(spec.image, spec.alt ?? fallbackTitle)
85
65
  }
66
+ // { render: {...} } — dynamic with explicit params; title defaults to page title.
67
+ const title = spec.render.title ?? fallbackTitle
68
+ return ogImageFragment(buildOgUrl({ ...spec.render, title }), title)
69
+ }
86
70
 
87
- const ogMeta = createOgMetadata(resolvedParams)
88
-
71
+ /**
72
+ * Merges OG image metadata (dynamic render) into existing Metadata. Kept for
73
+ * low-level/manual use; `createMetadata` is the recommended entry point.
74
+ *
75
+ * return withOgImage({ title: 'Page' }, { preset: 'DARK_BLUE' })
76
+ */
77
+ export function withOgImage(
78
+ metadata: Metadata,
79
+ params: Omit<OGImageParams, 'title'> & { title?: string } = {}
80
+ ): Metadata {
81
+ const title = params.title ?? extractTitle(metadata)
82
+ const frag = createOgMetadata({ ...params, title })
89
83
  return {
90
84
  ...metadata,
91
- openGraph: {
92
- ...metadata.openGraph,
93
- ...ogMeta.openGraph,
94
- },
95
- twitter: {
96
- ...metadata.twitter,
97
- ...ogMeta.twitter,
98
- },
85
+ openGraph: { ...metadata.openGraph, ...frag.openGraph },
86
+ twitter: { ...metadata.twitter, ...frag.twitter },
99
87
  }
100
88
  }
101
-
102
- function extractTitle(metadata: Metadata): string {
103
- const t = metadata.title
104
- if (!t) return ''
105
- if (typeof t === 'string') return t
106
- if (typeof t === 'object' && 'absolute' in t) return (t as { absolute: string }).absolute
107
- if (typeof t === 'object' && 'default' in t) return (t as { default: string }).default
108
- return ''
109
- }
@@ -11,6 +11,21 @@ export type OGPreset =
11
11
 
12
12
  export type OGLayout = 'DEFAULT' | 'HERO' | 'ARTICLE' | 'MINIMAL'
13
13
 
14
+ /**
15
+ * Per-page OG image intent — the single axis that decides what the OG image is.
16
+ * A discriminated union so every case is explicit and type-checked:
17
+ *
18
+ * 'static' brand image from site config (the default)
19
+ * 'dynamic' render the page title onto the OG card
20
+ * { render: {...} } render with explicit preset/layout/colors
21
+ * { image, alt? } an exact image URL (e.g. a content screenshot)
22
+ */
23
+ export type OgSpec =
24
+ | 'static'
25
+ | 'dynamic'
26
+ | { render: Omit<OGImageParams, 'title'> & { title?: string } }
27
+ | { image: string; alt?: string }
28
+
14
29
  /** Mirrors Django OGImageParams exactly */
15
30
  export interface OGImageParams {
16
31
  title: string