Skip to content

Commit 4013824

Browse files
authored
feat: add github stars and forks (#74)
1 parent ef404ab commit 4013824

3 files changed

Lines changed: 247 additions & 31 deletions

File tree

app/composables/useRepoMeta.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
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+
}

app/pages/[...package].vue

Lines changed: 46 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ const repositoryUrl = computed(() => {
154154
return url
155155
})
156156
157+
const { meta: repoMeta, stars, forks, forksLink } = useRepoMeta(repositoryUrl)
158+
157159
const homepageUrl = computed(() => {
158160
return displayVersion.value?.homepage ?? null
159161
})
@@ -281,7 +283,7 @@ defineOgImageComponent('Package', {
281283
<header class="mb-8 pb-8 border-b border-border">
282284
<div class="mb-4">
283285
<!-- 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">
285287
<h1
286288
class="font-mono text-2xl sm:text-3xl font-medium min-w-0 break-words"
287289
:title="pkg.name"
@@ -294,57 +296,65 @@ defineOgImageComponent('Package', {
294296
><span v-if="orgName">/</span
295297
>{{ orgName ? pkg.name.replace(`@${orgName}/`, '') : pkg.name }}
296298
</h1>
297-
<a
299+
<span
298300
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"
313302
>
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+
>
315311
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>
317318
<span
318319
v-if="
319320
requestedVersion &&
320321
latestVersion &&
321322
displayVersion.version !== latestVersion.version
322323
"
323-
class="text-fg-subtle shrink-0"
324+
class="text-fg-subtle text-sm shrink-0"
324325
>(not latest)</span
325326
>
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>
332328

333329
<!-- Package metrics (module format, types) -->
334330
<ClientOnly>
335331
<PackageMetricsBadges
336332
v-if="displayVersion"
337333
:package-name="pkg.name"
338334
:version="displayVersion.version"
335+
class="self-center ml-1 sm:ml-2"
339336
/>
340337
<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">
342339
<li class="skeleton w-8 h-5 rounded" />
343340
<li class="skeleton w-12 h-5 rounded" />
344341
</ul>
345342
</template>
346343
</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>
347356
</div>
357+
348358
<!-- Fixed height description container to prevent CLS -->
349359
<div ref="descriptionRef" class="relative max-w-2xl min-h-[4.5rem]">
350360
<p
@@ -471,7 +481,10 @@ defineOgImageComponent('Package', {
471481
class="link-subtle font-mono text-sm inline-flex items-center gap-1.5"
472482
>
473483
<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>
475488
</a>
476489
</li>
477490
<li v-if="homepageUrl">
@@ -496,17 +509,19 @@ defineOgImageComponent('Package', {
496509
issues
497510
</a>
498511
</li>
499-
<li>
512+
513+
<li v-if="forks && forksLink">
500514
<a
501-
:href="`https://www.npmjs.com/package/${pkg.name}`"
515+
:href="forksLink"
502516
target="_blank"
503517
rel="noopener noreferrer"
504518
class="link-subtle font-mono text-sm inline-flex items-center gap-1.5"
505519
>
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>
508522
</a>
509523
</li>
524+
510525
<li v-if="jsrInfo?.exists && jsrInfo.url">
511526
<a
512527
:href="jsrInfo.url"

app/utils/formatters.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,31 @@ export function toIsoDateString(date: Date): string {
88
const day = String(date.getUTCDate()).padStart(2, '0')
99
return `${year}-${month}-${day}`
1010
}
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+
}

0 commit comments

Comments
 (0)