Docs & CLI: configure llms-full.txt and og image generation by default

This commit is contained in:
Fuma Nama
2025-09-25 21:11:31 +08:00
parent 71bce864a8
commit eaf06c4570
16 changed files with 256 additions and 179 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'create-fumadocs-app': patch
---
[Next.js] configure `llms-full.txt` and og image generation by default
+22 -36
View File
@@ -1,7 +1,4 @@
import {
ImageResponse,
type ImageResponseOptions,
} from '@takumi-rs/image-response';
import { type ImageResponseOptions } from '@takumi-rs/image-response';
import type { ReactNode } from 'react';
import fs from 'node:fs/promises';
@@ -10,41 +7,30 @@ export interface GenerateProps {
description?: ReactNode;
}
const font = await fs.readFile('./lib/og/JetBrainsMono-Regular.ttf');
const fontBold = await fs.readFile('./lib/og/JetBrainsMono-Bold.ttf');
const font = fs.readFile('./lib/og/JetBrainsMono-Regular.ttf');
const fontBold = fs.readFile('./lib/og/JetBrainsMono-Bold.ttf');
export async function generateOGImage(
options: GenerateProps & Partial<ImageResponseOptions>,
): Promise<ImageResponse> {
const { title, description, ...rest } = options;
return new ImageResponse(
generate({
title,
description,
}),
{
format: 'webp',
width: 1200,
height: 630,
fonts: [
{
name: 'Mono',
data: font,
weight: 400,
},
{
name: 'Mono',
data: fontBold,
weight: 600,
},
],
...rest,
},
);
export async function getImageResponseOptions(): Promise<ImageResponseOptions> {
return {
format: 'webp',
width: 1200,
height: 630,
fonts: [
{
name: 'Mono',
data: await font,
weight: 400,
},
{
name: 'Mono',
data: await fontBold,
weight: 600,
},
],
};
}
function generate({ title, description }: GenerateProps) {
export function generate({ title, description }: GenerateProps) {
const siteName = 'Fumadocs';
const primaryTextColor = 'rgb(240,240,240)';
const logo = (
+11 -5
View File
@@ -1,6 +1,7 @@
import { source } from '@/lib/source';
import { notFound } from 'next/navigation';
import { generateOGImage } from './generate';
import { generate as MetadataImage, getImageResponseOptions } from './generate';
import { ImageResponse } from '@takumi-rs/image-response';
export const revalidate = false;
@@ -12,10 +13,15 @@ export async function GET(
const page = source.getPage(slug.slice(0, -1));
if (!page) notFound();
return generateOGImage({
title: page.data.title,
description: page.data.description,
});
return new ImageResponse(
(
<MetadataImage
title={page.data.title}
description={page.data.description}
/>
),
await getImageResponseOptions(),
);
}
export function generateStaticParams(): {
+34 -43
View File
@@ -7,46 +7,57 @@ description: Usage with Next.js Metadata API.
## Metadata Image
Fumadocs has a built-in metadata image generator.
You can generate metadata images dynamically using `next/og`.
You will need a route handler to get started.
Add the following under your loader, and define image metadata for pages:
<include cwd meta='title="app/docs-og/[...slug]/route.tsx"'>
../../examples/next-mdx/app/docs-og/[...slug]/route.tsx
</include>
```ts tab="lib/source.ts"
import { type InferPageType } from 'fumadocs-core/source';
> We need to append `image.png` to the end of slugs so that we can access it via `/docs-og/my-page/image.png`.
// [!code ++:8]
export function getPageImage(page: InferPageType<typeof source>) {
const segments = [...page.slugs, 'image.png'];
In your docs page, add the image to metadata.
return {
segments,
url: `/og/docs/${segments.join('/')}`,
};
}
```
```tsx title="app/docs/[[...slug]]/page.tsx"
```tsx tab="app/docs/[[...slug]]/page.tsx"
import { notFound } from 'next/navigation';
import { source } from '@/lib/source';
import { source, getPageImage } from '@/lib/source';
import type { Metadata } from 'next';
export async function generateMetadata({
params,
}: {
params: Promise<{ slug?: string[] }>;
}) {
const { slug = [] } = await params;
const page = source.getPage(slug);
export async function generateMetadata(
props: PageProps<'/docs/[[...slug]]'>,
): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
const image = ['/docs-og', ...slug, 'image.png'].join('/');
return {
title: page.data.title,
description: page.data.description,
openGraph: {
images: image,
},
twitter: {
card: 'summary_large_image',
images: image,
// [!code ++]
images: getPageImage(page).url,
},
};
}
```
> We append `image.png` to the end of slugs so that we can access it via `/og/docs/my-page/image.png`.
Finally, create a route handler to generate images at build time:
<include cwd meta='title="app/og/docs/[...slug]/route.tsx"'>
../../examples/next-mdx/app/og/docs/[...slug]/route.tsx
</include>
You can specify options for Satori (used by `next/og`), see https://github.com/vercel/satori for reference.
### Other Presets
There's other available styles on Fumadocs CLI, such as `mono`:
@@ -55,24 +66,4 @@ There's other available styles on Fumadocs CLI, such as `mono`:
npx @fumadocs/cli@latest add og/mono
```
Replaced your old `generateOGImage` with the installed one.
### Customisations
You can specify options for Satori, like adding fonts:
```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',
},
],
});
```
Replaced your old `generate` with the installed one.
+28 -59
View File
@@ -1,4 +1,3 @@
import { ImageResponse } from 'next/og';
import type { ReactNode } from 'react';
import fs from 'node:fs/promises';
import type { ImageResponseOptions } from 'next/server';
@@ -6,69 +5,39 @@ import type { ImageResponseOptions } from 'next/server';
export interface GenerateProps {
title: ReactNode;
description?: ReactNode;
site?: ReactNode;
logo?: ReactNode;
}
const font = await fs.readFile('./lib/og/JetBrainsMono-Regular.ttf');
const fontBold = await fs.readFile('./lib/og/JetBrainsMono-Bold.ttf');
const font = fs.readFile('./lib/og/JetBrainsMono-Regular.ttf');
const fontBold = fs.readFile('./lib/og/JetBrainsMono-Bold.ttf');
export async function generateOGImage(
options: GenerateProps & Partial<ImageResponseOptions>,
): Promise<ImageResponse> {
const { title, description, ...rest } = options;
return new ImageResponse(
generate({
title,
description,
}),
{
width: 1200,
height: 630,
fonts: [
{
name: 'Mono',
data: font,
weight: 400,
},
{
name: 'Mono',
data: fontBold,
weight: 600,
},
],
...rest,
},
);
export async function getImageResponseOptions(): Promise<ImageResponseOptions> {
return {
width: 1200,
height: 630,
fonts: [
{
name: 'Mono',
data: await font,
weight: 400,
},
{
name: 'Mono',
data: await fontBold,
weight: 600,
},
],
};
}
function generate({ title, description }: GenerateProps) {
const siteName = 'Fumadocs';
export function generate({
title,
description,
logo,
site = 'My App',
}: GenerateProps) {
const primaryTextColor = 'rgb(240,240,240)';
const logo = (
<svg
width="60"
height="60"
viewBox="0 0 180 180"
filter="url(#logo-shadow)"
>
<circle cx="90" cy="90" r="86" fill="url(#logo-iconGradient)" />
<defs>
<filter id="logo-shadow" colorInterpolationFilters="sRGB">
<feDropShadow
dx="0"
dy="0"
stdDeviation="4"
floodColor="white"
floodOpacity="1"
/>
</filter>
<linearGradient id="logo-iconGradient" gradientTransform="rotate(45)">
<stop offset="45%" stopColor="black" />
<stop offset="100%" stopColor="white" />
</linearGradient>
</defs>
</svg>
);
return (
<div
@@ -123,7 +92,7 @@ function generate({ title, description }: GenerateProps) {
fontWeight: 600,
}}
>
{siteName}
{site}
</p>
</div>
</div>
@@ -1,25 +0,0 @@
import { generateOGImage } from 'fumadocs-ui/og';
import { source } from '@/lib/source';
import { notFound } from 'next/navigation';
export async function GET(
_req: Request,
{ params }: RouteContext<'/docs-og/[...slug]'>,
) {
const { slug } = await params;
const page = source.getPage(slug.slice(0, -1));
if (!page) notFound();
return generateOGImage({
title: page.data.title,
description: page.data.description,
site: 'My App',
});
}
export function generateStaticParams() {
return source.generateParams().map((page) => ({
...page,
slug: [...page.slug, 'image.png'],
}));
}
@@ -1,4 +1,4 @@
import { source } from '@/lib/source';
import { getPageImage, source } from '@/lib/source';
import {
DocsBody,
DocsDescription,
@@ -8,6 +8,7 @@ import {
import { notFound } from 'next/navigation';
import { getMDXComponents } from '@/mdx-components';
import type { Metadata } from 'next';
import { createRelativeLink } from 'fumadocs-ui/mdx';
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
const params = await props.params;
@@ -21,7 +22,12 @@ export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDX components={getMDXComponents()} />
<MDX
components={getMDXComponents({
// this allows you to link to other pages with relative file paths
a: createRelativeLink(source, page),
})}
/>
</DocsBody>
</DocsPage>
);
@@ -41,5 +47,8 @@ export async function generateMetadata(
return {
title: page.data.title,
description: page.data.description,
openGraph: {
images: getPageImage(page).url,
},
};
}
@@ -0,0 +1,10 @@
import { getLLMText, source } from '@/lib/source';
export const revalidate = false;
export async function GET() {
const scan = source.getPages().map(getLLMText);
const scanned = await Promise.all(scan);
return new Response(scanned.join('\n\n'));
}
@@ -0,0 +1,36 @@
import { getPageImage, source } from '@/lib/source';
import { notFound } from 'next/navigation';
import { ImageResponse } from 'next/og';
import { generate as DefaultImage } from 'fumadocs-ui/og';
export const revalidate = false;
export async function GET(
_req: Request,
{ params }: RouteContext<'/og/docs/[...slug]'>,
) {
const { slug } = await params;
const page = source.getPage(slug.slice(0, -1));
if (!page) notFound();
return new ImageResponse(
(
<DefaultImage
title={page.data.title}
description={page.data.description}
site="My App"
/>
),
{
width: 1200,
height: 630,
},
);
}
export function generateStaticParams() {
return source.getPages().map((page) => ({
lang: page.locale,
slug: getPageImage(page).segments,
}));
}
+18 -1
View File
@@ -1,7 +1,24 @@
import { docs } from '@/.source';
import { loader } from 'fumadocs-core/source';
import { type InferPageType, loader } from 'fumadocs-core/source';
export const source = loader({
baseUrl: '/docs',
source: docs.toFumadocsSource(),
});
export function getPageImage(page: InferPageType<typeof source>) {
const segments = [...page.slugs, 'image.png'];
return {
segments,
url: `/og/docs/${segments.join('/')}`,
};
}
export async function getLLMText(page: InferPageType<typeof source>) {
const processed = await page.data.getText('processed');
return `# ${page.data.title}
${processed}`;
}
+5
View File
@@ -2,6 +2,11 @@ import { defineConfig, defineDocs } from 'fumadocs-mdx/config';
export const docs = defineDocs({
dir: 'content/docs',
docs: {
postprocess: {
includeProcessedMarkdown: true,
},
},
});
export default defineConfig();
@@ -1,28 +1,28 @@
import { source } from '@/lib/source';
import { getPageImage, source } from '@/lib/source';
import {
DocsBody,
DocsDescription,
DocsPage,
DocsTitle,
} from 'fumadocs-ui/page';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { createRelativeLink } from 'fumadocs-ui/mdx';
import { getMDXComponents } from '@/mdx-components';
import type { Metadata } from 'next';
import { createRelativeLink } from 'fumadocs-ui/mdx';
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
const MDXContent = page.data.body;
const MDX = page.data.body;
return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDXContent
<MDX
components={getMDXComponents({
// this allows you to link to other pages with relative file paths
a: createRelativeLink(source, page),
@@ -47,5 +47,8 @@ export async function generateMetadata(
return {
title: page.data.title,
description: page.data.description,
openGraph: {
images: getPageImage(page).url,
},
};
}
@@ -0,0 +1,10 @@
import { getLLMText, source } from '@/lib/source';
export const revalidate = false;
export async function GET() {
const scan = source.getPages().map(getLLMText);
const scanned = await Promise.all(scan);
return new Response(scanned.join('\n\n'));
}
@@ -0,0 +1,36 @@
import { getPageImage, source } from '@/lib/source';
import { notFound } from 'next/navigation';
import { ImageResponse } from 'next/og';
import { generate as DefaultImage } from 'fumadocs-ui/og';
export const revalidate = false;
export async function GET(
_req: Request,
{ params }: RouteContext<'/og/docs/[...slug]'>,
) {
const { slug } = await params;
const page = source.getPage(slug.slice(0, -1));
if (!page) notFound();
return new ImageResponse(
(
<DefaultImage
title={page.data.title}
description={page.data.description}
site="My App"
/>
),
{
width: 1200,
height: 630,
},
);
}
export function generateStaticParams() {
return source.getPages().map((page) => ({
lang: page.locale,
slug: getPageImage(page).segments,
}));
}
@@ -1,9 +1,25 @@
import { docs } from '@/.source';
import { loader } from 'fumadocs-core/source';
import { type InferPageType, loader } from 'fumadocs-core/source';
// See https://fumadocs.vercel.app/docs/headless/source-api for more info
export const source = loader({
// it assigns a URL to your pages
baseUrl: '/docs',
source: docs.toFumadocsSource(),
});
export function getPageImage(page: InferPageType<typeof source>) {
const segments = [...page.slugs, 'image.png'];
return {
segments,
url: `/og/docs/${segments.join('/')}`,
};
}
export async function getLLMText(page: InferPageType<typeof source>) {
const processed = await page.data.getText('processed');
return `# ${page.data.title} (${page.url})
${processed}`;
}
@@ -6,10 +6,13 @@ import {
} from 'fumadocs-mdx/config';
// You can customise Zod schemas for frontmatter and `meta.json` here
// see https://fumadocs.dev/docs/mdx/collections#define-docs
// see https://fumadocs.dev/docs/mdx/collections
export const docs = defineDocs({
docs: {
schema: frontmatterSchema,
postprocess: {
includeProcessedMarkdown: true,
},
},
meta: {
schema: metaSchema,