mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
UI: Support built-in OG Image generation
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-ui': minor
|
||||
---
|
||||
|
||||
Support built-in OG Image generation
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
@@ -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,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) => {
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user