Skip to content

Commit

Permalink
feat: merge sponsors
Browse files Browse the repository at this point in the history
  • Loading branch information
antfu committed May 9, 2024
1 parent b784428 commit ad1dfc3
Show file tree
Hide file tree
Showing 4 changed files with 124 additions and 3 deletions.
8 changes: 8 additions & 0 deletions example/sponsor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ export default defineConfig({
// ...
// },

// Merge sponsors from different platforms
// mergeSponsors: [
// [
// { login: 'patak-dev', provider: 'github' },
// { login: 'patak', provider: 'opencollective' },
// ],
// ],

// Run multiple renders with different configurations
renders: [
{
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"release": "bumpp && pnpm publish"
},
"dependencies": {
"@antfu/utils": "^0.7.7",
"consola": "^3.2.3",
"d3-hierarchy": "^3.1.2",
"dotenv": "^16.4.5",
Expand All @@ -61,7 +62,6 @@
"devDependencies": {
"@antfu/eslint-config": "^2.16.2",
"@antfu/ni": "^0.21.12",
"@antfu/utils": "^0.7.7",
"@types/d3-hierarchy": "^3.1.7",
"@types/node": "^20.12.10",
"@types/yargs": "^17.0.32",
Expand Down
96 changes: 94 additions & 2 deletions src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import fs from 'node:fs'
import fsp from 'node:fs/promises'
import { consola } from 'consola'
import c from 'picocolors'
import { notNullish } from '@antfu/utils'
import { version } from '../package.json'
import { loadConfig } from './configs'
import { resolveAvatars, svgToPng } from './processing/image'
import type { SponsorkitConfig, SponsorkitMainConfig, SponsorkitRenderOptions, SponsorkitRenderer, Sponsorship } from './types'
import type { SponsorMatcher, SponsorkitConfig, SponsorkitMainConfig, SponsorkitRenderOptions, SponsorkitRenderer, Sponsorship } from './types'
import { guessProviders, resolveProviders } from './providers'
import { builtinRenderers } from './renders'

Expand Down Expand Up @@ -49,6 +50,7 @@ export async function run(inlineConfig?: SponsorkitConfig, t = consola) {

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)
Expand All @@ -58,8 +60,98 @@ export async function run(inlineConfig?: SponsorkitConfig, t = consola) {
allSponsors.push(...sponsors)
}

// Custom hook
allSponsors = await config.onSponsorsAllFetched?.(allSponsors) || allSponsors

// Merge sponsors
{
const sponsorsMergeMap = new Map<Sponsorship, Set<Sponsorship>>()

function pushGroup(group: Sponsorship[]) {
const existingSets = new Set(group.map(s => sponsorsMergeMap.get(s)).filter(notNullish))
let set: Set<Sponsorship>
if (existingSets.size === 1) {
set = [...existingSets.values()][0]
}
else if (existingSets.size === 0) {
set = new Set(group)
}
// Multiple sets, merge them into one
else {
set = new Set()
for (const s of existingSets) {
for (const i of s)
set.add(i)
}
}

for (const s of group) {
set.add(s)
sponsorsMergeMap.set(s, set)
}
}

function matchSponsor(sponsor: Sponsorship, matcher: SponsorMatcher) {
if (matcher.provider && sponsor.provider !== matcher.provider)
return false
if (matcher.login && sponsor.sponsor.login !== matcher.login)
return false
if (matcher.name && sponsor.sponsor.name !== matcher.name)
return false
if (matcher.type && sponsor.sponsor.type !== matcher.type)
return false
return true
}

for (const rule of config.mergeSponsors || []) {
if (typeof rule === 'function') {
for (const ship of allSponsors) {
const result = rule(ship, allSponsors)
if (result)
pushGroup(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
})
pushGroup(group)
}
}

function mergeSponsors(main: Sponsorship, sponsors: Sponsorship[]) {
const all = [main, ...sponsors]
main.isOneTime = all.every(s => s.isOneTime)
main.expireAt = all.map(s => s.expireAt).filter(notNullish).sort((a, b) => b.localeCompare(a))[0]
main.createdAt = all.map(s => s.createdAt).filter(notNullish).sort((a, b) => a.localeCompare(b))[0]
main.monthlyDollars = all.every(s => s.monthlyDollars === -1)
? -1
: all.filter(s => s.monthlyDollars > 0).reduce((a, b) => a + b.monthlyDollars, 0)
main.provider = '[multiple]'
return main
}

const removeSponsors = new Set<Sponsorship>()
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 ${sorted.map(i => `@${i.sponsor.login}(${i.provider})`).join(' + ')}`)

for (const s of sorted.slice(1))
removeSponsors.add(s)
mergeSponsors(sorted[0], sorted.slice(1))
}

allSponsors = allSponsors.filter(s => !removeSponsors.has(s))
}

// Links and avatars replacements
allSponsors.forEach((ship) => {
for (const r of linksReplacements) {
Expand Down Expand Up @@ -182,7 +274,7 @@ export async function applyRenderer(
}

function normalizeReplacements(replaces: SponsorkitMainConfig['replaceLinks']) {
const array = (Array.isArray(replaces) ? replaces : [replaces]).filter(Boolean)
const array = (Array.isArray(replaces) ? replaces : [replaces]).filter(notNullish)
const entries = array.map((i) => {
if (!i)
return []
Expand Down
21 changes: 21 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,23 @@ export interface SponsorkitConfig extends ProvidersConfig, SponsorkitRenderOptio
*/
replaceAvatars?: Record<string, string> | (((sponsor: Sponsorship) => string) | Record<string, string>)[]

/**
* Merge multiple sponsors, useful for combining sponsors from different providers.
*
* @example
* ```js
* mergeSponsors: [
* // Array of sponsor matchers
* [{ login: 'antfu', provider: 'github' }, { login: 'antfu', provider: 'patreon' }],
* // custom functions to find matched sponsors
* (sponsor, allSponsors) => {
* return allSponsors.filter(s => s.sponsor.login === sponsor.sponsor.login)
* }
* ]
* ```
*/
mergeSponsors?: (SponsorMatcher[] | ((sponsor: Sponsorship, allSponsors: Sponsorship[]) => Sponsorship[] | void))[]

/**
* Hook to modify sponsors data for each provider.
*/
Expand Down Expand Up @@ -333,6 +350,10 @@ export interface SponsorkitConfig extends ProvidersConfig, SponsorkitRenderOptio
renders?: SponsorkitRenderOptions[]
}

export interface SponsorMatcher extends Partial<Pick<Sponsor, 'login' | 'name' | 'type'>> {
provider?: ProviderName | string
}

export type SponsorkitMainConfig = Omit<SponsorkitConfig, keyof SponsorkitRenderOptions>

export interface SponsorkitRenderer {
Expand Down

0 comments on commit ad1dfc3

Please sign in to comment.