[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.

feat: group package versions

+655 -89
+556
app/components/PackageVersions.vue
··· 1 + <script setup lang="ts"> 2 + import type { PackumentVersion, PackageVersionInfo } from '#shared/types' 3 + 4 + const props = defineProps<{ 5 + packageName: string 6 + versions: Record<string, PackumentVersion> 7 + distTags: Record<string, string> 8 + time: Record<string, string> 9 + }>() 10 + 11 + /** A version with its metadata */ 12 + interface VersionDisplay { 13 + version: string 14 + time?: string 15 + tag?: string 16 + hasProvenance: boolean 17 + } 18 + 19 + /** A dist-tag row */ 20 + interface TagRow { 21 + id: string 22 + tag: string 23 + primaryVersion: VersionDisplay 24 + /** Versions in this tag's channel (same major + same prerelease type) */ 25 + allVersions: VersionDisplay[] 26 + loading: boolean 27 + expanded: boolean 28 + } 29 + 30 + /** Unclaimed major version group */ 31 + interface MajorGroup { 32 + major: number 33 + versions: VersionDisplay[] 34 + expanded: boolean 35 + } 36 + 37 + // Check if a version has provenance/attestations 38 + function hasProvenance(version: PackumentVersion | undefined): boolean { 39 + if (!version?.dist) return false 40 + const dist = version.dist as { attestations?: unknown } 41 + return !!dist.attestations 42 + } 43 + 44 + // Parse semver 45 + function parseVersion(version: string): { major: number, minor: number, patch: number, prerelease: string } { 46 + const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?/) 47 + if (!match) return { major: 0, minor: 0, patch: 0, prerelease: '' } 48 + return { 49 + major: Number(match[1]), 50 + minor: Number(match[2]), 51 + patch: Number(match[3]), 52 + prerelease: match[4] ?? '', 53 + } 54 + } 55 + 56 + // Compare versions (descending - higher version = smaller result for sort) 57 + function compareVersions(a: string, b: string): number { 58 + const va = parseVersion(a) 59 + const vb = parseVersion(b) 60 + 61 + if (va.major !== vb.major) return va.major - vb.major 62 + if (va.minor !== vb.minor) return va.minor - vb.minor 63 + if (va.patch !== vb.patch) return va.patch - vb.patch 64 + 65 + if (va.prerelease && vb.prerelease) return va.prerelease.localeCompare(vb.prerelease) 66 + if (va.prerelease) return -1 67 + if (vb.prerelease) return 1 68 + 69 + return 0 70 + } 71 + 72 + // Get prerelease channel or empty string for stable 73 + function getPrereleaseChannel(version: string): string { 74 + const parsed = parseVersion(version) 75 + if (!parsed.prerelease) return '' 76 + const match = parsed.prerelease.match(/^([a-z]+)/i) 77 + return match ? match[1]!.toLowerCase() : '' 78 + } 79 + 80 + // Cached full version list 81 + const allVersionsCache = ref<PackageVersionInfo[] | null>(null) 82 + const loadingVersions = ref(false) 83 + const hasLoadedAll = ref(false) 84 + 85 + // Version to tag lookup 86 + const versionToTag = computed(() => { 87 + const map = new Map<string, string>() 88 + for (const [tag, version] of Object.entries(props.distTags)) { 89 + const existing = map.get(version) 90 + if (!existing || tag === 'latest' || (tag.length < existing.length && existing !== 'latest')) { 91 + map.set(version, tag) 92 + } 93 + } 94 + return map 95 + }) 96 + 97 + // Dist-tag rows (stable structure) 98 + const tagRows = ref<TagRow[]>([]) 99 + 100 + // Unclaimed versions section 101 + const otherVersionsExpanded = ref(false) 102 + const otherMajorGroups = ref<MajorGroup[]>([]) 103 + 104 + // Initialize tag rows from props 105 + watchEffect(() => { 106 + const rows: TagRow[] = Object.entries(props.distTags) 107 + .map(([tag, version]) => { 108 + const versionData = props.versions[version] 109 + return { 110 + id: `tag:${tag}`, 111 + tag, 112 + primaryVersion: { 113 + version, 114 + time: props.time[version], 115 + tag, 116 + hasProvenance: hasProvenance(versionData), 117 + }, 118 + allVersions: [], 119 + loading: false, 120 + expanded: false, 121 + } 122 + }) 123 + .sort((a, b) => compareVersions(b.primaryVersion.version, a.primaryVersion.version)) 124 + 125 + tagRows.value = rows 126 + }) 127 + 128 + // npm registry packument type (simplified) 129 + interface NpmPackument { 130 + versions: Record<string, unknown> 131 + time: Record<string, string> 132 + } 133 + 134 + // Load all versions directly from npm registry 135 + async function loadAllVersions(): Promise<PackageVersionInfo[]> { 136 + if (allVersionsCache.value) return allVersionsCache.value 137 + 138 + if (loadingVersions.value) { 139 + await new Promise<void>((resolve) => { 140 + const unwatch = watch(allVersionsCache, (val) => { 141 + if (val) { 142 + unwatch() 143 + resolve() 144 + } 145 + }) 146 + }) 147 + return allVersionsCache.value! 148 + } 149 + 150 + loadingVersions.value = true 151 + try { 152 + // Fetch directly from npm registry 153 + const encodedName = props.packageName.startsWith('@') 154 + ? `@${encodeURIComponent(props.packageName.slice(1))}` 155 + : encodeURIComponent(props.packageName) 156 + 157 + const data = await $fetch<NpmPackument>(`https://registry.npmjs.org/${encodedName}`) 158 + 159 + // Convert to our format 160 + const versions: PackageVersionInfo[] = Object.keys(data.versions) 161 + .filter(v => data.time[v]) 162 + .map(version => ({ 163 + version, 164 + time: data.time[version], 165 + hasProvenance: false, // We don't have this info from the basic packument 166 + })) 167 + .sort((a, b) => compareVersions(b.version, a.version)) 168 + 169 + allVersionsCache.value = versions 170 + return versions 171 + } 172 + finally { 173 + loadingVersions.value = false 174 + } 175 + } 176 + 177 + // Process loaded versions - populate tag rows and find unclaimed versions 178 + function processLoadedVersions(allVersions: PackageVersionInfo[]) { 179 + const distTags = props.distTags 180 + 181 + // For each tag, find versions in its channel (same major + same prerelease channel) 182 + const claimedVersions = new Set<string>() 183 + 184 + for (const row of tagRows.value) { 185 + const tagVersion = distTags[row.tag] 186 + if (!tagVersion) continue 187 + 188 + const tagParsed = parseVersion(tagVersion) 189 + const tagChannel = getPrereleaseChannel(tagVersion) 190 + 191 + const channelVersions = allVersions 192 + .filter((v) => { 193 + const vParsed = parseVersion(v.version) 194 + const vChannel = getPrereleaseChannel(v.version) 195 + return vParsed.major === tagParsed.major && vChannel === tagChannel 196 + }) 197 + .sort((a, b) => compareVersions(b.version, a.version)) 198 + .map(v => ({ 199 + version: v.version, 200 + time: v.time, 201 + tag: versionToTag.value.get(v.version), 202 + hasProvenance: v.hasProvenance, 203 + })) 204 + 205 + row.allVersions = channelVersions 206 + 207 + for (const v of channelVersions) { 208 + claimedVersions.add(v.version) 209 + } 210 + } 211 + 212 + // Group unclaimed versions by major 213 + const byMajor = new Map<number, VersionDisplay[]>() 214 + 215 + for (const v of allVersions) { 216 + if (claimedVersions.has(v.version)) continue 217 + 218 + const major = parseVersion(v.version).major 219 + if (!byMajor.has(major)) { 220 + byMajor.set(major, []) 221 + } 222 + byMajor.get(major)!.push({ 223 + version: v.version, 224 + time: v.time, 225 + tag: versionToTag.value.get(v.version), 226 + hasProvenance: v.hasProvenance, 227 + }) 228 + } 229 + 230 + // Sort within each major 231 + for (const versions of byMajor.values()) { 232 + versions.sort((a, b) => compareVersions(b.version, a.version)) 233 + } 234 + 235 + // Build major groups sorted by major descending 236 + const sortedMajors = Array.from(byMajor.keys()).sort((a, b) => b - a) 237 + otherMajorGroups.value = sortedMajors.map(major => ({ 238 + major, 239 + versions: byMajor.get(major)!, 240 + expanded: false, 241 + })) 242 + 243 + hasLoadedAll.value = true 244 + } 245 + 246 + // Expand a tag row 247 + async function expandTagRow(index: number) { 248 + const row = tagRows.value[index] 249 + if (!row) return 250 + 251 + if (row.expanded) { 252 + row.expanded = false 253 + return 254 + } 255 + 256 + if (!hasLoadedAll.value) { 257 + row.loading = true 258 + try { 259 + const allVersions = await loadAllVersions() 260 + processLoadedVersions(allVersions) 261 + } 262 + catch (error) { 263 + console.error('Failed to load versions:', error) 264 + } 265 + finally { 266 + row.loading = false 267 + } 268 + } 269 + 270 + row.expanded = true 271 + } 272 + 273 + // Expand "Other versions" section 274 + async function expandOtherVersions() { 275 + if (otherVersionsExpanded.value) { 276 + otherVersionsExpanded.value = false 277 + return 278 + } 279 + 280 + if (!hasLoadedAll.value) { 281 + try { 282 + const allVersions = await loadAllVersions() 283 + processLoadedVersions(allVersions) 284 + } 285 + catch (error) { 286 + console.error('Failed to load versions:', error) 287 + } 288 + } 289 + 290 + otherVersionsExpanded.value = true 291 + } 292 + 293 + // Toggle a major group 294 + function toggleMajorGroup(index: number) { 295 + const group = otherMajorGroups.value[index] 296 + if (group) { 297 + group.expanded = !group.expanded 298 + } 299 + } 300 + 301 + function formatDate(dateStr: string): string { 302 + return new Date(dateStr).toLocaleDateString('en-US', { 303 + year: 'numeric', 304 + month: 'short', 305 + day: 'numeric', 306 + }) 307 + } 308 + </script> 309 + 310 + <template> 311 + <section 312 + v-if="tagRows.length > 0" 313 + aria-labelledby="versions-heading" 314 + > 315 + <h2 316 + id="versions-heading" 317 + class="text-xs text-fg-subtle uppercase tracking-wider mb-3" 318 + > 319 + Versions 320 + </h2> 321 + 322 + <div class="space-y-0.5"> 323 + <!-- Dist-tag rows --> 324 + <div 325 + v-for="(row, index) in tagRows" 326 + :key="row.id" 327 + > 328 + <div class="flex items-center gap-2"> 329 + <!-- Expand button (only if there are more versions to show) --> 330 + <button 331 + v-if="row.allVersions.length > 1 || !hasLoadedAll" 332 + type="button" 333 + class="w-4 h-4 flex items-center justify-center text-fg-subtle hover:text-fg transition-colors" 334 + :aria-expanded="row.expanded" 335 + :aria-label="row.expanded ? `Collapse ${row.tag}` : `Expand ${row.tag}`" 336 + @click="expandTagRow(index)" 337 + > 338 + <span 339 + v-if="row.loading" 340 + class="i-carbon-rotate w-3 h-3 animate-spin" 341 + /> 342 + <span 343 + v-else 344 + class="w-3 h-3 transition-transform duration-200" 345 + :class="row.expanded ? 'i-carbon-chevron-down' : 'i-carbon-chevron-right'" 346 + /> 347 + </button> 348 + <span 349 + v-else 350 + class="w-4" 351 + /> 352 + 353 + <!-- Version info --> 354 + <div class="flex-1 flex items-center justify-between py-1.5 text-sm gap-2 min-w-0"> 355 + <div class="flex items-center gap-2 min-w-0"> 356 + <NuxtLink 357 + :to="`/package/${packageName}/v/${row.primaryVersion.version}`" 358 + class="font-mono text-fg-muted hover:text-fg transition-colors duration-200 truncate" 359 + > 360 + {{ row.primaryVersion.version }} 361 + </NuxtLink> 362 + <span class="px-1.5 py-0.5 text-[10px] font-semibold text-fg-subtle bg-bg-muted border border-border rounded shrink-0"> 363 + {{ row.tag }} 364 + </span> 365 + </div> 366 + <div class="flex items-center gap-2 shrink-0"> 367 + <time 368 + v-if="row.primaryVersion.time" 369 + :datetime="row.primaryVersion.time" 370 + class="text-xs text-fg-subtle" 371 + > 372 + {{ formatDate(row.primaryVersion.time) }} 373 + </time> 374 + <ProvenanceBadge 375 + v-if="row.primaryVersion.hasProvenance" 376 + :package-name="packageName" 377 + :version="row.primaryVersion.version" 378 + compact 379 + /> 380 + </div> 381 + </div> 382 + </div> 383 + 384 + <!-- Expanded versions --> 385 + <div 386 + v-if="row.expanded && row.allVersions.length > 1" 387 + class="ml-4 pl-2 border-l border-border space-y-0.5" 388 + > 389 + <div 390 + v-for="v in row.allVersions.slice(1)" 391 + :key="v.version" 392 + class="flex items-center justify-between py-1 text-sm gap-2" 393 + > 394 + <div class="flex items-center gap-2 min-w-0"> 395 + <NuxtLink 396 + :to="`/package/${packageName}/v/${v.version}`" 397 + class="font-mono text-xs text-fg-subtle hover:text-fg-muted transition-colors duration-200 truncate" 398 + > 399 + {{ v.version }} 400 + </NuxtLink> 401 + <span 402 + v-if="v.tag && v.tag !== row.tag" 403 + class="px-1 py-0.5 text-[9px] font-semibold text-fg-subtle bg-bg-muted border border-border rounded shrink-0" 404 + > 405 + {{ v.tag }} 406 + </span> 407 + </div> 408 + <div class="flex items-center gap-2 shrink-0"> 409 + <time 410 + v-if="v.time" 411 + :datetime="v.time" 412 + class="text-[10px] text-fg-subtle" 413 + > 414 + {{ formatDate(v.time) }} 415 + </time> 416 + <ProvenanceBadge 417 + v-if="v.hasProvenance" 418 + :package-name="packageName" 419 + :version="v.version" 420 + compact 421 + /> 422 + </div> 423 + </div> 424 + </div> 425 + </div> 426 + 427 + <!-- Other versions section --> 428 + <div class="pt-1"> 429 + <button 430 + type="button" 431 + class="flex items-center gap-2 text-left" 432 + :aria-expanded="otherVersionsExpanded" 433 + @click="expandOtherVersions" 434 + > 435 + <span class="w-4 h-4 flex items-center justify-center text-fg-subtle hover:text-fg transition-colors"> 436 + <span 437 + v-if="loadingVersions && !hasLoadedAll" 438 + class="i-carbon-rotate w-3 h-3 animate-spin" 439 + /> 440 + <span 441 + v-else 442 + class="w-3 h-3 transition-transform duration-200" 443 + :class="otherVersionsExpanded ? 'i-carbon-chevron-down' : 'i-carbon-chevron-right'" 444 + /> 445 + </span> 446 + <span class="text-xs text-fg-muted py-1.5"> 447 + Other versions 448 + </span> 449 + </button> 450 + 451 + <!-- Expanded other versions --> 452 + <div 453 + v-if="otherVersionsExpanded" 454 + class="ml-4 pl-2 border-l border-border space-y-0.5" 455 + > 456 + <template v-if="otherMajorGroups.length > 0"> 457 + <div 458 + v-for="(group, groupIndex) in otherMajorGroups" 459 + :key="group.major" 460 + > 461 + <!-- Major group header --> 462 + <button 463 + v-if="group.versions.length > 1" 464 + type="button" 465 + class="flex items-center gap-2 w-full text-left py-1" 466 + :aria-expanded="group.expanded" 467 + @click="toggleMajorGroup(groupIndex)" 468 + > 469 + <span 470 + class="w-3 h-3 transition-transform duration-200 text-fg-subtle" 471 + :class="group.expanded ? 'i-carbon-chevron-down' : 'i-carbon-chevron-right'" 472 + /> 473 + <span class="font-mono text-xs text-fg-muted"> 474 + {{ group.versions[0]?.version }} 475 + </span> 476 + <span 477 + v-if="group.versions[0]?.tag" 478 + class="px-1 py-0.5 text-[9px] font-semibold text-fg-subtle bg-bg-muted border border-border rounded shrink-0" 479 + > 480 + {{ group.versions[0].tag }} 481 + </span> 482 + </button> 483 + <!-- Single version (no expand needed) --> 484 + <div 485 + v-else 486 + class="flex items-center gap-2 py-1" 487 + > 488 + <span class="w-3" /> 489 + <NuxtLink 490 + :to="`/package/${packageName}/v/${group.versions[0]?.version}`" 491 + class="font-mono text-xs text-fg-muted hover:text-fg transition-colors duration-200" 492 + > 493 + {{ group.versions[0]?.version }} 494 + </NuxtLink> 495 + <span 496 + v-if="group.versions[0]?.tag" 497 + class="px-1 py-0.5 text-[9px] font-semibold text-fg-subtle bg-bg-muted border border-border rounded shrink-0" 498 + > 499 + {{ group.versions[0].tag }} 500 + </span> 501 + </div> 502 + 503 + <!-- Major group versions --> 504 + <div 505 + v-if="group.expanded && group.versions.length > 1" 506 + class="ml-5 space-y-0.5" 507 + > 508 + <div 509 + v-for="v in group.versions.slice(1)" 510 + :key="v.version" 511 + class="flex items-center justify-between py-1 text-sm gap-2" 512 + > 513 + <div class="flex items-center gap-2 min-w-0"> 514 + <NuxtLink 515 + :to="`/package/${packageName}/v/${v.version}`" 516 + class="font-mono text-xs text-fg-subtle hover:text-fg-muted transition-colors duration-200 truncate" 517 + > 518 + {{ v.version }} 519 + </NuxtLink> 520 + <span 521 + v-if="v.tag" 522 + class="px-1 py-0.5 text-[9px] font-semibold text-fg-subtle bg-bg-muted border border-border rounded shrink-0" 523 + > 524 + {{ v.tag }} 525 + </span> 526 + </div> 527 + <div class="flex items-center gap-2 shrink-0"> 528 + <time 529 + v-if="v.time" 530 + :datetime="v.time" 531 + class="text-[10px] text-fg-subtle" 532 + > 533 + {{ formatDate(v.time) }} 534 + </time> 535 + <ProvenanceBadge 536 + v-if="v.hasProvenance" 537 + :package-name="packageName" 538 + :version="v.version" 539 + compact 540 + /> 541 + </div> 542 + </div> 543 + </div> 544 + </div> 545 + </template> 546 + <div 547 + v-else-if="hasLoadedAll" 548 + class="py-1 text-xs text-fg-subtle" 549 + > 550 + All versions are covered by tags above 551 + </div> 552 + </div> 553 + </div> 554 + </div> 555 + </section> 556 + </template>
+16 -17
app/composables/useNpmRegistry.ts
··· 68 68 return encodeURIComponent(name) 69 69 } 70 70 71 - /** Maximum number of versions to include in the client payload */ 72 - const MAX_VERSIONS = 20 71 + /** Number of recent versions to include in initial payload */ 72 + const RECENT_VERSIONS_COUNT = 5 73 73 74 74 /** 75 75 * Transform a full Packument into a slimmed version for client-side use. 76 76 * Reduces payload size by: 77 77 * - Removing readme (fetched separately) 78 - * - Limiting versions to recent MAX_VERSIONS 78 + * - Including only: 5 most recent versions + one version per dist-tag 79 79 * - Stripping unnecessary fields from version objects 80 80 */ 81 81 function transformPackument(pkg: Packument): SlimPackument { 82 - // Sort versions by publish time (newest first) 83 - const sortedVersionKeys = Object.keys(pkg.versions) 84 - .filter(v => pkg.time[v]) // Only versions with timestamps 82 + // Get versions pointed to by dist-tags 83 + const distTagVersions = new Set(Object.values(pkg['dist-tags'] ?? {})) 84 + 85 + // Get 5 most recent versions by publish time 86 + const recentVersions = Object.keys(pkg.versions) 87 + .filter(v => pkg.time[v]) 85 88 .sort((a, b) => { 86 89 const timeA = pkg.time[a] 87 90 const timeB = pkg.time[b] 88 91 if (!timeA || !timeB) return 0 89 92 return new Date(timeB).getTime() - new Date(timeA).getTime() 90 93 }) 91 - .slice(0, MAX_VERSIONS) 94 + .slice(0, RECENT_VERSIONS_COUNT) 92 95 93 - // Always include the latest dist-tag version even if not in top MAX_VERSIONS 94 - const latestTag = pkg['dist-tags']?.latest 95 - if (latestTag && !sortedVersionKeys.includes(latestTag)) { 96 - sortedVersionKeys.push(latestTag) 97 - } 96 + // Combine: recent versions + dist-tag versions (deduplicated) 97 + const includedVersions = new Set([...recentVersions, ...distTagVersions]) 98 98 99 99 // Build filtered versions object 100 100 const filteredVersions: Record<string, PackumentVersion> = {} 101 - for (const v of sortedVersionKeys) { 101 + for (const v of includedVersions) { 102 102 const version = pkg.versions[v] 103 103 if (version) { 104 104 // Strip readme and scripts from each version to reduce size ··· 107 107 } 108 108 } 109 109 110 - // Build filtered time object (only for included versions) 110 + // Build filtered time object (only for included versions + metadata) 111 111 const filteredTime: Record<string, string> = {} 112 112 if (pkg.time.modified) filteredTime.modified = pkg.time.modified 113 113 if (pkg.time.created) filteredTime.created = pkg.time.created 114 - for (const v of sortedVersionKeys) { 114 + for (const v of includedVersions) { 115 115 if (pkg.time[v]) filteredTime[v] = pkg.time[v] 116 116 } 117 117 ··· 138 138 139 139 return useLazyAsyncData( 140 140 () => `package:${toValue(name)}`, 141 - () => registry.fetchPackage(toValue(name)), 142 - { transform: transformPackument }, 141 + () => registry.fetchPackage(toValue(name)).then(r => transformPackument(r)), 143 142 ) 144 143 } 145 144
+17 -59
app/pages/package/[...name].vue
··· 44 44 45 45 const { data: downloads } = usePackageDownloads(packageName, 'last-week') 46 46 47 - const { data: readmeData } = useLazyFetch(() => `/api/registry/readme/${packageName.value}`, { 48 - key: `readme:${packageName.value}`, 47 + // Fetch README for specific version if requested, otherwise latest 48 + const readmeUrl = computed(() => { 49 + const base = `/api/registry/readme/${packageName.value}` 50 + const version = requestedVersion.value 51 + return version ? `${base}/v/${version}` : base 52 + }) 53 + 54 + const { data: readmeData } = useLazyFetch(readmeUrl, { 55 + key: () => `readme:${packageName.value}:${requestedVersion.value ?? 'latest'}`, 49 56 default: () => ({ html: '' }), 50 57 }) 51 58 ··· 69 76 const latestTag = pkg.value['dist-tags']?.latest 70 77 if (!latestTag) return null 71 78 return pkg.value.versions[latestTag] ?? null 72 - }) 73 - 74 - const sortedVersions = computed(() => { 75 - if (!pkg.value?.versions) return [] 76 - return Object.keys(pkg.value.versions) 77 - .sort((a, b) => { 78 - const timeA = pkg.value?.time[a] 79 - const timeB = pkg.value?.time[b] 80 - if (!timeA || !timeB) return 0 81 - return new Date(timeB).getTime() - new Date(timeA).getTime() 82 - }) 83 - .slice(0, 20) 84 79 }) 85 80 86 81 const hasDependencies = computed(() => { ··· 636 631 </dl> 637 632 </section> 638 633 639 - <!-- Versions --> 640 - <section 641 - v-if="sortedVersions.length" 642 - aria-labelledby="versions-heading" 643 - > 644 - <h2 645 - id="versions-heading" 646 - class="text-xs text-fg-subtle uppercase tracking-wider mb-3" 647 - > 648 - Versions 649 - </h2> 650 - <div class="space-y-1"> 651 - <div 652 - v-for="version in sortedVersions.slice(0, 10)" 653 - :key="version" 654 - class="flex items-center justify-between py-1.5 text-sm gap-2" 655 - > 656 - <NuxtLink 657 - :to="`/package/${pkg.name}/v/${version}`" 658 - class="font-mono text-fg-muted hover:text-fg transition-colors duration-200 min-w-0" 659 - > 660 - {{ version }} 661 - <span 662 - v-if="pkg['dist-tags']?.latest === version" 663 - class="ml-1 text-xs text-fg-subtle" 664 - >(latest)</span> 665 - </NuxtLink> 666 - <div class="flex items-center gap-2 shrink-0"> 667 - <time 668 - v-if="pkg.time[version]" 669 - :datetime="pkg.time[version]" 670 - class="text-xs text-fg-subtle" 671 - > 672 - {{ formatDate(pkg.time[version]) }} 673 - </time> 674 - <ProvenanceBadge 675 - v-if="pkg.versions[version] && hasProvenance(pkg.versions[version])" 676 - :package-name="pkg.name" 677 - :version="version" 678 - compact 679 - /> 680 - </div> 681 - </div> 682 - </div> 683 - </section> 634 + <!-- Versions (grouped by release channel) --> 635 + <PackageVersions 636 + v-if="pkg.versions && Object.keys(pkg.versions).length > 0" 637 + :package-name="pkg.name" 638 + :versions="pkg.versions" 639 + :dist-tags="pkg['dist-tags'] ?? {}" 640 + :time="pkg.time" 641 + /> 684 642 685 643 <!-- Dependencies --> 686 644 <PackageDependencies
+53 -9
server/api/registry/readme/[...pkg].get.ts
··· 1 - async function fetchReadmeFromJsdelivr(packageName: string): Promise<string | null> { 2 - // Try common README filenames 1 + /** 2 + * Fetch README from jsdelivr CDN for a specific package version. 3 + * Falls back through common README filenames. 4 + */ 5 + async function fetchReadmeFromJsdelivr(packageName: string, version?: string): Promise<string | null> { 3 6 const filenames = ['README.md', 'readme.md', 'Readme.md', 'README', 'readme'] 7 + const versionSuffix = version ? `@${version}` : '' 4 8 5 9 for (const filename of filenames) { 6 10 try { 7 - const url = `https://cdn.jsdelivr.net/npm/${packageName}/${filename}` 11 + const url = `https://cdn.jsdelivr.net/npm/${packageName}${versionSuffix}/${filename}` 8 12 const response = await fetch(url) 9 13 if (response.ok) { 10 14 return await response.text() ··· 18 22 return null 19 23 } 20 24 25 + /** 26 + * Returns rendered README HTML for a package. 27 + * 28 + * URL patterns: 29 + * - /api/registry/readme/packageName - latest version 30 + * - /api/registry/readme/packageName/v/1.2.3 - specific version 31 + * - /api/registry/readme/@scope/packageName - scoped package, latest 32 + * - /api/registry/readme/@scope/packageName/v/1.2.3 - scoped package, specific version 33 + */ 21 34 export default defineCachedEventHandler( 22 35 async (event) => { 23 - const pkg = getRouterParam(event, 'pkg') 24 - if (!pkg) { 36 + const segments = getRouterParam(event, 'pkg')?.split('/') ?? [] 37 + if (segments.length === 0) { 25 38 throw createError({ statusCode: 400, message: 'Package name is required' }) 26 39 } 27 40 28 - const packageName = pkg.replace(/\//g, '/') 41 + // Parse package name and optional version from URL segments 42 + // Patterns: [pkg] or [pkg, 'v', version] or [@scope, pkg] or [@scope, pkg, 'v', version] 43 + let packageName: string 44 + let version: string | undefined 45 + 46 + const vIndex = segments.indexOf('v') 47 + if (vIndex !== -1 && vIndex < segments.length - 1) { 48 + packageName = segments.slice(0, vIndex).join('/') 49 + version = segments.slice(vIndex + 1).join('/') 50 + } 51 + else { 52 + packageName = segments.join('/') 53 + } 54 + 55 + if (!packageName) { 56 + throw createError({ statusCode: 400, message: 'Package name is required' }) 57 + } 29 58 30 59 try { 31 60 const packageData = await fetchNpmPackage(packageName) 32 61 33 - let readmeContent = packageData.readme 62 + let readmeContent: string | undefined 63 + 64 + // If a specific version is requested, get README from that version 65 + if (version) { 66 + const versionData = packageData.versions[version] 67 + if (versionData) { 68 + readmeContent = versionData.readme 69 + } 70 + } 71 + else { 72 + // Use the packument-level readme (from latest version) 73 + readmeContent = packageData.readme 74 + } 34 75 35 76 // If no README in packument, try fetching from jsdelivr (package tarball) 36 77 if (!readmeContent || readmeContent === 'ERROR: No README data found!') { 37 - readmeContent = await fetchReadmeFromJsdelivr(packageName) ?? undefined 78 + readmeContent = await fetchReadmeFromJsdelivr(packageName, version) ?? undefined 38 79 } 39 80 40 81 if (!readmeContent) { ··· 53 94 }, 54 95 { 55 96 maxAge: 60 * 10, 56 - getKey: event => `readme:${getRouterParam(event, 'pkg') ?? ''}`, 97 + getKey: (event) => { 98 + const pkg = getRouterParam(event, 'pkg') ?? '' 99 + return `readme:${pkg}` 100 + }, 57 101 }, 58 102 )
+13 -4
shared/types/npm-registry.ts
··· 13 13 * Slimmed down Packument for client-side use. 14 14 * Strips unnecessary fields to reduce payload size. 15 15 * - readme removed (fetched separately) 16 - * - versions limited to recent 20 17 - * - time limited to recent 20 versions 16 + * - versions limited to dist-tag versions only 17 + * - time limited to dist-tag versions 18 18 */ 19 19 export interface SlimPackument { 20 20 '_id': string ··· 22 22 'name': string 23 23 'description'?: string 24 24 'dist-tags': { latest?: string } & Record<string, string> 25 - /** Only includes time for recent versions + modified/created */ 25 + /** Only includes time for dist-tag versions + modified/created */ 26 26 'time': { modified?: string, created?: string } & Record<string, string> 27 27 'maintainers'?: NpmPerson[] 28 28 'author'?: NpmPerson ··· 31 31 'keywords'?: string[] 32 32 'repository'?: { type?: string, url?: string, directory?: string } 33 33 'bugs'?: { url?: string, email?: string } 34 - /** Only includes recent 20 versions */ 34 + /** Only includes dist-tag versions */ 35 35 'versions': Record<string, import('@npm/types').PackumentVersion> 36 + } 37 + 38 + /** 39 + * Lightweight version info for the version list 40 + */ 41 + export interface PackageVersionInfo { 42 + version: string 43 + time?: string 44 + hasProvenance: boolean 36 45 } 37 46 38 47 /**