@lizardbyte/contribkit
Version:
Toolkit for generating contributor images
1,588 lines (1,513 loc) • 53.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,
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
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: