[READ-ONLY] a fast, modern browser for the npm registry
at main 780 lines 20 kB view raw
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}