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:
parent
597fc2abf1
commit
effd242b07
24 changed files with 1724 additions and 740 deletions
|
|
@ -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",
|
||||
|
|
|
|||
94
apps/web/src/app/api/og/[...path]/route.ts
Normal file
94
apps/web/src/app/api/og/[...path]/route.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
19
apps/web/src/app/playground/layout.tsx
Normal file
19
apps/web/src/app/playground/layout.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
682
apps/web/src/app/tool/[...slug]/ToolDetailClient.tsx
Normal file
682
apps/web/src/app/tool/[...slug]/ToolDetailClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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('/')} />;
|
||||
}
|
||||
|
|
|
|||
21
apps/web/src/app/tool/tool-search/layout.tsx
Normal file
21
apps/web/src/app/tool/tool-search/layout.tsx
Normal 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;
|
||||
}
|
||||
67
apps/web/src/lib/og/cache.ts
Normal file
67
apps/web/src/lib/og/cache.ts
Normal 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;
|
||||
}
|
||||
228
apps/web/src/lib/og/content-extractor.ts
Normal file
228
apps/web/src/lib/og/content-extractor.ts
Normal 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'],
|
||||
};
|
||||
}
|
||||
56
apps/web/src/lib/og/image-generator.ts
Normal file
56
apps/web/src/lib/og/image-generator.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
12
apps/web/src/lib/og/index.ts
Normal file
12
apps/web/src/lib/og/index.ts
Normal 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';
|
||||
236
apps/web/src/lib/og/prompt-builder.ts
Normal file
236
apps/web/src/lib/og/prompt-builder.ts
Normal 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}`;
|
||||
}
|
||||
37
apps/web/src/lib/og/types.ts
Normal file
37
apps/web/src/lib/og/types.ts
Normal 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
58
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue