UNPKG

@lizardbyte/contribkit

Version:

Toolkit for generating contributor images

1,517 lines (1,404 loc) 71.6 kB
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); } 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