Skip to content

Commit 6f6f695

Browse files
committed
feat: add radicle + forgejo, fix tangled meta, and ensure meta is fetched on ssr
1 parent 5c606dc commit 6f6f695

5 files changed

Lines changed: 303 additions & 48 deletions

File tree

app/composables/useNpmRegistry.ts

Lines changed: 24 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -162,40 +162,36 @@ export function usePackage(
162162
name: MaybeRefOrGetter<string>,
163163
requestedVersion?: MaybeRefOrGetter<string | null>,
164164
) {
165-
const asyncData = useLazyAsyncData(
165+
return useLazyAsyncData(
166166
() => `package:${toValue(name)}:${toValue(requestedVersion) ?? ''}`,
167-
() =>
168-
fetchNpmPackage(toValue(name)).then(r => transformPackument(r, toValue(requestedVersion))),
167+
async () => {
168+
const r = await fetchNpmPackage(toValue(name))
169+
const reqVer = toValue(requestedVersion)
170+
const pkg = transformPackument(r, reqVer)
171+
const resolvedVersion = getResolvedVersion(pkg, reqVer)
172+
return { ...pkg, resolvedVersion }
173+
},
169174
)
175+
}
170176

171-
// Resolve requestedVersion to an exact version
172-
// Handles: exact versions, dist-tags (latest, next), and semver ranges (^4.2, >=1.0.0)
173-
const resolvedVersion = computed(() => {
174-
const pkg = asyncData.data.value
175-
const reqVer = toValue(requestedVersion)
176-
if (!pkg || !reqVer) return null
177-
178-
// 1. Check if it's already an exact version in pkg.versions
179-
if (isExactVersion(reqVer) && pkg.versions[reqVer]) {
180-
return reqVer
181-
}
182-
183-
// 2. Check if it's a dist-tag (latest, next, beta, etc.)
184-
const tagVersion = pkg['dist-tags']?.[reqVer]
185-
if (tagVersion) {
186-
return tagVersion
187-
}
177+
function getResolvedVersion(pkg: SlimPackument, reqVer?: string | null): string | null {
178+
if (!pkg || !reqVer) return null
188179

189-
// 3. Try to resolve as a semver range
190-
const versions = Object.keys(pkg.versions)
191-
const resolved = maxSatisfying(versions, reqVer)
192-
return resolved
193-
})
180+
// 1. Check if it's already an exact version in pkg.versions
181+
if (isExactVersion(reqVer) && pkg.versions[reqVer]) {
182+
return reqVer
183+
}
194184

195-
return {
196-
...asyncData,
197-
resolvedVersion,
185+
// 2. Check if it's a dist-tag (latest, next, beta, etc.)
186+
const tagVersion = pkg['dist-tags']?.[reqVer]
187+
if (tagVersion) {
188+
return tagVersion
198189
}
190+
191+
// 3. Try to resolve as a semver range
192+
const versions = Object.keys(pkg.versions)
193+
const resolved = maxSatisfying(versions, reqVer)
194+
return resolved
199195
}
200196

201197
export function usePackageDownloads(

app/composables/useRepoMeta.ts

Lines changed: 146 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,19 @@ type GiteeRepoResponse = {
6969
watchers_count?: number
7070
}
7171

72+
/** Radicle API response for project details */
73+
type RadicleProjectResponse = {
74+
id: string
75+
name: string
76+
description?: string
77+
defaultBranch?: string
78+
head?: string
79+
seeding?: number
80+
delegates?: Array<{ id: string; alias?: string }>
81+
patches?: { open: number; draft: number; archived: number; merged: number }
82+
issues?: { open: number; closed: number }
83+
}
84+
7285
type ProviderAdapter = {
7386
id: ProviderId
7487
parse(url: URL): RepoRef | null
@@ -491,22 +504,146 @@ const tangledAdapter: ProviderAdapter = {
491504
},
492505

493506
links(ref) {
494-
const base = `https://tangled.sh/${ref.owner}/${ref.repo}`
507+
const base = `https://tangled.org/${ref.owner}/${ref.repo}`
495508
return {
496509
repo: base,
497510
stars: base, // Tangled shows stars on the repo page
498511
forks: `${base}/fork`,
499512
}
500513
},
501514

502-
async fetchMeta(_ref, links) {
503-
// Tangled doesn't have a public API for repo stats yet
504-
// Just return basic info without fetching
515+
async fetchMeta(ref, links) {
516+
// Tangled doesn't have a public JSON API, but we can scrape the star count
517+
// from the HTML page (it's in the hx-post URL as countHint=N)
518+
try {
519+
const html = await $fetch<string>(`https://tangled.org/${ref.owner}/${ref.repo}`, {
520+
headers: { 'User-Agent': 'npmx', 'Accept': 'text/html' },
521+
})
522+
// Extract star count from: hx-post="/star?subject=...&countHint=23"
523+
const starMatch = html.match(/countHint=(\d+)/)
524+
const stars = starMatch?.[1] ? parseInt(starMatch[1], 10) : 0
525+
526+
return {
527+
provider: 'tangled',
528+
url: links.repo,
529+
stars,
530+
forks: 0, // Tangled doesn't expose fork count
531+
links,
532+
}
533+
} catch {
534+
return {
535+
provider: 'tangled',
536+
url: links.repo,
537+
stars: 0,
538+
forks: 0,
539+
links,
540+
}
541+
}
542+
},
543+
}
544+
545+
const radicleAdapter: ProviderAdapter = {
546+
id: 'radicle',
547+
548+
parse(url) {
549+
const host = url.hostname.toLowerCase()
550+
if (host !== 'radicle.at' && host !== 'app.radicle.at' && host !== 'seed.radicle.at') {
551+
return null
552+
}
553+
554+
// Radicle URLs: app.radicle.at/nodes/seed.radicle.at/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT
555+
const path = url.pathname
556+
const radMatch = path.match(/rad:[a-zA-Z0-9]+/)
557+
if (!radMatch?.[0]) return null
558+
559+
// Use empty owner, store full rad: ID as repo
560+
return { provider: 'radicle', owner: '', repo: radMatch[0], host }
561+
},
562+
563+
links(ref) {
564+
const base = `https://app.radicle.at/nodes/seed.radicle.at/${ref.repo}`
505565
return {
506-
provider: 'tangled',
566+
repo: base,
567+
stars: base, // Radicle doesn't have stars, shows seeding count
568+
forks: base,
569+
}
570+
},
571+
572+
async fetchMeta(ref, links) {
573+
const res = await $fetch<RadicleProjectResponse>(
574+
`https://seed.radicle.at/api/v1/projects/${ref.repo}`,
575+
{ headers: { 'User-Agent': 'npmx' } },
576+
).catch(() => null)
577+
578+
if (!res) return null
579+
580+
return {
581+
provider: 'radicle',
507582
url: links.repo,
508-
stars: 0,
509-
forks: 0,
583+
// Use seeding count as a proxy for "stars" (number of nodes hosting this repo)
584+
stars: res.seeding ?? 0,
585+
forks: 0, // Radicle doesn't have forks in the traditional sense
586+
description: res.description ?? null,
587+
defaultBranch: res.defaultBranch,
588+
links,
589+
}
590+
},
591+
}
592+
593+
const forgejoAdapter: ProviderAdapter = {
594+
id: 'forgejo',
595+
596+
parse(url) {
597+
const host = url.hostname.toLowerCase()
598+
599+
// Match explicit Forgejo instances
600+
const forgejoPatterns = [/^forgejo\./i, /\.forgejo\./i]
601+
const knownInstances = ['next.forgejo.org', 'try.next.forgejo.org']
602+
603+
const isMatch = knownInstances.some(h => host === h) || forgejoPatterns.some(p => p.test(host))
604+
if (!isMatch) return null
605+
606+
const parts = url.pathname.split('/').filter(Boolean)
607+
if (parts.length < 2) return null
608+
609+
const owner = decodeURIComponent(parts[0] ?? '').trim()
610+
const repo = decodeURIComponent(parts[1] ?? '')
611+
.trim()
612+
.replace(/\.git$/i, '')
613+
614+
if (!owner || !repo) return null
615+
616+
return { provider: 'forgejo', owner, repo, host }
617+
},
618+
619+
links(ref) {
620+
const base = `https://${ref.host}/${ref.owner}/${ref.repo}`
621+
return {
622+
repo: base,
623+
stars: base,
624+
forks: `${base}/forks`,
625+
watchers: base,
626+
}
627+
},
628+
629+
async fetchMeta(ref, links) {
630+
if (!ref.host) return null
631+
632+
const res = await $fetch<GiteaRepoResponse>(
633+
`https://${ref.host}/api/v1/repos/${ref.owner}/${ref.repo}`,
634+
{ headers: { 'User-Agent': 'npmx' } },
635+
).catch(() => null)
636+
637+
if (!res) return null
638+
639+
return {
640+
provider: 'forgejo',
641+
url: links.repo,
642+
stars: res.stars_count ?? 0,
643+
forks: res.forks_count ?? 0,
644+
watchers: res.watchers_count ?? 0,
645+
description: res.description ?? null,
646+
defaultBranch: res.default_branch,
510647
links,
511648
}
512649
},
@@ -521,6 +658,8 @@ const providers: readonly ProviderAdapter[] = [
521658
giteeAdapter,
522659
sourcehutAdapter,
523660
tangledAdapter,
661+
radicleAdapter,
662+
forgejoAdapter,
524663
giteaAdapter, // Generic Gitea adapter last as fallback for self-hosted instances
525664
] as const
526665

app/pages/[...package].vue

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,6 @@ const orgName = computed(() => {
6767
return match ? match[1] : null
6868
})
6969
70-
const { data: pkg, status, error, resolvedVersion } = usePackage(packageName, requestedVersion)
71-
7270
const { data: downloads } = usePackageDownloads(packageName, 'last-week')
7371
const { data: weeklyDownloads } = usePackageWeeklyDownloadEvolution(packageName, { weeks: 52 })
7472
@@ -126,6 +124,9 @@ const sizeTooltip = computed(() => {
126124
return chunks.filter(Boolean).join('\n')
127125
})
128126
127+
const { data: pkg, status, error } = await usePackage(packageName, requestedVersion)
128+
const resolvedVersion = computed(() => pkg.value?.resolvedVersion ?? null)
129+
129130
// Get the version to display (resolved version or latest)
130131
const displayVersion = computed(() => {
131132
if (!pkg.value) return null
@@ -194,9 +195,11 @@ const PROVIDER_ICONS: Record<string, string> = {
194195
bitbucket: 'i-simple-icons-bitbucket',
195196
codeberg: 'i-simple-icons-codeberg',
196197
gitea: 'i-simple-icons-gitea',
198+
forgejo: 'i-simple-icons-forgejo',
197199
gitee: 'i-simple-icons-gitee',
198200
sourcehut: 'i-simple-icons-sourcehut',
199201
tangled: 'i-custom-tangled',
202+
radicle: 'i-carbon-network-3', // Radicle is a P2P network, using network icon
200203
}
201204
202205
const repoProviderIcon = computed(() => {

0 commit comments

Comments
 (0)