diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue index d0306d3706..6f9d0806ab 100644 --- a/app/components/Package/Header.vue +++ b/app/components/Package/Header.vue @@ -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 @@ -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')) + }, + }, ] if (fundingUrl.value) { @@ -206,16 +222,15 @@ useShortcuts({
- -

@{{ orgName }} @@ -224,8 +239,19 @@ useShortcuts({ {{ orgName ? pkg?.name.replace(`@${orgName}/`, '') : pkg?.name }} -

-
+ + +
('package') + const route = useRoute() + + const parsed = computed<{ org?: string; name?: string; version: string | null }>(() => { + const params = route.params as Record + + // 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 } + } + + 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 }) diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 4f68ff0cb4..a09e22b59f 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -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.", diff --git a/i18n/schema.json b/i18n/schema.json index 35882a02e2..b7793e498c 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1261,6 +1261,9 @@ "copy_name": { "type": "string" }, + "copy_version": { + "type": "string" + }, "deprecation": { "type": "object", "properties": { diff --git a/test/e2e/interactions.spec.ts b/test/e2e/interactions.spec.ts index 2955eef2ed..2ac88c05fc 100644 --- a/test/e2e/interactions.spec.ts +++ b/test/e2e/interactions.spec.ts @@ -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() diff --git a/test/nuxt/components/Package/Header.spec.ts b/test/nuxt/components/Package/Header.spec.ts new file mode 100644 index 0000000000..97cd32dad9 --- /dev/null +++ b/test/nuxt/components/Package/Header.spec.ts @@ -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

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') + }) +}) diff --git a/test/nuxt/composables/use-package-route.spec.ts b/test/nuxt/composables/use-package-route.spec.ts new file mode 100644 index 0000000000..8a7fd6d49a --- /dev/null +++ b/test/nuxt/composables/use-package-route.spec.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest' + +// `useRoute()` cannot be mocked via `mockNuxtImport` in this runtime, so instead +// we drive the app's real router. That has the upside of exercising the actual +// route definitions: if a route's param names ever change (e.g. `packageName` → +// `name`), these tests break, which is exactly what should happen since +// `usePackageRoute` reads those params. +async function at(url: string) { + await useRouter().push(url) + return usePackageRoute() +} + +describe('usePackageRoute', () => { + describe('package / package-version routes (`name` param)', () => { + it('parses an unscoped package with no version', async () => { + const { packageName, requestedVersion, orgName } = await at('/package/nuxt') + expect(packageName.value).toBe('nuxt') + expect(requestedVersion.value).toBeNull() + expect(orgName.value).toBeNull() + }) + + it('parses a scoped package with a version', async () => { + const { packageName, requestedVersion, orgName } = await at('/package/@nuxt/kit/v/1.0.0') + expect(packageName.value).toBe('@nuxt/kit') + expect(requestedVersion.value).toBe('1.0.0') + expect(orgName.value).toBe('nuxt') + }) + }) + + describe('code / stats / timeline routes (`packageName` param)', () => { + it('parses the code route (scoped, with file path)', async () => { + const { packageName, requestedVersion, orgName } = await at( + '/package-code/@nuxt/kit/v/1.0.0/src/index.ts', + ) + expect(packageName.value).toBe('@nuxt/kit') + expect(requestedVersion.value).toBe('1.0.0') + expect(orgName.value).toBe('nuxt') + }) + + it('parses the stats route (unscoped)', async () => { + const { packageName, requestedVersion, orgName } = await at('/package-stats/nuxt/v/4.2.0') + expect(packageName.value).toBe('nuxt') + expect(requestedVersion.value).toBe('4.2.0') + expect(orgName.value).toBeNull() + }) + + it('parses the timeline route (scoped)', async () => { + const { packageName, requestedVersion, orgName } = await at( + '/package-timeline/@nuxt/kit/v/1.0.0', + ) + expect(packageName.value).toBe('@nuxt/kit') + expect(requestedVersion.value).toBe('1.0.0') + expect(orgName.value).toBe('nuxt') + }) + }) + + describe('changelog routes (`name` param)', () => { + it('parses a scoped package with a version', async () => { + const { packageName, requestedVersion, orgName } = await at( + '/package-changelog/@nuxt/kit/v/1.0.0', + ) + expect(packageName.value).toBe('@nuxt/kit') + expect(requestedVersion.value).toBe('1.0.0') + expect(orgName.value).toBe('nuxt') + }) + }) + + describe('docs route (catch-all `path` param)', () => { + it('parses a scoped package with a version', async () => { + const { packageName, requestedVersion, orgName } = await at('/package-docs/@nuxt/kit/v/1.0.0') + expect(packageName.value).toBe('@nuxt/kit') + expect(requestedVersion.value).toBe('1.0.0') + expect(orgName.value).toBe('nuxt') + }) + + it('parses an unscoped package with a version', async () => { + const { packageName, requestedVersion, orgName } = await at('/package-docs/nuxt/v/4.2.0') + expect(packageName.value).toBe('nuxt') + expect(requestedVersion.value).toBe('4.2.0') + expect(orgName.value).toBeNull() + }) + + it('parses an unscoped package with no version', async () => { + const { packageName, requestedVersion, orgName } = await at('/package-docs/nuxt') + expect(packageName.value).toBe('nuxt') + expect(requestedVersion.value).toBeNull() + expect(orgName.value).toBeNull() + }) + + it('parses a scoped package with no version', async () => { + const { packageName, requestedVersion, orgName } = await at('/package-docs/@nuxt/kit') + expect(packageName.value).toBe('@nuxt/kit') + expect(requestedVersion.value).toBeNull() + expect(orgName.value).toBe('nuxt') + }) + + it('treats a package literally named "v" as the package, not a version marker', async () => { + const { packageName, requestedVersion } = await at('/package-docs/v') + expect(packageName.value).toBe('v') + expect(requestedVersion.value).toBeNull() + }) + + it('recognises the version marker only when it follows the package name', async () => { + // package "v" at version "1.0.0": the first "v" is the name, the second is the marker + const { packageName, requestedVersion } = await at('/package-docs/v/v/1.0.0') + expect(packageName.value).toBe('v') + expect(requestedVersion.value).toBe('1.0.0') + }) + + it('takes only the version segment, leaving trailing docs segments out', async () => { + const { packageName, requestedVersion } = await at('/package-docs/nuxt/v/4.2.0/api') + expect(packageName.value).toBe('nuxt') + expect(requestedVersion.value).toBe('4.2.0') + }) + }) + + describe('diff route (`versionRange` param)', () => { + it('resolves the package/org but does not treat the range as a requested version', async () => { + const { packageName, requestedVersion, orgName } = await at('/diff/@nuxt/kit/v/1.0.0...2.0.0') + expect(packageName.value).toBe('@nuxt/kit') + expect(requestedVersion.value).toBeNull() + expect(orgName.value).toBe('nuxt') + }) + }) +})