Skip to content

Commit 15b8f5d

Browse files
committed
2 parents 66b2bc9 + 60b107b commit 15b8f5d

11 files changed

Lines changed: 212 additions & 13 deletions

File tree

.github/workflows/autofix.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
run: pnpm playwright install
2929

3030
- name: 🔠 Fix lint errors
31-
run: pnpm lint --fix
31+
run: pnpm lint:fix
3232

3333
- name: 🧪 Update unit test snapshots
3434
run: pnpm test:unit -u

app/components/ConnectorModal.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ watch(open, isOpen => {
105105

106106
<div class="p-3 bg-[#0d0d0d] border border-border rounded-lg font-mono text-sm">
107107
<span class="text-fg-subtle">$</span>
108-
<span class="text-fg ml-2">npx npmx-connector</span>
108+
<span class="text-fg ml-2">npx&nbsp;npmx-connector</span>
109109
</div>
110110

111111
<p class="text-sm text-fg-muted">Then paste the token shown in your terminal:</p>
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<script setup lang="ts">
2+
import { VueUiSparkline } from 'vue-data-ui/vue-ui-sparkline'
3+
4+
const props = defineProps<{
5+
downloads?: Array<{
6+
downloads: number | null
7+
weekStart: string
8+
weekEnd: string
9+
}>
10+
}>()
11+
12+
const dataset = computed(() =>
13+
props.downloads?.map(d => ({
14+
value: d?.downloads ?? 0,
15+
period: `${d.weekStart ?? '-'} to ${d.weekEnd ?? '-'}`,
16+
})),
17+
)
18+
19+
const lastDatapoint = computed(() => {
20+
return (dataset.value || []).at(-1)?.period ?? ''
21+
})
22+
23+
const config = computed(() => ({
24+
theme: 'dark', // enforced dark mode for now
25+
style: {
26+
backgroundColor: 'transparent',
27+
area: {
28+
color: '#6A6A6A',
29+
useGradient: false,
30+
opacity: 10,
31+
},
32+
dataLabel: {
33+
offsetX: -10,
34+
fontSize: 28,
35+
bold: false,
36+
color: '#FAFAFA',
37+
},
38+
line: {
39+
color: '#6A6A6A',
40+
},
41+
plot: {
42+
radius: 6,
43+
stroke: '#FAFAFA',
44+
},
45+
title: {
46+
text: lastDatapoint.value,
47+
fontSize: 12,
48+
color: '#666666',
49+
bold: false,
50+
},
51+
verticalIndicator: {
52+
strokeDasharray: 0,
53+
color: '#FAFAFA',
54+
},
55+
},
56+
}))
57+
</script>
58+
59+
<template>
60+
<div class="space-y-8">
61+
<!-- Download stats -->
62+
<section>
63+
<div class="flex items-center justify-between mb-3">
64+
<h2 id="dependencies-heading" class="text-xs text-fg-subtle uppercase tracking-wider">
65+
Weekly Downloads
66+
</h2>
67+
</div>
68+
<div class="w-full">
69+
<ClientOnly>
70+
<VueUiSparkline :dataset :config />
71+
</ClientOnly>
72+
</div>
73+
</section>
74+
</div>
75+
</template>
76+
77+
<style>
78+
/** Overrides */
79+
.vue-ui-sparkline-title span {
80+
padding: 0 !important;
81+
letter-spacing: 0.04rem;
82+
}
83+
.vue-ui-sparkline text {
84+
font-family:
85+
Geist Mono,
86+
monospace !important;
87+
}
88+
</style>

app/composables/useCharts.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { createSharedComposable } from '@vueuse/core'
2+
3+
export const useCharts = createSharedComposable(function useCharts() {})

app/composables/useNpmRegistry.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,47 @@ export function usePackageDownloads(
141141
)
142142
}
143143

144+
type NpmDownloadsRangeResponse = {
145+
start: string
146+
end: string
147+
package: string
148+
downloads: Array<{ day: string; downloads: number }>
149+
}
150+
151+
async function fetchNpmDownloadsRange(
152+
packageName: string,
153+
start: string,
154+
end: string,
155+
): Promise<NpmDownloadsRangeResponse> {
156+
const encodedName = encodePackageName(packageName)
157+
return await $fetch<NpmDownloadsRangeResponse>(
158+
`${NPM_API}/downloads/range/${start}:${end}/${encodedName}`,
159+
)
160+
}
161+
162+
export function usePackageWeeklyDownloadEvolution(
163+
name: MaybeRefOrGetter<string>,
164+
options: MaybeRefOrGetter<{
165+
weeks?: number
166+
endDate?: string
167+
}> = {},
168+
) {
169+
return useLazyAsyncData(
170+
() => `downloads-weekly-evolution:${toValue(name)}:${JSON.stringify(toValue(options))}`,
171+
async () => {
172+
const packageName = toValue(name)
173+
const { weeks = 12, endDate } = toValue(options) ?? {}
174+
const end = endDate ? new Date(`${endDate}T00:00:00.000Z`) : new Date()
175+
const start = addDays(end, -(weeks * 7) + 1)
176+
const startIso = toIsoDateString(start)
177+
const endIso = toIsoDateString(end)
178+
const range = await fetchNpmDownloadsRange(packageName, startIso, endIso)
179+
const sortedDaily = [...range.downloads].sort((a, b) => a.day.localeCompare(b.day))
180+
return buildWeeklyEvolutionFromDaily(sortedDaily)
181+
},
182+
)
183+
}
184+
144185
const emptySearchResponse = {
145186
objects: [],
146187
total: 0,

app/pages/package/[...name].vue

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const orgName = computed(() => {
4242
const { data: pkg, status, error } = usePackage(packageName, requestedVersion)
4343
4444
const { data: downloads } = usePackageDownloads(packageName, 'last-week')
45+
const { data: weeklyDownloads } = usePackageWeeklyDownloadEvolution(packageName, { weeks: 52 })
4546
4647
// Fetch README for specific version if requested, otherwise latest
4748
const { data: readmeData } = useLazyFetch<{ html: string }>(
@@ -495,15 +496,15 @@ defineOgImageComponent('Package', {
495496
<span class="text-fg-subtle font-mono text-sm select-none">$</span>
496497
<code class="font-mono text-sm"
497498
><ClientOnly
498-
><span class="text-fg">{{ selectedPMLabel }}</span>
499-
<span class="text-fg-muted">{{ selectedPMAction }}</span
499+
><span class="text-fg">{{ selectedPMLabel }}</span
500+
>&nbsp;<span class="text-fg-muted">{{ selectedPMAction }}</span
500501
><span v-if="selectedPM !== 'deno'" class="text-fg-muted"
501502
>&nbsp;{{ pkg.name }}</span
502503
><span v-else class="text-fg-muted">{{ pkg.name }}</span
503504
><span v-if="requestedVersion" class="text-fg-muted">@{{ requestedVersion }}</span
504505
><template #fallback
505506
><span class="text-fg">npm</span>&nbsp;<span class="text-fg-muted"
506-
>install {{ pkg.name }}</span
507+
>install&nbsp;{{ pkg.name }}</span
507508
></template
508509
></ClientOnly
509510
></code
@@ -566,6 +567,9 @@ defineOgImageComponent('Package', {
566567
</ul>
567568
</section>
568569

570+
<!-- Donwload stats -->
571+
<PackageDownloadStats :downloads="weeklyDownloads" />
572+
569573
<section
570574
v-if="
571575
displayVersion?.engines && (displayVersion.engines.node || displayVersion.engines.npm)

app/utils/charts.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export function sum(numbers: number[]): number {
2+
return numbers.reduce((a, b) => a + b, 0)
3+
}
4+
5+
export function chunkIntoWeeks<T>(items: T[], weekSize = 7): T[][] {
6+
const result: T[][] = []
7+
for (let index = 0; index < items.length; index += weekSize) {
8+
result.push(items.slice(index, index + weekSize))
9+
}
10+
return result
11+
}
12+
13+
export function buildWeeklyEvolutionFromDaily(
14+
daily: Array<{ day: string; downloads: number }>,
15+
): Array<{ weekStart: string; weekEnd: string; downloads: number }> {
16+
const weeks = chunkIntoWeeks(daily, 7)
17+
return weeks.map(weekDays => {
18+
const weekStart = weekDays[0]?.day ?? ''
19+
const weekEnd = weekDays[weekDays.length - 1]?.day ?? ''
20+
const downloads = sum(weekDays.map(d => d.downloads))
21+
return { weekStart, weekEnd, downloads }
22+
})
23+
}
24+
25+
export function addDays(date: Date, days: number): Date {
26+
const d = new Date(date)
27+
d.setUTCDate(d.getUTCDate() + days)
28+
return d
29+
}

app/utils/formatters.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,10 @@ export function formatDate(dateStr: string): string {
99
day: 'numeric',
1010
})
1111
}
12+
13+
export function toIsoDateString(date: Date): string {
14+
const year = date.getUTCFullYear()
15+
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
16+
const day = String(date.getUTCDate()).padStart(2, '0')
17+
return `${year}-${month}-${day}`
18+
}

cli/src/server.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@ import {
1010
getQuery,
1111
createError,
1212
getHeader,
13+
getRequestHeader,
1314
setResponseHeaders,
1415
getRouterParam,
1516
} from 'h3'
17+
18+
const ALLOWED_ORIGINS = new Set(['https://npmx.dev', 'http://localhost:3000'])
1619
import type { ConnectorState, PendingOperation, OperationType, ApiResponse } from './types.ts'
1720
import {
1821
getNpmUser,
@@ -66,24 +69,31 @@ export function createConnectorApp(expectedToken: string) {
6669
operations: [],
6770
}
6871

69-
const app = createApp({
70-
onRequest(event) {
71-
// CORS headers for browser connections
72+
function setCorsHeaders(event: Parameters<typeof setResponseHeaders>[0]) {
73+
const origin = getRequestHeader(event, 'origin')
74+
if (origin && ALLOWED_ORIGINS.has(origin)) {
7275
setResponseHeaders(event, {
73-
'Access-Control-Allow-Origin': 'https://npmx.dev, http://localhost:3000',
76+
'Access-Control-Allow-Origin': origin,
7477
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
7578
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
7679
})
80+
}
81+
}
82+
83+
const app = createApp({
84+
onRequest(event) {
85+
setCorsHeaders(event)
86+
},
87+
onBeforeResponse(event) {
88+
setCorsHeaders(event)
7789
},
7890
})
7991
const router = createRouter()
8092

8193
// Handle CORS preflight requests
8294
router.options(
8395
'/**',
84-
eventHandler(() => {
85-
return null
86-
}),
96+
eventHandler(() => ''),
8797
)
8898

8999
function validateToken(authHeader: string | null | undefined): boolean {

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
"shiki": "^3.21.0",
4343
"ufo": "^1.6.3",
4444
"unplugin-vue-router": "^0.19.2",
45-
"vue": "3.5.27"
45+
"vue": "3.5.27",
46+
"vue-data-ui": "^3.13.0"
4647
},
4748
"devDependencies": {
4849
"@iconify-json/carbon": "^1.2.18",

0 commit comments

Comments
 (0)