From 75a386359c739218db2dcecec297dc23089c71cd Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Sun, 28 Dec 2025 18:21:11 +1000 Subject: [PATCH] feat: add AI-generated OG images with OpenAI and Vercel Blob caching - Add /api/og/[...path] endpoint for dynamic OG image generation - Use OpenAI gpt-image-1 for image generation with page-specific prompts - Cache images in Vercel Blob storage with 30-day TTL - Extract page content for contextual prompts (static pages, tool details) - Update all page metadata to use dynamic OG image URLs - Refactor tool detail page to server component for generateMetadata support - Fall back to static /public/og-image.png on generation errors --- apps/web/package.json | 1 + apps/web/src/app/api/og/[...path]/route.ts | 94 ++ apps/web/src/app/changelog/layout.tsx | 5 + apps/web/src/app/docs/layout.tsx | 5 + apps/web/src/app/faq/page.tsx | 10 + apps/web/src/app/how-it-works/page.tsx | 9 + apps/web/src/app/layout.tsx | 4 +- apps/web/src/app/playground/layout.tsx | 19 + apps/web/src/app/privacy/page.tsx | 9 + apps/web/src/app/publish/page.tsx | 9 + apps/web/src/app/sdk/page.tsx | 10 + apps/web/src/app/spec/page.tsx | 10 + apps/web/src/app/stats/layout.tsx | 5 + apps/web/src/app/terms/page.tsx | 9 + .../app/tool/[...slug]/ToolDetailClient.tsx | 682 ++++++++++++++ apps/web/src/app/tool/[...slug]/page.tsx | 868 +++--------------- apps/web/src/app/tool/tool-search/layout.tsx | 21 + apps/web/src/lib/og/cache.ts | 67 ++ apps/web/src/lib/og/content-extractor.ts | 228 +++++ apps/web/src/lib/og/image-generator.ts | 56 ++ apps/web/src/lib/og/index.ts | 12 + apps/web/src/lib/og/prompt-builder.ts | 236 +++++ apps/web/src/lib/og/types.ts | 37 + pnpm-lock.yaml | 58 +- 24 files changed, 1724 insertions(+), 740 deletions(-) create mode 100644 apps/web/src/app/api/og/[...path]/route.ts create mode 100644 apps/web/src/app/playground/layout.tsx create mode 100644 apps/web/src/app/tool/[...slug]/ToolDetailClient.tsx create mode 100644 apps/web/src/app/tool/tool-search/layout.tsx create mode 100644 apps/web/src/lib/og/cache.ts create mode 100644 apps/web/src/lib/og/content-extractor.ts create mode 100644 apps/web/src/lib/og/image-generator.ts create mode 100644 apps/web/src/lib/og/index.ts create mode 100644 apps/web/src/lib/og/prompt-builder.ts create mode 100644 apps/web/src/lib/og/types.ts diff --git a/apps/web/package.json b/apps/web/package.json index c500f71..afca5b0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,6 +24,7 @@ "@types/d3": "^7.4.3", "@types/react-syntax-highlighter": "^15.5.13", "@vercel/analytics": "^1.6.1", + "@vercel/blob": "^2.0.0", "ai": "6.0.3", "bm25": "^0.1.1", "d3": "^7.9.0", diff --git a/apps/web/src/app/api/og/[...path]/route.ts b/apps/web/src/app/api/og/[...path]/route.ts new file mode 100644 index 0000000..3eba007 --- /dev/null +++ b/apps/web/src/app/api/og/[...path]/route.ts @@ -0,0 +1,94 @@ +/** + * OG Image Generation 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. + * + * Examples: + * /api/og/home -> Homepage OG image + * /api/og/docs -> Docs page OG image + * /api/og/tool/@tpmjs/hello/helloWorld -> Tool-specific OG image + */ + +import { readFile } 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; + +/** + * Serve the static fallback OG image + */ +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', + }, + }); + } catch { + // If even the fallback fails, return a simple error + return new NextResponse('Fallback image not found', { status: 500 }); + } +} + +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 }); + } + + 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 }); + } catch (error) { + console.error(`[OG] Generation failed for ${pagePath}:`, error); + + // Fall back to static image + return serveFallbackImage(); + } +} diff --git a/apps/web/src/app/changelog/layout.tsx b/apps/web/src/app/changelog/layout.tsx index e21a6b1..2556325 100644 --- a/apps/web/src/app/changelog/layout.tsx +++ b/apps/web/src/app/changelog/layout.tsx @@ -8,6 +8,11 @@ export const metadata: Metadata = { title: 'TPMJS Changelog', description: 'Release history for all published TPMJS packages. Track new features, improvements, and bug fixes across our SDK and tools.', + images: [{ url: '/api/og/changelog', width: 1200, height: 630 }], + }, + twitter: { + card: 'summary_large_image', + images: ['/api/og/changelog'], }, }; diff --git a/apps/web/src/app/docs/layout.tsx b/apps/web/src/app/docs/layout.tsx index d988b9f..f3478e1 100644 --- a/apps/web/src/app/docs/layout.tsx +++ b/apps/web/src/app/docs/layout.tsx @@ -8,6 +8,11 @@ export const metadata: Metadata = { title: 'TPMJS Documentation', description: 'Complete documentation for TPMJS - the registry for AI tools. Learn how to use the SDK, API, and publish your own tools.', + images: [{ url: '/api/og/docs', width: 1200, height: 630 }], + }, + twitter: { + card: 'summary_large_image', + images: ['/api/og/docs'], }, }; diff --git a/apps/web/src/app/faq/page.tsx b/apps/web/src/app/faq/page.tsx index 53ac0a2..fa6d7dd 100644 --- a/apps/web/src/app/faq/page.tsx +++ b/apps/web/src/app/faq/page.tsx @@ -6,6 +6,16 @@ export const metadata = { title: 'FAQ | TPMJS', description: 'Frequently asked questions about TPMJS - Tool Package Manager for AI agents. Learn how to publish tools, understand quality scores, and get help.', + openGraph: { + title: 'FAQ | TPMJS', + description: + 'Frequently asked questions about TPMJS - Tool Package Manager for AI agents. Learn how to publish tools, understand quality scores, and get help.', + images: [{ url: '/api/og/faq', width: 1200, height: 630 }], + }, + twitter: { + card: 'summary_large_image' as const, + images: ['/api/og/faq'], + }, }; interface FAQItemProps { diff --git a/apps/web/src/app/how-it-works/page.tsx b/apps/web/src/app/how-it-works/page.tsx index ae76f9d..d283f43 100644 --- a/apps/web/src/app/how-it-works/page.tsx +++ b/apps/web/src/app/how-it-works/page.tsx @@ -8,6 +8,15 @@ import { ArchitectureDiagram } from '~/components/ArchitectureDiagram'; export const metadata = { title: 'How It Works | TPMJS', description: 'Learn how TPMJS automatically discovers, indexes, and serves AI tools from npm', + openGraph: { + title: 'How It Works | TPMJS', + description: 'Learn how TPMJS automatically discovers, indexes, and serves AI tools from npm', + images: [{ url: '/api/og/how-it-works', width: 1200, height: 630 }], + }, + twitter: { + card: 'summary_large_image' as const, + images: ['/api/og/how-it-works'], + }, }; export default function HowItWorksPage(): React.ReactElement { diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 692e697..684c49b 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -59,7 +59,7 @@ export const metadata: Metadata = { 'Discover and use npm packages as AI agent tools. No config files, automatic discovery, works with any framework.', images: [ { - url: '/og-image.png', + url: '/api/og/home', width: 1200, height: 630, alt: 'TPMJS - Tool Package Manager for AI Agents', @@ -73,7 +73,7 @@ export const metadata: Metadata = { title: 'TPMJS - Tool Package Manager for AI Agents', description: 'Discover and use npm packages as AI agent tools. No config files, automatic discovery, works with any framework.', - images: ['/og-image.png'], + images: ['/api/og/home'], }, robots: { index: true, diff --git a/apps/web/src/app/playground/layout.tsx b/apps/web/src/app/playground/layout.tsx new file mode 100644 index 0000000..0dfb99b --- /dev/null +++ b/apps/web/src/app/playground/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'UI Component Playground | TPMJS', + description: 'Interactive playground to explore and test TPMJS UI components', + openGraph: { + title: 'UI Component Playground | TPMJS', + description: 'Interactive playground to explore and test TPMJS UI components', + images: [{ url: '/api/og/playground', width: 1200, height: 630 }], + }, + twitter: { + card: 'summary_large_image', + images: ['/api/og/playground'], + }, +}; + +export default function PlaygroundLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index b9e3ce7..a93be10 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -5,6 +5,15 @@ import { AppHeader } from '~/components/AppHeader'; export const metadata = { title: 'Privacy Policy | TPMJS', description: 'Learn how TPMJS collects, uses, and protects your data', + openGraph: { + title: 'Privacy Policy | TPMJS', + description: 'Learn how TPMJS collects, uses, and protects your data', + images: [{ url: '/api/og/privacy', width: 1200, height: 630 }], + }, + twitter: { + card: 'summary_large_image' as const, + images: ['/api/og/privacy'], + }, }; export default function PrivacyPage(): React.ReactElement { diff --git a/apps/web/src/app/publish/page.tsx b/apps/web/src/app/publish/page.tsx index 2155cba..8646123 100644 --- a/apps/web/src/app/publish/page.tsx +++ b/apps/web/src/app/publish/page.tsx @@ -7,6 +7,15 @@ import { AppHeader } from '~/components/AppHeader'; export const metadata = { title: 'Publish a Tool | TPMJS', description: 'Learn how to publish your AI tool to the TPMJS registry', + openGraph: { + title: 'Publish a Tool | TPMJS', + description: 'Learn how to publish your AI tool to the TPMJS registry', + images: [{ url: '/api/og/publish', width: 1200, height: 630 }], + }, + twitter: { + card: 'summary_large_image' as const, + images: ['/api/og/publish'], + }, }; export default function PublishPage(): React.ReactElement { diff --git a/apps/web/src/app/sdk/page.tsx b/apps/web/src/app/sdk/page.tsx index 41601be..907b6ca 100644 --- a/apps/web/src/app/sdk/page.tsx +++ b/apps/web/src/app/sdk/page.tsx @@ -9,6 +9,16 @@ export const metadata = { title: 'SDK - Registry Tools | TPMJS', description: 'Add two tools to your AI agent and instantly access thousands of tools from the TPMJS registry', + openGraph: { + title: 'SDK - Registry Tools | TPMJS', + description: + 'Add two tools to your AI agent and instantly access thousands of tools from the TPMJS registry', + images: [{ url: '/api/og/sdk', width: 1200, height: 630 }], + }, + twitter: { + card: 'summary_large_image' as const, + images: ['/api/og/sdk'], + }, }; export default function SDKPage(): React.ReactElement { diff --git a/apps/web/src/app/spec/page.tsx b/apps/web/src/app/spec/page.tsx index 56f75ce..539f6c7 100644 --- a/apps/web/src/app/spec/page.tsx +++ b/apps/web/src/app/spec/page.tsx @@ -11,6 +11,16 @@ export const metadata = { title: 'TPMJS Specification | The Open Standard for AI Tool Discovery', description: 'Complete technical reference for the TPMJS specification - field definitions, validation rules, and integration guide for AI tool developers.', + openGraph: { + title: 'TPMJS Specification | The Open Standard for AI Tool Discovery', + description: + 'Complete technical reference for the TPMJS specification - field definitions, validation rules, and integration guide for AI tool developers.', + images: [{ url: '/api/og/spec', width: 1200, height: 630 }], + }, + twitter: { + card: 'summary_large_image' as const, + images: ['/api/og/spec'], + }, }; export default function SpecPage(): React.ReactElement { diff --git a/apps/web/src/app/stats/layout.tsx b/apps/web/src/app/stats/layout.tsx index a0d298b..e2a92d1 100644 --- a/apps/web/src/app/stats/layout.tsx +++ b/apps/web/src/app/stats/layout.tsx @@ -8,6 +8,11 @@ export const metadata: Metadata = { title: 'Registry Statistics | TPMJS', description: 'Real-time metrics and analytics for the TPMJS tool registry. View tool counts, health status, execution statistics, and more.', + images: [{ url: '/api/og/stats', width: 1200, height: 630 }], + }, + twitter: { + card: 'summary_large_image', + images: ['/api/og/stats'], }, }; diff --git a/apps/web/src/app/terms/page.tsx b/apps/web/src/app/terms/page.tsx index 9c479c3..50ae674 100644 --- a/apps/web/src/app/terms/page.tsx +++ b/apps/web/src/app/terms/page.tsx @@ -5,6 +5,15 @@ import { AppHeader } from '~/components/AppHeader'; export const metadata = { title: 'Terms of Service | TPMJS', description: 'Terms of Service for TPMJS - the registry and execution platform for AI tools', + openGraph: { + title: 'Terms of Service | TPMJS', + description: 'Terms of Service for TPMJS - the registry and execution platform for AI tools', + images: [{ url: '/api/og/terms', width: 1200, height: 630 }], + }, + twitter: { + card: 'summary_large_image' as const, + images: ['/api/og/terms'], + }, }; export default function TermsPage(): React.ReactElement { diff --git a/apps/web/src/app/tool/[...slug]/ToolDetailClient.tsx b/apps/web/src/app/tool/[...slug]/ToolDetailClient.tsx new file mode 100644 index 0000000..6b62f1b --- /dev/null +++ b/apps/web/src/app/tool/[...slug]/ToolDetailClient.tsx @@ -0,0 +1,682 @@ +'use client'; + +import { Badge } from '@tpmjs/ui/Badge/Badge'; +import { Button } from '@tpmjs/ui/Button/Button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card'; +import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock'; +import { Container } from '@tpmjs/ui/Container/Container'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar'; +import { Spinner } from '@tpmjs/ui/Spinner/Spinner'; +import Link from 'next/link'; +import { useState } from 'react'; +import { AppHeader } from '~/components/AppHeader'; +import { Markdown } from '~/components/Markdown'; +import { ToolPlayground } from '~/components/ToolPlayground'; + +interface Package { + id: string; + npmPackageName: string; + npmVersion: string; + npmDescription: string | null; + npmHomepage: string | null; + category: string; + npmRepository: { url: string; type: string } | null; + isOfficial: boolean; + npmDownloadsLastMonth: number | null; + npmKeywords: string[]; + npmReadme: string | null; + npmAuthor: { name: string; email?: string; url?: string } | string | null; + npmMaintainers: Array<{ name: string; email?: string }> | null; + npmLicense: string | null; + githubStars: number | null; + frameworks: string[]; + tier: string; + createdAt: string; + updatedAt: string; +} + +export interface Tool { + id: string; + name: string; + description: string; + parameters: Array<{ + name: string; + type: string; + description: string; + required: boolean; + default?: unknown; + }> | null; + inputSchema: Record | null; + schemaSource: 'extracted' | 'author' | null; + schemaExtractedAt: string | null; + toolDiscoverySource: 'auto' | 'manual' | null; + returns: { + type: string; + description: string; + } | null; + aiAgent: { + useCase?: string; + limitations?: string; + examples?: string[]; + } | null; + qualityScore: string | null; + importHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN'; + executionHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN'; + healthCheckError?: string | null; + lastHealthCheck?: string | null; + package: Package; + createdAt: string; + updatedAt: string; +} + +interface ToolDetailClientProps { + tool: Tool; + slug: string; +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex UI component with many conditional renders +export function ToolDetailClient({ tool, slug }: ToolDetailClientProps): React.ReactElement { + const [recheckLoading, setRecheckLoading] = useState(false); + const [extractSchemaLoading, setExtractSchemaLoading] = useState(false); + + const pkg = tool.package; + const authorName = typeof pkg.npmAuthor === 'string' ? pkg.npmAuthor : pkg.npmAuthor?.name; + + // Generate JSON-LD structured data for SEO + const softwareApplicationSchema = { + '@context': 'https://schema.org', + '@type': 'SoftwareApplication', + name: tool.name, + description: tool.description, + applicationCategory: 'DeveloperApplication', + operatingSystem: 'Any', + offers: { + '@type': 'Offer', + price: '0', + priceCurrency: 'USD', + }, + author: { + '@type': authorName ? 'Person' : 'Organization', + name: authorName || 'Unknown', + }, + url: `https://tpmjs.com/tool/${pkg.npmPackageName}/${tool.name}`, + softwareVersion: pkg.npmVersion, + ...(pkg.npmHomepage && { mainEntityOfPage: pkg.npmHomepage }), + ...(pkg.npmRepository && + typeof pkg.npmRepository === 'object' && + pkg.npmRepository.url && { + codeRepository: pkg.npmRepository.url.replace(/^git\+/, '').replace(/\.git$/, ''), + }), + ...(pkg.npmLicense && { license: pkg.npmLicense }), + ...(pkg.npmDownloadsLastMonth && { + interactionStatistic: { + '@type': 'InteractionCounter', + interactionType: 'https://schema.org/DownloadAction', + userInteractionCount: pkg.npmDownloadsLastMonth, + }, + }), + ...(pkg.githubStars && { + aggregateRating: { + '@type': 'AggregateRating', + ratingValue: Math.min(5, (pkg.githubStars / 1000) * 5), + bestRating: 5, + worstRating: 0, + }, + }), + }; + + const recheckHealth = async () => { + setRecheckLoading(true); + try { + const response = await fetch(`/api/tools/${slug}`, { + method: 'POST', + }); + + if (!response.ok) { + const data = await response.json(); + alert(data.error || 'Recheck failed'); + return; + } + + // Refresh page to show updated health + window.location.reload(); + } catch { + alert('Failed to recheck health'); + } finally { + setRecheckLoading(false); + } + }; + + const extractSchema = async () => { + if (!tool) return; + + setExtractSchemaLoading(true); + try { + const response = await fetch('/api/tools/extract-schema', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + packageName: tool.package.npmPackageName, + name: tool.name, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + alert(data.message || data.error || 'Schema extraction failed'); + return; + } + + if (data.success) { + // Refresh page to show updated schema + window.location.reload(); + } else { + alert(data.message || 'Schema extraction failed'); + } + } catch { + alert('Failed to extract schema'); + } finally { + setExtractSchemaLoading(false); + } + }; + + return ( +
+