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>
This commit is contained in:
Ajax Davis 2025-11-29 22:41:50 +10:00
parent e38e627a07
commit f9ba1c094e
9 changed files with 1583 additions and 560 deletions

View file

@ -21,6 +21,10 @@
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"zod": "^3.24.1"
},
"devDependencies": {

View file

@ -1,5 +1,5 @@
import { prisma } from '@tpmjs/db';
import { fetchChanges, fetchLatestPackageVersion } from '@tpmjs/npm-client';
import { fetchChanges, fetchLatestPackageWithMetadata } from '@tpmjs/npm-client';
import { validateTpmjsField } from '@tpmjs/types/tpmjs';
import { type NextRequest, NextResponse } from 'next/server';
import { env } from '~/env';
@ -51,8 +51,8 @@ export async function POST(request: NextRequest) {
// Process each change
for (const change of changesResult.results) {
try {
// Fetch full package metadata
const pkg = await fetchLatestPackageVersion(change.id);
// Fetch full package metadata with README
const pkg = await fetchLatestPackageWithMetadata(change.id);
// Skip if package not found
if (!pkg) {
@ -100,6 +100,10 @@ export async function POST(request: NextRequest) {
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,

View file

@ -1,5 +1,5 @@
import { prisma } from '@tpmjs/db';
import { fetchLatestPackageVersion, searchByKeyword } from '@tpmjs/npm-client';
import { fetchLatestPackageWithMetadata, searchByKeyword } from '@tpmjs/npm-client';
import { validateTpmjsField } from '@tpmjs/types/tpmjs';
import { type NextRequest, NextResponse } from 'next/server';
import { env } from '~/env';
@ -41,8 +41,8 @@ export async function POST(request: NextRequest) {
// Process each package
for (const result of searchResults) {
try {
// Fetch full package metadata
const pkg = await fetchLatestPackageVersion(result.package.name);
// Fetch full package metadata with README
const pkg = await fetchLatestPackageWithMetadata(result.package.name);
// Skip if package not found
if (!pkg) {
@ -90,6 +90,10 @@ export async function POST(request: NextRequest) {
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,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,85 @@
'use client';
import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import remarkGfm from 'remark-gfm';
interface MarkdownProps {
content: string;
className?: string;
}
/**
* Markdown renderer component
* Safely renders markdown content with GitHub Flavored Markdown support
*/
export function Markdown({ content, className = '' }: MarkdownProps): React.ReactElement {
return (
<div
className={`prose prose-invert max-w-none
prose-headings:text-foreground prose-headings:font-bold
prose-h1:text-3xl prose-h1:mb-4 prose-h1:mt-6
prose-h2:text-2xl prose-h2:mb-3 prose-h2:mt-5
prose-h3:text-xl prose-h3:mb-2 prose-h3:mt-4
prose-p:text-foreground-secondary prose-p:leading-relaxed
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
prose-code:text-foreground prose-code:bg-surface prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-sm prose-code:font-mono prose-code:before:content-none prose-code:after:content-none
prose-pre:bg-surface prose-pre:border prose-pre:border-border prose-pre:rounded-lg prose-pre:p-4 prose-pre:overflow-x-auto
prose-pre:text-foreground-secondary
prose-blockquote:border-l-4 prose-blockquote:border-border prose-blockquote:pl-4 prose-blockquote:italic prose-blockquote:text-foreground-secondary
prose-ul:text-foreground-secondary prose-ul:list-disc prose-ul:pl-6
prose-ol:text-foreground-secondary prose-ol:list-decimal prose-ol:pl-6
prose-li:my-1
prose-table:border-collapse prose-table:w-full
prose-thead:bg-surface
prose-th:border prose-th:border-border prose-th:px-4 prose-th:py-2 prose-th:text-left prose-th:font-semibold prose-th:text-foreground
prose-td:border prose-td:border-border prose-td:px-4 prose-td:py-2 prose-td:text-foreground-secondary
prose-img:rounded-lg prose-img:shadow-md
prose-hr:border-border prose-hr:my-8
prose-strong:text-foreground prose-strong:font-semibold
prose-em:text-foreground-secondary
${className}`}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
components={{
// Custom component for code blocks to match the site's theme
code: ({ className, children, ...props }) => {
const isInline = !className;
if (isInline) {
return (
<code className="bg-surface px-1.5 py-0.5 rounded text-sm font-mono" {...props}>
{children}
</code>
);
}
return (
<code className={className} {...props}>
{children}
</code>
);
},
// Make links open in new tab
a: ({ href, children, ...props }) => {
const isExternal = href?.startsWith('http');
return (
<a
href={href}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
className="text-primary hover:underline"
{...props}
>
{children}
</a>
);
},
}}
>
{content}
</ReactMarkdown>
</div>
);
}

View file

@ -22,6 +22,10 @@ model Tool {
npmRepository Json? @map("npm_repository") @db.JsonB
npmHomepage String? @map("npm_homepage") @db.Text
npmLicense String? @map("npm_license") @db.VarChar(50)
npmKeywords String[] @default([]) @map("npm_keywords") @db.Text
npmReadme String? @map("npm_readme") @db.Text
npmAuthor Json? @map("npm_author") @db.JsonB
npmMaintainers Json? @map("npm_maintainers") @db.JsonB
// TPMJS Metadata (from package.json tpmjs field)
category String @db.VarChar(50)

View file

@ -26,9 +26,11 @@ export {
export {
fetchPackageMetadata,
fetchLatestPackageVersion,
fetchLatestPackageWithMetadata,
fetchPackageTpmjsField,
type PackageVersion,
type PackageMetadata,
type PackageVersionWithReadme,
} from './package';
// Download statistics

View file

@ -160,6 +160,48 @@ export async function fetchLatestPackageVersion(
};
}
export type PackageVersionWithReadme = PackageVersion & {
readme?: string;
topLevelKeywords?: string[];
topLevelDescription?: string;
};
/**
* Gets the latest version of a package with additional top-level metadata
* @param packageName - The name of the package
* @returns The latest package version with README and top-level metadata, or null if not found
*/
export async function fetchLatestPackageWithMetadata(
packageName: string
): Promise<PackageVersionWithReadme | null> {
const metadata = await fetchPackageMetadata(packageName);
if (!metadata) {
return null;
}
const latestTag = metadata['dist-tags'].latest;
if (!latestTag) {
return null;
}
const version = metadata.versions[latestTag];
if (!version) {
return null;
}
// Add publishedAt from metadata.time
const publishedAt = metadata.time?.[latestTag];
return {
...version,
publishedAt,
readme: metadata.readme,
topLevelKeywords: metadata.keywords,
topLevelDescription: metadata.description,
};
}
/**
* Checks if a package has a tpmjs field in its latest version
* @param packageName - The name of the package

1000
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff