From f2aadc519fade905d9616d0a7682036f9210d16a Mon Sep 17 00:00:00 2001 From: Wilhelm Berggren Date: Wed, 22 Jul 2026 17:19:05 +0200 Subject: [PATCH 1/2] fix(ui): resolve name, version and org in usePackageRoute across all package-related routes --- app/composables/usePackageRoute.ts | 57 ++++++-- .../composables/use-package-route.spec.ts | 125 ++++++++++++++++++ 2 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 test/nuxt/composables/use-package-route.spec.ts diff --git a/app/composables/usePackageRoute.ts b/app/composables/usePackageRoute.ts index abf3d28a8e..7e187d7c2a 100644 --- a/app/composables/usePackageRoute.ts +++ b/app/composables/usePackageRoute.ts @@ -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 + + // 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/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') + }) + }) +}) From 61883d2ddc287cca98192eb65061f8eb2ce9c0c3 Mon Sep 17 00:00:00 2001 From: Wilhelm Berggren Date: Wed, 22 Jul 2026 17:39:14 +0200 Subject: [PATCH 2/2] feat(ui): add "copy package version" to command palette and header --- app/components/Package/Header.vue | 48 +++++++-- i18n/locales/en.json | 1 + i18n/schema.json | 3 + test/e2e/interactions.spec.ts | 6 +- test/nuxt/components/Package/Header.spec.ts | 106 ++++++++++++++++++++ 5 files changed, 150 insertions(+), 14 deletions(-) create mode 100644 test/nuxt/components/Package/Header.spec.ts 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 }} -

-
+ + +
{ 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') + }) +})