UNPKG

@lizardbyte/contribkit

Version:

Toolkit for generating contributor images

1,588 lines (1,513 loc) 53.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, exechangeRate: Number.parseFloat(process.env.CONTRIBKIT_AFDIAN_EXECHANGERATE || process.env.AFDIAN_EXECHANGERATE || "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 } }; return JSON.parse(JSON.stringify(config)); } const version = "2025.327.12351"; 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 cropId = `c${crypto.createHash("md5").update(base64Image).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, exechangeRate = 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.all_sum_amount) / exechangeRate, privacyLevel: "PUBLIC", tierName: "Afdian", createdAt: new Date(raw.first_pay_time * 1e3).toISOString(), expireAt: expireTime ? new Date(expireTime * 1e3).toISOString() : void 0, // empty string means no plan, consider as one time sponsor isOneTime: Boolean(raw.current_plan?.name), provider: "afdian", raw }; }); return processed; } function md5(token, params, ts, userId) { return 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 testParameter = (name, filters) => filters.some(filter => filter instanceof RegExp ? filter.test(name) : filter === name); const supportedProtocols = new Set([ 'https:', 'http:', 'file:', ]); const hasCustomProtocol = urlString => { try { const {protocol} = new URL(urlString); return protocol.endsWith(':') && !protocol.includes('.') && !supportedProtocols.has(protocol); } catch { return false; } }; const normalizeDataURL = (urlString, {stripHash}) => { const match = /^data:(?<type>[^,]*?),(?<data>[^#]*?)(?:#(?<hash>.*))?$/.exec(urlString); if (!match) { throw new Error(`Invalid URL: ${urlString}`); } let {type, data, hash} = match.groups; const mediaType = type.split(';'); hash = stripHash ? '' : hash; let isBase64 = false; if (mediaType[mediaType.length - 1] === 'base64') { mediaType.pop(); isBase64 = true; } // 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); } return `data:${normalizedMediaType.join(';')},${isBase64 ? data.trim() : data}${hash ? `#${hash}` : ''}`; }; 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, ...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); } if (hasCustomProtocol(urlString)) { return urlString; } const hasRelativeProtocol = urlString.startsWith('//'); const isRelativeUrl = !hasRelativeProtocol && /^\.*\//.test(urlString); // Prepend protocol if (!isRelativeUrl) { 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.replace(/\/{2,}/g, '/'); result += protocol; lastIndex = protocolAtIndex + protocol.length; } const remnant = urlObject.pathname.slice(lastIndex, urlObject.pathname.length); result += remnant.replace(/\/{2,}/g, '/'); urlObject.pathname = result; } // Decode URI octets if (urlObject.pathname) { try { urlObject.pathname = decodeURI(urlObject.pathname); } catch {} } // Remove directory index if (options.removeDirectoryIndex === true) { options.removeDirectoryIndex = [/^index\.[a-z]+$/]; } if (Array.isArray(options.removeDirectoryIndex) && options.removeDirectoryIndex.length > 0) { let pathComponents = urlObject.pathname.split('/'); const lastComponent = pathComponents[pathComponents.length - 1]; if (testParameter(lastComponent, options.removeDirectoryIndex)) { pathComponents = pathComponents.slice(0, -1); urlObject.pathname = pathComponents.slice(1).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\./, ''); } } // Remove query unwanted parameters if (Array.isArray(options.removeQueryParameters)) { // eslint-disable-next-line unicorn/no-useless-spread -- We are intentionally spreading to get a copy. for (const key of [...urlObject.searchParams.keys()]) { if (testParameter(key, options.removeQueryParameters)) { urlObject.searchParams.delete(key); } } } if (!Array.isArray(options.keepQueryParameters) && options.removeQueryParameters === true) { urlObject.search = ''; } // Keep wanted query parameters if (Array.isArray(options.keepQueryParameters) && options.keepQueryParameters.length > 0) { // eslint-disable-next-line unicorn/no-useless-spread -- We are intentionally spreading to get a copy. for (const key of [...urlObject.searchParams.keys()]) { if (!testParameter(key, options.keepQueryParameters)) { urlObject.searchParams.delete(key); } } } // Sort query parameters if (options.sortQueryParameters) { urlObject.searchParams.sort(); // Calling `.sort()` encodes the search parameters, so we need to decode them again. try { urlObject.search = decodeURIComponent(urlObject.search); } catch {} } 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 GitlabContributorsProvider = { name: "gitlabContributors", fetchSponsors(config) { if (!config.gitlabContributors?.repoId) throw new Error("Gitlab repoId is required"); return fetchGitlabContributors( config.gitlabContributors?.token || config.token, config.gitlabContributors.repoId, config.gitlabContributors?.minContributions ); } }; async function fetchGitlabContributors(token, repoId, minContributions = 1) { if (!token) throw new Error("Gitlab token is required"); if (!repoId) throw new Error("Gitlab repoId is required"); const allContributors = []; let page = 1; let hasNextPage = true; while (hasNextPage) { const response = await $fetch( `https://gitlab.com/api/v4/projects/${repoId}/repository/contributors`, { query: { page: String(page), per_page: "100", sort: "desc" }, headers: { "PRIVATE-TOKEN": token, "Content-Type": "application/json" } } ); if (!response || !response.length) break; allContributors.push(...response); hasNextPage = response.length === 100; page++; } const sponsorships = []; for (const contributor of allContributors) { if (contributor.commits < minContributions) continue; try { const userDetails = await $fetch("https://gitlab.com/api/v4/users", { query: { search: contributor.email }, headers: { "PRIVATE-TOKEN": token, "Content-Type": "application/json" } }); if (userDetails && userDetails.length > 0) { const user = userDetails[0]; sponsorships.push({ sponsor: { type: "User", login: user.username, name: user.username, // user.name is also available avatarUrl: user.avatar_url, linkUrl: user.web_url }, isOneTime: false, monthlyDollars: contributor.commits, privacyLevel: "PUBLIC", tierName: "Contributor", createdAt: (/* @__PURE__ */ new Date()).toISOString(), provider: "gitlabContributors" }); } } catch (error) { console.warn(`Failed to fetch user details for ${contributor.email}:`, error); } } return sponsorships; } const LiberapayProvider = { name: "liberapay", fetchSponsors(config) { return fetchLiberapaySponsors(config.liberapay?.login); } }; async function fetchLiberapaySponsors(login) { if (!login) throw new Error("Liberapay login is required"); const csvUrl = `https://liberapay.com/${login}/patrons/public.csv`; const csvResponse = await $fetch(csvUrl); const rows = []; const { parseString } = await import('../chunks/index3.mjs').then(function (n) { return n.i; }); await new Promise((resolve) => { parseString(csvResponse, { headers: true, ignoreEmpty: true, trim: true }).on("data", (row) => rows.push(row)).on("end", resolve); }); const exchangeRates = rows.some((r) => r.donation_currency !== "USD") ? await $fetch("https://www.floatrates.com/daily/usd.json") : {}; return rows.map((row) => ({ sponsor: { type: "User", login: row.patron_username, name: row.patron_public_name || row.patron_username, avatarUrl: row.patron_avatar_url, linkUrl: `https://liberapay.com/${row.patron_username}` }, monthlyDollars: getMonthlyDollarAmount(Number.parseFloat(row.weekly_amount), row.donation_currency, exchangeRates), privacyLevel: "PUBLIC", createdAt: new Date(row.pledge_date).toISOString(), provider: "liberapay" })); } function getMonthlyDollarAmount(weeklyAmount, currency, exchangeRates) { const weeksPerMonth = 4.345; const monthlyAmount = weeklyAmount * weeksPerMonth; if (currency === "USD") return monthlyAmount; const currencyLower = currency.toLowerCase(); const inverseRate = exchangeRates[currencyLower]?.inverseRate ?? 1; return monthlyAmount * inverseRate; } const OpenCollectiveProvider = { name: "opencollective", fetchSponsors(config) { return fetchOpenCollectiveSponsors( config.opencollective?.key, config.opencollective?.id, config.opencollective?.slug, config.opencollective?.githubHandle, config.includePastSponsors ); } }; const API = "https://api.opencollective.com/graphql/v2/"; const graphql = String.raw; async function fetchOpenCollectiveSponsors(key, id, slug, githubHandle, includePastSponsors) { if (!key) throw new Error("OpenCollective api key is required"); if (!slug && !id && !githubHandle) throw new Error("OpenCollective collective id or slug or GitHub handle is required"); const sponsors = []; const monthlyTransactions = []; let offset; offset = 0; do { const query = makeSubscriptionsQuery(id, slug, githubHandle, offset, !includePastSponsors); const data = await $fetch(API, { method: "POST", body: { query }, headers: { "Api-Key": `${key}`, "Content-Type": "application/json" } }); const nodes = data.data.account.orders.nodes; const totalCount = data.data.account.orders.totalCount; sponsors.push(...nodes || []); if (nodes.length !== 0) { if (totalCount > offset + nodes.length) offset += nodes.length; else offset = void 0; } else { offset = void 0; } } while (offset); offset = 0; do { const now = /* @__PURE__ */ new Date(); const dateFrom = includePastSponsors ? void 0 : new Date(now.getFullYear(), now.getMonth(), 1); const query = makeTransactionsQuery(id, slug, githubHandle, offset, dateFrom); const data = await $fetch(API, { method: "POST", body: { query }, headers: { "Api-Key": `${key}`, "Content-Type": "application/json" } }); const nodes = data.data.account.transactions.nodes; const totalCount = data.data.account.transactions.totalCount; monthlyTransactions.push(...nodes || []); if (nodes.length !== 0) { if (totalCount > offset + nodes.length) offset += nodes.length; else offset = void 0; } else { offset = void 0; } } while (offset); const sponsorships = sponsors.map(createSponsorFromOrder).filter((sponsorship) => sponsorship !== null); const monthlySponsorships = monthlyTransactions.map((t) => createSponsorFromTransaction(t, sponsorships.map((i) => i[1].raw.id))).filter((sponsorship) => sponsorship !== null && sponsorship !== void 0); const transactionsBySponsorId = monthlySponsorships.reduce((map, [id2, sponsor]) => { const existingSponsor = map.get(id2); if (existingSponsor) { const createdAt = new Date(sponsor.createdAt); const existingSponsorCreatedAt = new Date(existingSponsor.createdAt); if (createdAt >= existingSponsorCreatedAt) map.set(id2, sponsor); else if (new Date(existingSponsorCreatedAt.getFullYear(), existingSponsorCreatedAt.getMonth(), 1) === new Date(createdAt.getFullYear(), createdAt.getMonth(), 1)) existingSponsor.monthlyDollars += sponsor.monthlyDollars; } else { map.set(id2, sponsor); } return map; }, /* @__PURE__ */ new Map()); const processed = sponsorships.reduce((map, [id2, sponsor]) => { const existingSponsor = map.get(id2); if (existingSponsor) { const createdAt = new Date(sponsor.createdAt); const existingSponsorCreatedAt = new Date(existingSponsor.createdAt); if (createdAt >= existingSponsorCreatedAt) map.set(id2, sponsor); } else { map.set(id2, sponsor); } return map; }, /* @__PURE__ */ new Map()); const result = Array.from(processed.values()).concat(Array.from(transactionsBySponsorId.values())); return result; } function createSponsorFromOrder(order) { const slug = order.fromAccount.slug; if (slug === "github-sponsors") return void 0; let monthlyDollars = order.amount.value; if (order.status !== "ACTIVE") monthlyDollars = -1; else if (order.frequency === "MONTHLY") monthlyDollars = order.amount.value; else if (order.frequency === "YEARLY") monthlyDollars = order.amount.value / 12; else if (order.frequency === "ONETIME") monthlyDollars = order.amount.value; const sponsor = { sponsor: { name: order.fromAccount.name, type: getAccountType(order.fromAccount.type), login: slug, avatarUrl: order.fromAccount.imageUrl, websiteUrl: normalizeUrl(getBestUrl(order.fromAccount.socialLinks)), linkUrl: `https://opencollective.com/${slug}`, socialLogins: getSocialLogins(order.fromAccount.socialLinks, slug) }, isOneTime: order.frequency === "ONETIME", monthlyDollars, privacyLevel: order.fromAccount.isIncognito ? "PRIVATE" : "PUBLIC", tierName: order.tier?.name, createdAt: order.frequency === "ONETIME" ? order.createdAt : order.order?.createdAt, raw: order }; return [order.fromAccount.id, sponsor]; } function createSponsorFromTransaction(transaction, excludeOrders) { const slug = transaction.fromAccount.slug; if (slug === "github-sponsors") return void 0; if (excludeOrders.includes(transaction.order?.id)) return void 0; let monthlyDollars = transaction.amount.value; if (transaction.order?.status !== "ACTIVE") { const firstDayOfCurrentMonth = new Date((/* @__PURE__ */ new Date()).getUTCFullYear(), (/* @__PURE__ */ new Date()).getUTCMonth(), 1); if (new Date(transaction.createdAt) < firstDayOfCurrentMonth) monthlyDollars = -1; } else if (transaction.order?.frequency === "MONTHLY") { monthlyDollars = transaction.order?.amount.value; } else if (transaction.order?.frequency === "YEARLY") { monthlyDollars = transaction.order?.amount.value / 12; } const sponsor = { sponsor: { name: transaction.fromAccount.name, type: getAccountType(transaction.fromAccount.type), login: slug, avatarUrl: transaction.fromAccount.imageUrl, websiteUrl: normalizeUrl(getBestUrl(transaction.fromAccount.socialLinks)), linkUrl: `https://opencollective.com/${slug}`, socialLogins: getSocialLogins(transaction.fromAccount.socialLinks, slug) }, isOneTime: transaction.order?.frequency === "ONETIME", monthlyDollars, privacyLevel: transaction.fromAccount.isIncognito ? "PRIVATE" : "PUBLIC", tierName: transaction.tier?.name, createdAt: transaction.order?.frequency === "ONETIME" ? transaction.createdAt : transaction.order?.createdAt, raw: transaction }; return [transaction.fromAccount.id, sponsor]; } function makeAccountQueryPartial(id, slug, githubHandle) { if (id) return `id: "${id}"`; else if (slug) return `slug: "${slug}"`; else if (githubHandle) return `githubHandle: "${githubHandle}"`; else throw new Error("OpenCollective collective id or slug or GitHub handle is required"); } function makeTransactionsQuery(id, slug, githubHandle, offset, dateFrom, dateTo) { const accountQueryPartial = makeAccountQueryPartial(id, slug, githubHandle); const dateFromParam = dateFrom ? `, dateFrom: "${dateFrom.toISOString()}"` : ""; const dateToParam = ""; return graphql`{ account(${accountQueryPartial}) { transactions(limit: 1000, offset:${offset}, type: CREDIT ${dateFromParam} ${dateToParam}) { offset limit totalCount nodes { type kind id order { id status frequency tier { name } amount { value } } createdAt amount { value } fromAccount { name id slug type githubHandle socialLinks { url type } isIncognito imageUrl(height: 460, format: png) } } } } }`; } function makeSubscriptionsQuery(id, slug, githubHandle, offset, activeOnly) { const activeOrNot = activeOnly ? "onlyActiveSubscriptions: true" : "onlySubscriptions: true"; return graphql`{ account(${makeAccountQueryPartial(id, slug, githubHandle)}) { orders(limit: 1000, offset:${offset}, ${activeOrNot}, filter: INCOMING) { nodes { id createdAt frequency status tier { name } amount { value } totalDonations { value } createdAt fromAccount { name id slug type socialLinks { url type } isIncognito imageUrl(height: 460, format: png) } } } } }`; } function getAccountType(type) { switch (type) { case "INDIVIDUAL": return "User"; case "ORGANIZATION": case "COLLECTIVE": case "FUND": case "PROJECT": case "EVENT": case "VENDOR": case "BOT": return "Organization"; default: throw new Error(`Unknown account type: ${type}`); } } function getBestUrl(socialLinks) { const urls = socialLinks.filter((i) => i.type === "WEBSITE" || i.type === "GITHUB" || i.type === "GITLAB" || i.type === "TWITTER" || i.type === "FACEBOOK" || i.type === "YOUTUBE" || i.type === "INSTAGRAM" || i.type === "LINKEDIN" || i.type === "DISCORD" || i.type === "TUMBLR").map((i) => i.url); return urls[0]; } function getSocialLogins(socialLinks = [], opencollectiveLogin) { const socialLogins = {}; for (const link of socialLinks) { if (link.type === "GITHUB") { const login = link.url.match(/github\.com\/([^/]*)/)?.[1]; if (login) socialLogins.github = login; } } if (opencollectiveLogin) socialLogins.opencollective = opencollectiveLogin; return socialLogins; } const PatreonProvider = { name: "patreon", fetchSponsors(config) { return fetchPatreonSponsors(config.patreon?.token || config.token); } }; async function fetchPatreonSponsors(token) { if (!token) throw new Error("Patreon token is required"); const userData = await $fetch( "https://www.patreon.com/api/oauth2/api/current_user/campaigns?include=null", { method: "GET", headers: { "Authorization": `bearer ${token}`, "Content-Type": "application/json" }, responseType: "json" } ); const userCampaignId = userData.data[0].id; const sponsors = []; let sponsorshipApi = `https://www.patreon.com/api/oauth2/v2/campaigns/${userCampaignId}/members?include=user&fields%5Bmember%5D=currently_entitled_amount_cents,patron_status,pledge_relationship_start,lifetime_support_cents&fields%5Buser%5D=image_url,url,first_name,full_name&page%5Bcount%5D=100`; do { const sponsorshipData = await $fetch(sponsorshipApi, { method: "GET", headers: { "Authorization": `bearer ${token}`, "Content-Type": "application/json" }, responseType: "json" }); sponsors.push( ...sponsorshipData.data.filter((membership) => membership.attributes.patron_status !== null).map((membership) => ({ membership, patron: sponsorshipData.included.find( (v) => v.id === membership.relationships.user.data.id ) })) ); sponsorshipApi = sponsorshipData.links?.next; } while (sponsorshipApi); const processed = sponsors.map( (raw) => ({ sponsor: { avatarUrl: raw.patron.attributes.image_url, login: raw.patron.attributes.first_name, name: raw.patron.attributes.full_name, type: "User", // Patreon only support user linkUrl: raw.patron.attributes.url }, isOneTime: false, // One-time pledges not supported // The "former_patron" and "declined_patron" both is past sponsors monthlyDollars: ["former_patron", "declined_patron"].includes(raw.membership.attributes.patron_status) ? -1 : Math.floor(raw.membership.attributes.currently_entitled_amount_cents / 100), privacyLevel: "PUBLIC", // Patreon is all public tierName: "Patreon", createdAt: raw.membership.attributes.pledge_relationship_start }) ); return processed; } const PolarProvider = { name: "polar", fetchSponsors(config) { return fetchPolarSponsors(config.polar?.token || config.token, config.polar?.organization); } }; async function fetchPolarSponsors(token, organization) { if (!token) throw new Error("Polar token is required"); if (!organization) throw new Error("Polar organization is required"); const apiFetch = ofetch.create({ baseURL: "https://api.polar.sh/v1", headers: { Authorization: `Bearer ${token}` } }); const org = await apiFetch("/organizations", { params: