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

Configure Feed

Select the types of activity you want to include in your feed.

refactor: change routing and support 'flat' routes

+130 -37
+2 -2
app/components/PackageCard.vue
··· 23 23 <template> 24 24 <article class="group card-interactive"> 25 25 <NuxtLink 26 - :to="`/package/${result.package.name}`" 26 + :to="{ name: 'package', params: { package: result.package.name.split('/') } }" 27 27 :prefetch-on="prefetch ? 'visibility' : 'interaction'" 28 28 class="block focus:outline-none decoration-none" 29 29 > ··· 78 78 > 79 79 <li v-for="keyword in result.package.keywords.slice(0, 5)" :key="keyword"> 80 80 <NuxtLink 81 - :to="`/search?q=keywords:${encodeURIComponent(keyword)}`" 81 + :to="{ name: 'search', query: { q: `keywords:${keyword}` } }" 82 82 class="tag decoration-none" 83 83 > 84 84 {{ keyword }}
+2 -2
app/components/PackageDependencies.vue
··· 71 71 class="flex items-center justify-between py-1 text-sm gap-2" 72 72 > 73 73 <NuxtLink 74 - :to="`/package/${dep}`" 74 + :to="{ name: 'package', params: { package: dep.split('/') } }" 75 75 class="font-mono text-fg-muted hover:text-fg transition-colors duration-200 truncate min-w-0" 76 76 > 77 77 {{ dep }} ··· 111 111 > 112 112 <div class="flex items-center gap-2 min-w-0"> 113 113 <NuxtLink 114 - :to="`/package/${peer.name}`" 114 + :to="{ name: 'package', params: { package: peer.name.split('/') } }" 115 115 class="font-mono text-fg-muted hover:text-fg transition-colors duration-200 truncate" 116 116 > 117 117 {{ peer.name }}
+15 -5
app/components/PackageVersions.vue
··· 1 1 <script setup lang="ts"> 2 2 import type { PackumentVersion, PackageVersionInfo } from '#shared/types' 3 + import type { RouteLocationRaw } from 'vue-router' 3 4 4 5 const props = defineProps<{ 5 6 packageName: string ··· 54 55 if (vb.prerelease) return 1 55 56 56 57 return 0 58 + } 59 + 60 + // Build route object for package version link 61 + function versionRoute(version: string): RouteLocationRaw { 62 + return { 63 + name: 'package', 64 + params: { package: [...props.packageName.split('/'), 'v', version] }, 65 + } 57 66 } 58 67 59 68 // Get prerelease channel or empty string for stable ··· 333 342 <div class="flex-1 flex items-center justify-between py-1.5 text-sm gap-2 min-w-0"> 334 343 <div class="flex items-center gap-2 min-w-0"> 335 344 <NuxtLink 336 - :to="`/package/${packageName}/v/${row.primaryVersion.version}`" 345 + :to="versionRoute(row.primaryVersion.version)" 337 346 class="font-mono text-fg-muted hover:text-fg transition-colors duration-200 truncate" 338 347 > 339 348 {{ row.primaryVersion.version }} ··· 374 383 > 375 384 <div class="flex items-center gap-2 min-w-0"> 376 385 <NuxtLink 377 - :to="`/package/${packageName}/v/${v.version}`" 386 + :to="versionRoute(v.version)" 378 387 class="font-mono text-xs text-fg-subtle hover:text-fg-muted transition-colors duration-200 truncate" 379 388 > 380 389 {{ v.version }} ··· 452 461 <div v-else class="flex items-center gap-2 py-1"> 453 462 <span class="w-3" /> 454 463 <NuxtLink 455 - :to="`/package/${packageName}/v/${group.versions[0]?.version}`" 464 + v-if="group.versions[0]" 465 + :to="versionRoute(group.versions[0].version)" 456 466 class="font-mono text-xs text-fg-muted hover:text-fg transition-colors duration-200" 457 467 > 458 - {{ group.versions[0]?.version }} 468 + {{ group.versions[0].version }} 459 469 </NuxtLink> 460 470 <span 461 471 v-if="group.versions[0]?.tag" ··· 474 484 > 475 485 <div class="flex items-center gap-2 min-w-0"> 476 486 <NuxtLink 477 - :to="`/package/${packageName}/v/${v.version}`" 487 + :to="versionRoute(v.version)" 478 488 class="font-mono text-xs text-fg-subtle hover:text-fg-muted transition-colors duration-200 truncate" 479 489 > 480 490 {{ v.version }}
+28
app/middleware/canonical-redirects.global.ts
··· 1 + /** 2 + * Redirect legacy URLs to canonical paths (client-side only) 3 + * 4 + * - /package/* → /* 5 + * - /package/code/* → /code/* 6 + * - /org/* → /@* 7 + */ 8 + export default defineNuxtRouteMiddleware(to => { 9 + // Only redirect on client-side to avoid breaking crawlers mid-transition 10 + if (import.meta.server) return 11 + 12 + const path = to.path 13 + 14 + // /package/code/* → /code/* 15 + if (path.startsWith('/package/code/')) { 16 + return navigateTo(path.replace('/package/code/', '/code/'), { replace: true }) 17 + } 18 + 19 + // /package/* → /* 20 + if (path.startsWith('/package/')) { 21 + return navigateTo(path.replace('/package/', '/'), { replace: true }) 22 + } 23 + 24 + // /org/* → /@* 25 + if (path.startsWith('/org/')) { 26 + return navigateTo(path.replace('/org/', '/@'), { replace: true }) 27 + } 28 + })
+1 -1
app/pages/index.vue
··· 98 98 :key="pkg" 99 99 > 100 100 <NuxtLink 101 - :to="`/package/${pkg}`" 101 + :to="{ name: 'package', params: { package: [pkg] } }" 102 102 class="link-subtle font-mono text-sm inline-flex items-center gap-2 group" 103 103 > 104 104 <span
+14 -2
app/pages/org/[name].vue app/pages/@[org].vue
··· 1 1 <script setup lang="ts"> 2 2 import { formatNumber } from '#imports' 3 3 4 - const route = useRoute('org-name') 4 + definePageMeta({ 5 + name: 'org', 6 + alias: ['/org/:org()'], 7 + }) 8 + 9 + const route = useRoute('org') 5 10 6 - const orgName = computed(() => route.params.name) 11 + const orgName = computed(() => route.params.org) 7 12 8 13 const { isConnected } = useConnector() 9 14 ··· 25 30 const packageCount = computed(() => scopedPackages.value.length) 26 31 27 32 const activeTab = ref<'members' | 'teams'>('members') 33 + 34 + // Canonical URL for this org page 35 + const canonicalUrl = computed(() => `https://npmx.dev/@${orgName.value}`) 36 + 37 + useHead({ 38 + link: [{ rel: 'canonical', href: canonicalUrl }], 39 + }) 28 40 29 41 useSeoMeta({ 30 42 title: () => `@${orgName.value} - npmx`,
+27 -9
app/pages/package/[...name].vue app/pages/[...package].vue
··· 2 2 import { joinURL } from 'ufo' 3 3 import type { PackumentVersion, NpmVersionDist } from '#shared/types' 4 4 5 - const route = useRoute('package-name') 5 + definePageMeta({ 6 + name: 'package', 7 + alias: ['/package/:package(.*)*'], 8 + }) 9 + 10 + const route = useRoute('package') 6 11 7 12 // Parse package name and optional version from URL 8 13 // Patterns: 9 - // /package/nuxt → packageName: "nuxt", requestedVersion: null 10 - // /package/nuxt/v/4.2.0 → packageName: "nuxt", requestedVersion: "4.2.0" 11 - // /package/@nuxt/kit → packageName: "@nuxt/kit", requestedVersion: null 12 - // /package/@nuxt/kit/v/1.0.0 → packageName: "@nuxt/kit", requestedVersion: "1.0.0" 14 + // /nuxt → packageName: "nuxt", requestedVersion: null 15 + // /nuxt/v/4.2.0 → packageName: "nuxt", requestedVersion: "4.2.0" 16 + // /@nuxt/kit → packageName: "@nuxt/kit", requestedVersion: null 17 + // /@nuxt/kit/v/1.0.0 → packageName: "@nuxt/kit", requestedVersion: "1.0.0" 13 18 const parsedRoute = computed(() => { 14 - const segments = Array.isArray(route.params.name) ? route.params.name : [route.params.name ?? ''] 19 + const segments = route.params.package || [] 15 20 16 21 // Find the /v/ separator for version 17 22 const vIndex = segments.indexOf('v') ··· 220 225 nextTick(checkDescriptionOverflow) 221 226 }) 222 227 228 + // Canonical URL for this package page 229 + const canonicalUrl = computed(() => { 230 + const base = `https://npmx.dev/${packageName.value}` 231 + return requestedVersion.value ? `${base}/v/${requestedVersion.value}` : base 232 + }) 233 + 234 + useHead({ 235 + link: [{ rel: 'canonical', href: canonicalUrl }], 236 + }) 237 + 223 238 useSeoMeta({ 224 239 title: () => (pkg.value?.name ? `${pkg.value.name} - npmx` : 'Package - npmx'), 225 240 description: () => pkg.value?.description ?? '', ··· 246 261 <h1 class="font-mono text-2xl sm:text-3xl font-medium"> 247 262 <NuxtLink 248 263 v-if="orgName" 249 - :to="`/org/${orgName}`" 264 + :to="{ name: 'org', params: { org: orgName } }" 250 265 class="text-fg-muted hover:text-fg transition-colors duration-200" 251 266 >@{{ orgName }}</NuxtLink 252 267 ><span v-if="orgName">/</span ··· 432 447 </li> 433 448 <li v-if="displayVersion"> 434 449 <NuxtLink 435 - :to="`/package/code/${pkg.name}/v/${displayVersion.version}`" 450 + :to="{ 451 + name: 'code', 452 + params: { path: [...pkg.name.split('/'), 'v', displayVersion.version] }, 453 + }" 436 454 class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 437 455 > 438 456 <span class="i-carbon-code w-4 h-4" /> ··· 560 578 </h2> 561 579 <ul class="flex flex-wrap gap-1.5 list-none m-0 p-0"> 562 580 <li v-for="keyword in displayVersion.keywords.slice(0, 15)" :key="keyword"> 563 - <NuxtLink :to="`/search?q=keywords:${encodeURIComponent(keyword)}`" class="tag"> 581 + <NuxtLink :to="{ name: 'search', query: { q: `keywords:${keyword}` } }" class="tag"> 564 582 {{ keyword }} 565 583 </NuxtLink> 566 584 </li>
+38 -13
app/pages/package/code/[...path].vue app/pages/code/[...path].vue
··· 5 5 PackageFileContentResponse, 6 6 } from '#shared/types' 7 7 8 - const route = useRoute('package-code-path') 8 + definePageMeta({ 9 + name: 'code', 10 + alias: ['/package/code/:path(.*)*'], 11 + }) 12 + 13 + const route = useRoute('code') 9 14 const router = useRouter() 10 15 11 16 // Parse package name, version, and file path from URL 12 17 // Patterns: 13 - // /package/code/nuxt/v/4.2.0 → packageName: "nuxt", version: "4.2.0", filePath: null (show tree) 14 - // /package/code/nuxt/v/4.2.0/src/index.ts → packageName: "nuxt", version: "4.2.0", filePath: "src/index.ts" 15 - // /package/code/@nuxt/kit/v/1.0.0 → packageName: "@nuxt/kit", version: "1.0.0", filePath: null 18 + // /code/nuxt/v/4.2.0 → packageName: "nuxt", version: "4.2.0", filePath: null (show tree) 19 + // /code/nuxt/v/4.2.0/src/index.ts → packageName: "nuxt", version: "4.2.0", filePath: "src/index.ts" 20 + // /code/@nuxt/kit/v/1.0.0 → packageName: "@nuxt/kit", version: "1.0.0", filePath: null 16 21 const parsedRoute = computed(() => { 17 - const segments = Array.isArray(route.params.path) ? route.params.path : [route.params.path ?? ''] 22 + const segments = route.params.path || [] 18 23 19 24 // Find the /v/ separator for version 20 25 const vIndex = segments.indexOf('v') ··· 75 80 // Version switch handler 76 81 function switchVersion(newVersion: string) { 77 82 const newPath = filePath.value 78 - ? `/package/code/${packageName.value}/v/${newVersion}/${filePath.value}` 79 - : `/package/code/${packageName.value}/v/${newVersion}` 83 + ? `/code/${packageName.value}/v/${newVersion}/${filePath.value}` 84 + : `/code/${packageName.value}/v/${newVersion}` 80 85 router.push(newPath) 81 86 } 82 87 ··· 207 212 208 213 // Navigation helper - build URL for a path 209 214 function getCodeUrl(path?: string): string { 210 - const base = `/package/code/${packageName.value}/v/${version.value}` 215 + const base = `/code/${packageName.value}/v/${version.value}` 211 216 return path ? `${base}/${path}` : base 212 217 } 213 218 ··· 219 224 return match ? match[1] : null 220 225 }) 221 226 227 + // Build route object for package link (with optional version) 228 + function packageRoute(ver?: string | null) { 229 + const segments = packageName.value.split('/') 230 + if (ver) { 231 + segments.push('v', ver) 232 + } 233 + return { name: 'package' as const, params: { package: segments } } 234 + } 235 + 222 236 // Format file size 223 237 function formatBytes(bytes: number): string { 224 238 if (bytes < 1024) return `${bytes} B` ··· 257 271 await navigator.clipboard.writeText(url.toString()) 258 272 } 259 273 274 + // Canonical URL for this code page 275 + const canonicalUrl = computed(() => { 276 + let url = `https://npmx.dev/code/${packageName.value}/v/${version.value}` 277 + if (filePath.value) { 278 + url += `/${filePath.value}` 279 + } 280 + return url 281 + }) 282 + 283 + useHead({ 284 + link: [{ rel: 'canonical', href: canonicalUrl }], 285 + }) 286 + 260 287 useSeoMeta({ 261 288 title: () => { 262 289 if (filePath.value) { ··· 276 303 <!-- Package info and navigation --> 277 304 <div class="flex items-center gap-2 mb-3 flex-wrap"> 278 305 <NuxtLink 279 - :to="`/package/${packageName}${version ? `/v/${version}` : ''}`" 306 + :to="packageRoute(version)" 280 307 class="font-mono text-lg font-medium hover:text-fg transition-colors" 281 308 > 282 309 <span v-if="orgName" class="text-fg-muted">@{{ orgName }}/</span ··· 337 364 <!-- Error: no version --> 338 365 <div v-if="!version" class="container py-20 text-center"> 339 366 <p class="text-fg-muted mb-4">Version is required to browse code</p> 340 - <NuxtLink :to="`/package/${packageName}`" class="btn"> Go to package </NuxtLink> 367 + <NuxtLink :to="packageRoute()" class="btn"> Go to package </NuxtLink> 341 368 </div> 342 369 343 370 <!-- Loading state --> ··· 349 376 <!-- Error state --> 350 377 <div v-else-if="treeStatus === 'error'" class="container py-20 text-center" role="alert"> 351 378 <p class="text-fg-muted mb-4">Failed to load files for this package version</p> 352 - <NuxtLink :to="`/package/${packageName}${version ? `/v/${version}` : ''}`" class="btn"> 353 - Back to package 354 - </NuxtLink> 379 + <NuxtLink :to="packageRoute(version)" class="btn"> Back to package </NuxtLink> 355 380 </div> 356 381 357 382 <!-- Main content: file tree + file viewer -->
+2 -2
server/utils/code-highlight.ts
··· 184 184 const dep = dependencies?.[packageName] 185 185 if (dep) { 186 186 // Link to code browser with resolved version 187 - return `/package/code/${packageName}/v/${dep.version}` 187 + return `/code/${packageName}/v/${dep.version}` 188 188 } 189 189 // Fall back to package page if not a known dependency 190 - return `/package/${packageName}` 190 + return `/${packageName}` 191 191 } 192 192 193 193 // Match: from keyword span followed by string span containing module specifier
+1 -1
server/utils/import-resolver.ts
··· 212 212 return (specifier: string) => { 213 213 const resolved = resolveRelativeImport(specifier, currentFile, files) 214 214 if (resolved) { 215 - return `/package/code/${packageName}/v/${version}/${resolved.path}` 215 + return `/code/${packageName}/v/${version}/${resolved.path}` 216 216 } 217 217 return null 218 218 }