[READ-ONLY] a fast, modern browser for the npm registry

refactor: use object-syntax/named routes (#1041)

authored by

Daniel Roe and committed by
GitHub
d3637469 88be8000

+313 -241
+3
.gitignore
··· 39 39 40 40 # generated files 41 41 shared/types/lexicons 42 + 43 + # output 44 + .vercel
+1 -1
app/app.vue
··· 62 62 return 63 63 } 64 64 65 - router.push('/search') 65 + router.push({ name: 'search' }) 66 66 }, 67 67 { dedupe: true }, 68 68 )
+2 -2
app/components/AppFooter.vue
··· 15 15 </div> 16 16 <!-- Desktop: Show all links. Mobile: Links are in MobileMenu --> 17 17 <div class="hidden sm:flex items-center gap-6"> 18 - <NuxtLink to="/about" class="link-subtle font-mono text-xs flex items-center"> 18 + <NuxtLink :to="{ name: 'about' }" class="link-subtle font-mono text-xs flex items-center"> 19 19 {{ $t('footer.about') }} 20 20 </NuxtLink> 21 21 <NuxtLink 22 - to="/privacy" 22 + :to="{ name: 'privacy' }" 23 23 class="link-subtle font-mono text-xs min-h-11 flex items-center gap-1 lowercase" 24 24 > 25 25 {{ $t('privacy_policy.title') }}
+5 -5
app/components/AppHeader.vue
··· 65 65 e => isKeyWithoutModifiers(e, ',') && !isEditableElement(e.target), 66 66 e => { 67 67 e.preventDefault() 68 - navigateTo('/settings') 68 + navigateTo({ name: 'settings' }) 69 69 }, 70 70 { dedupe: true }, 71 71 ) ··· 78 78 !e.defaultPrevented, 79 79 e => { 80 80 e.preventDefault() 81 - navigateTo('/compare') 81 + navigateTo({ name: 'compare' }) 82 82 }, 83 83 { dedupe: true }, 84 84 ) ··· 106 106 <!-- Desktop: Logo (navigates home) --> 107 107 <div v-if="showLogo" class="hidden sm:flex flex-shrink-0 items-center"> 108 108 <NuxtLink 109 - to="/" 109 + :to="{ name: 'index' }" 110 110 :aria-label="$t('header.home')" 111 111 dir="ltr" 112 112 class="inline-flex items-center gap-1 header-logo font-mono text-lg font-medium text-fg hover:text-fg/90 transition-colors duration-200 rounded" ··· 152 152 <div class="flex-shrink-0 flex items-center gap-0.5 sm:gap-2"> 153 153 <!-- Desktop: Compare link --> 154 154 <NuxtLink 155 - to="/compare" 155 + :to="{ name: 'compare' }" 156 156 class="hidden sm:inline-flex link-subtle font-mono text-sm items-center gap-2 px-2 py-1.5 hover:bg-bg-subtle focus-visible:outline-accent/70 rounded" 157 157 aria-keyshortcuts="c" 158 158 > ··· 167 167 168 168 <!-- Desktop: Settings link --> 169 169 <NuxtLink 170 - to="/settings" 170 + :to="{ name: 'settings' }" 171 171 class="hidden sm:inline-flex link-subtle font-mono text-sm items-center gap-2 px-2 py-1.5 hover:bg-bg-subtle focus-visible:outline-accent/70 rounded" 172 172 aria-keyshortcuts="," 173 173 >
+17 -2
app/components/Code/DirectoryListing.vue
··· 1 1 <script setup lang="ts"> 2 2 import type { PackageFileTree } from '#shared/types' 3 + import type { RouteLocationRaw } from 'vue-router' 3 4 import { getFileIcon } from '~/utils/file-icons' 4 5 import { formatBytes } from '~/utils/formatters' 5 6 ··· 7 8 tree: PackageFileTree[] 8 9 currentPath: string 9 10 baseUrl: string 11 + /** Base path segments for the code route (e.g., ['nuxt', 'v', '4.2.0']) */ 12 + basePath: string[] 10 13 }>() 11 14 12 15 // Get the current directory's contents ··· 36 39 if (parts.length <= 1) return '' 37 40 return parts.slice(0, -1).join('/') 38 41 }) 42 + 43 + // Build route object for a path 44 + function getCodeRoute(nodePath?: string): RouteLocationRaw { 45 + if (!nodePath) { 46 + return { name: 'code', params: { path: props.basePath as [string, ...string[]] } } 47 + } 48 + const pathSegments = [...props.basePath, ...nodePath.split('/')] 49 + return { 50 + name: 'code', 51 + params: { path: pathSegments as [string, ...string[]] }, 52 + } 53 + } 39 54 </script> 40 55 41 56 <template> ··· 61 76 > 62 77 <td class="py-2 px-4"> 63 78 <NuxtLink 64 - :to="parentPath ? `${baseUrl}/${parentPath}` : baseUrl" 79 + :to="getCodeRoute(parentPath || undefined)" 65 80 class="flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors" 66 81 > 67 82 <span class="i-carbon:folder w-4 h-4 text-yellow-600" /> ··· 79 94 > 80 95 <td class="py-2 px-4"> 81 96 <NuxtLink 82 - :to="`${baseUrl}/${node.path}`" 97 + :to="getCodeRoute(node.path)" 83 98 class="flex items-center gap-2 font-mono text-sm hover:text-fg transition-colors" 84 99 :class="node.type === 'directory' ? 'text-fg' : 'text-fg-muted'" 85 100 >
+14 -1
app/components/Code/FileTree.vue
··· 1 1 <script setup lang="ts"> 2 2 import type { PackageFileTree } from '#shared/types' 3 + import type { RouteLocationRaw } from 'vue-router' 3 4 import { getFileIcon } from '~/utils/file-icons' 4 5 5 6 const props = defineProps<{ 6 7 tree: PackageFileTree[] 7 8 currentPath: string 8 9 baseUrl: string 10 + /** Base path segments for the code route (e.g., ['nuxt', 'v', '4.2.0']) */ 11 + basePath: string[] 9 12 depth?: number 10 13 }>() 11 14 ··· 16 19 if (props.currentPath === node.path) return true 17 20 if (props.currentPath.startsWith(node.path + '/')) return true 18 21 return false 22 + } 23 + 24 + // Build route object for a file path 25 + function getFileRoute(nodePath: string): RouteLocationRaw { 26 + const pathSegments = [...props.basePath, ...nodePath.split('/')] 27 + return { 28 + name: 'code', 29 + params: { path: pathSegments as [string, ...string[]] }, 30 + } 19 31 } 20 32 21 33 const { toggleDir, isExpanded, autoExpandAncestors } = useFileTreeState(props.baseUrl) ··· 63 75 :tree="node.children" 64 76 :current-path="currentPath" 65 77 :base-url="baseUrl" 78 + :base-path="basePath" 66 79 :depth="depth + 1" 67 80 /> 68 81 </template> ··· 70 83 <!-- File --> 71 84 <template v-else> 72 85 <NuxtLink 73 - :to="`${baseUrl}/${node.path}`" 86 + :to="getFileRoute(node.path)" 74 87 class="flex items-center gap-1.5 py-1.5 px-3 font-mono text-sm transition-colors hover:bg-bg-muted" 75 88 :class="currentPath === node.path ? 'bg-bg-muted text-fg' : 'text-fg-muted'" 76 89 :style="{ paddingLeft: `${depth * 12 + 32}px` }"
+8 -1
app/components/Code/MobileTreeDrawer.vue
··· 5 5 tree: PackageFileTree[] 6 6 currentPath: string 7 7 baseUrl: string 8 + /** Base path segments for the code route (e.g., ['nuxt', 'v', '4.2.0']) */ 9 + basePath: string[] 8 10 }>() 9 11 10 12 const isOpen = shallowRef(false) ··· 73 75 <span class="i-carbon:close w-5 h-5" /> 74 76 </button> 75 77 </div> 76 - <CodeFileTree :tree="tree" :current-path="currentPath" :base-url="baseUrl" /> 78 + <CodeFileTree 79 + :tree="tree" 80 + :current-path="currentPath" 81 + :base-url="baseUrl" 82 + :base-path="basePath" 83 + /> 77 84 </aside> 78 85 </Transition> 79 86 </template>
+1 -1
app/components/Compare/ComparisonGrid.vue
··· 45 45 > 46 46 <span class="inline-flex items-center gap-1.5 truncate"> 47 47 <NuxtLink 48 - :to="`/package/${col.header}`" 48 + :to="packageRoute(col.header)" 49 49 class="link-subtle font-mono text-sm font-medium text-fg truncate" 50 50 :title="col.header" 51 51 >
+1 -1
app/components/Compare/PackageSelector.vue
··· 106 106 </template> 107 107 <NuxtLink 108 108 v-else 109 - :to="`/package/${pkg}`" 109 + :to="packageRoute(pkg)" 110 110 class="font-mono text-sm text-fg hover:text-accent transition-colors" 111 111 > 112 112 {{ pkg }}
+6 -10
app/components/DependencyPathPopup.vue
··· 93 93 > 94 94 <span v-if="idx > 0" class="text-fg-subtle me-1">└─</span> 95 95 <NuxtLink 96 - :to="{ 97 - name: 'package', 98 - params: { 99 - package: [ 100 - ...parsePackageString(pathItem).name.split('/'), 101 - 'v', 102 - parsePackageString(pathItem).version, 103 - ], 104 - }, 105 - }" 96 + :to=" 97 + packageRoute( 98 + parsePackageString(pathItem).name, 99 + parsePackageString(pathItem).version, 100 + ) 101 + " 106 102 class="hover:underline" 107 103 :class="idx === path.length - 1 ? 'text-fg font-medium' : 'text-fg-muted'" 108 104 @click="closePopup"
+6 -6
app/components/Header/MobileMenu.client.vue
··· 104 104 <!-- Main navigation --> 105 105 <div class="px-2 py-2"> 106 106 <NuxtLink 107 - to="/about" 107 + :to="{ name: 'about' }" 108 108 class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200" 109 109 @click="closeMenu" 110 110 > ··· 113 113 </NuxtLink> 114 114 115 115 <NuxtLink 116 - to="/privacy" 116 + :to="{ name: 'privacy' }" 117 117 class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200" 118 118 @click="closeMenu" 119 119 > ··· 122 122 </NuxtLink> 123 123 124 124 <NuxtLink 125 - to="/compare" 125 + :to="{ name: 'compare' }" 126 126 class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200" 127 127 @click="closeMenu" 128 128 > ··· 131 131 </NuxtLink> 132 132 133 133 <NuxtLink 134 - to="/settings" 134 + :to="{ name: 'settings' }" 135 135 class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200" 136 136 @click="closeMenu" 137 137 > ··· 142 142 <!-- Connected user links --> 143 143 <template v-if="isConnected && npmUser"> 144 144 <NuxtLink 145 - :to="`/~${npmUser}`" 145 + :to="{ name: '~username', params: { username: npmUser } }" 146 146 class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200" 147 147 @click="closeMenu" 148 148 > ··· 151 151 </NuxtLink> 152 152 153 153 <NuxtLink 154 - :to="`/~${npmUser}/orgs`" 154 + :to="{ name: '~username-orgs', params: { username: npmUser } }" 155 155 class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200" 156 156 @click="closeMenu" 157 157 >
+3 -3
app/components/Header/OrgsDropdown.vue
··· 58 58 @keydown="handleKeydown" 59 59 > 60 60 <NuxtLink 61 - :to="`/~${username}/orgs`" 61 + :to="{ name: '~username-orgs', params: { username } }" 62 62 class="link-subtle font-mono text-sm inline-flex items-center gap-1" 63 63 > 64 64 {{ $t('header.orgs') }} ··· 94 94 <ul v-else-if="orgs.length > 0" class="py-1 max-h-80 overflow-y-auto"> 95 95 <li v-for="org in orgs" :key="org"> 96 96 <NuxtLink 97 - :to="`/@${org}`" 97 + :to="{ name: 'org', params: { org } }" 98 98 class="block px-3 py-2 font-mono text-sm text-fg hover:bg-bg-subtle transition-colors" 99 99 > 100 100 @{{ org }} ··· 108 108 109 109 <div class="px-3 py-2 border-t border-border"> 110 110 <NuxtLink 111 - :to="`/~${username}/orgs`" 111 + :to="{ name: '~username-orgs', params: { username } }" 112 112 class="link-subtle font-mono text-xs inline-flex items-center gap-1" 113 113 > 114 114 {{ $t('header.orgs_dropdown.view_all') }}
+3 -3
app/components/Header/PackagesDropdown.vue
··· 58 58 @keydown="handleKeydown" 59 59 > 60 60 <NuxtLink 61 - :to="`/~${username}`" 61 + :to="{ name: '~username', params: { username } }" 62 62 class="link-subtle font-mono text-sm inline-flex items-center gap-1" 63 63 > 64 64 {{ $t('header.packages') }} ··· 94 94 <ul v-else-if="packages.length > 0" class="py-1 max-h-80 overflow-y-auto"> 95 95 <li v-for="pkg in packages" :key="pkg"> 96 96 <NuxtLink 97 - :to="`/package/${pkg}`" 97 + :to="packageRoute(pkg)" 98 98 class="block px-3 py-2 font-mono text-sm text-fg hover:bg-bg-subtle transition-colors truncate" 99 99 > 100 100 {{ pkg }} ··· 108 108 109 109 <div class="px-3 py-2 border-t border-border"> 110 110 <NuxtLink 111 - :to="`/~${username}`" 111 + :to="{ name: '~username', params: { username } }" 112 112 class="link-subtle font-mono text-xs inline-flex items-center gap-1" 113 113 > 114 114 {{ $t('header.packages_dropdown.view_all') }}
+1 -1
app/components/Package/Card.vue
··· 44 44 class="font-mono text-sm sm:text-base font-medium text-fg group-hover:text-fg transition-colors duration-200 min-w-0 break-all" 45 45 > 46 46 <NuxtLink 47 - :to="{ name: 'package', params: { package: result.package.name.split('/') } }" 47 + :to="packageRoute(result.package.name)" 48 48 :prefetch-on="prefetch ? 'visibility' : 'interaction'" 49 49 class="decoration-none scroll-mt-48 scroll-mb-6 after:content-[''] after:absolute after:inset-0" 50 50 :data-result-index="index"
+2 -2
app/components/Package/ClaimPackageModal.vue
··· 173 173 174 174 <div class="flex gap-3"> 175 175 <NuxtLink 176 - :to="`/package/${packageName}`" 176 + :to="packageRoute(packageName)" 177 177 class="flex-1 px-4 py-2 font-mono text-sm text-center text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 focus-visible:outline-accent/70" 178 178 @click="close" 179 179 > ··· 277 277 <span v-else class="w-4 h-4 shrink-0" /> 278 278 <div class="min-w-0"> 279 279 <NuxtLink 280 - :to="`/package/${pkg.name}`" 280 + :to="packageRoute(pkg.name)" 281 281 class="font-mono text-sm text-fg hover:underline focus-visible:outline-accent/70 rounded" 282 282 target="_blank" 283 283 >
+8 -26
app/components/Package/Dependencies.vue
··· 82 82 class="flex items-center justify-between py-1 text-sm gap-2" 83 83 > 84 84 <NuxtLink 85 - :to="{ name: 'package', params: { package: dep.split('/') } }" 85 + :to="packageRoute(dep)" 86 86 class="font-mono text-fg-muted hover:text-fg transition-colors duration-200 truncate min-w-0 flex-1" 87 87 > 88 88 {{ dep }} ··· 99 99 </span> 100 100 <NuxtLink 101 101 v-if="getVulnerableDepInfo(dep)" 102 - :to="{ 103 - name: 'package', 104 - params: { package: [...dep.split('/'), 'v', getVulnerableDepInfo(dep)!.version] }, 105 - }" 102 + :to="packageRoute(dep, getVulnerableDepInfo(dep)!.version)" 106 103 class="shrink-0" 107 104 :class="SEVERITY_TEXT_COLORS[getHighestSeverity(getVulnerableDepInfo(dep)!.counts)]" 108 105 :title="`${getVulnerableDepInfo(dep)!.counts.total} vulnerabilities`" ··· 112 109 </NuxtLink> 113 110 <NuxtLink 114 111 v-if="getDeprecatedDepInfo(dep)" 115 - :to="{ 116 - name: 'package', 117 - params: { package: [...dep.split('/'), 'v', getDeprecatedDepInfo(dep)!.version] }, 118 - }" 112 + :to="packageRoute(dep, getDeprecatedDepInfo(dep)!.version)" 119 113 class="shrink-0 text-purple-500" 120 114 :title="getDeprecatedDepInfo(dep)!.message" 121 115 > ··· 123 117 <span class="sr-only">{{ $t('package.deprecated.label') }}</span> 124 118 </NuxtLink> 125 119 <NuxtLink 126 - :to="{ 127 - name: 'package', 128 - params: { package: [...dep.split('/'), 'v', version] }, 129 - }" 120 + :to="packageRoute(dep, version)" 130 121 class="font-mono text-xs text-end truncate" 131 122 :class="getVersionClass(outdatedDeps[dep])" 132 123 :title="outdatedDeps[dep] ? getOutdatedTooltip(outdatedDeps[dep], $t) : version" ··· 174 165 > 175 166 <div class="flex items-center gap-1 min-w-0 flex-1"> 176 167 <NuxtLink 177 - :to="{ 178 - name: 'package', 179 - params: { package: peer.name.split('/') }, 180 - }" 168 + :to="packageRoute(peer.name)" 181 169 class="font-mono text-fg-muted hover:text-fg transition-colors duration-200 truncate" 182 170 > 183 171 {{ peer.name }} ··· 191 179 </span> 192 180 </div> 193 181 <NuxtLink 194 - :to="{ 195 - name: 'package', 196 - params: { package: [...peer.name.split('/'), 'v', peer.version] }, 197 - }" 182 + :to="packageRoute(peer.name, peer.version)" 198 183 class="font-mono text-xs text-fg-subtle max-w-[40%] truncate" 199 184 :title="peer.version" 200 185 > ··· 239 224 class="flex items-center justify-between py-1 text-sm gap-2" 240 225 > 241 226 <NuxtLink 242 - :to="{ name: 'package', params: { package: dep.split('/') } }" 227 + :to="packageRoute(dep)" 243 228 class="font-mono text-fg-muted hover:text-fg transition-colors duration-200 truncate min-w-0 flex-1" 244 229 > 245 230 {{ dep }} 246 231 </NuxtLink> 247 232 <NuxtLink 248 - :to="{ 249 - name: 'package', 250 - params: { package: [...dep.split('/'), 'v', version] }, 251 - }" 233 + :to="packageRoute(dep, version)" 252 234 class="font-mono text-xs text-fg-subtle max-w-[40%] text-end truncate" 253 235 :title="version" 254 236 >
+1 -4
app/components/Package/DeprecatedTree.vue
··· 84 84 <DependencyPathPopup v-if="pkg.path && pkg.path.length > 1" :path="pkg.path" /> 85 85 86 86 <NuxtLink 87 - :to="{ 88 - name: 'package', 89 - params: { package: [...pkg.name.split('/'), 'v', pkg.version] }, 90 - }" 87 + :to="packageRoute(pkg.name, pkg.version)" 91 88 class="font-mono text-sm font-medium hover:underline truncate py-4" 92 89 :class="getDepthStyle(pkg.depth).text" 93 90 >
+1 -1
app/components/Package/InstallScripts.vue
··· 73 73 class="flex items-center justify-between py-0.5 text-sm gap-2" 74 74 > 75 75 <NuxtLink 76 - :to="{ name: 'package', params: { package: dep.split('/') } }" 76 + :to="packageRoute(dep)" 77 77 class="font-mono text-fg-muted hover:text-fg transition-colors duration-200 truncate min-w-0" 78 78 > 79 79 {{ dep }}
+5 -2
app/components/Package/TableRow.vue
··· 34 34 return props.columns.find(c => c.id === id)?.visible ?? false 35 35 } 36 36 37 - const packageUrl = computed(() => `/package/${pkg.value.name}`) 37 + const packageUrl = computed(() => packageRoute(pkg.value.name)) 38 38 39 39 const allMaintainersText = computed(() => { 40 40 if (!pkg.value.maintainers?.length) return '' ··· 106 106 :key="maintainer.username || maintainer.email" 107 107 > 108 108 <NuxtLink 109 - :to="`/~${maintainer.username || maintainer.name}`" 109 + :to="{ 110 + name: '~username', 111 + params: { username: maintainer.username || maintainer.name || '' }, 112 + }" 110 113 class="hover:text-accent-fallback transition-colors duration-200" 111 114 @click.stop 112 115 >{{ maintainer.username || maintainer.name || maintainer.email }}</NuxtLink
+1 -4
app/components/Package/Versions.vue
··· 33 33 34 34 // Build route object for package version link 35 35 function versionRoute(version: string): RouteLocationRaw { 36 - return { 37 - name: 'package', 38 - params: { package: [...props.packageName.split('/'), 'v', version] }, 39 - } 36 + return packageRoute(props.packageName, version) 40 37 } 41 38 42 39 // Version to tags lookup (supports multiple tags per version)
+1 -4
app/components/Package/VulnerabilityTree.vue
··· 124 124 <DependencyPathPopup v-if="pkg.path && pkg.path.length > 1" :path="pkg.path" /> 125 125 126 126 <NuxtLink 127 - :to="{ 128 - name: 'package', 129 - params: { package: [...pkg.name.split('/'), 'v', pkg.version] }, 130 - }" 127 + :to="packageRoute(pkg.name, pkg.version)" 131 128 class="font-mono text-sm font-medium hover:underline truncate shrink min-w-0" 132 129 :class="getDepthStyle(pkg.depth).text" 133 130 >
+2 -2
app/components/Terminal/Install.vue
··· 149 149 ></code 150 150 > 151 151 <NuxtLink 152 - :to="`/package/${typesPackageName}`" 152 + :to="packageRoute(typesPackageName!)" 153 153 class="text-fg-subtle hover:text-fg-muted text-xs transition-colors focus-visible:outline-accent/70 rounded select-none" 154 154 :title="$t('package.get_started.view_types', { package: typesPackageName })" 155 155 > ··· 202 202 :text="$t('package.create.view', { packageName: createPackageInfo.packageName })" 203 203 > 204 204 <NuxtLink 205 - :to="`/package/${createPackageInfo.packageName}`" 205 + :to="packageRoute(createPackageInfo.packageName)" 206 206 class="inline-flex items-center justify-center min-w-6 min-h-6 -m-1 p-1 text-fg-muted hover:text-fg text-xs transition-colors focus-visible:outline-2 focus-visible:outline-accent/70 rounded" 207 207 > 208 208 <span class="i-carbon:information w-3 h-3" aria-hidden="true" />
+1 -1
app/components/VersionSelector.vue
··· 620 620 <!-- Link to package page for full version list --> 621 621 <div class="border-t border-border mt-1 pt-1 px-3 py-2"> 622 622 <NuxtLink 623 - :to="`/package/${packageName}`" 623 + :to="packageRoute(packageName)" 624 624 class="text-xs text-fg-subtle hover:text-fg transition-[color] focus-visible:outline-none focus-visible:text-fg" 625 625 @click="isOpen = false" 626 626 >
+12 -43
app/composables/usePackageRoute.ts
··· 1 1 /** 2 2 * Parse package name and optional version from the route URL. 3 3 * 4 - * Supported patterns: 5 - * /nuxt → packageName: "nuxt", requestedVersion: null 6 - * /nuxt/v/4.2.0 → packageName: "nuxt", requestedVersion: "4.2.0" 7 - * /@nuxt/kit → packageName: "@nuxt/kit", requestedVersion: null 8 - * /@nuxt/kit/v/1.0.0 → packageName: "@nuxt/kit", requestedVersion: "1.0.0" 9 - * /axios@1.13.3 → packageName: "axios", requestedVersion: "1.13.3" 10 - * /@nuxt/kit@1.0.0 → packageName: "@nuxt/kit", requestedVersion: "1.0.0" 4 + * Routes use structured params: 5 + * /package/nuxt → org: undefined, name: "nuxt" 6 + * /package/@nuxt/kit → org: "@nuxt", name: "kit" 7 + * /package/nuxt/v/4.2.0 → org: undefined, name: "nuxt", version: "4.2.0" 8 + * /package/@nuxt/kit/v/1.0.0 → org: "@nuxt", name: "kit", version: "1.0.0" 11 9 */ 12 10 export function usePackageRoute() { 13 - const route = useRoute('package') 14 - 15 - const parsedRoute = computed(() => { 16 - const segments = route.params.package?.filter(Boolean) || [] 11 + const route = useRoute<'package'>('package') 17 12 18 - // Find the /v/ separator for version 19 - const vIndex = segments.indexOf('v') 20 - if (vIndex !== -1 && vIndex < segments.length - 1) { 21 - return { 22 - packageName: segments.slice(0, vIndex).join('/'), 23 - requestedVersion: segments.slice(vIndex + 1).join('/'), 24 - } 25 - } 26 - 27 - // Parse @ versioned package 28 - const fullPath = segments.join('/') 29 - const versionMatch = fullPath.match(/^(@[^/]+\/[^/]+|[^/]+)@([^/]+)$/) 30 - if (versionMatch) { 31 - const [, packageName, requestedVersion] = versionMatch as [string, string, string] 32 - return { 33 - packageName, 34 - requestedVersion, 35 - } 36 - } 37 - 38 - return { 39 - packageName: fullPath, 40 - requestedVersion: null as string | null, 41 - } 13 + const packageName = computed(() => { 14 + const { org, name } = route.params 15 + return org ? `${org}/${name}` : name 42 16 }) 43 17 44 - const packageName = computed(() => parsedRoute.value.packageName) 45 - const requestedVersion = computed(() => parsedRoute.value.requestedVersion) 46 - 47 - // Extract org name from scoped package (e.g., "@nuxt/kit" -> "nuxt") 18 + const requestedVersion = computed(() => ('version' in route.params ? route.params.version : null)) 48 19 const orgName = computed(() => { 49 - const name = packageName.value 50 - if (!name.startsWith('@')) return null 51 - const match = name.match(/^@([^/]+)\//) 52 - return match ? match[1] : null 20 + const org = route.params.org 21 + return org ? org.replace(/^@/, '') : null 53 22 }) 54 23 55 24 return {
-49
app/middleware/canonical-redirects.global.ts
··· 1 - /** 2 - * Redirect legacy URLs to canonical paths (client-side only) 3 - * 4 - * - /package/code/* → /package-code/* 5 - * - /code/* → /package-code/* 6 - * - /package/docs/* → /package-docs/* 7 - * - /docs/* → /package-docs/* 8 - * - /org/* → /@* 9 - * - /* → /package/* (Unless it's an existing page) 10 - */ 11 - export default defineNuxtRouteMiddleware(to => { 12 - // Only redirect on client-side to avoid breaking crawlers mid-transition 13 - if (import.meta.server) return 14 - 15 - const path = to.path 16 - 17 - // /package/code/* → /package-code/* 18 - if (path.startsWith('/package/code/')) { 19 - return navigateTo(path.replace('/package/code/', '/package-code/'), { replace: true }) 20 - } 21 - // /code/* → /package-code/* 22 - if (path.startsWith('/code/')) { 23 - return navigateTo(path.replace('/code/', '/package-code/'), { replace: true }) 24 - } 25 - 26 - // /package/docs/* → /package-docs/* 27 - if (path.startsWith('/package/docs/')) { 28 - return navigateTo(path.replace('/package/docs/', '/package-docs/'), { replace: true }) 29 - } 30 - // /docs/* → /package-docs/* 31 - if (path.startsWith('/docs/')) { 32 - return navigateTo(path.replace('/docs/', '/package-docs/'), { replace: true }) 33 - } 34 - 35 - // /org/* → /@* 36 - if (path.startsWith('/org/')) { 37 - return navigateTo(path.replace('/org/', '/@'), { replace: true }) 38 - } 39 - 40 - // Keep this one last as it will catch everything 41 - // /* → /package/* (Unless it's an existing page) 42 - if (path.startsWith('/') && !path.startsWith('/package/')) { 43 - const router = useRouter() 44 - const resolved = router.resolve(path) 45 - if (resolved?.matched?.length === 1 && resolved.matched[0]?.path === '/:package(.*)*') { 46 - return navigateTo(`/package${path}`, { replace: true }) 47 - } 48 - } 49 - })
+1 -2
app/pages/@[org].vue app/pages/org/[org].vue
··· 5 5 6 6 definePageMeta({ 7 7 name: 'org', 8 - alias: ['/org/:org()'], 9 8 }) 10 9 11 10 const route = useRoute('org') ··· 245 244 <p class="text-fg-muted mb-4"> 246 245 {{ error?.message ?? $t('org.page.failed_to_load') }} 247 246 </p> 248 - <NuxtLink to="/" class="btn">{{ $t('common.go_back_home') }}</NuxtLink> 247 + <NuxtLink :to="{ name: 'index' }" class="btn">{{ $t('common.go_back_home') }}</NuxtLink> 249 248 </div> 250 249 251 250 <!-- Empty state -->
+1 -1
app/pages/index.vue
··· 124 124 <ul class="flex flex-wrap items-center justify-center gap-x-6 gap-y-3 list-none m-0 p-0"> 125 125 <li v-for="framework in SHOWCASED_FRAMEWORKS" :key="framework.name"> 126 126 <NuxtLink 127 - :to="{ name: 'package', params: { package: [framework.package] } }" 127 + :to="packageRoute(framework.package)" 128 128 class="link-subtle font-mono text-sm inline-flex items-center gap-2 group" 129 129 > 130 130 <span
+16 -12
app/pages/package-code/[...path].vue
··· 198 198 return path ? `${base}/${path}` : base 199 199 } 200 200 201 + // Base path segments for route objects (e.g., ['nuxt', 'v', '4.2.0'] or ['@nuxt', 'kit', 'v', '1.0.0']) 202 + const basePath = computed(() => { 203 + const segments = packageName.value.split('/') 204 + return [...segments, 'v', version.value ?? ''] 205 + }) 206 + 201 207 // Extract org name from scoped package 202 208 const orgName = computed(() => { 203 209 const name = packageName.value ··· 206 212 return match ? match[1] : null 207 213 }) 208 214 209 - // Build route object for package link (with optional version) 210 - function packageRoute(ver?: string | null) { 211 - const segments = packageName.value.split('/') 212 - if (ver) { 213 - segments.push('v', ver) 214 - } 215 - return { name: 'package' as const, params: { package: segments } } 216 - } 217 - 218 215 // Line number click handler - update URL hash without scrolling 219 216 function handleLineClick(lineNum: number, event: MouseEvent) { 220 217 let newHash: string ··· 315 312 <!-- Package info and navigation --> 316 313 <div class="flex items-center gap-2 mb-3 flex-wrap min-w-0"> 317 314 <NuxtLink 318 - :to="packageRoute(version)" 315 + :to="packageRoute(packageName, version)" 319 316 class="font-mono text-lg font-medium hover:text-fg transition-colors min-w-0 truncate max-w-[60vw] sm:max-w-none" 320 317 :title="packageName" 321 318 > ··· 373 370 <!-- Error: no version --> 374 371 <div v-if="!version" class="container py-20 text-center"> 375 372 <p class="text-fg-muted mb-4">{{ $t('code.version_required') }}</p> 376 - <NuxtLink :to="packageRoute()" class="btn">{{ $t('code.go_to_package') }}</NuxtLink> 373 + <NuxtLink :to="packageRoute(packageName)" class="btn">{{ 374 + $t('code.go_to_package') 375 + }}</NuxtLink> 377 376 </div> 378 377 379 378 <!-- Loading state --> ··· 385 384 <!-- Error state --> 386 385 <div v-else-if="treeStatus === 'error'" class="container py-20 text-center" role="alert"> 387 386 <p class="text-fg-muted mb-4">{{ $t('code.failed_to_load_tree') }}</p> 388 - <NuxtLink :to="packageRoute(version)" class="btn">{{ $t('code.back_to_package') }}</NuxtLink> 387 + <NuxtLink :to="packageRoute(packageName, version)" class="btn">{{ 388 + $t('code.back_to_package') 389 + }}</NuxtLink> 389 390 </div> 390 391 391 392 <!-- Main content: file tree + file viewer --> ··· 398 399 :tree="fileTree.tree" 399 400 :current-path="filePath ?? ''" 400 401 :base-url="getCodeUrl()" 402 + :base-path="basePath" 401 403 /> 402 404 </aside> 403 405 ··· 557 559 :tree="fileTree.tree" 558 560 :current-path="filePath ?? ''" 559 561 :base-url="getCodeUrl()" 562 + :base-path="basePath" 560 563 /> 561 564 </template> 562 565 </div> ··· 570 573 :tree="fileTree.tree" 571 574 :current-path="filePath ?? ''" 572 575 :base-url="getCodeUrl()" 576 + :base-path="basePath" 573 577 /> 574 578 </Teleport> 575 579 </ClientOnly>
+9 -4
app/pages/package-docs/[...path].vue
··· 46 46 const version = await fetchLatestVersion(packageName.value) 47 47 if (version) { 48 48 setResponseHeader(useRequestEvent()!, 'Cache-Control', 'no-cache') 49 + const pathSegments = [...packageName.value.split('/'), 'v', version] 49 50 app.runWithContext(() => 50 - navigateTo('/package-docs/' + packageName.value + '/v/' + version, { redirectCode: 302 }), 51 + navigateTo( 52 + { name: 'docs', params: { path: pathSegments as [string, ...string[]] } }, 53 + { redirectCode: 302 }, 54 + ), 51 55 ) 52 56 } 53 57 } ··· 56 60 [requestedVersion, latestVersion, packageName], 57 61 ([version, latest, name]) => { 58 62 if (!version && latest && name) { 59 - router.replace(`/package-docs/${name}/v/${latest}`) 63 + const pathSegments = [...name.split('/'), 'v', latest] 64 + router.replace({ name: 'docs', params: { path: pathSegments as [string, ...string[]] } }) 60 65 } 61 66 }, 62 67 { immediate: true }, ··· 127 132 <div class="flex items-center gap-3 min-w-0"> 128 133 <NuxtLink 129 134 v-if="packageName" 130 - :to="{ name: 'package', params: { package: [packageName] } }" 135 + :to="packageRoute(packageName)" 131 136 class="font-mono text-lg sm:text-xl font-semibold text-fg hover:text-fg-muted transition-colors truncate" 132 137 > 133 138 {{ packageName }} ··· 186 191 <div class="flex gap-4 mt-4"> 187 192 <NuxtLink 188 193 v-if="packageName" 189 - :to="{ name: 'package', params: { package: [packageName] } }" 194 + :to="packageRoute(packageName)" 190 195 class="link-subtle font-mono text-sm" 191 196 > 192 197 View package
+13 -13
app/pages/package/[...package].vue app/pages/package/[[org]]/[name].vue
··· 18 18 import { useAtproto } from '~/composables/atproto/useAtproto' 19 19 import { togglePackageLike } from '~/utils/atproto/likes' 20 20 21 - definePageMeta({ 22 - name: 'package', 23 - alias: ['/:package(.*)*'], 24 - }) 25 - 26 21 defineOgImageComponent('Package', { 27 22 name: () => packageName.value, 28 23 version: () => requestedVersion.value ?? '', ··· 318 313 const docsLink = computed(() => { 319 314 if (!resolvedVersion.value) return null 320 315 321 - return `/package-docs/${pkg.value!.name}/v/${resolvedVersion.value}` 316 + return { 317 + name: 'docs' as const, 318 + params: { 319 + path: [pkg.value!.name, 'v', resolvedVersion.value] satisfies [string, string, string], 320 + }, 321 + } 322 322 }) 323 323 324 324 const fundingUrl = computed(() => { ··· 493 493 e => { 494 494 if (!pkg.value) return 495 495 e.preventDefault() 496 - router.push({ path: '/compare', query: { packages: pkg.value.name } }) 496 + router.push({ name: 'compare', query: { packages: pkg.value.name } }) 497 497 }, 498 498 ) 499 499 </script> ··· 559 559 560 560 <NuxtLink 561 561 v-if="requestedVersion && resolvedVersion !== requestedVersion" 562 - :to="`/package/${pkg.name}/v/${resolvedVersion}`" 562 + :to="packageRoute(pkg.name, resolvedVersion)" 563 563 :title="$t('package.view_permalink')" 564 564 >{{ resolvedVersion }}</NuxtLink 565 565 > ··· 670 670 </kbd> 671 671 </NuxtLink> 672 672 <NuxtLink 673 - :to="`/package-code/${pkg.name}/v/${resolvedVersion}`" 673 + :to="{ name: 'code', params: { path: [pkg.name, 'v', resolvedVersion] } }" 674 674 class="px-2 py-1.5 font-mono text-xs rounded transition-colors duration-150 border border-transparent text-fg-subtle hover:text-fg hover:bg-bg hover:shadow hover:border-border inline-flex items-center gap-1.5" 675 675 aria-keyshortcuts="." 676 676 > ··· 684 684 </kbd> 685 685 </NuxtLink> 686 686 <NuxtLink 687 - :to="{ path: '/compare', query: { packages: pkg.name } }" 687 + :to="{ name: 'compare', query: { packages: pkg.name } }" 688 688 class="px-2 py-1.5 font-mono text-xs rounded transition-colors duration-150 border border-transparent text-fg-subtle hover:text-fg hover:bg-bg hover:shadow hover:border-border inline-flex items-center gap-1.5" 689 689 aria-keyshortcuts="c" 690 690 > ··· 821 821 </li> 822 822 <li v-if="resolvedVersion" class="sm:hidden"> 823 823 <NuxtLink 824 - :to="`/package-code/${pkg.name}/v/${resolvedVersion}`" 824 + :to="{ name: 'code', params: { path: [pkg.name, 'v', resolvedVersion] } }" 825 825 class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 826 826 > 827 827 <span class="i-carbon:code w-4 h-4" aria-hidden="true" /> ··· 830 830 </li> 831 831 <li class="sm:hidden"> 832 832 <NuxtLink 833 - :to="{ path: '/compare', query: { packages: pkg.name } }" 833 + :to="{ name: 'compare', query: { packages: pkg.name } }" 834 834 class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 835 835 > 836 836 <span class="i-carbon:compare w-4 h-4" aria-hidden="true" /> ··· 1266 1266 <p class="text-fg-muted mb-8"> 1267 1267 {{ error?.message ?? $t('package.not_found_message') }} 1268 1268 </p> 1269 - <NuxtLink to="/" class="btn">{{ $t('common.go_back_home') }}</NuxtLink> 1269 + <NuxtLink :to="{ name: 'index' }" class="btn">{{ $t('common.go_back_home') }}</NuxtLink> 1270 1270 </div> 1271 1271 </main> 1272 1272 </template>
+10
app/pages/package/[[org]]/[name]/index.vue
··· 1 + <script setup lang="ts"> 2 + // stub page to help with paths 3 + definePageMeta({ 4 + name: 'package', 5 + }) 6 + </script> 7 + 8 + <template> 9 + <div /> 10 + </template>
+10
app/pages/package/[[org]]/[name]/v/[version].vue
··· 1 + <script setup lang="ts"> 2 + // stub page to help with paths 3 + definePageMeta({ 4 + name: 'package-version', 5 + }) 6 + </script> 7 + 8 + <template> 9 + <div /> 10 + </template>
+2 -2
app/pages/privacy.vue
··· 138 138 </template> 139 139 <template #settings> 140 140 <NuxtLink 141 - to="/settings" 141 + :to="{ name: 'settings' }" 142 142 class="text-fg-muted hover:text-fg underline decoration-fg-subtle/50 hover:decoration-fg" 143 143 > 144 144 {{ $t('privacy_policy.cookies.local_storage.settings') }} ··· 266 266 <i18n-t keypath="privacy_policy.authenticated.p2" tag="span" scope="global"> 267 267 <template #settings> 268 268 <NuxtLink 269 - to="/settings" 269 + :to="{ name: 'settings' }" 270 270 class="text-fg-muted hover:text-fg underline decoration-fg-subtle/50 hover:decoration-fg" 271 271 > 272 272 {{ $t('privacy_policy.authenticated.settings') }}
+1 -4
app/pages/search.vue
··· 543 543 544 544 // Navigate to package page 545 545 async function navigateToPackage(packageName: string) { 546 - await navigateTo({ 547 - name: 'package', 548 - params: { package: packageName.split('/') }, 549 - }) 546 + await navigateTo(packageRoute(packageName)) 550 547 } 551 548 552 549 // Track the input value when user pressed Enter (for navigating when results arrive)
+1 -1
app/pages/~[username]/index.vue
··· 228 228 <p class="text-fg-muted mb-4"> 229 229 {{ error?.message ?? $t('user.page.failed_to_load') }} 230 230 </p> 231 - <NuxtLink to="/" class="btn">{{ $t('common.go_back_home') }}</NuxtLink> 231 + <NuxtLink :to="{ name: 'index' }" class="btn">{{ $t('common.go_back_home') }}</NuxtLink> 232 232 </div> 233 233 234 234 <!-- Package list -->
+7 -5
app/pages/~[username]/orgs.vue
··· 134 134 <!-- Back link --> 135 135 <nav aria-labelledby="back-to-profile"> 136 136 <NuxtLink 137 - :to="`/~${username}`" 137 + :to="{ name: '~username', params: { username } }" 138 138 id="back-to-profile" 139 139 class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 140 140 > ··· 158 158 <!-- Not own profile state --> 159 159 <div v-else-if="!isOwnProfile" class="py-12 text-center"> 160 160 <p class="text-fg-muted">{{ $t('user.orgs_page.own_orgs_only') }}</p> 161 - <NuxtLink :to="`/~${npmUser}/orgs`" class="btn mt-4">{{ 162 - $t('user.orgs_page.view_your_orgs') 163 - }}</NuxtLink> 161 + <NuxtLink 162 + :to="{ name: '~username-orgs', params: { username: npmUser! } }" 163 + class="btn mt-4" 164 + >{{ $t('user.orgs_page.view_your_orgs') }}</NuxtLink 165 + > 164 166 </div> 165 167 166 168 <!-- Loading state --> ··· 189 191 <ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> 190 192 <li v-for="org in orgs" :key="org.name"> 191 193 <NuxtLink 192 - :to="`/@${org.name}`" 194 + :to="{ name: 'org', params: { org: org.name } }" 193 195 class="block p-5 bg-bg-subtle border border-border rounded-lg hover:border-fg-subtle transition-colors h-full" 194 196 > 195 197 <div class="flex items-start gap-4 mb-4">
+22
app/utils/router.ts
··· 1 + export function packageRoute(packageName: string, version?: string | null) { 2 + const [org, name] = packageName.startsWith('@') ? packageName.split('/') : [null, packageName] 3 + 4 + if (version) { 5 + return { 6 + name: 'package-version' as const, 7 + params: { 8 + org, 9 + name, 10 + version, 11 + }, 12 + } 13 + } 14 + 15 + return { 16 + name: 'package' as const, 17 + params: { 18 + org, 19 + name, 20 + }, 21 + } 22 + }
+13 -2
modules/isr-fallback.ts
··· 1 - import { readFileSync, writeFileSync } from 'node:fs' 1 + import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' 2 2 import { resolve } from 'node:path' 3 3 import { defineNuxtModule } from 'nuxt/kit' 4 4 import { provider } from 'std-env' ··· 15 15 nuxt.hook('nitro:init', nitro => { 16 16 nitro.hooks.hook('compiled', () => { 17 17 const spaTemplate = readFileSync(nitro.options.output.publicDir + '/200.html', 'utf-8') 18 - for (const path of ['package', '']) { 18 + for (const path of [ 19 + 'package', 20 + 'package/[name]', 21 + 'package/[name]/v', 22 + 'package/[name]/v/[version]', 23 + 'package/[org]', 24 + 'package/[org]/[name]', 25 + 'package/[org]/[name]/v', 26 + 'package/[org]/[name]/v/[version]', 27 + '', 28 + ]) { 19 29 const outputPath = resolve( 20 30 nitro.options.output.serverDir, 21 31 '..', 22 32 path, 23 33 'spa.prerender-fallback.html', 24 34 ) 35 + mkdirSync(resolve(nitro.options.output.serverDir, '..', path), { recursive: true }) 25 36 writeFileSync(outputPath, spaTemplate) 26 37 } 27 38 })
+20 -20
nuxt.config.ts
··· 85 85 }, 86 86 87 87 routeRules: { 88 - '/': { prerender: true }, 89 - '/opensearch.xml': { isr: true }, 90 - '/**': { isr: getISRConfig(60, true) }, 91 - '/__og-image__/**': { isr: getISRConfig(60) }, 88 + // API routes 92 89 '/api/**': { isr: 60 }, 93 - '/200.html': { prerender: true }, 94 - '/package/**': { isr: getISRConfig(60, true) }, 90 + '/api/registry/docs/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 91 + '/api/registry/file/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 92 + '/api/registry/provenance/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 93 + '/api/registry/files/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 95 94 '/:pkg/.well-known/skills/**': { isr: 3600 }, 96 95 '/:scope/:pkg/.well-known/skills/**': { isr: 3600 }, 96 + '/__og-image__/**': { isr: getISRConfig(60) }, 97 + '/_avatar/**': { isr: 3600, proxy: 'https://www.gravatar.com/avatar/**' }, 98 + '/opensearch.xml': { isr: true }, 99 + '/oauth-client-metadata.json': { prerender: true }, 97 100 // never cache 98 - '/search': { isr: false, cache: false }, 99 101 '/api/auth/**': { isr: false, cache: false }, 100 102 '/api/social/**': { isr: false, cache: false }, 101 103 '/api/opensearch/suggestions': { ··· 105 107 allowQuery: ['q'], 106 108 }, 107 109 }, 110 + // pages 111 + '/package/:name': { isr: getISRConfig(60, true) }, 112 + '/package/:name/v/:version': { isr: getISRConfig(60, true) }, 113 + '/package/:org/:name': { isr: getISRConfig(60, true) }, 114 + '/package/:org/:name/v/:version': { isr: getISRConfig(60, true) }, 108 115 // infinite cache (versioned - doesn't change) 109 116 '/package-code/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 110 - '/package-docs/:pkg/v/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 111 - '/package-docs/:scope/:pkg/v/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 112 - '/api/registry/docs/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 113 - '/api/registry/file/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 114 - '/api/registry/provenance/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 115 - '/api/registry/files/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 116 - '/_avatar/**': { 117 - isr: 3600, 118 - proxy: { 119 - to: 'https://www.gravatar.com/avatar/**', 120 - }, 121 - }, 117 + '/package-docs/:name/v/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 118 + '/package-docs/:org/:name/v/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 122 119 // static pages 120 + '/': { prerender: true }, 121 + '/200.html': { prerender: true }, 123 122 '/about': { prerender: true }, 123 + '/privacy': { prerender: true }, 124 + '/search': { isr: false, cache: false }, // never cache 124 125 '/settings': { prerender: true }, 125 - '/oauth-client-metadata.json': { prerender: true }, 126 126 // proxy for insights 127 127 '/_v/script.js': { proxy: 'https://npmx.dev/_vercel/insights/script.js' }, 128 128 '/_v/view': { proxy: 'https://npmx.dev/_vercel/insights/view' },
+77
server/middleware/canonical-redirects.global.ts
··· 1 + /** 2 + * Redirect legacy/shorthand URLs to canonical paths. 3 + * 4 + * Handled here: 5 + * - /@org/pkg or /pkg → /package/@org/pkg or /package/pkg 6 + * - /@org/pkg/v/ver or /pkg@ver → /package/@org/pkg/v/ver or /package/pkg/v/ver 7 + * - /@org → /org/org 8 + * 9 + * Handled via route aliases (not here): 10 + * - /package/code/* → /package-code/* 11 + * - /code/* → /package-code/* 12 + * - /package/docs/* → /package-docs/* 13 + * - /docs/* → /package-docs/* 14 + */ 15 + const pages = [ 16 + '/200.html', 17 + '/about', 18 + '/compare', 19 + '/org', 20 + '/package', 21 + '/package-code', 22 + '/package-docs', 23 + '/privacy', 24 + '/search', 25 + '/settings', 26 + ] 27 + 28 + const cacheControl = 's-maxage=3600, stale-while-revalidate=36000' 29 + 30 + export default defineEventHandler(async event => { 31 + const routeRules = getRouteRules(event) 32 + if (Object.keys(routeRules).length > 1) { 33 + return 34 + } 35 + 36 + const [path = '/', query] = event.path.split('?') 37 + 38 + // username 39 + if (path.startsWith('/~') || path.startsWith('/_')) { 40 + return 41 + } 42 + 43 + if (pages.some(page => path === page || path.startsWith(page + '/'))) { 44 + return 45 + } 46 + 47 + // /@org/pkg or /pkg → /package/org/pkg or /package/pkg 48 + let pkgMatch = path.match(/^\/(?:(?<org>@[^/]+)\/)?(?<name>[^/@]+)$/) 49 + if (pkgMatch?.groups) { 50 + const args = [pkgMatch.groups.org, pkgMatch.groups.name].filter(Boolean).join('/') 51 + setHeader(event, 'cache-control', cacheControl) 52 + return sendRedirect(event, `/package/${args}` + (query ? '?' + query : ''), 301) 53 + } 54 + 55 + // /@org/pkg/v/version or /@org/pkg@version → /package/org/pkg/v/version 56 + // /pkg/v/version or /pkg@version → /package/pkg/v/version 57 + const pkgVersionMatch = 58 + path.match(/^\/(?:(?<org>@[^/]+)\/)?(?<name>[^/@]+)\/v\/(?<version>[^/]+)$/) || 59 + path.match(/^\/(?:(?<org>@[^/]+)\/)?(?<name>[^/@]+)@(?<version>[^/]+)$/) 60 + 61 + if (pkgVersionMatch?.groups) { 62 + const args = [pkgVersionMatch.groups.org, pkgVersionMatch.groups.name].filter(Boolean).join('/') 63 + setHeader(event, 'cache-control', cacheControl) 64 + return sendRedirect( 65 + event, 66 + `/package/${args}/v/${pkgVersionMatch.groups.version}` + (query ? '?' + query : ''), 67 + 301, 68 + ) 69 + } 70 + 71 + // /@org → /org/org 72 + const orgMatch = path.match(/^\/@(?<org>[^/]+)$/) 73 + if (orgMatch?.groups) { 74 + setHeader(event, 'cache-control', cacheControl) 75 + return sendRedirect(event, `/org/${orgMatch.groups.org}` + (query ? '?' + query : ''), 301) 76 + } 77 + })
+5
test/nuxt/a11y.spec.ts
··· 839 839 tree: mockTree, 840 840 currentPath: '', 841 841 baseUrl: '/package-code/vue', 842 + basePath: ['vue', 'v', '3.0.0'], 842 843 }, 843 844 }) 844 845 const results = await runAxe(component) ··· 851 852 tree: mockTree, 852 853 currentPath: 'src', 853 854 baseUrl: '/package-code/vue', 855 + basePath: ['vue', 'v', '3.0.0'], 854 856 }, 855 857 }) 856 858 const results = await runAxe(component) ··· 875 877 tree: mockTree, 876 878 currentPath: '', 877 879 baseUrl: '/package-code/vue', 880 + basePath: ['vue', 'v', '3.0.0'], 878 881 }, 879 882 }) 880 883 const results = await runAxe(component) ··· 887 890 tree: mockTree, 888 891 currentPath: 'src/index.ts', 889 892 baseUrl: '/package-code/vue', 893 + basePath: ['vue', 'v', '3.0.0'], 890 894 }, 891 895 }) 892 896 const results = await runAxe(component) ··· 1136 1140 tree: mockTree, 1137 1141 currentPath: '', 1138 1142 baseUrl: '/package-code/vue', 1143 + basePath: ['vue', 'v', '3.0.0'], 1139 1144 }, 1140 1145 }) 1141 1146 const results = await runAxe(component)