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:
parent
e38e627a07
commit
f9ba1c094e
9 changed files with 1583 additions and 560 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
85
apps/web/src/components/Markdown.tsx
Normal file
85
apps/web/src/components/Markdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -26,9 +26,11 @@ export {
|
|||
export {
|
||||
fetchPackageMetadata,
|
||||
fetchLatestPackageVersion,
|
||||
fetchLatestPackageWithMetadata,
|
||||
fetchPackageTpmjsField,
|
||||
type PackageVersion,
|
||||
type PackageMetadata,
|
||||
type PackageVersionWithReadme,
|
||||
} from './package';
|
||||
|
||||
// Download statistics
|
||||
|
|
|
|||
|
|
@ -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
1000
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue