feat: add build-time OG image generation with OpenAI

- Create generate-og-images.ts script for build-time image generation
- Generate 13 static page OG images using gpt-image-1-mini
- Update API route to serve pre-generated images from public/og/
- Add 30-day cache headers and fallback to default image
- Images regenerate if older than 30 days

Run with: pnpm --filter=@tpmjs/web generate-og

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2025-12-28 20:45:02 +10:00
parent 289544f101
commit 1efb2623b3
19 changed files with 389 additions and 63 deletions

View file

@ -10,7 +10,8 @@
"type-check": "tsc --noEmit",
"clean": "rm -rf .next .turbo",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"generate-og": "tsx scripts/generate-og-images.ts"
},
"dependencies": {
"@ai-sdk/openai": "3.0.1",
@ -54,6 +55,7 @@
"eslint-config-next": "^16.0.4",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^2.1.9"
}

View file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
apps/web/public/og/docs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
apps/web/public/og/faq.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
apps/web/public/og/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
apps/web/public/og/sdk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
apps/web/public/og/spec.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View file

@ -0,0 +1,316 @@
#!/usr/bin/env npx tsx
/**
* Generate OG images for all pages
* Run with: npx tsx scripts/generate-og-images.ts
*/
import { mkdir, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import OpenAI from 'openai';
import { buildOGPrompt } from '../src/lib/og/prompt-builder';
import type { PageContent } from '../src/lib/og/types';
const OUTPUT_DIR = path.join(process.cwd(), 'public', 'og');
const MAX_AGE_DAYS = 30;
// Static pages to generate
const STATIC_PAGES: Array<{ slug: string; content: PageContent }> = [
{
slug: 'home',
content: {
pageType: 'home',
title: 'TPMJS',
description: 'Tool Package Manager for AI Agents',
keywords: ['AI', 'tools', 'npm', 'registry'],
},
},
{
slug: 'docs',
content: {
pageType: 'docs',
title: 'Documentation',
description: 'Complete guide to using TPMJS',
keywords: ['documentation', 'guide', 'tutorial'],
},
},
{
slug: 'stats',
content: {
pageType: 'stats',
title: 'Registry Statistics',
description: 'Real-time metrics for TPMJS registry',
keywords: ['statistics', 'metrics', 'analytics'],
},
},
{
slug: 'publish',
content: {
pageType: 'publish',
title: 'Publish Your Tool',
description: 'Share your AI tool with the community',
keywords: ['publish', 'create', 'npm'],
},
},
{
slug: 'playground',
content: {
pageType: 'playground',
title: 'Playground',
description: 'Test and experiment with AI tools',
keywords: ['playground', 'test', 'experiment'],
},
},
{
slug: 'tool-search',
content: {
pageType: 'search',
title: 'Tool Search',
description: 'Search and discover AI tools',
keywords: ['search', 'browse', 'discover'],
},
},
{
slug: 'faq',
content: {
pageType: 'faq',
title: 'FAQ',
description: 'Frequently asked questions about TPMJS',
keywords: ['faq', 'questions', 'help'],
},
},
{
slug: 'sdk',
content: {
pageType: 'sdk',
title: 'SDK',
description: 'TPMJS SDK for integrating tools',
keywords: ['sdk', 'integration', 'api'],
},
},
{
slug: 'spec',
content: {
pageType: 'spec',
title: 'Specification',
description: 'TPMJS tool specification and schema',
keywords: ['spec', 'specification', 'schema'],
},
},
{
slug: 'changelog',
content: {
pageType: 'changelog',
title: 'Changelog',
description: 'TPMJS release history and updates',
keywords: ['changelog', 'releases', 'updates'],
},
},
{
slug: 'how-it-works',
content: {
pageType: 'how-it-works',
title: 'How It Works',
description: 'Learn how TPMJS works under the hood',
keywords: ['how', 'works', 'architecture'],
},
},
{
slug: 'privacy',
content: {
pageType: 'privacy',
title: 'Privacy Policy',
description: 'TPMJS privacy policy',
keywords: ['privacy', 'policy', 'data'],
},
},
{
slug: 'terms',
content: {
pageType: 'terms',
title: 'Terms of Service',
description: 'TPMJS terms and conditions',
keywords: ['terms', 'service', 'legal'],
},
},
];
let openai: OpenAI | null = null;
function getOpenAIClient(): OpenAI {
if (!openai) {
if (!process.env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY environment variable is required');
}
openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
}
return openai;
}
async function generateImage(prompt: string): Promise<Buffer> {
const client = getOpenAIClient();
const response = await client.images.generate({
model: 'gpt-image-1-mini',
prompt,
n: 1,
size: '1536x1024',
quality: 'medium',
});
if (!response.data || response.data.length === 0) {
throw new Error('No data returned from OpenAI');
}
const b64 = response.data[0]?.b64_json;
if (!b64) {
throw new Error('No base64 image data returned');
}
return Buffer.from(b64, 'base64');
}
async function shouldRegenerate(filePath: string): Promise<boolean> {
try {
const stats = await stat(filePath);
const ageMs = Date.now() - stats.mtime.getTime();
const ageDays = ageMs / (1000 * 60 * 60 * 24);
return ageDays > MAX_AGE_DAYS;
} catch {
// File doesn't exist
return true;
}
}
async function generateStaticPages(): Promise<void> {
console.log('\n📄 Generating static page images...\n');
for (const page of STATIC_PAGES) {
const filePath = path.join(OUTPUT_DIR, `${page.slug}.png`);
if (!(await shouldRegenerate(filePath))) {
console.log(` ⏭️ ${page.slug} - skipped (fresh)`);
continue;
}
console.log(` 🎨 ${page.slug} - generating...`);
try {
const prompt = buildOGPrompt(page.content);
const buffer = await generateImage(prompt);
await writeFile(filePath, buffer);
console.log(`${page.slug} - saved`);
} catch (error) {
console.error(`${page.slug} - failed:`, error);
}
}
}
async function fetchTools(): Promise<
Array<{
id: string;
name: string;
packageName: string;
description: string;
category: string;
}>
> {
// Fetch tools from the API
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
try {
const response = await fetch(`${baseUrl}/api/tools?limit=100`);
if (!response.ok) {
console.warn('Failed to fetch tools from API, skipping tool images');
return [];
}
const { data } = await response.json();
return data.tools || [];
} catch (error) {
console.warn('Could not fetch tools:', error);
return [];
}
}
function slugifyToolPath(packageName: string, toolName: string): string {
// Convert @scope/package/toolName to scope-package-toolName
return `${packageName}/${toolName}`
.replace(/^@/, '')
.replace(/\//g, '-')
.replace(/[^a-zA-Z0-9-]/g, '-')
.replace(/-+/g, '-')
.toLowerCase();
}
async function generateToolPages(): Promise<void> {
console.log('\n🔧 Generating tool page images...\n');
const tools = await fetchTools();
if (tools.length === 0) {
console.log(' ⚠️ No tools found, skipping tool images');
return;
}
console.log(` Found ${tools.length} tools\n`);
// Create tools subdirectory
const toolsDir = path.join(OUTPUT_DIR, 'tool');
await mkdir(toolsDir, { recursive: true });
for (const tool of tools) {
const slug = slugifyToolPath(tool.packageName, tool.name);
const filePath = path.join(toolsDir, `${slug}.png`);
if (!(await shouldRegenerate(filePath))) {
console.log(` ⏭️ ${tool.name} - skipped (fresh)`);
continue;
}
console.log(` 🎨 ${tool.name} - generating...`);
try {
const content: PageContent = {
pageType: 'tool',
title: tool.name,
description: tool.description || `AI tool from ${tool.packageName}`,
keywords: [tool.category, 'AI', 'tool'],
tool: {
name: tool.name,
packageName: tool.packageName,
category: tool.category || 'other',
description: tool.description || '',
},
};
const prompt = buildOGPrompt(content);
const buffer = await generateImage(prompt);
await writeFile(filePath, buffer);
console.log(`${tool.name} - saved`);
} catch (error) {
console.error(`${tool.name} - failed:`, error);
}
// Rate limit - wait 500ms between requests
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
async function main(): Promise<void> {
console.log('🖼️ TPMJS OG Image Generator\n');
console.log(`Output directory: ${OUTPUT_DIR}`);
console.log(`Max age: ${MAX_AGE_DAYS} days`);
// Ensure output directory exists
await mkdir(OUTPUT_DIR, { recursive: true });
// Generate static pages
await generateStaticPages();
// Generate tool pages
await generateToolPages();
console.log('\n✨ Done!\n');
}
main().catch(console.error);

View file

@ -1,31 +1,59 @@
/**
* OG Image Generation API Route
* OG Image API Route
*
* GET /api/og/[...path]
*
* Generates unique OpenGraph images for each page using OpenAI gpt-image-1-mini.
* Images are cached in Vercel Blob storage for 30 days.
* Serves pre-generated OG images from public/og/ directory.
* Images are generated at build time using: pnpm --filter=@tpmjs/web generate-og
*
* Examples:
* /api/og/home -> Homepage OG image
* /api/og/docs -> Docs page OG image
* /api/og/tool/@tpmjs/hello/helloWorld -> Tool-specific OG image
* /api/og/home -> public/og/home.png
* /api/og/docs -> public/og/docs.png
* /api/og/tool/@tpmjs/hello/helloWorld -> public/og/tool/tpmjs-hello-helloworld.png
*/
import { readFile } from 'node:fs/promises';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { type NextRequest, NextResponse } from 'next/server';
import {
buildOGPrompt,
cacheImage,
extractPageContent,
generateOGImage,
getCachedImage,
} from '~/lib/og';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
/**
* Convert a path to the corresponding OG image filename
* /home -> home.png
* /tool/@tpmjs/hello/helloWorld -> tool/tpmjs-hello-helloworld.png
*/
function pathToFilename(pagePath: string): string {
// Remove leading slash
const cleanPath = pagePath.replace(/^\//, '');
// Handle tool pages specially
if (cleanPath.startsWith('tool/')) {
// Extract package and tool name from path like tool/@scope/package/toolName
const toolPath = cleanPath.replace('tool/', '');
const slug = toolPath
.replace(/^@/, '')
.replace(/\//g, '-')
.replace(/[^a-zA-Z0-9-]/g, '-')
.replace(/-+/g, '-')
.toLowerCase();
return `tool/${slug}.png`;
}
return `${cleanPath}.png`;
}
/**
* Serve an image file with proper headers
*/
function serveImage(buffer: Buffer): NextResponse {
return new NextResponse(new Uint8Array(buffer), {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=2592000, stale-while-revalidate=86400', // 30 days
},
});
}
/**
* Serve the static fallback OG image
@ -34,15 +62,8 @@ async function serveFallbackImage(): Promise<NextResponse> {
try {
const fallbackPath = path.join(process.cwd(), 'public', 'og-image.png');
const buffer = await readFile(fallbackPath);
return new NextResponse(buffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
},
});
return serveImage(buffer);
} catch {
// If even the fallback fails, return a simple error
return new NextResponse('Fallback image not found', { status: 500 });
}
}
@ -51,44 +72,28 @@ export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
): Promise<NextResponse> {
const startTime = Date.now();
const { path: pathSegments } = await params;
const pagePath = `/${pathSegments.join('/')}`;
console.log(`[OG] Generating image for: ${pagePath}`);
try {
// 1. Check cache first
const cachedUrl = await getCachedImage(pagePath);
if (cachedUrl) {
console.log(`[OG] Cache hit for: ${pagePath} (${Date.now() - startTime}ms)`);
// Redirect to the cached blob URL
return NextResponse.redirect(cachedUrl, { status: 302 });
// Convert path to filename
const filename = pathToFilename(pagePath);
const imagePath = path.join(process.cwd(), 'public', 'og', filename);
// Check if file exists
try {
await stat(imagePath);
} catch {
// File doesn't exist, serve fallback
console.log(`[OG] Image not found: ${imagePath}, serving fallback`);
return serveFallbackImage();
}
console.log(`[OG] Cache miss for: ${pagePath}, generating...`);
// 2. Extract page content
const content = await extractPageContent(pagePath);
console.log(`[OG] Content extracted: ${content.pageType} - ${content.title}`);
// 3. Build prompt
const prompt = buildOGPrompt(content);
// 4. Generate image with OpenAI
const imageBuffer = await generateOGImage(prompt);
console.log(`[OG] Image generated (${imageBuffer.length} bytes)`);
// 5. Cache the image in Vercel Blob
const blobUrl = await cacheImage(pagePath, imageBuffer);
console.log(`[OG] Cached to: ${blobUrl} (${Date.now() - startTime}ms)`);
// 6. Redirect to the blob URL
return NextResponse.redirect(blobUrl, { status: 302 });
// Read and serve the image
const buffer = await readFile(imagePath);
return serveImage(buffer);
} catch (error) {
console.error(`[OG] Generation failed for ${pagePath}:`, error);
// Fall back to static image
console.error(`[OG] Error serving image for ${pagePath}:`, error);
return serveFallbackImage();
}
}

View file

@ -27,11 +27,11 @@ export async function generateOGImage(prompt: string): Promise<Buffer> {
console.log('[OG] Calling OpenAI image generation...');
const response = await client.images.generate({
model: 'gpt-image-1',
model: 'gpt-image-1-mini',
prompt,
n: 1,
size: '1536x1024', // Landscape format for OG images
quality: 'high',
quality: 'medium',
});
console.log('[OG] OpenAI response received');

13
pnpm-lock.yaml generated
View file

@ -277,6 +277,9 @@ importers:
tailwindcss:
specifier: ^3.4.17
version: 3.4.18(tsx@4.21.0)
tsx:
specifier: ^4.21.0
version: 4.21.0
typescript:
specifier: ^5.9.3
version: 5.9.3
@ -9543,7 +9546,7 @@ snapshots:
'@next/eslint-plugin-next': 16.0.4
eslint: 9.39.1(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7))
eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@1.21.7))
eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@1.21.7))
@ -9586,7 +9589,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)):
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
@ -9626,13 +9629,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)):
eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)):
dependencies:
debug: 3.2.7
optionalDependencies:
eslint: 9.39.1(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7))
transitivePeerDependencies:
- supports-color
@ -9686,7 +9689,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.1(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))
eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3