UNPKG

sponsorkit

Version:
1,936 lines 63.4 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';

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;
}
.sponsorkit-link {
  cursor: pointer;
}
.sponsorkit-tier-title {
  font-weight: 500;
  font-size: 20px;
}
`;
const defaultConfig = {
  mode: "sponsors",
  width: 800,
  outputDir: "./sponsorkit",
  cacheFile: ".cache.json",
  cacheFileSponsees: ".cache.sponsees.json",
  formats: ["json", "svg", "png"],
  tiers: defaultTiers,
  includePrivate: false,
  svgInlineCSS: defaultInlineCSS
};

function getDeprecatedEnv(name, replacement) {
  const value = process.env[name];
  if (value)
    console.warn(`[sponsorkit] env.${name} is deprecated, use env.${replacement} instead`);
  return value;
}
function loadEnv() {
  dotenv.config({ quiet: true });
  const config = {
    mode: process.env.SPONSORKIT_MODE,
    github: {
      login: process.env.SPONSORKIT_GITHUB_LOGIN || process.env.GITHUB_LOGIN || getDeprecatedEnv("SPONSORKIT_LOGIN", "SPONSORKIT_GITHUB_LOGIN"),
      token: process.env.SPONSORKIT_GITHUB_TOKEN || process.env.GITHUB_TOKEN || getDeprecatedEnv("SPONSORKIT_TOKEN", "SPONSORKIT_GITHUB_TOKEN"),
      type: process.env.SPONSORKIT_GITHUB_TYPE || process.env.GITHUB_TYPE
    },
    patreon: {
      token: process.env.SPONSORKIT_PATREON_TOKEN || process.env.PATREON_TOKEN
    },
    opencollective: {
      key: process.env.SPONSORKIT_OPENCOLLECTIVE_KEY || process.env.OPENCOLLECTIVE_KEY,
      id: process.env.SPONSORKIT_OPENCOLLECTIVE_ID || process.env.OPENCOLLECTIVE_ID,
      slug: process.env.SPONSORKIT_OPENCOLLECTIVE_SLUG || process.env.OPENCOLLECTIVE_SLUG,
      githubHandle: process.env.SPONSORKIT_OPENCOLLECTIVE_GH_HANDLE || process.env.OPENCOLLECTIVE_GH_HANDLE,
      type: process.env.SPONSORKIT_OPENCOLLECTIVE_TYPE || process.env.OPENCOLLECTIVE_TYPE
    },
    afdian: {
      userId: process.env.SPONSORKIT_AFDIAN_USER_ID || process.env.AFDIAN_USER_ID,
      token: process.env.SPONSORKIT_AFDIAN_TOKEN || process.env.AFDIAN_TOKEN,
      exchangeRate: Number.parseFloat(process.env.SPONSORKIT_AFDIAN_EXCHANGE_RATE || process.env.AFDIAN_EXCHANGE_RATE || "0") || void 0
    },
    polar: {
      token: process.env.SPONSORKIT_POLAR_TOKEN || process.env.POLAR_TOKEN,
      organization: process.env.SPONSORKIT_POLAR_ORGANIZATION || process.env.POLAR_ORGANIZATION
    },
    liberapay: {
      login: process.env.SPONSORKIT_LIBERAPAY_LOGIN || process.env.LIBERAPAY_LOGIN
    },
    outputDir: process.env.SPONSORKIT_DIR
  };
  return JSON.parse(JSON.stringify(config));
}

const version = "17.1.1";

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 Sponsorkit/${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: "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
    }
  };
  if (!["sponsors", "sponsees"].includes(resolved.mode))
    throw new Error(`Invalid mode: ${resolved.mode}. Expected "sponsors" or "sponsees".`);
  resolved.name = inlineConfig.name || config.name || env.name || resolved.mode;
  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 || "sponsorkit-link"}" target="_blank" id="${login}">
  ${preset.name ? `<text x="${x + size / 2}" y="${y + size + 18}" text-anchor="middle" class="${preset.name.classes || "sponsorkit-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 = "sponsorkit-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-collective/sponsorkit -->
