'use strict'; const node_buffer = require('node:buffer'); const ofetch = require('ofetch'); const imageDataURI = require('image-data-uri'); const sharp = require('sharp'); const consola = require('consola'); const unconfig = require('unconfig'); const process = require('node:process'); const dotenv = require('dotenv'); const nodeHtmlParser = require('node-html-parser'); const node_crypto = require('node:crypto'); function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; } const imageDataURI__default = /*#__PURE__*/_interopDefaultCompat(imageDataURI); const sharp__default = /*#__PURE__*/_interopDefaultCompat(sharp); const process__default = /*#__PURE__*/_interopDefaultCompat(process); const dotenv__default = /*#__PURE__*/_interopDefaultCompat(dotenv); var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => { __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); return value; }; let index = 0; function genSvgImage(x, y, size, url) { ++index; return ` `; } function generateBadge(x, y, sponsor, preset) { const size = preset.avatar.size; const { login } = sponsor; let name = (sponsor.name || sponsor.login).trim(); const url = sponsor.linkUrl; if (preset.name && preset.name.maxLength && name.length > preset.name.maxLength) { if (name.includes(" ")) name = name.split(" ")[0]; else name = `${name.slice(0, preset.name.maxLength - 3)}...`; } const avatarUrl = (size < 50 ? sponsor.avatarUrlLowRes : size < 90 ? sponsor.avatarUrlMediumRes : sponsor.avatarUrlHighRes) || sponsor.avatarUrl; return ` ${preset.name ? `${encodeHtmlEntities(name)} ` : ""}${genSvgImage(x, y, size, avatarUrl)} `.trim(); } class SvgComposer { constructor(config) { this.config = config; __publicField(this, "height", 0); __publicField(this, "body", ""); } addSpan(height = 0) { this.height += height; return this; } addTitle(text, classes = "sponsorkit-tier-title") { return this.addText(text, classes); } addText(text, classes = "text") { this.body += `${text}`; this.height += 20; return this; } addRaw(svg) { this.body += svg; return this; } addSponsorLine(sponsors, preset) { const offsetX = (this.config.width - sponsors.length * preset.boxWidth) / 2 + (preset.boxWidth - preset.avatar.size) / 2; this.body += sponsors.map((s, i) => { const x = offsetX + preset.boxWidth * i; const y = this.height; return generateBadge(x, y, s.sponsor, preset); }).join("\n"); this.height += preset.boxHeight; } addSponsorGrid(sponsors, preset) { const perLine = Math.floor((this.config.width - (preset.container?.sidePadding || 0) * 2) / preset.boxWidth); Array.from({ length: Math.ceil(sponsors.length / perLine) }).fill(0).forEach((_, i) => { this.addSponsorLine(sponsors.slice(i * perLine, (i + 1) * perLine), preset); }); return this; } generateSvg() { return ` ${this.body} `; } } function encodeHtmlEntities(str) { return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } async function resolveAvatars(ships, fallbackAvatar, t = consola.consola) { return Promise.all(ships.map(async (ship) => { const data = await ofetch.$fetch(ship.sponsor.avatarUrl, { responseType: "arrayBuffer" }).catch((e) => { t.error(`Failed to fetch avatar for ${ship.sponsor.login || ship.sponsor.name} [${ship.sponsor.avatarUrl}]`); t.error(e); if (typeof fallbackAvatar === "string") return ofetch.$fetch(fallbackAvatar, { responseType: "arrayBuffer" }); if (fallbackAvatar) return fallbackAvatar; throw e; }); const radius = ship.sponsor.type === "User" ? 0.5 : 0.15; ship.sponsor.avatarUrlHighRes = await imageDataURI__default.encode(await round(data, radius, 120), "PNG"); ship.sponsor.avatarUrlMediumRes = await imageDataURI__default.encode(await round(data, radius, 80), "PNG"); ship.sponsor.avatarUrlLowRes = await imageDataURI__default.encode(await round(data, radius, 50), "PNG"); })); } function toBuffer(ab) { const buf = node_buffer.Buffer.alloc(ab.byteLength); const view = new Uint8Array(ab); for (let i = 0; i < buf.length; ++i) buf[i] = view[i]; return buf; } async function round(image, radius = 0.5, size = 100) { const rect = node_buffer.Buffer.from( `` ); return await sharp__default(typeof image === "string" ? image : toBuffer(image)).resize(size, size, { fit: sharp__default.fit.cover }).composite([{ blend: "dest-in", input: rect, density: 72 }]).png({ quality: 80, compressionLevel: 8 }).toBuffer(); } function svgToPng(svg) { return sharp__default(node_buffer.Buffer.from(svg), { density: 150 }).png({ quality: 90 }).toBuffer(); } function base64ToArrayBuffer(base64) { const binaryString = atob(base64); const len = binaryString.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) bytes[i] = binaryString.charCodeAt(i); return bytes.buffer; } function arrayBufferToBase64(buffer) { let binary = ""; const bytes = new Uint8Array(buffer); const len = bytes.byteLength; for (let i = 0; i < len; i++) binary += String.fromCharCode(bytes[i]); return btoa(binary); } function pngToDataUri(png) { return `data:image/png;base64,${png.toString("base64")}`; } function getDeprecatedEnv(name, replacement) { const value = process__default.env[name]; if (value) console.warn(`[sponsorkit] env.${name} is deprecated, use env.${replacement} instead`); return value; } function loadEnv() { dotenv__default.config(); const config = { github: { login: process__default.env.SPONSORKIT_GITHUB_LOGIN || process__default.env.GITHUB_LOGIN || getDeprecatedEnv("SPONSORKIT_LOGIN", "SPONSORKIT_GITHUB_LOGIN"), token: process__default.env.SPONSORKIT_GITHUB_TOKEN || process__default.env.GITHUB_TOKEN || getDeprecatedEnv("SPONSORKIT_TOKEN", "SPONSORKIT_GITHUB_TOKEN"), type: process__default.env.SPONSORKIT_GITHUB_TYPE || process__default.env.GITHUB_TYPE }, patreon: { token: process__default.env.SPONSORKIT_PATREON_TOKEN || process__default.env.PATREON_TOKEN }, opencollective: { key: process__default.env.SPONSORKIT_OPENCOLLECTIVE_KEY || process__default.env.OPENCOLLECTIVE_KEY, id: process__default.env.SPONSORKIT_OPENCOLLECTIVE_ID || process__default.env.OPENCOLLECTIVE_ID, slug: process__default.env.SPONSORKIT_OPENCOLLECTIVE_SLUG || process__default.env.OPENCOLLECTIVE_SLUG, githubHandle: process__default.env.SPONSORKIT_OPENCOLLECTIVE_GH_HANDLE || process__default.env.OPENCOLLECTIVE_GH_HANDLE, type: process__default.env.SPONSORKIT_OPENCOLLECTIVE_TYPE || process__default.env.OPENCOLLECTIVE_TYPE }, afdian: { userId: process__default.env.SPONSORKIT_AFDIAN_USER_ID || process__default.env.AFDIAN_USER_ID, token: process__default.env.SPONSORKIT_AFDIAN_TOKEN || process__default.env.AFDIAN_TOKEN, exechangeRate: Number.parseFloat(process__default.env.SPONSORKIT_AFDIAN_EXECHANGERATE || process__default.env.AFDIAN_EXECHANGERATE) || void 0 }, outputDir: process__default.env.SPONSORKIT_DIR }; return JSON.parse(JSON.stringify(config)); } const fallback = ` `; const FALLBACK_AVATAR = svgToPng(fallback); const none = { avatar: { size: 0 }, boxWidth: 0, boxHeight: 0, container: { sidePadding: 0 } }; const base = { avatar: { size: 40 }, boxWidth: 48, boxHeight: 48, container: { sidePadding: 30 } }; const xs = { avatar: { size: 25 }, boxWidth: 30, boxHeight: 30, container: { sidePadding: 30 } }; const small = { avatar: { size: 35 }, boxWidth: 38, boxHeight: 38, container: { sidePadding: 30 } }; const medium = { avatar: { size: 50 }, boxWidth: 80, boxHeight: 90, container: { sidePadding: 20 }, name: { maxLength: 10 } }; const large = { avatar: { size: 70 }, boxWidth: 95, boxHeight: 115, container: { sidePadding: 20 }, name: { maxLength: 16 } }; const xl = { avatar: { size: 90 }, boxWidth: 120, boxHeight: 130, container: { sidePadding: 20 }, name: { maxLength: 20 } }; const presets = { none, xs, small, base, medium, large, xl }; const defaultTiers = [ { title: "Past Sponsors", monthlyDollars: -1, preset: presets.xs }, { title: "Backers", preset: presets.base }, { title: "Sponsors", monthlyDollars: 10, preset: presets.medium }, { title: "Silver Sponsors", monthlyDollars: 50, preset: presets.large }, { title: "Gold Sponsors", monthlyDollars: 100, preset: presets.xl } ]; const defaultInlineCSS = ` text { font-weight: 300; font-size: 14px; fill: #777777; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; } .sponsorkit-link { cursor: pointer; } .sponsorkit-tier-title { font-weight: 500; font-size: 20px; } `; const defaultConfig = { width: 800, outputDir: "./sponsorkit", cacheFile: ".cache.json", formats: ["json", "svg", "png"], tiers: defaultTiers, name: "sponsors", includePrivate: false, svgInlineCSS: defaultInlineCSS }; function defineConfig(config) { return config; } async function loadConfig(inlineConfig = {}) { const env = loadEnv(); const { config = {} } = await unconfig.loadConfig({ sources: [ { files: "sponsor.config" }, { files: "sponsorkit.config" } ], merge: true }); const hasNegativeTier = !!config.tiers?.find((tier) => tier && tier.monthlyDollars <= 0); const resolved = { fallbackAvatar: FALLBACK_AVATAR, includePastSponsors: hasNegativeTier, ...defaultConfig, ...env, ...config, ...inlineConfig, github: { ...env.github, ...config.github, ...inlineConfig.github }, patreon: { ...env.patreon, ...config.patreon, ...inlineConfig.patreon }, opencollective: { ...env.opencollective, ...config.opencollective, ...inlineConfig.opencollective }, afdian: { ...env.afdian, ...config.afdian, ...inlineConfig.afdian } }; return resolved; } function partitionTiers(sponsors, tiers) { const tierMappings = tiers.map((tier) => ({ monthlyDollars: tier.monthlyDollars ?? 0, tier, sponsors: [] })); tierMappings.sort((a, b) => b.monthlyDollars - a.monthlyDollars); const finalSponsors = tierMappings.filter((i) => i.monthlyDollars === 0); if (finalSponsors.length !== 1) throw new Error(`There should be exactly one tier with no \`monthlyDollars\`, but got ${finalSponsors.length}`); sponsors.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt)).forEach((sponsor) => { const tier = tierMappings.find((t) => sponsor.monthlyDollars >= t.monthlyDollars) ?? tierMappings[0]; tier.sponsors.push(sponsor); }); return tierMappings; } function pickSponsorsInfo(html) { const root = nodeHtmlParser.parse(html); const baseDate = /* @__PURE__ */ new Date("2000-1-1"); const sponsors = root.querySelectorAll("div").map((el) => { const isPublic = el.querySelector("img"); const name = isPublic ? isPublic?.getAttribute("alt")?.replace("@", "") : "Private Sponsor"; const avatarUrl = isPublic ? isPublic?.getAttribute("src") : FALLBACK_AVATAR; const login = isPublic ? el.querySelector("a")?.getAttribute("href")?.replace("/", "") : void 0; const type = el.querySelector("a")?.getAttribute("data-hovercard-type")?.replace(/^\S/, (s) => s.toUpperCase()); return { sponsor: { __typename: void 0, linkUrl: `https://github.com/${name}`, login, name, avatarUrl, type }, isOneTime: void 0, monthlyDollars: -1, privacyLevel: isPublic ? "PUBLIC" : "PRIVATE", tierName: void 0, createdAt: baseDate.toUTCString() }; }); return sponsors; } async function getPastSponsors(username) { const allSponsors = []; let newSponsors = []; let cursor = 1; do { const content = await ofetch.$fetch(`https://github.com/sponsors/${username}/sponsors_partial?filter=inactive&page=${cursor++}`, { method: "GET" }); newSponsors = pickSponsorsInfo(content); allSponsors.push(...newSponsors); } while (newSponsors.length); return allSponsors; } const API$1 = "https://api.github.com/graphql"; const graphql$1 = String.raw; const GitHubProvider = { name: "github", fetchSponsors(config) { return fetchGitHubSponsors( config.github?.token || config.token, config.github?.login || config.login, config.github?.type || "user", config ); } }; async function fetchGitHubSponsors(token, login, type, config) { 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`"); const sponsors = []; let cursor; do { const query = makeQuery$1(login, type, cursor); const data = await ofetch.$fetch(API$1, { method: "POST", body: { query }, headers: { "Authorization": `bearer ${token}`, "Content-Type": "application/json" } }); if (!data) throw new Error(`Get no response on requesting ${API$1}`); 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: ${JSON.stringify(data.errors, null, 2)}`); sponsors.push( ...data.data[type].sponsorshipsAsMaintainer.nodes || [] ); if (data.data[type].sponsorshipsAsMaintainer.pageInfo.hasNextPage) cursor = data.data[type].sponsorshipsAsMaintainer.pageInfo.endCursor; else cursor = void 0; } while (cursor); const processed = sponsors.map((raw) => ({ sponsor: { ...raw.sponsorEntity, linkUrl: `https://github.com/${raw.sponsorEntity.login}`, __typename: void 0, type: raw.sponsorEntity.__typename }, isOneTime: raw.tier.isOneTime, monthlyDollars: raw.tier.monthlyPriceInDollars, privacyLevel: raw.privacyLevel, tierName: raw.tier.name, createdAt: raw.createdAt })); if (config.includePastSponsors) { try { processed.push(...await getPastSponsors(login)); } catch (e) { console.error("Failed to fetch past sponsors:", e); } } return processed; } function makeQuery$1(login, type, cursor) { return graphql$1`{ ${type}(login: "${login}") { sponsorshipsAsMaintainer(first: 100${cursor ? ` after: "${cursor}"` : ""}) { totalCount pageInfo { endCursor hasNextPage } nodes { createdAt privacyLevel tier { name isOneTime monthlyPriceInCents monthlyPriceInDollars } sponsorEntity { __typename ...on Organization { login name avatarUrl websiteUrl } ...on User { login name avatarUrl websiteUrl } } } } } }`; } const PatreonProvider = { name: "patreon", fetchSponsors(config) { return fetchPatreonSponsors(config.patreon?.token || config.token); } }; async function fetchPatreonSponsors(token) { if (!token) throw new Error("Patreon token is required"); const userData = await ofetch.$fetch( "https://www.patreon.com/api/oauth2/api/current_user/campaigns?include=null", { method: "GET", headers: { "Authorization": `bearer ${token}`, "Content-Type": "application/json" }, responseType: "json" } ); const userCampaignId = userData.data[0].id; const sponsors = []; let sponsorshipApi = `https://www.patreon.com/api/oauth2/v2/campaigns/${userCampaignId}/members?include=user&fields%5Bmember%5D=currently_entitled_amount_cents,patron_status,pledge_relationship_start,lifetime_support_cents&fields%5Buser%5D=image_url,url,first_name,full_name&page%5Bcount%5D=100`; do { const sponsorshipData = await ofetch.$fetch(sponsorshipApi, { method: "GET", headers: { "Authorization": `bearer ${token}`, "Content-Type": "application/json" }, responseType: "json" }); sponsors.push( ...sponsorshipData.data.filter((membership) => { return membership.attributes.patron_status !== "declined_patron" && membership.attributes.patron_status !== null; }).map((membership) => ({ membership, patron: sponsorshipData.included.find( (v) => v.id === membership.relationships.user.data.id ) })) ); sponsorshipApi = sponsorshipData.links?.next; } while (sponsorshipApi); const processed = sponsors.map( (raw) => ({ sponsor: { avatarUrl: raw.patron.attributes.image_url, login: raw.patron.attributes.first_name, name: raw.patron.attributes.full_name, type: "User", // Patreon only support user linkUrl: raw.patron.attributes.url }, isOneTime: false, // One-time pledges not supported monthlyDollars: raw.membership.attributes.patron_status === "former_patron" ? -1 : Math.floor(raw.membership.attributes.currently_entitled_amount_cents / 100), privacyLevel: "PUBLIC", // Patreon is all public tierName: "Patreon", createdAt: raw.membership.attributes.pledge_relationship_start }) ); return processed; } const OpenCollectiveProvider = { name: "opencollective", fetchSponsors(config) { return fetchOpenCollectiveSponsors( config.opencollective?.key, config.opencollective?.id, config.opencollective?.slug, config.opencollective?.githubHandle, config.opencollective?.type ); } }; const API = "https://api.opencollective.com/graphql/v2/"; const graphql = String.raw; async function fetchOpenCollectiveSponsors(key, id, slug, githubHandle, type) { 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"); let collective = true; if (type && type !== "collective") collective = false; const sponsors = []; let offset; offset = 0; do { const query = makeQuery(id, slug, githubHandle, offset, collective); const data = await ofetch.$fetch(API, { method: "POST", body: { query }, headers: { "Api-Key": `${key}`, "Content-Type": "application/json" } }); const nodes = collective ? data.data.collective?.members.nodes : data.data.account?.transactions.nodes; sponsors.push(...nodes || []); if (nodes.length !== 0) offset += nodes.length; else offset = void 0; } while (offset); const count = []; const processed = []; for (const i in sponsors.sort((a, b) => new Date(b.createdAt).getDate() - new Date(a.createdAt).getDate())) { const v = sponsors[i]; const slug2 = collective ? v.account.slug : v.oppositeAccount.slug; if (!collective) { if (slug2 in count) { delete processed[count[slug2].index]; count[slug2].valueInCents += v.amount.valueInCents; count[slug2].index = i; } else { count[slug2] = { index: i, valueInCents: v.amount.valueInCents }; } } processed.push({ sponsor: { name: collective ? v.account.name : v.oppositeAccount.name, type: (collective ? v.account.type : v.oppositeAccount.name) === "INDIVIDUAL" ? "User" : "Organization", login: slug2, avatarUrl: collective ? v.account.imageUrl : v.oppositeAccount.imageUrl, websiteUrl: collective ? v.account.website : v.oppositeAccount.website, linkUrl: `https://opencollective.com/${slug2}` }, isOneTime: !v.tier || v.tier.type === "DONATION", monthlyDollars: (collective ? v.tier ? v.tier.amount.valueInCents : v.totalDonations.valueInCents : count[slug2].valueInCents) / 100, privacyLevel: (collective ? v.account.isIncognito : v.oppositeAccount.isIncognito) ? "PRIVATE" : "PUBLIC", tierName: collective ? !v.tier ? "" : v.tier.name : void 0, createdAt: v.createdAt }); } return processed.filter((i) => i !== null); } function makeQuery(id, slug, githubHandle, offset, collective = true) { return graphql`{ ${collective ? "collective" : "account"}(${id ? `id: "${id}", ` : ""}${slug ? `slug: "${slug}", ` : ""}${githubHandle ? `githubHandle: "${githubHandle}", ` : ""}throwIfMissing: true) { ${collective ? "members" : "transactions"}(limit: 100${offset ? ` offset: ${offset}` : ""}${collective ? " role: [BACKER]" : ""}) { offset limit totalCount nodes { id createdAt ${collective ? ` role tier { name type amount { valueInCents } }` : ""} ${collective ? "totalDonations" : "amount"} { valueInCents } ${collective ? "account" : "oppositeAccount"} { name slug type website isIncognito imageUrl(height: 460, format: png) } } } } }`; } const AfdianProvider = { name: "afdian", fetchSponsors(config) { return fetchAfdianSponsors(config.afdian); } }; async function fetchAfdianSponsors(options = {}) { const { userId, token, exechangeRate = 6.5 } = options; if (!userId || !token) throw new Error("Afdian id and token are required"); const sponsors = []; const sponsorshipApi = "https://afdian.net/api/open/query-sponsor"; let page = 1; let pages = 1; do { const params = JSON.stringify({ page }); const ts = Math.round(+ new Date() / 1e3); const sign = md5(token, params, ts, userId); const sponsorshipData = await ofetch.$fetch(sponsorshipApi, { method: "POST", headers: { "Content-Type": "application/json" }, responseType: "json", body: { user_id: userId, params, ts, sign } }); page += 1; if (sponsorshipData?.ec !== 200) break; pages = sponsorshipData.data.total_page; sponsors.push(...sponsorshipData.data.list); } while (page <= pages); const processed = sponsors.map((raw) => { const current = raw.current_plan; const expireTime = current?.expire_time; const isExpired = expireTime ? expireTime < Date.now() / 1e3 : true; return { sponsor: { type: "User", login: raw.user.user_id, name: raw.user.name, avatarUrl: raw.user.avatar, linkUrl: `https://afdian.net/u/${raw.user.user_id}` }, // all_sum_amount is based on cny monthlyDollars: isExpired ? -1 : Number.parseFloat(raw.all_sum_amount) / exechangeRate, privacyLevel: "PUBLIC", tierName: "Afdian", createdAt: new Date(raw.first_pay_time * 1e3).toISOString(), expireAt: expireTime ? new Date(expireTime * 1e3).toISOString() : void 0, // empty string means no plan, consider as one time sponsor isOneTime: Boolean(raw.current_plan?.name), provider: "afdian", raw }; }); return processed; } function md5(token, params, ts, userId) { return node_crypto.createHash("md5").update(`${token}params${params}ts${ts}user_id${userId}`).digest("hex"); } const ProvidersMap = { github: GitHubProvider, patreon: PatreonProvider, opencollective: OpenCollectiveProvider, afdian: AfdianProvider }; function guessProviders(config) { const items = []; if (config.github && config.github.login) items.push("github"); if (config.patreon && config.patreon.token) items.push("patreon"); if (config.opencollective && (config.opencollective.id || config.opencollective.slug || config.opencollective.githubHandle)) items.push("opencollective"); if (config.afdian && config.afdian.userId && config.afdian.token) items.push("afdian"); if (!items.length) items.push("github"); return items; } function resolveProviders(names) { return Array.from(new Set(names)).map((i) => { if (typeof i === "string") { const provider = ProvidersMap[i]; if (!provider) throw new Error(`Unknown provider: ${i}`); return provider; } return i; }); } async function fetchSponsors(config) { const providers = resolveProviders(guessProviders(config)); const sponsorships = await Promise.all( providers.map((provider) => provider.fetchSponsors(config)) ); return sponsorships.flat(1); } exports.GitHubProvider = GitHubProvider; exports.ProvidersMap = ProvidersMap; exports.SvgComposer = SvgComposer; exports.arrayBufferToBase64 = arrayBufferToBase64; exports.base64ToArrayBuffer = base64ToArrayBuffer; exports.defaultConfig = defaultConfig; exports.defaultInlineCSS = defaultInlineCSS; exports.defaultTiers = defaultTiers; exports.defineConfig = defineConfig; exports.fetchGitHubSponsors = fetchGitHubSponsors; exports.fetchSponsors = fetchSponsors; exports.genSvgImage = genSvgImage; exports.generateBadge = generateBadge; exports.guessProviders = guessProviders; exports.loadConfig = loadConfig; exports.makeQuery = makeQuery$1; exports.partitionTiers = partitionTiers; exports.pngToDataUri = pngToDataUri; exports.presets = presets; exports.resolveAvatars = resolveAvatars; exports.resolveProviders = resolveProviders; exports.round = round; exports.svgToPng = svgToPng;