[READ-ONLY] a fast, modern browser for the npm registry

feat: add GitHub contributors graph (#1445)

authored by

Tobbe Lundberg and committed by
GitHub
67557795 9fbdb324

+566 -57
+196 -51
app/components/Package/TrendsChart.vue
··· 6 6 import { OKLCH_NEUTRAL_FALLBACK, transparentizeOklch } from '~/utils/colors' 7 7 import { getFrameworkColor, isListedFramework } from '~/utils/frameworks' 8 8 import { drawNpmxLogoAndTaglineWatermark } from '~/composables/useChartWatermark' 9 + import type { RepoRef } from '#shared/utils/git-providers' 9 10 import type { 10 11 ChartTimeGranularity, 11 12 DailyDataPoint, ··· 35 36 * Used when `weeklyDownloads` is not provided. 36 37 */ 37 38 packageNames?: string[] 39 + repoRef?: RepoRef | null | undefined 38 40 createdIso?: string | null 39 41 40 42 /** When true, shows facet selector (e.g. Downloads / Likes). */ ··· 332 334 return single ? [single] : [] 333 335 }) 334 336 337 + const { 338 + fetchPackageDownloadEvolution, 339 + fetchPackageLikesEvolution, 340 + fetchRepoContributorsEvolution, 341 + fetchRepoRefsForPackages, 342 + } = useCharts() 343 + 344 + const repoRefsByPackage = shallowRef<Record<string, RepoRef | null>>({}) 345 + const repoRefsRequestToken = shallowRef(0) 346 + 347 + watch( 348 + () => effectivePackageNames.value, 349 + async names => { 350 + if (!import.meta.client) return 351 + if (!isMultiPackageMode.value) { 352 + repoRefsByPackage.value = {} 353 + return 354 + } 355 + const currentToken = ++repoRefsRequestToken.value 356 + const refs = await fetchRepoRefsForPackages(names) 357 + if (currentToken !== repoRefsRequestToken.value) return 358 + repoRefsByPackage.value = refs 359 + }, 360 + { immediate: true }, 361 + ) 362 + 335 363 const selectedGranularity = usePermalink<ChartTimeGranularity>('granularity', DEFAULT_GRANULARITY, { 336 364 permanent: props.permalink, 337 365 }) ··· 361 389 const isEstimationGranularity = computed( 362 390 () => displayedGranularity.value === 'monthly' || displayedGranularity.value === 'yearly', 363 391 ) 364 - const shouldRenderEstimationOverlay = computed( 365 - () => !pending.value && isEstimationGranularity.value, 392 + const supportsEstimation = computed( 393 + () => isEstimationGranularity.value && selectedMetric.value !== 'contributors', 366 394 ) 395 + const shouldRenderEstimationOverlay = computed(() => !pending.value && supportsEstimation.value) 367 396 368 397 const startDate = usePermalink<string>('start', '', { 369 398 permanent: props.permalink, ··· 571 600 return next 572 601 } 573 602 574 - const { fetchPackageDownloadEvolution, fetchPackageLikesEvolution } = useCharts() 603 + type MetricId = 'downloads' | 'likes' | 'contributors' 604 + const DEFAULT_METRIC_ID: MetricId = 'downloads' 575 605 576 - type MetricId = 'downloads' | 'likes' 577 - const DEFAULT_METRIC_ID: MetricId = 'downloads' 606 + type MetricContext = { 607 + packageName: string 608 + repoRef?: RepoRef | null 609 + } 578 610 579 611 type MetricDef = { 580 612 id: MetricId 581 613 label: string 582 - fetch: (pkg: string, options: EvolutionOptions) => Promise<EvolutionData> 614 + fetch: (context: MetricContext, options: EvolutionOptions) => Promise<EvolutionData> 615 + supportsMulti?: boolean 583 616 } 584 617 585 - const METRICS = computed<MetricDef[]>(() => [ 586 - { 587 - id: 'downloads', 588 - label: $t('package.trends.items.downloads'), 589 - fetch: (pkg, opts) => 590 - fetchPackageDownloadEvolution(pkg, props.createdIso ?? null, opts) as Promise<EvolutionData>, 591 - }, 592 - { 593 - id: 'likes', 594 - label: $t('package.trends.items.likes'), 595 - fetch: (pkg, opts) => fetchPackageLikesEvolution(pkg, opts) as Promise<EvolutionData>, 596 - }, 597 - ]) 618 + const hasContributorsFacet = computed(() => { 619 + if (isMultiPackageMode.value) { 620 + return Object.values(repoRefsByPackage.value).some(ref => ref?.provider === 'github') 621 + } 622 + const ref = props.repoRef 623 + return ref?.provider === 'github' && ref.owner && ref.repo 624 + }) 625 + 626 + const METRICS = computed<MetricDef[]>(() => { 627 + const metrics: MetricDef[] = [ 628 + { 629 + id: 'downloads', 630 + label: $t('package.trends.items.downloads'), 631 + fetch: ({ packageName }, opts) => 632 + fetchPackageDownloadEvolution( 633 + packageName, 634 + props.createdIso ?? null, 635 + opts, 636 + ) as Promise<EvolutionData>, 637 + supportsMulti: true, 638 + }, 639 + { 640 + id: 'likes', 641 + label: $t('package.trends.items.likes'), 642 + fetch: ({ packageName }, opts) => fetchPackageLikesEvolution(packageName, opts), 643 + supportsMulti: true, 644 + }, 645 + ] 646 + 647 + if (hasContributorsFacet.value) { 648 + metrics.push({ 649 + id: 'contributors', 650 + label: $t('package.trends.items.contributors'), 651 + fetch: ({ repoRef }, opts) => fetchRepoContributorsEvolution(repoRef, opts), 652 + supportsMulti: true, 653 + }) 654 + } 655 + 656 + return metrics 657 + }) 598 658 599 659 const selectedMetric = usePermalink<MetricId>('facet', DEFAULT_METRIC_ID, { 600 660 permanent: props.permalink, 601 661 }) 602 662 663 + const effectivePackageNamesForMetric = computed<string[]>(() => { 664 + if (!isMultiPackageMode.value) return effectivePackageNames.value 665 + if (selectedMetric.value !== 'contributors') return effectivePackageNames.value 666 + return effectivePackageNames.value.filter( 667 + name => repoRefsByPackage.value[name]?.provider === 'github', 668 + ) 669 + }) 670 + 671 + const skippedPackagesWithoutGitHub = computed(() => { 672 + if (!isMultiPackageMode.value) return [] 673 + if (selectedMetric.value !== 'contributors') return [] 674 + if (!effectivePackageNames.value.length) return [] 675 + 676 + return effectivePackageNames.value.filter( 677 + name => repoRefsByPackage.value[name]?.provider !== 'github', 678 + ) 679 + }) 680 + 681 + const availableGranularities = computed<ChartTimeGranularity[]>(() => { 682 + if (selectedMetric.value === 'contributors') { 683 + return ['weekly', 'monthly', 'yearly'] 684 + } 685 + 686 + return ['daily', 'weekly', 'monthly', 'yearly'] 687 + }) 688 + 689 + watch( 690 + () => [selectedMetric.value, availableGranularities.value] as const, 691 + () => { 692 + if (!availableGranularities.value.includes(selectedGranularity.value)) { 693 + selectedGranularity.value = 'weekly' 694 + } 695 + }, 696 + { immediate: true }, 697 + ) 698 + 699 + watch( 700 + () => METRICS.value, 701 + metrics => { 702 + if (!metrics.some(m => m.id === selectedMetric.value)) { 703 + selectedMetric.value = DEFAULT_METRIC_ID 704 + } 705 + }, 706 + { immediate: true }, 707 + ) 708 + 603 709 // Per-metric state keyed by metric id 604 710 const metricStates = reactive< 605 711 Record< ··· 624 730 evolutionsByPackage: {}, 625 731 requestToken: 0, 626 732 }, 733 + contributors: { 734 + pending: false, 735 + evolution: [], 736 + evolutionsByPackage: {}, 737 + requestToken: 0, 738 + }, 627 739 }) 628 740 629 741 const activeMetricState = computed(() => metricStates[selectedMetric.value]) 630 - const activeMetricDef = computed(() => METRICS.value.find(m => m.id === selectedMetric.value)!) 742 + const activeMetricDef = computed( 743 + () => METRICS.value.find(m => m.id === selectedMetric.value) ?? METRICS.value[0], 744 + ) 631 745 const pending = computed(() => activeMetricState.value.pending) 632 746 633 747 const isMounted = shallowRef(false) ··· 695 809 async function loadMetric(metricId: MetricId) { 696 810 if (!import.meta.client) return 697 811 698 - const packageNames = effectivePackageNames.value 699 - if (!packageNames.length) return 700 - 701 812 const state = metricStates[metricId] 702 813 const metric = METRICS.value.find(m => m.id === metricId)! 703 814 const currentToken = ++state.requestToken 704 815 state.pending = true 705 816 706 - const fetchFn = (pkg: string) => metric.fetch(pkg, options.value) 817 + const fetchFn = (context: MetricContext) => metric.fetch(context, options.value) 707 818 708 819 try { 820 + const packageNames = effectivePackageNamesForMetric.value 821 + if (!packageNames.length) { 822 + if (isMultiPackageMode.value) state.evolutionsByPackage = {} 823 + else state.evolution = [] 824 + displayedGranularity.value = selectedGranularity.value 825 + return 826 + } 827 + 709 828 if (isMultiPackageMode.value) { 829 + if (metric.supportsMulti === false) { 830 + state.evolutionsByPackage = {} 831 + displayedGranularity.value = selectedGranularity.value 832 + return 833 + } 834 + 710 835 const settled = await Promise.allSettled( 711 836 packageNames.map(async pkg => { 712 - const result = await fetchFn(pkg) 837 + const repoRef = metricId === 'contributors' ? repoRefsByPackage.value[pkg] : null 838 + const result = await fetchFn({ packageName: pkg, repoRef }) 713 839 return { pkg, result: (result ?? []) as EvolutionData } 714 840 }), 715 841 ) ··· 750 876 } 751 877 } 752 878 753 - const result = await fetchFn(pkg) 879 + const result = await fetchFn({ packageName: pkg, repoRef: props.repoRef }) 754 880 if (currentToken !== state.requestToken) return 755 881 756 882 state.evolution = (result ?? []) as EvolutionData ··· 778 904 const fetchTriggerKey = computed(() => { 779 905 const names = effectivePackageNames.value.join(',') 780 906 const o = options.value 907 + const repoKey = props.repoRef 908 + ? `${props.repoRef.provider}:${props.repoRef.owner}/${props.repoRef.repo}` 909 + : '' 781 910 return [ 782 911 isMultiPackageMode.value ? 'M' : 'S', 783 912 names, 913 + repoKey, 784 914 String(props.createdIso ?? ''), 785 915 String(o.granularity ?? ''), 786 916 String('weeks' in o ? (o.weeks ?? '') : ''), ··· 800 930 { flush: 'post' }, 801 931 ) 802 932 933 + watch( 934 + () => repoRefsByPackage.value, 935 + () => { 936 + if (!import.meta.client) return 937 + if (!isMounted.value) return 938 + if (!isMultiPackageMode.value) return 939 + if (selectedMetric.value !== 'contributors') return 940 + debouncedLoadNow() 941 + }, 942 + { deep: true }, 943 + ) 944 + 803 945 const effectiveDataSingle = computed<EvolutionData>(() => { 804 946 const state = activeMetricState.value 805 947 if ( ··· 837 979 } 838 980 839 981 const state = activeMetricState.value 840 - const names = effectivePackageNames.value 982 + const names = effectivePackageNamesForMetric.value 841 983 const granularity = displayedGranularity.value 842 984 843 985 const timestampSet = new Set<number>() ··· 877 1019 878 1020 const normalisedDataset = computed(() => { 879 1021 return chartData.value.dataset?.map(d => { 1022 + const lastValue = d.series.at(-1) ?? 0 1023 + 1024 + // Contributors is an absolute metric: keep the partial period value as-is. 1025 + const projectedLastValue = 1026 + selectedMetric.value === 'contributors' ? lastValue : extrapolateLastValue(lastValue) 1027 + 880 1028 return { 881 1029 ...d, 882 - series: [...d.series.slice(0, -1), extrapolateLastValue(d.series.at(-1) ?? 0)], 1030 + series: [...d.series.slice(0, -1), projectedLastValue], 883 1031 } 884 1032 }) 885 1033 }) ··· 936 1084 return granularityLabels.value[granularity] 937 1085 } 938 1086 1087 + const granularityItems = computed(() => 1088 + availableGranularities.value.map(granularity => ({ 1089 + label: granularityLabels.value[granularity], 1090 + value: granularity, 1091 + })), 1092 + ) 1093 + 939 1094 function clampRatio(value: number): number { 940 1095 if (value < 0) return 0 941 1096 if (value > 1) return 1 ··· 1052 1207 * or the original `lastValue` when no extrapolation should be applied. 1053 1208 */ 1054 1209 function extrapolateLastValue(lastValue: number) { 1210 + if (selectedMetric.value === 'contributors') return lastValue 1211 + 1055 1212 if (displayedGranularity.value !== 'monthly' && displayedGranularity.value !== 'yearly') 1056 1213 return lastValue 1057 1214 ··· 1234 1391 }) 1235 1392 1236 1393 // Inject the estimation legend item when necessary 1237 - if ( 1238 - ['monthly', 'yearly'].includes(displayedGranularity.value) && 1239 - !isEndDateOnPeriodEnd.value && 1240 - !isZoomed.value 1241 - ) { 1394 + if (supportsEstimation.value && !isEndDateOnPeriodEnd.value && !isZoomed.value) { 1242 1395 seriesNames.push(` 1243 1396 <line 1244 1397 x1="${svg.drawingArea.left + 12}" ··· 1333 1486 axis: { 1334 1487 yLabel: $t('package.trends.y_axis_label', { 1335 1488 granularity: getGranularityLabel(selectedGranularity.value), 1336 - facet: activeMetricDef.value.label, 1489 + facet: activeMetricDef.value?.label, 1337 1490 }), 1338 1491 yLabelOffsetX: 12, 1339 1492 fontSize: isMobile.value ? 32 : 24, ··· 1472 1625 id="granularity" 1473 1626 v-model="selectedGranularity" 1474 1627 :disabled="activeMetricState.pending" 1475 - :items="[ 1476 - { label: $t('package.trends.granularity_daily'), value: 'daily' }, 1477 - { label: $t('package.trends.granularity_weekly'), value: 'weekly' }, 1478 - { label: $t('package.trends.granularity_monthly'), value: 'monthly' }, 1479 - { label: $t('package.trends.granularity_yearly'), value: 'yearly' }, 1480 - ]" 1628 + :items="granularityItems" 1481 1629 /> 1482 1630 1483 1631 <div class="grid grid-cols-2 gap-2 flex-1"> ··· 1535 1683 <span class="i-lucide:undo-2 w-5 h-5" aria-hidden="true" /> 1536 1684 </button> 1537 1685 </div> 1686 + 1687 + <p v-if="skippedPackagesWithoutGitHub.length > 0" class="text-2xs font-mono text-fg-subtle"> 1688 + {{ $t('package.trends.contributors_skip', { count: skippedPackagesWithoutGitHub.length }) }} 1689 + {{ skippedPackagesWithoutGitHub.join(', ') }} 1690 + </p> 1538 1691 </div> 1539 1692 1540 1693 <h2 id="trends-chart-title" class="sr-only"> 1541 - {{ $t('package.trends.title') }} — {{ activeMetricDef.label }} 1694 + {{ $t('package.trends.title') }} — {{ activeMetricDef?.label }} 1542 1695 </h2> 1543 1696 1544 1697 <!-- Chart panel (active metric) --> ··· 1557 1710 <template #svg="{ svg }"> 1558 1711 <!-- Estimation lines for monthly & yearly granularities when the end date induces a downwards trend --> 1559 1712 <g 1560 - v-if=" 1561 - !pending && 1562 - ['monthly', 'yearly'].includes(displayedGranularity) && 1563 - !isEndDateOnPeriodEnd && 1564 - !isZoomed 1565 - " 1713 + v-if="shouldRenderEstimationOverlay && !isEndDateOnPeriodEnd && !isZoomed" 1566 1714 v-html="drawEstimationLine(svg)" 1567 1715 /> 1568 1716 ··· 1640 1788 </template> 1641 1789 1642 1790 <!-- Estimation extra legend item --> 1643 - <div 1644 - class="flex gap-1 place-items-center" 1645 - v-if="['monthly', 'yearly'].includes(selectedGranularity)" 1646 - > 1791 + <div class="flex gap-1 place-items-center" v-if="supportsEstimation"> 1647 1792 <svg viewBox="0 0 20 2" width="20"> 1648 1793 <line 1649 1794 x1="0"
+3
app/components/Package/WeeklyDownloadStats.vue
··· 3 3 import { useCssVariables } from '~/composables/useColors' 4 4 import type { WeeklyDataPoint } from '~/types/chart' 5 5 import { OKLCH_NEUTRAL_FALLBACK, lightenOklch } from '~/utils/colors' 6 + import type { RepoRef } from '#shared/utils/git-providers' 6 7 7 8 const props = defineProps<{ 8 9 packageName: string 9 10 createdIso: string | null 11 + repoRef?: RepoRef | null | undefined 10 12 }>() 11 13 12 14 const router = useRouter() ··· 315 317 :weeklyDownloads="weeklyDownloads" 316 318 :inModal="true" 317 319 :packageName="props.packageName" 320 + :repoRef="props.repoRef" 318 321 :createdIso="createdIso" 319 322 permalink 320 323 show-facet-selector
+240
app/composables/useCharts.ts
··· 8 8 WeeklyDataPoint, 9 9 YearlyDataPoint, 10 10 } from '~/types/chart' 11 + import type { RepoRef } from '#shared/utils/git-providers' 12 + import { parseRepoUrl } from '#shared/utils/git-providers' 13 + import type { PackageMetaResponse } from '#shared/types' 14 + import { encodePackageName } from '#shared/utils/npm' 11 15 import { fetchNpmDownloadsRange } from '~/utils/npm/api' 12 16 13 17 export type PackumentLikeForTime = { ··· 182 186 183 187 const npmDailyRangeCache = import.meta.client ? new Map<string, Promise<DailyRawPoint[]>>() : null 184 188 const likesEvolutionCache = import.meta.client ? new Map<string, Promise<DailyRawPoint[]>>() : null 189 + const contributorsEvolutionCache = import.meta.client 190 + ? new Map<string, Promise<GitHubContributorStats[]>>() 191 + : null 192 + const repoMetaCache = import.meta.client ? new Map<string, Promise<RepoRef | null>>() : null 185 193 186 194 /** Clears client-side promise caches. Exported for use in tests. */ 187 195 export function clearClientCaches() { 188 196 npmDailyRangeCache?.clear() 189 197 likesEvolutionCache?.clear() 198 + contributorsEvolutionCache?.clear() 199 + repoMetaCache?.clear() 200 + } 201 + 202 + type GitHubContributorWeek = { 203 + w: number 204 + a: number 205 + d: number 206 + c: number 207 + } 208 + 209 + type GitHubContributorStats = { 210 + total: number 211 + weeks: GitHubContributorWeek[] 212 + } 213 + 214 + function pad2(value: number): string { 215 + return value.toString().padStart(2, '0') 216 + } 217 + 218 + function toIsoMonthKey(date: Date): string { 219 + return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}` 220 + } 221 + 222 + function isOverlappingRange(start: Date, end: Date, rangeStart: Date, rangeEnd: Date): boolean { 223 + return end.getTime() >= rangeStart.getTime() && start.getTime() <= rangeEnd.getTime() 224 + } 225 + 226 + function buildWeeklyEvolutionFromContributorCounts( 227 + weeklyCounts: Map<number, number>, 228 + rangeStart: Date, 229 + rangeEnd: Date, 230 + ): WeeklyDataPoint[] { 231 + return Array.from(weeklyCounts.entries()) 232 + .sort(([a], [b]) => a - b) 233 + .map(([weekStartSeconds, value]) => { 234 + const weekStartDate = new Date(weekStartSeconds * 1000) 235 + const weekEndDate = addDays(weekStartDate, 6) 236 + 237 + if (!isOverlappingRange(weekStartDate, weekEndDate, rangeStart, rangeEnd)) return null 238 + 239 + const clampedWeekEndDate = weekEndDate.getTime() > rangeEnd.getTime() ? rangeEnd : weekEndDate 240 + 241 + const weekStartIso = toIsoDateString(weekStartDate) 242 + const weekEndIso = toIsoDateString(clampedWeekEndDate) 243 + 244 + return { 245 + value, 246 + weekKey: `${weekStartIso}_${weekEndIso}`, 247 + weekStart: weekStartIso, 248 + weekEnd: weekEndIso, 249 + timestampStart: weekStartDate.getTime(), 250 + timestampEnd: clampedWeekEndDate.getTime(), 251 + } 252 + }) 253 + .filter((item): item is WeeklyDataPoint => Boolean(item)) 254 + } 255 + 256 + function buildMonthlyEvolutionFromContributorCounts( 257 + monthlyCounts: Map<string, number>, 258 + rangeStart: Date, 259 + rangeEnd: Date, 260 + ): MonthlyDataPoint[] { 261 + return Array.from(monthlyCounts.entries()) 262 + .sort(([a], [b]) => a.localeCompare(b)) 263 + .map(([month, value]) => { 264 + const [year, monthNumber] = month.split('-').map(Number) 265 + if (!year || !monthNumber) return null 266 + 267 + const monthStartDate = new Date(Date.UTC(year, monthNumber - 1, 1)) 268 + const monthEndDate = new Date(Date.UTC(year, monthNumber, 0)) 269 + 270 + if (!isOverlappingRange(monthStartDate, monthEndDate, rangeStart, rangeEnd)) return null 271 + 272 + return { 273 + month, 274 + value, 275 + timestamp: monthStartDate.getTime(), 276 + } 277 + }) 278 + .filter((item): item is MonthlyDataPoint => Boolean(item)) 279 + } 280 + 281 + function buildYearlyEvolutionFromContributorCounts( 282 + yearlyCounts: Map<string, number>, 283 + rangeStart: Date, 284 + rangeEnd: Date, 285 + ): YearlyDataPoint[] { 286 + return Array.from(yearlyCounts.entries()) 287 + .sort(([a], [b]) => a.localeCompare(b)) 288 + .map(([year, value]) => { 289 + const yearNumber = Number(year) 290 + if (!yearNumber) return null 291 + 292 + const yearStartDate = new Date(Date.UTC(yearNumber, 0, 1)) 293 + const yearEndDate = new Date(Date.UTC(yearNumber, 11, 31)) 294 + 295 + if (!isOverlappingRange(yearStartDate, yearEndDate, rangeStart, rangeEnd)) return null 296 + 297 + return { 298 + year, 299 + value, 300 + timestamp: yearStartDate.getTime(), 301 + } 302 + }) 303 + .filter((item): item is YearlyDataPoint => Boolean(item)) 304 + } 305 + 306 + function buildContributorCounts(stats: GitHubContributorStats[]) { 307 + const weeklyCounts = new Map<number, number>() 308 + const monthlyCounts = new Map<string, number>() 309 + const yearlyCounts = new Map<string, number>() 310 + 311 + for (const contributor of stats ?? []) { 312 + const monthSet = new Set<string>() 313 + const yearSet = new Set<string>() 314 + 315 + for (const week of contributor?.weeks ?? []) { 316 + if (!week || week.c <= 0) continue 317 + 318 + weeklyCounts.set(week.w, (weeklyCounts.get(week.w) ?? 0) + 1) 319 + 320 + const weekStartDate = new Date(week.w * 1000) 321 + monthSet.add(toIsoMonthKey(weekStartDate)) 322 + yearSet.add(String(weekStartDate.getUTCFullYear())) 323 + } 324 + 325 + for (const key of monthSet) { 326 + monthlyCounts.set(key, (monthlyCounts.get(key) ?? 0) + 1) 327 + } 328 + for (const key of yearSet) { 329 + yearlyCounts.set(key, (yearlyCounts.get(key) ?? 0) + 1) 330 + } 331 + } 332 + 333 + return { weeklyCounts, monthlyCounts, yearlyCounts } 190 334 } 191 335 192 336 async function fetchDailyRangeCached(packageName: string, startIso: string, endIso: string) { ··· 377 521 return buildYearlyEvolutionFromDaily(filteredDaily) 378 522 } 379 523 524 + async function fetchRepoContributorsEvolution( 525 + repoRef: MaybeRefOrGetter<RepoRef | null | undefined>, 526 + evolutionOptions: MaybeRefOrGetter<EvolutionOptions>, 527 + ): Promise<DailyDataPoint[] | WeeklyDataPoint[] | MonthlyDataPoint[] | YearlyDataPoint[]> { 528 + const resolvedRepoRef = toValue(repoRef) 529 + if (!resolvedRepoRef || resolvedRepoRef.provider !== 'github') return [] 530 + 531 + const resolvedOptions = toValue(evolutionOptions) 532 + 533 + const cache = contributorsEvolutionCache 534 + const cacheKey = `${resolvedRepoRef.owner}/${resolvedRepoRef.repo}` 535 + 536 + let statsPromise: Promise<GitHubContributorStats[]> 537 + 538 + if (cache?.has(cacheKey)) { 539 + statsPromise = cache.get(cacheKey)! 540 + } else { 541 + statsPromise = $fetch<GitHubContributorStats[]>( 542 + `/api/github/contributors-evolution/${resolvedRepoRef.owner}/${resolvedRepoRef.repo}`, 543 + ) 544 + .then(data => (Array.isArray(data) ? data : [])) 545 + .catch(error => { 546 + cache?.delete(cacheKey) 547 + throw error 548 + }) 549 + 550 + cache?.set(cacheKey, statsPromise) 551 + } 552 + 553 + const stats = await statsPromise 554 + const { start, end } = resolveDateRange(resolvedOptions, null) 555 + 556 + const { weeklyCounts, monthlyCounts, yearlyCounts } = buildContributorCounts(stats) 557 + 558 + if (resolvedOptions.granularity === 'week') { 559 + return buildWeeklyEvolutionFromContributorCounts(weeklyCounts, start, end) 560 + } 561 + if (resolvedOptions.granularity === 'month') { 562 + return buildMonthlyEvolutionFromContributorCounts(monthlyCounts, start, end) 563 + } 564 + if (resolvedOptions.granularity === 'year') { 565 + return buildYearlyEvolutionFromContributorCounts(yearlyCounts, start, end) 566 + } 567 + 568 + return [] 569 + } 570 + 571 + async function fetchRepoRefsForPackages( 572 + packageNames: MaybeRefOrGetter<string[]>, 573 + ): Promise<Record<string, RepoRef | null>> { 574 + const names = (toValue(packageNames) ?? []).map(n => String(n).trim()).filter(Boolean) 575 + if (!import.meta.client || !names.length) return {} 576 + 577 + const settled = await Promise.allSettled( 578 + names.map(async name => { 579 + const cacheKey = name 580 + const cache = repoMetaCache 581 + if (cache?.has(cacheKey)) { 582 + const ref = await cache.get(cacheKey)! 583 + return { name, ref } 584 + } 585 + 586 + const promise = $fetch<PackageMetaResponse>( 587 + `/api/registry/package-meta/${encodePackageName(name)}`, 588 + ) 589 + .then(meta => { 590 + const repoUrl = meta?.links?.repository 591 + return repoUrl ? parseRepoUrl(repoUrl) : null 592 + }) 593 + .catch(error => { 594 + cache?.delete(cacheKey) 595 + throw error 596 + }) 597 + 598 + cache?.set(cacheKey, promise) 599 + const ref = await promise 600 + return { name, ref } 601 + }), 602 + ) 603 + 604 + const next: Record<string, RepoRef | null> = {} 605 + for (const [index, entry] of settled.entries()) { 606 + const name = names[index] 607 + if (!name) continue 608 + if (entry.status === 'fulfilled') { 609 + next[name] = entry.value.ref ?? null 610 + } else { 611 + next[name] = null 612 + } 613 + } 614 + 615 + return next 616 + } 617 + 380 618 return { 381 619 fetchPackageDownloadEvolution, 382 620 fetchPackageLikesEvolution, 621 + fetchRepoContributorsEvolution, 622 + fetchRepoRefsForPackages, 383 623 getNpmPackageCreationDate, 384 624 } 385 625 }
+8 -2
app/pages/compare.vue
··· 198 198 </h2> 199 199 200 200 <div 201 - v-if="status === 'pending' && (!packagesData || packagesData.every(p => p === null))" 201 + v-if=" 202 + (status === 'pending' || status === 'idle') && 203 + (!packagesData || packagesData.every(p => p === null)) 204 + " 202 205 class="flex items-center justify-center py-12" 203 206 > 204 207 <LoadingSpinner :text="$t('compare.packages.loading')" /> ··· 247 250 <CompareLineChart :packages="packages.filter(p => p !== NO_DEPENDENCY_ID)" /> 248 251 </div> 249 252 250 - <div v-else class="text-center py-12" role="alert"> 253 + <div v-else-if="status === 'error'" class="text-center py-12" role="alert"> 251 254 <p class="text-fg-muted">{{ $t('compare.packages.error') }}</p> 255 + </div> 256 + <div v-else class="flex items-center justify-center py-12"> 257 + <LoadingSpinner :text="$t('compare.packages.loading')" /> 252 258 </div> 253 259 </section> 254 260
+5 -1
app/pages/package/[[org]]/[name].vue
··· 1370 1370 </ClientOnly> 1371 1371 1372 1372 <!-- Download stats --> 1373 - <PackageWeeklyDownloadStats :packageName :createdIso="pkg?.time?.created ?? null" /> 1373 + <PackageWeeklyDownloadStats 1374 + :packageName 1375 + :createdIso="pkg?.time?.created ?? null" 1376 + :repoRef="repoRef" 1377 + /> 1374 1378 1375 1379 <!-- Playground links --> 1376 1380 <PackagePlaygrounds
+3 -1
i18n/locales/en.json
··· 362 362 "y_axis_label": "{granularity} {facet}", 363 363 "facet": "Facet", 364 364 "title": "Trends", 365 + "contributors_skip": "Not shown in Contributors (no GitHub repo):", 365 366 "items": { 366 367 "downloads": "Downloads", 367 - "likes": "Likes" 368 + "likes": "Likes", 369 + "contributors": "Contributors" 368 370 } 369 371 }, 370 372 "downloads": {
+6
i18n/schema.json
··· 1090 1090 "title": { 1091 1091 "type": "string" 1092 1092 }, 1093 + "contributors_skip": { 1094 + "type": "string" 1095 + }, 1093 1096 "items": { 1094 1097 "type": "object", 1095 1098 "properties": { ··· 1097 1100 "type": "string" 1098 1101 }, 1099 1102 "likes": { 1103 + "type": "string" 1104 + }, 1105 + "contributors": { 1100 1106 "type": "string" 1101 1107 } 1102 1108 },
+3 -1
lunaria/files/en-GB.json
··· 361 361 "y_axis_label": "{granularity} {facet}", 362 362 "facet": "Facet", 363 363 "title": "Trends", 364 + "contributors_skip": "Not shown in Contributors (no GitHub repo):", 364 365 "items": { 365 366 "downloads": "Downloads", 366 - "likes": "Likes" 367 + "likes": "Likes", 368 + "contributors": "Contributors" 367 369 } 368 370 }, 369 371 "downloads": {
+3 -1
lunaria/files/en-US.json
··· 361 361 "y_axis_label": "{granularity} {facet}", 362 362 "facet": "Facet", 363 363 "title": "Trends", 364 + "contributors_skip": "Not shown in Contributors (no GitHub repo):", 364 365 "items": { 365 366 "downloads": "Downloads", 366 - "likes": "Likes" 367 + "likes": "Likes", 368 + "contributors": "Contributors" 367 369 } 368 370 }, 369 371 "downloads": {
+13
modules/runtime/server/cache.ts
··· 27 27 esmHeaders: 'esm-sh:headers', 28 28 esmTypes: 'esm-sh:types', 29 29 githubContributors: 'github:contributors.json', 30 + githubContributorsStats: 'github:contributors-stats.json', 30 31 } as const 31 32 32 33 type FixtureType = keyof typeof FIXTURE_PATHS ··· 401 402 const { host, pathname } = urlObj 402 403 403 404 if (host !== 'api.github.com') return null 405 + 406 + // Contributors stats endpoint: /repos/{owner}/{repo}/stats/contributors 407 + const contributorsStatsMatch = pathname.match(/^\/repos\/([^/]+)\/([^/]+)\/stats\/contributors$/) 408 + if (contributorsStatsMatch) { 409 + const contributorsStats = await storage.getItem<unknown[]>( 410 + FIXTURE_PATHS.githubContributorsStats, 411 + ) 412 + if (contributorsStats) { 413 + return { data: contributorsStats } 414 + } 415 + return { data: [] } 416 + } 404 417 405 418 // Contributors endpoint: /repos/{owner}/{repo}/contributors 406 419 const contributorsMatch = pathname.match(/^\/repos\/([^/]+)\/([^/]+)\/contributors$/)
+68
server/api/github/contributors-evolution/[owner]/[repo].get.ts
··· 1 + import { CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants' 2 + 3 + type GitHubContributorWeek = { 4 + w: number 5 + a: number 6 + d: number 7 + c: number 8 + } 9 + 10 + type GitHubContributorStats = { 11 + total: number 12 + weeks: GitHubContributorWeek[] 13 + } 14 + 15 + export default defineCachedEventHandler( 16 + async event => { 17 + const owner = getRouterParam(event, 'owner') 18 + const repo = getRouterParam(event, 'repo') 19 + 20 + if (!owner || !repo) { 21 + throw createError({ 22 + status: 400, 23 + message: 'repository not provided', 24 + }) 25 + } 26 + 27 + const url = `https://api.github.com/repos/${owner}/${repo}/stats/contributors` 28 + const headers = { 29 + 'User-Agent': 'npmx', 30 + 'Accept': 'application/vnd.github+json', 31 + } 32 + 33 + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) 34 + const maxAttempts = 6 35 + let delayMs = 1000 36 + 37 + try { 38 + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { 39 + const response = await $fetch.raw<GitHubContributorStats[]>(url, { headers }) 40 + const status = response.status 41 + 42 + if (status === 200) { 43 + return Array.isArray(response._data) ? response._data : [] 44 + } 45 + 46 + if (status === 204) { 47 + return [] 48 + } 49 + 50 + if (status === 202) { 51 + if (attempt === maxAttempts - 1) return [] 52 + await sleep(delayMs) 53 + delayMs = Math.min(delayMs * 2, 16_000) 54 + continue 55 + } 56 + 57 + return [] 58 + } 59 + 60 + return [] 61 + } catch { 62 + return [] 63 + } 64 + }, 65 + { 66 + maxAge: CACHE_MAX_AGE_ONE_DAY, 67 + }, 68 + )
+18
test/fixtures/github/contributors-stats.json
··· 1 + [ 2 + { 3 + "total": 5, 4 + "weeks": [ 5 + { "w": 1700438400, "a": 12, "d": 3, "c": 2 }, 6 + { "w": 1701043200, "a": 0, "d": 0, "c": 0 }, 7 + { "w": 1701648000, "a": 7, "d": 1, "c": 1 } 8 + ] 9 + }, 10 + { 11 + "total": 9, 12 + "weeks": [ 13 + { "w": 1700438400, "a": 20, "d": 5, "c": 4 }, 14 + { "w": 1701043200, "a": 2, "d": 0, "c": 1 }, 15 + { "w": 1702252800, "a": 4, "d": 1, "c": 1 } 16 + ] 17 + } 18 + ]