forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import type { ProviderId, RepoRef } from '#shared/utils/git-providers'
2import { parseRepoUrl, GITLAB_HOSTS } from '#shared/utils/git-providers'
3
4// TTL for git repo metadata (10 minutes - repo stats don't change frequently)
5const REPO_META_TTL = 60 * 10
6
7export type RepoMetaLinks = {
8 repo: string
9 stars: string
10 forks: string
11 watchers?: string
12}
13
14export type RepoMeta = {
15 provider: ProviderId
16 url: string
17 stars: number
18 forks: number
19 watchers?: number
20 description?: string | null
21 defaultBranch?: string
22 links: RepoMetaLinks
23}
24
25type UnghRepoResponse = {
26 repo: {
27 description?: string | null
28 stars?: number
29 forks?: number
30 watchers?: number
31 defaultBranch?: string
32 } | null
33}
34
35/** GitLab API response for project details */
36type GitLabProjectResponse = {
37 id: number
38 description?: string | null
39 default_branch?: string
40 star_count?: number
41 forks_count?: number
42}
43
44/** Gitea/Forgejo API response for repository details */
45type GiteaRepoResponse = {
46 id: number
47 description?: string
48 default_branch?: string
49 stars_count?: number
50 forks_count?: number
51 watchers_count?: number
52}
53
54/** Bitbucket API response for repository details */
55type BitbucketRepoResponse = {
56 name: string
57 full_name: string
58 description?: string
59 mainbranch?: { name: string }
60 // Bitbucket doesn't expose star/fork counts in public API
61}
62
63/** Gitee API response for repository details */
64type GiteeRepoResponse = {
65 id: number
66 name: string
67 full_name: string
68 description?: string
69 default_branch?: string
70 stargazers_count?: number
71 forks_count?: number
72 watchers_count?: number
73}
74
75/** Radicle API response for project details */
76type RadicleProjectResponse = {
77 id: string
78 name: string
79 description?: string
80 defaultBranch?: string
81 head?: string
82 seeding?: number
83 delegates?: Array<{ id: string; alias?: string }>
84 patches?: { open: number; draft: number; archived: number; merged: number }
85 issues?: { open: number; closed: number }
86}
87
88type ProviderAdapter = {
89 id: ProviderId
90 parse(url: URL): RepoRef | null
91 links(ref: RepoRef): RepoMetaLinks
92 fetchMeta(
93 cachedFetch: CachedFetchFunction,
94 ref: RepoRef,
95 links: RepoMetaLinks,
96 options?: Parameters<typeof $fetch>[1],
97 ): Promise<RepoMeta | null>
98}
99
100const githubAdapter: ProviderAdapter = {
101 id: 'github',
102
103 parse(url) {
104 const host = url.hostname.toLowerCase()
105 if (host !== 'github.com' && host !== 'www.github.com') return null
106
107 const parts = url.pathname.split('/').filter(Boolean)
108 if (parts.length < 2) return null
109
110 const owner = decodeURIComponent(parts[0] ?? '').trim()
111 const repo = decodeURIComponent(parts[1] ?? '')
112 .trim()
113 .replace(/\.git$/i, '')
114
115 if (!owner || !repo) return null
116
117 return { provider: 'github', owner, repo }
118 },
119
120 links(ref) {
121 const base = `https://github.com/${ref.owner}/${ref.repo}`
122 return {
123 repo: base,
124 stars: `${base}/stargazers`,
125 forks: `${base}/forks`,
126 watchers: `${base}/watchers`,
127 }
128 },
129
130 async fetchMeta(cachedFetch, ref, links, options = {}) {
131 // Using UNGH to avoid API limitations of the Github API
132 let res: UnghRepoResponse | null = null
133 try {
134 const { data } = await cachedFetch<UnghRepoResponse>(
135 `https://ungh.cc/repos/${ref.owner}/${ref.repo}`,
136 { headers: { 'User-Agent': 'npmx', ...options.headers }, ...options },
137 REPO_META_TTL,
138 )
139 res = data
140 } catch {
141 return null
142 }
143
144 const repo = res?.repo
145 if (!repo) return null
146
147 return {
148 provider: 'github',
149 url: links.repo,
150 stars: repo.stars ?? 0,
151 forks: repo.forks ?? 0,
152 watchers: repo.watchers ?? 0,
153 description: repo.description ?? null,
154 defaultBranch: repo.defaultBranch,
155 links,
156 }
157 },
158}
159
160const gitlabAdapter: ProviderAdapter = {
161 id: 'gitlab',
162
163 parse(url) {
164 const host = url.hostname.toLowerCase()
165 const isGitLab = GITLAB_HOSTS.some(h => host === h || host === `www.${h}`)
166 if (!isGitLab) return null
167
168 const parts = url.pathname.split('/').filter(Boolean)
169 if (parts.length < 2) return null
170
171 // GitLab supports nested groups, so we join all parts except the last as owner
172 const repo = decodeURIComponent(parts[parts.length - 1] ?? '')
173 .trim()
174 .replace(/\.git$/i, '')
175 const owner = parts
176 .slice(0, -1)
177 .map(p => decodeURIComponent(p).trim())
178 .join('/')
179
180 if (!owner || !repo) return null
181
182 return { provider: 'gitlab', owner, repo, host }
183 },
184
185 links(ref) {
186 const baseHost = ref.host ?? 'gitlab.com'
187 const base = `https://${baseHost}/${ref.owner}/${ref.repo}`
188 return {
189 repo: base,
190 stars: `${base}/-/starrers`,
191 forks: `${base}/-/forks`,
192 }
193 },
194
195 async fetchMeta(cachedFetch, ref, links, options = {}) {
196 const baseHost = ref.host ?? 'gitlab.com'
197 const projectPath = encodeURIComponent(`${ref.owner}/${ref.repo}`)
198 let res: GitLabProjectResponse | null = null
199 try {
200 const { data } = await cachedFetch<GitLabProjectResponse>(
201 `https://${baseHost}/api/v4/projects/${projectPath}`,
202 { headers: { 'User-Agent': 'npmx', ...options.headers }, ...options },
203 REPO_META_TTL,
204 )
205 res = data
206 } catch {
207 return null
208 }
209
210 if (!res) return null
211
212 return {
213 provider: 'gitlab',
214 url: links.repo,
215 stars: res.star_count ?? 0,
216 forks: res.forks_count ?? 0,
217 description: res.description ?? null,
218 defaultBranch: res.default_branch,
219 links,
220 }
221 },
222}
223
224const bitbucketAdapter: ProviderAdapter = {
225 id: 'bitbucket',
226
227 parse(url) {
228 const host = url.hostname.toLowerCase()
229 if (host !== 'bitbucket.org' && host !== 'www.bitbucket.org') return null
230
231 const parts = url.pathname.split('/').filter(Boolean)
232 if (parts.length < 2) return null
233
234 const owner = decodeURIComponent(parts[0] ?? '').trim()
235 const repo = decodeURIComponent(parts[1] ?? '')
236 .trim()
237 .replace(/\.git$/i, '')
238
239 if (!owner || !repo) return null
240
241 return { provider: 'bitbucket', owner, repo }
242 },
243
244 links(ref) {
245 const base = `https://bitbucket.org/${ref.owner}/${ref.repo}`
246 return {
247 repo: base,
248 stars: base, // Bitbucket doesn't have public stars
249 forks: `${base}/forks`,
250 }
251 },
252
253 async fetchMeta(cachedFetch, ref, links, options = {}) {
254 let res: BitbucketRepoResponse | null = null
255 try {
256 const { data } = await cachedFetch<BitbucketRepoResponse>(
257 `https://api.bitbucket.org/2.0/repositories/${ref.owner}/${ref.repo}`,
258 { headers: { 'User-Agent': 'npmx', ...options.headers }, ...options },
259 REPO_META_TTL,
260 )
261 res = data
262 } catch {
263 return null
264 }
265
266 if (!res) return null
267
268 // Bitbucket doesn't expose star/fork counts in their public API
269 return {
270 provider: 'bitbucket',
271 url: links.repo,
272 stars: 0,
273 forks: 0,
274 description: res.description ?? null,
275 defaultBranch: res.mainbranch?.name,
276 links,
277 }
278 },
279}
280
281const codebergAdapter: ProviderAdapter = {
282 id: 'codeberg',
283
284 parse(url) {
285 const host = url.hostname.toLowerCase()
286 if (host !== 'codeberg.org' && host !== 'www.codeberg.org') return null
287
288 const parts = url.pathname.split('/').filter(Boolean)
289 if (parts.length < 2) return null
290
291 const owner = decodeURIComponent(parts[0] ?? '').trim()
292 const repo = decodeURIComponent(parts[1] ?? '')
293 .trim()
294 .replace(/\.git$/i, '')
295
296 if (!owner || !repo) return null
297
298 return { provider: 'codeberg', owner, repo, host: 'codeberg.org' }
299 },
300
301 links(ref) {
302 const base = `https://codeberg.org/${ref.owner}/${ref.repo}`
303 return {
304 repo: base,
305 stars: base, // Codeberg doesn't have a separate stargazers page
306 forks: `${base}/forks`,
307 watchers: base,
308 }
309 },
310
311 async fetchMeta(cachedFetch, ref, links, options = {}) {
312 let res: GiteaRepoResponse | null = null
313 try {
314 const { data } = await cachedFetch<GiteaRepoResponse>(
315 `https://codeberg.org/api/v1/repos/${ref.owner}/${ref.repo}`,
316 { headers: { 'User-Agent': 'npmx', ...options.headers }, ...options },
317 REPO_META_TTL,
318 )
319 res = data
320 } catch {
321 return null
322 }
323
324 if (!res) return null
325
326 return {
327 provider: 'codeberg',
328 url: links.repo,
329 stars: res.stars_count ?? 0,
330 forks: res.forks_count ?? 0,
331 watchers: res.watchers_count ?? 0,
332 description: res.description ?? null,
333 defaultBranch: res.default_branch,
334 links,
335 }
336 },
337}
338
339const giteeAdapter: ProviderAdapter = {
340 id: 'gitee',
341
342 parse(url) {
343 const host = url.hostname.toLowerCase()
344 if (host !== 'gitee.com' && host !== 'www.gitee.com') return null
345
346 const parts = url.pathname.split('/').filter(Boolean)
347 if (parts.length < 2) return null
348
349 const owner = decodeURIComponent(parts[0] ?? '').trim()
350 const repo = decodeURIComponent(parts[1] ?? '')
351 .trim()
352 .replace(/\.git$/i, '')
353
354 if (!owner || !repo) return null
355
356 return { provider: 'gitee', owner, repo }
357 },
358
359 links(ref) {
360 const base = `https://gitee.com/${ref.owner}/${ref.repo}`
361 return {
362 repo: base,
363 stars: `${base}/stargazers`,
364 forks: `${base}/members`,
365 watchers: `${base}/watchers`,
366 }
367 },
368
369 async fetchMeta(cachedFetch, ref, links, options = {}) {
370 let res: GiteeRepoResponse | null = null
371 try {
372 const { data } = await cachedFetch<GiteeRepoResponse>(
373 `https://gitee.com/api/v5/repos/${ref.owner}/${ref.repo}`,
374 { headers: { 'User-Agent': 'npmx', ...options.headers }, ...options },
375 REPO_META_TTL,
376 )
377 res = data
378 } catch {
379 return null
380 }
381
382 if (!res) return null
383
384 return {
385 provider: 'gitee',
386 url: links.repo,
387 stars: res.stargazers_count ?? 0,
388 forks: res.forks_count ?? 0,
389 watchers: res.watchers_count ?? 0,
390 description: res.description ?? null,
391 defaultBranch: res.default_branch,
392 links,
393 }
394 },
395}
396
397/**
398 * Generic Gitea adapter for self-hosted instances.
399 * Matches common Gitea/Forgejo hosting patterns.
400 */
401const giteaAdapter: ProviderAdapter = {
402 id: 'gitea',
403
404 parse(url) {
405 const host = url.hostname.toLowerCase()
406
407 // Match common Gitea/Forgejo hosting patterns
408 const giteaPatterns = [
409 /^git\./i, // git.example.com
410 /^gitea\./i, // gitea.example.com
411 /^forgejo\./i, // forgejo.example.com
412 /^code\./i, // code.example.com
413 /^src\./i, // src.example.com
414 /gitea\.io$/i, // *.gitea.io
415 ]
416
417 // Skip if it matches other known providers
418 const skipHosts = [
419 'github.com',
420 'gitlab.com',
421 'codeberg.org',
422 'bitbucket.org',
423 'gitee.com',
424 'sr.ht',
425 'git.sr.ht',
426 ...GITLAB_HOSTS,
427 ]
428 if (skipHosts.some(h => host === h || host.endsWith(`.${h}`))) return null
429
430 // Check if matches Gitea patterns
431 if (!giteaPatterns.some(p => p.test(host))) return null
432
433 const parts = url.pathname.split('/').filter(Boolean)
434 if (parts.length < 2) return null
435
436 const owner = decodeURIComponent(parts[0] ?? '').trim()
437 const repo = decodeURIComponent(parts[1] ?? '')
438 .trim()
439 .replace(/\.git$/i, '')
440
441 if (!owner || !repo) return null
442
443 return { provider: 'gitea', owner, repo, host }
444 },
445
446 links(ref) {
447 const base = `https://${ref.host}/${ref.owner}/${ref.repo}`
448 return {
449 repo: base,
450 stars: base,
451 forks: `${base}/forks`,
452 watchers: base,
453 }
454 },
455
456 async fetchMeta(cachedFetch, ref, links, options = {}) {
457 if (!ref.host) return null
458
459 // Note: Generic Gitea instances may not be in the allowlist,
460 // so caching may not apply for self-hosted instances
461 let res: GiteaRepoResponse | null = null
462 try {
463 const { data } = await cachedFetch<GiteaRepoResponse>(
464 `https://${ref.host}/api/v1/repos/${ref.owner}/${ref.repo}`,
465 { headers: { 'User-Agent': 'npmx', ...options.headers }, ...options },
466 REPO_META_TTL,
467 )
468 res = data
469 } catch {
470 return null
471 }
472
473 if (!res) return null
474
475 return {
476 provider: 'gitea',
477 url: links.repo,
478 stars: res.stars_count ?? 0,
479 forks: res.forks_count ?? 0,
480 watchers: res.watchers_count ?? 0,
481 description: res.description ?? null,
482 defaultBranch: res.default_branch,
483 links,
484 }
485 },
486}
487
488const sourcehutAdapter: ProviderAdapter = {
489 id: 'sourcehut',
490
491 parse(url) {
492 const host = url.hostname.toLowerCase()
493 if (host !== 'sr.ht' && host !== 'git.sr.ht') return null
494
495 const parts = url.pathname.split('/').filter(Boolean)
496 if (parts.length < 2) return null
497
498 // Sourcehut uses ~username/repo format
499 const owner = decodeURIComponent(parts[0] ?? '').trim()
500 const repo = decodeURIComponent(parts[1] ?? '')
501 .trim()
502 .replace(/\.git$/i, '')
503
504 if (!owner || !repo) return null
505
506 return { provider: 'sourcehut', owner, repo }
507 },
508
509 links(ref) {
510 const base = `https://git.sr.ht/${ref.owner}/${ref.repo}`
511 return {
512 repo: base,
513 stars: base, // Sourcehut doesn't have stars
514 forks: base,
515 }
516 },
517
518 async fetchMeta(_cachedFetch, _ref, links) {
519 // Sourcehut doesn't have a public API for repo stats
520 // Just return basic info without fetching
521 return {
522 provider: 'sourcehut',
523 url: links.repo,
524 stars: 0,
525 forks: 0,
526 links,
527 }
528 },
529}
530
531const tangledAdapter: ProviderAdapter = {
532 id: 'tangled',
533
534 parse(url) {
535 const host = url.hostname.toLowerCase()
536 if (
537 host !== 'tangled.sh' &&
538 host !== 'www.tangled.sh' &&
539 host !== 'tangled.org' &&
540 host !== 'www.tangled.org'
541 ) {
542 return null
543 }
544
545 const parts = url.pathname.split('/').filter(Boolean)
546 if (parts.length < 2) return null
547
548 // Tangled uses owner/repo format (owner is a domain-like identifier)
549 const owner = decodeURIComponent(parts[0] ?? '').trim()
550 const repo = decodeURIComponent(parts[1] ?? '')
551 .trim()
552 .replace(/\.git$/i, '')
553
554 if (!owner || !repo) return null
555
556 return { provider: 'tangled', owner, repo }
557 },
558
559 links(ref) {
560 const base = `https://tangled.org/${ref.owner}/${ref.repo}`
561 return {
562 repo: base,
563 stars: base, // Tangled shows stars on the repo page
564 forks: `${base}/fork`,
565 }
566 },
567
568 async fetchMeta(cachedFetch, ref, links, options = {}) {
569 try {
570 const { data } = await cachedFetch<{ stars: number; forks: number }>(
571 `/api/atproto/tangled-stats/${ref.owner}/${ref.repo}`,
572 options,
573 REPO_META_TTL,
574 )
575
576 return {
577 provider: 'tangled',
578 url: links.repo,
579 stars: data.stars,
580 forks: data.forks,
581 links,
582 }
583 } catch {
584 return {
585 provider: 'tangled',
586 url: links.repo,
587 stars: 0,
588 forks: 0,
589 links,
590 }
591 }
592 },
593}
594
595const radicleAdapter: ProviderAdapter = {
596 id: 'radicle',
597
598 parse(url) {
599 const host = url.hostname.toLowerCase()
600 if (host !== 'radicle.at' && host !== 'app.radicle.at' && host !== 'seed.radicle.at') {
601 return null
602 }
603
604 // Radicle URLs: app.radicle.at/nodes/seed.radicle.at/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT
605 const path = url.pathname
606 const radMatch = path.match(/rad:[a-zA-Z0-9]+/)
607 if (!radMatch?.[0]) return null
608
609 // Use empty owner, store full rad: ID as repo
610 return { provider: 'radicle', owner: '', repo: radMatch[0], host }
611 },
612
613 links(ref) {
614 const base = `https://app.radicle.at/nodes/seed.radicle.at/${ref.repo}`
615 return {
616 repo: base,
617 stars: base, // Radicle doesn't have stars, shows seeding count
618 forks: base,
619 }
620 },
621
622 async fetchMeta(cachedFetch, ref, links, options = {}) {
623 let res: RadicleProjectResponse | null = null
624 try {
625 const { data } = await cachedFetch<RadicleProjectResponse>(
626 `https://seed.radicle.at/api/v1/projects/${ref.repo}`,
627 { headers: { 'User-Agent': 'npmx', ...options.headers }, ...options },
628 REPO_META_TTL,
629 )
630 res = data
631 } catch {
632 return null
633 }
634
635 if (!res) return null
636
637 return {
638 provider: 'radicle',
639 url: links.repo,
640 // Use seeding count as a proxy for "stars" (number of nodes hosting this repo)
641 stars: res.seeding ?? 0,
642 forks: 0, // Radicle doesn't have forks in the traditional sense
643 description: res.description ?? null,
644 defaultBranch: res.defaultBranch,
645 links,
646 }
647 },
648}
649
650const forgejoAdapter: ProviderAdapter = {
651 id: 'forgejo',
652
653 parse(url) {
654 const host = url.hostname.toLowerCase()
655
656 // Match explicit Forgejo instances
657 const forgejoPatterns = [/^forgejo\./i, /\.forgejo\./i]
658 const knownInstances = ['next.forgejo.org', 'try.next.forgejo.org']
659
660 const isMatch = knownInstances.some(h => host === h) || forgejoPatterns.some(p => p.test(host))
661 if (!isMatch) return null
662
663 const parts = url.pathname.split('/').filter(Boolean)
664 if (parts.length < 2) return null
665
666 const owner = decodeURIComponent(parts[0] ?? '').trim()
667 const repo = decodeURIComponent(parts[1] ?? '')
668 .trim()
669 .replace(/\.git$/i, '')
670
671 if (!owner || !repo) return null
672
673 return { provider: 'forgejo', owner, repo, host }
674 },
675
676 links(ref) {
677 const base = `https://${ref.host}/${ref.owner}/${ref.repo}`
678 return {
679 repo: base,
680 stars: base,
681 forks: `${base}/forks`,
682 watchers: base,
683 }
684 },
685
686 async fetchMeta(cachedFetch, ref, links, options = {}) {
687 if (!ref.host) return null
688
689 let res: GiteaRepoResponse | null = null
690 try {
691 const { data } = await cachedFetch<GiteaRepoResponse>(
692 `https://${ref.host}/api/v1/repos/${ref.owner}/${ref.repo}`,
693 { headers: { 'User-Agent': 'npmx', ...options.headers }, ...options },
694 REPO_META_TTL,
695 )
696 res = data
697 } catch {
698 return null
699 }
700
701 if (!res) return null
702
703 return {
704 provider: 'forgejo',
705 url: links.repo,
706 stars: res.stars_count ?? 0,
707 forks: res.forks_count ?? 0,
708 watchers: res.watchers_count ?? 0,
709 description: res.description ?? null,
710 defaultBranch: res.default_branch,
711 links,
712 }
713 },
714}
715
716// Order matters: more specific adapters should come before generic ones
717const providers: readonly ProviderAdapter[] = [
718 githubAdapter,
719 gitlabAdapter,
720 bitbucketAdapter,
721 codebergAdapter,
722 giteeAdapter,
723 sourcehutAdapter,
724 tangledAdapter,
725 radicleAdapter,
726 forgejoAdapter,
727 giteaAdapter, // Generic Gitea adapter last as fallback for self-hosted instances
728] as const
729
730const parseRepoFromUrl = parseRepoUrl
731
732export function useRepoMeta(repositoryUrl: MaybeRefOrGetter<string | null | undefined>) {
733 // Get cachedFetch in setup context (outside async handler)
734 const cachedFetch = useCachedFetch()
735
736 const repoRef = computed(() => {
737 const url = toValue(repositoryUrl)
738 if (!url) return null
739 return parseRepoFromUrl(url)
740 })
741
742 const { data, pending, error, refresh } = useLazyAsyncData<RepoMeta | null>(
743 () =>
744 repoRef.value
745 ? `repo-meta:${repoRef.value.provider}:${repoRef.value.owner}/${repoRef.value.repo}`
746 : 'repo-meta:none',
747 async (_nuxtApp, { signal }) => {
748 const ref = repoRef.value
749 if (!ref) return null
750
751 const adapter = providers.find(provider => provider.id === ref.provider)
752 if (!adapter) return null
753
754 const links = adapter.links(ref)
755 return await adapter.fetchMeta(cachedFetch, ref, links, { signal })
756 },
757 )
758
759 const meta = computed<RepoMeta | null>(() => data.value ?? null)
760
761 return {
762 repoRef,
763 meta,
764
765 // TODO(serhalp): Consider removing the zero fallback so callers can make a distinction between
766 // "unresolved data" and "zero value"
767 stars: computed(() => meta.value?.stars ?? 0),
768 forks: computed(() => meta.value?.forks ?? 0),
769 watchers: computed(() => meta.value?.watchers ?? 0),
770
771 starsLink: computed(() => meta.value?.links.stars ?? null),
772 forksLink: computed(() => meta.value?.links.forks ?? null),
773 watchersLink: computed(() => meta.value?.links.watchers ?? null),
774 repoLink: computed(() => meta.value?.links.repo ?? null),
775
776 pending,
777 error,
778 refresh,
779 }
780}