diff --git a/app/components/Link/Base.vue b/app/components/Link/Base.vue index 4eeecda2b..679810bb3 100644 --- a/app/components/Link/Base.vue +++ b/app/components/Link/Base.vue @@ -35,8 +35,15 @@ const props = withDefaults( /** should only be used for links where the context makes it very clear they are clickable. Don't just use this, because you don't like underlines. */ noUnderline?: boolean + + /** + * When `true`, suppresses the external-link icon even for external `to` URLs. + * + * @default false + */ + noExternalIcon?: boolean }>(), - { variant: 'link', size: 'medium' }, + { variant: 'link', size: 'medium', noUnderline: false, noExternalIcon: false }, ) const isLinkExternal = computed( @@ -99,7 +106,7 @@ const isButtonMedium = computed(() => props.size === 'medium' && !isLink.value) diff --git a/app/pages/about.vue b/app/pages/about.vue index d14ee212f..5a164b53d 100644 --- a/app/pages/about.vue +++ b/app/pages/about.vue @@ -1,5 +1,5 @@ @@ -56,10 +204,10 @@ const roleLabels = computed( {{ $t('about.heading') }} {{ $t('nav.back') }} @@ -162,67 +310,6 @@ const roleLabels = computed( {{ $t('about.contributors.description') }} - - - - - {{ $t('about.team.governance') }} - - - - - - - - - @{{ person.login }} - - - - {{ roleLabels[person.role] ?? person.role }} - - - {{ $t('about.team.sponsor') }} - - - - - - - - @@ -252,43 +339,196 @@ const roleLabels = computed( {{ $t('about.contributors.error') }} - - @{{ contributor.login }} - - + + + + + + + {{ activeContributor.name || activeContributor.login }} + + + {{ roleLabels[activeContributor.role] }} + + + "{{ activeContributor.bio }}" + + + + + + + + + + + {{ activeContributor.company }} + + + + + + + + {{ activeContributor.location }} + + + + + {{ + activeContributor.websiteUrl.replace(/^https?:\/\//, '') + }} + + + + @{{ activeContributor.twitterUsername }} + + + + + + @{{ activeContributor.login }} + + + + {{ $t('about.team.sponsor') }} + + + + + + + + + diff --git a/i18n/locales/ar.json b/i18n/locales/ar.json index cfcde2921..fbff4a8cf 100644 --- a/i18n/locales/ar.json +++ b/i18n/locales/ar.json @@ -820,7 +820,6 @@ }, "team": { "title": "الفريق", - "governance": "الحوكمة", "role_steward": "راعي", "role_maintainer": "مشرف", "sponsor": "راعي", diff --git a/i18n/locales/cs-CZ.json b/i18n/locales/cs-CZ.json index f935947ea..ad0635fe2 100644 --- a/i18n/locales/cs-CZ.json +++ b/i18n/locales/cs-CZ.json @@ -820,7 +820,6 @@ }, "team": { "title": "Tým", - "governance": "Správa", "role_steward": "Vedoucí", "role_maintainer": "Správce", "sponsor": "Sponzor", diff --git a/i18n/locales/de-DE.json b/i18n/locales/de-DE.json index 59ad603c9..ef9908189 100644 --- a/i18n/locales/de-DE.json +++ b/i18n/locales/de-DE.json @@ -821,7 +821,6 @@ }, "team": { "title": "Team", - "governance": "Verwaltung", "role_steward": "Verwalter", "role_maintainer": "Maintainer", "sponsor": "Sponsor", diff --git a/i18n/locales/en.json b/i18n/locales/en.json index bd30ad861..f95317819 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -823,18 +823,23 @@ }, "team": { "title": "Team", - "governance": "Governance", "role_steward": "steward", "role_maintainer": "maintainer", "sponsor": "sponsor", "sponsor_aria": "Sponsor {name} on GitHub" }, "contributors": { - "title": "... and {count} more contributor | ... and {count} more contributors", + "title": "{count} contributor | {count} contributors", "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.", "loading": "Loading contributors...", "error": "Failed to load contributors", - "view_profile": "View {name}'s GitHub profile" + "view_profile": "View {name}'s GitHub profile", + "avatar": "{name}'s avatar", + "view_profile_detailed": "View {name}'s GitHub profile{role}{works_at}{location}", + "separator": " — ", + "role": "{separator}{role} at npmx.dev", + "works_at": "{separator}works at {company}", + "location": "{separator}based in {location}" }, "get_involved": { "title": "Get involved", diff --git a/i18n/locales/es.json b/i18n/locales/es.json index 5b11285d0..72d2da4a3 100644 --- a/i18n/locales/es.json +++ b/i18n/locales/es.json @@ -816,7 +816,6 @@ }, "team": { "title": "Equipo", - "governance": "Gobernanza", "role_steward": "Administrador", "role_maintainer": "Mantenedor", "sponsor": "Patrocinar", @@ -827,7 +826,13 @@ "description": "npmx es completamente de código abierto, construido por una increíble comunidad de colaboradores. Únete a nosotros y construyamos juntos la experiencia de navegación de npm que siempre quisimos.", "loading": "Cargando colaboradores...", "error": "Error al cargar colaboradores", - "view_profile": "Ver perfil de GitHub de {name}" + "view_profile": "Ver perfil de GitHub de {name}", + "avatar": "avatar de {name}", + "view_profile_detailed": "Ver el perfil de GitHub de {name}{role}{works_at}{location}", + "separator": " — ", + "role": "{separator}{role} en npmx.dev", + "works_at": "{separator}trabaja en {company}", + "location": "{separator}ubicado en {location}" }, "get_involved": { "title": "Involúcrate", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 9e2555190..bc2072987 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -822,7 +822,6 @@ }, "team": { "title": "Équipe", - "governance": "Gouvernance", "role_steward": "pilote", "role_maintainer": "mainteneur", "sponsor": "sponsor", diff --git a/i18n/locales/ja-JP.json b/i18n/locales/ja-JP.json index 112bd4a70..21a6e332f 100644 --- a/i18n/locales/ja-JP.json +++ b/i18n/locales/ja-JP.json @@ -820,7 +820,6 @@ }, "team": { "title": "チーム", - "governance": "ガバナンス", "role_steward": "スチュワード", "role_maintainer": "メンテナ", "sponsor": "スポンサー", diff --git a/i18n/locales/pl-PL.json b/i18n/locales/pl-PL.json index b30bae2ba..1d0bc9e72 100644 --- a/i18n/locales/pl-PL.json +++ b/i18n/locales/pl-PL.json @@ -821,7 +821,6 @@ }, "team": { "title": "Zespół", - "governance": "Zarządzanie", "role_steward": "steward", "role_maintainer": "maintainer", "sponsor": "sponsor", diff --git a/i18n/locales/uk-UA.json b/i18n/locales/uk-UA.json index 2b1ead44c..71250e7b2 100644 --- a/i18n/locales/uk-UA.json +++ b/i18n/locales/uk-UA.json @@ -820,7 +820,6 @@ }, "team": { "title": "Команда", - "governance": "Управління", "role_steward": "стюард", "role_maintainer": "супроводжувач", "sponsor": "спонсор", diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index 21af093e7..385634ae3 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -820,7 +820,6 @@ }, "team": { "title": "团队", - "governance": "治理", "role_steward": "管理者", "role_maintainer": "维护者", "sponsor": "赞助者", diff --git a/i18n/schema.json b/i18n/schema.json index 5511972b1..786e5c330 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -2473,9 +2473,6 @@ "title": { "type": "string" }, - "governance": { - "type": "string" - }, "role_steward": { "type": "string" }, @@ -2508,6 +2505,24 @@ }, "view_profile": { "type": "string" + }, + "avatar": { + "type": "string" + }, + "view_profile_detailed": { + "type": "string" + }, + "separator": { + "type": "string" + }, + "role": { + "type": "string" + }, + "works_at": { + "type": "string" + }, + "location": { + "type": "string" } }, "additionalProperties": false diff --git a/lunaria/files/ar-EG.json b/lunaria/files/ar-EG.json index 5ff151041..bf00c225b 100644 --- a/lunaria/files/ar-EG.json +++ b/lunaria/files/ar-EG.json @@ -819,7 +819,6 @@ }, "team": { "title": "الفريق", - "governance": "الحوكمة", "role_steward": "راعي", "role_maintainer": "مشرف", "sponsor": "راعي", diff --git a/lunaria/files/cs-CZ.json b/lunaria/files/cs-CZ.json index 5edbf6120..28ea82edc 100644 --- a/lunaria/files/cs-CZ.json +++ b/lunaria/files/cs-CZ.json @@ -819,7 +819,6 @@ }, "team": { "title": "Tým", - "governance": "Správa", "role_steward": "Vedoucí", "role_maintainer": "Správce", "sponsor": "Sponzor", diff --git a/lunaria/files/de-DE.json b/lunaria/files/de-DE.json index 82fb974ea..44b1b40cf 100644 --- a/lunaria/files/de-DE.json +++ b/lunaria/files/de-DE.json @@ -820,7 +820,6 @@ }, "team": { "title": "Team", - "governance": "Verwaltung", "role_steward": "Verwalter", "role_maintainer": "Maintainer", "sponsor": "Sponsor", diff --git a/lunaria/files/en-GB.json b/lunaria/files/en-GB.json index ddfa1c6a7..d5493f03c 100644 --- a/lunaria/files/en-GB.json +++ b/lunaria/files/en-GB.json @@ -822,18 +822,23 @@ }, "team": { "title": "Team", - "governance": "Governance", "role_steward": "steward", "role_maintainer": "maintainer", "sponsor": "sponsor", "sponsor_aria": "Sponsor {name} on GitHub" }, "contributors": { - "title": "... and {count} more contributor | ... and {count} more contributors", + "title": "{count} contributor | {count} contributors", "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.", "loading": "Loading contributors...", "error": "Failed to load contributors", - "view_profile": "View {name}'s GitHub profile" + "view_profile": "View {name}'s GitHub profile", + "avatar": "{name}'s avatar", + "view_profile_detailed": "View {name}'s GitHub profile{role}{works_at}{location}", + "separator": " — ", + "role": "{separator}{role} at npmx.dev", + "works_at": "{separator}works at {company}", + "location": "{separator}based in {location}" }, "get_involved": { "title": "Get involved", diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json index 42c9f3f20..36e96ccec 100644 --- a/lunaria/files/en-US.json +++ b/lunaria/files/en-US.json @@ -822,18 +822,23 @@ }, "team": { "title": "Team", - "governance": "Governance", "role_steward": "steward", "role_maintainer": "maintainer", "sponsor": "sponsor", "sponsor_aria": "Sponsor {name} on GitHub" }, "contributors": { - "title": "... and {count} more contributor | ... and {count} more contributors", + "title": "{count} contributor | {count} contributors", "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.", "loading": "Loading contributors...", "error": "Failed to load contributors", - "view_profile": "View {name}'s GitHub profile" + "view_profile": "View {name}'s GitHub profile", + "avatar": "{name}'s avatar", + "view_profile_detailed": "View {name}'s GitHub profile{role}{works_at}{location}", + "separator": " — ", + "role": "{separator}{role} at npmx.dev", + "works_at": "{separator}works at {company}", + "location": "{separator}based in {location}" }, "get_involved": { "title": "Get involved", diff --git a/lunaria/files/es-419.json b/lunaria/files/es-419.json index bbc6e8678..d918546c1 100644 --- a/lunaria/files/es-419.json +++ b/lunaria/files/es-419.json @@ -815,7 +815,6 @@ }, "team": { "title": "Equipo", - "governance": "Gobernanza", "role_steward": "Administrador", "role_maintainer": "Mantenedor", "sponsor": "Patrocinar", @@ -826,7 +825,13 @@ "description": "npmx es completamente de código abierto, construido por una increíble comunidad de colaboradores. Únete a nosotros y construyamos juntos la experiencia de navegación de npm que siempre quisimos.", "loading": "Cargando colaboradores...", "error": "Error al cargar colaboradores", - "view_profile": "Ver perfil de GitHub de {name}" + "view_profile": "Ver perfil de GitHub de {name}", + "avatar": "avatar de {name}", + "view_profile_detailed": "Ver el perfil de GitHub de {name}{role}{works_at}{location}", + "separator": " — ", + "role": "{separator}{role} en npmx.dev", + "works_at": "{separator}trabaja en {company}", + "location": "{separator}ubicado en {location}" }, "get_involved": { "title": "Involúcrate", diff --git a/lunaria/files/es-ES.json b/lunaria/files/es-ES.json index ec4d350ff..d32ae5287 100644 --- a/lunaria/files/es-ES.json +++ b/lunaria/files/es-ES.json @@ -815,7 +815,6 @@ }, "team": { "title": "Equipo", - "governance": "Gobernanza", "role_steward": "Administrador", "role_maintainer": "Mantenedor", "sponsor": "Patrocinar", @@ -826,7 +825,13 @@ "description": "npmx es completamente de código abierto, construido por una increíble comunidad de colaboradores. Únete a nosotros y construyamos juntos la experiencia de navegación de npm que siempre quisimos.", "loading": "Cargando colaboradores...", "error": "Error al cargar colaboradores", - "view_profile": "Ver perfil de GitHub de {name}" + "view_profile": "Ver perfil de GitHub de {name}", + "avatar": "avatar de {name}", + "view_profile_detailed": "Ver el perfil de GitHub de {name}{role}{works_at}{location}", + "separator": " — ", + "role": "{separator}{role} en npmx.dev", + "works_at": "{separator}trabaja en {company}", + "location": "{separator}ubicado en {location}" }, "get_involved": { "title": "Involúcrate", diff --git a/lunaria/files/fr-FR.json b/lunaria/files/fr-FR.json index 179381c21..3cfba16ec 100644 --- a/lunaria/files/fr-FR.json +++ b/lunaria/files/fr-FR.json @@ -821,7 +821,6 @@ }, "team": { "title": "Équipe", - "governance": "Gouvernance", "role_steward": "pilote", "role_maintainer": "mainteneur", "sponsor": "sponsor", diff --git a/lunaria/files/ja-JP.json b/lunaria/files/ja-JP.json index 3cb054895..bdb8b239c 100644 --- a/lunaria/files/ja-JP.json +++ b/lunaria/files/ja-JP.json @@ -819,7 +819,6 @@ }, "team": { "title": "チーム", - "governance": "ガバナンス", "role_steward": "スチュワード", "role_maintainer": "メンテナ", "sponsor": "スポンサー", diff --git a/lunaria/files/pl-PL.json b/lunaria/files/pl-PL.json index 240b50e0a..8c2546bd4 100644 --- a/lunaria/files/pl-PL.json +++ b/lunaria/files/pl-PL.json @@ -820,7 +820,6 @@ }, "team": { "title": "Zespół", - "governance": "Zarządzanie", "role_steward": "steward", "role_maintainer": "maintainer", "sponsor": "sponsor", diff --git a/lunaria/files/uk-UA.json b/lunaria/files/uk-UA.json index d6428f02d..c4e42eb74 100644 --- a/lunaria/files/uk-UA.json +++ b/lunaria/files/uk-UA.json @@ -819,7 +819,6 @@ }, "team": { "title": "Команда", - "governance": "Управління", "role_steward": "стюард", "role_maintainer": "супроводжувач", "sponsor": "спонсор", diff --git a/lunaria/files/zh-CN.json b/lunaria/files/zh-CN.json index 9e626de72..367b4c943 100644 --- a/lunaria/files/zh-CN.json +++ b/lunaria/files/zh-CN.json @@ -819,7 +819,6 @@ }, "team": { "title": "团队", - "governance": "治理", "role_steward": "管理者", "role_maintainer": "维护者", "sponsor": "赞助者", diff --git a/server/api/contributors.get.ts b/server/api/contributors.get.ts index 639350d35..aa72e1454 100644 --- a/server/api/contributors.get.ts +++ b/server/api/contributors.get.ts @@ -1,6 +1,18 @@ +import sanitize from 'sanitize-html' + export type Role = 'steward' | 'maintainer' | 'contributor' -export interface GitHubContributor { +export interface GitHubUserData { + name: string | null + bio: string | null + company: string | null + companyHTML: string | null + location: string | null + websiteUrl: string | null + twitterUsername: string | null +} + +export interface GitHubContributor extends GitHubUserData { login: string id: number avatar_url: string @@ -10,12 +22,44 @@ export interface GitHubContributor { sponsors_url: string | null } -type GitHubAPIContributor = Omit +/** + * Raw data coming from the GitHub REST API (/contributors). + * We exclude 'role', 'sponsors_url' AND all fields that only exist in GraphQL. + */ +type GitHubAPIContributor = Omit // Fallback when no GitHub token is available (e.g. preview environments). // Only stewards are shown as maintainers; everyone else is a contributor. const FALLBACK_STEWARDS = new Set(['danielroe', 'patak-dev']) +const DEFAULT_USER_INFO: GitHubUserData = { + name: null, + bio: null, + company: null, + companyHTML: null, + location: null, + websiteUrl: null, + twitterUsername: null, +} + +// Configure sanitize-html for GitHub's companyHTML and company fields +const SANITIZE_HTML_OPTIONS: sanitize.IOptions = { + allowedTags: ['a', 'span', 'strong', 'em', 'code'], + allowedAttributes: { + a: ['href', 'target', 'rel'], + }, + transformTags: { + a: (tagName, attribs) => ({ + tagName, + attribs: { + ...attribs, + target: '_blank', + rel: 'noopener noreferrer', + }, + }), + }, +} + interface TeamMembers { steward: Set maintainer: Set @@ -61,15 +105,44 @@ async function fetchTeamMembers(token: string): Promise { } /** - * Batch-query GitHub GraphQL API to check which users have sponsors enabled. + * Sanitizes GitHub HTML to remove XSS vectors while preserving safe formatting. + * Applies to both rich companyHTML and plain-text company fields. + */ +function sanitizeGitHubHTML(html: string | null): string | null { + if (!html) return null + + const cleaned = sanitize(html.trim(), SANITIZE_HTML_OPTIONS) + return cleaned === '' ? null : cleaned +} + +/** + * Handles "undefined" strings, empty values, or purely whitespace strings. + * Prevents UI issues with empty icons or broken conditional logic. + */ +function cleanString(val: string | null, url = false): string | null { + if (!val || val === 'undefined' || val.trim() === '') return null + val = val.trim() + if (!url) { + return val + } + return val.startsWith('https://') || val.startsWith('http://') ? val : null +} + +/** + * Batch-query GitHub GraphQL API to check which users have sponsors enabled and getting user info. * Returns a Set of logins that have a sponsors listing. */ -async function fetchSponsorable(token: string, logins: string[]): Promise> { +async function fetchGitHubUserData( + token: string, + logins: string[], + usersData: Map, +): Promise> { if (logins.length === 0) return new Set() // 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}") { hasSponsorsListing login name bio company companyHTML location websiteUrl twitterUsername }`, ) const query = `{ ${fragments.join('\n')} }` @@ -90,15 +163,30 @@ async function fetchSponsorable(token: string, logins: string[]): Promise + data?: Record< + string, + (GitHubUserData & { login: string; hasSponsorsListing: boolean }) | null + > } const sponsorable = new Set() if (json.data) { for (const user of Object.values(json.data)) { - if (user?.hasSponsorsListing) { + if (!user) continue + if (user.hasSponsorsListing) { sponsorable.add(user.login) } + // --- SERVER-SIDE SANITIZATION AND BATCHING --- + usersData.set(user.login, { + name: cleanString(user.name), + bio: cleanString(user.bio), + company: cleanString(user.company), + // Rich HTML sanitization for company mentions/orgs + companyHTML: sanitizeGitHubHTML(user.companyHTML), + location: cleanString(user.location), + websiteUrl: cleanString(user.websiteUrl, true), + twitterUsername: cleanString(user.twitterUsername), + }) } } return sponsorable @@ -167,22 +255,24 @@ export default defineCachedEventHandler( const filtered = allContributors.filter(c => !c.login.includes('[bot]')) - // Identify maintainers (stewards + maintainers) and check their sponsors status - const maintainerLogins = filtered - .filter(c => teams.steward.has(c.login) || teams.maintainer.has(c.login)) - .map(c => c.login) + const userData = new Map() const sponsorable = githubToken - ? await fetchSponsorable(githubToken, maintainerLogins) + ? await fetchGitHubUserData( + githubToken, + filtered.map(c => c.login), + userData, + ) : new Set() return filtered .map(c => { const { role, order } = getRoleInfo(c.login, teams) + const userInfo = userData.get(c.login) ?? DEFAULT_USER_INFO const sponsors_url = sponsorable.has(c.login) ? `https://github.com/sponsors/${c.login}` : null - Object.assign(c, { role, order, sponsors_url }) + Object.assign(c, { role, order, sponsors_url, ...userInfo }) return c as GitHubContributor & { order: number; sponsors_url: string | null; role: Role } }) .sort((a, b) => a.order - b.order || b.contributions - a.contributions)
{{ $t('about.contributors.description') }}
+ "{{ activeContributor.bio }}" +