<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) {
    if (config.mode === "sponsees") {
      console.warn('[sponsorkit] Afdian provider does not support `mode: "sponsees"` yet');
      return Promise.resolve([]);
    }
    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(Date.now() / 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");
}

// 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) {
    if (config.mode === "sponsees") {
      return fetchGitHubSponsoringAsSponsorships(
        config.github?.token || config.token,
        config.github?.login || config.login,
        config.github?.type || "user"
      );
    }
    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
          }
        }
      }
    }
  }
}`;
}
function assertGitHubSponsoringParams(token, login, type) {
  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`");
}
async function requestGitHubSponsoringGraphQL(token, query) {
  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)}`);
  return data;
}
async function fetchGitHubSponsoringNodes(token, login, type, activeOnly) {
  const nodes = [];
  let cursor;
  do {
    const query = makeSponsoringQuery(login, type, activeOnly, cursor);
    const data = await requestGitHubSponsoringGraphQL(token, query);
    const page = data.data?.[type]?.sponsorshipsAsSponsor;
    if (!page)
      throw new Error("Invalid GitHub response: `sponsorshipsAsSponsor` is missing");
    nodes.push(...page.nodes || []);
    cursor = page.pageInfo?.hasNextPage ? page.pageInfo.endCursor : void 0;
  } while (cursor);
  return nodes;
}
function toGitHubSponsoringRecord(raw) {
  return {
    sponsorable: {
      type: raw.sponsorable.__typename,
      login: raw.sponsorable.login,
      name: raw.sponsorable.name || raw.sponsorable.login,
      avatarUrl: raw.sponsorable.avatarUrl,
      websiteUrl: normalizeUrl(raw.sponsorable.websiteUrl),
      linkUrl: `https://github.com/${raw.sponsorable.login}`
    },
    monthlyDollars: raw.tier.monthlyPriceInDollars,
    monthlyCents: raw.tier.monthlyPriceInCents,
    tierName: raw.tier.name,
    isOneTime: raw.tier.isOneTime,
    privacyLevel: raw.privacyLevel,
    createdAt: raw.createdAt,
    isActive: raw.isActive,
    raw
  };
}
function groupSponsoringRecordsByLogin(records) {
  const recordsBySponsorable = /* @__PURE__ */ new Map();
  for (const record of records) {
    const list = recordsBySponsorable.get(record.sponsorable.login);
    if (list)
      list.push(record);
    else
      recordsBySponsorable.set(record.sponsorable.login, [record]);
  }
  return recordsBySponsorable;
}
async function fetchTotalCentsBySponsorable(token, login, type, sponsorableLogins) {
  return new Map(
    await Promise.all(
      sponsorableLogins.map(async (sponsorableLogin) => {
        const totalInCents = await fetchGitHubTotalSponsorshipAmountAsSponsor(
          token,
          login,
          type,
          { sponsorableLogins: [sponsorableLogin] }
        );
        return [sponsorableLogin, totalInCents];
      })
    )
  );
}
function summarizeSponsoringRecords(records) {
  const [first, ...rest] = records;
  let latest = first;
  let firstCreatedAt = first.createdAt;
  let isOneTime = first.isOneTime;
  const raws = [first.raw];
  for (const record of rest) {
    raws.push(record.raw);
    if (Date.parse(record.createdAt) > Date.parse(latest.createdAt))
      latest = record;
    if (record.createdAt.localeCompare(firstCreatedAt) < 0)
      firstCreatedAt = record.createdAt;
    isOneTime &&= record.isOneTime;
  }
  return {
    latest,
    firstCreatedAt,
    isOneTime,
    raws
  };
}
async function fetchGitHubSponsoring(token, login, type, activeOnly = true) {
  assertGitHubSponsoringParams(token, login, type);
  return (await fetchGitHubSponsoringNodes(token, login, type, activeOnly)).filter((raw) => !!raw.tier && !!raw.sponsorable).map(toGitHubSponsoringRecord);
}
async function fetchGitHubSponsoringAsSponsorships(token, login, type) {
  const records = await fetchGitHubSponsoring(token, login, type, false);
  const recordsBySponsorable = groupSponsoringRecordsByLogin(records);
  const totalBySponsorable = await fetchTotalCentsBySponsorable(
    token,
    login,
    type,
    [...recordsBySponsorable.keys()]
  );
  return [...recordsBySponsorable.entries()].map(([sponsorableLogin, list]) => {
    const summary = summarizeSponsoringRecords(list);
    const totalInCents = totalBySponsorable.get(sponsorableLogin) || 0;
    return {
      sponsor: {
        type: summary.latest.sponsorable.type,
        login: summary.latest.sponsorable.login,
        name: summary.latest.sponsorable.name,
        avatarUrl: summary.latest.sponsorable.avatarUrl,
        websiteUrl: summary.latest.sponsorable.websiteUrl,
        linkUrl: summary.latest.sponsorable.linkUrl,
        socialLogins: {
          github: summary.latest.sponsorable.login
        }
      },
      isOneTime: summary.isOneTime,
      // In sponsees mode, ranking/tiers are based on lifetime sponsored amount.
      monthlyDollars: totalInCents / 100,
      privacyLevel: summary.latest.privacyLevel,
      tierName: summary.latest.tierName,
      createdAt: summary.firstCreatedAt,
      raw: {
        records: summary.raws,
        totalSponsoredCents: totalInCents
      }
    };
  });
}
async function fetchGitHubTotalSponsorshipAmountAsSponsor(token, login, type, options = {}) {
  assertGitHubSponsoringParams(token, login, type);
  const query = makeSponsoringTotalAmountQuery(login, type, options);
  const data = await requestGitHubSponsoringGraphQL(token, query);
  const totalInCents = data.data?.[type]?.totalSponsorshipAmountAsSponsorInCents;
  if (typeof totalInCents !== "number")
    throw new Error("Invalid GitHub response: `totalSponsorshipAmountAsSponsorInCents` is missing");
  return totalInCents;
}
function makeSponsoringQuery(login, type, activeOnly = true, cursor) {
  return graphql$1`{
  ${type}(login: "${login}") {
    sponsorshipsAsSponsor(activeOnly: ${Boolean(activeOnly)}, first: 100${cursor ? ` after: "${cursor}"` : ""}) {
      totalCount
      pageInfo {
        endCursor
        hasNextPage
      }
      nodes {
        createdAt
        privacyLevel
        isActive
        tier {
          name
          isOneTime
          monthlyPriceInCents
          monthlyPriceInDollars
        }
        sponsorable {
          __typename
          ...on Organization {
            login
            name
            avatarUrl
            websiteUrl
          }
          ...on User {
            login
            name
            avatarUrl
            websiteUrl
          }
        }
      }
    }
  }
}`;
}
function makeSponsoringTotalAmountQuery(login, type, options = {}) {
  const args = [];
  if (options.since)
    args.push(`since: ${JSON.stringify(options.since)}`);
  if (options.until)
    args.push(`until: ${JSON.stringify(options.until)}`);
  if (options.sponsorableLogins?.length)
    args.push(`sponsorableLogins: [${options.sponsorableLogins.map((v) => JSON.stringify(v)).join(", ")}]`);
  const parameters = args.length ? `(${args.join(", ")})` : "";
  return graphql$1`{
  ${type}(login: "${login}") {
    totalSponsorshipAmountAsSponsorInCents${parameters}
  }
}`;
}

