@djangocfg/nextjs 1.0.1
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/LICENSE +21 -0
- package/README.md +449 -0
- package/package.json +133 -0
- package/src/components/HomePage.tsx +73 -0
- package/src/components/index.ts +7 -0
- package/src/config/base-next-config.ts +262 -0
- package/src/config/index.ts +6 -0
- package/src/contact/index.ts +13 -0
- package/src/contact/route.ts +102 -0
- package/src/contact/submit.ts +80 -0
- package/src/errors/ErrorLayout.tsx +228 -0
- package/src/errors/errorConfig.ts +118 -0
- package/src/errors/index.ts +10 -0
- package/src/health/index.ts +7 -0
- package/src/health/route.ts +65 -0
- package/src/health/types.ts +19 -0
- package/src/index.ts +36 -0
- package/src/legal/LegalPage.tsx +85 -0
- package/src/legal/configs.ts +131 -0
- package/src/legal/index.ts +24 -0
- package/src/legal/pages.tsx +58 -0
- package/src/legal/types.ts +15 -0
- package/src/navigation/index.ts +9 -0
- package/src/navigation/types.ts +68 -0
- package/src/navigation/utils.ts +181 -0
- package/src/og-image/README.md +66 -0
- package/src/og-image/components/DefaultTemplate.tsx +369 -0
- package/src/og-image/components/index.ts +9 -0
- package/src/og-image/index.ts +27 -0
- package/src/og-image/route.tsx +253 -0
- package/src/og-image/types.ts +46 -0
- package/src/og-image/utils/fonts.ts +150 -0
- package/src/og-image/utils/index.ts +28 -0
- package/src/og-image/utils/metadata.ts +235 -0
- package/src/og-image/utils/url.ts +327 -0
- package/src/sitemap/generator.ts +64 -0
- package/src/sitemap/index.ts +8 -0
- package/src/sitemap/route.ts +74 -0
- package/src/sitemap/types.ts +20 -0
- package/src/types.ts +35 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OG Image Route Handler
|
|
3
|
+
*
|
|
4
|
+
* Factory function to create OG Image route handler for Next.js App Router
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* ```tsx
|
|
8
|
+
* // app/api/og/route.tsx
|
|
9
|
+
* import { createOgImageHandler } from '@djangocfg/nextjs/og-image';
|
|
10
|
+
* import { MyTemplate } from './templates';
|
|
11
|
+
*
|
|
12
|
+
* export const { GET, runtime } = createOgImageHandler({
|
|
13
|
+
* template: MyTemplate,
|
|
14
|
+
* defaultProps: {
|
|
15
|
+
* siteName: 'My Site',
|
|
16
|
+
* },
|
|
17
|
+
* fonts: [{ family: 'Manrope', weight: 700 }],
|
|
18
|
+
* });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { ImageResponse } from 'next/og';
|
|
23
|
+
import { NextRequest } from 'next/server';
|
|
24
|
+
import type { ReactElement } from 'react';
|
|
25
|
+
import { loadGoogleFonts } from './utils';
|
|
26
|
+
import { parseOgImageData } from './utils/url';
|
|
27
|
+
import { DefaultTemplate } from './components/DefaultTemplate';
|
|
28
|
+
import type { OgImageTemplateProps } from './types';
|
|
29
|
+
|
|
30
|
+
export interface OgImageHandlerConfig {
|
|
31
|
+
/** Custom template component (optional, defaults to DefaultTemplate) */
|
|
32
|
+
template?: (props: OgImageTemplateProps) => ReactElement;
|
|
33
|
+
/** Default props to merge with query params */
|
|
34
|
+
defaultProps?: Partial<OgImageTemplateProps>;
|
|
35
|
+
/** Google Fonts to load */
|
|
36
|
+
fonts?: Array<{ family: string; weight: 400 | 500 | 600 | 700 | 800 | 900 }>;
|
|
37
|
+
/** Image size */
|
|
38
|
+
size?: { width: number; height: number };
|
|
39
|
+
/** Enable debug mode */
|
|
40
|
+
debug?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Factory function to create OG Image route handler
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```tsx
|
|
48
|
+
* // app/api/og/route.tsx
|
|
49
|
+
* import { createOgImageHandler } from '@djangocfg/nextjs/og-image';
|
|
50
|
+
* import { MyTemplate } from '@/components/MyTemplate';
|
|
51
|
+
*
|
|
52
|
+
* export const { GET, runtime } = createOgImageHandler({
|
|
53
|
+
* template: MyTemplate,
|
|
54
|
+
* defaultProps: { siteName: 'My Site' },
|
|
55
|
+
* fonts: [{ family: 'Inter', weight: 700 }],
|
|
56
|
+
* });
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export function createOgImageHandler(config: OgImageHandlerConfig) {
|
|
60
|
+
const {
|
|
61
|
+
template: Template = DefaultTemplate,
|
|
62
|
+
defaultProps = {},
|
|
63
|
+
fonts: fontConfig = [],
|
|
64
|
+
size = { width: 1200, height: 630 },
|
|
65
|
+
debug = false,
|
|
66
|
+
} = config;
|
|
67
|
+
|
|
68
|
+
async function GET(req: NextRequest) {
|
|
69
|
+
let searchParams: URLSearchParams = new URLSearchParams();
|
|
70
|
+
|
|
71
|
+
// Try to get searchParams from multiple sources
|
|
72
|
+
if (req.nextUrl?.searchParams && req.nextUrl.searchParams.size > 0) {
|
|
73
|
+
searchParams = req.nextUrl.searchParams;
|
|
74
|
+
} else if (req.nextUrl?.search && req.nextUrl.search.length > 1) {
|
|
75
|
+
searchParams = new URLSearchParams(req.nextUrl.search);
|
|
76
|
+
} else {
|
|
77
|
+
try {
|
|
78
|
+
const url = new URL(req.url);
|
|
79
|
+
if (url.searchParams.size > 0) {
|
|
80
|
+
searchParams = url.searchParams;
|
|
81
|
+
}
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// Ignore
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (searchParams.size === 0 && req.url) {
|
|
87
|
+
const queryIndex = req.url.indexOf('?');
|
|
88
|
+
if (queryIndex !== -1) {
|
|
89
|
+
const queryString = req.url.substring(queryIndex + 1);
|
|
90
|
+
searchParams = new URLSearchParams(queryString);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (searchParams.size === 0) {
|
|
95
|
+
const customParams = req.headers.get('x-og-search-params');
|
|
96
|
+
if (customParams) {
|
|
97
|
+
searchParams = new URLSearchParams(customParams);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Initialize with defaults
|
|
103
|
+
let title = defaultProps.title || 'Untitled';
|
|
104
|
+
let subtitle = defaultProps.subtitle || '';
|
|
105
|
+
let description = defaultProps.description || subtitle;
|
|
106
|
+
|
|
107
|
+
// Support base64 data parameter (priority: base64 > query params > defaults)
|
|
108
|
+
const dataParam = searchParams.get('data');
|
|
109
|
+
|
|
110
|
+
if (dataParam) {
|
|
111
|
+
try {
|
|
112
|
+
const paramsObj: Record<string, string> = { data: dataParam };
|
|
113
|
+
for (const [key, value] of searchParams.entries()) {
|
|
114
|
+
if (key !== 'data') {
|
|
115
|
+
paramsObj[key] = value;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const decodedData = parseOgImageData(paramsObj);
|
|
119
|
+
|
|
120
|
+
// Base64 data takes precedence - apply all decoded values
|
|
121
|
+
if (decodedData.title && typeof decodedData.title === 'string' && decodedData.title.trim() !== '') {
|
|
122
|
+
title = decodedData.title.trim();
|
|
123
|
+
}
|
|
124
|
+
if (decodedData.subtitle && typeof decodedData.subtitle === 'string' && decodedData.subtitle.trim() !== '') {
|
|
125
|
+
subtitle = decodedData.subtitle.trim();
|
|
126
|
+
}
|
|
127
|
+
if (decodedData.description && typeof decodedData.description === 'string' && decodedData.description.trim() !== '') {
|
|
128
|
+
description = decodedData.description.trim();
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
// Silently fall back to defaults
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Fallback to query params if not set from base64
|
|
136
|
+
if (!title || title === 'Untitled') {
|
|
137
|
+
const titleParam = searchParams.get('title');
|
|
138
|
+
if (titleParam) {
|
|
139
|
+
title = titleParam;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!subtitle) {
|
|
143
|
+
const subtitleParam = searchParams.get('subtitle');
|
|
144
|
+
if (subtitleParam) {
|
|
145
|
+
subtitle = subtitleParam;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!description || description === subtitle) {
|
|
149
|
+
const descParam = searchParams.get('description');
|
|
150
|
+
if (descParam) {
|
|
151
|
+
description = descParam;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Load fonts if configured
|
|
156
|
+
let fonts: any[] = [];
|
|
157
|
+
if (fontConfig.length > 0) {
|
|
158
|
+
fonts = await loadGoogleFonts(fontConfig);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Merge props
|
|
162
|
+
const templateProps: OgImageTemplateProps = {
|
|
163
|
+
...defaultProps,
|
|
164
|
+
title,
|
|
165
|
+
subtitle,
|
|
166
|
+
description,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
return new ImageResponse(
|
|
171
|
+
<Template {...templateProps} />,
|
|
172
|
+
{
|
|
173
|
+
width: size.width,
|
|
174
|
+
height: size.height,
|
|
175
|
+
fonts,
|
|
176
|
+
debug: debug || process.env.NODE_ENV === 'development',
|
|
177
|
+
}
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
GET,
|
|
183
|
+
runtime: 'edge' as const,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Create OG Image route handler for dynamic route with path parameter
|
|
189
|
+
*
|
|
190
|
+
* This is a convenience wrapper for Next.js dynamic routes like `/api/og/[data]/route.tsx`.
|
|
191
|
+
* It extracts the `data` parameter from the path and passes it to the handler as a query parameter.
|
|
192
|
+
* Also handles static export mode automatically.
|
|
193
|
+
*
|
|
194
|
+
* @example
|
|
195
|
+
* ```tsx
|
|
196
|
+
* // app/api/og/[data]/route.tsx
|
|
197
|
+
* import { createOgImageDynamicRoute } from '@djangocfg/nextjs/og-image';
|
|
198
|
+
* import { OgImageTemplate } from '@/components/OgImageTemplate';
|
|
199
|
+
*
|
|
200
|
+
* export const runtime = 'nodejs';
|
|
201
|
+
* export const dynamic = 'force-static';
|
|
202
|
+
* export const revalidate = false;
|
|
203
|
+
*
|
|
204
|
+
* const handler = createOgImageDynamicRoute({
|
|
205
|
+
* template: OgImageTemplate,
|
|
206
|
+
* defaultProps: {
|
|
207
|
+
* siteName: 'My App',
|
|
208
|
+
* logo: '/logo.svg',
|
|
209
|
+
* },
|
|
210
|
+
* });
|
|
211
|
+
*
|
|
212
|
+
* export async function GET(
|
|
213
|
+
* request: NextRequest,
|
|
214
|
+
* { params }: { params: { data: string } }
|
|
215
|
+
) {
|
|
216
|
+
* return handler(request, params);
|
|
217
|
+
* }
|
|
218
|
+
* ```
|
|
219
|
+
*/
|
|
220
|
+
export function createOgImageDynamicRoute(config: OgImageHandlerConfig) {
|
|
221
|
+
const handler = createOgImageHandler(config);
|
|
222
|
+
const isStaticBuild = typeof process !== 'undefined' && process.env.NEXT_PUBLIC_STATIC_BUILD === 'true';
|
|
223
|
+
|
|
224
|
+
return async function GET(
|
|
225
|
+
request: NextRequest,
|
|
226
|
+
context: { params: Promise<{ data: string }> }
|
|
227
|
+
) {
|
|
228
|
+
// In static export mode, return a simple error response
|
|
229
|
+
if (isStaticBuild) {
|
|
230
|
+
return new Response('OG Image generation is not available in static export mode', {
|
|
231
|
+
status: 404,
|
|
232
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Await params (Next.js 15+ uses Promise)
|
|
237
|
+
const params = await context.params;
|
|
238
|
+
|
|
239
|
+
// Extract data from path parameter
|
|
240
|
+
const dataParam = params.data;
|
|
241
|
+
|
|
242
|
+
// Create a request with the data parameter as a query param for the handler
|
|
243
|
+
const url = new URL(request.url);
|
|
244
|
+
url.searchParams.set('data', dataParam);
|
|
245
|
+
|
|
246
|
+
const modifiedRequest = new NextRequest(url.toString(), {
|
|
247
|
+
method: request.method,
|
|
248
|
+
headers: request.headers,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return handler.GET(modifiedRequest);
|
|
252
|
+
};
|
|
253
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OG Image Types
|
|
3
|
+
*
|
|
4
|
+
* Shared types for OG image templates and handlers
|
|
5
|
+
* This file is safe to import in client components
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface OgImageTemplateProps {
|
|
9
|
+
// Content
|
|
10
|
+
title: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
subtitle?: string;
|
|
13
|
+
siteName?: string;
|
|
14
|
+
logo?: string;
|
|
15
|
+
|
|
16
|
+
// Visibility flags
|
|
17
|
+
showLogo?: boolean;
|
|
18
|
+
showSiteName?: boolean;
|
|
19
|
+
|
|
20
|
+
// Background customization
|
|
21
|
+
backgroundType?: 'gradient' | 'solid';
|
|
22
|
+
gradientStart?: string;
|
|
23
|
+
gradientEnd?: string;
|
|
24
|
+
backgroundColor?: string;
|
|
25
|
+
|
|
26
|
+
// Typography - Title
|
|
27
|
+
titleSize?: number;
|
|
28
|
+
titleWeight?: number;
|
|
29
|
+
titleColor?: string;
|
|
30
|
+
|
|
31
|
+
// Typography - Description
|
|
32
|
+
descriptionSize?: number;
|
|
33
|
+
descriptionColor?: string;
|
|
34
|
+
|
|
35
|
+
// Typography - Site Name
|
|
36
|
+
siteNameSize?: number;
|
|
37
|
+
siteNameColor?: string;
|
|
38
|
+
|
|
39
|
+
// Layout
|
|
40
|
+
padding?: number;
|
|
41
|
+
logoSize?: number;
|
|
42
|
+
|
|
43
|
+
// Dev mode (for debugging)
|
|
44
|
+
devMode?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Font Utilities for OG Image Generation
|
|
3
|
+
*
|
|
4
|
+
* Provides dynamic font loading from Google Fonts without requiring files in public/
|
|
5
|
+
* Based on Vercel's official @vercel/og documentation
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface FontConfig {
|
|
9
|
+
name: string;
|
|
10
|
+
weight?: 400 | 500 | 600 | 700 | 800 | 900;
|
|
11
|
+
style?: 'normal' | 'italic';
|
|
12
|
+
data: ArrayBuffer;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Load a Google Font dynamically
|
|
17
|
+
*
|
|
18
|
+
* @param font - Font family name (e.g., "Inter", "Roboto", "Manrope")
|
|
19
|
+
* @param text - Text to optimize font for (optional, reduces file size)
|
|
20
|
+
* @param weight - Font weight (default: 700)
|
|
21
|
+
* @returns ArrayBuffer of font data
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* const fontData = await loadGoogleFont('Manrope', 'Hello World', 700);
|
|
25
|
+
*/
|
|
26
|
+
export async function loadGoogleFont(
|
|
27
|
+
font: string,
|
|
28
|
+
text?: string,
|
|
29
|
+
weight: number = 700
|
|
30
|
+
): Promise<ArrayBuffer> {
|
|
31
|
+
// Construct Google Fonts API URL
|
|
32
|
+
let url = `https://fonts.googleapis.com/css2?family=${font}:wght@${weight}`;
|
|
33
|
+
|
|
34
|
+
// Add text parameter to optimize font subset (reduces size)
|
|
35
|
+
if (text) {
|
|
36
|
+
url += `&text=${encodeURIComponent(text)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
// Fetch CSS containing font URL
|
|
41
|
+
const css = await fetch(url, {
|
|
42
|
+
headers: {
|
|
43
|
+
// Required to get TTF format instead of WOFF2
|
|
44
|
+
'User-Agent':
|
|
45
|
+
'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; de-at) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1',
|
|
46
|
+
},
|
|
47
|
+
}).then((res) => res.text());
|
|
48
|
+
|
|
49
|
+
// Extract font URL from CSS
|
|
50
|
+
const resource = css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/);
|
|
51
|
+
|
|
52
|
+
if (!resource || !resource[1]) {
|
|
53
|
+
throw new Error(`Failed to parse font URL from CSS for font: ${font}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Fetch actual font file
|
|
57
|
+
const response = await fetch(resource[1]);
|
|
58
|
+
|
|
59
|
+
if (response.status !== 200) {
|
|
60
|
+
throw new Error(`Failed to fetch font data: HTTP ${response.status}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return await response.arrayBuffer();
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error(`Error loading Google Font "${font}":`, error);
|
|
66
|
+
throw new Error(`Failed to load font "${font}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Load multiple Google Fonts
|
|
72
|
+
*
|
|
73
|
+
* @param fonts - Array of font configurations to load
|
|
74
|
+
* @returns Array of FontConfig objects ready for ImageResponse
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* const fonts = await loadGoogleFonts([
|
|
78
|
+
* { family: 'Manrope', weight: 700 },
|
|
79
|
+
* { family: 'Inter', weight: 400 }
|
|
80
|
+
* ]);
|
|
81
|
+
*/
|
|
82
|
+
export async function loadGoogleFonts(
|
|
83
|
+
fonts: Array<{
|
|
84
|
+
family: string;
|
|
85
|
+
weight?: 400 | 500 | 600 | 700 | 800 | 900;
|
|
86
|
+
style?: 'normal' | 'italic';
|
|
87
|
+
text?: string;
|
|
88
|
+
}>
|
|
89
|
+
): Promise<FontConfig[]> {
|
|
90
|
+
const fontConfigs = await Promise.all(
|
|
91
|
+
fonts.map(async ({ family, weight = 700, style = 'normal', text }) => {
|
|
92
|
+
const data = await loadGoogleFont(family, text, weight);
|
|
93
|
+
return {
|
|
94
|
+
name: family,
|
|
95
|
+
weight,
|
|
96
|
+
style,
|
|
97
|
+
data,
|
|
98
|
+
};
|
|
99
|
+
})
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
return fontConfigs;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Create a font loader with caching
|
|
107
|
+
*
|
|
108
|
+
* Useful for reusing font data across multiple OG image requests
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* const fontLoader = createFontLoader();
|
|
112
|
+
* const font = await fontLoader.load('Manrope', 700);
|
|
113
|
+
*/
|
|
114
|
+
export function createFontLoader() {
|
|
115
|
+
const cache = new Map<string, Promise<ArrayBuffer>>();
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
/**
|
|
119
|
+
* Load a font with caching
|
|
120
|
+
*/
|
|
121
|
+
async load(
|
|
122
|
+
family: string,
|
|
123
|
+
weight: number = 700,
|
|
124
|
+
text?: string
|
|
125
|
+
): Promise<ArrayBuffer> {
|
|
126
|
+
const cacheKey = `${family}-${weight}-${text || 'all'}`;
|
|
127
|
+
|
|
128
|
+
if (!cache.has(cacheKey)) {
|
|
129
|
+
cache.set(cacheKey, loadGoogleFont(family, text, weight));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return cache.get(cacheKey)!;
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Clear the cache
|
|
137
|
+
*/
|
|
138
|
+
clear() {
|
|
139
|
+
cache.clear();
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Get cache size
|
|
144
|
+
*/
|
|
145
|
+
size() {
|
|
146
|
+
return cache.size;
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for OG Image Generation
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
loadGoogleFont,
|
|
7
|
+
loadGoogleFonts,
|
|
8
|
+
createFontLoader,
|
|
9
|
+
type FontConfig,
|
|
10
|
+
} from './fonts';
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
generateOgImageUrl,
|
|
14
|
+
getAbsoluteOgImageUrl,
|
|
15
|
+
createOgImageUrlBuilder,
|
|
16
|
+
parseOgImageUrl,
|
|
17
|
+
parseOgImageData,
|
|
18
|
+
encodeBase64,
|
|
19
|
+
decodeBase64,
|
|
20
|
+
type OgImageUrlParams,
|
|
21
|
+
} from './url';
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
generateOgImageMetadata,
|
|
25
|
+
createOgImageMetadataGenerator,
|
|
26
|
+
type OgImageMetadataOptions,
|
|
27
|
+
} from './metadata';
|
|
28
|
+
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata Utilities for OG Images
|
|
3
|
+
*
|
|
4
|
+
* Helpers to automatically add og:image to Next.js metadata
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Metadata } from 'next';
|
|
8
|
+
import { generateOgImageUrl, getAbsoluteOgImageUrl, type OgImageUrlParams } from './url';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Options for generating OG image metadata
|
|
12
|
+
*/
|
|
13
|
+
export interface OgImageMetadataOptions {
|
|
14
|
+
/** Base URL of the OG image API route (e.g., '/api/og') */
|
|
15
|
+
ogImageBaseUrl?: string;
|
|
16
|
+
/** Site URL for absolute URLs (e.g., 'https://example.com') */
|
|
17
|
+
siteUrl?: string;
|
|
18
|
+
/** Default parameters to merge with page-specific params */
|
|
19
|
+
defaultParams?: Partial<OgImageUrlParams>;
|
|
20
|
+
/** Whether to use base64 encoding (default: true) */
|
|
21
|
+
useBase64?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract title from metadata
|
|
26
|
+
*/
|
|
27
|
+
function extractTitle(metadata: Metadata): string {
|
|
28
|
+
if (typeof metadata.title === 'string') {
|
|
29
|
+
return metadata.title;
|
|
30
|
+
}
|
|
31
|
+
if (metadata.title) {
|
|
32
|
+
if ('default' in metadata.title) {
|
|
33
|
+
return metadata.title.default;
|
|
34
|
+
}
|
|
35
|
+
if ('absolute' in metadata.title) {
|
|
36
|
+
return metadata.title.absolute;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Extract description from metadata
|
|
44
|
+
*/
|
|
45
|
+
function extractDescription(metadata: Metadata): string {
|
|
46
|
+
if (typeof metadata.description === 'string') {
|
|
47
|
+
return metadata.description;
|
|
48
|
+
}
|
|
49
|
+
return '';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Generate Next.js metadata with OG image
|
|
54
|
+
*
|
|
55
|
+
* Automatically adds og:image, twitter:image, and other OG meta tags
|
|
56
|
+
* Automatically extracts title and description from metadata if not provided
|
|
57
|
+
*
|
|
58
|
+
* @param metadata - Base metadata object
|
|
59
|
+
* @param ogImageParams - Optional parameters for OG image generation (if not provided, extracted from metadata)
|
|
60
|
+
* @param options - Configuration options
|
|
61
|
+
* @returns Enhanced metadata with OG image
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```typescript
|
|
65
|
+
* // In page.tsx or layout.tsx
|
|
66
|
+
* import { generateOgImageMetadata } from '@djangocfg/nextjs/og-image';
|
|
67
|
+
* import { settings } from '@/core/settings';
|
|
68
|
+
*
|
|
69
|
+
* export const metadata = generateOgImageMetadata(
|
|
70
|
+
* {
|
|
71
|
+
* title: 'My Page',
|
|
72
|
+
* description: 'Page description',
|
|
73
|
+
* },
|
|
74
|
+
* undefined, // Will auto-extract from metadata
|
|
75
|
+
* {
|
|
76
|
+
* ogImageBaseUrl: '/api/og',
|
|
77
|
+
* siteUrl: settings.app.siteUrl,
|
|
78
|
+
* defaultParams: {
|
|
79
|
+
* siteName: settings.app.name,
|
|
80
|
+
* logo: settings.app.icons.logoVector,
|
|
81
|
+
* },
|
|
82
|
+
* }
|
|
83
|
+
* );
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
/**
|
|
87
|
+
* Get site URL automatically from environment
|
|
88
|
+
* Priority: NEXT_PUBLIC_SITE_URL > VERCEL_URL > fallback
|
|
89
|
+
*/
|
|
90
|
+
function getSiteUrl(): string {
|
|
91
|
+
// Try NEXT_PUBLIC_SITE_URL first (most reliable)
|
|
92
|
+
if (typeof process !== 'undefined' && process.env.NEXT_PUBLIC_SITE_URL) {
|
|
93
|
+
return process.env.NEXT_PUBLIC_SITE_URL;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Fallback to VERCEL_URL if available
|
|
97
|
+
if (typeof process !== 'undefined' && process.env.VERCEL_URL) {
|
|
98
|
+
return `https://${process.env.VERCEL_URL}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Development fallback
|
|
102
|
+
return 'http://localhost:3000';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function generateOgImageMetadata(
|
|
106
|
+
metadata: Metadata,
|
|
107
|
+
ogImageParams?: Partial<OgImageUrlParams>,
|
|
108
|
+
options: OgImageMetadataOptions = {}
|
|
109
|
+
): Metadata {
|
|
110
|
+
const {
|
|
111
|
+
ogImageBaseUrl = '/api/og',
|
|
112
|
+
siteUrl: providedSiteUrl,
|
|
113
|
+
defaultParams = {},
|
|
114
|
+
useBase64 = true,
|
|
115
|
+
} = options;
|
|
116
|
+
|
|
117
|
+
// Automatically determine siteUrl if not provided or is undefined
|
|
118
|
+
const siteUrl = providedSiteUrl && providedSiteUrl !== 'undefined'
|
|
119
|
+
? providedSiteUrl
|
|
120
|
+
: getSiteUrl();
|
|
121
|
+
|
|
122
|
+
// Auto-extract title and description from metadata if not provided
|
|
123
|
+
const extractedTitle = extractTitle(metadata);
|
|
124
|
+
const extractedDescription = extractDescription(metadata);
|
|
125
|
+
|
|
126
|
+
// Merge with provided params (provided params take precedence)
|
|
127
|
+
const finalOgImageParams: OgImageUrlParams = {
|
|
128
|
+
...defaultParams,
|
|
129
|
+
title: ogImageParams?.title || extractedTitle || defaultParams.title || '',
|
|
130
|
+
description: ogImageParams?.description || extractedDescription || defaultParams.description || '',
|
|
131
|
+
...ogImageParams,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Get alt text for image (title or siteName as fallback)
|
|
135
|
+
const imageAlt = finalOgImageParams.title || finalOgImageParams.siteName;
|
|
136
|
+
|
|
137
|
+
// Generate relative OG image URL
|
|
138
|
+
const relativeOgImageUrl = generateOgImageUrl(
|
|
139
|
+
ogImageBaseUrl,
|
|
140
|
+
finalOgImageParams,
|
|
141
|
+
useBase64
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// CRITICAL: Use absolute URL to ensure query params are preserved
|
|
145
|
+
// Next.js might strip query params from relative URLs in some cases
|
|
146
|
+
// Absolute URLs ensure the full URL with params is used
|
|
147
|
+
const ogImageUrl = siteUrl
|
|
148
|
+
? getAbsoluteOgImageUrl(relativeOgImageUrl, siteUrl)
|
|
149
|
+
: relativeOgImageUrl;
|
|
150
|
+
|
|
151
|
+
// Normalize existing images to arrays
|
|
152
|
+
const existingOgImages = metadata.openGraph?.images
|
|
153
|
+
? Array.isArray(metadata.openGraph.images)
|
|
154
|
+
? metadata.openGraph.images
|
|
155
|
+
: [metadata.openGraph.images]
|
|
156
|
+
: [];
|
|
157
|
+
|
|
158
|
+
const existingTwitterImages = metadata.twitter?.images
|
|
159
|
+
? Array.isArray(metadata.twitter.images)
|
|
160
|
+
? metadata.twitter.images
|
|
161
|
+
: [metadata.twitter.images]
|
|
162
|
+
: [];
|
|
163
|
+
|
|
164
|
+
// Merge with existing metadata
|
|
165
|
+
return {
|
|
166
|
+
...metadata,
|
|
167
|
+
openGraph: {
|
|
168
|
+
...metadata.openGraph,
|
|
169
|
+
images: [
|
|
170
|
+
...existingOgImages,
|
|
171
|
+
{
|
|
172
|
+
url: ogImageUrl,
|
|
173
|
+
width: 1200,
|
|
174
|
+
height: 630,
|
|
175
|
+
alt: imageAlt,
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
twitter: {
|
|
180
|
+
...metadata.twitter,
|
|
181
|
+
card: 'summary_large_image',
|
|
182
|
+
images: [
|
|
183
|
+
...existingTwitterImages,
|
|
184
|
+
{
|
|
185
|
+
url: ogImageUrl,
|
|
186
|
+
alt: imageAlt,
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Create OG image metadata generator with preset configuration
|
|
195
|
+
*
|
|
196
|
+
* Useful when you want to reuse the same configuration across multiple pages
|
|
197
|
+
*
|
|
198
|
+
* @param options - Configuration options
|
|
199
|
+
* @returns Metadata generator function
|
|
200
|
+
*
|
|
201
|
+
* @example
|
|
202
|
+
* ```typescript
|
|
203
|
+
* // In a shared file (e.g., lib/metadata.ts)
|
|
204
|
+
* import { createOgImageMetadataGenerator } from '@djangocfg/nextjs/og-image';
|
|
205
|
+
* import { settings } from '@/core/settings';
|
|
206
|
+
*
|
|
207
|
+
* export const generateMetadata = createOgImageMetadataGenerator({
|
|
208
|
+
* ogImageBaseUrl: '/api/og',
|
|
209
|
+
* siteUrl: settings.app.siteUrl,
|
|
210
|
+
* defaultParams: {
|
|
211
|
+
* siteName: settings.app.name,
|
|
212
|
+
* logo: settings.app.icons.logoVector,
|
|
213
|
+
* },
|
|
214
|
+
* });
|
|
215
|
+
*
|
|
216
|
+
* // In page.tsx
|
|
217
|
+
* import { generateMetadata } from '@/lib/metadata';
|
|
218
|
+
*
|
|
219
|
+
* export const metadata = generateMetadata({
|
|
220
|
+
* title: 'My Page',
|
|
221
|
+
* description: 'Description',
|
|
222
|
+
* });
|
|
223
|
+
* ```
|
|
224
|
+
*/
|
|
225
|
+
export function createOgImageMetadataGenerator(
|
|
226
|
+
options: OgImageMetadataOptions
|
|
227
|
+
) {
|
|
228
|
+
return (
|
|
229
|
+
metadata: Metadata,
|
|
230
|
+
ogImageParams?: Partial<OgImageUrlParams>
|
|
231
|
+
): Metadata => {
|
|
232
|
+
return generateOgImageMetadata(metadata, ogImageParams, options);
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|