@@ -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+
7285type 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 ( / c o u n t H i n t = ( \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 ( / r a d : [ a - z A - Z 0 - 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 = [ / ^ f o r g e j o \. / i, / \. f o r g e j o \. / 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 ( / \. g i t $ / 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
0 commit comments