Skip to content

Commit 71a9108

Browse files
committed
feat: Implement Open Graph image resolution logic
- Added a helper function to resolve Open Graph image URLs based on various conditions, including manual overrides and fallback mechanisms. - Integrated the new function into the metadata generation process to prioritize custom images, the first image from content, or a default image. - Introduced a new remark plugin to extract the first image from markdown content for use in Open Graph metadata. - Updated frontmatter type definitions to include an optional `og_image` field for custom Open Graph images.
1 parent 9a931fb commit 71a9108

File tree

4 files changed

+145
-5
lines changed

4 files changed

+145
-5
lines changed

app/[[...path]]/page.tsx

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,65 @@ function formatCanonicalTag(tag: string) {
194194
return tag;
195195
}
196196

197+
// Helper function to resolve OG image URLs
198+
async function resolveOgImageUrl(
199+
imageUrl: string | undefined,
200+
domain: string,
201+
pagePath: string[]
202+
): Promise<string | null> {
203+
if (!imageUrl) {
204+
return null;
205+
}
206+
207+
// Remove hash fragments (e.g., #600x400 from remark-image-size)
208+
const cleanUrl = imageUrl.split('#')[0];
209+
210+
// External URLs - return as is
211+
if (cleanUrl.startsWith('http://') || cleanUrl.startsWith('https://')) {
212+
return cleanUrl;
213+
}
214+
215+
// Absolute paths (public folder or already processed mdx-images)
216+
if (cleanUrl.startsWith('/')) {
217+
return `${domain}${cleanUrl}`;
218+
}
219+
220+
// For relative paths, try to find the processed image in /mdx-images/
221+
// Images get hashed during build, so we need to find the hashed version
222+
if (cleanUrl.startsWith('./')) {
223+
const {readdir} = await import('fs/promises');
224+
const path = await import('path');
225+
226+
// Extract the base filename without path
227+
const filename = path.basename(cleanUrl);
228+
const nameWithoutExt = filename.replace(path.extname(filename), '');
229+
230+
try {
231+
// Look for the hashed version in public/mdx-images/
232+
const mdxImagesDir = path.join(process.cwd(), 'public', 'mdx-images');
233+
const files = await readdir(mdxImagesDir);
234+
235+
// Find a file that starts with the same base name
236+
const hashedFile = files.find(f => f.startsWith(nameWithoutExt + '-'));
237+
238+
if (hashedFile) {
239+
return `${domain}/mdx-images/${hashedFile}`;
240+
}
241+
} catch (e) {
242+
// If we can't find the hashed version, fall through to default behavior
243+
}
244+
245+
// Fallback: resolve relative to page directory
246+
const relativePath = cleanUrl.slice(2);
247+
const pageDir = pagePath.join('/');
248+
return `${domain}/${pageDir}/${relativePath}`;
249+
}
250+
251+
// Default case: treat as relative to page
252+
const pageDir = pagePath.join('/');
253+
return `${domain}/${pageDir}/${cleanUrl}`;
254+
}
255+
197256
export async function generateMetadata(props: MetadataProps): Promise<Metadata> {
198257
const params = await props.params;
199258
const domain = isDeveloperDocs
@@ -208,12 +267,8 @@ export async function generateMetadata(props: MetadataProps): Promise<Metadata>
208267
let customCanonicalTag: string = '';
209268
let description =
210269
'Self-hosted and cloud-based application performance monitoring & error tracking that helps software teams see clearer, solve quicker, and learn continuously.';
211-
// show og image on the home page only
212-
const images =
213-
((await props.params).path ?? []).length === 0
214-
? [{url: `${previewDomain ?? domain}/og.png`, width: 1200, height: 630}]
215-
: [];
216270

271+
let ogImageUrl: string | null = null;
217272
let noindex: undefined | boolean = undefined;
218273

219274
const rootNode = await getDocsRootNode();
@@ -236,9 +291,46 @@ export async function generateMetadata(props: MetadataProps): Promise<Metadata>
236291
}
237292

238293
noindex = pageNode.frontmatter.noindex;
294+
295+
// Three-tier OG image priority:
296+
// 1. Manual override via og_image frontmatter
297+
if (pageNode.frontmatter.og_image) {
298+
ogImageUrl = await resolveOgImageUrl(
299+
pageNode.frontmatter.og_image,
300+
previewDomain ?? domain,
301+
params.path
302+
);
303+
}
304+
305+
// 2. First image from page content (if no manual override)
306+
if (!ogImageUrl) {
307+
try {
308+
const doc = await getFileBySlugWithCache(
309+
isDeveloperDocs
310+
? `develop-docs/${params.path.join('/')}`
311+
: `docs/${pageNode.path}`
312+
);
313+
if (doc.firstImage) {
314+
ogImageUrl = await resolveOgImageUrl(
315+
doc.firstImage,
316+
previewDomain ?? domain,
317+
params.path
318+
);
319+
}
320+
} catch (e) {
321+
// If we can't load the doc, just continue without the first image
322+
}
323+
}
239324
}
240325
}
241326

