From f3500cd34668b9c2e4c9bb5bee98d8af4be7728c Mon Sep 17 00:00:00 2001 From: Vordgi Date: Sun, 7 Jun 2026 14:44:03 +0100 Subject: [PATCH 01/17] feat: support custom version in use-package-comparison hook --- app/composables/usePackageComparison.ts | 57 +++++++-- modules/runtime/server/cache.ts | 39 +----- shared/utils/parse-package-param.ts | 47 +++++++ .../use-package-comparison.spec.ts | 121 ++++++++++++++++++ 4 files changed, 215 insertions(+), 49 deletions(-) diff --git a/app/composables/usePackageComparison.ts b/app/composables/usePackageComparison.ts index 568e24787d..48244faddd 100644 --- a/app/composables/usePackageComparison.ts +++ b/app/composables/usePackageComparison.ts @@ -1,4 +1,5 @@ import { normalizeLicense } from '#shared/utils/npm' +import { parsePackageSpec } from '#shared/utils/parse-package-param' import { getDependencyCount } from '~/utils/npm/dependency-count' /** Special identifier for the "What Would James Do?" comparison column */ @@ -60,9 +61,25 @@ export interface PackageComparisonData { isNoDependency?: boolean } +/** + * Resolve a requested version (exact version or dist-tag) against a packument. + * + * @returns The concrete version string, or `undefined` if it cannot be resolved. + */ +function resolveRequestedVersion(pkgData: Packument, requested?: string): string | undefined { + if (!requested) return pkgData['dist-tags']?.latest + // Allow dist-tags such as "next" or "beta" in addition to exact versions. + const fromTag = pkgData['dist-tags']?.[requested] + if (fromTag) return fromTag + if (pkgData.versions?.[requested]) return requested + return undefined +} + /** * Composable for fetching and comparing multiple packages. * + * Each entry may pin a version using the npm spec syntax (e.g. `nuxt`, `react@18.2.0` + * or `vue@3.6.0`). When no version is given the latest is used */ export function usePackageComparison(packageNames: MaybeRefOrGetter) { const { t } = useI18n() @@ -118,25 +135,35 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { try { // First pass: fetch fast data (package info, downloads, analysis, vulns) const results = await Promise.all( - namesToFetch.map(async (name): Promise => { + namesToFetch.map(async (spec): Promise => { try { + // A spec may include a version (vue@3.6.0) + const { name, version: requestedVersion } = parsePackageSpec(spec) + // Fetch basic package info first (required) const { data: pkgData } = await $npmRegistry(`/${encodePackageName(name)}`) - const latestVersion = pkgData['dist-tags']?.latest - if (!latestVersion) return null + const resolvedVersion = resolveRequestedVersion(pkgData, requestedVersion) + if (!resolvedVersion) return null + + // Only target a specific version on the API endpoints when one was + // explicitly requested + const versionSuffix = requestedVersion + ? `/v/${encodeURIComponent(resolvedVersion)}` + : '' // Fetch fast additional data in parallel (optional - failures are ok) const repoInfo = parseRepositoryInfo(pkgData.repository) const isGitHub = repoInfo?.provider === 'github' const [downloads, analysis, vulns, likes, ghStars, ghIssues] = await Promise.all([ + // Download counts are per-package, not per-version. $fetch<{ downloads: number }>( `https://api.npmjs.org/downloads/point/last-week/${encodePackageName(name)}`, ).catch(() => null), $fetch( - `/api/registry/analysis/${encodePackageName(name)}`, + `/api/registry/analysis/${encodePackageName(name)}${versionSuffix}`, ).catch(() => null), $fetch( - `/api/registry/vulnerabilities/${encodePackageName(name)}`, + `/api/registry/vulnerabilities/${encodePackageName(name)}${versionSuffix}`, ).catch(() => null), $fetch(`/api/social/likes/${encodePackageName(name)}`).catch( () => null, @@ -156,7 +183,7 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { .catch(() => null) : Promise.resolve(null), ]) - const versionData = pkgData.versions[latestVersion] + const versionData = pkgData.versions[resolvedVersion] const packageSize = versionData?.dist?.unpackedSize // Detect if package is binary-only @@ -181,7 +208,7 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { return { package: { name: pkgData.name, - version: latestVersion, + version: resolvedVersion, description: undefined, }, downloads: downloads?.downloads, @@ -197,7 +224,7 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { license: normalizeLicense(pkgData.license), // Use version-specific publish time, NOT time.modified (which can be // updated by metadata changes like maintainer additions) - lastUpdated: pkgData.time?.[latestVersion], + lastUpdated: pkgData.time?.[resolvedVersion], createdAt: pkgData.time?.created, engines: analysis?.engines, deprecated: versionData?.deprecated, @@ -230,19 +257,25 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { // Second pass: fetch slow install size data in background for new packages installSizeLoading.value = true Promise.all( - namesToFetch.map(async name => { + namesToFetch.map(async spec => { try { + const { name, version: requestedVersion } = parsePackageSpec(spec) + // Reuse the version resolved during the first pass when one was pinned. + const resolvedVersion = cache.value.get(spec)?.package.version + const versionSuffix = + requestedVersion && resolvedVersion ? `/v/${encodeURIComponent(resolvedVersion)}` : '' + const installSize = await $fetch<{ selfSize: number totalSize: number dependencyCount: number - }>(`/api/registry/install-size/${encodePackageName(name)}`) + }>(`/api/registry/install-size/${encodePackageName(name)}${versionSuffix}`) // Update cache with install size - const existing = cache.value.get(name) + const existing = cache.value.get(spec) if (existing) { const updated = new Map(cache.value) - updated.set(name, { ...existing, installSize }) + updated.set(spec, { ...existing, installSize }) cache.value = updated } } catch { diff --git a/modules/runtime/server/cache.ts b/modules/runtime/server/cache.ts index b00279266f..d5efaa8d53 100644 --- a/modules/runtime/server/cache.ts +++ b/modules/runtime/server/cache.ts @@ -1,5 +1,6 @@ import process from 'node:process' import type { CachedFetchResult } from '#shared/utils/fetch-cache-config' +import { parsePackageSpec } from '#shared/utils/parse-package-param' import { createFetch } from 'ofetch' /** @@ -65,42 +66,6 @@ function getFixturePath(type: FixtureType, name: string): string { return `${dir}:${filename.replace(/\//g, ':')}` } -/** - * Parse a scoped package name with optional version. - * Handles formats like: @scope/name, @scope/name@version, name, name@version - */ -function parseScopedPackageWithVersion(input: string): { name: string; version?: string } { - if (input.startsWith('@')) { - // Scoped package: @scope/name or @scope/name@version - const slashIndex = input.indexOf('/') - if (slashIndex === -1) { - // Invalid format like just "@scope" - return { name: input } - } - const afterSlash = input.slice(slashIndex + 1) - const atIndex = afterSlash.indexOf('@') - if (atIndex === -1) { - // @scope/name (no version) - return { name: input } - } - // @scope/name@version - return { - name: input.slice(0, slashIndex + 1 + atIndex), - version: afterSlash.slice(atIndex + 1), - } - } - - // Unscoped package: name or name@version - const atIndex = input.indexOf('@') - if (atIndex === -1) { - return { name: input } - } - return { - name: input.slice(0, atIndex), - version: input.slice(atIndex + 1), - } -} - function getMockForUrl(url: string): MockResult | null { const urlObj = URL.parse(url) if (!urlObj) return null @@ -563,7 +528,7 @@ async function handleJsdelivrDataApi( const packageMatch = decodeURIComponent(urlObj.pathname).match(/^\/v1\/packages\/npm\/(.+)$/) if (!packageMatch?.[1]) return null - const parsed = parseScopedPackageWithVersion(packageMatch[1]) + const parsed = parsePackageSpec(packageMatch[1]) // Try per-package fixture first const fixturePath = getFixturePath('jsdelivr', parsed.name) diff --git a/shared/utils/parse-package-param.ts b/shared/utils/parse-package-param.ts index 310fcd2fd0..7ea86c73b8 100644 --- a/shared/utils/parse-package-param.ts +++ b/shared/utils/parse-package-param.ts @@ -58,3 +58,50 @@ export function parsePackageParam(pkgParam: string): ParsedPackageParams { rest: [], } } + +/** + * Parse a package spec into its name and optional version. + * Handles formats like: `name`, `name@version`, `@scope/name`, `@scope/name@version`. + * + * The version segment may be an exact version or a dist-tag (e.g. `next`); it is + * returned verbatim for the caller to resolve. + * + * @example + * ```ts + * parsePackageSpec('react') // { name: 'react' } + * parsePackageSpec('react@18.2.0') // { name: 'react', version: '18.2.0' } + * parsePackageSpec('@vue/reactivity') // { name: '@vue/reactivity' } + * parsePackageSpec('@vue/reactivity@3.4.0') // { name: '@vue/reactivity', version: '3.4.0' } + * ``` + */ +export function parsePackageSpec(input: string): { name: string; version?: string } { + if (input.startsWith('@')) { + // Scoped package: @scope/name or @scope/name@version + const slashIndex = input.indexOf('/') + if (slashIndex === -1) { + // Invalid format like just "@scope" + return { name: input } + } + const afterSlash = input.slice(slashIndex + 1) + const atIndex = afterSlash.indexOf('@') + if (atIndex === -1) { + // @scope/name (no version) + return { name: input } + } + // @scope/name@version + return { + name: input.slice(0, slashIndex + 1 + atIndex), + version: afterSlash.slice(atIndex + 1), + } + } + + // Unscoped package: name or name@version + const atIndex = input.indexOf('@') + if (atIndex === -1) { + return { name: input } + } + return { + name: input.slice(0, atIndex), + version: input.slice(atIndex + 1), + } +} diff --git a/test/nuxt/composables/use-package-comparison.spec.ts b/test/nuxt/composables/use-package-comparison.spec.ts index 8e838f3181..36d6a30546 100644 --- a/test/nuxt/composables/use-package-comparison.spec.ts +++ b/test/nuxt/composables/use-package-comparison.spec.ts @@ -302,6 +302,127 @@ describe('usePackageComparison', () => { }) }) + describe('custom version', () => { + it('fetches version-specific data when a version is pinned in the spec', async () => { + const requestedUrls: string[] = [] + vi.stubGlobal( + '$fetch', + vi.fn().mockImplementation((url: string, options?: { baseURL?: string }) => { + const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url + requestedUrls.push(fullUrl) + if (fullUrl.startsWith('https://registry.npmjs.org/')) { + return Promise.resolve({ + 'name': 'test-package', + 'dist-tags': { latest: '2.0.0' }, + 'time': { + 'modified': '2025-01-01T00:00:00.000Z', + '1.0.0': '2022-03-20T00:00:00.000Z', + '2.0.0': '2025-01-01T00:00:00.000Z', + }, + 'license': 'MIT', + 'versions': { + '1.0.0': { dist: { unpackedSize: 10000 } }, + '2.0.0': { dist: { unpackedSize: 20000 } }, + }, + }) + } + if (fullUrl.includes('/api/registry/install-size/')) { + return Promise.resolve({ selfSize: 1, totalSize: 2, dependencyCount: 3 }) + } + return Promise.resolve(null) + }), + ) + + const { packagesData, status, getFacetValues } = await usePackageComparisonInComponent([ + 'test-package@1.0.0', + ]) + + await vi.waitFor(() => { + expect(status.value).toBe('success') + }) + + // Uses the pinned version, not the latest dist-tag + expect(packagesData.value[0]?.package.version).toBe('1.0.0') + + // Version-specific metadata and size + expect(packagesData.value[0]?.metadata?.lastUpdated).toBe('2022-03-20T00:00:00.000Z') + expect(getFacetValues('packageSize')[0]?.raw).toBe(10000) + + // Version-aware endpoints are hit with the /v/ segment + expect(requestedUrls).toContainEqual( + expect.stringContaining('/api/registry/analysis/test-package/v/1.0.0'), + ) + expect(requestedUrls).toContainEqual( + expect.stringContaining('/api/registry/vulnerabilities/test-package/v/1.0.0'), + ) + await vi.waitFor(() => { + expect(requestedUrls).toContainEqual( + expect.stringContaining('/api/registry/install-size/test-package/v/1.0.0'), + ) + }) + }) + + it('resolves a dist-tag spec to its concrete version', async () => { + vi.stubGlobal( + '$fetch', + vi.fn().mockImplementation((url: string, options?: { baseURL?: string }) => { + const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url + if (fullUrl.startsWith('https://registry.npmjs.org/')) { + return Promise.resolve({ + 'name': 'test-package', + 'dist-tags': { latest: '1.0.0', next: '2.0.0-beta.1' }, + 'time': { + '1.0.0': '2024-01-01T00:00:00.000Z', + '2.0.0-beta.1': '2025-01-01T00:00:00.000Z', + }, + 'license': 'MIT', + 'versions': { + '1.0.0': { dist: { unpackedSize: 10000 } }, + '2.0.0-beta.1': { dist: { unpackedSize: 20000 } }, + }, + }) + } + return Promise.resolve(null) + }), + ) + + const { packagesData, status } = await usePackageComparisonInComponent(['test-package@next']) + + await vi.waitFor(() => { + expect(status.value).toBe('success') + }) + + expect(packagesData.value[0]?.package.version).toBe('2.0.0-beta.1') + }) + + it('returns null when the pinned version cannot be resolved', async () => { + vi.stubGlobal( + '$fetch', + vi.fn().mockImplementation((url: string, options?: { baseURL?: string }) => { + const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url + if (fullUrl.startsWith('https://registry.npmjs.org/')) { + return Promise.resolve({ + 'name': 'test-package', + 'dist-tags': { latest: '1.0.0' }, + 'versions': { + '1.0.0': { dist: { unpackedSize: 10000 } }, + }, + }) + } + return Promise.resolve(null) + }), + ) + + const { packagesData, status } = await usePackageComparisonInComponent(['test-package@9.9.9']) + + await vi.waitFor(() => { + expect(status.value).toBe('success') + }) + + expect(packagesData.value[0]).toBeNull() + }) + }) + describe('createdAt facet', () => { it('displays the creation date without status', async () => { const createdDate = '2020-01-01T00:00:00.000Z' From 4f173c9b4ee9759841d2105dafdb8b406f375e1a Mon Sep 17 00:00:00 2001 From: Vordgi Date: Sun, 7 Jun 2026 18:40:44 +0100 Subject: [PATCH 02/17] feat: add stats tab --- app/components/AppFooter.vue | 4 + app/components/Package/Header.vue | 17 +- .../useCommandPalettePackageCommands.ts | 10 ++ .../package-stats/[[org]]/[packageName].vue | 145 ++++++++++++++++++ app/utils/router.ts | 13 ++ docs/content/2.guide/2.keyboard-shortcuts.md | 1 + i18n/locales/en.json | 12 +- i18n/schema.json | 18 +++ 8 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 app/pages/package-stats/[[org]]/[packageName].vue diff --git a/app/components/AppFooter.vue b/app/components/AppFooter.vue index d493ff2cd2..2ee2a47f19 100644 --- a/app/components/AppFooter.vue +++ b/app/components/AppFooter.vue @@ -244,6 +244,10 @@ const footerSections = computed>(( t {{ $t('shortcuts.open_timeline') }} +
  • + s + {{ $t('shortcuts.open_stats') }} +
  • c {{ $t('shortcuts.compare_from_package') }} diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue index b42f27ffd1..aa7c351292 100644 --- a/app/components/Package/Header.vue +++ b/app/components/Package/Header.vue @@ -11,7 +11,7 @@ const props = defineProps<{ latestVersion?: SlimVersion | null provenanceData?: ProvenanceDetails | null provenanceStatus?: string | null - page: 'main' | 'docs' | 'code' | 'diff' | 'changelog' | 'timeline' + page: 'main' | 'docs' | 'code' | 'diff' | 'changelog' | 'timeline' | 'stats' versionUrlPattern: string }>() @@ -182,6 +182,11 @@ const timelineLink = computed((): RouteLocationRaw | null => { return packageTimelineRoute(props.pkg.name, props.resolvedVersion) }) +const statsLink = computed((): RouteLocationRaw | null => { + if (props.pkg == null || props.resolvedVersion == null) return null + return packageStatsRoute(props.pkg.name, props.resolvedVersion) +}) + useShortcuts({ '.': () => codeLink.value, 'm': () => mainLink.value, @@ -190,6 +195,7 @@ useShortcuts({ 'f': () => diffLink.value, '-': () => changelogLink.value, 't': () => timelineLink.value, + 's': () => statsLink.value, }) @@ -370,6 +376,15 @@ useShortcuts({ > {{ $t('package.links.timeline') }} + + {{ $t('package.links.stats') }} + diff --git a/app/composables/useCommandPalettePackageCommands.ts b/app/composables/useCommandPalettePackageCommands.ts index acbb59364c..03f0b955c2 100644 --- a/app/composables/useCommandPalettePackageCommands.ts +++ b/app/composables/useCommandPalettePackageCommands.ts @@ -107,6 +107,16 @@ export function useCommandPalettePackageCommands( activeLabel: activeLabel(route.name === 'timeline', t('command_palette.here')), to: packageTimelineRoute(resolvedContext.packageName, resolvedContext.resolvedVersion), }, + { + id: 'package-stats', + group: 'package', + label: t('command_palette.package.stats'), + keywords: [resolvedContext.packageName, t('shortcuts.open_stats')], + iconClass: 'i-lucide:chart-bar', + active: route.name === 'stats', + activeLabel: activeLabel(route.name === 'stats', t('command_palette.here')), + to: packageStatsRoute(resolvedContext.packageName, resolvedContext.resolvedVersion), + }, ] const uChangelog = changelog.value diff --git a/app/pages/package-stats/[[org]]/[packageName].vue b/app/pages/package-stats/[[org]]/[packageName].vue new file mode 100644 index 0000000000..88c3022036 --- /dev/null +++ b/app/pages/package-stats/[[org]]/[packageName].vue @@ -0,0 +1,145 @@ + + + diff --git a/app/utils/router.ts b/app/utils/router.ts index c9f2c5247c..9db74d8ba9 100644 --- a/app/utils/router.ts +++ b/app/utils/router.ts @@ -94,3 +94,16 @@ export function packageTimelineRoute(packageName: string, version: string): Rout }, } } + +export function packageStatsRoute(packageName: string, version: string): RouteLocationRaw { + const { org, name } = splitPackageName(packageName) + + return { + name: 'stats', + params: { + org: org || undefined, + packageName: name, + version: version.replace(/\s+/g, ''), + }, + } +} diff --git a/docs/content/2.guide/2.keyboard-shortcuts.md b/docs/content/2.guide/2.keyboard-shortcuts.md index e299ba7571..1c35a31660 100644 --- a/docs/content/2.guide/2.keyboard-shortcuts.md +++ b/docs/content/2.guide/2.keyboard-shortcuts.md @@ -52,3 +52,4 @@ These shortcuts work anywhere on the site. Press `/` from any page to quickly se | `c` | Compare package | | `-` | Open changelog | | `t` | Open timeline | +| `s` | Open stats | diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 7ee25a08ce..24038b8108 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -46,7 +46,8 @@ "disable_shortcuts": "You can disable keyboard shortcuts in {settings}.", "open_main": "Open main information", "open_diff": "Open version differences", - "open_timeline": "Open timeline" + "open_timeline": "Open timeline", + "open_stats": "Open stats" }, "search": { "label": "Search npm packages", @@ -154,7 +155,8 @@ "diff": "Diff", "compare": "Compare this package", "download": "Download tarball", - "changelog": "Changelog" + "changelog": "Changelog", + "stats": "Stats" }, "package_actions": { "copy_run": "Copy run command" @@ -445,7 +447,10 @@ "size_tooltip": { "unpacked": "{size} unpacked size (this package)", "total": "{size} total unpacked size (including {count} dependency for linux-x64) | {size} total unpacked size (including all {count} dependencies for linux-x64)" - } + }, + "main_information": "Main Information", + "trends": "Trends", + "version_distribution": "Version Distribution" }, "skills": { "title": "Agent Skills", @@ -476,6 +481,7 @@ "fund": "fund", "compare": "compare", "timeline": "timeline", + "stats": "stats", "compare_this_package": "compare this package", "changelog": "changelog" }, diff --git a/i18n/schema.json b/i18n/schema.json index bdbf0d1bd2..d6c32c60b4 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -144,6 +144,9 @@ }, "open_timeline": { "type": "string" + }, + "open_stats": { + "type": "string" } }, "additionalProperties": false @@ -468,6 +471,9 @@ }, "changelog": { "type": "string" + }, + "stats": { + "type": "string" } }, "additionalProperties": false @@ -1341,6 +1347,15 @@ } }, "additionalProperties": false + }, + "main_information": { + "type": "string" + }, + "trends": { + "type": "string" + }, + "version_distribution": { + "type": "string" } }, "additionalProperties": false @@ -1432,6 +1447,9 @@ "timeline": { "type": "string" }, + "stats": { + "type": "string" + }, "compare_this_package": { "type": "string" }, From 73d9e575697bb50e752c3055734984d971bf7e21 Mon Sep 17 00:00:00 2001 From: Vordgi Date: Sun, 7 Jun 2026 18:56:22 +0100 Subject: [PATCH 03/17] feat: lowercase facet title --- app/pages/package-stats/[[org]]/[packageName].vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/package-stats/[[org]]/[packageName].vue b/app/pages/package-stats/[[org]]/[packageName].vue index 88c3022036..68c75ba281 100644 --- a/app/pages/package-stats/[[org]]/[packageName].vue +++ b/app/pages/package-stats/[[org]]/[packageName].vue @@ -103,7 +103,7 @@ useSeoMeta({ class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-y-2 gap-x-4 border-y-border border-y py-2" >
    -
    {{ row.label.toLowerCase() }}
    +
    {{ row.label }}
    From f5e821a1b161d8cf527ef25720801dd328d62e81 Mon Sep 17 00:00:00 2001 From: Vordgi Date: Sun, 7 Jun 2026 18:58:41 +0100 Subject: [PATCH 04/17] feat: render empty space on facet loading --- app/pages/package-stats/[[org]]/[packageName].vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/package-stats/[[org]]/[packageName].vue b/app/pages/package-stats/[[org]]/[packageName].vue index 68c75ba281..dc1beafb87 100644 --- a/app/pages/package-stats/[[org]]/[packageName].vue +++ b/app/pages/package-stats/[[org]]/[packageName].vue @@ -105,7 +105,7 @@ useSeoMeta({
    {{ row.label }}
    - + {{ formatFacetValue(row.value) From 77af1340b0541be5594c9ba2519f9a7c83c19885 Mon Sep 17 00:00:00 2001 From: Vordgi Date: Sun, 7 Jun 2026 19:03:27 +0100 Subject: [PATCH 05/17] chore: mention stats tab in docs --- README.md | 2 +- docs/content/1.getting-started/1.introduction.md | 4 ++-- docs/content/2.guide/1.features.md | 2 +- docs/content/2.guide/3.url-structure.md | 1 + docs/content/index.md | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 79d2c4f87f..a61932601d 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ What npmx offers: - **Dark mode and light mode** – plus customize the color palette to your preferences - **Translated interface** – localized UI across 39+ locales, including RTL support - **First-class accessibility** – accessible components, keyboard workflows, and automated axe/Lighthouse checks -- **URL-driven feature views** – share exact package versions, search results, compare sets, source files and lines, diffs, docs, changelogs, and timelines +- **URL-driven feature views** – share exact package versions, search results, compare sets, source files and lines, diffs, docs, changelogs, stats and timelines - **Fast search** – quick package search with instant results - **Package details** – READMEs, versions, dependencies, and metadata - **Code viewer** – browse package source code with syntax highlighting and permalink to specific lines diff --git a/docs/content/1.getting-started/1.introduction.md b/docs/content/1.getting-started/1.introduction.md index b052a90dd4..0a48c5ca63 100644 --- a/docs/content/1.getting-started/1.introduction.md +++ b/docs/content/1.getting-started/1.introduction.md @@ -16,7 +16,7 @@ npmx.dev is a fast, modern browser for the npm registry, focused on a great deve - **Search packages** - Quick package search with instant results and infinite scroll - **Browse source code** - View package source code with syntax highlighting - **Compare packages** - Evaluate package size, dependencies, types, security, and repository health side-by-side -- **Share exact views** - Package versions, code files, diffs, docs, changelogs, timelines, search, and compare states are URL-driven +- **Share exact views** - Package versions, code files, diffs, docs, changelogs, timelines, stats, search, and compare states are URL-driven - **Check security** - See vulnerability warnings and provenance indicators - **Manage packages and orgs** - Update package access, owners, organization members, and teams - **Use your language** - Browse a translated interface across dozens of locales @@ -30,7 +30,7 @@ Use npmx.dev when you want to: - Quickly find and evaluate npm packages - Browse package source code without downloading - Compare packages before adding a dependency -- Share a permalink to the exact package, code, docs, diff, changelog, timeline, search, or comparison view +- Share a permalink to the exact package, code, docs, diff, changelog, timeline, stats, search, or comparison view - Check package security and provenance - Manage npm package access and organization teams - View package metadata in a clean, translated, dark-mode interface diff --git a/docs/content/2.guide/1.features.md b/docs/content/2.guide/1.features.md index d957d2c561..758329252d 100644 --- a/docs/content/2.guide/1.features.md +++ b/docs/content/2.guide/1.features.md @@ -15,7 +15,7 @@ Open the command palette with `⌘K` on macOS, or `Ctrl+K` on Windows and Linux, ### Share exact views -npmx.dev keeps shareable state in the URL. Package versions, source files and line anchors, generated docs, version diffs, changelogs, timelines, search results, and package comparisons can all be copied and shared as direct links. +npmx.dev keeps shareable state in the URL. Package versions, source files and line anchors, generated docs, version diffs, changelogs, timelines, stats, search results, and package comparisons can all be copied and shared as direct links. ### View package details diff --git a/docs/content/2.guide/3.url-structure.md b/docs/content/2.guide/3.url-structure.md index cd1c82a8c4..9e0df4bbfe 100644 --- a/docs/content/2.guide/3.url-structure.md +++ b/docs/content/2.guide/3.url-structure.md @@ -49,6 +49,7 @@ npmx.dev keeps package exploration views in routes or query parameters so you ca | Version diff | `/diff/vue/v/3.5.26...3.5.27` | | Changelog | `/package-changelog/vue/v/3.5.27` | | Timeline | `/package-timeline/vue/v/3.5.27` | +| Stats | `/package-stats/vue/v/3.5.27` | ## Understand URL limitations diff --git a/docs/content/index.md b/docs/content/index.md index c0e3895c40..012bd7ce8f 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -39,7 +39,7 @@ links: :::u-page-feature{icon="i-lucide:git-compare" to="/guide/features" title="Compare packages" description="Compare package size, install size, dependencies, types, security, repository health, and social signals."} ::: -:::u-page-feature{icon="i-lucide:link" to="/guide/url-structure" title="Use familiar URLs" description="Replace npmjs.com with npmx.dev in any URL and share exact package, code, diff, docs, changelog, timeline, search, and compare views."} +:::u-page-feature{icon="i-lucide:link" to="/guide/url-structure" title="Use familiar URLs" description="Replace npmjs.com with npmx.dev in any URL and share exact package, code, diff, docs, changelog, timeline, stats, search, and compare views."} ::: :::u-page-feature{icon="i-lucide:keyboard" to="/guide/keyboard-shortcuts" title="Navigate with keyboard" description="Open the command palette with ⌘K on macOS or Ctrl+K on Windows and Linux. Press / to search. Use arrow keys to browse results."} From 6e4f03ce4d5740623c9a6f27a0d6e0b23d156e2d Mon Sep 17 00:00:00 2001 From: Vordgi Date: Sun, 7 Jun 2026 19:11:23 +0100 Subject: [PATCH 06/17] chore: show loader on stats page --- app/pages/package-stats/[[org]]/[packageName].vue | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/pages/package-stats/[[org]]/[packageName].vue b/app/pages/package-stats/[[org]]/[packageName].vue index dc1beafb87..f3c03577db 100644 --- a/app/pages/package-stats/[[org]]/[packageName].vue +++ b/app/pages/package-stats/[[org]]/[packageName].vue @@ -55,7 +55,8 @@ const comparisonSpecs = computed(() => [ version.value ? `${packageName.value}@${version.value}` : packageName.value, ]) -const { getFacetValues, isFacetLoading, isColumnLoading } = usePackageComparison(comparisonSpecs) +const { getFacetValues, isFacetLoading, isColumnLoading, status } = + usePackageComparison(comparisonSpecs) const { facetLabels } = useFacetSelection() @@ -105,7 +106,13 @@ useSeoMeta({
    {{ row.label }}
    - +
    @@ -1124,41 +1068,4 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b
    - - - - - - - - - - - -
    - - - diff --git a/app/pages/package-stats/[[org]]/[packageName].vue b/app/pages/package-stats/[[org]]/[packageName].vue index 2422ec2f74..073a414962 100644 --- a/app/pages/package-stats/[[org]]/[packageName].vue +++ b/app/pages/package-stats/[[org]]/[packageName].vue @@ -123,7 +123,7 @@ useSeoMeta({
    -
    + -
    +

    {{ $t('package.stats.version_distribution') }}

    diff --git a/app/utils/router.ts b/app/utils/router.ts index 9db74d8ba9..5b9d2eca1c 100644 --- a/app/utils/router.ts +++ b/app/utils/router.ts @@ -95,11 +95,16 @@ export function packageTimelineRoute(packageName: string, version: string): Rout } } -export function packageStatsRoute(packageName: string, version: string): RouteLocationRaw { +export function packageStatsRoute( + packageName: string, + version: string, + hash?: '#distribution' | '#trends', +): RouteLocationRaw { const { org, name } = splitPackageName(packageName) return { name: 'stats', + hash, params: { org: org || undefined, packageName: name, From 1c13b7ee825024a35d59066e03d6b1aaf4a368ca Mon Sep 17 00:00:00 2001 From: Vordgi Date: Sun, 7 Jun 2026 19:50:12 +0100 Subject: [PATCH 09/17] refactor: navigate to trends page instead of downloads modal --- app/components/Package/ChartModal.vue | 25 ---- .../Package/WeeklyDownloadStats.vue | 125 ++---------------- app/pages/package/[[org]]/[name].vue | 3 +- test/nuxt/a11y.spec.ts | 17 --- 4 files changed, 13 insertions(+), 157 deletions(-) delete mode 100644 app/components/Package/ChartModal.vue diff --git a/app/components/Package/ChartModal.vue b/app/components/Package/ChartModal.vue deleted file mode 100644 index 598291c894..0000000000 --- a/app/components/Package/ChartModal.vue +++ /dev/null @@ -1,25 +0,0 @@ - - - diff --git a/app/components/Package/WeeklyDownloadStats.vue b/app/components/Package/WeeklyDownloadStats.vue index 77fdf6e7d3..6ed1514cd9 100644 --- a/app/components/Package/WeeklyDownloadStats.vue +++ b/app/components/Package/WeeklyDownloadStats.vue @@ -18,50 +18,11 @@ const props = defineProps<{ packageName: string createdIso: string | null repoRef?: RepoRef | null | undefined + version?: string | null }>() -const router = useRouter() -const route = useRoute() const { settings } = useSettings() -const chartModal = useModal('chart-modal') -const hasChartModalTransitioned = shallowRef(false) - -const modalTitle = computed(() => { - const facet = route.query.facet as string | undefined - if (facet === 'likes') return $t('package.trends.items.likes') - if (facet === 'contributors') return $t('package.trends.items.contributors') - return $t('package.trends.items.downloads') -}) - -const modalSubtitle = computed(() => { - const facet = route.query.facet as string | undefined - if (facet === 'likes' || facet === 'contributors') return undefined - return $t('package.downloads.subtitle') -}) - -const isChartModalOpen = shallowRef(false) - -function handleModalClose() { - isChartModalOpen.value = false - hasChartModalTransitioned.value = false - - router.replace({ - query: { - ...route.query, - modal: undefined, - granularity: undefined, - end: undefined, - start: undefined, - facet: undefined, - }, - }) -} - -function handleModalTransitioned() { - hasChartModalTransitioned.value = true -} - const { fetchPackageDownloadEvolution } = useCharts() const numberFormatter = useNumberFormatter() @@ -116,25 +77,6 @@ const weeklyDownloads = shallowRef([]) const isLoadingWeeklyDownloads = shallowRef(true) const hasWeeklyDownloads = computed(() => weeklyDownloads.value.length > 0) -async function openChartModal() { - if (!hasWeeklyDownloads.value) return - - isChartModalOpen.value = true - hasChartModalTransitioned.value = false - - await router.replace({ - query: { - ...route.query, - modal: 'chart', - }, - }) - - // ensure the component renders before opening the dialog - await nextTick() - await nextTick() - chartModal.open() -} - async function loadWeeklyDownloads() { if (!import.meta.client) return @@ -155,14 +97,6 @@ async function loadWeeklyDownloads() { onMounted(async () => { await loadWeeklyDownloads() - - if (route.query.modal === 'chart') { - isChartModalOpen.value = true - } - - if (isChartModalOpen.value && hasWeeklyDownloads.value) { - openChartModal() - } }) watch( @@ -194,6 +128,11 @@ const dataset = computed(() => })), ) +const trendsRoute = computed(() => { + if (!props.version) return null + return packageStatsRoute(props.packageName, props.version, '#trends') +}) + const lastDatapoint = computed(() => dataset.value.at(-1)?.period ?? '') const showPulse = shallowRef(true) @@ -399,16 +338,16 @@ const config = computed(() => { :subtitle="$t('package.downloads.subtitle')" > @@ -456,51 +395,9 @@ const config = computed(() => {
    - - - - - - - - - - -
    -