@djangocfg/nextjs 2.1.437 → 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.
@@ -0,0 +1,146 @@
1
+ import type { Metadata } from 'next'
2
+ import type { OgSpec } from './types'
3
+ import { resolveOgImage } from './metadata'
4
+
5
+ /**
6
+ * Site-wide identity the metadata factory needs. Config-agnostic on purpose —
7
+ * the package doesn't know about any app's `settings` object, so the consumer
8
+ * passes these once (typically derived from their own settings module).
9
+ */
10
+ export interface SiteMetaConfig {
11
+ /** Brand / app name, e.g. "CMDOP". Used in titles, siteName, alt text. */
12
+ name: string
13
+ /** Default description used when a page doesn't supply its own. */
14
+ description: string
15
+ /** Absolute site origin, e.g. "https://cmdop.com". Used for canonical/og url. */
16
+ siteUrl: string
17
+ /** Ready-made static brand OG image URL (absolute or root-relative). */
18
+ ogImage: string
19
+ /** Optional favicon / apple-touch icons forwarded to Metadata.icons. */
20
+ icons?: Metadata['icons']
21
+ /** Locales for hreflang alternates. Omit for single-locale sites. */
22
+ locales?: readonly string[]
23
+ /** Locale used for x-default hreflang (defaults to "en" or first locale). */
24
+ defaultLocale?: string
25
+ }
26
+
27
+ /**
28
+ * Per-page inputs. Only `title` is really worth setting per page; everything
29
+ * else falls back to the site config.
30
+ */
31
+ export interface PageMeta {
32
+ /** Page title (without the brand suffix). Falls back to the site name. */
33
+ title?: string
34
+ /** Page description; falls back to the site description. */
35
+ description?: string
36
+ keywords?: string[]
37
+ /** Current locale, used for canonical + openGraph.locale + alternates. */
38
+ locale?: string
39
+ /** Path without locale prefix, e.g. "/skills". '' for home. */
40
+ path?: string
41
+ /**
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)
47
+ */
48
+ og?: OgSpec
49
+ /** Escape hatch: extra Metadata fields merged last (robots, authors, etc.). */
50
+ extra?: Metadata
51
+ }
52
+
53
+ function buildAlternates(
54
+ config: SiteMetaConfig,
55
+ path: string,
56
+ locale: string
57
+ ): Metadata['alternates'] {
58
+ if (!config.locales?.length) {
59
+ return { canonical: `${config.siteUrl}/${path}`.replace(/\/+$/, '') || config.siteUrl }
60
+ }
61
+ const languages: Record<string, string> = {}
62
+ for (const loc of config.locales) {
63
+ languages[loc] = `${config.siteUrl}/${loc}${path}`
64
+ }
65
+ const xDefault =
66
+ config.defaultLocale ?? (config.locales.includes('en') ? 'en' : config.locales[0])
67
+ languages['x-default'] = `${config.siteUrl}/${xDefault}${path}`
68
+ return { canonical: `${config.siteUrl}/${locale}${path}`, languages }
69
+ }
70
+
71
+ /**
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.
75
+ *
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.
78
+ *
79
+ * // app/_core/metadata.ts
80
+ * export const createMetadata = makeMetadataFactory({
81
+ * name: settings.app.name,
82
+ * description: settings.app.description,
83
+ * siteUrl: settings.app.siteUrl,
84
+ * ogImage: settings.app.media.ogimage,
85
+ * icons: { icon: settings.app.media.favicon },
86
+ * locales: LOCALES,
87
+ * })
88
+ *
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 } })
95
+ */
96
+ export function createMetadata(config: SiteMetaConfig, page: PageMeta = {}): Metadata {
97
+ const title = page.title ?? config.name
98
+ const description = page.description ?? config.description
99
+ const locale = page.locale ?? config.defaultLocale ?? 'en'
100
+ const path = page.path ?? ''
101
+ const ogUrl = config.locales?.length
102
+ ? `${config.siteUrl}/${locale}${path}`
103
+ : `${config.siteUrl}${path}`
104
+
105
+ const ogImage = resolveOgImage(page.og ?? 'static', config.ogImage, title)
106
+
107
+ const metadata: Metadata = {
108
+ title,
109
+ description,
110
+ keywords: page.keywords,
111
+ alternates: buildAlternates(config, path, locale),
112
+ openGraph: {
113
+ type: 'website',
114
+ locale,
115
+ url: ogUrl,
116
+ siteName: config.name,
117
+ title,
118
+ description,
119
+ ...ogImage.openGraph,
120
+ },
121
+ twitter: {
122
+ title,
123
+ description,
124
+ ...ogImage.twitter,
125
+ },
126
+ icons: config.icons,
127
+ }
128
+
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 },
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Curries `createMetadata` with a fixed site config so pages call a zero-config
142
+ * factory. This is the single recommended entry point for app metadata.
143
+ */
144
+ export function makeMetadataFactory(config: SiteMetaConfig) {
145
+ return (page: PageMeta = {}): Metadata => createMetadata(config, page)
146
+ }
@@ -1,63 +1,38 @@
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
- * Merges og:image metadata into an existing Metadata object.
28
- *
29
- * Usage:
30
- * return withOgImage(
31
- * { title: 'Page', description: '...' },
32
- * { preset: 'DARK_BLUE', layout: 'HERO' }
33
- * )
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.
34
29
  */
