tpmjs/apps/web/src/app/api/sync/keyword/route.ts
Ajax Davis 32499bbc68 feat: add README rendering and enhanced package metadata display
**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>
2025-11-29 22:41:50 +10:00

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