UI: Support built-in OG Image generation

This commit is contained in:
fuma-nama
2024-08-29 12:53:54 +08:00
parent c0d1fafe30
commit 26f53608f1
9 changed files with 285 additions and 140 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-ui': minor
---
Support built-in OG Image generation
+2 -7
View File
@@ -8,6 +8,7 @@ import {
} from 'fumadocs-ui/page';
import { notFound } from 'next/navigation';
import { Fragment } from 'react';
import { getImageMeta } from 'fumadocs-ui/og';
import { utils } from '@/utils/source';
import { createMetadata } from '@/utils/metadata';
import Preview from '@/components/preview';
@@ -71,13 +72,7 @@ export function generateMetadata({ params }: { params: Param }): Metadata {
const description =
page.data.description ?? 'The library for building documentation sites';
const image = {
alt: 'Banner',
url: `/og/docs/${page.slugs.join('/')}.png`,
width: 1200,
height: 630,
};
const image = getImageMeta('og', page.slugs);
return createMetadata({
title: page.data.title,
description,
+58
View File
@@ -0,0 +1,58 @@
import { generateOGImage } from 'fumadocs-ui/og';
import { type ImageResponse } from 'next/og';
import { type NextRequest } from 'next/server';
import { notFound } from 'next/navigation';
import { utils } from '@/utils/source';
export function GET(
_: NextRequest,
{
params,
}: {
params: {
slug: string[];
};
},
): ImageResponse {
const page = utils.getPage(params.slug.slice(0, -1));
if (!page) notFound();
return generateOGImage({
primaryTextColor: 'rgb(240,240,240)',
primaryColor: 'rgba(255,255,255,0.5)',
title: page.data.title,
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="120"
height="120"
viewBox="0 0 24 24"
stroke="url(#test)"
style={{
marginLeft: '-4px',
}}
strokeWidth="1"
strokeLinecap="round"
fill="rgb(0,0,0,0.8)"
strokeLinejoin="round"
>
<linearGradient id="test" x1="0" y1="0" x2="0" y2="1">
<stop stopColor="white" />
<stop stopColor="rgb(60,60,60)" offset="100%" />
</linearGradient>
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<polyline points="11 3 11 11 14 8 17 11 17 3" />
</svg>
),
description: page.data.description,
site: 'Fumadocs',
});
}
export function generateStaticParams(): {
slug: string[];
}[] {
return utils.getPages().map((page) => ({
slug: [...page.slugs, 'og.png'],
}));
}
@@ -0,0 +1,91 @@
---
title: Open Graph
description: Open Graph with Next.js Metadata API
---
## Introduction
Next.js provides an useful set of utilities, allowing a flexible experience with Fumadocs.
Fumadocs uses the Next.js Metadata API for SEO.
Make sure to read their [Metadata section](https://nextjs.org/docs/app/building-your-application/optimizing/metadata) for the fundamentals of Metadata API.
### Open Graph Image
For docs pages, Fumadocs UI has a built-in dynamic image generator.
You will need a route handler to get started.
```tsx title="app/og/[...slug]/route.tsx"
import { generateOGImage } from 'fumadocs-ui/og';
import { type NextRequest } from 'next/server';
import { notFound } from 'next/navigation';
import { getPage, getPages } from '@/app/source';
export function GET(
_: NextRequest,
{ params }: { params: { slug: string[] } },
) {
const page = getPage(params.slug.slice(0, -1));
if (!page) notFound();
return generateOGImage({
title: page.data.title,
description: page.data.description,
site: 'My App',
});
}
export function generateStaticParams() {
return getPages().map((page) => ({
slug: [...page.slugs, 'og.png'],
}));
}
```
> We need to append `og.png` to the end of slugs so that we can access it via `/og/my-page/og.png`.
In your docs page, add the image to metadata.
```tsx title="app/docs/[[...slug]]/page.tsx"
import { getPage } from '@/app/source';
import { notFound } from 'next/navigation';
import { getImageMeta } from 'fumadocs-ui/og';
export function generateMetadata({ params }: { params: { slug?: string[] } }) {
const page = getPage(params.slug);
if (!page) notFound();
const image = getImageMeta('og', page.slugs);
return {
title: page.data.title,
description: page.data.description,
openGraph: {
url: `/docs/${page.slugs.join('/')}`,
images: image,
},
twitter: {
images: image,
card: 'summary_large_image',
},
};
}
```
You can also customise the font, options for Satori are also available on the built-in generator.
```ts
import { generateOGImage } from 'fumadocs-ui/og';
generateOGImage({
fonts: [
{
name: 'Roboto',
// Use `fs` (Node.js only) or `fetch` to read the font as Buffer/ArrayBuffer and provide `data` here.
data: robotoArrayBuffer,
weight: 400,
style: 'normal',
},
],
});
```
-130
View File
@@ -1,130 +0,0 @@
/* eslint-disable react/no-unknown-property -- Tailwind CSS `tw` property */
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join, dirname, resolve } from 'node:path';
import React from 'react';
import { Resvg } from '@resvg/resvg-js';
import type { SearchIndex } from 'fumadocs-mdx';
import satori from 'satori';
interface Mode {
param: string;
package: string;
name: string;
}
const modes: Mode[] = [
{
param: 'headless',
package: 'fumadocs-core',
name: 'Core',
},
{
param: 'ui',
package: 'fumadocs-ui',
name: 'UI',
},
{
param: 'mdx',
package: 'fumadocs-mdx',
name: 'MDX',
},
];
export async function writeOgImages(indexes: SearchIndex[]): Promise<void> {
const font = await readFile(
resolve(process.cwd(), './scripts/inter-bold.woff'),
);
await Promise.all(
indexes.map(async (index) => {
const img = createOgImage({
title: index.title,
description: index.description ?? 'The Documentation Framework',
mode:
modes.find((m) => m.param === index.url.split('/')[2]) ?? modes[0],
});
const svg = await satori(img, {
width: 1200,
height: 630,
fonts: [{ name: 'Inter', data: font, weight: 700 }],
});
const image = new Resvg(svg);
const out = join('./out/og', `${index.url}.png`);
await mkdir(dirname(out), { recursive: true });
await writeFile(out, image.render().asPng());
}),
);
}
const foreground = 'hsl(0 0% 98%)';
const mutedForeground = 'hsl(0 0% 63.9%)';
const background = 'rgba(10, 10, 10)';
function createOgImage({
title,
description,
mode,
}: {
mode: Mode;
title: string;
description: string;
}): React.ReactElement {
return (
<div
style={{
color: foreground,
background,
}}
tw="flex flex-col w-full h-full p-12"
>
<div
style={{
background:
'linear-gradient(to right bottom, rgb(150, 200, 255), rgb(200, 100, 255))',
}}
tw="flex flex-col justify-center rounded-2xl p-4 shadow-2xl shadow-purple-600"
>
<div
tw="flex flex-col rounded-2xl p-12"
style={{
border: '1px rgba(156,163,175,0.3)',
background,
}}
>
<p tw="font-bold text-6xl">{title}</p>
<p
tw="text-4xl"
style={{
color: mutedForeground,
}}
>
{description}
</p>
</div>
</div>
<div tw="flex flex-row items-center mt-auto p-4">
<svg
fill="currentColor"
height="60"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="60"
>
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
<path d="M5 3v4" />
<path d="M19 17v4" />
<path d="M3 5h4" />
<path d="M17 19h4" />
</svg>
<p tw="font-bold ml-4 text-4xl">{mode.name}</p>
</div>
</div>
);
}
+1 -2
View File
@@ -1,5 +1,4 @@
import env from '@next/env';
import { writeOgImages } from './generate-og-images';
import { updateSearchIndexes } from './update-index.mjs';
import { readFile } from 'node:fs/promises';
import type { SearchIndex } from 'fumadocs-mdx';
@@ -11,7 +10,7 @@ async function main() {
(await readFile('.next/server/chunks/fumadocs_search.json')).toString(),
) as SearchIndex[];
await Promise.all([writeOgImages(indexes), updateSearchIndexes(indexes)]);
await Promise.all([updateSearchIndexes(indexes)]);
}
await main().catch((e) => {
+7
View File
@@ -51,6 +51,10 @@
"./mdx": {
"import": "./dist/mdx.js",
"types": "./dist/mdx.d.ts"
},
"./og": {
"import": "./dist/og.js",
"types": "./dist/og.d.ts"
}
},
"typesVersions": {
@@ -81,6 +85,9 @@
],
"mdx": [
"./dist/mdx.d.ts"
],
"og": [
"./dist/og.d.ts"
]
}
},
+120
View File
@@ -0,0 +1,120 @@
import { ImageResponse } from 'next/og';
import type { ReactElement, ReactNode } from 'react';
import type { ImageResponseOptions } from 'next/dist/compiled/@vercel/og/types';
interface GenerateProps {
title: ReactNode;
description?: ReactNode;
icon?: ReactNode;
primaryColor?: string;
primaryTextColor?: string;
site?: ReactNode;
}
export function getImageMeta(
baseUrl: string,
slugs: string[] = [],
): {
alt: string;
url: string;
width: number;
height: number;
} {
return {
alt: 'Banner',
url: `/${[...baseUrl.split('/'), ...slugs, 'og.png'].filter((v) => v.length > 0).join('/')}`,
width: 1200,
height: 630,
};
}
export function generateOGImage(
options: GenerateProps & ImageResponseOptions,
): ImageResponse {
const {
title,
description,
icon,
site,
primaryColor,
primaryTextColor,
...rest
} = options;
return new ImageResponse(
generate({
title,
description,
icon,
site,
primaryTextColor,
primaryColor,
}),
{
width: 1200,
height: 630,
...rest,
},
);
}
export function generate({
primaryColor = 'rgba(255,150,255,0.3)',
primaryTextColor = 'rgb(255,150,255)',
...props
}: GenerateProps): ReactElement {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
color: 'white',
padding: '4rem',
backgroundColor: '#000000',
backgroundImage: `linear-gradient(to top right, ${primaryColor}, transparent), linear-gradient(to top, #303030 2px, transparent 2px), linear-gradient(to right, #303030 2px, transparent 2px)`,
backgroundSize: '100% 100%, 40px 40px, 40px 40px',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: '16px',
marginBottom: '12px',
color: primaryTextColor,
}}
>
{props.icon}
<p
style={{
fontSize: '64px',
fontWeight: 800,
}}
>
{props.site}
</p>
</div>
<p
style={{
fontWeight: 800,
fontSize: '82px',
}}
>
{props.title}
</p>
<p
style={{
marginTop: '-10px',
fontSize: '56px',
color: 'rgba(240,240,240,0.8)',
}}
>
{props.description}
</p>
</div>
);
}
+1 -1
View File
@@ -33,7 +33,7 @@ const injectImports = [
export default defineConfig({
entry: [
`./src/components/{${exportedComponents.join(',')}}.tsx`,
'./src/{i18n,home-layout,layout,page,provider,mdx,tailwind-plugin}.{ts,tsx}',
'./src/{i18n,home-layout,layout,page,provider,mdx,tailwind-plugin,og}.{ts,tsx}',
'./src/twoslash/popup.tsx',
'./src/**/*.client.tsx',
],