Skip to content
Merged
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
8 changes: 4 additions & 4 deletions apps/site/components/Common/Supporters/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@ import type { Supporter } from '#site/types';
import type { FC } from 'react';

type SupportersListProps = {
supporters: Array<Supporter<'opencollective'>>;
supporters: Array<Supporter<'opencollective' | 'github'>>;
};

const SupportersList: FC<SupportersListProps> = ({ supporters }) => (
<div className="flex max-w-full flex-wrap items-center justify-center gap-1">
{supporters.map(({ name, image, profile }) => (
{supporters.map(({ name, image, source, url }) => (
<Avatar
nickname={name}
fallback={getAcronymFromString(name)}
image={image}
key={name}
url={profile}
key={`${source}:${name}`}
url={url}
/>
))}
</div>
Expand Down
222 changes: 217 additions & 5 deletions apps/site/next-data/generators/supportersData.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,85 @@
import { OPENCOLLECTIVE_MEMBERS_URL } from '#site/next.constants.mjs';
import {
OPENCOLLECTIVE_MEMBERS_URL,
GITHUB_GRAPHQL_URL,
GITHUB_READ_API_KEY,
} from '#site/next.constants.mjs';
import { fetchWithRetry } from '#site/next.fetch.mjs';
import { shuffle } from '#site/util/array';

const SPONSORSHIPS_QUERY = `
query ($cursor: String) {
organization(login: "nodejs") {
sponsorshipsAsMaintainer(
first: 100
includePrivate: false
after: $cursor
activeOnly: false
) {
nodes {
sponsor: sponsorEntity {
...on User {
id: databaseId
name
login
avatarUrl
url
websiteUrl
}
...on Organization {
id: databaseId
name
login
avatarUrl
url
websiteUrl
}
}
}
pageInfo {
endCursor
startCursor
hasNextPage
hasPreviousPage
}
}
}
}
`;

const DONATIONS_QUERY = `
query {
organization(login: "nodejs") {
sponsorsActivities(first: 100, includePrivate: false) {
nodes {
id
sponsor {
...on User {
id: databaseId
name
login
avatarUrl
url
websiteUrl
}
...on Organization {
id: databaseId
name
login
avatarUrl
url
websiteUrl
}
}
timestamp
tier: sponsorsTier {
monthlyPriceInDollars
isOneTime
}
}
}
}
}
`;

/**
* Fetches supporters data from Open Collective API, filters active backers,
Expand All @@ -14,13 +94,13 @@ async function fetchOpenCollectiveData() {

const members = payload
.filter(({ role, isActive }) => role === 'BACKER' && isActive)
.filter(({ name, image }) => name || image) // Ensure we have a name or image for the supporter
.sort((a, b) => b.totalAmountDonated - a.totalAmountDonated)
.map(({ name, website, image, profile }) => ({
.map(({ name, image, profile }) => ({
name,
image,
url: website,
// If profile starts with the guest- prefix, it's a non-existing account
profile: profile.startsWith('https://opencollective.com/guest-')
url: profile.startsWith('https://opencollective.com/guest-')
? undefined
: profile,
source: 'opencollective',
Expand All @@ -29,4 +109,136 @@ async function fetchOpenCollectiveData() {
return members;
}

export default fetchOpenCollectiveData;
/**
* Fetches supporters data from Github API, filters active backers,
* and maps it to the Supporters type.
*
* @returns {Promise<Array<import('#site/types/supporters').GitHubSponsorSupporter>>} Array of supporters
*/
async function fetchGithubSponsorsData() {
if (!GITHUB_READ_API_KEY) {
return [];
}

const [sponsorships, donations] = await Promise.all([
fetchSponsorshipsQuery(),
fetchDonationsQuery(),
]);

return [...sponsorships, ...donations];
}

async function fetchSponsorshipsQuery() {
const sponsors = [];
let cursor = null;

while (true) {
const data = await graphql(
SPONSORSHIPS_QUERY,
cursor ? { cursor } : undefined
);

if (data.errors) {
throw new Error(JSON.stringify(data.errors));
}

const nodeRes = data.data.organization?.sponsorshipsAsMaintainer;
if (!nodeRes) {
break;
}

const { nodes, pageInfo } = nodeRes;
const mapped = nodes.map(n => {
const s = n.sponsor || n.sponsorEntity; // support different field names
return {
name: s?.name || s?.login || null,
image: s?.avatarUrl || null,
url: s?.url || null,
source: 'github',
};
});

sponsors.push(...mapped);

if (!pageInfo.hasNextPage) {
break;
}

cursor = pageInfo.endCursor;
}

return sponsors;
}

async function fetchDonationsQuery() {
const data = await graphql(DONATIONS_QUERY);

if (data.errors) {
throw new Error(JSON.stringify(data.errors));
}

const nodeRes = data.data.organization?.sponsorsActivities;
if (!nodeRes) {
return [];
}

const { nodes } = nodeRes;
return nodes.map(n => {
const s = n.sponsor || n.sponsorEntity; // support different field names
return {
name: s?.name || s?.login || null,
image: s?.avatarUrl || null,
url: s?.url || null,
source: 'github',
};
});
}

const graphql = async (query, variables = {}) => {
const res = await fetchWithRetry(GITHUB_GRAPHQL_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${GITHUB_READ_API_KEY}`,
},
body: JSON.stringify({ query, variables }),
});

if (!res.ok) {
const text = await res.text();
throw new Error(`GitHub API error: ${res.status} ${text}`);
}

return res.json();
};

/**
* Fetches supporters data from Open Collective API and GitHub Sponsors, filters active backers,
* and maps it to the Supporters type.
*
* @returns {Promise<Array<import('#site/types/supporters').OpenCollectiveSupporter | import('#site/types/supporters').GitHubSponsorSupporter>>} Array of supporters
*/
async function sponsorsData() {
const seconds = 300; // Change every 5 minutes
const seed = Math.floor(Date.now() / (seconds * 1000));

const sponsorsResults = await Promise.allSettled([
fetchGithubSponsorsData(),
fetchOpenCollectiveData(),
]);

const sponsors = sponsorsResults.flatMap(result => {
if (result.status === 'fulfilled') {
return result.value;
}

console.error('Supporters data source failed:', result.reason);
return [];
});

const shuffled = await shuffle(sponsors, seed);

return shuffled;
}

export default sponsorsData;
7 changes: 6 additions & 1 deletion apps/site/next.constants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export const EXTERNAL_LINKS_SITEMAP = [
*
* Note: This has no NEXT_PUBLIC prefix as it should not be exposed to the Browser.
*/
export const GITHUB_API_KEY = process.env.NEXT_GITHUB_API_KEY || '';
export const GITHUB_READ_API_KEY = process.env.NEXT_GITHUB_READ_API_KEY || '';

/**
* The resource we point people to when discussing internationalization efforts.
Expand Down Expand Up @@ -178,6 +178,11 @@ export const VULNERABILITIES_URL =
export const OPENCOLLECTIVE_MEMBERS_URL =
'https://opencollective.com/nodejs/members/all.json';

/**
* The location of the GitHub GraphQL API
*/
export const GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql';

/**
* Orama DB URLs for the Learn and API sections of the website
*/
Expand Down
4 changes: 2 additions & 2 deletions apps/site/pages/en/about/partners.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ without we can't test and release new versions of Node.js.

## Supporters

Supporters are individuals and organizations that provide financial support through
[OpenCollective](https://opencollective.com/nodejs) of the Node.js project.
Supporters are individuals and organizations who financially support the Node.js project
through [OpenCollective](https://opencollective.com/nodejs) and [GitHub Sponsors](https://github.com/sponsors/nodejs).

<WithSupporters />

Expand Down
1 change: 1 addition & 0 deletions apps/site/types/supporters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export type Supporter<T extends string> = {
};

export type OpenCollectiveSupporter = Supporter<'opencollective'>;
export type GitHubSponsorSupporter = Supporter<'github'>;
Loading