const LiberapayProvider = {
  name: "liberapay",
  fetchSponsors(config) {
    if (config.mode === "sponsees") {
      console.warn('[sponsorkit] Liberapay provider does not support `mode: "sponsees"` yet');
      return Promise.resolve([]);
    }
    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) {
    if (config.mode === "sponsees") {
      const slug = config.opencollective?.slug || config.opencollective?.id;
      return fetchOpenCollectiveSponsors(
        config.opencollective?.key,
        void 0,
        slug,
        config.opencollective?.githubHandle,
        true,
        true
      );
    }
    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, sponseesMode = false) {
  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");
  includePastSponsors ||= sponseesMode;
  const sponsors = [];
  const monthlyTransactions = [];
  let offset;
  if (!sponseesMode) {
    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, void 0, sponseesMode);
    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), sponseesMode)).filter((sponsorship) => sponsorship !== null && sponsorship !== void 0);
  if (sponseesMode) {
    const processed2 = sponsorships.concat(monthlySponsorships).reduce((map, [id2, sponsor]) => {
      const existingSponsor = map.get(id2);
      if (existingSponsor) {
        existingSponsor.monthlyDollars += sponsor.monthlyDollars;
        existingSponsor.isOneTime = Boolean(existingSponsor.isOneTime && sponsor.isOneTime);
        if (sponsor.createdAt && (!existingSponsor.createdAt || sponsor.createdAt.localeCompare(existingSponsor.createdAt) < 0))
          existingSponsor.createdAt = sponsor.createdAt;
      } else {
        map.set(id2, sponsor);
      }
      return map;
    }, /* @__PURE__ */ new Map());
    return Array.from(processed2.values());
  }
  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.createdAt,
    raw: order
  };
  return [order.fromAccount.id, sponsor];
}
function createSponsorFromTransaction(transaction, excludeOrders, sponseesMode = false) {
  const account = sponseesMode ? transaction.toAccount : transaction.fromAccount;
  if (!account?.slug)
    return void 0;
  const slug = account.slug;
  if (slug === "github-sponsors")
    return void 0;
  if (excludeOrders.includes(transaction.order?.id))
    return void 0;
  let monthlyDollars = transaction.amount.value;
  if (sponseesMode) {
    monthlyDollars = Math.abs(transaction.amount.value);
  } else 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: account.name,
      type: getAccountType(account.type),
      login: slug,
      avatarUrl: account.imageUrl,
      websiteUrl: normalizeUrl(getBestUrl(account.socialLinks || [])),
      linkUrl: `https://opencollective.com/${slug}`,
      socialLogins: getSocialLogins(account.socialLinks || [], slug)
    },
    isOneTime: transaction.order?.frequency === "ONETIME",
    monthlyDollars,
    privacyLevel: sponseesMode ? "PUBLIC" : account.isIncognito ? "PRIVATE" : "PUBLIC",
    tierName: transaction.order?.tier?.name || transaction.tier?.name,
    createdAt: sponseesMode ? transaction.createdAt : transaction.order?.frequency === "ONETIME" ? transaction.createdAt : transaction.order?.createdAt,
    raw: transaction
  };
  return [account.id || slug, 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, sponseesMode = false) {
  const accountQueryPartial = makeAccountQueryPartial(id, slug, githubHandle);
  const dateFromParam = dateFrom ? `, dateFrom: "${dateFrom.toISOString()}"` : "";
  const dateToParam = "";
  const type = sponseesMode ? "DEBIT" : "CREDIT";
  const accountField = sponseesMode ? "toAccount" : "fromAccount";
  return graphql`{
    account(${accountQueryPartial}) {
      transactions(limit: 1000, offset:${offset}, type: ${type} ${dateFromParam} ${dateToParam}) {
        offset
        limit
        totalCount
        nodes {
          type
          kind
          id
          order {
            id
            status
            frequency
            tier {
              name
            }
            createdAt
            amount {
              value
            }
          }
          createdAt
          amount {
            value
          }
          ${accountField} {
            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
          }
          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];
}
const RE_GITHUB_URL = /github\.com\/([^/]*)/;
function getSocialLogins(socialLinks = [], opencollectiveLogin) {
  const socialLogins = {};
  for (const link of socialLinks) {
    if (link.type === "GITHUB") {
      const login = link.url.match(RE_GITHUB_URL)?.[1];
      if (login)
        socialLogins.github = login;
    }
  }
  if (opencollectiveLogin)
    socialLogins.opencollective = opencollectiveLogin;
  return socialLogins;
}

const PatreonProvider = {
  name: "patreon",
  fetchSponsors(config) {
    if (config.mode === "sponsees") {
      console.warn('[sponsorkit] Patreon provider does not support `mode: "sponsees"` yet');
      return Promise.resolve([]);
    }
    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,currently_entitled_tiers&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&fields%5Btier%5D=amount_cents&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.type === "user" && v.id === membership.relationships.user.data.id
        ),
        tier: sponsorshipData.included.find(
          (v) => v.type === "tier" && v.id === membership.relationships.currently_entitled_tiers.data[0]?.id
        )
      }))
    );
    sponsorshipApi = sponsorshipData.links?.next;
  } while (sponsorshipApi);
  const processed = sponsors.map((raw) => {
    const sponsor = {
      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
      monthlyDollars: 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
    };
    if (["former_patron", "declined_patron"].includes(raw.membership.attributes.patron_status))
      sponsor.monthlyDollars = -1;
    else if (sponsor.monthlyDollars <= 0 && (raw.tier?.attributes.amount_cents || 0) > 0)
      sponsor.monthlyDollars = Math.floor(raw.tier.attributes.amount_cents / 100);
    return sponsor;
  });
  return processed;
}

const PolarProvider = {
  name: "polar",
  fetchSponsors(config) {
    if (config.mode === "sponsees") {
      console.warn('[sponsorkit] Polar provider does not support `mode: "sponsees"` yet');
      return Promise.resolve([]);
    }
    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: {
      slug: organization
    }
  });
  const orgId = org.items?.[0]?.id;
  if (!orgId)
    throw new Error(`Polar organization "${organization}" not found`);
  let page = 1;
  let pages = 1;
  const subscriptions = [];
  do {
    const params = {
      organization_id: orgId,
      page
    };
    const subs = await apiFetch("/subscriptions", { params });
    subscriptions.push(...subs.items);
    pages = subs.pagination.max_page;
    page += 1;
  } while (page <= pages);
  return subscriptions.filter((sub) => !!sub.price).map((sub) => {
    const isActive = sub.status === "active";
    return {
      sponsor: {
        name: sub.user.public_name,
        avatarUrl: sub.user.avatar_url,
        login: sub.user.github_username,
        type: sub.product.type === "individual" ? "User" : "Organization",
        socialLogins: {
          github: sub.user.github_username
        }
      },
      isOneTime: false,
      provider: "polar",
      privacyLevel: "PUBLIC",
      createdAt: new Date(sub.created_at).toISOString(),
      tierName: isActive ? sub.product.name : void 0,
      monthlyDollars: isActive ? sub.price.price_amount / 100 : -1
    };
  });
}

const ProvidersMap = {
  github: GitHubProvider,
  patreon: PatreonProvider,
  opencollective: OpenCollectiveProvider,
  afdian: AfdianProvider,
  polar: PolarProvider,
  liberapay: LiberapayProvider
};
function guessProviders(config) {
  const items = [];
  if (config.github && config.github.login)
    items.push("github");
  if (config.patreon && config.patreon.token)
    items.push("patreon");
  if (config.opencollective && (config.opencollective.id || config.opencollective.slug || config.opencollective.githubHandle))
    items.push("opencollective");
  if (config.afdian && config.afdian.userId && config.afdian.token)
    items.push("afdian");
  if (config.polar && config.polar.token)
    items.push("polar");
  if (config.liberapay && config.liberapay.login)
    items.push("liberapay");
  if (!items.length)
    items.push("github");
  return items;
}
function resolveProviders(names) {
  return Array.from(new Set(names)).map((i) => {
    if (typeof i === "string") {
      const provider = ProvidersMap[i];
      if (!provider)
        throw new Error(`Unknown provider: ${i}`);
      return provider;
    }
    return i;
  });
}
async function fetchSponsors(config) {
  const providers = resolveProviders(guessProviders(config));
  const sponsorships = await Promise.all(
    providers.map((provider) => provider.fetchSponsors(config))
  );
  return sponsorships.flat(1);
}

const outputFormats = ["svg", "png", "webp", "json"];

export { FALLBACK_AVATAR as F, GitHubProvider as G, ProvidersMap as P, SvgComposer as S, defaultTiers as a, defaultInlineCSS as b, defaultConfig as c, defineConfig as d, presets as e, resizeImage as f, svgToWebp as g, genSvgImage as h, generateBadge as i, guessProviders as j, resolveProviders as k, loadConfig as l, fetchSponsors as m, fetchGitHubSponsors as n, makeQuery as o, partitionTiers as p, fetchGitHubSponsoring as q, resolveAvatars as r, svgToPng as s, tierPresets as t, fetchGitHubSponsoringAsSponsorships as u, fetchGitHubTotalSponsorshipAmountAsSponsor as v, makeSponsoringQuery as w, makeSponsoringTotalAmountQuery as x, outputFormats as y, version as z };