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
This commit is contained in:
Ajax Davis 2025-12-28 18:21:11 +10:00
parent 597fc2abf1
commit effd242b07
24 changed files with 1724 additions and 740 deletions

View file

@ -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",

View file

@ -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<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',
},
});
} 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<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 });
}
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();
}
}

View file

@ -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'],
},
};

View file

@ -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'],
},
};

View file

@ -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 {

View file

@ -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 {

View file

@ -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,

View file

@ -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;
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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'],
},
};

View file

@ -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 {

View file

@ -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<string, unknown> | 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 (
<div className="min-h-screen bg-background">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareApplicationSchema) }}
/>
<AppHeader />
{/* Main content */}
<Container size="xl" padding="md" className="py-8">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-foreground-secondary mb-6">
<Link href="/" className="hover:text-foreground">
Home
</Link>
<span>/</span>
<Link href="/tool/tool-search" className="hover:text-foreground">
Tools
</Link>
<span>/</span>
<span className="text-foreground">{pkg.npmPackageName}</span>
</div>
{/* Title section */}
<div className="mb-8">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-4">
<div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-foreground mb-2">
{tool.name}
</h1>
<p className="text-sm text-foreground-tertiary font-mono mb-2">
{pkg.npmPackageName}
</p>
<p className="text-lg text-foreground-secondary">{tool.description}</p>
{authorName && (
<p className="text-sm text-foreground-tertiary mt-2">
by <span className="text-foreground-secondary">{authorName}</span>
</p>
)}
</div>
{pkg.isOfficial && (
<Badge variant="default" size="lg">
Official
</Badge>
)}
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">{pkg.category}</Badge>
<Badge variant="outline">v{pkg.npmVersion}</Badge>
{pkg.npmLicense && <Badge variant="outline">{pkg.npmLicense}</Badge>}
{tool.toolDiscoverySource === 'auto' && (
<Badge variant="warning" size="sm">
Auto-discovered
</Badge>
)}
</div>
</div>
{/* Auto-discovery info banner */}
{tool.toolDiscoverySource === 'auto' && (
<div className="mb-6 p-4 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900">
<div className="flex items-start gap-3">
<span className="text-xl mt-0.5">🔍</span>
<div className="flex-1">
<h3 className="text-sm font-semibold text-amber-800 dark:text-amber-300 mb-1">
Auto-discovered tool
</h3>
<p className="text-sm text-amber-700 dark:text-amber-400">
This tool was automatically discovered from the package exports. The author did
not explicitly register it in their{' '}
<code className="font-mono">package.json</code>. Schema and description were
auto-extracted.
</p>
</div>
</div>
</div>
)}
{/* Health warning banner */}
{(tool.importHealth === 'BROKEN' || tool.executionHealth === 'BROKEN') && (
<div className="mb-6 p-4 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900">
<div className="flex items-start gap-3">
<span className="text-xl mt-0.5"></span>
<div className="flex-1">
<h3 className="text-sm font-semibold text-red-800 dark:text-red-300 mb-1">
This tool is currently broken
</h3>
<div className="space-y-1 text-sm text-red-700 dark:text-red-400">
{tool.importHealth === 'BROKEN' && (
<div className="flex items-center gap-2">
<Badge variant="error" size="sm">
Import Failed
</Badge>
<span className="text-xs">Cannot load from Railway service</span>
</div>
)}
{tool.executionHealth === 'BROKEN' && (
<div className="flex items-center gap-2">
<Badge variant="error" size="sm">
Execution Failed
</Badge>
<span className="text-xs">Runtime error with test parameters</span>
</div>
)}
</div>
{tool.healthCheckError && (
<pre className="mt-2 p-2 rounded bg-red-100 dark:bg-red-900/30 text-xs font-mono text-red-800 dark:text-red-300 overflow-x-auto whitespace-pre-wrap">
{tool.healthCheckError}
</pre>
)}
{tool.lastHealthCheck && (
<p className="text-xs text-red-600 dark:text-red-500 mt-2">
Last checked: {new Date(tool.lastHealthCheck).toLocaleString()}
</p>
)}
<button
type="button"
onClick={recheckHealth}
disabled={recheckLoading}
className="mt-3 text-sm font-medium text-red-700 dark:text-red-400 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
>
{recheckLoading ? 'Rechecking...' : 'Recheck health →'}
</button>
</div>
</div>
</div>
)}
{/* Main grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-6">
{/* Left column - Main content */}
<div className="lg:col-span-2 space-y-4 md:space-y-6">
{/* Interactive Playground */}
{/* biome-ignore lint/suspicious/noExplicitAny: Prisma Tool type compatibility with component props */}
<ToolPlayground tool={tool as any} />
{/* Installation & Usage */}
<Card>
<CardHeader>
<CardTitle>Installation & Usage</CardTitle>
<CardDescription>Install this tool and use it with the AI SDK</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h4 className="text-sm font-semibold text-foreground mb-3">
1. Install the package
</h4>
<div className="space-y-2">
<CodeBlock
code={`npm install ${pkg.npmPackageName}`}
language="bash"
showCopy={true}
/>
<CodeBlock
code={`pnpm add ${pkg.npmPackageName}`}
language="bash"
showCopy={true}
/>
</div>
</div>
<div>
<h4 className="text-sm font-semibold text-foreground mb-3">2. Import the tool</h4>
<CodeBlock
code={`import { ${tool.name} } from '${pkg.npmPackageName}';`}
language="typescript"
showCopy={true}
/>
</div>
<div>
<h4 className="text-sm font-semibold text-foreground mb-3">3. Use with AI SDK</h4>
<CodeBlock
code={`import { generateText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { ${tool.name} } from '${pkg.npmPackageName}';
const result = await generateText({
model: openai('gpt-4o'),
tools: { ${tool.name} },
prompt: 'Your prompt here...',
});
console.log(result.text);`}
language="typescript"
showCopy={true}
/>
</div>
</CardContent>
</Card>
{/* AI Agent Information */}
{tool.aiAgent && (
<Card>
<CardHeader>
<CardTitle>AI Agent Integration</CardTitle>
<CardDescription>How AI agents can use this tool</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{tool.aiAgent.useCase && (
<div>
<h4 className="text-sm font-semibold text-foreground mb-2">Use Case</h4>
<p className="text-sm text-foreground-secondary">{tool.aiAgent.useCase}</p>
</div>
)}
{tool.aiAgent.limitations && (
<div>
<h4 className="text-sm font-semibold text-foreground mb-2">Limitations</h4>
<p className="text-sm text-foreground-secondary">
{tool.aiAgent.limitations}
</p>
</div>
)}
{tool.aiAgent.examples && tool.aiAgent.examples.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-foreground mb-2">Examples</h4>
<ul className="list-disc list-inside space-y-1">
{tool.aiAgent.examples.map((example) => (
<li key={example} className="text-sm text-foreground-secondary">
{example}
</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
)}
{/* Parameters / Input Schema */}
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Parameters</CardTitle>
<CardDescription>Available configuration options</CardDescription>
</div>
<div className="flex items-center gap-2">
{tool.schemaSource === 'extracted' ? (
<Badge variant="default" size="sm">
Auto-extracted
</Badge>
) : tool.schemaSource === 'author' ? (
<Badge variant="secondary" size="sm">
Author-provided
</Badge>
) : (
<Badge variant="outline" size="sm">
No schema
</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent>
{tool.parameters && tool.parameters.length > 0 ? (
<div className="space-y-4">
{tool.parameters.map((param) => (
<div key={param.name} className="border-b border-border pb-4 last:border-0">
<div className="flex items-start justify-between mb-2">
<code className="text-sm font-mono text-foreground">{param.name}</code>
{param.required ? (
<Badge variant="error" size="sm">
Required
</Badge>
) : (
<Badge variant="outline" size="sm">
Optional
</Badge>
)}
</div>
<div className="text-sm text-foreground-secondary mb-1">
<span className="font-semibold">Type: </span>
<code className="font-mono">{param.type}</code>
</div>
<p className="text-sm text-foreground-secondary">{param.description}</p>
{param.default !== undefined && (
<div className="text-sm text-foreground-tertiary mt-1">
<span className="font-semibold">Default: </span>
<code className="font-mono">{JSON.stringify(param.default)}</code>
</div>
)}
</div>
))}
{tool.schemaExtractedAt && (
<p className="text-xs text-foreground-tertiary">
Schema extracted: {new Date(tool.schemaExtractedAt).toLocaleString()}
</p>
)}
</div>
) : (
<div className="text-center py-6">
<p className="text-sm text-foreground-secondary mb-4">
No schema available for this tool.
</p>
<Button
variant="outline"
size="sm"
onClick={extractSchema}
disabled={extractSchemaLoading}
>
{extractSchemaLoading ? (
<>
<Spinner size="sm" className="mr-2" />
Extracting...
</>
) : (
'Extract Schema'
)}
</Button>
</div>
)}
{tool.schemaSource !== 'extracted' &&
tool.parameters &&
tool.parameters.length > 0 && (
<div className="mt-4 pt-4 border-t border-border">
<Button
variant="outline"
size="sm"
onClick={extractSchema}
disabled={extractSchemaLoading}
>
{extractSchemaLoading ? (
<>
<Spinner size="sm" className="mr-2" />
Extracting...
</>
) : (
'Re-extract Schema'
)}
</Button>
<p className="text-xs text-foreground-tertiary mt-2">
Try to auto-extract schema from the package
</p>
</div>
)}
</CardContent>
</Card>
{/* README */}
{pkg.npmReadme && (
<Card>
<CardHeader>
<CardTitle>README</CardTitle>
</CardHeader>
<CardContent>
<Markdown content={pkg.npmReadme} />
</CardContent>
</Card>
)}
</div>
{/* Right column - Sidebar */}
<div className="space-y-4 md:space-y-6">
{/* Stats */}
<Card>
<CardHeader>
<CardTitle>Statistics</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm text-foreground-secondary mb-1">Downloads/month</p>
<p className="text-2xl font-bold text-foreground">
{pkg.npmDownloadsLastMonth?.toLocaleString() || '0'}
</p>
</div>
{pkg.githubStars != null && (
<div>
<p className="text-sm text-foreground-secondary mb-1">GitHub Stars</p>
<p className="text-2xl font-bold text-foreground">
{pkg.githubStars.toLocaleString()}
</p>
</div>
)}
<div>
<p className="text-sm text-foreground-secondary mb-2">Quality Score</p>
<ProgressBar
value={(tool.qualityScore ? Number.parseFloat(tool.qualityScore) : 0) * 100}
variant={
tool.qualityScore && Number.parseFloat(tool.qualityScore) >= 0.7
? 'success'
: tool.qualityScore && Number.parseFloat(tool.qualityScore) >= 0.5
? 'primary'
: 'warning'
}
size="md"
showLabel={true}
/>
</div>
</CardContent>
</Card>
{/* NPM Keywords */}
{pkg.npmKeywords && pkg.npmKeywords.length > 0 && (
<Card>
<CardHeader>
<CardTitle>NPM Keywords</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{pkg.npmKeywords.map((keyword) => (
<Badge key={keyword} variant="outline" size="sm">
{keyword}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Maintainers */}
{pkg.npmMaintainers && pkg.npmMaintainers.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Maintainers</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{pkg.npmMaintainers.map((maintainer) => (
<div key={maintainer.name} className="text-sm">
<span className="text-foreground font-medium">{maintainer.name}</span>
{maintainer.email && (
<span className="text-foreground-tertiary ml-2">
({maintainer.email})
</span>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Links */}
<Card>
<CardHeader>
<CardTitle>Links</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<a
href={`https://www.npmjs.com/package/${pkg.npmPackageName}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-foreground-secondary hover:text-foreground"
>
<Icon icon="externalLink" size="sm" />
<span>View on NPM</span>
</a>
{pkg.npmHomepage && (
<a
href={pkg.npmHomepage}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-foreground-secondary hover:text-foreground"
>
<Icon icon="externalLink" size="sm" />
<span>Homepage</span>
</a>
)}
{pkg.npmRepository &&
typeof pkg.npmRepository === 'object' &&
pkg.npmRepository.url && (
<a
href={pkg.npmRepository.url.replace(/^git\+/, '').replace(/\.git$/, '')}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-foreground-secondary hover:text-foreground"
>
<Icon icon="github" size="sm" />
<span>Repository</span>
</a>
)}
</CardContent>
</Card>
{/* Frameworks */}
{pkg.frameworks && pkg.frameworks.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Frameworks</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{pkg.frameworks.map((framework) => (
<Badge key={framework} variant="secondary" size="sm">
{framework}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
</div>
</div>
</Container>
</div>
);
}

View file

@ -1,747 +1,149 @@
'use client';
import { prisma } from '@tpmjs/db';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { type Tool, ToolDetailClient } from './ToolDetailClient';
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 { useEffect, 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;
}
interface Tool {
id: string;
name: string;
description: string;
parameters: Array<{
name: string;
type: string;
description: string;
required: boolean;
default?: unknown;
}> | null;
inputSchema: Record<string, unknown> | 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;
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex UI component with many conditional renders
export default function ToolDetailPage({
params,
}: {
interface ToolDetailPageProps {
params: Promise<{ slug: string[] }>;
}): React.ReactElement {
const [tool, setTool] = useState<Tool | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [slug, setSlug] = useState<string>('');
const [recheckLoading, setRecheckLoading] = useState(false);
const [extractSchemaLoading, setExtractSchemaLoading] = useState(false);
}
useEffect(() => {
// Join slug array to reconstruct package name (e.g., ['@tpmjs', 'text-transformer'] -> '@tpmjs/text-transformer')
params.then((p) => setSlug(p.slug.join('/')));
}, [params]);
/**
* Parse the URL slug to extract package name and optional export name
*/
function parseSlug(slug: string[]): { packageName: string; exportName?: string } {
if (slug[0]?.startsWith('@')) {
// Scoped package: ['@scope', 'package', 'exportName?']
const packageName = slug.slice(0, 2).join('/');
const exportName = slug[2];
return { packageName, exportName };
}
// Unscoped: ['package', 'exportName?']
return { packageName: slug[0] || '', exportName: slug[1] };
}
useEffect(() => {
if (!slug) return;
/**
* Fetch tool data from database
*/
async function getTool(slug: string[]): Promise<Tool | null> {
const { packageName, exportName } = parseSlug(slug);
const fetchTool = async () => {
try {
setLoading(true);
const response = await fetch(`/api/tools/${slug}`);
const data = await response.json();
if (data.success) {
setTool(data.data);
setError(null);
} else {
setError(data.error || 'Failed to fetch tool');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
fetchTool();
}, [slug]);
if (loading) {
return (
<div className="min-h-screen bg-background">
<AppHeader />
<Container size="xl" padding="md" className="py-12">
<div className="flex items-center justify-center py-24 gap-4">
<Spinner size="lg" />
<span className="text-foreground-secondary font-mono text-sm tracking-wide">
Loading tool...
</span>
</div>
</Container>
</div>
);
if (!packageName) {
return null;
}
if (error || !tool) {
return (
<div className="min-h-screen bg-background">
<AppHeader />
<Container size="xl" padding="md" className="py-12">
<div className="text-center">
<p className="text-red-500 text-lg mb-4">{error || 'Tool not found'}</p>
<Link href="/tool/tool-search">
<Button variant="default">Browse All Tools</Button>
</Link>
</div>
</Container>
</div>
);
const tool = await prisma.tool.findFirst({
where: {
package: { npmPackageName: packageName },
...(exportName && { name: exportName }),
},
include: {
package: true,
},
orderBy: { qualityScore: 'desc' },
});
if (!tool) {
return null;
}
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',
// Transform Prisma result to Tool interface
return {
id: tool.id,
name: tool.name,
description: tool.description,
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Any',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'USD',
parameters: tool.parameters as Tool['parameters'],
inputSchema: tool.inputSchema as Tool['inputSchema'],
schemaSource: tool.schemaSource as Tool['schemaSource'],
schemaExtractedAt: tool.schemaExtractedAt?.toISOString() ?? null,
toolDiscoverySource: tool.toolDiscoverySource as Tool['toolDiscoverySource'],
returns: tool.returns as Tool['returns'],
aiAgent: tool.aiAgent as Tool['aiAgent'],
qualityScore: tool.qualityScore?.toString() ?? null,
importHealth: tool.importHealth ?? undefined,
executionHealth: tool.executionHealth ?? undefined,
healthCheckError: tool.healthCheckError ?? null,
lastHealthCheck: tool.lastHealthCheck?.toISOString() ?? null,
createdAt: tool.createdAt.toISOString(),
updatedAt: tool.updatedAt.toISOString(),
package: {
id: tool.package.id,
npmPackageName: tool.package.npmPackageName,
npmVersion: tool.package.npmVersion,
npmDescription: tool.package.npmDescription,
npmHomepage: tool.package.npmHomepage,
category: tool.package.category,
npmRepository: tool.package.npmRepository as Tool['package']['npmRepository'],
isOfficial: tool.package.isOfficial,
npmDownloadsLastMonth: tool.package.npmDownloadsLastMonth,
npmKeywords: tool.package.npmKeywords,
npmReadme: tool.package.npmReadme,
npmAuthor: tool.package.npmAuthor as Tool['package']['npmAuthor'],
npmMaintainers: tool.package.npmMaintainers as Tool['package']['npmMaintainers'],
npmLicense: tool.package.npmLicense,
githubStars: tool.package.githubStars,
frameworks: tool.package.frameworks,
tier: tool.package.tier,
createdAt: tool.package.createdAt.toISOString(),
updatedAt: tool.package.updatedAt.toISOString(),
},
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 (
<div className="min-h-screen bg-background">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareApplicationSchema) }}
/>
<AppHeader />
{/* Main content */}
<Container size="xl" padding="md" className="py-8">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-foreground-secondary mb-6">
<Link href="/" className="hover:text-foreground">
Home
</Link>
<span>/</span>
<Link href="/tool/tool-search" className="hover:text-foreground">
Tools
</Link>
<span>/</span>
<span className="text-foreground">{pkg.npmPackageName}</span>
</div>
{/* Title section */}
<div className="mb-8">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-4">
<div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-foreground mb-2">
{tool.name}
</h1>
<p className="text-sm text-foreground-tertiary font-mono mb-2">
{pkg.npmPackageName}
</p>
<p className="text-lg text-foreground-secondary">{tool.description}</p>
{authorName && (
<p className="text-sm text-foreground-tertiary mt-2">
by <span className="text-foreground-secondary">{authorName}</span>
</p>
)}
</div>
{pkg.isOfficial && (
<Badge variant="default" size="lg">
Official
</Badge>
)}
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">{pkg.category}</Badge>
<Badge variant="outline">v{pkg.npmVersion}</Badge>
{pkg.npmLicense && <Badge variant="outline">{pkg.npmLicense}</Badge>}
{tool.toolDiscoverySource === 'auto' && (
<Badge variant="warning" size="sm">
Auto-discovered
</Badge>
)}
</div>
</div>
{/* Auto-discovery info banner */}
{tool.toolDiscoverySource === 'auto' && (
<div className="mb-6 p-4 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900">
<div className="flex items-start gap-3">
<span className="text-xl mt-0.5">🔍</span>
<div className="flex-1">
<h3 className="text-sm font-semibold text-amber-800 dark:text-amber-300 mb-1">
Auto-discovered tool
</h3>
<p className="text-sm text-amber-700 dark:text-amber-400">
This tool was automatically discovered from the package exports. The author did
not explicitly register it in their{' '}
<code className="font-mono">package.json</code>. Schema and description were
auto-extracted.
</p>
</div>
</div>
</div>
)}
{/* Health warning banner */}
{(tool.importHealth === 'BROKEN' || tool.executionHealth === 'BROKEN') && (
<div className="mb-6 p-4 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900">
<div className="flex items-start gap-3">
<span className="text-xl mt-0.5"></span>
<div className="flex-1">
<h3 className="text-sm font-semibold text-red-800 dark:text-red-300 mb-1">
This tool is currently broken
</h3>
<div className="space-y-1 text-sm text-red-700 dark:text-red-400">
{tool.importHealth === 'BROKEN' && (
<div className="flex items-center gap-2">
<Badge variant="error" size="sm">
Import Failed
</Badge>
<span className="text-xs">Cannot load from Railway service</span>
</div>
)}
{tool.executionHealth === 'BROKEN' && (
<div className="flex items-center gap-2">
<Badge variant="error" size="sm">
Execution Failed
</Badge>
<span className="text-xs">Runtime error with test parameters</span>
</div>
)}
</div>
{tool.healthCheckError && (
<pre className="mt-2 p-2 rounded bg-red-100 dark:bg-red-900/30 text-xs font-mono text-red-800 dark:text-red-300 overflow-x-auto whitespace-pre-wrap">
{tool.healthCheckError}
</pre>
)}
{tool.lastHealthCheck && (
<p className="text-xs text-red-600 dark:text-red-500 mt-2">
Last checked: {new Date(tool.lastHealthCheck).toLocaleString()}
</p>
)}
<button
type="button"
onClick={recheckHealth}
disabled={recheckLoading}
className="mt-3 text-sm font-medium text-red-700 dark:text-red-400 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
>
{recheckLoading ? 'Rechecking...' : 'Recheck health →'}
</button>
</div>
</div>
</div>
)}
{/* Main grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-6">
{/* Left column - Main content */}
<div className="lg:col-span-2 space-y-4 md:space-y-6">
{/* Interactive Playground */}
{/* biome-ignore lint/suspicious/noExplicitAny: Prisma Tool type compatibility with component props */}
<ToolPlayground tool={tool as any} />
{/* Installation & Usage */}
<Card>
<CardHeader>
<CardTitle>Installation & Usage</CardTitle>
<CardDescription>Install this tool and use it with the AI SDK</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h4 className="text-sm font-semibold text-foreground mb-3">
1. Install the package
</h4>
<div className="space-y-2">
<CodeBlock
code={`npm install ${pkg.npmPackageName}`}
language="bash"
showCopy={true}
/>
<CodeBlock
code={`pnpm add ${pkg.npmPackageName}`}
language="bash"
showCopy={true}
/>
</div>
</div>
<div>
<h4 className="text-sm font-semibold text-foreground mb-3">2. Import the tool</h4>
<CodeBlock
code={`import { ${tool.name} } from '${pkg.npmPackageName}';`}
language="typescript"
showCopy={true}
/>
</div>
<div>
<h4 className="text-sm font-semibold text-foreground mb-3">3. Use with AI SDK</h4>
<CodeBlock
code={`import { generateText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { ${tool.name} } from '${pkg.npmPackageName}';
const result = await generateText({
model: openai('gpt-4o'),
tools: { ${tool.name} },
prompt: 'Your prompt here...',
});
console.log(result.text);`}
language="typescript"
showCopy={true}
/>
</div>
</CardContent>
</Card>
{/* AI Agent Information */}
{tool.aiAgent && (
<Card>
<CardHeader>
<CardTitle>AI Agent Integration</CardTitle>
<CardDescription>How AI agents can use this tool</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{tool.aiAgent.useCase && (
<div>
<h4 className="text-sm font-semibold text-foreground mb-2">Use Case</h4>
<p className="text-sm text-foreground-secondary">{tool.aiAgent.useCase}</p>
</div>
)}
{tool.aiAgent.limitations && (
<div>
<h4 className="text-sm font-semibold text-foreground mb-2">Limitations</h4>
<p className="text-sm text-foreground-secondary">
{tool.aiAgent.limitations}
</p>
</div>
)}
{tool.aiAgent.examples && tool.aiAgent.examples.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-foreground mb-2">Examples</h4>
<ul className="list-disc list-inside space-y-1">
{tool.aiAgent.examples.map((example) => (
<li key={example} className="text-sm text-foreground-secondary">
{example}
</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
)}
{/* Parameters / Input Schema */}
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Parameters</CardTitle>
<CardDescription>Available configuration options</CardDescription>
</div>
<div className="flex items-center gap-2">
{tool.schemaSource === 'extracted' ? (
<Badge variant="default" size="sm">
Auto-extracted
</Badge>
) : tool.schemaSource === 'author' ? (
<Badge variant="secondary" size="sm">
Author-provided
</Badge>
) : (
<Badge variant="outline" size="sm">
No schema
</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent>
{tool.parameters && tool.parameters.length > 0 ? (
<div className="space-y-4">
{tool.parameters.map((param) => (
<div key={param.name} className="border-b border-border pb-4 last:border-0">
<div className="flex items-start justify-between mb-2">
<code className="text-sm font-mono text-foreground">{param.name}</code>
{param.required ? (
<Badge variant="error" size="sm">
Required
</Badge>
) : (
<Badge variant="outline" size="sm">
Optional
</Badge>
)}
</div>
<div className="text-sm text-foreground-secondary mb-1">
<span className="font-semibold">Type: </span>
<code className="font-mono">{param.type}</code>
</div>
<p className="text-sm text-foreground-secondary">{param.description}</p>
{param.default !== undefined && (
<div className="text-sm text-foreground-tertiary mt-1">
<span className="font-semibold">Default: </span>
<code className="font-mono">{JSON.stringify(param.default)}</code>
</div>
)}
</div>
))}
{tool.schemaExtractedAt && (
<p className="text-xs text-foreground-tertiary">
Schema extracted: {new Date(tool.schemaExtractedAt).toLocaleString()}
</p>
)}
</div>
) : (
<div className="text-center py-6">
<p className="text-sm text-foreground-secondary mb-4">
No schema available for this tool.
</p>
<Button
variant="outline"
size="sm"
onClick={extractSchema}
disabled={extractSchemaLoading}
>
{extractSchemaLoading ? (
<>
<Spinner size="sm" className="mr-2" />
Extracting...
</>
) : (
'Extract Schema'
)}
</Button>
</div>
)}
{tool.schemaSource !== 'extracted' &&
tool.parameters &&
tool.parameters.length > 0 && (
<div className="mt-4 pt-4 border-t border-border">
<Button
variant="outline"
size="sm"
onClick={extractSchema}
disabled={extractSchemaLoading}
>
{extractSchemaLoading ? (
<>
<Spinner size="sm" className="mr-2" />
Extracting...
</>
) : (
'Re-extract Schema'
)}
</Button>
<p className="text-xs text-foreground-tertiary mt-2">
Try to auto-extract schema from the package
</p>
</div>
)}
</CardContent>
</Card>
{/* README */}
{pkg.npmReadme && (
<Card>
<CardHeader>
<CardTitle>README</CardTitle>
</CardHeader>
<CardContent>
<Markdown content={pkg.npmReadme} />
</CardContent>
</Card>
)}
</div>
{/* Right column - Sidebar */}
<div className="space-y-4 md:space-y-6">
{/* Stats */}
<Card>
<CardHeader>
<CardTitle>Statistics</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm text-foreground-secondary mb-1">Downloads/month</p>
<p className="text-2xl font-bold text-foreground">
{pkg.npmDownloadsLastMonth?.toLocaleString() || '0'}
</p>
</div>
{pkg.githubStars != null && (
<div>
<p className="text-sm text-foreground-secondary mb-1">GitHub Stars</p>
<p className="text-2xl font-bold text-foreground">
{pkg.githubStars.toLocaleString()}
</p>
</div>
)}
<div>
<p className="text-sm text-foreground-secondary mb-2">Quality Score</p>
<ProgressBar
value={(tool.qualityScore ? Number.parseFloat(tool.qualityScore) : 0) * 100}
variant={
tool.qualityScore && Number.parseFloat(tool.qualityScore) >= 0.7
? 'success'
: tool.qualityScore && Number.parseFloat(tool.qualityScore) >= 0.5
? 'primary'
: 'warning'
}
size="md"
showLabel={true}
/>
</div>
</CardContent>
</Card>
{/* NPM Keywords */}
{pkg.npmKeywords && pkg.npmKeywords.length > 0 && (
<Card>
<CardHeader>
<CardTitle>NPM Keywords</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{pkg.npmKeywords.map((keyword) => (
<Badge key={keyword} variant="outline" size="sm">
{keyword}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Maintainers */}
{pkg.npmMaintainers && pkg.npmMaintainers.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Maintainers</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{pkg.npmMaintainers.map((maintainer) => (
<div key={maintainer.name} className="text-sm">
<span className="text-foreground font-medium">{maintainer.name}</span>
{maintainer.email && (
<span className="text-foreground-tertiary ml-2">
({maintainer.email})
</span>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Links */}
<Card>
<CardHeader>
<CardTitle>Links</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<a
href={`https://www.npmjs.com/package/${pkg.npmPackageName}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-foreground-secondary hover:text-foreground"
>
<Icon icon="externalLink" size="sm" />
<span>View on NPM</span>
</a>
{pkg.npmHomepage && (
<a
href={pkg.npmHomepage}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-foreground-secondary hover:text-foreground"
>
<Icon icon="externalLink" size="sm" />
<span>Homepage</span>
</a>
)}
{pkg.npmRepository &&
typeof pkg.npmRepository === 'object' &&
pkg.npmRepository.url && (
<a
href={pkg.npmRepository.url.replace(/^git\+/, '').replace(/\.git$/, '')}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-foreground-secondary hover:text-foreground"
>
<Icon icon="github" size="sm" />
<span>Repository</span>
</a>
)}
</CardContent>
</Card>
{/* Frameworks */}
{pkg.frameworks && pkg.frameworks.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Frameworks</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{pkg.frameworks.map((framework) => (
<Badge key={framework} variant="secondary" size="sm">
{framework}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
</div>
</div>
</Container>
</div>
);
}
/**
* Generate metadata for the tool page
*/
export async function generateMetadata({ params }: ToolDetailPageProps): Promise<Metadata> {
const { slug } = await params;
const tool = await getTool(slug);
if (!tool) {
return {
title: 'Tool Not Found',
description: 'The requested tool could not be found.',
};
}
const { packageName, exportName } = parseSlug(slug);
const ogPath = exportName
? `/api/og/tool/${encodeURIComponent(packageName)}/${encodeURIComponent(exportName)}`
: `/api/og/tool/${encodeURIComponent(packageName)}`;
return {
title: `${tool.name} | TPMJS`,
description: tool.description || `${tool.name} - AI tool from ${tool.package.npmPackageName}`,
keywords: [tool.package.category, 'AI', 'npm', 'tool', tool.name, tool.package.npmPackageName],
openGraph: {
title: tool.name,
description: tool.description,
type: 'website',
images: [
{
url: ogPath,
width: 1200,
height: 630,
alt: `${tool.name} - TPMJS Tool`,
},
],
},
twitter: {
card: 'summary_large_image',
title: tool.name,
description: tool.description,
images: [ogPath],
},
};
}
/**
* Tool detail page - server component
*/
export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
const { slug } = await params;
const tool = await getTool(slug);
if (!tool) {
notFound();
}
return <ToolDetailClient tool={tool} slug={slug.join('/')} />;
}

View file

@ -0,0 +1,21 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Tool Search | TPMJS',
description:
'Search and discover AI tools from the TPMJS registry. Browse by category, sort by downloads or recency.',
openGraph: {
title: 'Tool Search | TPMJS',
description:
'Search and discover AI tools from the TPMJS registry. Browse by category, sort by downloads or recency.',
images: [{ url: '/api/og/search', width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
images: ['/api/og/search'],
},
};
export default function ToolSearchLayout({ children }: { children: React.ReactNode }) {
return children;
}

View file

@ -0,0 +1,67 @@
/**
* Cache OG images in Vercel Blob storage
*/
import { head, put } from '@vercel/blob';
import { normalizePath } from './content-extractor';
/**
* Cache TTL in seconds (30 days)
*/
const CACHE_TTL_SECONDS = 30 * 24 * 60 * 60;
/**
* Build cache key from path
*/
export function buildCacheKey(path: string): string {
const normalized = normalizePath(path);
return `og/${normalized || 'home'}.png`;
}
/**
* Check if a cached image exists and is still valid
* @returns The blob URL if cached and valid, null otherwise
*/
export async function getCachedImage(path: string): Promise<string | null> {
const key = buildCacheKey(path);
try {
const blob = await head(key);
if (!blob) {
return null;
}
// Check if cache is still valid (within TTL)
const uploadedAt = new Date(blob.uploadedAt);
const now = new Date();
const ageSeconds = (now.getTime() - uploadedAt.getTime()) / 1000;
if (ageSeconds > CACHE_TTL_SECONDS) {
// Cache expired
return null;
}
return blob.url;
} catch (error) {
// Blob doesn't exist or error accessing it
return null;
}
}
/**
* Cache an image in Vercel Blob storage
* @returns The blob URL
*/
export async function cacheImage(path: string, imageBuffer: Buffer): Promise<string> {
const key = buildCacheKey(path);
const { url } = await put(key, imageBuffer, {
access: 'public',
contentType: 'image/png',
cacheControlMaxAge: CACHE_TTL_SECONDS,
addRandomSuffix: false, // Use exact key for predictable caching
});
return url;
}

View file

@ -0,0 +1,228 @@
/**
* Extract page content for OG image generation
*/
import type { PageContent } from './types';
/**
* Static content map for known pages
*/
const STATIC_PAGES: Record<string, PageContent> = {
home: {
pageType: 'home',
title: 'TPMJS',
description: 'Tool Package Manager for AI Agents',
keywords: ['AI', 'tools', 'npm', 'registry', 'LLM', 'agents'],
},
docs: {
pageType: 'docs',
title: 'Documentation',
description: 'Complete guide to using TPMJS',
keywords: ['documentation', 'guide', 'tutorial', 'getting started'],
},
publish: {
pageType: 'publish',
title: 'Publish Your Tool',
description: 'Share your AI tool with the community',
keywords: ['publish', 'create', 'npm', 'contribute'],
},
stats: {
pageType: 'stats',
title: 'Registry Statistics',
description: 'Real-time metrics for TPMJS registry',
keywords: ['statistics', 'metrics', 'analytics', 'dashboard'],
},
'tool-search': {
pageType: 'search',
title: 'Tool Search',
description: 'Search and discover AI tools',
keywords: ['search', 'browse', 'discover', 'find'],
},
faq: {
pageType: 'faq',
title: 'Frequently Asked Questions',
description: 'Common questions about TPMJS',
keywords: ['faq', 'questions', 'help', 'support'],
},
playground: {
pageType: 'playground',
title: 'Playground',
description: 'Test and experiment with AI tools',
keywords: ['playground', 'test', 'experiment', 'demo'],
},
spec: {
pageType: 'spec',
title: 'Specification',
description: 'TPMJS tool specification and schema',
keywords: ['spec', 'specification', 'schema', 'format'],
},
sdk: {
pageType: 'sdk',
title: 'SDK',
description: 'TPMJS SDK for integrating tools',
keywords: ['sdk', 'integration', 'api', 'library'],
},
changelog: {
pageType: 'changelog',
title: 'Changelog',
description: 'TPMJS release history and updates',
keywords: ['changelog', 'releases', 'updates', 'versions'],
},
'how-it-works': {
pageType: 'how-it-works',
title: 'How It Works',
description: 'Learn how TPMJS works under the hood',
keywords: ['how', 'works', 'architecture', 'process'],
},
terms: {
pageType: 'terms',
title: 'Terms of Service',
description: 'TPMJS terms and conditions',
keywords: ['terms', 'service', 'legal', 'agreement'],
},
privacy: {
pageType: 'privacy',
title: 'Privacy Policy',
description: 'TPMJS privacy policy',
keywords: ['privacy', 'policy', 'data', 'protection'],
},
};
/**
* Normalize path for cache key and lookup
*/
export function normalizePath(path: string): string {
return path
.toLowerCase()
.replace(/^\/+|\/+$/g, '') // Trim slashes
.replace(/\//g, '-') // Replace slashes with dashes
.replace(/[^a-z0-9-]/g, '-') // Replace special chars
.replace(/-+/g, '-') // Collapse multiple dashes
.replace(/^-|-$/g, ''); // Trim leading/trailing dashes
}
/**
* Parse tool path to extract package name and export name
*/
function parseToolPath(path: string): { packageName: string; exportName?: string } {
// Remove /tool/ prefix
const segments = path.replace(/^\/tool\//, '').split('/');
let packageName: string;
let exportName: string | undefined;
if (segments[0]?.startsWith('@')) {
// Scoped package: @scope/package/export
packageName = segments.slice(0, 2).join('/');
exportName = segments[2];
} else {
// Unscoped: package/export
packageName = segments[0] || '';
exportName = segments[1];
}
return { packageName, exportName };
}
/**
* Fetch tool data from internal API
*/
async function fetchToolContent(path: string): Promise<PageContent> {
const { packageName, exportName } = parseToolPath(path);
// Build API URL
const baseUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
const apiPath = exportName
? `/api/tools/${encodeURIComponent(packageName)}/${encodeURIComponent(exportName)}`
: `/api/tools/${encodeURIComponent(packageName)}`;
try {
const response = await fetch(`${baseUrl}${apiPath}`, {
cache: 'no-store',
});
if (!response.ok) {
throw new Error(`API returned ${response.status}`);
}
const { data } = await response.json();
const tool = data.tools ? data.tools[0] : data;
if (!tool) {
throw new Error('Tool not found');
}
return {
pageType: 'tool',
title: tool.name || exportName || packageName,
description: tool.description || `AI tool from ${packageName}`,
keywords: [tool.package?.category || 'tool', 'AI', 'npm', packageName],
tool: {
name: tool.name || exportName || 'Tool',
packageName: tool.package?.npmPackageName || packageName,
category: tool.package?.category || 'other',
description: tool.description || '',
downloads: tool.package?.npmDownloadsLastMonth,
qualityScore: tool.qualityScore ? Number(tool.qualityScore) : undefined,
},
};
} catch (error) {
console.error('Failed to fetch tool content:', error);
// Return basic content on failure
return {
pageType: 'tool',
title: exportName || packageName,
description: `AI tool from ${packageName}`,
keywords: ['tool', 'AI', 'npm'],
tool: {
name: exportName || 'Tool',
packageName,
category: 'other',
description: '',
},
};
}
}
/**
* Extract page content based on path
*/
export async function extractPageContent(path: string): Promise<PageContent> {
// Normalize the path
const normalizedPath = path.replace(/^\/+|\/+$/g, '');
// Check static pages first
if (normalizedPath === '' || normalizedPath === 'home') {
const homePage = STATIC_PAGES.home;
if (homePage) return homePage;
}
// Direct match in static pages
const staticPage = STATIC_PAGES[normalizedPath];
if (staticPage) {
return staticPage;
}
// Handle tool/tool-search specifically
if (normalizedPath === 'tool/tool-search') {
const searchPage = STATIC_PAGES['tool-search'];
if (searchPage) return searchPage;
}
// Handle tool detail pages
if (normalizedPath.startsWith('tool/')) {
return fetchToolContent(`/${normalizedPath}`);
}
// Default fallback
return {
pageType: 'static',
title: 'TPMJS',
description: 'Tool Package Manager for AI Agents',
keywords: ['AI', 'tools'],
};
}

View file

@ -0,0 +1,56 @@
/**
* Generate OG images using OpenAI gpt-image-1-mini
*/
import OpenAI from 'openai';
// Lazy initialization to avoid build-time errors
let openai: OpenAI | null = null;
function getOpenAIClient(): OpenAI {
if (!openai) {
openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
}
return openai;
}
/**
* Generate an OG image using OpenAI's image generation API
* @param prompt - The prompt describing the image to generate
* @returns Buffer containing the generated PNG image
*/
export async function generateOGImage(prompt: string): Promise<Buffer> {
try {
const client = getOpenAIClient();
const response = await client.images.generate({
model: 'gpt-image-1',
prompt,
n: 1,
size: '1536x1024', // Closest aspect ratio to 1200x630, will serve as-is
quality: 'low', // Use low for faster generation and lower cost
});
if (!response.data || response.data.length === 0) {
throw new Error('No data returned from OpenAI');
}
const imageUrl = response.data[0]?.url;
if (!imageUrl) {
throw new Error('No image URL returned from OpenAI');
}
// Fetch the image
const imageResponse = await fetch(imageUrl);
if (!imageResponse.ok) {
throw new Error(`Failed to fetch generated image: ${imageResponse.status}`);
}
const arrayBuffer = await imageResponse.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch (error) {
console.error('OpenAI image generation failed:', error);
throw error;
}
}

View file

@ -0,0 +1,12 @@
/**
* OG Image Generation Library
*
* Generates unique OpenGraph images for each page using OpenAI,
* cached in Vercel Blob storage for 30 days.
*/
export * from './types';
export { extractPageContent, normalizePath } from './content-extractor';
export { buildOGPrompt } from './prompt-builder';
export { generateOGImage } from './image-generator';
export { getCachedImage, cacheImage, buildCacheKey } from './cache';

View file

@ -0,0 +1,236 @@
/**
* Build prompts for OpenAI image generation
*/
import type { PageContent } from './types';
/**
* Category-specific visual hints for tool pages
*/
const CATEGORY_VISUALS: Record<string, string> = {
'text-analysis':
'flowing text streams, linguistic wave patterns, word-like abstract shapes floating in space',
'code-generation':
'code bracket symbols, terminal-like geometric shapes, syntax highlighting color bands',
'data-processing':
'data pipeline flows, transformation arrows, structured grid patterns with glowing nodes',
'image-generation':
'abstract brush strokes, creative color gradients, artistic shape compositions',
'web-scraping': 'interconnected web-like node patterns, spider web motifs, document flow shapes',
search:
'magnifying glass energy radiating outward, discovery beam patterns, concentric search rings',
integration: 'puzzle pieces connecting, API endpoint symbols, synchronization arrows',
database: 'cylindrical storage shapes, connected data nodes, structured table patterns',
ai: 'neural network node patterns, brain-like abstract shapes, intelligence flowing streams',
automation: 'gear mechanisms, workflow arrows, robotic precision patterns',
communication: 'message bubble abstractions, signal wave patterns, connection lines',
file: 'folder shapes, document stacks, organized file grid patterns',
weather: 'cloud formations, sun ray patterns, atmospheric gradients',
location: 'map pin abstractions, geographic grid patterns, compass-like radial designs',
time: 'clock face abstractions, timeline flow patterns, temporal wave forms',
math: 'geometric shapes, equation-like line patterns, mathematical precision grids',
other: 'abstract tech patterns, geometric shapes, neural connection lines',
};
/**
* Base style prompt for all OG images
*/
const BASE_STYLE = `Create a 1200x630 pixel Open Graph image.
STRICT STYLE REQUIREMENTS:
- Dark gradient background from #0a0a0a to #1a1a1a
- Primary accent color: cyan (#00d4ff)
- Secondary accent color: purple (#8b5cf6)
- Modern, minimalist, professional tech aesthetic
- Abstract geometric shapes or flowing patterns
- Subtle depth and dimensionality with glow effects
- Clean composition with balanced visual weight
CRITICAL: Generate ONLY abstract visuals. Do NOT include any text, letters, numbers, words, or readable characters in the image.`;
/**
* Build page-specific context for the prompt
*/
function buildPageContext(content: PageContent): string {
switch (content.pageType) {
case 'tool': {
const category = content.tool?.category || 'other';
const visualHint = CATEGORY_VISUALS[category] || CATEGORY_VISUALS.other;
return `
CONTEXT: Developer tool named "${content.tool?.name}"
CATEGORY: ${category}
PURPOSE: ${content.tool?.description?.slice(0, 150) || 'AI-powered developer tool'}
VISUAL ELEMENTS TO INCLUDE:
${visualHint}
MOOD: Powerful, trustworthy, professional, cutting-edge developer tool`;
}
case 'home':
return `
CONTEXT: Homepage for TPMJS - a registry of AI/LLM tools for developers
VISUAL ELEMENTS TO INCLUDE:
- Interconnected glowing nodes forming a network pattern
- Flowing data streams with particle effects
- Neural network-inspired radiating patterns
- Central focal point with emanating connections
MOOD: Cutting-edge, innovative, comprehensive, developer-focused`;
case 'docs':
return `
CONTEXT: Documentation hub for a developer tool registry
VISUAL ELEMENTS TO INCLUDE:
- Layered page-like shapes stacked in 3D space
- Knowledge structure visualization with connected nodes
- Organized grid patterns suggesting structured information
- Glowing connection lines between concept nodes
MOOD: Organized, comprehensive, accessible, helpful`;
case 'stats':
return `
CONTEXT: Statistics dashboard showing real-time registry metrics
VISUAL ELEMENTS TO INCLUDE:
- Abstract bar chart shapes rising upward
- Trend lines with glowing data points
- Circular gauge patterns
- Growth-oriented upward visual flow
MOOD: Data-driven, analytical, transparent, growth-focused`;
case 'search':
return `
CONTEXT: Search interface for discovering AI tools
VISUAL ELEMENTS TO INCLUDE:
- Magnifying glass shape radiating search beams
- Grid of abstract tool icons
- Discovery-oriented radiating patterns
- Exploratory path visualizations
MOOD: Discovery, exploration, finding solutions`;
case 'publish':
return `
CONTEXT: Guide for publishing tools to the registry
VISUAL ELEMENTS TO INCLUDE:
- Package box shapes with upload arrows
- Publication flow visualization
- Building block patterns coming together
- Contribution-oriented upward movement
MOOD: Creative, contributive, community-building`;
case 'faq':
return `
CONTEXT: FAQ page answering common questions
VISUAL ELEMENTS TO INCLUDE:
- Question mark abstract shapes with answer connections
- Branching knowledge tree patterns
- Illumination-like glowing effects
- Organized information clusters
MOOD: Helpful, clarifying, supportive`;
case 'playground':
return `
CONTEXT: Interactive playground for testing AI tools
VISUAL ELEMENTS TO INCLUDE:
- Interactive particle systems
- Experimental visualization patterns
- Dynamic energy flows
- Testing-oriented active elements
MOOD: Experimental, interactive, playful yet professional`;
case 'spec':
return `
CONTEXT: Technical specification for TPMJS tools
VISUAL ELEMENTS TO INCLUDE:
- Schema-like structured patterns
- Technical blueprint aesthetic
- Precise geometric arrangements
- Specification document abstractions
MOOD: Technical, precise, authoritative`;
case 'sdk':
return `
CONTEXT: SDK for integrating TPMJS tools
VISUAL ELEMENTS TO INCLUDE:
- Integration connection patterns
- API endpoint visualizations
- Library-like stacked components
- Developer toolkit arrangements
MOOD: Technical, integrated, powerful`;
case 'changelog':
return `
CONTEXT: Release history and updates
VISUAL ELEMENTS TO INCLUDE:
- Timeline flow patterns
- Version milestone markers
- Evolution and progression visualization
- Update wave patterns
MOOD: Progress, evolution, continuous improvement`;
case 'how-it-works':
return `
CONTEXT: Explanation of TPMJS architecture
VISUAL ELEMENTS TO INCLUDE:
- Process flow visualization
- Architecture diagram abstractions
- Step-by-step progression patterns
- System component connections
MOOD: Educational, clear, systematic`;
case 'terms':
case 'privacy':
return `
CONTEXT: Legal document page
VISUAL ELEMENTS TO INCLUDE:
- Shield and protection symbols abstracted
- Secure lock patterns
- Trust-oriented design elements
- Professional document aesthetic
MOOD: Trustworthy, secure, professional`;
default:
return `
CONTEXT: TPMJS - AI Tool Registry for developers
VISUAL ELEMENTS TO INCLUDE:
- Abstract tech patterns
- Neural network connections
- Modern developer aesthetic
- Professional geometric shapes
MOOD: Professional, innovative, reliable`;
}
}
/**
* Build the complete prompt for OpenAI image generation
*/
export function buildOGPrompt(content: PageContent): string {
const pageContext = buildPageContext(content);
return `${BASE_STYLE}\n${pageContext}`;
}

View file

@ -0,0 +1,37 @@
/**
* Types for OG image generation
*/
export type PageType =
| 'home'
| 'tool'
| 'docs'
| 'stats'
| 'search'
| 'publish'
| 'faq'
| 'playground'
| 'spec'
| 'sdk'
| 'changelog'
| 'how-it-works'
| 'terms'
| 'privacy'
| 'static';
export interface ToolData {
name: string;
packageName: string;
category: string;
description: string;
downloads?: number;
qualityScore?: number;
}
export interface PageContent {
pageType: PageType;
title: string;
description: string;
keywords: string[];
tool?: ToolData;
}

58
pnpm-lock.yaml generated
View file

@ -192,6 +192,9 @@ importers:
'@vercel/analytics':
specifier: ^1.6.1
version: 1.6.1(next@16.0.8(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)
'@vercel/blob':
specifier: ^2.0.0
version: 2.0.0
ai:
specifier: 6.0.3
version: 6.0.3(zod@4.1.13)
@ -1591,6 +1594,10 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@fastify/busboy@2.1.1':
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
engines: {node: '>=14'}
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@ -2818,6 +2825,10 @@ packages:
vue-router:
optional: true
'@vercel/blob@2.0.0':
resolution: {integrity: sha512-oAj7Pdy83YKSwIaMFoM7zFeLYWRc+qUpW3PiDSblxQMnGFb43qs4bmfq7dr/+JIfwhs6PTwe1o2YBwKhyjWxXw==}
engines: {node: '>=20.0.0'}
'@vercel/oidc@3.0.5':
resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==}
engines: {node: '>= 20'}
@ -3019,6 +3030,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
async-retry@1.3.3:
resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@ -4309,6 +4323,10 @@ packages:
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
engines: {node: '>= 0.4'}
is-buffer@2.0.5:
resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==}
engines: {node: '>=4'}
is-bun-module@2.0.0:
resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
@ -5544,6 +5562,10 @@ packages:
resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==}
hasBin: true
retry@0.13.1:
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
engines: {node: '>= 4'}
rettime@0.7.0:
resolution: {integrity: sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==}
@ -6091,6 +6113,10 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici@5.29.0:
resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==}
engines: {node: '>=14.0'}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@ -7157,6 +7183,8 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
'@fastify/busboy@2.1.1': {}
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
@ -8387,6 +8415,14 @@ snapshots:
next: 16.0.8(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0
'@vercel/blob@2.0.0':
dependencies:
async-retry: 1.3.3
is-buffer: 2.0.5
is-node-process: 1.2.0
throttleit: 2.1.0
undici: 5.29.0
'@vercel/oidc@3.0.5': {}
'@vitest/expect@2.0.5':
@ -8655,6 +8691,10 @@ snapshots:
async-function@1.0.0: {}
async-retry@1.3.3:
dependencies:
retry: 0.13.1
asynckit@0.4.0: {}
autoprefixer@10.4.22(postcss@8.5.6):
@ -9503,7 +9543,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-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-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))
@ -9546,7 +9586,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-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)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
@ -9586,13 +9626,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@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-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)):
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-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))
transitivePeerDependencies:
- supports-color
@ -9646,7 +9686,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@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-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))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@ -10492,6 +10532,8 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
is-buffer@2.0.5: {}
is-bun-module@2.0.0:
dependencies:
semver: 7.7.3
@ -12008,6 +12050,8 @@ snapshots:
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
retry@0.13.1: {}
rettime@0.7.0: {}
reusify@1.1.0: {}
@ -12737,6 +12781,10 @@ snapshots:
undici-types@6.21.0: {}
undici@5.29.0:
dependencies:
'@fastify/busboy': 2.1.1
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3