diff --git a/apps/web/app/components/PluginCard.vue b/apps/web/app/components/PluginCard.vue index 427a8add..980a2070 100644 --- a/apps/web/app/components/PluginCard.vue +++ b/apps/web/app/components/PluginCard.vue @@ -14,6 +14,7 @@ interface Plugin { source: PluginSource | string marketplaceRepo?: string marketplaceJsonName?: string + stars?: number } interface PluginMetadata { @@ -237,6 +238,24 @@ const badges = computed(() => { }) } + // GitHub stars badge + if (props.plugin.stars !== null && props.plugin.stars !== undefined && props.plugin.stars >= 0) { + const formatStars = (count: number): string => { + if (count >= 1000) { + return `${(count / 1000).toFixed(1)}k` + } + return count.toString() + } + + badgeList.push({ + key: 'stars', + icon: 'i-heroicons-star', + label: formatStars(props.plugin.stars), + color: 'warning', + title: `${props.plugin.stars.toLocaleString()} GitHub stars`, + }) + } + return badgeList }) diff --git a/apps/web/app/pages/index.vue b/apps/web/app/pages/index.vue index dd6796af..070b2b2e 100644 --- a/apps/web/app/pages/index.vue +++ b/apps/web/app/pages/index.vue @@ -35,8 +35,13 @@ const targetPluginName = computed(() => { return undefined }) -// Fetch marketplace data from API -const { data: apiData, pending, error } = await useFetch('/api/marketplaces') +// Fetch marketplace data using useAsyncData for SSR/ISR support +// useAsyncData automatically handles server-side fetching and client hydration +// Data is fetched on the server, cached, and passed to the client without re-fetching +const { data: apiData, pending, error } = await useAsyncData( + 'marketplaces', + () => $fetch('/api/marketplaces'), +) // Aggregate all plugins from all marketplaces const allPlugins = computed(() => { diff --git a/apps/web/app/types/marketplace.ts b/apps/web/app/types/marketplace.ts index 154e455a..bfc820b8 100644 --- a/apps/web/app/types/marketplace.ts +++ b/apps/web/app/types/marketplace.ts @@ -23,6 +23,7 @@ export interface Plugin { author?: string | { name?: string, email?: string } category?: string source: PluginSource | string + stars?: number } export interface Marketplace { diff --git a/apps/web/nuxt.config.ts b/apps/web/nuxt.config.ts index f851e8f7..1d9d303f 100644 --- a/apps/web/nuxt.config.ts +++ b/apps/web/nuxt.config.ts @@ -7,6 +7,14 @@ export default defineNuxtConfig({ preset: 'vercel', }, + // ISR configuration for marketplace data + // Page uses server-side data fetching (useAsyncData), so only page ISR is needed + // Data is fetched on the server and included in the cached HTML + routeRules: { + '/': { isr: 3600 }, // Main page with embedded data: revalidate every 1 hour + '/api/marketplaces': { isr: 3600 }, // Keep API endpoint cached for direct API access if needed + }, + modules: [ '@nuxt/ui', '@nuxt/content', diff --git a/apps/web/server/api/marketplaces.get.ts b/apps/web/server/api/marketplaces.get.ts index fd2a2667..00575349 100644 --- a/apps/web/server/api/marketplaces.get.ts +++ b/apps/web/server/api/marketplaces.get.ts @@ -1,12 +1,14 @@ import type { AggregatedMarketplace, AggregatedPlugin, MarketplaceAPIResponse, MarketplaceSource } from '~/types/marketplace' import marketplaceSourcesConfig from '../marketplace-sources.json' import { marketplaceSchema, marketplaceSourcesConfigSchema } from '../utils/marketplace-schema' +import { fetchPluginStars } from '../utils/github' /** * Fetch and aggregate multiple marketplaces * Uses Nuxt's built-in caching with 5-minute TTL + * Exported for direct use in server-side rendering */ -const fetchMarketplaces = defineCachedFunction( +export const fetchMarketplaces = defineCachedFunction( async (): Promise => { // Validate configuration with Zod const validatedConfig = marketplaceSourcesConfigSchema.parse(marketplaceSourcesConfig) @@ -30,20 +32,46 @@ const fetchMarketplaces = defineCachedFunction( // Validate marketplace data with Zod const validatedMarketplace = marketplaceSchema.parse(response) - // Transform plugins to include marketplace metadata - const plugins: AggregatedPlugin[] = validatedMarketplace.plugins.map(plugin => ({ - ...plugin, - marketplaceRepo: source.repo, - marketplaceJsonName: validatedMarketplace.name, - })) + // Cache marketplace repo stars to avoid duplicate API calls + let marketplaceRepoStars: number | null | undefined + + // Fetch GitHub stars for all plugins in parallel + const pluginsWithStars = await Promise.all( + validatedMarketplace.plugins.map(async (plugin) => { + let stars = plugin.stars + + // Fetch stars if not already provided + if (stars === undefined || stars === null) { + // For local plugins (e.g., "./plugins/xxx"), use marketplace repo + if (typeof plugin.source === 'string' && plugin.source.startsWith('./')) { + // Fetch marketplace repo stars only once and cache it + if (marketplaceRepoStars === undefined) { + marketplaceRepoStars = await fetchPluginStars(source.repo) + } + stars = marketplaceRepoStars + } + else { + // For GitHub plugins, use plugin's own repo + stars = await fetchPluginStars(plugin.source) + } + } + + return { + ...plugin, + stars, + marketplaceRepo: source.repo, + marketplaceJsonName: validatedMarketplace.name, + } + }), + ) return { name: source.name, description: source.description, repo: source.repo, marketplaceJsonName: validatedMarketplace.name, - pluginCount: plugins.length, - plugins, + pluginCount: pluginsWithStars.length, + plugins: pluginsWithStars, } } catch (error) { diff --git a/apps/web/server/utils/github.ts b/apps/web/server/utils/github.ts new file mode 100644 index 00000000..44115700 --- /dev/null +++ b/apps/web/server/utils/github.ts @@ -0,0 +1,99 @@ +/** + * GitHub API utilities for fetching repository information + */ + +interface GitHubRepo { + stargazers_count: number + forks_count: number + open_issues_count: number + updated_at: string +} + +/** + * Extract owner and repo from GitHub URL or repo string + * @param source - GitHub URL (https://github.com/owner/repo) or repo string (owner/repo) + * @returns Object with owner and repo, or null if invalid + */ +export function parseGitHubRepo(source: string): { owner: string, repo: string } | null { + try { + // Handle URL format: https://github.com/owner/repo + if (source.startsWith('http')) { + const url = new URL(source) + const parts = url.pathname.split('/').filter(Boolean) + if (parts.length >= 2) { + return { owner: parts[0], repo: parts[1] } + } + } + + // Handle repo format: owner/repo + const parts = source.split('/') + if (parts.length === 2 && parts[0] && parts[1]) { + return { owner: parts[0], repo: parts[1] } + } + + return null + } + catch { + return null + } +} + +/** + * Fetch GitHub repository star count + * @param owner - Repository owner + * @param repo - Repository name + * @returns Star count, or null if fetch fails + */ +export async function fetchGitHubStars(owner: string, repo: string): Promise { + try { + const url = `https://api.github.com/repos/${owner}/${repo}` + + // Use GitHub token if available to increase rate limit + const headers: HeadersInit = { + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'claude-code-plugins-marketplace', + } + + const githubToken = process.env.GITHUB_TOKEN + if (githubToken) { + headers.Authorization = `token ${githubToken}` + } + + const response = await $fetch(url, { + headers, + // Add timeout to prevent hanging + timeout: 5000, + }) + + return response.stargazers_count + } + catch (error) { + console.warn(`Failed to fetch stars for ${owner}/${repo}:`, error) + return null + } +} + +/** + * Fetch stars for a plugin based on its source + * @param source - Plugin source (GitHub URL or repo string) + * @returns Star count, or null if fetch fails + */ +export async function fetchPluginStars(source: string | { source: string, repo: string }): Promise { + // Handle object format with repo property + if (typeof source === 'object' && source.repo) { + const parsed = parseGitHubRepo(source.repo) + if (!parsed) + return null + return fetchGitHubStars(parsed.owner, parsed.repo) + } + + // Handle string format + if (typeof source === 'string') { + const parsed = parseGitHubRepo(source) + if (!parsed) + return null + return fetchGitHubStars(parsed.owner, parsed.repo) + } + + return null +} \ No newline at end of file diff --git a/apps/web/server/utils/marketplace-schema.ts b/apps/web/server/utils/marketplace-schema.ts index 124fc269..1e48ad6d 100644 --- a/apps/web/server/utils/marketplace-schema.ts +++ b/apps/web/server/utils/marketplace-schema.ts @@ -26,6 +26,7 @@ export const pluginSchema = z.object({ ]).optional(), category: z.string().optional(), source: pluginSourceSchema, + stars: z.number().optional(), }) export const marketplaceSchema = z.object({