@lizardbyte/contribkit 2025.922.2626 → 2026.518.124816

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -196,12 +196,18 @@ function loadEnv() {
196
196
  token: process.env.CONTRIBKIT_CROWDIN_TOKEN,
197
197
  projectId: Number(process.env.CONTRIBKIT_CROWDIN_PROJECT_ID),
198
198
  minTranslations: Number(process.env.CONTRIBKIT_CROWDIN_MIN_TRANSLATIONS) || 1
199
+ },
200
+ githubContributions: {
201
+ login: process.env.CONTRIBKIT_GITHUB_CONTRIBUTIONS_LOGIN,
202
+ token: process.env.CONTRIBKIT_GITHUB_CONTRIBUTIONS_TOKEN,
203
+ maxContributions: Number(process.env.CONTRIBKIT_GITHUB_CONTRIBUTIONS_MAX) || void 0,
204
+ logarithmicScaling: process.env.CONTRIBKIT_GITHUB_CONTRIBUTIONS_LOGARITHMIC === "true"
199
205
  }
200
206
  };
201
207
  return JSON.parse(JSON.stringify(config));
202
208
  }
203
209
 
204
- const version = "2025.922.2626";
210
+ const version = "2026.518.124816";
205
211
 
206
212
  async function fetchImage(url) {
207
213
  const arrayBuffer = await $fetch(url, {
@@ -345,7 +351,8 @@ function partitionTiers(sponsors, tiers, includePastSponsors) {
345
351
  }
346
352
 
347
353
  function genSvgImage(x, y, size, radius, base64Image, imageFormat) {
348
- const cropId = `c${crypto.createHash("md5").update(base64Image).digest("hex").slice(0, 6)}`;
354
+ const hashInput = `${x}:${y}:${size}:${radius}:${base64Image}`;
355
+ const cropId = `c${crypto.createHash("sha256").update(hashInput).digest("hex").slice(0, 6)}`;
349
356
  return `
350
357
  <clipPath id="${cropId}">
351
358
  <rect x="${x}" y="${y}" width="${size}" height="${size}" rx="${size * radius}" ry="${size * radius}" />
@@ -588,7 +595,23 @@ async function fetchCrowdinContributors(token, projectId, minTranslations = 1) {
588
595
  const DATA_URL_DEFAULT_MIME_TYPE = 'text/plain';
589
596
  const DATA_URL_DEFAULT_CHARSET = 'us-ascii';
590
597
 
591
- const testParameter = (name, filters) => filters.some(filter => filter instanceof RegExp ? filter.test(name) : filter === name);
598
+ const encodedReservedCharactersPattern = '%(?:3A|2F|3F|23|5B|5D|40|21|24|26|27|28|29|2A|2B|2C|3B|3D)';
599
+ const temporaryEncodedReservedTokenBase = '__normalize_url_encoded_reserved__';
600
+ const temporaryEncodedReservedTokenPattern = /__normalize_url_encoded_reserved__(\d+)__/g;
601
+ const hasEncodedReservedCharactersRegex = new RegExp(encodedReservedCharactersPattern, 'i');
602
+ const encodedReservedCharactersRegex = new RegExp(encodedReservedCharactersPattern, 'gi');
603
+
604
+ const testParameter = (name, filters) => Array.isArray(filters) && filters.some(filter => {
605
+ if (filter instanceof RegExp) {
606
+ if (filter.flags.includes('g') || filter.flags.includes('y')) {
607
+ return new RegExp(filter.source, filter.flags.replaceAll(/[gy]/g, '')).test(name);
608
+ }
609
+
610
+ return filter.test(name);
611
+ }
612
+
613
+ return filter === name;
614
+ });
592
615
 
593
616
  const supportedProtocols = new Set([
594
617
  'https:',
@@ -596,16 +619,155 @@ const supportedProtocols = new Set([
596
619
  'file:',
597
620
  ]);
598
621
 
599
- const hasCustomProtocol = urlString => {
622
+ const normalizeCustomProtocolOption = protocol => {
623
+ if (typeof protocol !== 'string') {
624
+ return undefined;
625
+ }
626
+
627
+ const normalizedProtocol = protocol.trim().toLowerCase().replace(/:$/, '');
628
+ return normalizedProtocol === '' ? undefined : `${normalizedProtocol}:`;
629
+ };
630
+
631
+ const getCustomProtocol = urlString => {
600
632
  try {
601
633
  const {protocol} = new URL(urlString);
634
+ const hasAuthority = urlString.slice(0, protocol.length + 2).toLowerCase() === `${protocol}//`;
602
635
 
603
- return protocol.endsWith(':')
604
- && !protocol.includes('.')
605
- && !supportedProtocols.has(protocol);
636
+ if (protocol.endsWith(':')
637
+ && (!protocol.includes('.') || hasAuthority)
638
+ && !supportedProtocols.has(protocol)) {
639
+ return protocol;
640
+ }
641
+ } catch {}
642
+
643
+ return undefined;
644
+ };
645
+
646
+ const decodeQueryKey = value => {
647
+ try {
648
+ return decodeURIComponent(value.replaceAll('+', '%20'));
606
649
  } catch {
607
- return false;
650
+ // Match URLSearchParams behavior for malformed percent-encoding.
651
+ return new URLSearchParams(`${value}=`).keys().next().value;
652
+ }
653
+ };
654
+
655
+ const getKeysWithoutEquals = search => {
656
+ const keys = new Set();
657
+ if (!search) {
658
+ return keys;
659
+ }
660
+
661
+ for (const part of search.slice(1).split('&')) {
662
+ if (part && !part.includes('=')) {
663
+ keys.add(decodeQueryKey(part));
664
+ }
665
+ }
666
+
667
+ return keys;
668
+ };
669
+
670
+ const getTemporaryEncodedReservedTokenPrefix = search => {
671
+ let decodedSearch = search;
672
+
673
+ try {
674
+ decodedSearch = decodeURIComponent(search);
675
+ } catch {
676
+ decodedSearch = new URLSearchParams(search).toString();
677
+ }
678
+
679
+ const getUsedTokenIndexes = value => {
680
+ const indexes = new Set();
681
+
682
+ for (const match of value.matchAll(temporaryEncodedReservedTokenPattern)) {
683
+ indexes.add(Number.parseInt(match[1], 10));
684
+ }
685
+
686
+ return indexes;
687
+ };
688
+
689
+ const usedTokenIndexes = getUsedTokenIndexes(search);
690
+ for (const tokenIndex of getUsedTokenIndexes(decodedSearch)) {
691
+ usedTokenIndexes.add(tokenIndex);
692
+ }
693
+
694
+ let tokenIndex = 0;
695
+ while (usedTokenIndexes.has(tokenIndex)) {
696
+ tokenIndex++;
608
697
  }
698
+
699
+ return `${temporaryEncodedReservedTokenBase}${tokenIndex}__`;
700
+ };
701
+
702
+ const sortSearchParameters = (searchParameters, encodedReservedTokenRegex) => {
703
+ if (!encodedReservedTokenRegex) {
704
+ searchParameters.sort();
705
+ return searchParameters.toString();
706
+ }
707
+
708
+ const getSortableKey = key => key.replace(encodedReservedTokenRegex, (_, hexCode) => String.fromCodePoint(Number.parseInt(hexCode, 16)));
709
+ const entries = [...searchParameters.entries()];
710
+ entries.sort(([leftKey], [rightKey]) => {
711
+ const left = getSortableKey(leftKey);
712
+ const right = getSortableKey(rightKey);
713
+ return left < right ? -1 : (left > right ? 1 : 0);
714
+ });
715
+
716
+ return new URLSearchParams(entries).toString();
717
+ };
718
+
719
+ const decodeReservedTokens = (value, encodedReservedTokenRegex) => {
720
+ if (!encodedReservedTokenRegex) {
721
+ return value;
722
+ }
723
+
724
+ return value.replace(encodedReservedTokenRegex, (_, hexCode) => String.fromCodePoint(Number.parseInt(hexCode, 16)));
725
+ };
726
+
727
+ const normalizeEmptyQueryParameters = (search, emptyQueryValue, originalSearch) => {
728
+ const isAlways = emptyQueryValue === 'always';
729
+ const isNever = emptyQueryValue === 'never';
730
+ const keysWithoutEquals = (isAlways || isNever) ? undefined : getKeysWithoutEquals(originalSearch);
731
+
732
+ const normalizeKey = key => key.replaceAll('+', '%20');
733
+ const formatEmptyValue = normalizedKey => {
734
+ if (isAlways) {
735
+ return `${normalizedKey}=`;
736
+ }
737
+
738
+ if (isNever) {
739
+ return normalizedKey;
740
+ }
741
+
742
+ return keysWithoutEquals.has(decodeQueryKey(normalizedKey)) ? normalizedKey : `${normalizedKey}=`;
743
+ };
744
+
745
+ const normalizeParameter = parameter => {
746
+ const equalIndex = parameter.indexOf('=');
747
+
748
+ if (equalIndex === -1) {
749
+ // Normalize + to %20 (+ means space in query strings)
750
+ return formatEmptyValue(normalizeKey(parameter));
751
+ }
752
+
753
+ const key = parameter.slice(0, equalIndex);
754
+ const value = parameter.slice(equalIndex + 1);
755
+
756
+ if (value === '') {
757
+ if (key === '') {
758
+ return '=';
759
+ }
760
+
761
+ // Normalize + to %20 (+ means space in query strings)
762
+ return formatEmptyValue(normalizeKey(key));
763
+ }
764
+
765
+ // Normalize + to %20 in key.
766
+ return `${normalizeKey(key)}=${value}`;
767
+ };
768
+
769
+ const parameters = search.slice(1).split('&').filter(Boolean);
770
+ return parameters.length === 0 ? '' : `?${parameters.map(x => normalizeParameter(x)).join('&')}`;
609
771
  };
610
772
 
611
773
  const normalizeDataURL = (urlString, {stripHash}) => {
@@ -615,18 +777,16 @@ const normalizeDataURL = (urlString, {stripHash}) => {
615
777
  throw new Error(`Invalid URL: ${urlString}`);
616
778
  }
617
779
 
618
- let {type, data, hash} = match.groups;
780
+ const {type, data, hash} = match.groups;
619
781
  const mediaType = type.split(';');
620
- hash = stripHash ? '' : hash;
621
782
 
622
- let isBase64 = false;
623
- if (mediaType[mediaType.length - 1] === 'base64') {
783
+ const isBase64 = mediaType.at(-1) === 'base64';
784
+ if (isBase64) {
624
785
  mediaType.pop();
625
- isBase64 = true;
626
786
  }
627
787
 
628
788
  // Lowercase MIME type
629
- const mimeType = mediaType.shift()?.toLowerCase() ?? '';
789
+ const mimeType = mediaType.shift().toLowerCase();
630
790
  const attributes = mediaType
631
791
  .map(attribute => {
632
792
  let [key, value = ''] = attribute.split('=').map(string => string.trim());
@@ -644,9 +804,7 @@ const normalizeDataURL = (urlString, {stripHash}) => {
644
804
  })
645
805
  .filter(Boolean);
646
806
 
647
- const normalizedMediaType = [
648
- ...attributes,
649
- ];
807
+ const normalizedMediaType = [...attributes];
650
808
 
651
809
  if (isBase64) {
652
810
  normalizedMediaType.push('base64');
@@ -656,7 +814,8 @@ const normalizeDataURL = (urlString, {stripHash}) => {
656
814
  normalizedMediaType.unshift(mimeType);
657
815
  }
658
816
 
659
- return `data:${normalizedMediaType.join(';')},${isBase64 ? data.trim() : data}${hash ? `#${hash}` : ''}`;
817
+ const hashPart = stripHash || !hash ? '' : `#${hash}`;
818
+ return `data:${normalizedMediaType.join(';')},${isBase64 ? data.trim() : data}${hashPart}`;
660
819
  };
661
820
 
662
821
  function normalizeUrl$1(urlString, options) {
@@ -677,6 +836,7 @@ function normalizeUrl$1(urlString, options) {
677
836
  sortQueryParameters: true,
678
837
  removePath: false,
679
838
  transformPath: false,
839
+ emptyQueryValue: 'preserve',
680
840
  ...options,
681
841
  };
682
842
 
@@ -692,7 +852,13 @@ function normalizeUrl$1(urlString, options) {
692
852
  return normalizeDataURL(urlString, options);
693
853
  }
694
854
 
695
- if (hasCustomProtocol(urlString)) {
855
+ const customProtocols = Array.isArray(options.customProtocols) ? options.customProtocols : [];
856
+ const normalizedCustomProtocols = new Set(customProtocols
857
+ .map(protocol => normalizeCustomProtocolOption(protocol))
858
+ .filter(Boolean));
859
+
860
+ const customProtocol = getCustomProtocol(urlString);
861
+ if (customProtocol && !normalizedCustomProtocols.has(customProtocol)) {
696
862
  return urlString;
697
863
  }
698
864
 
@@ -700,7 +866,7 @@ function normalizeUrl$1(urlString, options) {
700
866
  const isRelativeUrl = !hasRelativeProtocol && /^\.*\//.test(urlString);
701
867
 
702
868
  // Prepend protocol
703
- if (!isRelativeUrl) {
869
+ if (!isRelativeUrl && !customProtocol) {
704
870
  urlString = urlString.replace(/^(?!(?:\w+:)?\/\/)|^\/\//, options.defaultProtocol);
705
871
  }
706
872
 
@@ -755,13 +921,13 @@ function normalizeUrl$1(urlString, options) {
755
921
  const protocolAtIndex = match.index;
756
922
  const intermediate = urlObject.pathname.slice(lastIndex, protocolAtIndex);
757
923
 
758
- result += intermediate.replace(/\/{2,}/g, '/');
924
+ result += intermediate.replaceAll(/\/{2,}/g, '/');
759
925
  result += protocol;
760
926
  lastIndex = protocolAtIndex + protocol.length;
761
927
  }
762
928
 
763
- const remnant = urlObject.pathname.slice(lastIndex, urlObject.pathname.length);
764
- result += remnant.replace(/\/{2,}/g, '/');
929
+ const remnant = urlObject.pathname.slice(lastIndex);
930
+ result += remnant.replaceAll(/\/{2,}/g, '/');
765
931
 
766
932
  urlObject.pathname = result;
767
933
  }
@@ -769,7 +935,7 @@ function normalizeUrl$1(urlString, options) {
769
935
  // Decode URI octets
770
936
  if (urlObject.pathname) {
771
937
  try {
772
- urlObject.pathname = decodeURI(urlObject.pathname).replace(/\\/g, '%5C');
938
+ urlObject.pathname = decodeURI(urlObject.pathname).replaceAll('\\', '%5C');
773
939
  } catch {}
774
940
  }
775
941
 
@@ -779,12 +945,12 @@ function normalizeUrl$1(urlString, options) {
779
945
  }
780
946
 
781
947
  if (Array.isArray(options.removeDirectoryIndex) && options.removeDirectoryIndex.length > 0) {
782
- let pathComponents = urlObject.pathname.split('/');
783
- const lastComponent = pathComponents[pathComponents.length - 1];
948
+ const pathComponents = urlObject.pathname.split('/').filter(Boolean);
949
+ const lastComponent = pathComponents.at(-1);
784
950
 
785
- if (testParameter(lastComponent, options.removeDirectoryIndex)) {
786
- pathComponents = pathComponents.slice(0, -1);
787
- urlObject.pathname = pathComponents.slice(1).join('/') + '/';
951
+ if (lastComponent && testParameter(lastComponent, options.removeDirectoryIndex)) {
952
+ pathComponents.pop();
953
+ urlObject.pathname = pathComponents.length > 0 ? `/${pathComponents.join('/')}/` : '/';
788
954
  }
789
955
  }
790
956
 
@@ -814,49 +980,61 @@ function normalizeUrl$1(urlString, options) {
814
980
  }
815
981
  }
816
982
 
983
+ // Capture original query params format before any searchParams modifications
984
+ const originalSearch = urlObject.search;
985
+ let encodedReservedTokenRegex;
986
+
987
+ if (options.sortQueryParameters && hasEncodedReservedCharactersRegex.test(originalSearch)) {
988
+ const encodedReservedTokenPrefix = getTemporaryEncodedReservedTokenPrefix(originalSearch);
989
+ urlObject.search = originalSearch.replaceAll(encodedReservedCharactersRegex, match => `${encodedReservedTokenPrefix}${match.slice(1).toUpperCase()}`);
990
+ encodedReservedTokenRegex = new RegExp(`${encodedReservedTokenPrefix}([0-9A-F]{2})`, 'g');
991
+ }
992
+
993
+ const hasKeepQueryParameters = Array.isArray(options.keepQueryParameters);
994
+ const {searchParams} = urlObject;
995
+
817
996
  // Remove query unwanted parameters
818
- if (Array.isArray(options.removeQueryParameters)) {
997
+ if (!hasKeepQueryParameters && Array.isArray(options.removeQueryParameters) && options.removeQueryParameters.length > 0) {
819
998
  // eslint-disable-next-line unicorn/no-useless-spread -- We are intentionally spreading to get a copy.
820
- for (const key of [...urlObject.searchParams.keys()]) {
821
- if (testParameter(key, options.removeQueryParameters)) {
822
- urlObject.searchParams.delete(key);
999
+ for (const key of [...searchParams.keys()]) {
1000
+ if (testParameter(decodeReservedTokens(key, encodedReservedTokenRegex), options.removeQueryParameters)) {
1001
+ searchParams.delete(key);
823
1002
  }
824
1003
  }
825
1004
  }
826
1005
 
827
- if (!Array.isArray(options.keepQueryParameters) && options.removeQueryParameters === true) {
1006
+ if (!hasKeepQueryParameters && options.removeQueryParameters === true) {
828
1007
  urlObject.search = '';
829
1008
  }
830
1009
 
831
1010
  // Keep wanted query parameters
832
- if (Array.isArray(options.keepQueryParameters) && options.keepQueryParameters.length > 0) {
1011
+ if (hasKeepQueryParameters && options.keepQueryParameters.length > 0) {
833
1012
  // eslint-disable-next-line unicorn/no-useless-spread -- We are intentionally spreading to get a copy.
834
- for (const key of [...urlObject.searchParams.keys()]) {
835
- if (!testParameter(key, options.keepQueryParameters)) {
836
- urlObject.searchParams.delete(key);
1013
+ for (const key of [...searchParams.keys()]) {
1014
+ if (!testParameter(decodeReservedTokens(key, encodedReservedTokenRegex), options.keepQueryParameters)) {
1015
+ searchParams.delete(key);
837
1016
  }
838
1017
  }
1018
+ } else if (hasKeepQueryParameters) {
1019
+ urlObject.search = '';
839
1020
  }
840
1021
 
841
1022
  // Sort query parameters
842
1023
  if (options.sortQueryParameters) {
843
- const originalSearch = urlObject.search;
844
- urlObject.searchParams.sort();
1024
+ urlObject.search = sortSearchParameters(urlObject.searchParams, encodedReservedTokenRegex);
845
1025
 
846
- // Calling `.sort()` encodes the search parameters, so we need to decode them again.
847
- try {
848
- urlObject.search = decodeURIComponent(urlObject.search);
849
- } catch {}
1026
+ // Sorting and serializing encode the search parameters, so we need to decode them again.
1027
+ // Protect &%#? and %2B from decoding (would break URL structure or change meaning) by double-encoding them first.
1028
+ urlObject.search = decodeURIComponent(urlObject.search.replaceAll(/%(?:26|23|3f|25|2b)/gi, match => `%25${match.slice(1)}`));
850
1029
 
851
- // Fix parameters that originally had no equals sign but got one added by URLSearchParams
852
- const partsWithoutEquals = originalSearch.slice(1).split('&').filter(p => p && !p.includes('='));
853
- for (const part of partsWithoutEquals) {
854
- const decoded = decodeURIComponent(part);
855
- // Only replace at word boundaries to avoid partial matches
856
- urlObject.search = urlObject.search.replace(`?${decoded}=`, `?${decoded}`).replace(`&${decoded}=`, `&${decoded}`);
1030
+ if (encodedReservedTokenRegex) {
1031
+ urlObject.search = urlObject.search.replace(encodedReservedTokenRegex, '%$1');
857
1032
  }
858
1033
  }
859
1034
 
1035
+ // Normalize empty query parameter values
1036
+ urlObject.search = normalizeEmptyQueryParameters(urlObject.search, options.emptyQueryValue, originalSearch);
1037
+
860
1038
  if (options.removeTrailingSlash) {
861
1039
  urlObject.pathname = urlObject.pathname.replace(/\/$/, '');
862
1040
  }
@@ -1105,6 +1283,246 @@ async function fetchGitHubContributors(token, login, repo, minContributions = 1)
1105
1283
  }));
1106
1284
  }
1107
1285
 
1286
+ const GitHubContributionsProvider = {
1287
+ name: "githubContributions",
1288
+ fetchSponsors(config) {
1289
+ if (!config.githubContributions?.login)
1290
+ throw new Error("GitHub login is required for githubContributions provider");
1291
+ return fetchGitHubContributions(
1292
+ config.githubContributions?.token || config.token,
1293
+ config.githubContributions.login,
1294
+ config.githubContributions.maxContributions,
1295
+ config.githubContributions.logarithmicScaling
1296
+ );
1297
+ }
1298
+ };
1299
+ function createGraphQLFetch(token) {
1300
+ return async (body) => {
1301
+ return await $fetch("https://api.github.com/graphql", {
1302
+ method: "POST",
1303
+ headers: {
1304
+ Authorization: `bearer ${token}`,
1305
+ "Content-Type": "application/json"
1306
+ },
1307
+ body
1308
+ });
1309
+ };
1310
+ }
1311
+ async function fetchUserCreationDate(graphqlFetch, login) {
1312
+ const userInfoQuery = `
1313
+ query($login: String!) {
1314
+ user(login: $login) {
1315
+ createdAt
1316
+ }
1317
+ }
1318
+ `;
1319
+ const userInfo = await graphqlFetch({
1320
+ query: userInfoQuery,
1321
+ variables: { login }
1322
+ });
1323
+ return new Date(userInfo.data.user.createdAt);
1324
+ }
1325
+ function generateYearRanges(accountCreated, now) {
1326
+ const years = [];
1327
+ for (let year = accountCreated.getFullYear(); year <= now.getFullYear(); year++) {
1328
+ const from = year === accountCreated.getFullYear() ? accountCreated.toISOString() : `${year}-01-01T00:00:00Z`;
1329
+ const to = year === now.getFullYear() ? now.toISOString() : `${year}-12-31T23:59:59Z`;
1330
+ years.push({ from, to });
1331
+ }
1332
+ return years;
1333
+ }
1334
+ async function fetchContributionsForYear(graphqlFetch, login, from, to) {
1335
+ const contributionsQuery = `
1336
+ query($login: String!, $from: DateTime!, $to: DateTime!) {
1337
+ user(login: $login) {
1338
+ contributionsCollection(from: $from, to: $to) {
1339
+ commitContributionsByRepository {
1340
+ repository {
1341
+ name
1342
+ nameWithOwner
1343
+ url
1344
+ owner { login url avatarUrl __typename }
1345
+ }
1346
+ }
1347
+ }
1348
+ }
1349
+ }
1350
+ `;
1351
+ const contributionsResp = await graphqlFetch({
1352
+ query: contributionsQuery,
1353
+ variables: { login, from, to }
1354
+ });
1355
+ return contributionsResp.data.user.contributionsCollection.commitContributionsByRepository.map((item) => item.repository).filter((repo) => repo?.nameWithOwner);
1356
+ }
1357
+ async function discoverReposFromContributions(graphqlFetch, login, repoMap) {
1358
+ console.log(`[contribkit][githubContributions] fetching contribution timeline to discover more repos...`);
1359
+ try {
1360
+ const accountCreated = await fetchUserCreationDate(graphqlFetch, login);
1361
+ const now = /* @__PURE__ */ new Date();
1362
+ const years = generateYearRanges(accountCreated, now);
1363
+ console.log(`[contribkit][githubContributions] querying contributions across ${years.length} years...`);
1364
+ for (const { from, to } of years) {
1365
+ try {
1366
+ const repos = await fetchContributionsForYear(graphqlFetch, login, from, to);
1367
+ for (const repo of repos) {
1368
+ repoMap.set(repo.nameWithOwner, repo);
1369
+ }
1370
+ } catch (e) {
1371
+ console.warn(`[contribkit][githubContributions] failed contributions query for ${from.slice(0, 4)}:`, e.message);
1372
+ }
1373
+ }
1374
+ } catch (e) {
1375
+ console.warn(`[contribkit][githubContributions] contribution timeline discovery failed:`, e.message);
1376
+ }
1377
+ }
1378
+ async function discoverReposFromMergedPRs(graphqlFetch, login, repoMap) {
1379
+ console.log(`[contribkit][githubContributions] searching for repos with merged PRs...`);
1380
+ try {
1381
+ const searchQueryBase = `is:pr is:merged author:${login}`;
1382
+ let searchAfter = null;
1383
+ let page = 0;
1384
+ const maxPages = 10;
1385
+ do {
1386
+ const response = await graphqlFetch({
1387
+ query: `
1388
+ query($searchQuery: String!, $after: String) {
1389
+ search(query: $searchQuery, type: ISSUE, first: 100, after: $after) {
1390
+ pageInfo { hasNextPage endCursor }
1391
+ edges { node { ... on PullRequest { repository { name nameWithOwner url owner { login url avatarUrl __typename } } } } }
1392
+ }
1393
+ }
1394
+ `,
1395
+ variables: { searchQuery: searchQueryBase, after: searchAfter }
1396
+ });
1397
+ for (const edge of response.data.search.edges) {
1398
+ const r = edge.node.repository;
1399
+ if (r?.nameWithOwner)
1400
+ repoMap.set(r.nameWithOwner, r);
1401
+ }
1402
+ searchAfter = response.data.search.pageInfo.endCursor;
1403
+ page++;
1404
+ if (response.data.search.pageInfo.hasNextPage && page < maxPages)
1405
+ console.log(`[contribkit][githubContributions] merged PR search page ${page}, ${repoMap.size} repos so far`);
1406
+ } while (searchAfter && page < maxPages);
1407
+ } catch (e) {
1408
+ console.warn(`[contribkit][githubContributions] merged PR search failed:`, e.message);
1409
+ }
1410
+ }
1411
+ async function fetchPRCountForRepo(graphqlFetch, repo, login) {
1412
+ const searchQuery = `repo:${repo.nameWithOwner} is:pr is:merged author:${login}`;
1413
+ try {
1414
+ const response = await graphqlFetch({
1415
+ query: `query($q: String!) { search(query: $q, type: ISSUE) { issueCount } }`,
1416
+ variables: { q: searchQuery }
1417
+ });
1418
+ return response.data.search.issueCount;
1419
+ } catch (e) {
1420
+ console.warn(`[contribkit][githubContributions] failed PR count for ${repo.nameWithOwner}:`, e.message);
1421
+ return 0;
1422
+ }
1423
+ }
1424
+ async function fetchMergedPRCounts(graphqlFetch, allRepos, login) {
1425
+ console.log(`[contribkit][githubContributions] fetching merged PR counts per repository...`);
1426
+ const repoPRs = /* @__PURE__ */ new Map();
1427
+ const batchSize = 10;
1428
+ for (let i = 0; i < allRepos.length; i += batchSize) {
1429
+ const batch = allRepos.slice(i, i + batchSize);
1430
+ const counts = await Promise.all(batch.map((repo) => fetchPRCountForRepo(graphqlFetch, repo, login)));
1431
+ for (let index = 0; index < batch.length; index++) {
1432
+ const count = counts[index];
1433
+ if (count > 0)
1434
+ repoPRs.set(batch[index].nameWithOwner, count);
1435
+ }
1436
+ if (i + batchSize < allRepos.length)
1437
+ console.log(`[contribkit][githubContributions] processed PR batches for ${Math.min(i + batchSize, allRepos.length)}/${allRepos.length} repos...`);
1438
+ }
1439
+ console.log(`[contribkit][githubContributions] found merged PR counts for ${repoPRs.size} repositories`);
1440
+ return repoPRs;
1441
+ }
1442
+ function aggregateByOwner(results) {
1443
+ const aggregated = /* @__PURE__ */ new Map();
1444
+ for (const { repo, prs } of results) {
1445
+ const key = `${repo.owner.__typename}:${repo.owner.login}`;
1446
+ const existing = aggregated.get(key);
1447
+ if (existing) {
1448
+ existing.totalPRs += prs;
1449
+ existing.repos.push({ repo, prs });
1450
+ } else {
1451
+ aggregated.set(key, { owner: repo.owner, totalPRs: prs, repos: [{ repo, prs }] });
1452
+ }
1453
+ }
1454
+ return aggregated;
1455
+ }
1456
+ function logConsolidatedOwners(aggregated) {
1457
+ const consolidated = Array.from(aggregated.values()).filter((a) => a.repos.length > 1);
1458
+ if (consolidated.length) {
1459
+ console.log(`[contribkit][githubContributions] consolidated ${consolidated.length} owners with multiple repos:`);
1460
+ for (const { owner, repos, totalPRs } of consolidated.toSorted((a, b) => b.repos.length - a.repos.length).slice(0, 10))
1461
+ console.log(` - ${owner.login}: ${repos.length} repos, ${totalPRs} merged PRs`);
1462
+ if (consolidated.length > 10)
1463
+ console.log(` ... and ${consolidated.length - 10} more`);
1464
+ }
1465
+ }
1466
+ function applyContributionScaling(totalPRs, maxContributions, logarithmicScaling) {
1467
+ let scaled = totalPRs;
1468
+ if (logarithmicScaling && scaled > 0) {
1469
+ scaled = Math.log10(scaled + 1) * 10;
1470
+ }
1471
+ if (maxContributions !== void 0 && scaled > maxContributions) {
1472
+ scaled = maxContributions;
1473
+ }
1474
+ return scaled;
1475
+ }
1476
+ function convertToSponsorships(aggregated, maxContributions, logarithmicScaling) {
1477
+ return Array.from(aggregated.values()).sort((a, b) => b.totalPRs - a.totalPRs).map(({ owner, totalPRs, repos }) => {
1478
+ const scaledPRs = applyContributionScaling(totalPRs, maxContributions, logarithmicScaling);
1479
+ const linkUrl = repos.length === 1 ? repos[0].repo.url : owner.url;
1480
+ return {
1481
+ sponsor: { type: owner.__typename, login: owner.login, name: owner.login, avatarUrl: owner.avatarUrl, linkUrl, socialLogins: { github: owner.login } },
1482
+ isOneTime: false,
1483
+ monthlyDollars: scaledPRs,
1484
+ privacyLevel: "PUBLIC",
1485
+ tierName: "Repository",
1486
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1487
+ provider: "githubContributions",
1488
+ raw: { owner, totalPRs, scaledPRs, repoCount: repos.length }
1489
+ };
1490
+ });
1491
+ }
1492
+ async function fetchGitHubContributions(token, login, maxContributions, logarithmicScaling) {
1493
+ if (!token)
1494
+ throw new Error("GitHub token is required");
1495
+ if (!login)
1496
+ throw new Error("GitHub login is required");
1497
+ const graphqlFetch = createGraphQLFetch(token);
1498
+ console.log(`[contribkit][githubContributions] discovering repositories (sources: contributionsCollection + merged PR search)...`);
1499
+ const repoMap = /* @__PURE__ */ new Map();
1500
+ await discoverReposFromContributions(graphqlFetch, login, repoMap);
1501
+ console.log(`[contribkit][githubContributions] found ${repoMap.size} repos after contribution timeline`);
1502
+ await discoverReposFromMergedPRs(graphqlFetch, login, repoMap);
1503
+ console.log(`[contribkit][githubContributions] found ${repoMap.size} repos after merged PR search`);
1504
+ const allRepos = Array.from(repoMap.values());
1505
+ console.log(`[contribkit][githubContributions] discovered ${allRepos.length} total unique repositories`);
1506
+ const repoPRs = await fetchMergedPRCounts(graphqlFetch, allRepos, login);
1507
+ const results = [];
1508
+ for (const repo of allRepos) {
1509
+ const prs = repoPRs.get(repo.nameWithOwner) || 0;
1510
+ if (prs > 0)
1511
+ results.push({ repo, prs });
1512
+ }
1513
+ console.log(`[contribkit][githubContributions] computed merged PR counts for ${results.length} repositories (from ${allRepos.length} total repos with PRs)`);
1514
+ const aggregated = aggregateByOwner(results);
1515
+ logConsolidatedOwners(aggregated);
1516
+ const scalingInfo = [];
1517
+ if (maxContributions !== void 0)
1518
+ scalingInfo.push(`max cap: ${maxContributions}`);
1519
+ if (logarithmicScaling)
1520
+ scalingInfo.push("logarithmic scaling enabled");
1521
+ if (scalingInfo.length > 0)
1522
+ console.log(`[contribkit][githubContributions] applying contribution scaling: ${scalingInfo.join(", ")}`);
1523
+ return convertToSponsorships(aggregated, maxContributions, logarithmicScaling);
1524
+ }
1525
+
1108
1526
  const GitlabContributorsProvider = {
1109
1527
  name: "gitlabContributors",
1110
1528
  fetchSponsors(config) {
@@ -1665,6 +2083,7 @@ const ProvidersMap = {
1665
2083
  polar: PolarProvider,
1666
2084
  liberapay: LiberapayProvider,
1667
2085
  githubContributors: GitHubContributorsProvider,
2086
+ githubContributions: GitHubContributionsProvider,
1668
2087
  gitlabContributors: GitlabContributorsProvider,
1669
2088
  crowdinContributors: CrowdinContributorsProvider
1670
2089
  };
@@ -1684,6 +2103,8 @@ function guessProviders(config) {
1684
2103
  items.push("liberapay");
1685
2104
  if (config.githubContributors?.login && config.githubContributors?.token)
1686
2105
  items.push("githubContributors");
2106
+ if (config.githubContributions?.login && config.githubContributions?.token)
2107
+ items.push("githubContributions");
1687
2108
  if (config.gitlabContributors?.token && config.gitlabContributors?.repoId)
1688
2109
  items.push("gitlabContributors");
1689
2110
  if (config.crowdinContributors?.token && config.crowdinContributors?.projectId)
@@ -1713,4 +2134,4 @@ async function fetchSponsors(config) {
1713
2134
 
1714
2135
  const outputFormats = ["svg", "png", "webp", "json"];
1715
2136
 
1716
- 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, outputFormats as q, resolveAvatars as r, svgToPng as s, tierPresets as t, version as v };
2137
+ export { FALLBACK_AVATAR as F, GitHubProvider as G, ProvidersMap as P, SvgComposer as S, defaultInlineCSS as a, defaultTiers as b, defineConfig as c, defaultConfig as d, fetchSponsors as e, fetchGitHubSponsors as f, genSvgImage as g, generateBadge as h, guessProviders as i, presets as j, resolveAvatars as k, loadConfig as l, makeQuery as m, resolveProviders as n, outputFormats as o, partitionTiers as p, svgToWebp as q, resizeImage as r, svgToPng as s, tierPresets as t, version as v };