From 46867175d58f5facdaeaae03cf2a161f0984e9ba Mon Sep 17 00:00:00 2001 From: Wilhelm Berggren Date: Thu, 30 Jul 2026 13:55:14 +0200 Subject: [PATCH 1/3] fix: build package docs route consistently --- app/components/Package/Header.vue | 11 ++---- .../useCommandPalettePackageCommands.ts | 10 +----- app/pages/package-docs/[...path].vue | 19 ++--------- app/utils/router.ts | 18 ++++++++++ test/unit/app/utils/router.spec.ts | 34 +++++++++++++++++++ 5 files changed, 59 insertions(+), 33 deletions(-) create mode 100644 test/unit/app/utils/router.spec.ts diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue index ea7e9bed85..e01e0dc03b 100644 --- a/app/components/Package/Header.vue +++ b/app/components/Package/Header.vue @@ -132,15 +132,10 @@ useCommandPaletteContextCommands( ) // Docs URL: use our generated API docs -const docsLink = computed(() => { - if (!props.resolvedVersion) return null +const docsLink = computed((): RouteLocationRaw | null => { + if (!props.pkg?.name || !props.resolvedVersion) return null - return { - name: 'docs' as const, - params: { - path: [props.pkg?.name ?? '', 'v', props.resolvedVersion] satisfies [string, string, string], - }, - } + return docsRoute(props.pkg.name, props.resolvedVersion) }) const codeLink = computed((): RouteLocationRaw | null => { diff --git a/app/composables/useCommandPalettePackageCommands.ts b/app/composables/useCommandPalettePackageCommands.ts index 92f79f69a0..78ae3a62e0 100644 --- a/app/composables/useCommandPalettePackageCommands.ts +++ b/app/composables/useCommandPalettePackageCommands.ts @@ -37,15 +37,7 @@ export function useCommandPalettePackageCommands( const { org, name } = splitPackageName(resolvedContext.packageName) if (!name) return [] - const docsPath: [string, ...string[]] = org - ? [org, name, 'v', resolvedContext.resolvedVersion] - : [name, 'v', resolvedContext.resolvedVersion] - const docsLink = { - name: 'docs' as const, - params: { - path: docsPath, - }, - } + const docsLink = docsRoute(resolvedContext.packageName, resolvedContext.resolvedVersion) const codeLink = { name: 'code' as const, params: { diff --git a/app/pages/package-docs/[...path].vue b/app/pages/package-docs/[...path].vue index bc0315a6dd..f7c66fba38 100644 --- a/app/pages/package-docs/[...path].vue +++ b/app/pages/package-docs/[...path].vue @@ -49,12 +49,8 @@ if (import.meta.server && !requestedVersion.value && packageName.value) { const version = await fetchLatestVersion(packageName.value) if (version) { setResponseHeader(useRequestEvent()!, 'Cache-Control', 'no-cache') - const pathSegments = [...packageName.value.split('/'), 'v', version] app.runWithContext(() => - navigateTo( - { name: 'docs', params: { path: pathSegments as [string, ...string[]] } }, - { redirectCode: 302 }, - ), + navigateTo(docsRoute(packageName.value, version), { redirectCode: 302 }), ) } } @@ -63,8 +59,7 @@ watch( [requestedVersion, latestVersion, packageName], ([version, latest, name]) => { if (!version && latest && name) { - const pathSegments = [...name.split('/'), 'v', latest] - router.replace({ name: 'docs', params: { path: pathSegments as [string, ...string[]] } }) + router.replace(docsRoute(name, latest)) } }, { immediate: true }, @@ -125,15 +120,7 @@ const versionUrlPattern = computed( ) function docsVersionRoute(version: string): RouteLocationRaw { - const name = pkg.value?.name || packageName.value - const [firstSegment = name, ...remainingSegments] = name.split('/') - - return { - name: 'docs', - params: { - path: [firstSegment, ...remainingSegments, 'v', version], - }, - } + return docsRoute(pkg.value?.name || packageName.value, version) } useCommandPaletteVersionCommands(commandPalettePackageContext, docsVersionRoute) diff --git a/app/utils/router.ts b/app/utils/router.ts index 5c608ba742..2ea9c0700b 100644 --- a/app/utils/router.ts +++ b/app/utils/router.ts @@ -30,6 +30,24 @@ export function packageRoute( } } +/** + * Docs tab route (`/package-docs/...`). + * + * The docs route uses a single catch-all `path` param. Emit the scoped name as + * two segments (`["@org", "name", ...]`) rather than one (`["@org/name", ...]`), + * so the URL keeps a literal slash instead of a `%2F`-encoded one. + */ +export function docsRoute(packageName: string, version?: string | null): RouteLocationRaw { + const { org, name } = splitPackageName(packageName) + const nameSegments = org ? [org, name] : [name] + const path = version ? [...nameSegments, 'v', version.replace(/\s+/g, '')] : nameSegments + + return { + name: 'docs', + params: { path: path as [string, ...string[]] }, + } +} + /** Full version history page (`/package/.../versions`) */ export function packageVersionsRoute(packageName: string): RouteLocationRaw { const { org, name } = splitPackageName(packageName) diff --git a/test/unit/app/utils/router.spec.ts b/test/unit/app/utils/router.spec.ts new file mode 100644 index 0000000000..71afed5568 --- /dev/null +++ b/test/unit/app/utils/router.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { docsRoute } from '~/utils/router' + +describe('docsRoute', () => { + it('emits a scoped name as two path segments (literal slash, not %2F)', () => { + // A single "@org/name" segment would be URL-encoded to "@org%2Fname"; the + // docs route must keep the scope slash literal by splitting it into two. + expect(docsRoute('@vitest/pretty-format', '4.1.10')).toEqual({ + name: 'docs', + params: { path: ['@vitest', 'pretty-format', 'v', '4.1.10'] }, + }) + }) + + it('handles an unscoped name with a version', () => { + expect(docsRoute('nuxt', '4.2.0')).toEqual({ + name: 'docs', + params: { path: ['nuxt', 'v', '4.2.0'] }, + }) + }) + + it('omits the version marker when no version is given', () => { + expect(docsRoute('@vitest/pretty-format')).toEqual({ + name: 'docs', + params: { path: ['@vitest', 'pretty-format'] }, + }) + }) + + it('strips whitespace from the version', () => { + expect(docsRoute('nuxt', ' 4.2.0 ')).toEqual({ + name: 'docs', + params: { path: ['nuxt', 'v', '4.2.0'] }, + }) + }) +}) From bdb60673f349c975b40305da254455762b5e9ef5 Mon Sep 17 00:00:00 2001 From: Wilhelm Berggren Date: Thu, 30 Jul 2026 13:57:52 +0200 Subject: [PATCH 2/3] fix: handle encoded scoped package name in usePackageRoute --- app/composables/usePackageRoute.ts | 10 +++++----- .../composables/use-package-route.spec.ts | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/app/composables/usePackageRoute.ts b/app/composables/usePackageRoute.ts index 7e187d7c2a..238905e0e9 100644 --- a/app/composables/usePackageRoute.ts +++ b/app/composables/usePackageRoute.ts @@ -27,11 +27,11 @@ export function usePackageRoute() { 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 nameLength = scoped && !segments[0]?.includes('/') ? 2 : 1 + const fullName = segments.slice(0, nameLength).join('/') + const version = segments[nameLength] === 'v' ? (segments[nameLength + 1] ?? null) : null + const { org, name } = splitPackageName(fullName) + return { org: org || undefined, name, version } } const org = typeof params.org === 'string' ? params.org : undefined diff --git a/test/nuxt/composables/use-package-route.spec.ts b/test/nuxt/composables/use-package-route.spec.ts index 8a7fd6d49a..32d27c016b 100644 --- a/test/nuxt/composables/use-package-route.spec.ts +++ b/test/nuxt/composables/use-package-route.spec.ts @@ -80,6 +80,25 @@ describe('usePackageRoute', () => { expect(orgName.value).toBeNull() }) + it('parses a scoped package whose full name is a single %2F-encoded segment', async () => { + await useRouter().push({ + name: 'docs', + params: { path: ['@vitest/pretty-format', 'v', '4.1.10'] }, + }) + const { packageName, requestedVersion, orgName } = usePackageRoute() + expect(packageName.value).toBe('@vitest/pretty-format') + expect(requestedVersion.value).toBe('4.1.10') + expect(orgName.value).toBe('vitest') + }) + + it('parses a scoped single-segment name with no version', async () => { + await useRouter().push({ name: 'docs', params: { path: ['@vitest/pretty-format'] } }) + const { packageName, requestedVersion, orgName } = usePackageRoute() + expect(packageName.value).toBe('@vitest/pretty-format') + expect(requestedVersion.value).toBeNull() + expect(orgName.value).toBe('vitest') + }) + it('parses an unscoped package with no version', async () => { const { packageName, requestedVersion, orgName } = await at('/package-docs/nuxt') expect(packageName.value).toBe('nuxt') From 696f6081bcdfe358eca2e6779406f077413f7360 Mon Sep 17 00:00:00 2001 From: Wilhelm Berggren Date: Thu, 30 Jul 2026 14:43:38 +0200 Subject: [PATCH 3/3] fix: use `usePackageRoute` in `package-docs` and ensure consistent package parsing --- app/pages/package-docs/[...path].vue | 21 +------------------ .../composables/use-package-route.spec.ts | 16 ++++++++++++++ test/unit/app/utils/router.spec.ts | 14 +++++++++++++ 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/app/pages/package-docs/[...path].vue b/app/pages/package-docs/[...path].vue index f7c66fba38..76e50e38db 100644 --- a/app/pages/package-docs/[...path].vue +++ b/app/pages/package-docs/[...path].vue @@ -9,29 +9,10 @@ definePageMeta({ scrollMargin: 180, }) -const route = useRoute('docs') const router = useRouter() const { t } = useI18n() -const parsedRoute = computed(() => { - const segments = route.params.path?.filter(Boolean) - const vIndex = segments.indexOf('v') - - if (vIndex === -1 || vIndex >= segments.length - 1) { - return { - packageName: segments.join('/'), - version: null as string | null, - } - } - - return { - packageName: segments.slice(0, vIndex).join('/'), - version: segments.slice(vIndex + 1).join('/'), - } -}) - -const packageName = computed(() => parsedRoute.value.packageName) -const requestedVersion = computed(() => parsedRoute.value.version) +const { packageName, requestedVersion } = usePackageRoute() // Validate package name on server-side for early error detection if (import.meta.server && packageName.value) { diff --git a/test/nuxt/composables/use-package-route.spec.ts b/test/nuxt/composables/use-package-route.spec.ts index 32d27c016b..d7f36c52d9 100644 --- a/test/nuxt/composables/use-package-route.spec.ts +++ b/test/nuxt/composables/use-package-route.spec.ts @@ -131,6 +131,22 @@ describe('usePackageRoute', () => { expect(packageName.value).toBe('nuxt') expect(requestedVersion.value).toBe('4.2.0') }) + + it('round-trips a package literally named "v" via docsRoute', async () => { + await useRouter().push(docsRoute('v', '1.0.0')) + const { packageName, requestedVersion, orgName } = usePackageRoute() + expect(packageName.value).toBe('v') + expect(requestedVersion.value).toBe('1.0.0') + expect(orgName.value).toBeNull() + }) + + it('round-trips a scoped package whose name is "v" via docsRoute', async () => { + await useRouter().push(docsRoute('@org/v', '1.0.0')) + const { packageName, requestedVersion, orgName } = usePackageRoute() + expect(packageName.value).toBe('@org/v') + expect(requestedVersion.value).toBe('1.0.0') + expect(orgName.value).toBe('org') + }) }) describe('diff route (`versionRange` param)', () => { diff --git a/test/unit/app/utils/router.spec.ts b/test/unit/app/utils/router.spec.ts index 71afed5568..cf93ed00ea 100644 --- a/test/unit/app/utils/router.spec.ts +++ b/test/unit/app/utils/router.spec.ts @@ -31,4 +31,18 @@ describe('docsRoute', () => { params: { path: ['nuxt', 'v', '4.2.0'] }, }) }) + + it('keeps a package literally named "v" separate from the version marker', () => { + expect(docsRoute('v', '1.0.0')).toEqual({ + name: 'docs', + params: { path: ['v', 'v', '1.0.0'] }, + }) + }) + + it('handles a scoped package whose name is "v"', () => { + expect(docsRoute('@org/v', '1.0.0')).toEqual({ + name: 'docs', + params: { path: ['@org', 'v', 'v', '1.0.0'] }, + }) + }) })