[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add github stars and forks (#74)

authored by

Alec Lloyd Probert and committed by
GitHub
4013824b ef404ab3

+247 -31
+173
app/composables/useRepoMeta.ts
··· 1 + type ProviderId = 'github' // Could be extended to support other providers (gitlab, codeforge, tangled...) 2 + export type RepoRef = { provider: ProviderId; owner: string; repo: string } 3 + 4 + export type RepoMetaLinks = { 5 + repo: string 6 + stars: string 7 + forks: string 8 + watchers?: string 9 + } 10 + 11 + export type RepoMeta = { 12 + provider: ProviderId 13 + url: string 14 + stars: number 15 + forks: number 16 + watchers?: number 17 + description?: string | null 18 + defaultBranch?: string 19 + links: RepoMetaLinks 20 + } 21 + 22 + type UnghRepoResponse = { 23 + repo: { 24 + description?: string | null 25 + stars?: number 26 + forks?: number 27 + watchers?: number 28 + defaultBranch?: string 29 + } | null 30 + } 31 + 32 + function normalizeInputToUrl(input: string): string | null { 33 + const raw = input.trim() 34 + if (!raw) return null 35 + 36 + const normalized = raw.replace(/^git\+/, '') 37 + 38 + if (!/^https?:\/\//i.test(normalized)) { 39 + const scp = normalized.match(/^(?:git@)?([^:/]+):(.+)$/i) 40 + if (scp?.[1] && scp?.[2]) { 41 + const host = scp[1] 42 + const path = scp[2].replace(/^\/*/, '') 43 + return `https://${host}/${path}` 44 + } 45 + } 46 + 47 + return normalized 48 + } 49 + 50 + type ProviderAdapter = { 51 + id: ProviderId 52 + parse(url: URL): RepoRef | null 53 + links(ref: RepoRef): RepoMetaLinks 54 + fetchMeta(ref: RepoRef, links: RepoMetaLinks): Promise<RepoMeta | null> 55 + } 56 + 57 + const githubAdapter: ProviderAdapter = { 58 + id: 'github', 59 + 60 + parse(url) { 61 + const host = url.hostname.toLowerCase() 62 + if (host !== 'github.com' && host !== 'www.github.com') return null 63 + 64 + const parts = url.pathname.split('/').filter(Boolean) 65 + if (parts.length < 2) return null 66 + 67 + const owner = decodeURIComponent(parts[0] ?? '').trim() 68 + const repo = decodeURIComponent(parts[1] ?? '') 69 + .trim() 70 + .replace(/\.git$/i, '') 71 + 72 + if (!owner || !repo) return null 73 + 74 + return { provider: 'github', owner, repo } 75 + }, 76 + 77 + links(ref) { 78 + const base = `https://github.com/${ref.owner}/${ref.repo}` 79 + return { 80 + repo: base, 81 + stars: `${base}/stargazers`, 82 + forks: `${base}/forks`, 83 + watchers: `${base}/watchers`, 84 + } 85 + }, 86 + 87 + async fetchMeta(ref, links) { 88 + // Using UNGH to avoid API limitations of the Github API 89 + const res = await $fetch<UnghRepoResponse>(`https://ungh.cc/repos/${ref.owner}/${ref.repo}`, { 90 + headers: { 'User-Agent': 'npmx' }, 91 + }).catch(() => null) 92 + 93 + const repo = res?.repo 94 + if (!repo) return null 95 + 96 + return { 97 + provider: 'github', 98 + url: links.repo, 99 + stars: repo.stars ?? 0, 100 + forks: repo.forks ?? 0, 101 + watchers: repo.watchers ?? 0, 102 + description: repo.description ?? null, 103 + defaultBranch: repo.defaultBranch, 104 + links, 105 + } 106 + }, 107 + } 108 + 109 + const providers: readonly ProviderAdapter[] = [githubAdapter] as const 110 + 111 + function parseRepoFromUrl(input: string): RepoRef | null { 112 + const normalized = normalizeInputToUrl(input) 113 + if (!normalized) return null 114 + 115 + try { 116 + const url = new URL(normalized) 117 + for (const provider of providers) { 118 + const ref = provider.parse(url) 119 + if (ref) return ref 120 + } 121 + return null 122 + } catch { 123 + return null 124 + } 125 + } 126 + 127 + async function fetchRepoMeta(ref: RepoRef): Promise<RepoMeta | null> { 128 + const adapter = providers.find(provider => provider.id === ref.provider) 129 + if (!adapter) return null 130 + 131 + const links = adapter.links(ref) 132 + return await adapter.fetchMeta(ref, links) 133 + } 134 + 135 + export function useRepoMeta(repositoryUrl: MaybeRefOrGetter<string | null | undefined>) { 136 + const repoRef = computed(() => { 137 + const url = toValue(repositoryUrl) 138 + if (!url) return null 139 + return parseRepoFromUrl(url) 140 + }) 141 + 142 + const { data, pending, error, refresh } = useLazyAsyncData<RepoMeta | null>( 143 + () => 144 + repoRef.value 145 + ? `repo-meta:${repoRef.value.provider}:${repoRef.value.owner}/${repoRef.value.repo}` 146 + : 'repo-meta:none', 147 + async () => { 148 + const ref = repoRef.value 149 + if (!ref) return null 150 + return await fetchRepoMeta(ref) 151 + }, 152 + ) 153 + 154 + const meta = computed<RepoMeta | null>(() => data.value ?? null) 155 + 156 + return { 157 + repoRef, 158 + meta, 159 + 160 + stars: computed(() => meta.value?.stars ?? 0), 161 + forks: computed(() => meta.value?.forks ?? 0), 162 + watchers: computed(() => meta.value?.watchers ?? 0), 163 + 164 + starsLink: computed(() => meta.value?.links.stars ?? null), 165 + forksLink: computed(() => meta.value?.links.forks ?? null), 166 + watchersLink: computed(() => meta.value?.links.watchers ?? null), 167 + repoLink: computed(() => meta.value?.links.repo ?? null), 168 + 169 + pending, 170 + error, 171 + refresh, 172 + } 173 + }
+46 -31
app/pages/[...package].vue
··· 154 154 return url 155 155 }) 156 156 157 + const { meta: repoMeta, stars, forks, forksLink } = useRepoMeta(repositoryUrl) 158 + 157 159 const homepageUrl = computed(() => { 158 160 return displayVersion.value?.homepage ?? null 159 161 }) ··· 281 283 <header class="mb-8 pb-8 border-b border-border"> 282 284 <div class="mb-4"> 283 285 <!-- Package name and version --> 284 - <div class="flex items-start gap-2 mb-1.5 sm:gap-3 sm:mb-2 flex-wrap min-w-0"> 286 + <div class="flex items-baseline gap-2 mb-1.5 sm:gap-3 sm:mb-2 flex-wrap min-w-0"> 285 287 <h1 286 288 class="font-mono text-2xl sm:text-3xl font-medium min-w-0 break-words" 287 289 :title="pkg.name" ··· 294 296 ><span v-if="orgName">/</span 295 297 >{{ orgName ? pkg.name.replace(`@${orgName}/`, '') : pkg.name }} 296 298 </h1> 297 - <a 299 + <span 298 300 v-if="displayVersion" 299 - :href=" 300 - hasProvenance(displayVersion) 301 - ? `https://www.npmjs.com/package/${pkg.name}/v/${displayVersion.version}#provenance` 302 - : undefined 303 - " 304 - :target="hasProvenance(displayVersion) ? '_blank' : undefined" 305 - :rel="hasProvenance(displayVersion) ? 'noopener noreferrer' : undefined" 306 - class="inline-flex items-center gap-1.5 px-3 py-1 font-mono text-sm bg-bg-muted border border-border rounded-md transition-colors duration-200 max-w-full shrink-0" 307 - :class=" 308 - hasProvenance(displayVersion) 309 - ? 'hover:border-border-hover cursor-pointer' 310 - : 'cursor-default' 311 - " 312 - :title="`v${displayVersion.version}`" 301 + class="inline-flex items-baseline gap-1.5 font-mono text-base sm:text-lg text-fg-muted shrink-0" 313 302 > 314 - <span class="truncate max-w-24 sm:max-w-32 md:max-w-48"> 303 + <a 304 + v-if="hasProvenance(displayVersion)" 305 + :href="`https://www.npmjs.com/package/${pkg.name}/v/${displayVersion.version}#provenance`" 306 + target="_blank" 307 + rel="noopener noreferrer" 308 + class="inline-flex items-center gap-1.5 text-fg-muted hover:text-fg-muted/80 transition-colors duration-200" 309 + title="Verified provenance" 310 + > 315 311 v{{ displayVersion.version }} 316 - </span> 312 + <span 313 + class="i-solar-shield-check-outline w-3.5 h-3.5 shrink-0" 314 + aria-hidden="true" 315 + /> 316 + </a> 317 + <span v-else>v{{ displayVersion.version }}</span> 317 318 <span 318 319 v-if=" 319 320 requestedVersion && 320 321 latestVersion && 321 322 displayVersion.version !== latestVersion.version 322 323 " 323 - class="text-fg-subtle shrink-0" 324 + class="text-fg-subtle text-sm shrink-0" 324 325 >(not latest)</span 325 326 > 326 - <span 327 - v-if="hasProvenance(displayVersion)" 328 - class="i-solar-shield-check-outline w-4 h-4 text-fg-muted shrink-0" 329 - aria-label="Verified provenance" 330 - /> 331 - </a> 327 + </span> 332 328 333 329 <!-- Package metrics (module format, types) --> 334 330 <ClientOnly> ··· 336 332 v-if="displayVersion" 337 333 :package-name="pkg.name" 338 334 :version="displayVersion.version" 335 + class="self-center ml-1 sm:ml-2" 339 336 /> 340 337 <template #fallback> 341 - <ul class="flex items-center gap-1.5"> 338 + <ul class="flex items-center gap-1.5 self-center ml-1 sm:ml-2"> 342 339 <li class="skeleton w-8 h-5 rounded" /> 343 340 <li class="skeleton w-12 h-5 rounded" /> 344 341 </ul> 345 342 </template> 346 343 </ClientOnly> 344 + 345 + <a 346 + :href="`https://www.npmjs.com/package/${pkg.name}`" 347 + target="_blank" 348 + rel="noopener noreferrer" 349 + class="link-subtle font-mono text-sm inline-flex items-center gap-1.5 ml-auto shrink-0 self-center" 350 + title="View on npm" 351 + > 352 + <span class="i-carbon-logo-npm w-4 h-4" aria-hidden="true" /> 353 + <span class="hidden sm:inline">npm</span> 354 + <span class="sr-only sm:hidden">View on npm</span> 355 + </a> 347 356 </div> 357 + 348 358 <!-- Fixed height description container to prevent CLS --> 349 359 <div ref="descriptionRef" class="relative max-w-2xl min-h-[4.5rem]"> 350 360 <p ··· 471 481 class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 472 482 > 473 483 <span class="i-carbon-logo-github w-4 h-4" aria-hidden="true" /> 474 - repo 484 + <span v-if="repoMeta"> 485 + {{ formatCompactNumber(stars, { decimals: 1 }) }} stars 486 + </span> 487 + <span v-else>repo</span> 475 488 </a> 476 489 </li> 477 490 <li v-if="homepageUrl"> ··· 496 509 issues 497 510 </a> 498 511 </li> 499 - <li> 512 + 513 + <li v-if="forks && forksLink"> 500 514 <a 501 - :href="`https://www.npmjs.com/package/${pkg.name}`" 515 + :href="forksLink" 502 516 target="_blank" 503 517 rel="noopener noreferrer" 504 518 class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 505 519 > 506 - <span class="i-carbon-cube w-4 h-4" aria-hidden="true" /> 507 - npm 520 + <span class="i-carbon-fork w-4 h-4" aria-hidden="true" /> 521 + <span>{{ formatCompactNumber(forks, { decimals: 1 }) }} forks</span> 508 522 </a> 509 523 </li> 524 + 510 525 <li v-if="jsrInfo?.exists && jsrInfo.url"> 511 526 <a 512 527 :href="jsrInfo.url"
+28
app/utils/formatters.ts
··· 8 8 const day = String(date.getUTCDate()).padStart(2, '0') 9 9 return `${year}-${month}-${day}` 10 10 } 11 + 12 + export function formatCompactNumber( 13 + value: number, 14 + options?: { decimals?: number; space?: boolean }, 15 + ): string { 16 + const decimals = options?.decimals ?? 0 17 + const space = options?.space ?? false 18 + 19 + const sign = value < 0 ? '-' : '' 20 + const abs = Math.abs(value) 21 + 22 + const fmt = (n: number) => { 23 + if (decimals <= 0) return Math.round(n).toString() 24 + return n 25 + .toFixed(decimals) 26 + .replace(/\.0+$/, '') 27 + .replace(/(\.\d*?)0+$/, '$1') 28 + } 29 + 30 + const join = (suffix: string, n: number) => `${sign}${fmt(n)}${space ? ' ' : ''}${suffix}` 31 + 32 + if (abs >= 1e12) return join('T', abs / 1e12) 33 + if (abs >= 1e9) return join('B', abs / 1e9) 34 + if (abs >= 1e6) return join('M', abs / 1e6) 35 + if (abs >= 1e3) return join('k', abs / 1e3) 36 + 37 + return `${sign}${Math.round(abs)}` 38 + }