@lizardbyte/contribkit
Version:
Toolkit for generating contributor images
1,517 lines (1,404 loc) • 71.6 kB
JavaScript
import { loadConfig as loadConfig$1 } from 'unconfig';
import process from 'node:process';
import dotenv from 'dotenv';
import crypto, { createHash } from 'node:crypto';
import { Buffer } from 'node:buffer';
import { consola } from 'consola';
import { $fetch, ofetch } from 'ofetch';
import sharp from 'sharp';
import { ProjectsGroups, Reports } from '@crowdin/crowdin-api-client';
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 tierPresets = {
none,
xs,
small,
base,
medium,
large,
xl
};
const presets = tierPresets;
const defaultTiers = [
{
title: "Past Sponsors",
monthlyDollars: -1,
preset: tierPresets.xs
},
{
title: "Backers",
preset: tierPresets.base
},
{
title: "Sponsors",
monthlyDollars: 10,
preset: tierPresets.medium
},
{
title: "Silver Sponsors",
monthlyDollars: 50,
preset: tierPresets.large
},
{
title: "Gold Sponsors",
monthlyDollars: 100,
preset: tierPresets.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;
}
.contribkit-link {
cursor: pointer;
}
.contribkit-tier-title {
font-weight: 500;
font-size: 20px;
}
`;
const defaultConfig = {
width: 800,
outputDir: "./contribkit",
cacheFile: ".cache.json",
formats: ["json", "svg", "png"],
tiers: defaultTiers,
name: "sponsors",
includePrivate: false,
svgInlineCSS: defaultInlineCSS
};
function loadEnv() {
dotenv.config();
const config = {
github: {
login: process.env.CONTRIBKIT_GITHUB_LOGIN || process.env.GITHUB_LOGIN,
token: process.env.CONTRIBKIT_GITHUB_TOKEN || process.env.GITHUB_TOKEN,
type: process.env.CONTRIBKIT_GITHUB_TYPE || process.env.GITHUB_TYPE
},
patreon: {
token: process.env.CONTRIBKIT_PATREON_TOKEN || process.env.PATREON_TOKEN
},
opencollective: {
key: process.env.CONTRIBKIT_OPENCOLLECTIVE_KEY || process.env.OPENCOLLECTIVE_KEY,
id: process.env.CONTRIBKIT_OPENCOLLECTIVE_ID || process.env.OPENCOLLECTIVE_ID,
slug: process.env.CONTRIBKIT_OPENCOLLECTIVE_SLUG || process.env.OPENCOLLECTIVE_SLUG,
githubHandle: process.env.CONTRIBKIT_OPENCOLLECTIVE_GH_HANDLE || process.env.OPENCOLLECTIVE_GH_HANDLE,
type: process.env.CONTRIBKIT_OPENCOLLECTIVE_TYPE || process.env.OPENCOLLECTIVE_TYPE
},
afdian: {
userId: process.env.CONTRIBKIT_AFDIAN_USER_ID || process.env.AFDIAN_USER_ID,
token: process.env.CONTRIBKIT_AFDIAN_TOKEN || process.env.AFDIAN_TOKEN,
exchangeRate: Number.parseFloat(process.env.CONTRIBKIT_AFDIAN_EXCHANGE_RATE || process.env.AFDIAN_EXCHANGE_RATE || "0") || void 0
},
polar: {
token: process.env.CONTRIBKIT_POLAR_TOKEN || process.env.POLAR_TOKEN,
organization: process.env.CONTRIBKIT_POLAR_ORGANIZATION || process.env.POLAR_ORGANIZATION
},
liberapay: {
login: process.env.CONTRIBKIT_LIBERAPAY_LOGIN || process.env.LIBERAPAY_LOGIN
},
outputDir: process.env.CONTRIBKIT_DIR,
githubContributors: {
login: process.env.CONTRIBKIT_GITHUB_CONTRIBUTORS_LOGIN,
token: process.env.CONTRIBKIT_GITHUB_CONTRIBUTORS_TOKEN,
minContributions: Number(process.env.CONTRIBKIT_GITHUB_CONTRIBUTORS_MIN) || 1,
repo: process.env.CONTRIBKIT_GITHUB_CONTRIBUTORS_REPO
},
gitlabContributors: {
token: process.env.CONTRIBKIT_GITLAB_CONTRIBUTORS_TOKEN,
minContributions: Number(process.env.CONTRIBKIT_GITLAB_CONTRIBUTORS_MIN) || 1,
repoId: Number(process.env.CONTRIBKIT_GITLAB_CONTRIBUTORS_REPO_ID)
},
crowdinContributors: {
token: process.env.CONTRIBKIT_CROWDIN_TOKEN,
projectId: Number(process.env.CONTRIBKIT_CROWDIN_PROJECT_ID),
minTranslations: Number(process.env.CONTRIBKIT_CROWDIN_MIN_TRANSLATIONS) || 1
},
githubContributions: {
login: process.env.CONTRIBKIT_GITHUB_CONTRIBUTIONS_LOGIN,
token: process.env.CONTRIBKIT_GITHUB_CONTRIBUTIONS_TOKEN,
maxContributions: Number(process.env.CONTRIBKIT_GITHUB_CONTRIBUTIONS_MAX) || void 0,
logarithmicScaling: process.env.CONTRIBKIT_GITHUB_CONTRIBUTIONS_LOGARITHMIC === "true"
}
};
return JSON.parse(JSON.stringify(config));
}
const version = "2026.518.124816";
async function fetchImage(url) {
const arrayBuffer = await $fetch(url, {
responseType: "arrayBuffer",
headers: {
"User-Agent": `Mozilla/5.0 Chrome/124.0.0.0 Safari/537.36 Contribkit/${version}`
}
});
return Buffer.from(arrayBuffer);
}
async function resolveAvatars(ships, getFallbackAvatar, t = consola) {
const fallbackAvatar = await (() => {
if (typeof getFallbackAvatar === "string") {
return fetchImage(getFallbackAvatar);
}
if (getFallbackAvatar)
return getFallbackAvatar;
})();
const pLimit = await import('../chunks/index.mjs').then((r) => r.default);
const limit = pLimit(15);
return Promise.all(ships.map((ship) => limit(async () => {
if (ship.privacyLevel === "PRIVATE" || !ship.sponsor.avatarUrl) {
ship.sponsor.avatarBuffer = fallbackAvatar;
return;
}
const pngBuffer = await fetchImage(ship.sponsor.avatarUrl).catch((e) => {
if (ship.provider === "liberapay" && e.toString().includes("404 Not Found") && fallbackAvatar)
return fallbackAvatar;
t.error(`Failed to fetch avatar for ${ship.sponsor.login || ship.sponsor.name} [${ship.sponsor.avatarUrl}]`);
t.error(e);
if (fallbackAvatar)
return fallbackAvatar;
throw e;
});
if (pngBuffer) {
ship.sponsor.avatarBuffer = await resizeImage(pngBuffer, 120, "webp");
}
})));
}
const cache = /* @__PURE__ */ new Map();
async function resizeImage(image, size = 100, format) {
const cacheKey = `${size}:${format}`;
if (cache.has(image)) {
const cacheHit = cache.get(image).get(cacheKey);
if (cacheHit) {
return cacheHit;
}
}
let processing = sharp(image).resize(size, size, { fit: sharp.fit.cover });
processing = format === "webp" ? processing.webp() : processing.png({ quality: 80, compressionLevel: 8 });
const result = await processing.toBuffer();
if (!cache.has(image)) {
cache.set(image, /* @__PURE__ */ new Map());
}
cache.get(image).set(cacheKey, result);
return result;
}
function svgToPng(svg) {
return sharp(Buffer.from(svg), { density: 150 }).png({ quality: 90 }).toBuffer();
}
function svgToWebp(svg) {
return sharp(Buffer.from(svg), { density: 150 }).webp().toBuffer();
}
const fallback = `
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 135.47 135.47">
<path fill="#2d333b" stroke="#000" stroke-linejoin="round" stroke-width=".32" d="M.16.16h135.15v135.15H.16z" paint-order="stroke markers fill"/>
<path fill="#636e7b" fill-rule="evenodd" d="M81.85 53.56a14.13 14.13 0 1 1-28.25 0 14.13 14.13 0 0 1 28.25 0zm.35 17.36a22.6 22.6 0 1 0-28.95 0 33.92 33.92 0 0 0-19.38 29.05 4.24 4.24 0 0 0 8.46.4 25.43 25.43 0 0 1 50.8 0 4.24 4.24 0 1 0 8.46-.4 33.93 33.93 0 0 0-19.4-29.05z"/>
</svg>
`;
const FALLBACK_AVATAR = svgToPng(fallback);
function defineConfig(config) {
return config;
}
async function loadConfig(inlineConfig = {}) {
const env = loadEnv();
const { config = {} } = await loadConfig$1({
sources: [
{
files: "contrib.config"
},
{
files: "contribkit.config"
},
{
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, includePastSponsors) {
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)).filter((s) => s.monthlyDollars > 0 || includePastSponsors).forEach((sponsor) => {
const tier = tierMappings.find((t) => sponsor.monthlyDollars >= t.monthlyDollars) ?? tierMappings[0];
tier.sponsors.push(sponsor);
});
return tierMappings;
}
function genSvgImage(x, y, size, radius, base64Image, imageFormat) {
const hashInput = `${x}:${y}:${size}:${radius}:${base64Image}`;
const cropId = `c${crypto.createHash("sha256").update(hashInput).digest("hex").slice(0, 6)}`;
return `
<clipPath id="${cropId}">
<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="${size * radius}" ry="${size * radius}" />
</clipPath>
<image x="${x}" y="${y}" width="${size}" height="${size}" href="data:image/${imageFormat};base64,${base64Image}" clip-path="url(#${cropId})"/>`;
}
async function generateBadge(x, y, sponsor, preset, radius, imageFormat) {
const { login } = sponsor;
let name = (sponsor.name || sponsor.login).trim();
const url = sponsor.websiteUrl || 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 { size } = preset.avatar;
let avatar = sponsor.avatarBuffer;
if (size < 50) {
avatar = await resizeImage(avatar, 50, imageFormat);
} else if (size < 80) {
avatar = await resizeImage(avatar, 80, imageFormat);
} else if (imageFormat === "png") {
avatar = await resizeImage(avatar, 120, imageFormat);
}
const avatarBase64 = avatar.toString("base64");
return `<a ${url ? `href="${url}" ` : ""}class="${preset.classes || "contribkit-link"}" target="_blank" id="${login}">
${preset.name ? `<text x="${x + size / 2}" y="${y + size + 18}" text-anchor="middle" class="${preset.name.classes || "contribkit-name"}" fill="${preset.name.color || "currentColor"}">${encodeHtmlEntities(name)}</text>
` : ""}${genSvgImage(x, y, size, radius, avatarBase64, imageFormat)}
</a>`.trim();
}
class SvgComposer {
constructor(config) {
this.config = config;
}
height = 0;
body = "";
addSpan(height = 0) {
this.height += height;
return this;
}
addTitle(text, classes = "contribkit-tier-title") {
return this.addText(text, classes);
}
addText(text, classes = "text") {
this.body += `<text x="${this.config.width / 2}" y="${this.height}" text-anchor="middle" class="${classes}">${text}</text>`;
this.height += 20;
return this;
}
addRaw(svg) {
this.body += svg;
return this;
}
async addSponsorLine(sponsors, preset) {
const offsetX = (this.config.width - sponsors.length * preset.boxWidth) / 2 + (preset.boxWidth - preset.avatar.size) / 2;
const sponsorLine = await Promise.all(sponsors.map(async (s, i) => {
const x = offsetX + preset.boxWidth * i;
const y = this.height;
const radius = s.sponsor.type === "Organization" ? 0.1 : 0.5;
return await generateBadge(x, y, s.sponsor, preset, radius, this.config.imageFormat);
}));
this.body += sponsorLine.join("\n");
this.height += preset.boxHeight;
}
async addSponsorGrid(sponsors, preset) {
const perLine = Math.floor((this.config.width - (preset.container?.sidePadding || 0) * 2) / preset.boxWidth);
for (let i = 0; i < Math.ceil(sponsors.length / perLine); i++) {
await this.addSponsorLine(sponsors.slice(i * perLine, (i + 1) * perLine), preset);
}
return this;
}
generateSvg() {
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 ${this.config.width} ${this.height}" width="${this.config.width}" height="${this.height}">
<!-- Generated by https://github.com/antfu/sponsorskit -->
<style>${this.config.svgInlineCSS}</style>
${this.body}
</svg>
`;
}
}
function encodeHtmlEntities(str) {
return String(str).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
const AfdianProvider = {
name: "afdian",
fetchSponsors(config) {
return fetchAfdianSponsors(config.afdian);
}
};
async function fetchAfdianSponsors(options = {}) {
const {
userId,
token,
exchangeRate = 6.5,
includePurchases = true,
purchaseEffectivity = 30
} = options;
if (!userId || !token)
throw new Error("Afdian id and token are required");
const sponsors = [];
const sponsorshipApi = "https://afdian.com/api/open/query-sponsor";
let page = 1;
let pages = 1;
do {
const params = JSON.stringify({ page });
const ts = Math.round(+/* @__PURE__ */ new Date() / 1e3);
const sign = md5(token, params, ts, userId);
const sponsorshipData = await $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;
if (!includePurchases) {
sponsorshipData.data.list = sponsorshipData.data.list.filter((sponsor) => {
const current = sponsor.current_plan;
if (!current || current.product_type === 0)
return true;
return false;
});
}
if (purchaseEffectivity > 0) {
sponsorshipData.data.list = sponsorshipData.data.list.map((sponsor) => {
const current = sponsor.current_plan;
if (!current || current.product_type === 0)
return sponsor;
const expireTime = current.update_time + purchaseEffectivity * 24 * 3600;
sponsor.current_plan.expire_time = expireTime;
return sponsor;
});
}
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;
let name = raw.user.name;
if (name.startsWith("\u7231\u53D1\u7535\u7528\u6237_"))
name = raw.user.user_id.slice(0, 5);
const avatarUrl = raw.user.avatar;
return {
sponsor: {
type: "User",
login: raw.user.user_id,
name,
avatarUrl,
linkUrl: `https://afdian.com/u/${raw.user.user_id}`
},
// all_sum_amount is based on cny
monthlyDollars: isExpired ? -1 : Number.parseFloat(raw.current_plan.show_price) / exchangeRate,
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 createHash("md5").update(`${token}params${params}ts${ts}user_id${userId}`).digest("hex");
}
const CrowdinContributorsProvider = {
name: "crowdinContributors",
fetchSponsors(config) {
return fetchCrowdinContributors(
config.crowdinContributors?.token || config.token,
config.crowdinContributors?.projectId || 0,
config.crowdinContributors?.minTranslations || 1
);
}
};
async function fetchCrowdinContributors(token, projectId, minTranslations = 1) {
if (!token)
throw new Error("Crowdin token is required");
if (!projectId)
throw new Error("Crowdin project ID is required");
const credentials = {
token
};
const projectsGroups = new ProjectsGroups(credentials);
const project = await projectsGroups.getProject(projectId);
const reports = new Reports(credentials);
const dateTo = (/* @__PURE__ */ new Date()).toISOString();
const dateFrom = project.data.createdAt;
const createReportRequestBody = {
name: "top-members",
schema: {
unit: "words",
format: "json",
dateFrom,
dateTo
}
};
const createReport = await reports.generateReport(projectId, createReportRequestBody);
await new Promise((resolve) => setTimeout(resolve, 5e3));
const report = await reports.downloadReport(projectId, createReport.data.identifier);
const reportRaw = await fetch(report.data.url);
const reportData = await reportRaw.json();
const contributors = reportData.data.filter((entry) => entry.translated > minTranslations).map((entry) => ({
member: entry.user,
translations: entry.translated
}));
return contributors.filter(Boolean).map(({ member, translations }) => ({
sponsor: {
type: "User",
login: member.username,
name: member.username,
// fullName is also available
avatarUrl: member.avatarUrl,
linkUrl: `https://crowdin.com/profile/${member.username}`
},
isOneTime: false,
monthlyDollars: translations,
privacyLevel: "PUBLIC",
tierName: "Translator",
createdAt: member.joinedAt,
provider: "crowdinContributors"
}));
}
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
const DATA_URL_DEFAULT_MIME_TYPE = 'text/plain';
const DATA_URL_DEFAULT_CHARSET = 'us-ascii';
const encodedReservedCharactersPattern = '%(?:3A|2F|3F|23|5B|5D|40|21|24|26|27|28|29|2A|2B|2C|3B|3D)';
const temporaryEncodedReservedTokenBase = '__normalize_url_encoded_reserved__';
const temporaryEncodedReservedTokenPattern = /__normalize_url_encoded_reserved__(\d+)__/g;
const hasEncodedReservedCharactersRegex = new RegExp(encodedReservedCharactersPattern, 'i');
const encodedReservedCharactersRegex = new RegExp(encodedReservedCharactersPattern, 'gi');
const testParameter = (name, filters) => Array.isArray(filters) && filters.some(filter => {
if (filter instanceof RegExp) {
if (filter.flags.includes('g') || filter.flags.includes('y')) {
return new RegExp(filter.source, filter.flags.replaceAll(/[gy]/g, '')).test(name);
}
return filter.test(name);
}
return filter === name;
});
const supportedProtocols = new Set([
'https:',
'http:',
'file:',
]);
const normalizeCustomProtocolOption = protocol => {
if (typeof protocol !== 'string') {
return undefined;
}
const normalizedProtocol = protocol.trim().toLowerCase().replace(/:$/, '');
return normalizedProtocol === '' ? undefined : `${normalizedProtocol}:`;
};
const getCustomProtocol = urlString => {
try {
const {protocol} = new URL(urlString);
const hasAuthority = urlString.slice(0, protocol.length + 2).toLowerCase() === `${protocol}//`;
if (protocol.endsWith(':')
&& (!protocol.includes('.') || hasAuthority)
&& !supportedProtocols.has(protocol)) {
return protocol;
}
} catch {}
return undefined;
};
const decodeQueryKey = value => {
try {
return decodeURIComponent(value.replaceAll('+', '%20'));
} catch {
// Match URLSearchParams behavior for malformed percent-encoding.
return new URLSearchParams(`${value}=`).keys().next().value;
}
};
const getKeysWithoutEquals = search => {
const keys = new Set();
if (!search) {
return keys;
}
for (const part of search.slice(1).split('&')) {
if (part && !part.includes('=')) {
keys.add(decodeQueryKey(part));
}
}
return keys;
};
const getTemporaryEncodedReservedTokenPrefix = search => {
let decodedSearch = search;
try {
decodedSearch = decodeURIComponent(search);
} catch {
decodedSearch = new URLSearchParams(search).toString();
}
const getUsedTokenIndexes = value => {
const indexes = new Set();
for (const match of value.matchAll(temporaryEncodedReservedTokenPattern)) {
indexes.add(Number.parseInt(match[1], 10));
}
return indexes;
};
const usedTokenIndexes = getUsedTokenIndexes(search);
for (const tokenIndex of getUsedTokenIndexes(decodedSearch)) {
usedTokenIndexes.add(tokenIndex);
}
let tokenIndex = 0;
while (usedTokenIndexes.has(tokenIndex)) {
tokenIndex++;
}
return `${temporaryEncodedReservedTokenBase}${tokenIndex}__`;
};
const sortSearchParameters = (searchParameters, encodedReservedTokenRegex) => {
if (!encodedReservedTokenRegex) {
searchParameters.sort();
return searchParameters.toString();
}
const getSortableKey = key => key.replace(encodedReservedTokenRegex, (_, hexCode) => String.fromCodePoint(Number.parseInt(hexCode, 16)));
const entries = [...searchParameters.entries()];
entries.sort(([leftKey], [rightKey]) => {
const left = getSortableKey(leftKey);
const right = getSortableKey(rightKey);
return left < right ? -1 : (left > right ? 1 : 0);
});
return new URLSearchParams(entries).toString();
};
const decodeReservedTokens = (value, encodedReservedTokenRegex) => {
if (!encodedReservedTokenRegex) {
return value;
}
return value.replace(encodedReservedTokenRegex, (_, hexCode) => String.fromCodePoint(Number.parseInt(hexCode, 16)));
};
const normalizeEmptyQueryParameters = (search, emptyQueryValue, originalSearch) => {
const isAlways = emptyQueryValue === 'always';
const isNever = emptyQueryValue === 'never';
const keysWithoutEquals = (isAlways || isNever) ? undefined : getKeysWithoutEquals(originalSearch);
const normalizeKey = key => key.replaceAll('+', '%20');
const formatEmptyValue = normalizedKey => {
if (isAlways) {
return `${normalizedKey}=`;
}
if (isNever) {
return normalizedKey;
}
return keysWithoutEquals.has(decodeQueryKey(normalizedKey)) ? normalizedKey : `${normalizedKey}=`;
};
const normalizeParameter = parameter => {
const equalIndex = parameter.indexOf('=');
if (equalIndex === -1) {
// Normalize + to %20 (+ means space in query strings)
return formatEmptyValue(normalizeKey(parameter));
}
const key = parameter.slice(0, equalIndex);
const value = parameter.slice(equalIndex + 1);
if (value === '') {
if (key === '') {
return '=';
}
// Normalize + to %20 (+ means space in query strings)
return formatEmptyValue(normalizeKey(key));
}
// Normalize + to %20 in key.
return `${normalizeKey(key)}=${value}`;
};
const parameters = search.slice(1).split('&').filter(Boolean);
return parameters.length === 0 ? '' : `?${parameters.map(x => normalizeParameter(x)).join('&')}`;
};
const normalizeDataURL = (urlString, {stripHash}) => {
const match = /^data:(?<type>[^,]*?),(?<data>[^#]*?)(?:#(?<hash>.*))?$/.exec(urlString);
if (!match) {
throw new Error(`Invalid URL: ${urlString}`);
}
const {type, data, hash} = match.groups;
const mediaType = type.split(';');
const isBase64 = mediaType.at(-1) === 'base64';
if (isBase64) {
mediaType.pop();
}
// Lowercase MIME type
const mimeType = mediaType.shift().toLowerCase();
const attributes = mediaType
.map(attribute => {
let [key, value = ''] = attribute.split('=').map(string => string.trim());
// Lowercase `charset`
if (key === 'charset') {
value = value.toLowerCase();
if (value === DATA_URL_DEFAULT_CHARSET) {
return '';
}
}
return `${key}${value ? `=${value}` : ''}`;
})
.filter(Boolean);
const normalizedMediaType = [...attributes];
if (isBase64) {
normalizedMediaType.push('base64');
}
if (normalizedMediaType.length > 0 || (mimeType && mimeType !== DATA_URL_DEFAULT_MIME_TYPE)) {
normalizedMediaType.unshift(mimeType);
}
const hashPart = stripHash || !hash ? '' : `#${hash}`;
return `data:${normalizedMediaType.join(';')},${isBase64 ? data.trim() : data}${hashPart}`;
};
function normalizeUrl$1(urlString, options) {
options = {
defaultProtocol: 'http',
normalizeProtocol: true,
forceHttp: false,
forceHttps: false,
stripAuthentication: true,
stripHash: false,
stripTextFragment: true,
stripWWW: true,
removeQueryParameters: [/^utm_\w+/i],
removeTrailingSlash: true,
removeSingleSlash: true,
removeDirectoryIndex: false,
removeExplicitPort: false,
sortQueryParameters: true,
removePath: false,
transformPath: false,
emptyQueryValue: 'preserve',
...options,
};
// Legacy: Append `:` to the protocol if missing.
if (typeof options.defaultProtocol === 'string' && !options.defaultProtocol.endsWith(':')) {
options.defaultProtocol = `${options.defaultProtocol}:`;
}
urlString = urlString.trim();
// Data URL
if (/^data:/i.test(urlString)) {
return normalizeDataURL(urlString, options);
}
const customProtocols = Array.isArray(options.customProtocols) ? options.customProtocols : [];
const normalizedCustomProtocols = new Set(customProtocols
.map(protocol => normalizeCustomProtocolOption(protocol))
.filter(Boolean));
const customProtocol = getCustomProtocol(urlString);
if (customProtocol && !normalizedCustomProtocols.has(customProtocol)) {
return urlString;
}
const hasRelativeProtocol = urlString.startsWith('//');
const isRelativeUrl = !hasRelativeProtocol && /^\.*\//.test(urlString);
// Prepend protocol
if (!isRelativeUrl && !customProtocol) {
urlString = urlString.replace(/^(?!(?:\w+:)?\/\/)|^\/\//, options.defaultProtocol);
}
const urlObject = new URL(urlString);
if (options.forceHttp && options.forceHttps) {
throw new Error('The `forceHttp` and `forceHttps` options cannot be used together');
}
if (options.forceHttp && urlObject.protocol === 'https:') {
urlObject.protocol = 'http:';
}
if (options.forceHttps && urlObject.protocol === 'http:') {
urlObject.protocol = 'https:';
}
// Remove auth
if (options.stripAuthentication) {
urlObject.username = '';
urlObject.password = '';
}
// Remove hash
if (options.stripHash) {
urlObject.hash = '';
} else if (options.stripTextFragment) {
urlObject.hash = urlObject.hash.replace(/#?:~:text.*?$/i, '');
}
// Remove duplicate slashes if not preceded by a protocol
// NOTE: This could be implemented using a single negative lookbehind
// regex, but we avoid that to maintain compatibility with older js engines
// which do not have support for that feature.
if (urlObject.pathname) {
// TODO: Replace everything below with `urlObject.pathname = urlObject.pathname.replace(/(?<!\b[a-z][a-z\d+\-.]{1,50}:)\/{2,}/g, '/');` when Safari supports negative lookbehind.
// Split the string by occurrences of this protocol regex, and perform
// duplicate-slash replacement on the strings between those occurrences
// (if any).
const protocolRegex = /\b[a-z][a-z\d+\-.]{1,50}:\/\//g;
let lastIndex = 0;
let result = '';
for (;;) {
const match = protocolRegex.exec(urlObject.pathname);
if (!match) {
break;
}
const protocol = match[0];
const protocolAtIndex = match.index;
const intermediate = urlObject.pathname.slice(lastIndex, protocolAtIndex);
result += intermediate.replaceAll(/\/{2,}/g, '/');
result += protocol;
lastIndex = protocolAtIndex + protocol.length;
}
const remnant = urlObject.pathname.slice(lastIndex);
result += remnant.replaceAll(/\/{2,}/g, '/');
urlObject.pathname = result;
}
// Decode URI octets
if (urlObject.pathname) {
try {
urlObject.pathname = decodeURI(urlObject.pathname).replaceAll('\\', '%5C');
} catch {}
}
// Remove directory index
if (options.removeDirectoryIndex === true) {
options.removeDirectoryIndex = [/^index\.[a-z]+$/];
}
if (Array.isArray(options.removeDirectoryIndex) && options.removeDirectoryIndex.length > 0) {
const pathComponents = urlObject.pathname.split('/').filter(Boolean);
const lastComponent = pathComponents.at(-1);
if (lastComponent && testParameter(lastComponent, options.removeDirectoryIndex)) {
pathComponents.pop();
urlObject.pathname = pathComponents.length > 0 ? `/${pathComponents.join('/')}/` : '/';
}
}
// Remove path
if (options.removePath) {
urlObject.pathname = '/';
}
// Transform path components
if (options.transformPath && typeof options.transformPath === 'function') {
const pathComponents = urlObject.pathname.split('/').filter(Boolean);
const newComponents = options.transformPath(pathComponents);
urlObject.pathname = newComponents?.length > 0 ? `/${newComponents.join('/')}` : '/';
}
if (urlObject.hostname) {
// Remove trailing dot
urlObject.hostname = urlObject.hostname.replace(/\.$/, '');
// Remove `www.`
if (options.stripWWW && /^www\.(?!www\.)[a-z\-\d]{1,63}\.[a-z.\-\d]{2,63}$/.test(urlObject.hostname)) {
// Each label should be max 63 at length (min: 1).
// Source: https://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names
// Each TLD should be up to 63 characters long (min: 2).
// It is technically possible to have a single character TLD, but none currently exist.
urlObject.hostname = urlObject.hostname.replace(/^www\./, '');
}
}
// Capture original query params format before any searchParams modifications
const originalSearch = urlObject.search;
let encodedReservedTokenRegex;
if (options.sortQueryParameters && hasEncodedReservedCharactersRegex.test(originalSearch)) {
const encodedReservedTokenPrefix = getTemporaryEncodedReservedTokenPrefix(originalSearch);
urlObject.search = originalSearch.replaceAll(encodedReservedCharactersRegex, match => `${encodedReservedTokenPrefix}${match.slice(1).toUpperCase()}`);
encodedReservedTokenRegex = new RegExp(`${encodedReservedTokenPrefix}([0-9A-F]{2})`, 'g');
}
const hasKeepQueryParameters = Array.isArray(options.keepQueryParameters);
const {searchParams} = urlObject;
// Remove query unwanted parameters
if (!hasKeepQueryParameters && Array.isArray(options.removeQueryParameters) && options.removeQueryParameters.length > 0) {
// eslint-disable-next-line unicorn/no-useless-spread -- We are intentionally spreading to get a copy.
for (const key of [...searchParams.keys()]) {
if (testParameter(decodeReservedTokens(key, encodedReservedTokenRegex), options.removeQueryParameters)) {
searchParams.delete(key);
}
}
}
if (!hasKeepQueryParameters && options.removeQueryParameters === true) {
urlObject.search = '';
}
// Keep wanted query parameters
if (hasKeepQueryParameters && options.keepQueryParameters.length > 0) {
// eslint-disable-next-line unicorn/no-useless-spread -- We are intentionally spreading to get a copy.
for (const key of [...searchParams.keys()]) {
if (!testParameter(decodeReservedTokens(key, encodedReservedTokenRegex), options.keepQueryParameters)) {
searchParams.delete(key);
}
}
} else if (hasKeepQueryParameters) {
urlObject.search = '';
}
// Sort query parameters
if (options.sortQueryParameters) {
urlObject.search = sortSearchParameters(urlObject.searchParams, encodedReservedTokenRegex);
// Sorting and serializing encode the search parameters, so we need to decode them again.
// Protect &%#? and %2B from decoding (would break URL structure or change meaning) by double-encoding them first.
urlObject.search = decodeURIComponent(urlObject.search.replaceAll(/%(?:26|23|3f|25|2b)/gi, match => `%25${match.slice(1)}`));
if (encodedReservedTokenRegex) {
urlObject.search = urlObject.search.replace(encodedReservedTokenRegex, '%$1');
}
}
// Normalize empty query parameter values
urlObject.search = normalizeEmptyQueryParameters(urlObject.search, options.emptyQueryValue, originalSearch);
if (options.removeTrailingSlash) {
urlObject.pathname = urlObject.pathname.replace(/\/$/, '');
}
// Remove an explicit port number, excluding a default port number, if applicable
if (options.removeExplicitPort && urlObject.port) {
urlObject.port = '';
}
const oldUrlString = urlString;
// Take advantage of many of the Node `url` normalizations
urlString = urlObject.toString();
if (!options.removeSingleSlash && urlObject.pathname === '/' && !oldUrlString.endsWith('/') && urlObject.hash === '') {
urlString = urlString.replace(/\/$/, '');
}
// Remove ending `/` unless removeSingleSlash is false
if ((options.removeTrailingSlash || urlObject.pathname === '/') && urlObject.hash === '' && options.removeSingleSlash) {
urlString = urlString.replace(/\/$/, '');
}
// Restore relative protocol, if applicable
if (hasRelativeProtocol && !options.normalizeProtocol) {
urlString = urlString.replace(/^http:\/\//, '//');
}
// Remove http/https
if (options.stripProtocol) {
urlString = urlString.replace(/^(?:https?:)?\/\//, '');
}
return urlString;
}
function normalizeUrl(url) {
if (!url)
return void 0;
try {
return normalizeUrl$1(url, {
defaultProtocol: "https"
});
} catch {
return url;
}
}
function getMonthDifference(startDate, endDate) {
return (endDate.getFullYear() - startDate.getFullYear()) * 12 + (endDate.getMonth() - startDate.getMonth());
}
function getCurrentMonthTier(dateNow, sponsorDate, tiers, monthlyDollars) {
let currentMonths = 0;
for (const tier of tiers) {
const monthsAtTier = Math.floor(monthlyDollars / tier.monthlyDollars);
if (monthsAtTier === 0) {
continue;
}
if (currentMonths + monthsAtTier > getMonthDifference(sponsorDate, dateNow)) {
return tier.monthlyDollars;
}
monthlyDollars -= monthsAtTier * tier.monthlyDollars;
currentMonths += monthsAtTier;
}
return -1;
}
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;
const tiers = config.tiers?.filter((tier) => tier.monthlyDollars && tier.monthlyDollars > 0).sort((a, b) => b.monthlyDollars - a.monthlyDollars);
do {
const query = makeQuery(login, type, !config.includePastSponsors, cursor);
const data = await $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 dateNow = /* @__PURE__ */ new Date();
const processed = sponsors.filter((raw) => !!raw.tier).map((raw) => {
let monthlyDollars = raw.tier.monthlyPriceInDollars;
if (!raw.isActive) {
if (tiers && raw.tier.isOneTime && config.prorateOnetime) {
monthlyDollars = getCurrentMonthTier(
dateNow,
new Date(raw.createdAt),
tiers,
monthlyDollars
);
} else {
monthlyDollars = -1;
}
}
return {
sponsor: {
...raw.sponsorEntity,
websiteUrl: normalizeUrl(raw.sponsorEntity.websiteUrl),
linkUrl: `https://github.com/${raw.sponsorEntity.login}`,
__typename: void 0,
type: raw.sponsorEntity.__typename
},
isOneTime: raw.tier.isOneTime,
monthlyDollars,
privacyLevel: raw.privacyLevel,
tierName: raw.tier.name,
createdAt: raw.createdAt
};
});
return processed;
}
function makeQuery(login, type, activeOnly = true, cursor) {
return graphql$1`{
${type}(login: "${login}") {
sponsorshipsAsMaintainer(activeOnly: ${Boolean(activeOnly)}, first: 100${cursor ? ` after: "${cursor}"` : ""}) {
totalCount
pageInfo {
endCursor
hasNextPage
}
nodes {
createdAt
privacyLevel
isActive
tier {
name
isOneTime
monthlyPriceInCents
monthlyPriceInDollars
}
sponsorEntity {
__typename
...on Organization {
login
name
avatarUrl
websiteUrl
}
...on User {
login
name
avatarUrl
websiteUrl
}
}
}
}
}
}`;
}
const GitHubContributorsProvider = {
name: "githubContributors",
fetchSponsors(config) {
if (!config.githubContributors?.repo)
throw new Error("GitHub repository is required");
return fetchGitHubContributors(
config.githubContributors?.token || config.token,
config.githubContributors?.login || config.login,
config.githubContributors.repo,
config.githubContributors?.minContributions
);
}
};
async function fetchGitHubContributors(token, login, repo, minContributions = 1) {
if (!token)
throw new Error("GitHub token is required");
if (!login)
throw new Error("GitHub login is required");
if (!repo)
throw new Error("GitHub repository is required");
const allContributors = [];
let page = 1;
let hasNextPage = true;
while (hasNextPage) {
const response = await $fetch(
`https://api.github.com/repos/${login}/${repo}/contributors`,
{
query: {
page: String(page),
per_page: "100"
},
headers: {
Authorization: `bearer ${token}`,
Accept: "application/vnd.github.v3+json"
}
}
);
if (!response || !response.length)
break;
allContributors.push(...response);
hasNextPage = response.length === 100;
page++;
}
return allContributors.filter(
(contributor) => contributor.type === "User" && contributor.contributions >= minContributions
).map((contributor) => ({
sponsor: {
type: "User",
login: contributor.login,
name: contributor.login,
avatarUrl: contributor.avatar_url,
linkUrl: contributor.url
},
isOneTime: false,
monthlyDollars: contributor.contributions,
privacyLevel: "PUBLIC",
tierName: "Contributor",
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
provider: "githubContributors"
}));
}
const GitHubContributionsProvider = {
name: "githubContributions",
fetchSponsors(config) {
if (!config.githubContributions?.login)
throw new Error("GitHub login is required for githubContributions provider");
return fetchGitHubContributions(
config.githubContributions?.token || config.token,
config.githubContributions.login,
config.githubContributions.maxContributions,
config.githubContributions.logarithmicScaling
);
}
};
function createGraphQLFetch(token) {
return async (body) => {
return await $fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
Authorization: `bearer ${token}`,
"Content-Type": "application/json"
},
body
});
};
}
async function fetchUserCreationDate(graphqlFetch, login) {
const userInfoQuery = `
query($login: String!) {
user(login: $login) {
createdAt
}
}
`;
const userInfo = await graphqlFetch({
query: userInfoQuery,
variables: { login }
});
return new Date(userInfo.data.user.createdAt);
}
function generateYearRanges(accountCreated, now) {
const years = [];
for (let year = accountCreated.getFullYear(); year <= now.getFullYear(); year++) {
const from = year === accountCreated.getFullYear() ? accountCreated.toISOString() : `${year}-01-01T00:00:00Z`;
const to = year === now.getFullYear() ? now.toISOString() : `${year}-12-31T23:59:59Z`;
years.push({ from, to });
}
return years;
}
async function fetchContributionsForYear(graphqlFetch, login, from, to) {
const contributionsQuery = `
query($login: String!, $from: DateTime!, $to: DateTime!) {
user(login: $login) {
contributionsCollection(from: $from, to: $to) {
commitContributionsByRepository {
repository {
name
nameWithOwner
url
owner { login url avatarUrl __typename }
}
}
}
}
}
`;
const contributionsResp = await graphqlFetch({
query: contributionsQuery,
variables: { login, from, to }
});
return contributionsResp.data.user.contributionsCollection.commitContributionsByRepository.map((item) => item.repository).filter((repo) => repo?.nameWithOwner);
}
async function discoverReposFromContributions(graphqlFetch, login, repoMap) {
console.log(`[contribkit][githubContributions] fetching contribution timeline to discover more repos...`);
try {
const accountCreated = await fetchUserCreationDate(graphqlFetch, login);
const now = /* @__PURE__ */ new Date();
const years = generateYearRanges(accountCreated, now);
console.log(`[contribkit][githubContributions] querying contributions across ${years.length} years...`);
for (const { from, to } of years) {
try {
const repos = await fetchContributionsForYear(graphqlFetch, login, from, to);
for (const repo of repos) {
repoMap.set(repo.nameWithOwner, repo);
}
} catch (e) {
console.warn(`[contribkit][githubContributions] failed contributions query for ${from.slice(0, 4)}:`, e.message);
}
}
} catch (e) {
console.warn(`[contribkit][githubContributions] contribution timeline discovery failed:`, e.message);
}
}
async function discoverReposFromMergedPRs(graphqlFetch, login, repoMap) {
console.log(`[contribkit][githubContributions] searching for repos with merged PRs...`);
try {
const searchQueryBase = `is:pr is:merged author:${login}`;
let searchAfter = null;
let page = 0;
const maxPages = 10;
do {
const response = await graphqlFetch({
query: `
query($searchQuery: String!, $after: String) {
search(query: $searchQuery, type: ISSUE, first: 100, after: $after) {
pageInfo { hasNextPage endCursor }
edges { node { ... on PullRequest { repository { name nameWithOwner url owner { login url avatarUrl __typename } } } } }
}
}
`,
variables: { searchQuery: searchQueryBase, after: searchAfter }
});
for (const edge of response.data.search.edges) {
const r = edge.node.repository;
if (r?.nameWithOwner)
repoMap.set(r.nameWithOwner, r);
}
searchAfter = response.data.search.pageInfo.endCursor;
page++;
if (response.data.search.pageInfo.hasNextPage && page < maxPages)
console.log(`[contribkit][githubContributions] merged PR search page ${page}, ${repoMap.size} repos so far`);
} while (searchAfter && page < maxPages);
} catch (e) {
console.warn(`[contribkit][githubContributions] merged PR search failed:`, e.message);
}
}
async function fetchPRCountForRepo(graphqlFetch, repo, login) {
const searchQuery = `repo:${repo.nameWithOwner} is:pr is:merged author:${login}`;
try {
const response = await graphqlFetch({
query: `query($q: String!) { search(query: $q, type: ISSUE) { issueCount } }`,
variables: { q: searchQuery }
});
return response.data.search.issueCount;
} catch (e) {
console.warn(`[contribkit][githubContributions] failed PR count for ${repo.nameWithOwner}:`, e.message);
return 0;
}
}
async function fetchMergedPRCounts(graphqlFetch, allRepos, login) {
console.log(`[contribkit][githubContributions] fetching merged PR counts per repository...`);
const repoPRs = /* @__PURE__ */ new Map();
const batchSize = 10;
for (let i = 0; i < allRepos.length; i += batchSize) {
const batch = allRepos.slice(i, i + batchSize);
const counts = await Promise.all(batch.map((repo) => fetchPRCountForRepo(graphqlFetch, repo, login)));
for (let index = 0; index < batch.length; index++) {
const count = counts[index];
if (count > 0)
repoPRs.set(batch[index].nameWithOwner, count);
}
if (i + batchSize < allRepos.length)
console.log(`[contribkit][githubContributions] processed PR batches for ${Math.min(i + batchSize, allRepos.length)}/${allRepos.length} repos...`);
}
console.log(`[contribkit][githubContributions] found merged PR counts for ${repoPRs.size} repositories`);
return repoPRs;
}
function aggregateByOwner(results) {
const aggregated = /* @__PURE__ */ new Map();
for (const { repo, prs } of results) {
const key = `${repo.owner.__typename}:${repo.owner.login}`;
const existing = aggregated.get(key);
if (existing) {
existing.totalPRs += prs;
existing.repos.push({ repo, prs });
} else {
aggregated.set(key, { owner: repo.owner, totalPRs: prs, repos: [{ repo, prs }] });
}
}
return aggregated;
}
function logConsolidatedOwners(aggregated) {
const consolidated = Array.from(aggregated.values()).filter((a) => a.repos.length > 1);
if (consolidated.length) {
console.log(`[contribkit][githubContributions] consolidated ${consolidated.length} owners with multiple repos:`);
for (const { owner, repos, totalPRs } of consolidated.toSorted((a, b) => b.repos.length - a.repos.length).slice(0, 10))
console.log(` - ${owner.login}: ${repos.length} repos, ${totalPRs} merged PRs`);
if (consolidated.length > 10)
console.log(` ... and ${consolidated.length - 10} more`);
}
}
function applyContributionScaling(totalPRs, maxContributions, logarithmicScaling) {
let scaled = totalPRs;
if (logarithmicScaling && scaled > 0) {
scaled = Math.log10(scaled + 1) * 10;
}
if (maxContributions !== void 0 && scaled > maxContributions) {
scaled = maxContributions;
}
return scaled;
}
function convertToSponsorships(aggregated, maxContributions, logarithmicScaling) {
return Array.from(aggregated.values()).sort((a, b) => b.totalPRs - a.totalPRs).map(({ owner, totalPRs, repos }) => {
const scaledPRs = applyContributionScaling(totalPRs, maxContributions, logarithmicScaling);
const linkUrl = repos.length === 1 ? repos[0].repo.url : owner.url;
return {
sponsor: { type: owner.__typename, login: owner.login, name: owner.login, avatarUrl: owner.avatarUrl, linkUrl, socialLogins: { github: owner.login } },
isOneTime: false,
monthlyDollars: scaledPRs,
privacyLevel: "PUBLIC",
tierName: "Repository",
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
provider: "githubContributions",
raw: { owner, totalPRs, scaledPRs, repoCount: repos.length }
};
});
}
async function fetchGitHubContributions(token, login, maxContributions, logarithmicScaling) {
if (!token)
throw new Error("GitHub token is required");
if (!login)
throw new Error("GitHub login is required");
const graphqlFetch = createGraphQLFetch(token);
console.log(`[contribkit][githubContributions] discovering repositories (sources: contributionsCollection + merged PR search)...`);
const repoMap = /* @__PURE__ */ new Map();
await discoverReposFromContributions(graphqlFetch, login, repoMap);
console.log(`[contribkit][githubContributions] found ${repoMap.size} repos after contribution timeline`);
await discoverReposFromMergedPRs(graphqlFetch, login, repoMap);
console.log(`[contribkit][githubContributions] found ${repoMap.size} repos after merged PR search`);
const allRepos = Array.from(repoMap.values());
console.log(`[contribkit][githubContributions] discovered ${allRepos.length} total unique repositories`);
const repoPRs = await fetchMergedPRCounts(graphqlFetch, allRepos, login);
const results = [];
for (const repo of allRepos) {
const prs = repoPRs.get(repo.nameWithOwner) || 0;
if (prs > 0)
results.push({ repo, prs });
}
console.log(`[contribkit][githubContributions] computed merged PR counts for ${results.length} repositories (from ${allRepos.length} total repos with PRs)`);
const aggregated = aggregateByOwner(results);
logConsolidatedOwners(aggregated);
const scalingInfo = [];
if (maxContributions !== vo