Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
185 changes: 185 additions & 0 deletions app/composables/useRepoMeta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
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 requestKey = computed(() => {
const ref = repoRef.value
if (!ref) return 'repo-meta:none'
return `repo-meta:${ref.provider}:${ref.owner}/${ref.repo}`
})

const { data, pending, error, refresh } = useLazyAsyncData<RepoMeta | null>(
requestKey,
async () => {
const ref = repoRef.value
if (!ref) return null
return await fetchRepoMeta(ref)
},
{ default: () => null },
)

watch(
repoRef,
ref => {
if (ref) refresh()
},
{ immediate: true },
)

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,
}
}
145 changes: 81 additions & 64 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 { stars, forks, forksLink } = useRepoMeta(repositoryUrl)

const homepageUrl = computed(() => {
return displayVersion.value?.homepage ?? null
})
Expand Down Expand Up @@ -292,69 +294,81 @@ defineOgImageComponent('Package', {
<!-- Package header -->
<header class="mb-8 pb-8 border-b border-border">
<div class="mb-4">
<!-- Package name and version -->
<div class="flex items-start gap-3 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"
>
<NuxtLink
v-if="orgName"
:to="{ name: 'org', params: { org: orgName } }"
class="text-fg-muted hover:text-fg transition-colors duration-200"
>@{{ orgName }}</NuxtLink
><span v-if="orgName">/</span
>{{ orgName ? pkg.name.replace(`@${orgName}/`, '') : pkg.name }}
</h1>
<a
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}`"
>
<span class="truncate max-w-32 sm:max-w-48"> v{{ displayVersion.version }} </span>
<span
v-if="
requestedVersion &&
latestVersion &&
displayVersion.version !== latestVersion.version
<div class="flex flex-row justify-between">
<!-- Package name and version -->
<div class="flex items-start gap-3 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"
>
<NuxtLink
v-if="orgName"
:to="{ name: 'org', params: { org: orgName } }"
class="text-fg-muted hover:text-fg transition-colors duration-200"
>@{{ orgName }}</NuxtLink
><span v-if="orgName">/</span
>{{ orgName ? pkg.name.replace(`@${orgName}/`, '') : pkg.name }}
</h1>
<a
v-if="displayVersion"
:href="
hasProvenance(displayVersion)
? `https://www.npmjs.com/package/${pkg.name}/v/${displayVersion.version}#provenance`
: undefined
"
class="text-fg-subtle shrink-0"
>(not latest)</span
: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}`"
>
<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 class="truncate max-w-32 sm:max-w-48"> v{{ displayVersion.version }} </span>
<span
v-if="
requestedVersion &&
latestVersion &&
displayVersion.version !== latestVersion.version
"
class="text-fg-subtle 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>

<!-- Package metrics (module format, types) -->
<ClientOnly>
<PackageMetricsBadges
v-if="displayVersion"
:package-name="pkg.name"
:version="displayVersion.version"
/>
<template #fallback>
<ul class="flex items-center gap-1.5">
<li class="skeleton w-8 h-5 rounded" />
<li class="skeleton w-12 h-5 rounded" />
</ul>
</template>
</ClientOnly>
<!-- Package metrics (module format, types) -->
<ClientOnly>
<PackageMetricsBadges
v-if="displayVersion"
:package-name="pkg.name"
:version="displayVersion.version"
/>
<template #fallback>
<ul class="flex items-center gap-1.5">
<li class="skeleton w-8 h-5 rounded" />
<li class="skeleton w-12 h-5 rounded" />
</ul>
</template>
</ClientOnly>
</div>
<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"
>
<span class="i-carbon-cube w-4 h-4" aria-hidden="true" />
npm
</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 @@ -481,7 +495,8 @@ 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="stars"> {{ formatCompactNumber(stars, { decimals: 1 }) }} stars </span>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this should link to starsLink rather than to repositoryUrl

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this replaces the previous repo link (in interests of saving space)

<span v-else>repo</span>
</a>
</li>
<li v-if="homepageUrl">
Expand All @@ -506,17 +521,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
Loading