Skip to content
Open
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
88 changes: 77 additions & 11 deletions app/pages/about.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Role } from '#server/api/contributors.get'
import type { Role, SocialAccount } from '#server/api/contributors.get'
import { SPONSORS } from '~/assets/logos/sponsors'
import { OSS_PARTNERS } from '~/assets/logos/oss-partners'

Expand Down Expand Up @@ -37,6 +37,47 @@ const communityContributors = computed(
() => contributors.value?.filter(c => c.role === 'contributor') ?? [],
)

const socialIcons: Record<string, string> = {
TWITTER: 'i-simple-icons:x',
MASTODON: 'i-simple-icons:mastodon',
BLUESKY: 'i-simple-icons:bluesky',
LINKEDIN: 'i-simple-icons:linkedin',
YOUTUBE: 'i-simple-icons:youtube',
HOMETOWN: 'i-lucide:globe',
DISCORD: 'i-simple-icons:discord',
Comment on lines +40 to +47
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "uno.config.ts" -o -name "uno.config.js" | head -5

Repository: npmx-dev/npmx.dev

Length of output: 76


🏁 Script executed:

wc -l app/pages/about.vue

Repository: npmx-dev/npmx.dev

Length of output: 84


🏁 Script executed:

head -100 app/pages/about.vue | tail -80

Repository: npmx-dev/npmx.dev

Length of output: 2307


🏁 Script executed:

cat uno.config.ts

Repository: npmx-dev/npmx.dev

Length of output: 8651


🏁 Script executed:

sed -n '272,295p' app/pages/about.vue

Repository: npmx-dev/npmx.dev

Length of output: 1134


🏁 Script executed:

sed -n '250,290p' app/pages/about.vue

Repository: npmx-dev/npmx.dev

Length of output: 1903


Register missing icon collections in UnoCSS configuration.

The socialIcons object uses i-simple-icons:* and i-lucide:* icons, but uno.config.ts only registers the custom collection. Without these collections configured, the icons won't resolve at build time despite being statically discoverable in the source code. Either register these collections in presetIcons or safelist the specific icon classes.

Affected locations
  • Lines 40–47: Icon definitions in socialIcons object
  • Line 273: Icon rendering via :class="[link.icon, 'w-3 h-3']"

}

function getSocialIcon(provider: string): string {
return socialIcons[provider] ?? 'i-lucide:link'
}

function getSocialLinks(person: {
twitterUsername: string | null
socialAccounts: SocialAccount[]
}): { provider: string; url: string; icon: string }[] {
const links: { provider: string; url: string; icon: string }[] = []

if (person.twitterUsername) {
links.push({
provider: 'TWITTER',
url: `https://x.com/${person.twitterUsername}`,
icon: socialIcons.TWITTER!,
})
}

for (const account of person.socialAccounts) {
// Skip twitter if already added via twitterUsername
if (account.provider === 'TWITTER') continue
links.push({
provider: account.provider,
url: account.url,
icon: getSocialIcon(account.provider),
})
}

return links
}