35
- export function withOgImage(
36
- metadata: Metadata,
37
- params: Omit<OGImageParams, 'title'> & { title?: string }
38
- ): Metadata {
39
- // Auto-extract title from metadata; params.title overrides if explicitly provided
40
- const resolvedParams: OGImageParams = {
41
- title: extractTitle(metadata),
42
- ...params,
43
- }
44
-
45
- const ogMeta = createOgMetadata(resolvedParams)
46
-
47
- return {
48
- ...metadata,
49
- openGraph: {
50
- ...metadata.openGraph,
51
- ...ogMeta.openGraph,
52
- },
53
- twitter: {
54
- ...metadata.twitter,
55
- ...ogMeta.twitter,
56
- },
57
- }
30
+ export function createOgMetadata(params: OGImageParams): Metadata {
31
+ return ogImageFragment(buildOgUrl(params), params.title)
58
32
  }
59
33
 
60
- function extractTitle(metadata: Metadata): string {
34
+ /** Extract a plain title string from Next.js's title shapes. */
35
+ export function extractTitle(metadata: Metadata): string {
61
36
  const t = metadata.title
62
37
  if (!t) return ''
63
38
  if (typeof t === 'string') return t
@@ -65,3 +40,49 @@ function extractTitle(metadata: Metadata): string {
65
40
  if (typeof t === 'object' && 'default' in t) return (t as { default: string }).default
66
41
  return ''
67
42
  }
43
+
44
+ /**
45
+ * Resolves an `OgSpec` against a base Metadata into a concrete og/twitter image
46
+ * fragment. `staticUrl` is the site's brand image, used for the `'static'` case.
47
+ * Returns `null` only if there's genuinely nothing to set.
48
+ *
49
+ * This is the single decision point for "what is the OG image" — no other code
50
+ * branches on the spec.
51
+ */
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)
59
+ }
60
+ if (spec === 'dynamic') {
61
+ return ogImageFragment(buildOgUrl({ title: fallbackTitle }), fallbackTitle)
62
+ }
63
+ if ('image' in spec) {
64
+ return ogImageFragment(spec.image, spec.alt ?? fallbackTitle)
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
+ }
70
+
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 })
83
+ return {
84
+ ...metadata,
85
+ openGraph: { ...metadata.openGraph, ...frag.openGraph },
86
+ twitter: { ...metadata.twitter, ...frag.twitter },
87
+ }
88
+ }
@@ -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