Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions app/composables/useRepoMeta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
type ProviderId = 'github' // Could be extended to support other providers (gitlab, codeforge, tangled...)
export type RepoRef = { provider: ProviderId; owner: string; repo: string }

export type RepoMetaLinks = {
repo: string
stars: string
forks: string
watchers?: string
}

export type RepoMeta = {
provider: ProviderId
url: string
stars: number
forks: number
watchers?: number
description?: string | null
defaultBranch?: string
links: RepoMetaLinks
}

type UnghRepoResponse = {
repo: {
description?: string | null
stars?: number
forks?: number
watchers?: number
defaultBranch?: string
} | null
}

function normalizeInputToUrl(input: string): string | null {
const raw = input.trim()
if (!raw) return null

const normalized = raw.replace(/^git\+/, '')

if (!/^https?:\/\//i.test(normalized)) {
const scp = normalized.match(/^(?:git@)?([^:/]+):(.+)$/i)
if (scp?.[1] && scp?.[2]) {
const host = scp[1]
const path = scp[2].replace(/^\/*/, '')
return `https://${host}/${path}`
}
}

return normalized
}

type ProviderAdapter = {
id: ProviderId
parse(url: URL): RepoRef | null
links(ref: RepoRef): RepoMetaLinks
fetchMeta(ref: RepoRef, links: RepoMetaLinks): Promise<RepoMeta | null>
}

const githubAdapter: ProviderAdapter = {
id: 'github',

parse(url) {
const host = url.hostname.toLowerCase()
if (host !== 'github.com' && host !== 'www.github.com') return null

const parts = url.pathname.split('/').filter(Boolean)
if (parts.length < 2) return null

const owner = decodeURIComponent(parts[0] ?? '').trim()
const repo = decodeURIComponent(parts[1] ?? '')
.trim()
.replace(/\.git$/i, '')

if (!owner || !repo) return null

return { provider: 'github', owner, repo }
},

links(ref) {
const base = `https://github.com/${ref.owner}/${ref.repo}`
return {
repo: base,
stars: `${base}/stargazers`,
forks: `${base}/forks`,
watchers: `${base}/watchers`,
}
},

async fetchMeta(ref, links) {
// Using UNGH to avoid API limitations of the Github API
const res = await $fetch<UnghRepoResponse>(`https://ungh.cc/repos/${ref.owner}/${ref.repo}`, {
headers: { 'User-Agent': 'npmx' },
}).catch(() => null)

const repo = res?.repo
if (!repo) return null

return {
provider: 'github',
url: links.repo,
stars: repo.stars ?? 0,
forks: repo.forks ?? 0,
watchers: repo.watchers ?? 0,
description: repo.description ?? null,
defaultBranch: repo.defaultBranch,
links,
}
},
}

const providers: readonly ProviderAdapter[] = [githubAdapter] as const

function parseRepoFromUrl(input: string): RepoRef | null {
const normalized = normalizeInputToUrl(input)
if (!normalized) return null

try {
const url = new URL(normalized)
for (const provider of providers) {
const ref = provider.parse(url)
if (ref) return ref
}
return null
} catch {
return null
}
}

async function fetchRepoMeta(ref: RepoRef): Promise<RepoMeta | null> {
const adapter = providers.find(provider => provider.id === ref.provider)
if (!adapter) return null

const links = adapter.links(ref)
return await adapter.fetchMeta(ref, links)
}

export function useRepoMeta(repositoryUrl: MaybeRefOrGetter<string | null | undefined>) {
const repoRef = computed(() => {
const url = toValue(repositoryUrl)
if (!url) return null
return parseRepoFromUrl(url)
})

const { data, pending, error, refresh } = useLazyAsyncData<RepoMeta | null>(
() =>
repoRef.value
? `repo-meta:${repoRef.value.provider}:${repoRef.value.owner}/${repoRef.value.repo}`
: 'repo-meta:none',
async () => {
const ref = repoRef.value
if (!ref) return null
return await fetchRepoMeta(ref)
},
)

const meta = computed<RepoMeta | null>(() => data.value ?? null)

return {
repoRef,
meta,

stars: computed(() => meta.value?.stars ?? 0),
forks: computed(() => meta.value?.forks ?? 0),
watchers: computed(() => meta.value?.watchers ?? 0),

starsLink: computed(() => meta.value?.links.stars ?? null),
forksLink: computed(() => meta.value?.links.forks ?? null),
watchersLink: computed(() => meta.value?.links.watchers ?? null),
repoLink: computed(() => meta.value?.links.repo ?? null),

pending,
error,
refresh,
}
}
77 changes: 46 additions & 31 deletions app/pages/[...package].vue
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ const repositoryUrl = computed(() => {
return url
})

const { meta: repoMeta, stars, forks, forksLink } = useRepoMeta(repositoryUrl)

const homepageUrl = computed(() => {
return displayVersion.value?.homepage ?? null
})
Expand Down Expand Up @@ -281,7 +283,7 @@ defineOgImageComponent('Package', {
<header class="mb-8 pb-8 border-b border-border">
<div class="mb-4">
<!-- Package name and version -->
<div class="flex items-start gap-2 mb-1.5 sm:gap-3 sm:mb-2 flex-wrap min-w-0">
<div class="flex items-baseline gap-2 mb-1.5 sm:gap-3 sm:mb-2 flex-wrap min-w-0">
<h1
class="font-mono text-2xl sm:text-3xl font-medium min-w-0 break-words"
:title="pkg.name"
Expand All @@ -294,57 +296,65 @@ defineOgImageComponent('Package', {
><span v-if="orgName">/</span
>{{ orgName ? pkg.name.replace(`@${orgName}/`, '') : pkg.name }}
</h1>
<a
<span
v-if="displayVersion"
:href="
hasProvenance(displayVersion)
? `https://www.npmjs.com/package/${pkg.name}/v/${displayVersion.version}#provenance`
: undefined
"
:target="hasProvenance(displayVersion) ? '_blank' : undefined"
:rel="hasProvenance(displayVersion) ? 'noopener noreferrer' : undefined"
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"
:class="
hasProvenance(displayVersion)
? 'hover:border-border-hover cursor-pointer'
: 'cursor-default'
"
:title="`v${displayVersion.version}`"
class="inline-flex items-baseline gap-1.5 font-mono text-base sm:text-lg text-fg-muted shrink-0"
>
<span class="truncate max-w-24 sm:max-w-32 md:max-w-48">
<a
v-if="hasProvenance(displayVersion)"
:href="`https://www.npmjs.com/package/${pkg.name}/v/${displayVersion.version}#provenance`"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-fg-muted hover:text-fg-muted/80 transition-colors duration-200"
title="Verified provenance"
>
v{{ displayVersion.version }}
</span>
<span
class="i-solar-shield-check-outline w-3.5 h-3.5 shrink-0"
aria-hidden="true"
/>
</a>
<span v-else>v{{ displayVersion.version }}</span>
<span
v-if="
requestedVersion &&
latestVersion &&
displayVersion.version !== latestVersion.version
"
class="text-fg-subtle shrink-0"
class="text-fg-subtle text-sm shrink-0"
>(not latest)</span
>
<span
v-if="hasProvenance(displayVersion)"
class="i-solar-shield-check-outline w-4 h-4 text-fg-muted shrink-0"
aria-label="Verified provenance"
/>
</a>
</span>

<!-- Package metrics (module format, types) -->
<ClientOnly>
<PackageMetricsBadges
v-if="displayVersion"
:package-name="pkg.name"
:version="displayVersion.version"
class="self-center ml-1 sm:ml-2"
/>
<template #fallback>
<ul class="flex items-center gap-1.5">
<ul class="flex items-center gap-1.5 self-center ml-1 sm:ml-2">
<li class="skeleton w-8 h-5 rounded" />
<li class="skeleton w-12 h-5 rounded" />
</ul>
</template>
</ClientOnly>

<a
:href="`https://www.npmjs.com/package/${pkg.name}`"
target="_blank"
rel="noopener noreferrer"
class="link-subtle font-mono text-sm inline-flex items-center gap-1.5 ml-auto shrink-0 self-center"
title="View on npm"
>
<span class="i-carbon-logo-npm w-4 h-4" aria-hidden="true" />
<span class="hidden sm:inline">npm</span>
<span class="sr-only sm:hidden">View on npm</span>
</a>
</div>

<!-- Fixed height description container to prevent CLS -->
<div ref="descriptionRef" class="relative max-w-2xl min-h-[4.5rem]">
<p
Expand Down Expand Up @@ -471,7 +481,10 @@ defineOgImageComponent('Package', {
class="link-subtle font-mono text-sm inline-flex items-center gap-1.5"
>
<span class="i-carbon-logo-github w-4 h-4" aria-hidden="true" />
repo
<span v-if="repoMeta">
{{ formatCompactNumber(stars, { decimals: 1 }) }} stars
</span>
<span v-else>repo</span>
</a>
</li>
<li v-if="homepageUrl">
Expand All @@ -496,17 +509,19 @@ defineOgImageComponent('Package', {
issues
</a>
</li>
<li>

<li v-if="forks && forksLink">
<a
:href="`https://www.npmjs.com/package/${pkg.name}`"
:href="forksLink"
target="_blank"
rel="noopener noreferrer"
class="link-subtle font-mono text-sm inline-flex items-center gap-1.5"
>
<span class="i-carbon-cube w-4 h-4" aria-hidden="true" />
npm
<span class="i-carbon-fork w-4 h-4" aria-hidden="true" />
<span>{{ formatCompactNumber(forks, { decimals: 1 }) }} forks</span>
</a>
</li>

<li v-if="jsrInfo?.exists && jsrInfo.url">
<a
:href="jsrInfo.url"
Expand Down
28 changes: 28 additions & 0 deletions app/utils/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,31 @@ export function toIsoDateString(date: Date): string {
const day = String(date.getUTCDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}

export function formatCompactNumber(
value: number,
options?: { decimals?: number; space?: boolean },
): string {
const decimals = options?.decimals ?? 0
const space = options?.space ?? false

const sign = value < 0 ? '-' : ''
const abs = Math.abs(value)

const fmt = (n: number) => {
if (decimals <= 0) return Math.round(n).toString()
return n
.toFixed(decimals)
.replace(/\.0+$/, '')
.replace(/(\.\d*?)0+$/, '$1')
}

const join = (suffix: string, n: number) => `${sign}${fmt(n)}${space ? ' ' : ''}${suffix}`

if (abs >= 1e12) return join('T', abs / 1e12)
if (abs >= 1e9) return join('B', abs / 1e9)
if (abs >= 1e6) return join('M', abs / 1e6)
if (abs >= 1e3) return join('k', abs / 1e3)

return `${sign}${Math.round(abs)}`
}