327+
// 3. Default fallback
328+
if (!ogImageUrl) {
329+
ogImageUrl = `${previewDomain ?? domain}/og.png`;
330+
}
331+
332+
const images = [{url: ogImageUrl, width: 1200, height: 630}];
333+
242334
const canonical = customCanonicalTag
243335
? domain + customCanonicalTag
244336
: params.path

src/mdx.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import rehypeSlug from './rehype-slug.js';
3333
import remarkCodeTabs from './remark-code-tabs';
3434
import remarkCodeTitles from './remark-code-title';
3535
import remarkComponentSpacing from './remark-component-spacing';
36+
import remarkExtractFirstImage from './remark-extract-first-image';
3637
import remarkExtractFrontmatter from './remark-extract-frontmatter';
3738
import remarkFormatCodeBlocks from './remark-format-code';
3839
import remarkImageResize from './remark-image-resize';
@@ -44,6 +45,7 @@ import {isNotNil} from './utils';
4445
import {isVersioned, VERSION_INDICATOR} from './versioning';
4546

4647
type SlugFile = {
48+
firstImage?: string;
4749
frontMatter: Platform & {slug: string};
4850
matter: Omit<matter.GrayMatterFile<string>, 'data'> & {
4951
data: Platform;
@@ -579,6 +581,7 @@ export async function getFileBySlug(slug: string): Promise<SlugFile> {
579581
);
580582

581583
const toc: TocNode[] = [];
584+
const firstImageRef: string[] = [];
582585

583586
// cwd is how mdx-bundler knows how to resolve relative paths
584587
const cwd = path.dirname(sourcePath);
@@ -598,6 +601,7 @@ export async function getFileBySlug(slug: string): Promise<SlugFile> {
598601
remarkDefList,
599602
remarkFormatCodeBlocks,
600603
[remarkImageSize, {sourceFolder: cwd, publicFolder: path.join(root, 'public')}],
604+
[remarkExtractFirstImage, {exportRef: firstImageRef}],
601605
remarkMdxImages,
602606
remarkImageResize,
603607
remarkCodeTitles,
@@ -694,6 +698,7 @@ export async function getFileBySlug(slug: string): Promise<SlugFile> {
694698
matter: result.matter,
695699
mdxSource: code,
696700
toc,
701+
firstImage: firstImageRef[0],
697702
frontMatter: {
698703
...mergedFrontmatter,
699704
slug,

src/remark-extract-first-image.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import {visit} from 'unist-util-visit';
2+
3+
/**
4+
* Remark plugin to extract the first image URL from markdown content.
5+
* This extracts the first image found in the document to be used as the
6+
* Open Graph image for social sharing.
7+
*
8+
* IMPORTANT: This plugin must run BEFORE remarkMdxImages so it can capture
9+
* standard markdown image nodes before they are transformed to JSX.
10+
*
11+
* The extracted image URL is exported via the exportRef option and can be
12+
* accessed after processing to set in frontmatter or metadata.
13+
*
14+
* @param options - Plugin options
15+
* @param options.exportRef - Array to store the extracted first image URL
16+
*/
17+
export default function remarkExtractFirstImage(options: {exportRef: string[]}) {
18+
return (tree: any) => {
19+
let firstImage: string | null = null;
20+
21+
// Visit standard markdown image nodes
22+
// This runs before remarkMdxImages transforms them to JSX
23+
visit(tree, 'image', (node: any) => {
24+
if (!firstImage && node.url) {
25+
firstImage = node.url;
26+
return false; // Stop visiting once we find the first image
27+
}
28+
});
29+
30+
// Export the first image URL if found
31+
if (firstImage) {
32+
options.exportRef.push(firstImage);
33+
}
34+
};
35+
}

src/types/frontmatter.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ export interface FrontMatter {
5656
*/
5757
notoc?: boolean;
5858

59+
/**
60+
* Custom Open Graph image for social sharing.
61+
* Can be a relative path (e.g., './img/my-image.png'), absolute path (e.g., '/images/og.png'),
62+
* or external URL. If not specified, the first image in the page content will be used,
63+
* or falls back to the default OG image.
64+
*/
65+
og_image?: string;
66+
5967
/**
6068
* The previous page in the bottom pagination navigation.
6169
*/

0 commit comments

Comments
 (0)