Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 37 additions & 11 deletions app/components/Package/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ const { copied: copiedPkgName, copy: copyPkgName } = useClipboard({
copiedDuring: 2000,
})

const { copied: copiedPkgVersion, copy: copyPkgVersion } = useClipboard({
source: () => props.resolvedVersion ?? '',
copiedDuring: 2000,
})

function hasProvenance(version: PackumentVersion | null): boolean {
if (!version?.dist) return false
return !!(version.dist as { attestations?: unknown }).attestations
Expand All @@ -98,6 +103,17 @@ useCommandPaletteContextCommands(
announce($t('command_palette.announcements.copied_to_clipboard'))
},
},
{
id: 'package-copy-version',
group: 'package',
label: $t('package.copy_version'),
keywords: [packageName.value],
iconClass: 'i-lucide:copy',
action: () => {
copyPkgVersion()
announce($t('command_palette.announcements.copied_to_clipboard'))
},
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
]

if (fundingUrl.value) {
Expand Down Expand Up @@ -206,16 +222,15 @@ useShortcuts({
<header class="bg-bg pt-5 pb-1 w-full container">
<!-- Package name and version -->
<div class="flex items-baseline justify-between gap-x-2 gap-y-1 flex-wrap min-w-0">
<CopyToClipboardButton
:copied="copiedPkgName"
:copy-text="$t('package.copy_name')"
class="flex flex-col items-start min-w-0"
@click="copyPkgName()"
<h1
class="flex flex-row items-start min-w-0 font-mono text-lg sm:text-3xl font-medium break-words"
:title="pkg?.name"
dir="ltr"
>
<h1
class="font-mono text-lg sm:text-3xl font-medium min-w-0 break-words"
:title="pkg?.name"
dir="ltr"
<CopyToClipboardButton
:copied="copiedPkgName"
:copy-text="$t('package.copy_name')"
@click="copyPkgName()"
>
<LinkBase v-if="orgName" :to="{ name: 'org', params: { org: orgName } }">
@{{ orgName }}
Expand All @@ -224,8 +239,19 @@ useShortcuts({
<span :class="{ 'text-fg-muted': orgName }">
{{ orgName ? pkg?.name.replace(`@${orgName}/`, '') : pkg?.name }}
</span>
</h1>
</CopyToClipboardButton>
</CopyToClipboardButton>
<template v-if="requestedVersion && resolvedVersion">
<span class="text-fg-muted">@</span>
<CopyToClipboardButton
:copied="copiedPkgVersion"
:copy-text="$t('package.copy_version')"
:title="resolvedVersion"
@click="copyPkgVersion()"
>
{{ resolvedVersion }}
</CopyToClipboardButton>
</template>
</h1>
<!-- Package metrics -->
<div class="flex gap-2 flex-wrap items-stretch">
<LinkBase
Expand Down
57 changes: 47 additions & 10 deletions app/composables/usePackageRoute.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,60 @@
/**
* Parse package name and optional version from the route URL.
* Parse package name and optional version from the current route URL.
*
* Routes use structured params:
* /package/nuxt → org: undefined, name: "nuxt"
* /package/@nuxt/kit → org: "@nuxt", name: "kit"
* /package/nuxt/v/4.2.0 → org: undefined, name: "nuxt", version: "4.2.0"
* /package/@nuxt/kit/v/1.0.0 → org: "@nuxt", name: "kit", version: "1.0.0"
* Works across every package-scoped route, which use different param shapes:
* /package/nuxt → org: undefined, name: "nuxt"
* /package/@nuxt/kit/v/1.0.0 → org: "@nuxt", name: "kit", version: "1.0.0"
* /package-code/@nuxt/kit/v/1.0.0/... → org: "@nuxt", packageName: "kit", version: "1.0.0"
* /package-stats/nuxt/v/4.2.0 → packageName: "nuxt", version: "4.2.0"
* /package-timeline/nuxt/v/4.2.0 → packageName: "nuxt", version: "4.2.0"
* /package-docs/@nuxt/kit/v/1.0.0 → path: ["@nuxt", "kit", "v", "1.0.0"]
*
* Rather than pinning to a single named route, read the live route params and
* normalise the differing param names (`name` vs `packageName`) and the docs
* catch-all `path` into a common `{ org, name, version }` shape.
*/
export function usePackageRoute() {
const route = useRoute<'package'>('package')
const route = useRoute()

const parsed = computed<{ org?: string; name?: string; version: string | null }>(() => {
const params = route.params as Record<string, string | string[] | undefined>

// Docs uses a single catch-all `path` param: [org?, name, "v", version?].
// The package prefix is one segment (unscoped) or two (scoped, "@org/name").
// A "v" only marks the version when it directly follows that prefix, so a
// package literally named "v" (e.g. /package-docs/v) isn't mistaken for a
// version delimiter and a later "v" stays part of the package name.
if (Array.isArray(params.path)) {
const segments = params.path.filter(Boolean)
const scoped = segments[0]?.startsWith('@') ?? false
const prefixLength = scoped ? 2 : 1
const org = scoped ? segments[0] : undefined
const name = segments.slice(scoped ? 1 : 0, prefixLength).join('/')
const version = segments[prefixLength] === 'v' ? (segments[prefixLength + 1] ?? null) : null
return { org, name, version }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

const org = typeof params.org === 'string' ? params.org : undefined
// `package`/`changelog` name their param `name`; `code`/`stats`/`timeline`/`diff`
// name it `packageName`.
const name =
(typeof params.name === 'string' ? params.name : undefined) ??
(typeof params.packageName === 'string' ? params.packageName : undefined)
const version = typeof params.version === 'string' ? params.version : null

return { org, name, version }
})

const packageName = computed(() => {
const { org, name } = route.params
const { org, name } = parsed.value
if (!name) return ''
return org ? `${org}/${name}` : name
})

const requestedVersion = computed(() => ('version' in route.params ? route.params.version : null))
const requestedVersion = computed(() => parsed.value.version)

const orgName = computed(() => {
const org = route.params.org
const org = parsed.value.org
return org ? org.replace(/^@/, '') : null
})

Expand Down
1 change: 1 addition & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@
"verified_provenance": "Verified provenance",
"navigation": "Package",
"copy_name": "Copy package name",
"copy_version": "Copy package version",
"deprecation": {
"package": "This package has been deprecated.",
"version": "This version has been deprecated.",
Expand Down
3 changes: 3 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1261,6 +1261,9 @@
"copy_name": {
"type": "string"
},
"copy_version": {
"type": "string"
},
"deprecation": {
"type": "object",
"properties": {
Expand Down
6 changes: 3 additions & 3 deletions test/e2e/interactions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,14 @@ test.describe('Package Page', () => {
const packageHeading = page.locator('h1').first()
await expect(packageHeading).toBeVisible({ timeout: 10000 })

// Hover the parent of the heading to trigger the button's visibility
await packageHeading.locator('..').hover()

const copyButton = page
.locator('button[aria-label="copy"]')
.filter({ hasText: /copy/i })
.first()

// Hover the button's group container (its parent) to trigger its visibility
await copyButton.locator('..').hover()

await expect(copyButton).toBeVisible({ timeout: 10000 })
await copyButton.hover()

Expand Down
106 changes: 106 additions & 0 deletions test/nuxt/components/Package/Header.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { VueWrapper } from '@vue/test-utils'
import PackageHeader from '~/components/Package/Header.vue'

const { mockUsePackageRoute } = vi.hoisted(() => ({
mockUsePackageRoute: vi.fn(),
}))

mockNuxtImport('usePackageRoute', () => mockUsePackageRoute)

function setRoute({
requestedVersion = null as string | null,
orgName = null as string | null,
} = {}) {
mockUsePackageRoute.mockReturnValue({
packageName: computed(() => 'vue'),
requestedVersion: computed(() => requestedVersion),
orgName: computed(() => orgName),
})
}

const baseProps = {
pkg: {
'name': 'vue',
'dist-tags': {},
'versions': {},
},
resolvedVersion: '3.5.0',
displayVersion: {
_id: '1234567890',
_npmVersion: '3.5.0',
name: 'vue',
version: '3.5.0',
dist: {
shasum: '1234567890',
signatures: [],
tarball: 'https://npmx.dev/package/vue/tarball',
},
},
latestVersion: { version: '3.5.0', tags: [] },
provenanceData: null,
provenanceStatus: 'idle',
page: 'docs' as const,
versionUrlPattern: '/package/vue/v/{version}',
}

function mountHeader() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return mountSuspended(PackageHeader, { props: baseProps as any })
}

describe('PackageHeader version display', () => {
let wrapper: VueWrapper

beforeEach(() => {
mockUsePackageRoute.mockReset()
})

afterEach(() => {
wrapper?.unmount()
})

it('hides the resolved version in the title when the URL has no explicit version', async () => {
setRoute({ requestedVersion: null })

wrapper = await mountHeader()

// The <h1> title should show only the package name, not "@3.5.0"
expect(wrapper.get('h1').text()).not.toContain('3.5.0')
// The version copy button should not be rendered
expect(wrapper.text()).not.toContain('Copy package version')
})

it('shows the resolved version in the title when the URL has an explicit version', async () => {
setRoute({ requestedVersion: '3.5.0' })

wrapper = await mountHeader()

expect(wrapper.get('h1').text()).toContain('3.5.0')
expect(wrapper.text()).toContain('Copy package version')
})

it('renders separate copy buttons for the package name and the version', async () => {
setRoute({ requestedVersion: '3.5.0' })

wrapper = await mountHeader()

const copyButtonLabels = wrapper
.findAll('button')
.map(b => b.text())
.filter(text => text.includes('Copy package'))

expect(copyButtonLabels.some(text => text.includes('Copy package name'))).toBe(true)
expect(copyButtonLabels.some(text => text.includes('Copy package version'))).toBe(true)
})

it('shows the resolved version for a dist-tag request (e.g. /v/latest)', async () => {
// requestedVersion is the raw URL segment ("latest"); resolvedVersion is the concrete number
setRoute({ requestedVersion: 'latest' })

wrapper = await mountHeader()

expect(wrapper.get('h1').text()).toContain('3.5.0')
})
})
Loading
Loading