const roleLabels = computed(
() =>
({
Expand Down Expand Up @@ -209,17 +250,42 @@ const roleLabels = computed(
<div class="text-xs text-fg-muted tracking-tight">
{{ roleLabels[person.role] ?? person.role }}
</div>
<LinkBase
v-if="person.sponsors_url"
:to="person.sponsors_url"
no-underline
no-external-icon
classicon="i-lucide:heart"
class="relative z-10 text-xs text-fg-muted hover:text-pink-400 mt-0.5"
:aria-label="$t('about.team.sponsor_aria', { name: person.login })"
<div
v-if="person.bio"
class="text-xs text-fg-subtle truncate mt-0.5"
:title="person.bio"
>
{{ $t('about.team.sponsor') }}
</LinkBase>
{{ person.bio }}
</div>
<div class="flex items-center gap-1.5 mt-1">
<LinkBase
v-if="person.sponsors_url"
:to="person.sponsors_url"
no-underline
no-external-icon
classicon="i-lucide:heart"
class="relative z-10 text-xs text-fg-muted hover:text-pink-400"
:aria-label="$t('about.team.sponsor_aria', { name: person.login })"
>
{{ $t('about.team.sponsor') }}
</LinkBase>
<div
v-if="getSocialLinks(person).length"
class="relative z-10 flex items-center gap-1"
>
<a
v-for="link in getSocialLinks(person)"
:key="link.provider"
:href="link.url"
target="_blank"
rel="noopener noreferrer"
class="text-fg-muted hover:text-fg transition-colors"
:aria-label="`${person.login} on ${link.provider.toLowerCase()}`"
>
<span :class="[link.icon, 'w-3 h-3']" aria-hidden="true" />
Comment on lines +276 to +285
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Localise the social-link aria-labels.

This is hard-coded English on an otherwise translated page, and link.provider.toLowerCase() yields awkward labels such as hometown. Please route the label through $t(...) and map providers to user-facing names.

</a>
</div>
</div>
</div>
<span
class="i-lucide:external-link rtl-flip w-3.5 h-3.5 text-fg-muted opacity-50 shrink-0 self-start mt-0.5 pointer-events-none"
Expand Down
91 changes: 69 additions & 22 deletions server/api/contributors.get.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
export type Role = 'steward' | 'maintainer' | 'contributor'

export interface SocialAccount {
provider: string
url: string
}

export interface GitHubContributor {
login: string
id: number
Expand All @@ -8,9 +13,15 @@ export interface GitHubContributor {
contributions: number
role: Role
sponsors_url: string | null
bio: string | null
twitterUsername: string | null
socialAccounts: SocialAccount[]
}

type GitHubAPIContributor = Omit<GitHubContributor, 'role' | 'sponsors_url'>
type GitHubAPIContributor = Omit<
GitHubContributor,
'role' | 'sponsors_url' | 'bio' | 'twitterUsername' | 'socialAccounts'
>

// Fallback when no GitHub token is available (e.g. preview environments).
// Only stewards are shown as maintainers; everyone else is a contributor.
Expand Down Expand Up @@ -60,16 +71,31 @@ async function fetchTeamMembers(token: string): Promise<TeamMembers | null> {
}
}

interface GovernanceProfile {
hasSponsorsListing: boolean
bio: string | null
twitterUsername: string | null
socialAccounts: SocialAccount[]
}

/**
* Batch-query GitHub GraphQL API to check which users have sponsors enabled.
* Returns a Set of logins that have a sponsors listing.
* Batch-query GitHub GraphQL API to fetch profile data for governance members.
* Returns bio, social accounts, and sponsors listing status.
*/
async function fetchSponsorable(token: string, logins: string[]): Promise<Set<string>> {
if (logins.length === 0) return new Set()
async function fetchGovernanceProfiles(
token: string,
logins: string[],
): Promise<Map<string, GovernanceProfile>> {
if (logins.length === 0) return new Map()

// Build aliased GraphQL query: user0: user(login: "x") { hasSponsorsListing login }
const fragments = logins.map(
(login, i) => `user${i}: user(login: "${login}") { hasSponsorsListing login }`,
(login, i) => `user${i}: user(login: "${login}") {
login
hasSponsorsListing
bio
twitterUsername
socialAccounts(first: 10) { nodes { provider url } }
}`,
)
const query = `{ ${fragments.join('\n')} }`

Expand All @@ -85,26 +111,43 @@ async function fetchSponsorable(token: string, logins: string[]): Promise<Set<st
})

if (!response.ok) {
console.warn(`Failed to fetch sponsors info: ${response.status}`)
return new Set()
console.warn(`Failed to fetch governance profiles: ${response.status}`)
return new Map()
}

const json = (await response.json()) as {
data?: Record<string, { login: string; hasSponsorsListing: boolean } | null>
data?: Record<
string,
{
login: string
hasSponsorsListing: boolean
bio: string | null
twitterUsername: string | null
socialAccounts: { nodes: { provider: string; url: string }[] }
} | null
>
}

const sponsorable = new Set<string>()
const profiles = new Map<string, GovernanceProfile>()
if (json.data) {
for (const user of Object.values(json.data)) {
if (user?.hasSponsorsListing) {
sponsorable.add(user.login)
if (user) {
profiles.set(user.login, {
hasSponsorsListing: user.hasSponsorsListing,
bio: user.bio,
twitterUsername: user.twitterUsername,
socialAccounts: user.socialAccounts.nodes.map(n => ({
provider: n.provider,
url: n.url,
})),
})
}
}
}
return sponsorable
return profiles
} catch (error) {
console.warn('Failed to fetch sponsors info:', error)
return new Set()
console.warn('Failed to fetch governance profiles:', error)
return new Map()
Comment on lines 118 to +150
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

wc -l server/api/contributors.get.ts

Repository: npmx-dev/npmx.dev

Length of output: 95


🏁 Script executed:

cat -n server/api/contributors.get.ts

Repository: npmx-dev/npmx.dev

Length of output: 8837


🏁 Script executed:

# Search for any error handling around GraphQL responses
rg -A 5 -B 5 "json\.errors|graphql|GraphQL" server/api/contributors.get.ts

Repository: npmx-dev/npmx.dev

Length of output: 653


Handle GraphQL errors before accepting the payload.

GitHub GraphQL can return HTTP 200 with an errors array. Currently, line 118's type assertion omits the errors field entirely, so GraphQL errors are silently ignored. When json.data is absent or null (due to errors), the code returns an empty Map at line 147 with no explicit error logging—causing profile data (bios, social accounts, sponsor status) to vanish silently. This degraded state then gets cached for one hour. Add a check for json.errors and log it explicitly before processing json.data. This aligns with the coding guideline: "Use error handling patterns consistently" and "Ensure you write strictly type-safe code."

}
}

Expand Down Expand Up @@ -172,18 +215,22 @@ export default defineCachedEventHandler(
.filter(c => teams.steward.has(c.login) || teams.maintainer.has(c.login))
.map(c => c.login)

const sponsorable = githubToken
? await fetchSponsorable(githubToken, maintainerLogins)
: new Set<string>()
const governanceProfiles = githubToken
? await fetchGovernanceProfiles(githubToken, maintainerLogins)
: new Map<string, GovernanceProfile>()

return filtered
.map(c => {
const { role, order } = getRoleInfo(c.login, teams)
const sponsors_url = sponsorable.has(c.login)
const profile = governanceProfiles.get(c.login)
const sponsors_url = profile?.hasSponsorsListing
? `https://github.com/sponsors/${c.login}`
: null
Object.assign(c, { role, order, sponsors_url })
return c as GitHubContributor & { order: number; sponsors_url: string | null; role: Role }
const bio = profile?.bio ?? null
const twitterUsername = profile?.twitterUsername ?? null
const socialAccounts = profile?.socialAccounts ?? []
Comment on lines +218 to +231
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

The no-token fallback now strips sponsor links from every governance card.

When githubToken is missing, governanceProfiles is always empty, so sponsors_url becomes null for all governance members. That does not match the stated graceful-degradation behaviour for preview/tokenless environments. Please preserve a non-GraphQL fallback for that link if the sponsor CTA should remain available there.

Object.assign(c, { role, order, sponsors_url, bio, twitterUsername, socialAccounts })
return c as GitHubContributor & { order: number }
})
.sort((a, b) => a.order - b.order || b.contributions - a.contributions)
.map(({ order: _, ...rest }) => rest)
Expand Down
Loading