Skip to content

Commit 022d207

Browse files
Adebesin-Cellclaudeautofix-ci[bot]serhalpghostdevv
authored
feat: add package download button (#1586)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Philippe Serhal <philippe.serhal@gmail.com> Co-authored-by: Willow (GHOST) <git@willow.sh>
1 parent fbf01bd commit 022d207

File tree

11 files changed

+155
-6
lines changed

11 files changed

+155
-6
lines changed
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<script setup lang="ts">
2+
import type { SlimPackumentVersion } from '#shared/types'
3+
4+
const props = defineProps<{
5+
packageName: string
6+
version: SlimPackumentVersion
7+
}>()
8+
9+
const loading = shallowRef(false)
10+
11+
async function getDownloadUrl(tarballUrl: string) {
12+
try {
13+
const response = await fetch(tarballUrl)
14+
if (!response.ok) {
15+
throw new Error(`Failed to fetch tarball (${response.status})`)
16+
}
17+
const blob = await response.blob()
18+
return URL.createObjectURL(blob)
19+
} catch (error) {
20+
// oxlint-disable-next-line no-console -- error logging
21+
console.error('failed to fetch tarball', { cause: error })
22+
return null
23+
}
24+
}
25+
26+
async function downloadPackage() {
27+
const tarballUrl = props.version.dist.tarball
28+
if (!tarballUrl) return
29+
30+
if (loading.value) return
31+
loading.value = true
32+
33+
const downloadUrl = await getDownloadUrl(tarballUrl)
34+
35+
const link = document.createElement('a')
36+
link.href = downloadUrl ?? tarballUrl
37+
link.download = `${props.packageName.replace(/\//g, '__')}-${props.version.version}.tgz`
38+
document.body.appendChild(link)
39+
link.click()
40+
document.body.removeChild(link)
41+
42+
if (downloadUrl) {
43+
URL.revokeObjectURL(downloadUrl)
44+
}
45+
46+
loading.value = false
47+
}
48+
</script>
49+
50+
<template>
51+
<TooltipApp :text="$t('package.download.tarball')">
52+
<ButtonBase
53+
ref="triggerRef"
54+
v-bind="$attrs"
55+
type="button"
56+
@click="downloadPackage"
57+
:disabled="loading"
58+
class="border-border-subtle bg-bg-subtle! text-xs text-fg-muted hover:enabled:(text-fg border-border-hover)"
59+
>
60+
<span
61+
class="size-[1em]"
62+
aria-hidden="true"
63+
:class="loading ? 'i-lucide:loader-circle animate-spin' : 'i-lucide:download'"
64+
/>
65+
{{ $t('package.download.button') }}
66+
</ButtonBase>
67+
</TooltipApp>
68+
</template>

app/components/Package/Skeleton.vue

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,11 @@
132132
<h2 class="text-xs font-mono text-fg-subtle uppercase tracking-wider">
133133
{{ $t('package.get_started.title') }}
134134
</h2>
135-
<!-- Package manager select placeholder -->
136-
<SkeletonInline class="h-7 w-24 rounded" />
135+
<!-- Download button + Package manager select placeholder -->
136+
<div class="flex items-center gap-2">
137+
<SkeletonInline class="h-7 w-24 rounded" />
138+
<SkeletonInline class="h-7 w-24 rounded" />
139+
</div>
137140
</div>
138141
<!-- Terminal-style install command — matches TerminalInstall.vue -->
139142
<div class="bg-bg-subtle border border-border rounded-lg overflow-hidden">

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -771,8 +771,15 @@ const showSkeleton = shallowRef(false)
771771
{{ $t('package.get_started.title') }}
772772
</LinkBase>
773773
</h2>
774-
<!-- Package manager dropdown -->
775-
<PackageManagerSelect />
774+
<!-- Package manager dropdown + Download button -->
775+
<div class="flex items-center gap-2">
776+
<PackageDownloadButton
777+
v-if="displayVersion"
778+
:package-name="pkg.name"
779+
:version="displayVersion"
780+
/>
781+
<PackageManagerSelect />
782+
</div>
776783
</div>
777784
<div>
778785
<div

i18n/locales/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,10 @@
630630
"b": "{size} B",
631631
"kb": "{size} kB",
632632
"mb": "{size} MB"
633+
},
634+
"download": {
635+
"button": "Download",
636+
"tarball": "Download Tarball as .tar.gz"
633637
}
634638
},
635639
"connector": {

i18n/schema.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1896,6 +1896,18 @@
18961896
}
18971897
},
18981898
"additionalProperties": false
1899+
},
1900+
"download": {
1901+
"type": "object",
1902+
"properties": {
1903+
"button": {
1904+
"type": "string"
1905+
},
1906+
"tarball": {
1907+
"type": "string"
1908+
}
1909+
},
1910+
"additionalProperties": false
18991911
}
19001912
},
19011913
"additionalProperties": false

server/utils/dependency-resolver.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export interface ResolvedPackage {
9898
name: string
9999
version: string
100100
size: number
101+
tarballUrl: string
101102
optional: boolean
102103
/** Depth level (only when trackDepth is enabled) */
103104
depth?: DependencyDepth
@@ -152,13 +153,14 @@ export async function resolveDependencyTree(
152153
if (!matchesPlatform(versionData)) return
153154

154155
const size = (versionData.dist as { unpackedSize?: number })?.unpackedSize ?? 0
156+
const tarballUrl = versionData.dist?.tarball ?? ''
155157
const key = `${name}@${version}`
156158

157159
// Build path for this package (path to parent + this package with version)
158160
const currentPath = [...path, `${name}@${version}`]
159161

160162
if (!resolved.has(key)) {
161-
const pkg: ResolvedPackage = { name, version, size, optional }
163+
const pkg: ResolvedPackage = { name, version, size, tarballUrl, optional }
162164
if (options.trackDepth) {
163165
pkg.depth = level === 0 ? 'root' : level === 1 ? 'direct' : 'transitive'
164166
pkg.path = currentPath

server/utils/install-size.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const calculateInstallSize = defineCachedFunction(
2727
name: dep.name,
2828
version: dep.version,
2929
size: dep.size,
30+
tarballUrl: dep.tarballUrl,
3031
optional: dep.optional || undefined,
3132
})
3233
totalSize += dep.size

shared/types/install-size.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export interface DependencySize {
22
name: string
33
version: string
44
size: number
5+
tarballUrl: string
56
/** True if this is an optional dependency */
67
optional?: boolean
78
}

test/nuxt/a11y.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ import {
187187
PackageListControls,
188188
PackageListToolbar,
189189
PackageMaintainers,
190+
PackageDownloadButton,
190191
PackageManagerSelect,
191192
PackageMetricsBadges,
192193
PackagePlaygrounds,
@@ -2983,6 +2984,23 @@ describe('component accessibility audits', () => {
29832984
})
29842985
})
29852986

2987+
describe('PackageDownloadButton', () => {
2988+
it('should have no accessibility violations', async () => {
2989+
const component = await mountSuspended(PackageDownloadButton, {
2990+
props: {
2991+
packageName: 'vue',
2992+
version: {
2993+
version: '3.5.0',
2994+
dist: { tarball: 'https://registry.npmjs.org/vue/-/vue-3.5.0.tgz' },
2995+
} as any,
2996+
dependencies: null,
2997+
},
2998+
})
2999+
const results = await runAxe(component)
3000+
expect(results.violations).toEqual([])
3001+
})
3002+
})
3003+
29863004
// Diff components
29873005
describe('DiffFileTree', () => {
29883006
const mockFiles = [

0 commit comments

Comments
 (0)