[READ-ONLY] a fast, modern browser for the npm registry

feat: sort contributors list by role (#1369)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by shuuji3.xyz

Daniel Roe and committed by
GitHub
307c21c1 0a2b3957

+332 -46
+134 -38
app/pages/about.vue
··· 1 1 <script setup lang="ts"> 2 + import type { Role } from '#server/api/contributors.get' 3 + 2 4 const router = useRouter() 3 5 const canGoBack = useCanGoBack() 4 6 ··· 27 29 } 28 30 29 31 const { data: contributors, status: contributorsStatus } = useLazyFetch('/api/contributors') 32 + 33 + const governanceMembers = computed( 34 + () => contributors.value?.filter(c => c.role !== 'contributor') ?? [], 35 + ) 36 + 37 + const communityContributors = computed( 38 + () => contributors.value?.filter(c => c.role === 'contributor') ?? [], 39 + ) 40 + 41 + const roleLabels = computed( 42 + () => 43 + ({ 44 + steward: $t('about.team.role_steward'), 45 + maintainer: $t('about.team.role_maintainer'), 46 + }) as Partial<Record<Role, string>>, 47 + ) 30 48 </script> 31 49 32 50 <template> ··· 139 157 140 158 <div> 141 159 <h2 class="text-lg text-fg-subtle uppercase tracking-wider mb-4"> 142 - {{ 143 - $t( 144 - 'about.contributors.title', 145 - { count: $n(contributors?.length ?? 0) }, 146 - contributors?.length ?? 0, 147 - ) 148 - }} 160 + {{ $t('about.team.title') }} 149 161 </h2> 150 162 <p class="text-fg-muted leading-relaxed mb-6"> 151 163 {{ $t('about.contributors.description') }} 152 164 </p> 153 165 154 - <!-- Contributors cloud --> 155 - <div v-if="contributorsStatus === 'pending'" class="text-fg-subtle text-sm"> 156 - {{ $t('about.contributors.loading') }} 157 - </div> 158 - <div v-else-if="contributorsStatus === 'error'" class="text-fg-subtle text-sm"> 159 - {{ $t('about.contributors.error') }} 160 - </div> 161 - <div 162 - v-else-if="contributors?.length" 163 - class="grid grid-cols-[repeat(auto-fill,48px)] justify-center gap-2" 166 + <!-- Governance: stewards + maintainers --> 167 + <section 168 + v-if="governanceMembers.length" 169 + class="mb-12" 170 + aria-labelledby="governance-heading" 164 171 > 165 - <a 166 - v-for="contributor in contributors" 167 - :key="contributor.id" 168 - :href="contributor.html_url" 169 - target="_blank" 170 - rel="noopener noreferrer" 171 - class="group relative" 172 - :aria-label="$t('about.contributors.view_profile', { name: contributor.login })" 172 + <h3 173 + id="governance-heading" 174 + class="text-sm text-fg-subtle uppercase tracking-wider mb-4" 173 175 > 174 - <div class="relative flex items-center"> 176 + {{ $t('about.team.governance') }} 177 + </h3> 178 + 179 + <ul class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 list-none p-0"> 180 + <li 181 + v-for="person in governanceMembers" 182 + :key="person.id" 183 + class="relative flex items-center gap-3 p-3 border border-border rounded-lg hover:border-border-hover hover:bg-bg-muted transition-[border-color,background-color] duration-200 cursor-pointer focus-within:ring-2 focus-within:ring-offset-bg focus-within:ring-offset-2 focus-within:ring-fg/50" 184 + > 175 185 <img 176 - :src="`${contributor.avatar_url}&s=64`" 177 - :alt="contributor.login" 178 - width="32" 179 - height="32" 180 - class="w-12 h-12 rounded-lg ring-2 ring-transparent group-hover:ring-accent transition-all duration-200 ease-out hover:scale-125 will-change-transform" 186 + :src="`${person.avatar_url}&s=80`" 187 + :alt="`${person.login}'s avatar`" 188 + class="w-12 h-12 rounded-md ring-1 ring-border shrink-0" 181 189 loading="lazy" 182 190 /> 191 + <div class="min-w-0 flex-1"> 192 + <div class="font-mono text-sm text-fg truncate"> 193 + <NuxtLink 194 + :to="person.html_url" 195 + target="_blank" 196 + class="decoration-none after:content-[''] after:absolute after:inset-0" 197 + :aria-label="$t('about.contributors.view_profile', { name: person.login })" 198 + > 199 + @{{ person.login }} 200 + </NuxtLink> 201 + </div> 202 + <div class="text-xs text-fg-muted tracking-tight"> 203 + {{ roleLabels[person.role] ?? person.role }} 204 + </div> 205 + <LinkBase 206 + v-if="person.sponsors_url" 207 + :to="person.sponsors_url" 208 + no-underline 209 + no-external-icon 210 + classicon="i-carbon:favorite" 211 + class="relative z-10 text-xs text-fg-muted hover:text-pink-400 mt-0.5" 212 + :aria-label="$t('about.team.sponsor_aria', { name: person.login })" 213 + > 214 + {{ $t('about.team.sponsor') }} 215 + </LinkBase> 216 + </div> 183 217 <span 184 - class="pointer-events-none absolute -top-9 inset-is-1/2 -translate-x-1/2 whitespace-nowrap rounded-md bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 text-xs px-2 py-1 shadow-lg opacity-0 scale-95 transition-all duration-150 group-hover:opacity-100 group-hover:scale-100" 185 - dir="ltr" 218 + class="i-carbon:launch rtl-flip w-3.5 h-3.5 text-fg-muted opacity-50 shrink-0 self-start mt-0.5" 219 + aria-hidden="true" 220 + /> 221 + </li> 222 + </ul> 223 + </section> 224 + 225 + <!-- Contributors cloud --> 226 + <section aria-labelledby="contributors-heading"> 227 + <h3 228 + id="contributors-heading" 229 + class="text-sm text-fg-subtle uppercase tracking-wider mb-4" 230 + > 231 + {{ 232 + $t( 233 + 'about.contributors.title', 234 + { count: $n(communityContributors.length) }, 235 + communityContributors.length, 236 + ) 237 + }} 238 + </h3> 239 + 240 + <div 241 + v-if="contributorsStatus === 'pending'" 242 + class="text-fg-subtle text-sm" 243 + role="status" 244 + > 245 + {{ $t('about.contributors.loading') }} 246 + </div> 247 + <div 248 + v-else-if="contributorsStatus === 'error'" 249 + class="text-fg-subtle text-sm" 250 + role="alert" 251 + > 252 + {{ $t('about.contributors.error') }} 253 + </div> 254 + <ul 255 + v-else-if="communityContributors.length" 256 + class="grid grid-cols-[repeat(auto-fill,48px)] justify-center gap-2 list-none p-0" 257 + > 258 + <li 259 + v-for="contributor in communityContributors" 260 + :key="contributor.id" 261 + class="group relative" 262 + > 263 + <LinkBase 264 + :to="contributor.html_url" 265 + no-underline 266 + no-external-icon 267 + :aria-label="$t('about.contributors.view_profile', { name: contributor.login })" 186 268 > 187 - @{{ contributor.login }} 188 - </span> 189 - </div> 190 - </a> 191 - </div> 269 + <img 270 + :src="`${contributor.avatar_url}&s=64`" 271 + :alt="`${contributor.login}'s avatar`" 272 + width="48" 273 + height="48" 274 + class="w-12 h-12 rounded-lg ring-2 ring-transparent group-hover:ring-accent transition-all duration-200 ease-out hover:scale-125 will-change-transform" 275 + loading="lazy" 276 + /> 277 + <span 278 + class="pointer-events-none absolute -top-9 inset-is-1/2 -translate-x-1/2 whitespace-nowrap rounded-md bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 text-xs px-2 py-1 shadow-lg opacity-0 scale-95 transition-all duration-150 group-hover:opacity-100 group-hover:scale-100" 279 + dir="ltr" 280 + role="tooltip" 281 + > 282 + @{{ contributor.login }} 283 + </span> 284 + </LinkBase> 285 + </li> 286 + </ul> 287 + </section> 192 288 </div> 193 289 194 290 <CallToAction />
+9 -1
i18n/locales/en.json
··· 810 810 "managers": "package managers" 811 811 } 812 812 }, 813 + "team": { 814 + "title": "Team", 815 + "governance": "Governance", 816 + "role_steward": "steward", 817 + "role_maintainer": "maintainer", 818 + "sponsor": "sponsor", 819 + "sponsor_aria": "Sponsor {name} on GitHub" 820 + }, 813 821 "contributors": { 814 - "title": "{count} Contributor | {count} Contributors", 822 + "title": "... and {count} more contributor | ... and {count} more contributors", 815 823 "description": "npmx is fully open source, built by an amazing community of contributors. Join us and let's build the npm browsing experience we always wanted, together.", 816 824 "loading": "Loading contributors...", 817 825 "error": "Failed to load contributors",
+24
i18n/schema.json
··· 2434 2434 }, 2435 2435 "additionalProperties": false 2436 2436 }, 2437 + "team": { 2438 + "type": "object", 2439 + "properties": { 2440 + "title": { 2441 + "type": "string" 2442 + }, 2443 + "governance": { 2444 + "type": "string" 2445 + }, 2446 + "role_steward": { 2447 + "type": "string" 2448 + }, 2449 + "role_maintainer": { 2450 + "type": "string" 2451 + }, 2452 + "sponsor": { 2453 + "type": "string" 2454 + }, 2455 + "sponsor_aria": { 2456 + "type": "string" 2457 + } 2458 + }, 2459 + "additionalProperties": false 2460 + }, 2437 2461 "contributors": { 2438 2462 "type": "object", 2439 2463 "properties": {
+9 -1
lunaria/files/en-GB.json
··· 809 809 "managers": "package managers" 810 810 } 811 811 }, 812 + "team": { 813 + "title": "Team", 814 + "governance": "Governance", 815 + "role_steward": "steward", 816 + "role_maintainer": "maintainer", 817 + "sponsor": "sponsor", 818 + "sponsor_aria": "Sponsor {name} on GitHub" 819 + }, 812 820 "contributors": { 813 - "title": "{count} Contributor | {count} Contributors", 821 + "title": "... and {count} more contributor | ... and {count} more contributors", 814 822 "description": "npmx is fully open source, built by an amazing community of contributors. Join us and let's build the npm browsing experience we always wanted, together.", 815 823 "loading": "Loading contributors...", 816 824 "error": "Failed to load contributors",
+9 -1
lunaria/files/en-US.json
··· 809 809 "managers": "package managers" 810 810 } 811 811 }, 812 + "team": { 813 + "title": "Team", 814 + "governance": "Governance", 815 + "role_steward": "steward", 816 + "role_maintainer": "maintainer", 817 + "sponsor": "sponsor", 818 + "sponsor_aria": "Sponsor {name} on GitHub" 819 + }, 812 820 "contributors": { 813 - "title": "{count} Contributor | {count} Contributors", 821 + "title": "... and {count} more contributor | ... and {count} more contributors", 814 822 "description": "npmx is fully open source, built by an amazing community of contributors. Join us and let's build the npm browsing experience we always wanted, together.", 815 823 "loading": "Loading contributors...", 816 824 "error": "Failed to load contributors",
+3
nuxt.config.ts
··· 34 34 35 35 runtimeConfig: { 36 36 sessionPassword: '', 37 + github: { 38 + orgToken: '', 39 + }, 37 40 // Upstash Redis for distributed OAuth token refresh locking in production 38 41 upstash: { 39 42 redisRestUrl: process.env.UPSTASH_KV_REST_API_URL || process.env.KV_REST_API_URL || '',
+144 -5
server/api/contributors.get.ts
··· 1 + export type Role = 'steward' | 'maintainer' | 'contributor' 2 + 1 3 export interface GitHubContributor { 2 4 login: string 3 5 id: number 4 6 avatar_url: string 5 7 html_url: string 6 8 contributions: number 9 + role: Role 10 + sponsors_url: string | null 11 + } 12 + 13 + type GitHubAPIContributor = Omit<GitHubContributor, 'role' | 'sponsors_url'> 14 + 15 + // Fallback when no GitHub token is available (e.g. preview environments). 16 + // Only stewards are shown as maintainers; everyone else is a contributor. 17 + const FALLBACK_STEWARDS = new Set(['danielroe', 'patak-dev']) 18 + 19 + interface TeamMembers { 20 + steward: Set<string> 21 + maintainer: Set<string> 22 + } 23 + 24 + async function fetchTeamMembers(token: string): Promise<TeamMembers | null> { 25 + const teams: Record<keyof TeamMembers, string> = { 26 + steward: 'stewards', 27 + maintainer: 'maintainers', 28 + } 29 + 30 + try { 31 + const result: TeamMembers = { steward: new Set(), maintainer: new Set() } 32 + 33 + for (const [role, slug] of Object.entries(teams) as [keyof TeamMembers, string][]) { 34 + const response = await fetch( 35 + `https://api.github.com/orgs/npmx-dev/teams/${slug}/members?per_page=100`, 36 + { 37 + headers: { 38 + 'Accept': 'application/vnd.github.v3+json', 39 + 'Authorization': `Bearer ${token}`, 40 + 'User-Agent': 'npmx', 41 + }, 42 + }, 43 + ) 44 + 45 + if (!response.ok) { 46 + console.warn(`Failed to fetch ${slug} team members: ${response.status}`) 47 + return null 48 + } 49 + 50 + const members = (await response.json()) as { login: string }[] 51 + for (const member of members) { 52 + result[role].add(member.login) 53 + } 54 + } 55 + 56 + return result 57 + } catch (error) { 58 + console.warn('Failed to fetch team members from GitHub:', error) 59 + return null 60 + } 61 + } 62 + 63 + /** 64 + * Batch-query GitHub GraphQL API to check which users have sponsors enabled. 65 + * Returns a Set of logins that have a sponsors listing. 66 + */ 67 + async function fetchSponsorable(token: string, logins: string[]): Promise<Set<string>> { 68 + if (logins.length === 0) return new Set() 69 + 70 + // Build aliased GraphQL query: user0: user(login: "x") { hasSponsorsListing login } 71 + const fragments = logins.map( 72 + (login, i) => `user${i}: user(login: "${login}") { hasSponsorsListing login }`, 73 + ) 74 + const query = `{ ${fragments.join('\n')} }` 75 + 76 + try { 77 + const response = await fetch('https://api.github.com/graphql', { 78 + method: 'POST', 79 + headers: { 80 + 'Authorization': `Bearer ${token}`, 81 + 'Content-Type': 'application/json', 82 + 'User-Agent': 'npmx', 83 + }, 84 + body: JSON.stringify({ query }), 85 + }) 86 + 87 + if (!response.ok) { 88 + console.warn(`Failed to fetch sponsors info: ${response.status}`) 89 + return new Set() 90 + } 91 + 92 + const json = (await response.json()) as { 93 + data?: Record<string, { login: string; hasSponsorsListing: boolean } | null> 94 + } 95 + 96 + const sponsorable = new Set<string>() 97 + if (json.data) { 98 + for (const user of Object.values(json.data)) { 99 + if (user?.hasSponsorsListing) { 100 + sponsorable.add(user.login) 101 + } 102 + } 103 + } 104 + return sponsorable 105 + } catch (error) { 106 + console.warn('Failed to fetch sponsors info:', error) 107 + return new Set() 108 + } 109 + } 110 + 111 + function getRoleInfo(login: string, teams: TeamMembers): { role: Role; order: number } { 112 + if (teams.steward.has(login)) return { role: 'steward', order: 0 } 113 + if (teams.maintainer.has(login)) return { role: 'maintainer', order: 1 } 114 + return { role: 'contributor', order: 2 } 7 115 } 8 116 9 117 export default defineCachedEventHandler( 10 118 async (): Promise<GitHubContributor[]> => { 11 - const allContributors: GitHubContributor[] = [] 119 + const githubToken = useRuntimeConfig().github.orgToken 120 + 121 + // Fetch team members dynamically if token is available, otherwise use fallback 122 + const teams: TeamMembers = await (async () => { 123 + if (githubToken) { 124 + const fetched = await fetchTeamMembers(githubToken) 125 + if (fetched) return fetched 126 + } 127 + return { steward: FALLBACK_STEWARDS, maintainer: new Set<string>() } 128 + })() 129 + 130 + const allContributors: GitHubAPIContributor[] = [] 12 131 let page = 1 13 132 const perPage = 100 14 133 ··· 19 138 headers: { 20 139 'Accept': 'application/vnd.github.v3+json', 21 140 'User-Agent': 'npmx', 141 + ...(githubToken && { Authorization: `Bearer ${githubToken}` }), 22 142 }, 23 143 }, 24 144 ) ··· 30 150 }) 31 151 } 32 152 33 - const contributors = (await response.json()) as GitHubContributor[] 153 + const contributors = (await response.json()) as GitHubAPIContributor[] 34 154 35 155 if (contributors.length === 0) { 36 156 break ··· 38 158 39 159 allContributors.push(...contributors) 40 160 41 - // If we got fewer than perPage results, we've reached the end 42 161 if (contributors.length < perPage) { 43 162 break 44 163 } ··· 46 165 page++ 47 166 } 48 167 49 - // Filter out bots 50 - return allContributors.filter(c => !c.login.includes('[bot]')) 168 + const filtered = allContributors.filter(c => !c.login.includes('[bot]')) 169 + 170 + // Identify maintainers (stewards + maintainers) and check their sponsors status 171 + const maintainerLogins = filtered 172 + .filter(c => teams.steward.has(c.login) || teams.maintainer.has(c.login)) 173 + .map(c => c.login) 174 + 175 + const sponsorable = githubToken 176 + ? await fetchSponsorable(githubToken, maintainerLogins) 177 + : new Set<string>() 178 + 179 + return filtered 180 + .map(c => { 181 + const { role, order } = getRoleInfo(c.login, teams) 182 + const sponsors_url = sponsorable.has(c.login) 183 + ? `https://github.com/sponsors/${c.login}` 184 + : null 185 + Object.assign(c, { role, order, sponsors_url }) 186 + return c as GitHubContributor & { order: number; sponsors_url: string | null; role: Role } 187 + }) 188 + .sort((a, b) => a.order - b.order || b.contributions - a.contributions) 189 + .map(({ order: _, ...rest }) => rest) 51 190 }, 52 191 { 53 192 maxAge: 3600, // Cache for 1 hour