Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/add-plugin-og-image.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@rspress/plugin-og-image': minor
---

feat: add plugin-og-image for dynamic Open Graph image generation

This plugin automatically generates OG images for each page during build. It uses Satori for SVG rendering and Sharp for PNG conversion. Images are placed at `/og/{route-path}.png` and og:image meta tags are automatically injected into the HTML.
8 changes: 8 additions & 0 deletions e2e/fixtures/plugin-og-image/doc/blog/first-post.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: My First Post
description: This is my first blog post
---

# My first post

Welcome to my blog!
8 changes: 8 additions & 0 deletions e2e/fixtures/plugin-og-image/doc/guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: Getting Started
description: Learn how to get started with our platform
---

# Getting started

This guide will help you get started.
3 changes: 3 additions & 0 deletions e2e/fixtures/plugin-og-image/doc/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Welcome

This is the home page.
5 changes: 5 additions & 0 deletions e2e/fixtures/plugin-og-image/fixture.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"base": "/",
"siteUrl": "http://localhost:4173/",
"title": "OG Image Test Site"
}
63 changes: 63 additions & 0 deletions e2e/fixtures/plugin-og-image/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import * as NodePath from 'node:path';
import { expect, test } from '@playwright/test';
import { runBuildCommand } from '../../utils/runCommands';

const appDir = __dirname;

test.describe('plugin og-image test', async () => {
test.beforeAll(async () => {
await runBuildCommand(appDir);
});

test('should generate og image for home page', async () => {
const imagePath = NodePath.resolve(appDir, 'doc_build/og/index.png');
expect(existsSync(imagePath)).toBe(true);

// Verify it's a valid PNG
const buffer = await readFile(imagePath);
expect(buffer.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a'); // PNG signature
});

test('should generate og image for guide page', async () => {
const imagePath = NodePath.resolve(appDir, 'doc_build/og/guide.png');
expect(existsSync(imagePath)).toBe(true);

const buffer = await readFile(imagePath);
expect(buffer.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a'); // PNG signature
});

test('should generate og image for blog post', async () => {
const imagePath = NodePath.resolve(
appDir,
'doc_build/og/blog/first-post.png',
);
expect(existsSync(imagePath)).toBe(true);

const buffer = await readFile(imagePath);
expect(buffer.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a'); // PNG signature
});

test('should have correct image dimensions', async () => {
const imagePath = NodePath.resolve(appDir, 'doc_build/og/index.png');
const buffer = await readFile(imagePath);

// PNG files store width and height at specific byte positions
// Width is at bytes 16-19, height at bytes 20-23 (big-endian)
const width = buffer.readUInt32BE(16);
const height = buffer.readUInt32BE(20);

expect(width).toBe(1200); // Default width
expect(height).toBe(630); // Default height
});

test('should include og:image meta tag in HTML', async () => {
const htmlPath = NodePath.resolve(appDir, 'doc_build/guide.html');
const html = await readFile(htmlPath, 'utf-8');

// Check that og:image meta tag is in the HTML
expect(html).toContain('og:image');
expect(html).toContain('og/guide.png');
});
});
14 changes: 14 additions & 0 deletions e2e/fixtures/plugin-og-image/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "@rspress-fixture/plugin-og-image",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "rspress build",
"dev": "rspress dev",
"preview": "rspress preview"
},
"dependencies": {
"@rspress/core": "workspace:*",
"@rspress/plugin-og-image": "workspace:*"
}
}
15 changes: 15 additions & 0 deletions e2e/fixtures/plugin-og-image/rspress.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as NodePath from 'node:path';
import { defineConfig } from '@rspress/core';
import { pluginOgImage } from '@rspress/plugin-og-image';
import fixture from './fixture.json';

export default defineConfig({
root: NodePath.resolve(__dirname, 'doc'),
title: fixture.title,
base: fixture.base,
plugins: [
pluginOgImage({
siteUrl: fixture.siteUrl,
}),
],
});
3 changes: 3 additions & 0 deletions e2e/fixtures/plugin-og-image/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "../../../tsconfig.json"
}
22 changes: 22 additions & 0 deletions packages/plugin-og-image/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
MIT License

Copyright (c) 2024 jl917
Copyright (c) 2025-present Bytedance, Inc. and its affiliates.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
106 changes: 106 additions & 0 deletions packages/plugin-og-image/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# @rspress/plugin-og-image

A plugin for Rspress to dynamically generate Open Graph (OG) images for each page.

## Installation

```bash
npm install @rspress/plugin-og-image
# or
pnpm add @rspress/plugin-og-image
# or
yarn add @rspress/plugin-og-image
```

## Usage

Add the plugin to your `rspress.config.ts`:

```ts
import { defineConfig } from '@rspress/core';
import { pluginOgImage } from '@rspress/plugin-og-image';

export default defineConfig({
plugins: [
pluginOgImage({
siteUrl: 'https://your-site.com',
}),
],
});
```

