sponsorkit
Version:
Toolkit for generating sponsors images
400 lines (392 loc) • 14.5 kB
JavaScript
import cac from 'cac';
import { S as SvgComposer, i as generateBadge, p as partitionTiers, t as tierPresets, v as version, l as loadConfig, k as resolveProviders, j as guessProviders, r as resolveAvatars, q as outputFormats, s as svgToPng, g as svgToWebp } from './shared/sponsorkit.C0ygyvtI.mjs';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import { resolve, dirname, relative, join } from 'node:path';
import process from 'node:process';
import c from 'ansis';
import { consola } from 'consola';
import { Buffer } from 'node:buffer';
import 'unconfig';
import 'dotenv';
import 'node:crypto';
import 'ofetch';
import 'sharp';
function notNullish(v) {
return v != null;
}
function stringifyCache(cache) {
return JSON.stringify(
cache,
(_key, value) => {
if (value && value.type === "Buffer" && Array.isArray(value.data)) {
return Buffer.from(value.data).toString("base64");
}
return value;
},
2
);
}
function parseCache(cache) {
return JSON.parse(cache, (key, value) => {
if (key === "avatarBuffer") {
return Buffer.from(value, "base64");
}
return value;
});
}
const circlesRenderer = {
name: "sponsorkit:circles",
async renderSVG(config, sponsors) {
const { hierarchy, pack } = await import('./chunks/index2.mjs');
const composer = new SvgComposer(config);
const amountMax = Math.max(...sponsors.map((sponsor) => sponsor.monthlyDollars));
const {
radiusMax = 300,
radiusMin = 10,
radiusPast = 5,
weightInterop = defaultInterop
} = config.circles || {};
function defaultInterop(sponsor) {
return sponsor.monthlyDollars < 0 ? radiusPast : lerp(radiusMin, radiusMax, (Math.max(0.1, sponsor.monthlyDollars || 0) / amountMax) ** 0.9);
}
if (!config.includePastSponsors)
sponsors = sponsors.filter((sponsor) => sponsor.monthlyDollars > 0);
const root = hierarchy({ ...sponsors[0], children: sponsors, id: "root" }).sum((d) => weightInterop(d, amountMax)).sort((a, b) => (b.value || 0) - (a.value || 0));
const p = pack();
p.size([config.width, config.width]);
p.padding(config.width / 400);
const circles = p(root).descendants().slice(1);
for (const circle of circles) {
composer.addRaw(await generateBadge(
circle.x - circle.r,
circle.y - circle.r,
circle.data.sponsor,
{
name: false,
boxHeight: circle.r * 2,
boxWidth: circle.r * 2,
avatar: {
size: circle.r * 2
}
},
0.5,
config.imageFormat
));
}
composer.height = config.width;
return composer.generateSvg();
}
};
function lerp(a, b, t) {
if (t < 0)
return a;
return a + (b - a) * t;
}
async function tiersComposer(composer, sponsors, config) {
const tierPartitions = partitionTiers(sponsors, config.tiers, config.includePastSponsors);
composer.addSpan(config.padding?.top ?? 20);
for (const { tier: t, sponsors: sponsors2 } of tierPartitions) {
t.composeBefore?.(composer, sponsors2, config);
if (t.compose) {
t.compose(composer, sponsors2, config);
} else {
const preset = t.preset || tierPresets.base;
if (sponsors2.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(sponsors2, preset);
if (paddingBottom)
composer.addSpan(paddingBottom);
}
}
t.composeAfter?.(composer, sponsors2, config);
}
composer.addSpan(config.padding?.bottom ?? 20);
}
const tiersRenderer = {
name: "sponsorkit:tiers",
async renderSVG(config, sponsors) {
const composer = new SvgComposer(config);
await (config.customComposer || tiersComposer)(composer, sponsors, config);
return composer.generateSvg();
}
};
const builtinRenderers = {
tiers: tiersRenderer,
circles: circlesRenderer
};
function r(path) {
return `./${relative(process.cwd(), path)}`;
}
async function run(inlineConfig, t = consola) {
t.log(`
${c.magenta.bold`SponsorKit`} ${c.dim`v${version}`}
`);
const fullConfig = await loadConfig(inlineConfig);
const config = fullConfig;
const dir = resolve(process.cwd(), config.outputDir);
const cacheFile = resolve(dir, config.cacheFile);
const providers = resolveProviders(config.providers || guessProviders(config));
if (config.renders?.length) {
const names = /* @__PURE__ */ new Set();
config.renders.forEach((renderOptions, idx) => {
const name = renderOptions.name || "sponsors";
if (names.has(name))
throw new Error(`Duplicate render name: ${name} at index ${idx}`);
names.add(name);
});
}
const linksReplacements = normalizeReplacements(config.replaceLinks);
const avatarsReplacements = normalizeReplacements(config.replaceAvatars);
let allSponsors = [];
if (!fs.existsSync(cacheFile) || config.force) {
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);
}
allSponsors = await config.onSponsorsAllFetched?.(allSponsors) || allSponsors;
{
let pushGroup = function(group) {
const existingSets = new Set(group.map((s) => sponsorsMergeMap.get(s)).filter(notNullish));
let set;
if (existingSets.size === 1) {
set = [...existingSets.values()][0];
} else if (existingSets.size === 0) {
set = new Set(group);
} else {
set = /* @__PURE__ */ 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);
}
}, matchSponsor = function(sponsor, matcher) {
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;
}, mergeSponsors = function(main, sponsors) {
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 = [...new Set(all.map((s) => s.provider))].join("+");
return main;
};
const sponsorsMergeMap = /* @__PURE__ */ new Map();
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);
}
}
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)
pushGroup([ship, ...matched]);
}
}
}
const removeSponsors = /* @__PURE__ */ 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 ${sorted.map((i) => c.cyan`@${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));
}
allSponsors.forEach((ship) => {
for (const r2 of linksReplacements) {
if (typeof r2 === "function") {
const result = r2(ship);
if (result) {
ship.sponsor.linkUrl = result;
break;
}
} else if (r2[0] === ship.sponsor.linkUrl) {
ship.sponsor.linkUrl = r2[1];
break;
}
}
for (const r2 of avatarsReplacements) {
if (typeof r2 === "function") {
const result = r2(ship);
if (result) {
ship.sponsor.avatarUrl = result;
break;
}
} else if (r2[0] === ship.sponsor.avatarUrl) {
ship.sponsor.avatarUrl = r2[1];
break;
}
}
});
t.info("Resolving avatars...");
await resolveAvatars(allSponsors, config.fallbackAvatar, t);
t.success("Avatars resolved");
await fsp.mkdir(dirname(cacheFile), { recursive: true });
await fsp.writeFile(cacheFile, stringifyCache(allSponsors));
} else {
allSponsors = parseCache(await fsp.readFile(cacheFile, "utf8"));
t.success(`Loaded from cache ${r(cacheFile)}`);
}
allSponsors.sort(
(a, b) => b.monthlyDollars - a.monthlyDollars || Date.parse(b.createdAt) - Date.parse(a.createdAt) || (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 {
const renderer = builtinRenderers[fullConfig.renderer || "tiers"];
await applyRenderer(
renderer,
config,
fullConfig,
allSponsors,
t
);
}
}
async function applyRenderer(renderer, config, renderOptions, sponsors, t = consola) {
sponsors = [...sponsors];
sponsors = await renderOptions.onBeforeRenderer?.(sponsors) || sponsors;
const logPrefix = c.dim`[${renderOptions.name}]`;
const dir = resolve(process.cwd(), config.outputDir);
await fsp.mkdir(dir, { recursive: true });
if (renderOptions.filter)
sponsors = sponsors.filter((s) => renderOptions.filter(s, sponsors) !== false);
if (!renderOptions.includePrivate)
sponsors = sponsors.filter((s) => s.privacyLevel !== "PRIVATE");
if (!renderOptions.imageFormat)
renderOptions.imageFormat = "webp";
const processingSvg = (async () => {
let svgWebp = await renderer.renderSVG(renderOptions, sponsors);
if (renderOptions.onSvgGenerated) {
svgWebp = await renderOptions.onSvgGenerated(svgWebp) || svgWebp;
}
return svgWebp;
})();
if (renderOptions.formats) {
let svgPng;
await Promise.all([
renderOptions.formats.map(async (format) => {
if (!outputFormats.includes(format))
throw new Error(`Unsupported format: ${format}`);
const path = join(dir, `${renderOptions.name}.${format}`);
let data;
if (format === "svg") {
t.info(`${logPrefix} Composing SVG...`);
data = await processingSvg;
}
if (format === "json") {
data = JSON.stringify(sponsors, null, 2);
}
if (format === "png" || format === "webp") {
if (!svgPng) {
svgPng = renderer.renderSVG({
...renderOptions,
imageFormat: "png"
}, sponsors);
}
if (format === "png") {
data = await svgToPng(await svgPng);
}
if (format === "webp") {
data = await svgToWebp(await svgPng);
}
}
await fsp.writeFile(path, data);
t.success(`${logPrefix} Wrote to ${r(path)}`);
})
]);
}
}
function normalizeReplacements(replaces) {
const array = (Array.isArray(replaces) ? replaces : [replaces]).filter(notNullish);
const entries = array.map((i) => {
if (!i)
return [];
if (typeof i === "function")
return [i];
return Object.entries(i);
}).flat();
return entries;
}
const cli = cac("sponsors-svg").version(version).help();
cli.command("[outputDir]", "Generate sponsors SVG").option("--width, -w <width>", "SVG width", { default: 800 }).option("--fallback-avatar, --fallback <url>", "Fallback avatar URL").option("--force, -f", "Force regeneration", { default: false }).option("--name <name>", "Name").option("--filter <filter>", "Filter sponsors").option("--output-dir, -o, --dir <dir>", "Output directory").action(async (outputDir, options) => {
const config = options;
if (outputDir)
config.outputDir = outputDir;
if (options.filter)
config.filter = createFilterFromString(options.filter);
await run(config);
});
cli.parse();
function createFilterFromString(template) {
const [_, op, value] = template.split(/([<>=]+)/);
const num = Number.parseInt(value);
if (op === "<")
return (s) => s.monthlyDollars < num;
if (op === "<=")
return (s) => s.monthlyDollars <= num;
if (op === ">")
return (s) => s.monthlyDollars > num;
if (op === ">=")
return (s) => s.monthlyDollars >= num;
throw new Error(`Unable to parse filter template ${template}`);
}