Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/web/app/components/PluginCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface Plugin {
source: PluginSource | string
marketplaceRepo?: string
marketplaceJsonName?: string
stars?: number
}

interface PluginMetadata {
Expand Down Expand Up @@ -237,6 +238,24 @@ const badges = computed<Badge[]>(() => {
})
}

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

Expand Down
9 changes: 7 additions & 2 deletions apps/web/app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,13 @@ const targetPluginName = computed(() => {
return undefined
})

// Fetch marketplace data from API
const { data: apiData, pending, error } = await useFetch<MarketplaceAPIResponse>('/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<MarketplaceAPIResponse>('/api/marketplaces'),
)

// Aggregate all plugins from all marketplaces
const allPlugins = computed(() => {
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/types/marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface Plugin {
author?: string | { name?: string, email?: string }
category?: string
source: PluginSource | string
stars?: number
}

export interface Marketplace {
Expand Down
8 changes: 8 additions & 0 deletions apps/web/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
46 changes: 37 additions & 9 deletions apps/web/server/api/marketplaces.get.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { AggregatedMarketplace, AggregatedPlugin, MarketplaceAPIResponse, MarketplaceSource } from '~/types/marketplace'

Check failure on line 1 in apps/web/server/api/marketplaces.get.ts

View workflow job for this annotation

GitHub Actions / Lint

'AggregatedPlugin' is defined but never used
import marketplaceSourcesConfig from '../marketplace-sources.json'
import { marketplaceSchema, marketplaceSourcesConfigSchema } from '../utils/marketplace-schema'
import { fetchPluginStars } from '../utils/github'

Check failure on line 4 in apps/web/server/api/marketplaces.get.ts

View workflow job for this annotation

GitHub Actions / Lint

Expected "../utils/github" to come before "../utils/marketplace-schema"

/**
* 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<MarketplaceAPIResponse> => {
// Validate configuration with Zod
const validatedConfig = marketplaceSourcesConfigSchema.parse(marketplaceSourcesConfig)
Expand All @@ -30,20 +32,46 @@
// 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) {
Expand Down
99 changes: 99 additions & 0 deletions apps/web/server/utils/github.ts
Original file line number Diff line number Diff line change
@@ -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<number | null> {
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

Check failure on line 57 in apps/web/server/utils/github.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected use of the global variable 'process'. Use 'require("process")' instead
if (githubToken) {
headers.Authorization = `token ${githubToken}`
}

const response = await $fetch<GitHubRepo>(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<number | null> {
// 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
}

Check failure on line 99 in apps/web/server/utils/github.ts

View workflow job for this annotation

GitHub Actions / Lint

Newline required at end of file but not found
1 change: 1 addition & 0 deletions apps/web/server/utils/marketplace-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading