**Database Schema:** - Add npmReadme, npmKeywords, npmAuthor, npmMaintainers fields to Tool model **NPM Client:** - Add fetchLatestPackageWithMetadata() function to fetch README and top-level metadata - Export new PackageVersionWithReadme type **Sync Workers:** - Update keyword and changes sync to fetch and store README content - Store author, maintainers, and keywords from package.json **UI Components:** - Create Markdown component using react-markdown with GitHub Flavored Markdown - Add rehype-sanitize for security and remark-gfm for tables/strikethrough support **Tool Detail Page:** - Convert from createElement to JSX for better maintainability - Display README in a dedicated card with proper markdown rendering - Show NPM keywords, author, and maintainers in sidebar - Add ThemeToggle to header - Improve layout with better spacing and organization This brings the tool detail pages much closer to NPM's package pages, providing users with comprehensive information about each tool including the full README documentation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
210 lines
6.4 KiB
TypeScript
210 lines
6.4 KiB
TypeScript
import { prisma } from '@tpmjs/db';
|
|
import { fetchLatestPackageWithMetadata, searchByKeyword } from '@tpmjs/npm-client';
|
|
import { validateTpmjsField } from '@tpmjs/types/tpmjs';
|
|
import { type NextRequest, NextResponse } from 'next/server';
|
|
import { env } from '~/env';
|
|
|
|
export const runtime = 'nodejs';
|
|
export const dynamic = 'force-dynamic';
|
|
export const maxDuration = 300; // 5 minutes max for cron jobs
|
|
|
|
/**
|
|
* POST /api/sync/keyword
|
|
* Sync tools by searching NPM for 'tpmjs-tool' keyword
|
|
*
|
|
* This endpoint is called by Vercel Cron (every 15 minutes)
|
|
* Requires Authorization: Bearer <CRON_SECRET>
|
|
*/
|
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex but straightforward CRUD operation
|
|
export async function POST(request: NextRequest) {
|
|
// Verify cron secret for security
|
|
const authHeader = request.headers.get('authorization');
|
|
const token = authHeader?.replace('Bearer ', '');
|
|
|
|
if (env.CRON_SECRET && token !== env.CRON_SECRET) {
|
|
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
let processed = 0;
|
|
let skipped = 0;
|
|
let errors = 0;
|
|
const errorMessages: string[] = [];
|
|
|
|
try {
|
|
// Search for packages with 'tpmjs-tool' keyword
|
|
const searchResults = await searchByKeyword({
|
|
keyword: 'tpmjs-tool',
|
|
size: 250, // Get up to 250 packages per sync
|
|
});
|
|
|
|
// Process each package
|
|
for (const result of searchResults) {
|
|
try {
|
|
// Fetch full package metadata with README
|
|
const pkg = await fetchLatestPackageWithMetadata(result.package.name);
|
|
|
|
// Skip if package not found
|
|
if (!pkg) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
// Check if package has tpmjs field
|
|
if (!pkg.tpmjs) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
// Validate tpmjs field
|
|
const validation = validateTpmjsField(pkg.tpmjs);
|
|
if (!validation.valid || !validation.data) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
// Extract repository URL and GitHub stars
|
|
const githubStars: number | null = null;
|
|
|
|
// Cast to TpmjsRich to access optional fields (they'll be undefined if not present)
|
|
const tpmjsData = validation.data as {
|
|
category: string;
|
|
description: string;
|
|
example: string;
|
|
parameters?: unknown;
|
|
returns?: unknown;
|
|
authentication?: unknown;
|
|
pricing?: unknown;
|
|
frameworks?: string[];
|
|
links?: unknown;
|
|
tags?: string[];
|
|
status?: string;
|
|
aiAgent?: unknown;
|
|
};
|
|
|
|
// Prepare data for upsert
|
|
const toolData = {
|
|
npmVersion: pkg.version,
|
|
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
|
|
npmDescription: pkg.description ?? undefined,
|
|
npmRepository: pkg.repository ?? undefined,
|
|
npmHomepage: pkg.homepage ?? undefined,
|
|
npmLicense: pkg.license ?? undefined,
|
|
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
|
|
npmReadme: pkg.readme ?? undefined,
|
|
npmAuthor: pkg.author ?? undefined,
|
|
npmMaintainers: pkg.maintainers ?? undefined,
|
|
category: tpmjsData.category,
|
|
description: tpmjsData.description,
|
|
example: tpmjsData.example,
|
|
parameters: tpmjsData.parameters ?? undefined,
|
|
returns: tpmjsData.returns ?? undefined,
|
|
authentication: tpmjsData.authentication ?? undefined,
|
|
pricing: tpmjsData.pricing ?? undefined,
|
|
frameworks: tpmjsData.frameworks || [],
|
|
links: tpmjsData.links ?? undefined,
|
|
tags: tpmjsData.tags || [],
|
|
status: tpmjsData.status ?? undefined,
|
|
aiAgent: tpmjsData.aiAgent ?? undefined,
|
|
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
|
|
tier: validation.tier || 'minimal',
|
|
};
|
|
|
|
// Upsert tool to database
|
|
await prisma.tool.upsert({
|
|
where: { npmPackageName: pkg.name },
|
|
create: {
|
|
npmPackageName: pkg.name,
|
|
...toolData,
|
|
discoveryMethod: 'keyword',
|
|
npmDownloadsLastMonth: 0, // Will be updated by metrics sync
|
|
githubStars: githubStars,
|
|
qualityScore: null, // Will be calculated by metrics sync
|
|
},
|
|
update: toolData,
|
|
});
|
|
|
|
processed++;
|
|
} catch (error) {
|
|
errors++;
|
|
const errorMsg = `Failed to process ${result.package.name}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
|
errorMessages.push(errorMsg);
|
|
console.error(errorMsg);
|
|
}
|
|
}
|
|
|
|
// Update checkpoint with last run timestamp
|
|
await prisma.syncCheckpoint.upsert({
|
|
where: { source: 'keyword-search' },
|
|
create: {
|
|
source: 'keyword-search',
|
|
checkpoint: {
|
|
lastRun: new Date().toISOString(),
|
|
packagesFound: searchResults.length,
|
|
},
|
|
},
|
|
update: {
|
|
checkpoint: {
|
|
lastRun: new Date().toISOString(),
|
|
packagesFound: searchResults.length,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Log sync operation
|
|
await prisma.syncLog.create({
|
|
data: {
|
|
source: 'keyword-search',
|
|
status: errors > 0 ? 'partial' : 'success',
|
|
processed,
|
|
skipped,
|
|
errors,
|
|
message:
|
|
errors > 0
|
|
? `Processed with errors: ${errorMessages.slice(0, 3).join('; ')}`
|
|
: `Successfully processed ${processed} packages`,
|
|
metadata: {
|
|
durationMs: Date.now() - startTime,
|
|
packagesFound: searchResults.length,
|
|
},
|
|
},
|
|
});
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
data: {
|
|
processed,
|
|
skipped,
|
|
errors,
|
|
packagesFound: searchResults.length,
|
|
durationMs: Date.now() - startTime,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error('Keyword search sync failed:', error);
|
|
|
|
// Log failed sync
|
|
await prisma.syncLog.create({
|
|
data: {
|
|
source: 'keyword-search',
|
|
status: 'error',
|
|
processed,
|
|
skipped,
|
|
errors: errors + 1,
|
|
message: error instanceof Error ? error.message : 'Unknown error',
|
|
metadata: {
|
|
durationMs: Date.now() - startTime,
|
|
},
|
|
},
|
|
});
|
|
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: 'Sync failed',
|
|
message: error instanceof Error ? error.message : 'Unknown error',
|
|
},
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|