Skip to content

Commit 8fad0b6

Browse files
committed
feat: add github stars and forks counts, position npm to top right
1 parent 2a5ed7d commit 8fad0b6

4 files changed

Lines changed: 334 additions & 64 deletions

File tree

app/components/GithubStats.vue

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<script setup lang="ts">
2+
const { repositoryUrl } = defineProps<{
3+
repositoryUrl: string | null
4+
}>()
5+
6+
const {
7+
githubRepo,
8+
stars: githubStars,
9+
forks: githubForks,
10+
pending: githubPending,
11+
error: githubError,
12+
} = useGithubRepo(repositoryUrl)
13+
</script>
14+
15+
<template>
16+
<div v-if="githubRepo" class="inline-flex items-center gap-2">
17+
<a
18+
:href="`https://github.com/${githubRepo.owner}/${githubRepo.repo}`"
19+
target="_blank"
20+
rel="noopener noreferrer"
21+
class="inline-flex items-center gap-1 font-mono text-sm text-fg-muted hover:text-fg transition-colors"
22+
title="GitHub stars"
23+
>
24+
<span>{{ formatNumber(githubStars) }}</span>
25+
<span class="i-carbon-star-filled w-4 h-4" aria-hidden="true" />
26+
</a>
27+
28+
<span class="text-fg-subtle">·</span>
29+
30+
<span
31+
class="inline-flex items-center gap-1 font-mono text-sm text-fg-muted"
32+
title="GitHub forks"
33+
>
34+
<span>{{ formatNumber(githubForks) }}</span>
35+
<span class="i-carbon-fork w-4 h-4" aria-hidden="true" />
36+
</span>
37+
38+
<span v-if="githubPending" class="text-fg-subtle font-mono text-xs">loading…</span>
39+
</div>
40+
</template>

app/composables/useRepoMeta.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
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 requestKey = computed(() => {
143+
const ref = repoRef.value
144+
if (!ref) return 'repo-meta:none'
145+
return `repo-meta:${ref.provider}:${ref.owner}/${ref.repo}`
146+
})
147+
148+
const { data, pending, error, refresh } = useLazyAsyncData<RepoMeta | null>(
149+
requestKey,
150+
async () => {
151+
const ref = repoRef.value
152+
if (!ref) return null
153+
return await fetchRepoMeta(ref)
154+
},
155+
{ default: () => null },
156+
)
157+
158+
watch(
159+
repoRef,
160+
ref => {
161+
if (ref) refresh()
162+
},
163+
{ immediate: true },
164+
)
165+
166+
const meta = computed<RepoMeta | null>(() => data.value ?? null)
167+
168+
return {
169+
repoRef,
170+
meta,
171+
172+
stars: computed(() => meta.value?.stars ?? 0),
173+
forks: computed(() => meta.value?.forks ?? 0),
174+
watchers: computed(() => meta.value?.watchers ?? 0),
175+
176+
starsLink: computed(() => meta.value?.links.stars ?? null),
177+
forksLink: computed(() => meta.value?.links.forks ?? null),
178+
watchersLink: computed(() => meta.value?.links.watchers ?? null),
179+
repoLink: computed(() => meta.value?.links.repo ?? null),
180+
181+
pending,
182+
error,
183+
refresh,
184+
}
185+
}

0 commit comments

Comments
 (0)