## Options

### `siteUrl`

- Type: `string`
- Required: `true`

The base URL of your site. Used to generate absolute URLs for OG images.

### `ogImage`

- Type: `OgImageOptions`
- Required: `false`

Options for OG image generation.

#### `ogImage.width`

- Type: `number`
- Default: `1200`

Width of the generated OG image in pixels.

#### `ogImage.height`

- Type: `number`
- Default: `630`

Height of the generated OG image in pixels (630px is the recommended size for OG images).

#### `ogImage.template`

- Type: `(data: OgImageTemplateData) => string | Promise<string>`
- Required: `false`

Custom template function to generate the image. Receives page data and should return a React-like JSX structure compatible with [Satori](https://github.com/vercel/satori).

#### `ogImage.filter`

- Type: `(pageData: PageIndexInfo) => boolean`
- Default: `() => true`

Filter function to determine which pages should have OG images generated. By default, all pages get OG images.

## Frontmatter options

You can customize OG images per page using frontmatter:

```md
---
title: My Page
description: A description of my page
ogBackgroundColor: '#1a1a1a'
ogTextColor: '#ffffff'
siteName: My Site
---
```

## Generated URLs

OG images are generated with URLs matching your page routes:

- Page: `https://your-site.com/guide/getting-started`
- OG Image: `https://your-site.com/og/guide/getting-started.png`

## How it works

1. During the build process, the plugin generates OG images for each page
2. Images are created using [Satori](https://github.com/vercel/satori) (SVG generation) and [Sharp](https://sharp.pixelplumbing.com/) (PNG conversion)
3. OG image meta tags are automatically added to each page's frontmatter
4. Images are saved to the output directory in the `/og` folder

## License

MIT
51 changes: 51 additions & 0 deletions packages/plugin-og-image/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"name": "@rspress/plugin-og-image",
"version": "2.0.0-rc.1",
"description": "A plugin for rspress to generate dynamic OG (Open Graph) images",
"bugs": "https://github.com/web-infra-dev/rspress/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/web-infra-dev/rspress.git",
"directory": "packages/plugin-og-image"
},
"license": "MIT",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist",
"static"
],
"scripts": {
"build": "rslib build",
"dev": "rslib build -w",
"reset": "rimraf ./**/node_modules"
},
"dependencies": {
"satori": "^0.11.2",
"sharp": "^0.33.5"
},
"devDependencies": {
"@microsoft/api-extractor": "^7.55.0",
"@rslib/core": "0.17.2",
"@types/node": "^22.8.1",
"rsbuild-plugin-publint": "^0.3.3"
},
"peerDependencies": {
"@rspress/core": "workspace:^2.0.0-rc.1"
},
"engines": {
"node": ">=18.0.0"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}
9 changes: 9 additions & 0 deletions packages/plugin-og-image/rslib.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from '@rslib/core';
import { pluginPublint } from 'rsbuild-plugin-publint';

export default defineConfig({
plugins: [pluginPublint()],
lib: [
{ bundle: true, syntax: 'es2022', format: 'esm', dts: { bundle: true } },
],
});
62 changes: 62 additions & 0 deletions packages/plugin-og-image/src/generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { readFile } from 'node:fs/promises';
import satori from 'satori';
import sharp from 'sharp';
import { defaultTemplate } from './template';
import type { OgImageOptions, OgImageTemplateData } from './types';

let cachedFont: Buffer | null = null;

/**
* Get font data for Satori
*/
async function getFontData(): Promise<Buffer> {
if (cachedFont) {
return cachedFont;
}

try {
// Try to use a system font or bundled font
// For now, we'll use a simple approach - in production this might need bundled fonts
const fontPath = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf';
const fontData = await readFile(fontPath);
cachedFont = Buffer.from(fontData);
return cachedFont;
} catch (error) {
// Fallback: return empty buffer and Satori will use default
console.warn('Could not load font, using Satori defaults');
return Buffer.from([]);
}
}

/**
* Generate OG image PNG from template data
*/
export async function generateOgImage(
data: OgImageTemplateData,
options: OgImageOptions = {},
): Promise<Buffer> {
const { width = 1200, height = 630, template = defaultTemplate } = options;

// Get the template (either custom or default)
const templateResult =
typeof template === 'function' ? await template(data) : template;

// Convert template to SVG using Satori
const svg = await satori(templateResult, {
width,
height,
fonts: [
{
name: 'sans-serif',
data: await getFontData(),
weight: 400,
style: 'normal',
},
],
});

// Convert SVG to PNG using Sharp
const png = await sharp(Buffer.from(svg)).png().toBuffer();

return png;
}
6 changes: 6 additions & 0 deletions packages/plugin-og-image/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export { pluginOgImage } from './plugin';
export type {
OgImageOptions,
OgImageTemplateData,
PluginOgImageOptions,
} from './types';
Loading
Loading