diff --git a/.npmrc b/.npmrc
deleted file mode 100644
index ae90f70..0000000
--- a/.npmrc
+++ /dev/null
@@ -1 +0,0 @@
-ignore-workspace-root-check=true
diff --git a/README.md b/README.md
index 2051913..37f3e50 100644
--- a/README.md
+++ b/README.md
@@ -69,6 +69,10 @@ CONTRIBKIT_GITLAB_CONTRIBUTORS_REPO_ID=
; Token requires the `read:user` and `read:org` scopes.
CONTRIBKIT_GITHUB_TOKEN=
CONTRIBKIT_GITHUB_LOGIN=
+; Optional data mode:
+; - sponsors: people sponsoring you (default)
+; - sponsees: people you have sponsored, including past sponsorships
+CONTRIBKIT_MODE=sponsors
; Patreon provider.
; Create v2 API key at https://www.patreon.com/portal/registration/register-clients
@@ -144,6 +148,11 @@ Create `contribkit.config.js` file with:
import { defineConfig, tierPresets } from '@lizardbyte/contribkit'
export default defineConfig({
+ // Data mode:
+ // - sponsors: people sponsoring you (default)
+ // - sponsees: people you have sponsored, including past sponsorships
+ mode: 'sponsors',
+
// Providers configs
github: {
login: 'antfu',
@@ -243,7 +252,7 @@ export default defineConfig({
-
+
@@ -258,7 +267,7 @@ export default defineConfig({
-
+
diff --git a/package-lock.json b/package-lock.json
index b353f8c..1145803 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,42 +10,42 @@
"license": "MIT",
"dependencies": {
"@crowdin/crowdin-api-client": "^1.41.2",
- "ansis": "^4.2.0",
- "cac": "^6.7.14",
- "consola": "^3.4.0",
- "dotenv": "^17.3.1",
- "ofetch": "^1.4.1",
+ "ansis": "^4.3.0",
+ "cac": "^7.0.0",
+ "consola": "^3.4.2",
+ "dotenv": "^17.4.2",
+ "ofetch": "^1.5.1",
"sharp": "^0.34.5",
- "unconfig": "^7.3.0"
+ "unconfig": "^7.5.0"
},
"bin": {
"contribkit": "bin/contribkit.mjs"
},
"devDependencies": {
- "@antfu/ni": "^30.0.0",
- "@antfu/utils": "^9.1.0",
+ "@antfu/ni": "^30.1.0",
+ "@antfu/utils": "^9.3.0",
"@babel/core": "7.29.7",
"@babel/preset-env": "7.29.7",
"@codecov/webpack-plugin": "2.0.1",
"@eslint/js": "^10.0.1",
- "@fast-csv/parse": "^5.0.2",
+ "@fast-csv/parse": "^5.0.7",
"@types/d3-hierarchy": "^3.1.7",
- "@types/node": "^25.2.3",
- "bumpp": "^11.0.1",
+ "@types/node": "^25.9.1",
+ "bumpp": "^11.1.0",
"d3-hierarchy": "^3.1.2",
- "eslint": "^10.0.0",
+ "eslint": "^10.4.0",
"eslint-plugin-jest": "29.15.2",
"globals": "17.6.0",
"jest": "30.4.2",
"jest-environment-jsdom": "30.4.1",
"jest-junit": "17.0.0",
- "jiti": "^2.4.2",
+ "jiti": "^2.7.0",
"normalize-url": "^9.0.0",
"npm-run-all2": "9.0.1",
"p-limit": "^7.3.0",
- "tsx": "^4.19.3",
- "typescript": "^5.8.2",
- "unbuild": "^3.5.0"
+ "tsx": "^4.22.3",
+ "typescript": "^5.9.2",
+ "unbuild": "^3.6.1"
},
"funding": {
"url": "https://app.lizardbyte.dev"
@@ -5997,16 +5997,6 @@
"node": ">=20.19.0"
}
},
- "node_modules/bumpp/node_modules/cac": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/cac/-/cac-7.0.0.tgz",
- "integrity": "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=20.19.0"
- }
- },
"node_modules/bumpp/node_modules/semver": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
@@ -6021,12 +6011,12 @@
}
},
"node_modules/cac": {
- "version": "6.7.14",
- "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
- "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-7.0.0.tgz",
+ "integrity": "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==",
"license": "MIT",
"engines": {
- "node": ">=8"
+ "node": ">=20.19.0"
}
},
"node_modules/call-bind-apply-helpers": {
diff --git a/package.json b/package.json
index 465ad96..3b4beac 100644
--- a/package.json
+++ b/package.json
@@ -36,7 +36,7 @@
"scripts": {
"build": "unbuild",
"stub": "unbuild --stub",
- "dev": "esno src/cli.ts",
+ "dev": "tsx src/cli.ts",
"test": "npm-run-all test:unit test:report test:lint test:typecheck",
"test:unit": "jest --coverage",
"test:report": "jest --reporters=jest-junit",
@@ -47,39 +47,39 @@
},
"dependencies": {
"@crowdin/crowdin-api-client": "^1.41.2",
- "ansis": "^4.2.0",
- "cac": "^6.7.14",
- "consola": "^3.4.0",
- "dotenv": "^17.3.1",
- "ofetch": "^1.4.1",
+ "ansis": "^4.3.0",
+ "cac": "^7.0.0",
+ "consola": "^3.4.2",
+ "dotenv": "^17.4.2",
+ "ofetch": "^1.5.1",
"sharp": "^0.34.5",
- "unconfig": "^7.3.0"
+ "unconfig": "^7.5.0"
},
"devDependencies": {
- "@antfu/ni": "^30.0.0",
- "@antfu/utils": "^9.1.0",
+ "@antfu/ni": "^30.1.0",
+ "@antfu/utils": "^9.3.0",
"@babel/core": "7.29.7",
"@babel/preset-env": "7.29.7",
"@codecov/webpack-plugin": "2.0.1",
"@eslint/js": "^10.0.1",
- "@fast-csv/parse": "^5.0.2",
+ "@fast-csv/parse": "^5.0.7",
"@types/d3-hierarchy": "^3.1.7",
- "@types/node": "^25.2.3",
- "bumpp": "^11.0.1",
+ "@types/node": "^25.9.1",
+ "bumpp": "^11.1.0",
"d3-hierarchy": "^3.1.2",
- "eslint": "^10.0.0",
+ "eslint": "^10.4.0",
"eslint-plugin-jest": "29.15.2",
"globals": "17.6.0",
"jest": "30.4.2",
"jest-environment-jsdom": "30.4.1",
"jest-junit": "17.0.0",
- "jiti": "^2.4.2",
+ "jiti": "^2.7.0",
"normalize-url": "^9.0.0",
"npm-run-all2": "9.0.1",
"p-limit": "^7.3.0",
- "tsx": "^4.19.3",
- "typescript": "^5.8.2",
- "unbuild": "^3.5.0"
+ "tsx": "^4.22.3",
+ "typescript": "^5.9.2",
+ "unbuild": "^3.6.1"
},
"jest": {
"collectCoverageFrom": [
diff --git a/src/cli.ts b/src/cli.ts
index 8e2b082..bb38559 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -3,6 +3,7 @@ import cac from 'cac'
import { version } from '../package.json'
import { run } from './run'
+const RE_FILTER = /([<>=]+)(\d+)/
const cli = cac('contributors-svg')
.version(version)
.help()
@@ -36,7 +37,7 @@ cli.parse()
* @param template
*/
function createFilterFromString(template: string): ContribkitConfig['filter'] {
- const [_, op, value] = template.split(/([<>=]+)/)
+ const [_, op, value] = template.split(RE_FILTER)
const num = Number.parseInt(value)
if (op === '<')
return s => s.monthlyDollars < num
diff --git a/src/configs/defaults.ts b/src/configs/defaults.ts
index 5267fe4..6b4b4f9 100644
--- a/src/configs/defaults.ts
+++ b/src/configs/defaults.ts
@@ -45,12 +45,13 @@ text {
`
export const defaultConfig: ContribkitConfig = {
+ mode: 'sponsors',
width: 800,
outputDir: './contribkit',
cacheFile: '.cache.json',
+ cacheFileSponsees: '.cache.sponsees.json',
formats: ['json', 'svg', 'png'],
tiers: defaultTiers,
- name: 'sponsors',
includePrivate: false,
svgInlineCSS: defaultInlineCSS,
}
diff --git a/src/configs/env.ts b/src/configs/env.ts
index f74a317..5a9a151 100644
--- a/src/configs/env.ts
+++ b/src/configs/env.ts
@@ -10,9 +10,10 @@ function getDeprecatedEnv(name: string, replacement: string) {
}
export function loadEnv(): Partial {
- dotenv.config()
+ dotenv.config({ quiet: true })
const config: Partial = {
+ mode: process.env.CONTRIBKIT_MODE as ContribkitConfig['mode'] | undefined,
github: {
login: process.env.CONTRIBKIT_GITHUB_LOGIN || process.env.GITHUB_LOGIN,
token: process.env.CONTRIBKIT_GITHUB_TOKEN || process.env.GITHUB_TOKEN,
diff --git a/src/configs/index.ts b/src/configs/index.ts
index 537cd20..f690982 100644
--- a/src/configs/index.ts
+++ b/src/configs/index.ts
@@ -64,6 +64,11 @@ export async function loadConfig(inlineConfig: ContribkitConfig = {}): Promise
+ if (!['sponsors', 'sponsees'].includes(resolved.mode))
+ throw new Error(`Invalid mode: ${resolved.mode}. Expected "sponsors" or "sponsees".`)
+
+ resolved.name = inlineConfig.name || config.name || env.name || resolved.mode
+
return resolved
}
diff --git a/src/processing/svg.ts b/src/processing/svg.ts
index 2f7177b..94a297c 100644
--- a/src/processing/svg.ts
+++ b/src/processing/svg.ts
@@ -113,7 +113,7 @@ export class SvgComposer {
generateSvg() {
return `
diff --git a/src/providers/afdian.ts b/src/providers/afdian.ts
index 5b12554..2c47edc 100644
--- a/src/providers/afdian.ts
+++ b/src/providers/afdian.ts
@@ -7,6 +7,11 @@ import { $fetch } from 'ofetch'
export const AfdianProvider: Provider = {
name: 'afdian',
fetchSponsors(config) {
+ if (config.mode === 'sponsees') {
+ console.warn('[contribkit] Afdian provider does not support `mode: "sponsees"` yet')
+ return Promise.resolve([])
+ }
+
return fetchAfdianSponsors(config.afdian)
},
}
@@ -29,7 +34,7 @@ export async function fetchAfdianSponsors(options: ContribkitConfig['afdian'] =
let pages = 1
do {
const params = JSON.stringify({ page })
- const ts = Math.round(+new Date() / 1000)
+ const ts = Math.round(Date.now() / 1000)
const sign = md5(token, params, ts, userId)
const sponsorshipData = await $fetch(sponsorshipApi, {
method: 'POST',
diff --git a/src/providers/github.ts b/src/providers/github.ts
index ec11518..dcf6c1c 100644
--- a/src/providers/github.ts
+++ b/src/providers/github.ts
@@ -40,6 +40,14 @@ const graphql = String.raw
export const GitHubProvider: Provider = {
name: 'github',
fetchSponsors(config) {
+ if (config.mode === 'sponsees') {
+ return fetchGitHubSponsoringAsSponsorships(
+ config.github?.token || config.token!,
+ config.github?.login || config.login!,
+ config.github?.type || 'user',
+ )
+ }
+
return fetchGitHubSponsors(
config.github?.token || config.token!,
config.github?.login || config.login!,
@@ -176,3 +184,309 @@ export function makeQuery(
}
}`
}
+
+export interface GitHubSponsoringRecord {
+ sponsorable: {
+ type: 'User' | 'Organization'
+ login: string
+ name: string
+ avatarUrl: string
+ websiteUrl?: string
+ linkUrl: string
+ }
+ monthlyDollars: number
+ monthlyCents: number
+ tierName: string
+ isOneTime: boolean
+ privacyLevel?: 'PUBLIC' | 'PRIVATE'
+ createdAt: string
+ isActive: boolean
+ raw: any
+}
+
+export interface GitHubSponsoringTotalOptions {
+ since?: string
+ until?: string
+ sponsorableLogins?: string[]
+}
+
+function assertGitHubSponsoringParams(
+ token: string,
+ login: string,
+ type: GitHubAccountType,
+) {
+ if (!token)
+ throw new Error('GitHub token is required')
+ if (!login)
+ throw new Error('GitHub login is required')
+ if (!['user', 'organization'].includes(type))
+ throw new Error('GitHub type must be either `user` or `organization`')
+}
+
+async function requestGitHubSponsoringGraphQL(token: string, query: string): Promise {
+ const data = await $fetch(API, {
+ method: 'POST',
+ body: { query },
+ headers: {
+ 'Authorization': `bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ }) as any
+
+ if (!data)
+ throw new Error(`Get no response on requesting ${API}`)
+ else if (data.errors?.[0]?.type === 'INSUFFICIENT_SCOPES')
+ throw new Error('Token is missing the `read:user` and/or `read:org` scopes')
+ else if (data.errors?.length)
+ throw new Error(`GitHub API error:\n${JSON.stringify(data.errors, null, 2)}`)
+
+ return data
+}
+
+async function fetchGitHubSponsoringNodes(
+ token: string,
+ login: string,
+ type: GitHubAccountType,
+ activeOnly: boolean,
+): Promise {
+ const nodes: any[] = []
+ let cursor: string | undefined
+
+ do {
+ const query = makeSponsoringQuery(login, type, activeOnly, cursor)
+ const data = await requestGitHubSponsoringGraphQL(token, query)
+ const page = data.data?.[type]?.sponsorshipsAsSponsor
+ if (!page)
+ throw new Error('Invalid GitHub response: `sponsorshipsAsSponsor` is missing')
+
+ nodes.push(...(page.nodes || []))
+ cursor = page.pageInfo?.hasNextPage
+ ? page.pageInfo.endCursor
+ : undefined
+ } while (cursor)
+
+ return nodes
+}
+
+function toGitHubSponsoringRecord(raw: any): GitHubSponsoringRecord {
+ return {
+ sponsorable: {
+ type: raw.sponsorable.__typename,
+ login: raw.sponsorable.login,
+ name: raw.sponsorable.name || raw.sponsorable.login,
+ avatarUrl: raw.sponsorable.avatarUrl,
+ websiteUrl: normalizeUrl(raw.sponsorable.websiteUrl),
+ linkUrl: `https://github.com/${raw.sponsorable.login}`,
+ },
+ monthlyDollars: raw.tier.monthlyPriceInDollars,
+ monthlyCents: raw.tier.monthlyPriceInCents,
+ tierName: raw.tier.name,
+ isOneTime: raw.tier.isOneTime,
+ privacyLevel: raw.privacyLevel,
+ createdAt: raw.createdAt,
+ isActive: raw.isActive,
+ raw,
+ }
+}
+
+function groupSponsoringRecordsByLogin(records: GitHubSponsoringRecord[]) {
+ const recordsBySponsorable = new Map()
+ for (const record of records) {
+ const list = recordsBySponsorable.get(record.sponsorable.login)
+ if (list)
+ list.push(record)
+ else
+ recordsBySponsorable.set(record.sponsorable.login, [record])
+ }
+ return recordsBySponsorable
+}
+
+async function fetchTotalCentsBySponsorable(
+ token: string,
+ login: string,
+ type: GitHubAccountType,
+ sponsorableLogins: string[],
+) {
+ return new Map(
+ await Promise.all(
+ sponsorableLogins.map(async (sponsorableLogin) => {
+ const totalInCents = await fetchGitHubTotalSponsorshipAmountAsSponsor(
+ token,
+ login,
+ type,
+ { sponsorableLogins: [sponsorableLogin] },
+ )
+ return [sponsorableLogin, totalInCents] as const
+ }),
+ ),
+ )
+}
+
+function summarizeSponsoringRecords(records: GitHubSponsoringRecord[]) {
+ const [first, ...rest] = records
+ let latest = first
+ let firstCreatedAt = first.createdAt
+ let isOneTime = first.isOneTime
+ const raws = [first.raw]
+
+ for (const record of rest) {
+ raws.push(record.raw)
+ if (Date.parse(record.createdAt) > Date.parse(latest.createdAt))
+ latest = record
+ if (record.createdAt.localeCompare(firstCreatedAt) < 0)
+ firstCreatedAt = record.createdAt
+ isOneTime &&= record.isOneTime
+ }
+
+ return {
+ latest,
+ firstCreatedAt,
+ isOneTime,
+ raws,
+ }
+}
+
+export async function fetchGitHubSponsoring(
+ token: string,
+ login: string,
+ type: GitHubAccountType,
+ activeOnly = true,
+): Promise {
+ assertGitHubSponsoringParams(token, login, type)
+
+ return (await fetchGitHubSponsoringNodes(token, login, type, activeOnly))
+ .filter((raw: any) => !!raw.tier && !!raw.sponsorable)
+ .map(toGitHubSponsoringRecord)
+}
+
+export async function fetchGitHubSponsoringAsSponsorships(
+ token: string,
+ login: string,
+ type: GitHubAccountType,
+): Promise {
+ // Sponsees mode always loads full history regardless of active status.
+ const records = await fetchGitHubSponsoring(token, login, type, false)
+ const recordsBySponsorable = groupSponsoringRecordsByLogin(records)
+ const totalBySponsorable = await fetchTotalCentsBySponsorable(
+ token,
+ login,
+ type,
+ [...recordsBySponsorable.keys()],
+ )
+
+ return [...recordsBySponsorable.entries()].map(([sponsorableLogin, list]) => {
+ const summary = summarizeSponsoringRecords(list)
+ const totalInCents = totalBySponsorable.get(sponsorableLogin) || 0
+
+ return {
+ sponsor: {
+ type: summary.latest.sponsorable.type,
+ login: summary.latest.sponsorable.login,
+ name: summary.latest.sponsorable.name,
+ avatarUrl: summary.latest.sponsorable.avatarUrl,
+ websiteUrl: summary.latest.sponsorable.websiteUrl,
+ linkUrl: summary.latest.sponsorable.linkUrl,
+ socialLogins: {
+ github: summary.latest.sponsorable.login,
+ },
+ },
+ isOneTime: summary.isOneTime,
+ // In sponsees mode, ranking/tiers are based on lifetime sponsored amount.
+ monthlyDollars: totalInCents / 100,
+ privacyLevel: summary.latest.privacyLevel,
+ tierName: summary.latest.tierName,
+ createdAt: summary.firstCreatedAt,
+ raw: {
+ records: summary.raws,
+ totalSponsoredCents: totalInCents,
+ },
+ }
+ })
+}
+
+export async function fetchGitHubTotalSponsorshipAmountAsSponsor(
+ token: string,
+ login: string,
+ type: GitHubAccountType,
+ options: GitHubSponsoringTotalOptions = {},
+): Promise {
+ assertGitHubSponsoringParams(token, login, type)
+
+ const query = makeSponsoringTotalAmountQuery(login, type, options)
+ const data = await requestGitHubSponsoringGraphQL(token, query)
+
+ const totalInCents = data.data?.[type]?.totalSponsorshipAmountAsSponsorInCents
+ if (typeof totalInCents !== 'number')
+ throw new Error('Invalid GitHub response: `totalSponsorshipAmountAsSponsorInCents` is missing')
+
+ return totalInCents
+}
+
+export function makeSponsoringQuery(
+ login: string,
+ type: GitHubAccountType,
+ activeOnly = true,
+ cursor?: string,
+) {
+ return graphql`{
+ ${type}(login: "${login}") {
+ sponsorshipsAsSponsor(activeOnly: ${Boolean(activeOnly)}, first: 100${cursor ? ` after: "${cursor}"` : ''}) {
+ totalCount
+ pageInfo {
+ endCursor
+ hasNextPage
+ }
+ nodes {
+ createdAt
+ privacyLevel
+ isActive
+ tier {
+ name
+ isOneTime
+ monthlyPriceInCents
+ monthlyPriceInDollars
+ }
+ sponsorable {
+ __typename
+ ...on Organization {
+ login
+ name
+ avatarUrl
+ websiteUrl
+ }
+ ...on User {
+ login
+ name
+ avatarUrl
+ websiteUrl
+ }
+ }
+ }
+ }
+ }
+}`
+}
+
+export function makeSponsoringTotalAmountQuery(
+ login: string,
+ type: GitHubAccountType,
+ options: GitHubSponsoringTotalOptions = {},
+) {
+ const args: string[] = []
+ if (options.since)
+ args.push(`since: ${JSON.stringify(options.since)}`)
+ if (options.until)
+ args.push(`until: ${JSON.stringify(options.until)}`)
+ if (options.sponsorableLogins?.length)
+ args.push(`sponsorableLogins: [${options.sponsorableLogins.map(v => JSON.stringify(v)).join(', ')}]`)
+
+ const parameters = args.length
+ ? `(${args.join(', ')})`
+ : ''
+
+ return graphql`{
+ ${type}(login: "${login}") {
+ totalSponsorshipAmountAsSponsorInCents${parameters}
+ }
+}`
+}
diff --git a/src/providers/liberapay.ts b/src/providers/liberapay.ts
index fd0bfb0..7093988 100644
--- a/src/providers/liberapay.ts
+++ b/src/providers/liberapay.ts
@@ -4,6 +4,11 @@ import { $fetch } from 'ofetch'
export const LiberapayProvider: Provider = {
name: 'liberapay',
fetchSponsors(config) {
+ if (config.mode === 'sponsees') {
+ console.warn('[contribkit] Liberapay provider does not support `mode: "sponsees"` yet')
+ return Promise.resolve([])
+ }
+
return fetchLiberapaySponsors(config.liberapay?.login)
},
}
diff --git a/src/providers/opencollective.ts b/src/providers/opencollective.ts
index 016ac48..373bed7 100644
--- a/src/providers/opencollective.ts
+++ b/src/providers/opencollective.ts
@@ -10,6 +10,18 @@ interface SocialLink {
export const OpenCollectiveProvider: Provider = {
name: 'opencollective',
fetchSponsors(config) {
+ if (config.mode === 'sponsees') {
+ const slug = config.opencollective?.slug || config.opencollective?.id
+ return fetchOpenCollectiveSponsors(
+ config.opencollective?.key,
+ undefined,
+ slug,
+ config.opencollective?.githubHandle,
+ true,
+ true,
+ )
+ }
+
return fetchOpenCollectiveSponsors(
config.opencollective?.key,
config.opencollective?.id,
@@ -29,40 +41,43 @@ export async function fetchOpenCollectiveSponsors(
slug?: string,
githubHandle?: string,
includePastSponsors?: boolean,
+ sponseesMode = false,
): Promise {
if (!key)
throw new Error('OpenCollective api key is required')
if (!slug && !id && !githubHandle)
throw new Error('OpenCollective collective id or slug or GitHub handle is required')
+ includePastSponsors ||= sponseesMode
const sponsors: any[] = []
const monthlyTransactions: any[] = []
let offset
- offset = 0
-
- do {
- const query = makeSubscriptionsQuery(id, slug, githubHandle, offset, !includePastSponsors)
- const data = await $fetch(API, {
- method: 'POST',
- body: { query },
- headers: {
- 'Api-Key': `${key}`,
- 'Content-Type': 'application/json',
- },
- }) as any
- const nodes = data.data.account.orders.nodes
- const totalCount = data.data.account.orders.totalCount
-
- sponsors.push(...(nodes || []))
-
- if ((nodes.length) !== 0) {
- if (totalCount > offset + nodes.length)
- offset += nodes.length
- else
- offset = undefined
- }
- else { offset = undefined }
- } while (offset)
+ if (!sponseesMode) {
+ offset = 0
+ do {
+ const query = makeSubscriptionsQuery(id, slug, githubHandle, offset, !includePastSponsors)
+ const data = await $fetch(API, {
+ method: 'POST',
+ body: { query },
+ headers: {
+ 'Api-Key': `${key}`,
+ 'Content-Type': 'application/json',
+ },
+ }) as any
+ const nodes = data.data.account.orders.nodes
+ const totalCount = data.data.account.orders.totalCount
+
+ sponsors.push(...(nodes || []))
+
+ if ((nodes.length) !== 0) {
+ if (totalCount > offset + nodes.length)
+ offset += nodes.length
+ else
+ offset = undefined
+ }
+ else { offset = undefined }
+ } while (offset)
+ }
offset = 0
do {
@@ -70,7 +85,7 @@ export async function fetchOpenCollectiveSponsors(
const dateFrom: Date | undefined = includePastSponsors
? undefined
: new Date(now.getFullYear(), now.getMonth(), 1)
- const query = makeTransactionsQuery(id, slug, githubHandle, offset, dateFrom)
+ const query = makeTransactionsQuery(id, slug, githubHandle, offset, dateFrom, undefined, sponseesMode)
const data = await $fetch(API, {
method: 'POST',
body: { query },
@@ -97,9 +112,29 @@ export async function fetchOpenCollectiveSponsors(
.filter((sponsorship): sponsorship is [string, Sponsorship] => sponsorship !== null)
const monthlySponsorships: [string, Sponsorship][] = monthlyTransactions
- .map(t => createSponsorFromTransaction(t, sponsorships.map(i => i[1].raw.id)))
+ .map(t => createSponsorFromTransaction(t, sponsorships.map(i => i[1].raw.id), sponseesMode))
.filter((sponsorship): sponsorship is [string, Sponsorship] => sponsorship !== null && sponsorship !== undefined)
+ if (sponseesMode) {
+ const processed: Map = sponsorships
+ .concat(monthlySponsorships)
+ .reduce((map, [id, sponsor]) => {
+ const existingSponsor = map.get(id)
+ if (existingSponsor) {
+ existingSponsor.monthlyDollars += sponsor.monthlyDollars
+ existingSponsor.isOneTime = Boolean(existingSponsor.isOneTime && sponsor.isOneTime)
+ if (sponsor.createdAt && (!existingSponsor.createdAt || sponsor.createdAt.localeCompare(existingSponsor.createdAt) < 0))
+ existingSponsor.createdAt = sponsor.createdAt
+ }
+ else {
+ map.set(id, sponsor)
+ }
+ return map
+ }, new Map())
+
+ return Array.from(processed.values())
+ }
+
const transactionsBySponsorId: Map = monthlySponsorships.reduce((map, [id, sponsor]) => {
const existingSponsor = map.get(id)
if (existingSponsor) {
@@ -165,15 +200,19 @@ function createSponsorFromOrder(order: any): [string, Sponsorship] | undefined {
monthlyDollars,
privacyLevel: order.fromAccount.isIncognito ? 'PRIVATE' : 'PUBLIC',
tierName: order.tier?.name,
- createdAt: order.frequency === 'ONETIME' ? order.createdAt : order.order?.createdAt,
+ createdAt: order.createdAt,
raw: order,
}
return [order.fromAccount.id, sponsor]
}
-function createSponsorFromTransaction(transaction: any, excludeOrders: string[]): [string, Sponsorship] | undefined {
- const slug = transaction.fromAccount.slug
+function createSponsorFromTransaction(transaction: any, excludeOrders: string[], sponseesMode = false): [string, Sponsorship] | undefined {
+ const account = sponseesMode ? transaction.toAccount : transaction.fromAccount
+ if (!account?.slug)
+ return undefined
+
+ const slug = account.slug
if (slug === 'github-sponsors') // ignore github sponsors
return undefined
@@ -181,7 +220,10 @@ function createSponsorFromTransaction(transaction: any, excludeOrders: string[])
return undefined
let monthlyDollars: number = transaction.amount.value
- if (transaction.order?.status !== 'ACTIVE') {
+ if (sponseesMode) {
+ monthlyDollars = Math.abs(transaction.amount.value)
+ }
+ else if (transaction.order?.status !== 'ACTIVE') {
const firstDayOfCurrentMonth = new Date(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1)
if (new Date(transaction.createdAt) < firstDayOfCurrentMonth)
monthlyDollars = -1
@@ -195,23 +237,27 @@ function createSponsorFromTransaction(transaction: any, excludeOrders: string[])
const sponsor: Sponsorship = {
sponsor: {
- name: transaction.fromAccount.name,
- type: getAccountType(transaction.fromAccount.type),
+ name: account.name,
+ type: getAccountType(account.type),
login: slug,
- avatarUrl: transaction.fromAccount.imageUrl,
- websiteUrl: normalizeUrl(getBestUrl(transaction.fromAccount.socialLinks)),
+ avatarUrl: account.imageUrl,
+ websiteUrl: normalizeUrl(getBestUrl(account.socialLinks || [])),
linkUrl: `https://opencollective.com/${slug}`,
- socialLogins: getSocialLogins(transaction.fromAccount.socialLinks, slug),
+ socialLogins: getSocialLogins(account.socialLinks || [], slug),
},
isOneTime: transaction.order?.frequency === 'ONETIME',
monthlyDollars,
- privacyLevel: transaction.fromAccount.isIncognito ? 'PRIVATE' : 'PUBLIC',
- tierName: transaction.tier?.name,
- createdAt: transaction.order?.frequency === 'ONETIME' ? transaction.createdAt : transaction.order?.createdAt,
+ privacyLevel: sponseesMode ? 'PUBLIC' : (account.isIncognito ? 'PRIVATE' : 'PUBLIC'),
+ tierName: transaction.order?.tier?.name || transaction.tier?.name,
+ createdAt: sponseesMode
+ ? transaction.createdAt
+ : transaction.order?.frequency === 'ONETIME'
+ ? transaction.createdAt
+ : transaction.order?.createdAt,
raw: transaction,
}
- return [transaction.fromAccount.id, sponsor]
+ return [account.id || slug, sponsor]
}
/**
@@ -247,13 +293,16 @@ function makeTransactionsQuery(
offset?: number,
dateFrom?: Date,
dateTo?: Date,
+ sponseesMode = false,
) {
const accountQueryPartial = makeAccountQueryPartial(id, slug, githubHandle)
const dateFromParam = dateFrom ? `, dateFrom: "${dateFrom.toISOString()}"` : ''
const dateToParam = dateTo ? `, dateTo: "${dateTo.toISOString()}"` : ''
+ const type = sponseesMode ? 'DEBIT' : 'CREDIT'
+ const accountField = sponseesMode ? 'toAccount' : 'fromAccount'
return graphql`{
account(${accountQueryPartial}) {
- transactions(limit: 1000, offset:${offset}, type: CREDIT ${dateFromParam} ${dateToParam}) {
+ transactions(limit: 1000, offset:${offset}, type: ${type} ${dateFromParam} ${dateToParam}) {
offset
limit
totalCount
@@ -268,6 +317,7 @@ function makeTransactionsQuery(
tier {
name
}
+ createdAt
amount {
value
}
@@ -276,7 +326,7 @@ function makeTransactionsQuery(
amount {
value
}
- fromAccount {
+ ${accountField} {
name
id
slug
@@ -320,7 +370,6 @@ function makeSubscriptionsQuery(
totalDonations {
value
}
- createdAt
fromAccount {
name
id
@@ -382,11 +431,13 @@ function getBestUrl(socialLinks: SocialLink[]): string | undefined {
return urls[0]
}
+const RE_GITHUB_URL = /github\.com\/([^/]*)/
+
function getSocialLogins(socialLinks: SocialLink[] = [], opencollectiveLogin: string): Record {
const socialLogins: Record = {}
for (const link of socialLinks) {
if (link.type === 'GITHUB') {
- const login = link.url.match(/github\.com\/([^/]*)/)?.[1]
+ const login = link.url.match(RE_GITHUB_URL)?.[1]
if (login)
socialLogins.github = login
}
diff --git a/src/providers/patreon.ts b/src/providers/patreon.ts
index 9966fbb..f165915 100644
--- a/src/providers/patreon.ts
+++ b/src/providers/patreon.ts
@@ -4,6 +4,11 @@ import { $fetch } from 'ofetch'
export const PatreonProvider: Provider = {
name: 'patreon',
fetchSponsors(config) {
+ if (config.mode === 'sponsees') {
+ console.warn('[contribkit] Patreon provider does not support `mode: "sponsees"` yet')
+ return Promise.resolve([])
+ }
+
return fetchPatreonSponsors(config.patreon?.token || config.token!)
},
}
diff --git a/src/providers/polar.ts b/src/providers/polar.ts
index 40919df..8ea687a 100644
--- a/src/providers/polar.ts
+++ b/src/providers/polar.ts
@@ -4,6 +4,11 @@ import { ofetch } from 'ofetch'
export const PolarProvider: Provider = {
name: 'polar',
fetchSponsors(config) {
+ if (config.mode === 'sponsees') {
+ console.warn('[contribkit] Polar provider does not support `mode: "sponsees"` yet')
+ return Promise.resolve([])
+ }
+
return fetchPolarSponsors(config.polar?.token || config.token!, config.polar?.organization)
},
}
diff --git a/src/run.ts b/src/run.ts
index cb780f6..41f8e48 100644
--- a/src/run.ts
+++ b/src/run.ts
@@ -34,14 +34,19 @@ export async function run(inlineConfig?: ContribkitConfig, t = consola) {
const fullConfig = await loadConfig(inlineConfig)
const config = fullConfig as Required
const dir = resolve(process.cwd(), config.outputDir)
- const cacheFile = resolve(dir, config.cacheFile)
+ const cacheFile = resolve(
+ dir,
+ config.mode === 'sponsees'
+ ? config.cacheFileSponsees
+ : config.cacheFile,
+ )
const providers = resolveProviders(config.providers || guessProviders(config))
if (config.renders?.length) {
const names = new Set()
config.renders.forEach((renderOptions, idx) => {
- const name = renderOptions.name || 'sponsors'
+ const name = renderOptions.name || fullConfig.name || fullConfig.mode
if (names.has(name))
throw new Error(`Duplicate render name: ${name} at index ${idx}`)
names.add(name)
diff --git a/src/types.ts b/src/types.ts
index 0aea45f..96392fe 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -78,6 +78,7 @@ export type OutputFormat = typeof outputFormats[number]
export type ProviderName = 'github' | 'patreon' | 'opencollective' | 'afdian' | 'polar' | 'liberapay' | 'githubContributors' | 'gitlabContributors' | 'crowdinContributors' | 'githubContributions'
export type GitHubAccountType = 'user' | 'organization'
+export type SponsorshipMode = 'sponsors' | 'sponsees'
export interface ProvidersConfig {
github?: {
@@ -319,7 +320,7 @@ export interface ContribkitRenderOptions {
/**
* Name of exported files
*
- * @default 'sponsors'
+ * @default config.mode
*/
name?: string
@@ -415,6 +416,15 @@ export interface ContribkitRenderOptions {
}
export interface ContribkitConfig extends ProvidersConfig, ContribkitRenderOptions {
+ /**
+ * Data mode:
+ * - `sponsors`: people sponsoring you
+ * - `sponsees`: people you have sponsored, including past sponsorships
+ *
+ * @default 'sponsors'
+ */
+ mode?: SponsorshipMode
+
/**
* @deprecated use `github.login` instead
*/
@@ -442,6 +452,13 @@ export interface ContribkitConfig extends ProvidersConfig, ContribkitRenderOptio
*/
cacheFile?: string
+ /**
+ * Path to cache file used when `mode` is `sponsees`.
+ *
+ * @default './contribkit/.cache.sponsees.json'
+ */
+ cacheFileSponsees?: string
+
/**
* Directory of output files.
*
diff --git a/tsconfig.json b/tsconfig.json
index 44c2f05..fb3f9a6 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,9 +1,9 @@
{
"compilerOptions": {
- "target": "es2017",
+ "target": "esnext",
"lib": ["esnext"],
"module": "esnext",
- "moduleResolution": "node",
+ "moduleResolution": "bundler",
"paths": {
"contribkit": [
"./src/index.ts"