UNPKG

@simon_he/sponsorkit

Version:

Toolkit for generating sponsors images

286 lines (281 loc) 10.7 kB
import process from 'node:process'; import yargs from 'yargs'; import { resolve, join, dirname, relative } from 'node:path'; import fs from 'fs-extra'; import { consola } from 'consola'; import c from 'picocolors'; import { loadConfig, resolveProviders, guessProviders, SvgComposer, svgToPng, generateBadge, resolveAvatars, base64ToArrayBuffer, pngToDataUri, round, partitionTiers, presets } from './index.mjs'; import 'node:buffer'; import { $fetch } from 'ofetch'; import 'image-data-uri'; import 'sharp'; import 'unconfig'; import 'dotenv'; import 'node-html-parser'; import 'node:crypto'; const version = "0.0.10"; async function customAddUser(users) { const customData = []; await Promise.all(users.map(({ user, monthlyDollars }) => { return new Promise((resolve) => { $fetch(`https://api.github.com/users/${user}`, { responseType: "json" }).then(async (data) => { customData.push({ sponsor: { type: "User", name: user, login: user, avatarUrl: data.avatar_url, linkUrl: `https://github.com/${user}` }, monthlyDollars }); resolve(customData); }).catch((error) => { console.error("\u83B7\u53D6\u7528\u6237\u4FE1\u606F\u5931\u8D25:", error); resolve(customData); }); }); })); return customData; } function r(path) { return `./${relative(process.cwd(), path)}`; } async function run(inlineConfig, t = consola) { t.log(` ${c.magenta(c.bold("SponsorKit"))} ${c.dim(`v${version}`)} `); const config = await loadConfig(inlineConfig); const type = config.type || "all"; const dir = resolve(process.cwd(), config.outputDir); const cacheFile = resolve(dir, config.cacheFile); const providers = resolveProviders(config.providers || guessProviders(config)); t.info("Composing SVG..."); let allSponsors = await getAllSponsors(); if (type === "all") { generateTier(); generateCircle(); } else if (type === "tiers") { generateTier(); } else if (type === "circle") { generateCircle(); } async function generateTier() { const composer = new SvgComposer(config); await (config.customComposer || defaultComposer)(composer, allSponsors, config); let svg = composer.generateSvg(); svg = await config.onSvgGenerated?.(svg) || svg; if (config.formats?.includes("svg")) { const path = join(dir, `${config.name}.svg`); await fs.writeFile(path, svg, "utf-8"); t.success(`Wrote to ${r(path)}`); } if (config.formats?.includes("png")) { const path = join(dir, `${config.name}.png`); await fs.writeFile(path, await svgToPng(svg)); t.success(`Wrote to ${r(path)}`); } } async function generateCircle() { const { hierarchy, pack } = await import('d3-hierarchy'); const composer = new SvgComposer(config); const amountMax = Math.max(...allSponsors.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) allSponsors = allSponsors.filter((sponsor) => sponsor.monthlyDollars > 0); const root = hierarchy({ ...allSponsors[0], children: allSponsors, 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(generateBadge( circle.x - circle.r, circle.y - circle.r, await getRoundedAvatars(circle.data.sponsor), { name: false, boxHeight: circle.r * 2, boxWidth: circle.r * 2, avatar: { size: circle.r * 2 } } )); } composer.height = config.width; const svg = composer.generateSvg(); if (config.formats?.includes("svg")) { const path = join(dir, `${config.name}_circle.svg`); await fs.writeFile(path, svg, "utf-8"); t.success(`Wrote to ${r(path)}`); } if (config.formats?.includes("png")) { const path = join(dir, `${config.name}_circle.png`); await fs.writeFile(path, await svgToPng(svg)); t.success(`Wrote to ${r(path)}`); } } async function getAllSponsors() { let allSponsors2 = []; if (!fs.existsSync(cacheFile) || config.force) { for (const i of providers) { t.info(`Fetching sponsorships from ${i.name}...`); let sponsors = []; try { sponsors = await i.fetchSponsors(config); } catch (error) { } sponsors.forEach((s) => s.provider = i.name); sponsors = await config.onSponsorsFetched?.(sponsors, i.name) || sponsors; t.success(`${sponsors.length} sponsorships fetched from ${i.name}`); allSponsors2.push(...sponsors); } if (config.customGithubUser) { const customSponsors = await customAddUser(config.customGithubUser); await resolveAvatars(customSponsors, config.fallbackAvatar, t); allSponsors2.push(...customSponsors); } t.info("Resolving avatars..."); await resolveAvatars(allSponsors2, config.fallbackAvatar, t); t.success("Avatars resolved"); await fs.ensureDir(dirname(cacheFile)); await fs.writeJSON(cacheFile, allSponsors2, { spaces: 2 }); } else { allSponsors2 = await fs.readJSON(cacheFile); if (config.customGithubUser) { const cacheUsers = allSponsors2.map((s) => ({ user: s.sponsor.name, monthlyDollars: s.monthlyDollars })); const isCached = (user) => cacheUsers.some((c2) => c2.user === user.user && c2.monthlyDollars === user.monthlyDollars); const isNeedUpdateMonthlyDollars = (user) => cacheUsers.some((c2) => c2.user === user.user && c2.monthlyDollars !== user.monthlyDollars); const filters = config.customGithubUser.filter((p) => p.user && !isCached(p)); allSponsors2 = allSponsors2.filter((s) => !isNeedUpdateMonthlyDollars({ user: s.sponsor.name, monthlyDollars: s.monthlyDollars })); const customSponsors = await customAddUser(filters); if (customSponsors.length > 0) { await resolveAvatars(customSponsors, config.fallbackAvatar, t); allSponsors2 = allSponsors2.filter((s) => !customSponsors.some((c2) => c2.sponsor.name === s.sponsor.name)); allSponsors2.push(...customSponsors); t.success(`Added new users: ${filters.map((i) => `[${i.user}]`).join(" ")} custom sponsorships`); await fs.writeJSON(cacheFile, allSponsors2, { spaces: 2 }); } else { t.success(`Loaded from cache ${r(cacheFile)}`); } } else { t.success(`Loaded from cache ${r(cacheFile)}`); } } allSponsors2.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 ); await fs.ensureDir(dir); if (config.formats?.includes("json")) { const path = join(dir, `${config.name}.json`); await fs.writeJSON(path, allSponsors2, { spaces: 2 }); t.success(`Wrote to ${r(path)}`); } allSponsors2 = await config.onSponsorsReady?.(allSponsors2) || allSponsors2; if (config.filter) allSponsors2 = allSponsors2.filter((s) => config.filter(s, allSponsors2) !== false); if (!config.includePrivate) allSponsors2 = allSponsors2.filter((s) => s.privacyLevel !== "PRIVATE"); return allSponsors2; } } async function getRoundedAvatars(sponsor) { if (!sponsor.avatarBuffer || sponsor.type === "User") return sponsor; const data = base64ToArrayBuffer(sponsor.avatarBuffer); return { ...sponsor, avatarUrlHighRes: pngToDataUri(await round(data, 0.5, 120)), avatarUrlLowRes: pngToDataUri(await round(data, 0.5, 50)), avatarUrlMediumRes: pngToDataUri(await round(data, 0.5, 80)) }; } function lerp(a, b, t) { if (t < 0) return a; return a + (b - a) * t; } async function defaultComposer(composer, sponsors, config) { const tierPartitions = partitionTiers(sponsors, config.tiers); composer.addSpan(config.padding?.top ?? 20); tierPartitions.forEach(({ tier: t, sponsors: sponsors2 }) => { t.composeBefore?.(composer, sponsors2, config); if (t.compose) { t.compose(composer, sponsors2, config); } else { const preset = t.preset || presets.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); } composer.addSponsorGrid(sponsors2, preset); if (paddingBottom) composer.addSpan(paddingBottom); } } t.composeAfter?.(composer, sponsors2, config); }); composer.addSpan(config.padding?.bottom ?? 20); } const cli = yargs(process.argv.slice(2)).scriptName("sponsors-svg").usage("$0 [args]").version(version).strict().showHelpOnFail(false).alias("h", "help").alias("v", "version"); cli.command( "*", "Generate", (args) => args.option("width", { alias: "w", type: "number", default: 800 }).option("fallbackAvatar", { type: "string", alias: "fallback" }).option("force", { alias: "f", default: false, type: "boolean" }).option("name", { type: "string" }).option("filter", { type: "string" }).option("outputDir", { type: "string", alias: ["o", "dir"] }).strict().help(), async (options) => { const config = options; if (options._[0]) config.outputDir = options._[0]; if (options.filter) config.filter = createFilterFromString(options.filter); await run(config); } ); cli.help().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}`); }