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[] = []