diff --git a/src/processing/svg.ts b/src/processing/svg.ts
index bda2196..0c146d8 100644
--- a/src/processing/svg.ts
+++ b/src/processing/svg.ts
@@ -52,12 +52,14 @@ export async function generateBadge(
}
const avatarBase64 = avatar.toString('base64')
-
- return `
- ${preset.name
+ const linkAttributes = url ? `href="${url}" ` : ''
+ const nameSvg = preset.name
? `${encodeHtmlEntities(name)}
`
- : ''}${genSvgImage(x, y, size, radius, avatarBase64, imageFormat)}
+ : ''
+
+ return `
+ ${nameSvg}${genSvgImage(x, y, size, radius, avatarBase64, imageFormat)}
`.trim()
}
diff --git a/src/providers/index.ts b/src/providers/index.ts
index 9ef5765..d64960e 100644
--- a/src/providers/index.ts
+++ b/src/providers/index.ts
@@ -29,35 +29,23 @@ export const ProvidersMap = {
export function guessProviders(config: ContribkitConfig) {
const items: ProviderName[] = []
const credentials = getCredentials(config)
- if (config.github?.login)
- items.push('github')
-
- if (credentials.patreon?.token)
- items.push('patreon')
-
- if (config.opencollective && (config.opencollective.id || config.opencollective.slug || config.opencollective.githubHandle))
- items.push('opencollective')
-
- if (config.afdian?.userId && credentials.afdian?.token)
- items.push('afdian')
-
- if (credentials.polar?.token)
- items.push('polar')
-
- if (config.liberapay?.login)
- items.push('liberapay')
-
- if (config.githubContributors?.login && credentials.githubContributors?.token)
- items.push('githubContributors')
-
- if (config.githubContributions?.login && credentials.githubContributions?.token)
- items.push('githubContributions')
-
- if (credentials.gitlabContributors?.token && config.gitlabContributors?.repoId)
- items.push('gitlabContributors')
-
- if (config.crowdinContributors?.token && config.crowdinContributors?.projectId)
- items.push('crowdinContributors')
+ const providerChecks: [ProviderName, boolean | string | number | undefined][] = [
+ ['github', config.github?.login],
+ ['patreon', credentials.patreon?.token],
+ ['opencollective', config.opencollective?.id || config.opencollective?.slug || config.opencollective?.githubHandle],
+ ['afdian', config.afdian?.userId && credentials.afdian?.token],
+ ['polar', credentials.polar?.token],
+ ['liberapay', config.liberapay?.login],
+ ['githubContributors', config.githubContributors?.login && credentials.githubContributors?.token],
+ ['githubContributions', config.githubContributions?.login && credentials.githubContributions?.token],
+ ['gitlabContributors', credentials.gitlabContributors?.token && config.gitlabContributors?.repoId],
+ ['crowdinContributors', config.crowdinContributors?.token && config.crowdinContributors?.projectId],
+ ]
+
+ for (const [provider, enabled] of providerChecks) {
+ if (enabled)
+ items.push(provider)
+ }
// fallback
if (!items.length)
diff --git a/src/providers/opencollective.ts b/src/providers/opencollective.ts
index 04b296d..28c729c 100644
--- a/src/providers/opencollective.ts
+++ b/src/providers/opencollective.ts
@@ -44,68 +44,12 @@ export async function fetchOpenCollectiveSponsors(
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
- 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)
- offset = undefined
- else if (totalCount > offset + nodes.length)
- offset += nodes.length
- else
- offset = undefined
- } while (offset)
- }
-
- offset = 0
- do {
- const now: Date = new Date()
- const dateFrom: Date | undefined = includePastSponsors
- ? undefined
- : new Date(now.getFullYear(), now.getMonth(), 1)
- const query = makeTransactionsQuery(id, slug, githubHandle, offset, dateFrom, undefined, sponseesMode)
- const data = await $fetch(API, {
- method: 'POST',
- body: { query },
- headers: {
- 'Api-Key': `${key}`,
- 'Content-Type': 'application/json',
- },
- }) as any
- const nodes = data.data.account.transactions.nodes
- const totalCount = data.data.account.transactions.totalCount
-
- monthlyTransactions.push(...(nodes || []))
- if (nodes.length === 0)
- offset = undefined
- else if (totalCount > offset + nodes.length)
- offset += nodes.length
- else
- offset = undefined
- } while (offset)
-
+ const apiKey = requireOpenCollectiveOptions(key, id, slug, githubHandle)
+ const includePast = includePastSponsors || sponseesMode
+ const sponsors = sponseesMode
+ ? []
+ : await fetchOpenCollectiveOrders(apiKey, id, slug, githubHandle, includePast)
+ const monthlyTransactions = await fetchOpenCollectiveTransactions(apiKey, id, slug, githubHandle, includePast, sponseesMode)
const sponsorships: [string, Sponsorship][] = sponsors
.map(createSponsorFromOrder)
.filter((sponsorship): sponsorship is [string, Sponsorship] => sponsorship !== null)
@@ -114,57 +58,151 @@ export async function fetchOpenCollectiveSponsors(
.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 sponseesMode
+ ? mergeSponseeSponsorships(sponsorships.concat(monthlySponsorships))
+ : mergeSponsorSponsorships(sponsorships, monthlySponsorships)
+}
+
+function requireOpenCollectiveOptions(key?: string, id?: string, slug?: string, githubHandle?: string) {
+ 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')
+ return key
+}
- return Array.from(processed.values())
+async function fetchOpenCollectiveOrders(
+ key: string,
+ id: string | undefined,
+ slug: string | undefined,
+ githubHandle: string | undefined,
+ includePastSponsors: boolean,
+) {
+ return await fetchOpenCollectivePages(
+ key,
+ offset => makeSubscriptionsQuery(id, slug, githubHandle, offset, !includePastSponsors),
+ data => data.data.account.orders,
+ )
+}
+
+async function fetchOpenCollectiveTransactions(
+ key: string,
+ id: string | undefined,
+ slug: string | undefined,
+ githubHandle: string | undefined,
+ includePastSponsors: boolean,
+ sponseesMode: boolean,
+) {
+ const dateFrom = getTransactionsStartDate(includePastSponsors)
+ return await fetchOpenCollectivePages(
+ key,
+ offset => makeTransactionsQuery(id, slug, githubHandle, offset, dateFrom, undefined, sponseesMode),
+ data => data.data.account.transactions,
+ )
+}
+
+function getTransactionsStartDate(includePastSponsors: boolean) {
+ const now = new Date()
+ return includePastSponsors
+ ? undefined
+ : new Date(now.getFullYear(), now.getMonth(), 1)
+}
+
+async function fetchOpenCollectivePages(
+ key: string,
+ makeQuery: (offset: number) => string,
+ getConnection: (data: any) => { nodes?: any[], totalCount: number },
+) {
+ const nodes: any[] = []
+ let offset: number | undefined = 0
+ while (offset !== undefined) {
+ const data = await fetchOpenCollectivePage(key, makeQuery(offset))
+ const connection = getConnection(data)
+ const pageNodes = connection.nodes ?? []
+ nodes.push(...pageNodes)
+ offset = getNextOffset(offset, pageNodes.length, connection.totalCount)
}
+ return nodes
+}
- const transactionsBySponsorId: Map = monthlySponsorships.reduce((map, [id, sponsor]) => {
- const existingSponsor = map.get(id)
- if (existingSponsor) {
- const createdAt = toDate(sponsor.createdAt)
- const existingSponsorCreatedAt = toDate(existingSponsor.createdAt)
- if (createdAt >= existingSponsorCreatedAt)
- map.set(id, sponsor)
+async function fetchOpenCollectivePage(key: string, query: string) {
+ return await $fetch(API, {
+ method: 'POST',
+ body: { query },
+ headers: {
+ 'Api-Key': `${key}`,
+ 'Content-Type': 'application/json',
+ },
+ }) as any
+}
- else if (isSameMonth(existingSponsorCreatedAt, createdAt))
- existingSponsor.monthlyDollars += sponsor.monthlyDollars
- }
- else { map.set(id, sponsor) }
-
- return map
- }, new Map())
-
- const processed: Map = sponsorships
- .reduce((map, [id, sponsor]) => {
- const existingSponsor = map.get(id)
- if (existingSponsor) {
- const createdAt = toDate(sponsor.createdAt)
- const existingSponsorCreatedAt = toDate(existingSponsor.createdAt)
- if (createdAt >= existingSponsorCreatedAt)
- map.set(id, sponsor)
- }
- else { map.set(id, sponsor) }
- return map
- }, new Map())
+function getNextOffset(offset: number, nodeCount: number, totalCount: number) {
+ if (nodeCount === 0)
+ return undefined
+ return totalCount > offset + nodeCount ? offset + nodeCount : undefined
+}
- const result: Sponsorship[] = Array.from(processed.values()).concat(Array.from(transactionsBySponsorId.values()))
- return result
+function mergeSponseeSponsorships(sponsorships: [string, Sponsorship][]) {
+ const processed = new Map()
+ for (const [id, sponsor] of sponsorships)
+ mergeSponseeSponsor(processed, id, sponsor)
+ return Array.from(processed.values())
+}
+
+function mergeSponseeSponsor(processed: Map, id: string, sponsor: Sponsorship) {
+ const existingSponsor = processed.get(id)
+ if (!existingSponsor) {
+ processed.set(id, sponsor)
+ return
+ }
+
+ existingSponsor.monthlyDollars += sponsor.monthlyDollars
+ existingSponsor.isOneTime = Boolean(existingSponsor.isOneTime && sponsor.isOneTime)
+ if (isEarlierSponsor(sponsor, existingSponsor))
+ existingSponsor.createdAt = sponsor.createdAt
+}
+
+function isEarlierSponsor(sponsor: Sponsorship, existingSponsor: Sponsorship) {
+ return !!sponsor.createdAt && (!existingSponsor.createdAt || sponsor.createdAt.localeCompare(existingSponsor.createdAt) < 0)
+}
+
+function mergeSponsorSponsorships(sponsorships: [string, Sponsorship][], monthlySponsorships: [string, Sponsorship][]) {
+ const processed = keepLatestSponsorships(sponsorships)
+ const transactionsBySponsorId = keepLatestSponsorships(monthlySponsorships, mergeSameMonthSponsorship)
+ return Array.from(processed.values()).concat(Array.from(transactionsBySponsorId.values()))
+}
+
+function keepLatestSponsorships(
+ sponsorships: [string, Sponsorship][],
+ mergeOlder?: (existingSponsor: Sponsorship, sponsor: Sponsorship) => void,
+) {
+ const processed = new Map()
+ for (const [id, sponsor] of sponsorships)
+ keepLatestSponsorship(processed, id, sponsor, mergeOlder)
+ return processed
+}
+
+function keepLatestSponsorship(
+ processed: Map,
+ id: string,
+ sponsor: Sponsorship,
+ mergeOlder?: (existingSponsor: Sponsorship, sponsor: Sponsorship) => void,
+) {
+ const existingSponsor = processed.get(id)
+ if (!existingSponsor) {
+ processed.set(id, sponsor)
+ return
+ }
+
+ if (toDate(sponsor.createdAt) >= toDate(existingSponsor.createdAt))
+ processed.set(id, sponsor)
+ else
+ mergeOlder?.(existingSponsor, sponsor)
+}
+
+function mergeSameMonthSponsorship(existingSponsor: Sponsorship, sponsor: Sponsorship) {
+ if (isSameMonth(toDate(existingSponsor.createdAt), toDate(sponsor.createdAt)))
+ existingSponsor.monthlyDollars += sponsor.monthlyDollars
}
function createSponsorFromOrder(order: any): [string, Sponsorship] | undefined {
@@ -218,22 +256,6 @@ function createSponsorFromTransaction(transaction: any, excludeOrders: string[],
if (excludeOrders.includes(transaction.order?.id))
return undefined
- let monthlyDollars: number = transaction.amount.value
- 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
- }
- else if (transaction.order?.frequency === 'MONTHLY') {
- monthlyDollars = transaction.order?.amount.value
- }
- else if (transaction.order?.frequency === 'YEARLY') {
- monthlyDollars = transaction.order?.amount.value / 12
- }
-
const sponsor: Sponsorship = {
sponsor: {
name: account.name,
@@ -245,20 +267,47 @@ function createSponsorFromTransaction(transaction: any, excludeOrders: string[],
socialLogins: getSocialLogins(slug, account.socialLinks),
},
isOneTime: transaction.order?.frequency === 'ONETIME',
- monthlyDollars,
- privacyLevel: sponseesMode ? 'PUBLIC' : (account.isIncognito ? 'PRIVATE' : 'PUBLIC'),
+ monthlyDollars: getTransactionMonthlyDollars(transaction, sponseesMode),
+ privacyLevel: getTransactionPrivacyLevel(account, sponseesMode),
tierName: transaction.order?.tier?.name ?? transaction.tier?.name,
- createdAt: sponseesMode
- ? transaction.createdAt
- : transaction.order?.frequency === 'ONETIME'
- ? transaction.createdAt
- : transaction.order?.createdAt,
+ createdAt: getTransactionCreatedAt(transaction, sponseesMode),
raw: transaction,
}
return [account.id || slug, sponsor]
}
+function getTransactionMonthlyDollars(transaction: any, sponseesMode: boolean) {
+ if (sponseesMode)
+ return Math.abs(transaction.amount.value)
+ if (transaction.order?.status !== 'ACTIVE')
+ return getInactiveTransactionMonthlyDollars(transaction)
+ if (transaction.order?.frequency === 'MONTHLY')
+ return transaction.order?.amount.value
+ if (transaction.order?.frequency === 'YEARLY')
+ return transaction.order?.amount.value / 12
+ return transaction.amount.value
+}
+
+function getInactiveTransactionMonthlyDollars(transaction: any) {
+ const firstDayOfCurrentMonth = new Date(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1)
+ return new Date(transaction.createdAt) < firstDayOfCurrentMonth
+ ? -1
+ : transaction.amount.value
+}
+
+function getTransactionPrivacyLevel(account: any, sponseesMode: boolean): 'PUBLIC' | 'PRIVATE' {
+ if (sponseesMode)
+ return 'PUBLIC'
+ return account.isIncognito ? 'PRIVATE' : 'PUBLIC'
+}
+
+function getTransactionCreatedAt(transaction: any, sponseesMode: boolean) {
+ if (sponseesMode || transaction.order?.frequency === 'ONETIME')
+ return transaction.createdAt
+ return transaction.order?.createdAt
+}
+
/**
* Make a partial query for the OpenCollective API.
* This is used to query for either a collective or an account.
diff --git a/src/renders/tiers.ts b/src/renders/tiers.ts
index 52b757d..4cc1f21 100644
--- a/src/renders/tiers.ts
+++ b/src/renders/tiers.ts
@@ -8,34 +8,40 @@ export async function tiersComposer(composer: SvgComposer, sponsors: Sponsorship
composer.addSpan(config.padding?.top ?? 20)
- for (const { tier: t, sponsors } of tierPartitions) {
- t.composeBefore?.(composer, sponsors, config)
- if (t.compose) {
- t.compose(composer, sponsors, config)
- }
- else {
- const preset = t.preset || tierPresets.base
- if (sponsors.length && preset.avatar.size) {
- const paddingTop = t.padding?.top ?? 20
- const paddingBottom = t.padding?.bottom ?? 10
- if (paddingTop)
- composer.addSpan(paddingTop)
- if (t.title) {
- composer
- .addTitle(t.title)
- .addSpan(5)
- }
- await composer.addSponsorGrid(sponsors, preset)
- if (paddingBottom)
- composer.addSpan(paddingBottom)
- }
- }
- t.composeAfter?.(composer, sponsors, config)
- }
+ for (const partition of tierPartitions)
+ await composeTier(composer, partition.sponsors, partition.tier, config)
composer.addSpan(config.padding?.bottom ?? 20)
}
+async function composeTier(composer: SvgComposer, sponsors: Sponsorship[], tier: NonNullable[number], config: ContribkitConfig) {
+ tier.composeBefore?.(composer, sponsors, config)
+ if (tier.compose)
+ tier.compose(composer, sponsors, config)
+ else
+ await composePresetTier(composer, sponsors, tier)
+ tier.composeAfter?.(composer, sponsors, config)
+}
+
+async function composePresetTier(composer: SvgComposer, sponsors: Sponsorship[], tier: NonNullable[number]) {
+ const preset = tier.preset || tierPresets.base
+ if (!sponsors.length || !preset.avatar.size)
+ return
+
+ const paddingTop = tier.padding?.top ?? 20
+ const paddingBottom = tier.padding?.bottom ?? 10
+ if (paddingTop)
+ composer.addSpan(paddingTop)
+ if (tier.title) {
+ composer
+ .addTitle(tier.title)
+ .addSpan(5)
+ }
+ await composer.addSponsorGrid(sponsors, preset)
+ if (paddingBottom)
+ composer.addSpan(paddingBottom)
+}
+
export const tiersRenderer: ContribkitRenderer = {
name: 'contribkit:tiers',
async renderSVG(config, sponsors) {
diff --git a/src/run.ts b/src/run.ts
index 6f71ba5..13aaf67 100644
--- a/src/run.ts
+++ b/src/run.ts
@@ -15,6 +15,11 @@ import { guessProviders, resolveProviders } from './providers'
import { builtinRenderers } from './renders'
import { outputFormats } from './types'
+type Logger = typeof consola
+type ResolvedConfig = Required
+type ResolvedMainConfig = Required
+type Replacement = ((sponsor: Sponsorship) => string) | [string, string]
+
export {
tiersComposer as defaultComposer,
// default
@@ -29,170 +34,239 @@ function r(path: string) {
}
export async function run(inlineConfig?: ContribkitConfig, t = consola) {
- t.log(`\n${c.magenta.bold`ContribKit`} ${c.dim`v${version}`}\n`)
+ t.log(formatHeader())
const fullConfig = await loadConfig(inlineConfig)
- const config = fullConfig as Required
+ const config = fullConfig as ResolvedMainConfig
const dir = resolve(process.cwd(), config.outputDir)
- const cacheFile = resolve(
- dir,
- config.mode === 'sponsees'
- ? config.cacheFileSponsees
- : config.cacheFile,
- )
-
+ const cacheFile = resolveCacheFile(dir, config)
const providers = resolveProviders(config.providers ?? guessProviders(config))
- if (config.renders?.length) {
- const names = new Set()
- config.renders.forEach((renderOptions, idx) => {
- 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)
- })
- }
+ validateRenderNames(config.renders, fullConfig)
const linksReplacements = normalizeReplacements(config.replaceLinks)
const avatarsReplacements = normalizeReplacements(config.replaceAvatars)
- let allSponsors: Sponsorship[] = []
- if (!fs.existsSync(cacheFile) || config.force) {
- // Fetch sponsors
- for (const i of providers) {
- t.info(`Fetching sponsorships from ${i.name}...`)
- let sponsors = await i.fetchSponsors(config)
- sponsors.forEach(s => s.provider = i.name)
- sponsors = (await config.onSponsorsFetched?.(sponsors, i.name)) ?? sponsors
- t.success(`${sponsors.length} sponsorships fetched from ${i.name}`)
- allSponsors.push(...sponsors)
- }
+ let allSponsors = await getSponsors(
+ config,
+ providers,
+ cacheFile,
+ linksReplacements,
+ avatarsReplacements,
+ t,
+ )
- // Custom hook
- allSponsors = (await config.onSponsorsAllFetched?.(allSponsors)) ?? allSponsors
-
- // Merge sponsors
- {
- const sponsorsMergeMap = new Map>()
-
- for (const rule of config.mergeSponsors || []) {
- if (typeof rule === 'function') {
- for (const ship of allSponsors) {
- const result = rule(ship, allSponsors)
- if (result)
- pushSponsorGroup(sponsorsMergeMap, result)
- }
- }
- else {
- const group = rule.flatMap((matcher) => {
- const matched = allSponsors.filter(s => matchSponsor(s, matcher))
- if (!matched.length)
- t.warn(`No sponsor matched for ${JSON.stringify(matcher)}`)
- return matched
- })
- pushSponsorGroup(sponsorsMergeMap, group)
- }
- }
+ sortSponsors(allSponsors)
- if (config.sponsorsAutoMerge) {
- for (const ship of allSponsors) {
- if (!ship.sponsor.socialLogins)
- continue
- for (const [provider, login] of Object.entries(ship.sponsor.socialLogins)) {
- const matched = allSponsors.filter(s => s.sponsor.login === login && s.provider === provider)
- if (matched)
- pushSponsorGroup(sponsorsMergeMap, [ship, ...matched])
- }
- }
- }
+ allSponsors = (await config.onSponsorsReady?.(allSponsors)) ?? allSponsors
+ await renderSponsors(fullConfig, config, allSponsors, t)
+}
- const removeSponsors = new Set()
- const groups = new Set(sponsorsMergeMap.values())
- for (const group of groups) {
- if (group.size === 1)
- continue
- const sorted = [...group]
- .sort((a, b) => allSponsors.indexOf(a) - allSponsors.indexOf(b))
+function formatHeader() {
+ const title = c.magenta.bold('ContribKit')
+ const versionText = c.dim(`v${version}`)
+ return `\n${title} ${versionText}\n`
+}
- t.info(`Merging ${sorted.map(i => c.cyan`@${i.sponsor.login}(${i.provider})`).join(' + ')}`)
+function resolveCacheFile(dir: string, config: ResolvedMainConfig) {
+ const filename = config.mode === 'sponsees'
+ ? config.cacheFileSponsees
+ : config.cacheFile
+ return resolve(dir, filename)
+}
- for (const s of sorted.slice(1))
- removeSponsors.add(s)
- mergeSponsors(sorted[0], sorted.slice(1))
- }
+function validateRenderNames(renders: ContribkitConfig['renders'], fullConfig: ResolvedConfig) {
+ const names = new Set()
+ for (const [idx, renderOptions] of (renders ?? []).entries()) {
+ 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)
+ }
+}
+
+async function getSponsors(
+ config: ResolvedMainConfig,
+ providers: ReturnType,
+ cacheFile: string,
+ linksReplacements: Replacement[],
+ avatarsReplacements: Replacement[],
+ t: Logger,
+) {
+ if (fs.existsSync(cacheFile) && !config.force)
+ return await loadSponsorsFromCache(cacheFile, t)
+
+ const allSponsors = await fetchFreshSponsors(config, providers, linksReplacements, avatarsReplacements, t)
+ await writeSponsorsCache(cacheFile, allSponsors)
+ return allSponsors
+}
+
+async function loadSponsorsFromCache(cacheFile: string, t: Logger) {
+ const allSponsors = parseCache(await fsp.readFile(cacheFile, 'utf8'))
+ t.success(`Loaded from cache ${r(cacheFile)}`)
+ return allSponsors
+}
+
+async function fetchFreshSponsors(
+ config: ResolvedMainConfig,
+ providers: ReturnType,
+ linksReplacements: Replacement[],
+ avatarsReplacements: Replacement[],
+ t: Logger,
+) {
+ let allSponsors = await fetchProviderSponsors(config, providers, t)
+ allSponsors = (await config.onSponsorsAllFetched?.(allSponsors)) ?? allSponsors
+ allSponsors = mergeSponsorGroups(allSponsors, config, t)
+ applySponsorReplacements(allSponsors, linksReplacements, avatarsReplacements)
+
+ t.info('Resolving avatars...')
+ await resolveAvatars(allSponsors, config.fallbackAvatar, t)
+ t.success('Avatars resolved')
+
+ return allSponsors
+}
+
+async function fetchProviderSponsors(config: ResolvedMainConfig, providers: ReturnType, t: Logger) {
+ const allSponsors: Sponsorship[] = []
+ for (const provider of providers) {
+ t.info(`Fetching sponsorships from ${provider.name}...`)
+ let sponsors = await provider.fetchSponsors(config)
+ sponsors.forEach(s => s.provider = provider.name)
+ sponsors = (await config.onSponsorsFetched?.(sponsors, provider.name)) ?? sponsors
+ t.success(`${sponsors.length} sponsorships fetched from ${provider.name}`)
+ allSponsors.push(...sponsors)
+ }
+ return allSponsors
+}
+
+function mergeSponsorGroups(allSponsors: Sponsorship[], config: ResolvedMainConfig, t: Logger) {
+ const sponsorsMergeMap = new Map>()
+ applyConfiguredMergeRules(sponsorsMergeMap, allSponsors, config, t)
+ applyAutoMerge(sponsorsMergeMap, allSponsors, config)
+ return applyMergedSponsors(sponsorsMergeMap, allSponsors, t)
+}
+
+function applyConfiguredMergeRules(
+ sponsorsMergeMap: Map>,
+ allSponsors: Sponsorship[],
+ config: ResolvedMainConfig,
+ t: Logger,
+) {
+ for (const rule of config.mergeSponsors || []) {
+ if (typeof rule === 'function')
+ applyCustomMergeRule(sponsorsMergeMap, allSponsors, rule)
+ else
+ applyMatcherMergeRule(sponsorsMergeMap, allSponsors, rule, t)
+ }
+}
+
+function applyCustomMergeRule(
+ sponsorsMergeMap: Map>,
+ allSponsors: Sponsorship[],
+ rule: (sponsor: Sponsorship, allSponsors: Sponsorship[]) => Sponsorship[] | void,
+) {
+ for (const ship of allSponsors) {
+ const result = rule(ship, allSponsors)
+ if (result)
+ pushSponsorGroup(sponsorsMergeMap, result)
+ }
+}
+
+function applyMatcherMergeRule(
+ sponsorsMergeMap: Map>,
+ allSponsors: Sponsorship[],
+ rule: SponsorMatcher[],
+ t: Logger,
+) {
+ const group = rule.flatMap((matcher) => {
+ const matched = allSponsors.filter(s => matchSponsor(s, matcher))
+ if (!matched.length)
+ t.warn(`No sponsor matched for ${JSON.stringify(matcher)}`)
+ return matched
+ })
+ pushSponsorGroup(sponsorsMergeMap, group)
+}
- allSponsors = allSponsors.filter(s => !removeSponsors.has(s))
+function applyAutoMerge(
+ sponsorsMergeMap: Map>,
+ allSponsors: Sponsorship[],
+ config: ResolvedMainConfig,
+) {
+ if (!config.sponsorsAutoMerge)
+ return
+
+ for (const ship of allSponsors) {
+ for (const [provider, login] of Object.entries(ship.sponsor.socialLogins ?? {})) {
+ const matched = allSponsors.filter(s => s.sponsor.login === login && s.provider === provider)
+ pushSponsorGroup(sponsorsMergeMap, [ship, ...matched])
}
+ }
+}
- // Links and avatars replacements
- allSponsors.forEach((ship) => {
- for (const r of linksReplacements) {
- if (typeof r === 'function') {
- const result = r(ship)
- if (result) {
- ship.sponsor.linkUrl = result
- break
- }
- }
- else if (r[0] === ship.sponsor.linkUrl) {
- ship.sponsor.linkUrl = r[1]
- break
- }
- }
- for (const r of avatarsReplacements) {
- if (typeof r === 'function') {
- const result = r(ship)
- if (result) {
- ship.sponsor.avatarUrl = result
- break
- }
- }
- else if (r[0] === ship.sponsor.avatarUrl) {
- ship.sponsor.avatarUrl = r[1]
- break
- }
- }
- })
+function applyMergedSponsors(sponsorsMergeMap: Map>, allSponsors: Sponsorship[], t: Logger) {
+ const removeSponsors = new Set()
+ const groups = new Set(sponsorsMergeMap.values())
+ for (const group of groups) {
+ if (group.size === 1)
+ continue
+ const sorted = [...group]
+ .sort((a, b) => allSponsors.indexOf(a) - allSponsors.indexOf(b))
+
+ t.info(`Merging ${formatMergedSponsors(sorted)}`)
+
+ for (const s of sorted.slice(1))
+ removeSponsors.add(s)
+ mergeSponsors(sorted[0], sorted.slice(1))
+ }
+
+ return allSponsors.filter(s => !removeSponsors.has(s))
+}
- t.info('Resolving avatars...')
- await resolveAvatars(allSponsors, config.fallbackAvatar, t)
- t.success('Avatars resolved')
+function formatMergedSponsors(sponsors: Sponsorship[]) {
+ return sponsors.map(i => c.cyan(`@${i.sponsor.login}(${i.provider})`)).join(' + ')
+}
- await fsp.mkdir(dirname(cacheFile), { recursive: true })
- await fsp.writeFile(cacheFile, stringifyCache(allSponsors))
+function applySponsorReplacements(sponsors: Sponsorship[], linksReplacements: Replacement[], avatarsReplacements: Replacement[]) {
+ for (const ship of sponsors) {
+ ship.sponsor.linkUrl = findReplacement(linksReplacements, ship, ship.sponsor.linkUrl) ?? ship.sponsor.linkUrl
+ ship.sponsor.avatarUrl = findReplacement(avatarsReplacements, ship, ship.sponsor.avatarUrl) ?? ship.sponsor.avatarUrl
}
- else {
- allSponsors = parseCache(await fsp.readFile(cacheFile, 'utf8'))
- t.success(`Loaded from cache ${r(cacheFile)}`)
+}
+
+function findReplacement(replacements: Replacement[], ship: Sponsorship, current: string | undefined) {
+ for (const replacement of replacements) {
+ const result = getReplacementValue(replacement, ship, current)
+ if (result)
+ return result
}
+}
- // Sort
+function getReplacementValue(replacement: Replacement, ship: Sponsorship, current: string | undefined) {
+ if (typeof replacement === 'function')
+ return replacement(ship)
+ return replacement[0] === current ? replacement[1] : undefined
+}
+
+async function writeSponsorsCache(cacheFile: string, allSponsors: Sponsorship[]) {
+ await fsp.mkdir(dirname(cacheFile), { recursive: true })
+ await fsp.writeFile(cacheFile, stringifyCache(allSponsors))
+}
+
+function sortSponsors(allSponsors: Sponsorship[]) {
allSponsors.sort((a, b) =>
b.monthlyDollars - a.monthlyDollars // DESC amount
|| Date.parse(b.createdAt!) - Date.parse(a.createdAt!) // DESC date
|| (b.sponsor.login ?? b.sponsor.name).localeCompare(a.sponsor.login ?? a.sponsor.name), // ASC name
)
+}
- allSponsors = (await config.onSponsorsReady?.(allSponsors)) ?? allSponsors
-
- if (config.renders?.length) {
- t.info(`Generating with ${config.renders.length} renders...`)
- await Promise.all(config.renders.map(async (renderOptions) => {
- const mergedOptions = {
- ...fullConfig,
- ...renderOptions,
- }
- const renderer = builtinRenderers[mergedOptions.renderer ?? 'tiers']
- await applyRenderer(
- renderer,
- config,
- mergedOptions,
- allSponsors,
- t,
- )
- }))
- }
- else {
+async function renderSponsors(
+ fullConfig: ResolvedConfig,
+ config: ResolvedMainConfig,
+ allSponsors: Sponsorship[],
+ t: Logger,
+) {
+ if (!config.renders?.length) {
const renderer = builtinRenderers[fullConfig.renderer ?? 'tiers']
await applyRenderer(
renderer,
@@ -201,7 +275,34 @@ export async function run(inlineConfig?: ContribkitConfig, t = consola) {
allSponsors,
t,
)
+ return
}
+
+ t.info(`Generating with ${config.renders.length} renders...`)
+ await Promise.all(config.renders.map(renderOptions =>
+ renderWithOptions(fullConfig, config, renderOptions, allSponsors, t),
+ ))
+}
+
+async function renderWithOptions(
+ fullConfig: ResolvedConfig,
+ config: ResolvedMainConfig,
+ renderOptions: ContribkitRenderOptions,
+ allSponsors: Sponsorship[],
+ t: Logger,
+) {
+ const mergedOptions = {
+ ...fullConfig,
+ ...renderOptions,
+ }
+ const renderer = builtinRenderers[mergedOptions.renderer ?? 'tiers']
+ await applyRenderer(
+ renderer,
+ config,
+ mergedOptions,
+ allSponsors,
+ t,
+ )
}
export async function applyRenderer(
@@ -316,8 +417,6 @@ function mergeSponsors(main: Sponsorship, sponsors: Sponsorship[]) {
return main
}
-type Replacement = ((sponsor: Sponsorship) => string) | [string, string]
-
function normalizeReplacements(replaces: ContribkitMainConfig['replaceLinks']) {
const array = (Array.isArray(replaces) ? replaces : [replaces]).filter(notNullish)
const entries: Replacement[] = []