diff --git a/apps/web/package.json b/apps/web/package.json index afca5b0..a90c940 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" } diff --git a/apps/web/public/og/.gitkeep b/apps/web/public/og/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/public/og/changelog.png b/apps/web/public/og/changelog.png new file mode 100644 index 0000000..540272a Binary files /dev/null and b/apps/web/public/og/changelog.png differ diff --git a/apps/web/public/og/docs.png b/apps/web/public/og/docs.png new file mode 100644 index 0000000..9657184 Binary files /dev/null and b/apps/web/public/og/docs.png differ diff --git a/apps/web/public/og/faq.png b/apps/web/public/og/faq.png new file mode 100644 index 0000000..2f3160a Binary files /dev/null and b/apps/web/public/og/faq.png differ diff --git a/apps/web/public/og/home.png b/apps/web/public/og/home.png new file mode 100644 index 0000000..efa4b93 Binary files /dev/null and b/apps/web/public/og/home.png differ diff --git a/apps/web/public/og/how-it-works.png b/apps/web/public/og/how-it-works.png new file mode 100644 index 0000000..8578823 Binary files /dev/null and b/apps/web/public/og/how-it-works.png differ diff --git a/apps/web/public/og/playground.png b/apps/web/public/og/playground.png new file mode 100644 index 0000000..4c96136 Binary files /dev/null and b/apps/web/public/og/playground.png differ diff --git a/apps/web/public/og/privacy.png b/apps/web/public/og/privacy.png new file mode 100644 index 0000000..573b90e Binary files /dev/null and b/apps/web/public/og/privacy.png differ diff --git a/apps/web/public/og/publish.png b/apps/web/public/og/publish.png new file mode 100644 index 0000000..6959454 Binary files /dev/null and b/apps/web/public/og/publish.png differ diff --git a/apps/web/public/og/sdk.png b/apps/web/public/og/sdk.png new file mode 100644 index 0000000..868bb03 Binary files /dev/null and b/apps/web/public/og/sdk.png differ diff --git a/apps/web/public/og/spec.png b/apps/web/public/og/spec.png new file mode 100644 index 0000000..56a871b Binary files /dev/null and b/apps/web/public/og/spec.png differ diff --git a/apps/web/public/og/stats.png b/apps/web/public/og/stats.png new file mode 100644 index 0000000..cc0a7ba Binary files /dev/null and b/apps/web/public/og/stats.png differ diff --git a/apps/web/public/og/terms.png b/apps/web/public/og/terms.png new file mode 100644 index 0000000..d99c25f Binary files /dev/null and b/apps/web/public/og/terms.png differ diff --git a/apps/web/public/og/tool-search.png b/apps/web/public/og/tool-search.png new file mode 100644 index 0000000..f77532b Binary files /dev/null and b/apps/web/public/og/tool-search.png differ diff --git a/apps/web/scripts/generate-og-images.ts b/apps/web/scripts/generate-og-images.ts new file mode 100644 index 0000000..8409a00 --- /dev/null +++ b/apps/web/scripts/generate-og-images.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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); diff --git a/apps/web/src/app/api/og/[...path]/route.ts b/apps/web/src/app/api/og/[...path]/route.ts index 3eba007..371a606 100644 --- a/apps/web/src/app/api/og/[...path]/route.ts +++ b/apps/web/src/app/api/og/[...path]/route.ts @@ -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 { 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 { - 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(); } } diff --git a/apps/web/src/lib/og/image-generator.ts b/apps/web/src/lib/og/image-generator.ts index 33ceeab..0839f68 100644 --- a/apps/web/src/lib/og/image-generator.ts +++ b/apps/web/src/lib/og/image-generator.ts @@ -27,11 +27,11 @@ export async function generateOGImage(prompt: string): Promise { 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'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9f1878..d54513a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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