From 07dff447f8403e243931f5927986808ca4fcd25c Mon Sep 17 00:00:00 2001 From: Vordgi Date: Sat, 25 Jul 2026 09:26:06 +0100 Subject: [PATCH 1/5] feat: implement tools section --- app/components/AppFooter.vue | 4 + app/components/DepsStats/DependencyList.vue | 127 ++++++++++++++++ app/components/DepsStats/DependencyStats.vue | 44 ++++++ .../DepsStats/DependencyStatsPanel.vue | 124 ++++++++++++++++ .../DepsStats/PackageJsonUpload.vue | 82 +++++++++++ .../useCommandPaletteGlobalCommands.ts | 23 +++ app/pages/tools/deps-stats.vue | 116 +++++++++++++++ app/pages/tools/index.vue | 64 ++++++++ app/utils/parse-package-json-deps.ts | 137 +++++++++++++++++ docs/content/2.guide/3.url-structure.md | 2 + i18n/locales/en.json | 46 ++++++ i18n/schema.json | 138 ++++++++++++++++++ nuxt.config.ts | 2 + .../middleware/canonical-redirects.global.ts | 1 + test/nuxt/a11y.spec.ts | 130 +++++++++++++++++ .../app/utils/parse-package-json-deps.spec.ts | 88 +++++++++++ 16 files changed, 1128 insertions(+) create mode 100644 app/components/DepsStats/DependencyList.vue create mode 100644 app/components/DepsStats/DependencyStats.vue create mode 100644 app/components/DepsStats/DependencyStatsPanel.vue create mode 100644 app/components/DepsStats/PackageJsonUpload.vue create mode 100644 app/pages/tools/deps-stats.vue create mode 100644 app/pages/tools/index.vue create mode 100644 app/utils/parse-package-json-deps.ts create mode 100644 test/unit/app/utils/parse-package-json-deps.spec.ts diff --git a/app/components/AppFooter.vue b/app/components/AppFooter.vue index 4580671cd3..67590100ee 100644 --- a/app/components/AppFooter.vue +++ b/app/components/AppFooter.vue @@ -79,6 +79,10 @@ const footerSections = computed>(( name: t('shortcuts.compare'), href: '/compare', }, + { + name: t('footer.tools'), + href: '/tools', + }, { name: t('shortcuts.settings'), href: '/settings', diff --git a/app/components/DepsStats/DependencyList.vue b/app/components/DepsStats/DependencyList.vue new file mode 100644 index 0000000000..97667536c4 --- /dev/null +++ b/app/components/DepsStats/DependencyList.vue @@ -0,0 +1,127 @@ + + + diff --git a/app/components/DepsStats/DependencyStats.vue b/app/components/DepsStats/DependencyStats.vue new file mode 100644 index 0000000000..afa3491437 --- /dev/null +++ b/app/components/DepsStats/DependencyStats.vue @@ -0,0 +1,44 @@ + + + diff --git a/app/components/DepsStats/DependencyStatsPanel.vue b/app/components/DepsStats/DependencyStatsPanel.vue new file mode 100644 index 0000000000..5be01eb38e --- /dev/null +++ b/app/components/DepsStats/DependencyStatsPanel.vue @@ -0,0 +1,124 @@ + + + diff --git a/app/components/DepsStats/PackageJsonUpload.vue b/app/components/DepsStats/PackageJsonUpload.vue new file mode 100644 index 0000000000..7ea98a9d70 --- /dev/null +++ b/app/components/DepsStats/PackageJsonUpload.vue @@ -0,0 +1,82 @@ + + + diff --git a/app/composables/useCommandPaletteGlobalCommands.ts b/app/composables/useCommandPaletteGlobalCommands.ts index 0ba7af3c6e..65096752b5 100644 --- a/app/composables/useCommandPaletteGlobalCommands.ts +++ b/app/composables/useCommandPaletteGlobalCommands.ts @@ -314,6 +314,29 @@ export function useCommandPaletteGlobalCommands() { activeLabel: activeLabel(route.name === 'compare', t('command_palette.here')), to: { name: 'compare' }, }, + { + id: 'tools', + group: 'navigation', + label: t('nav.tools'), + keywords: [t('footer.tools'), t('shortcuts.deps_stats')], + iconClass: 'i-lucide:wrench', + active: route.name === 'tools' || route.name === 'tools-deps-stats', + activeLabel: activeLabel( + route.name === 'tools' || route.name === 'tools-deps-stats', + t('command_palette.here'), + ), + to: { name: 'tools' }, + }, + { + id: 'deps-stats', + group: 'navigation', + label: t('tools.deps_stats.name'), + keywords: [t('shortcuts.deps_stats'), 'package.json', 'deps'], + iconClass: 'i-lucide:file-json', + active: route.name === 'tools-deps-stats', + activeLabel: activeLabel(route.name === 'tools-deps-stats', t('command_palette.here')), + to: { name: 'tools-deps-stats' }, + }, { id: 'settings', group: 'navigation', diff --git a/app/pages/tools/deps-stats.vue b/app/pages/tools/deps-stats.vue new file mode 100644 index 0000000000..35883fd9af --- /dev/null +++ b/app/pages/tools/deps-stats.vue @@ -0,0 +1,116 @@ + + + diff --git a/app/pages/tools/index.vue b/app/pages/tools/index.vue new file mode 100644 index 0000000000..405e625794 --- /dev/null +++ b/app/pages/tools/index.vue @@ -0,0 +1,64 @@ + + + diff --git a/app/utils/parse-package-json-deps.ts b/app/utils/parse-package-json-deps.ts new file mode 100644 index 0000000000..68fdb0a978 --- /dev/null +++ b/app/utils/parse-package-json-deps.ts @@ -0,0 +1,137 @@ +import { parsePackageSpec } from '#shared/utils/parse-package-param' + +export type DependencyCategory = + | 'dependencies' + | 'devDependencies' + | 'peerDependencies' + | 'optionalDependencies' + +export interface PackageJsonDependency { + name: string + /** Version range from package.json */ + range: string + packageName: string + category: DependencyCategory + /** True when the range is not a registry package (file:, workspace:, git, etc.) */ + nonRegistry: boolean +} + +export interface ParsedPackageJson { + name?: string + version?: string + dependencies: PackageJsonDependency[] +} + +const DEPENDENCY_CATEGORIES: DependencyCategory[] = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +] + +const NON_REGISTRY_PREFIXES = [ + 'file:', + 'link:', + 'workspace:', + 'portal:', + 'git+', + 'git:', + 'github:', + 'gist:', + 'bitbucket:', + 'gitlab:', + 'jsr:', + 'http:', + 'https:', +] + +export function isNonRegistryRange(range: string): boolean { + const trimmed = range.trim() + if (!trimmed) return true + if (trimmed.startsWith('.') || trimmed.startsWith('/')) return true + return NON_REGISTRY_PREFIXES.some(prefix => trimmed.toLowerCase().startsWith(prefix)) +} + +/** + * Resolve `npm:pkg@range` / `npm:@scope/pkg@range` aliases to a registry package name. + * Returns `null` when the alias cannot be parsed. + */ +export function resolveNpmAlias(range: string): { packageName: string; range: string } | null { + if (!range.startsWith('npm:')) return null + + const spec = range.slice('npm:'.length) + if (!spec) return null + + const { name, version } = parsePackageSpec(spec) + return { + packageName: name, + range: version || '*', + } +} + +function toDependency( + name: string, + range: string, + category: DependencyCategory, +): PackageJsonDependency { + const alias = resolveNpmAlias(range) + if (alias) { + return { + name, + range: alias.range, + packageName: alias.packageName, + category, + nonRegistry: isNonRegistryRange(alias.range), + } + } + + return { + name, + range, + packageName: name, + category, + nonRegistry: isNonRegistryRange(range), + } +} + +export function parsePackageJsonDependencies(raw: unknown): ParsedPackageJson { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('Invalid package.json: expected a JSON object') + } + + const pkg = raw as Record + const dependencies: PackageJsonDependency[] = [] + + for (const category of DEPENDENCY_CATEGORIES) { + const section = pkg[category] + if (!section || typeof section !== 'object' || Array.isArray(section)) continue + + for (const [name, range] of Object.entries(section as Record)) { + if (typeof range !== 'string') continue + dependencies.push(toDependency(name, range, category)) + } + } + + dependencies.sort((a, b) => { + const categoryOrder = + DEPENDENCY_CATEGORIES.indexOf(a.category) - DEPENDENCY_CATEGORIES.indexOf(b.category) + if (categoryOrder !== 0) return categoryOrder + return a.name.localeCompare(b.name) + }) + + return { + name: typeof pkg.name === 'string' ? pkg.name : undefined, + version: typeof pkg.version === 'string' ? pkg.version : undefined, + dependencies, + } +} + +export function parsePackageJsonText(text: string): ParsedPackageJson { + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + throw new Error('Invalid package.json: could not parse JSON') + } + return parsePackageJsonDependencies(parsed) +} diff --git a/docs/content/2.guide/3.url-structure.md b/docs/content/2.guide/3.url-structure.md index 9e0df4bbfe..d7ebfdc614 100644 --- a/docs/content/2.guide/3.url-structure.md +++ b/docs/content/2.guide/3.url-structure.md @@ -44,6 +44,8 @@ npmx.dev keeps package exploration views in routes or query parameters so you ca | ------------------- | --------------------------------- | | Search results | `/search?q=vue` | | Package comparison | `/compare?packages=react,preact` | +| Tools | `/tools` | +| Dependency stats | `/tools/deps-stats` | | Source file or line | `/package-code/vue/v/3.5.27/...` | | Generated docs | `/package-docs/ufo/v/1.6.3` | | Version diff | `/diff/vue/v/3.5.26...3.5.27` | diff --git a/i18n/locales/en.json b/i18n/locales/en.json index a09e22b59f..22538c41d7 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -24,6 +24,7 @@ "brand": "brand", "resources": "Resources", "features": "Features", + "tools": "tools", "other": "Other", "sponsored_by": "Sponsored by {list}" }, @@ -40,6 +41,7 @@ "show_kbd_hints": "Highlight keyboard hints", "settings": "Open settings", "compare": "Open compare", + "deps_stats": "Open dependency stats tool", "compare_from_package": "Open compare (prefilled with current package)", "changelog": "Open changelog", "navigate_results": "Navigate results", @@ -207,6 +209,7 @@ "popular_packages": "Popular packages", "settings": "settings", "compare": "compare", + "tools": "tools", "back": "back", "menu": "Menu", "mobile_menu": "Navigation menu", @@ -214,6 +217,49 @@ "links": "Links", "tap_to_search": "Tap to search" }, + "tools": { + "title": "tools", + "tagline": "utilities for exploring packages and project dependencies", + "meta_title": "Tools - npmx", + "meta_description": "Browse npmx tools for exploring packages and project dependencies", + "deps_stats": { + "name": "deps stats", + "description": "Upload a package.json and inspect stats for each dependency" + } + }, + "deps_stats": { + "title": "deps stats", + "meta_title": "Deps stats - npmx", + "meta_description": "Upload a package.json and explore stats for each dependency", + "upload": { + "section": "Package.json", + "label": "Upload package.json", + "hint": "Drop a package.json here, or choose a file. Only the dependency fields are read — nothing is uploaded to a server", + "clear": "Clear", + "invalid_package_json": "Invalid package.json" + }, + "workspace": { + "section": "Dependencies" + }, + "dependencies": { + "title": "Dependencies", + "count": "No deps | 1 dep | {count} deps", + "filter_label": "Filter dependencies", + "filter_placeholder": "Filter dependencies...", + "empty": "No dependencies found in this package.json", + "no_matches": "No dependencies match your filter", + "non_registry": "Not an npm registry package" + }, + "stats": { + "empty_title": "Select a dependency", + "empty_description": "Pick a package from the list to see downloads, size, vulnerabilities, trends, and more", + "non_registry_title": "Local or remote dependency", + "non_registry_description": "This entry points to a file, workspace, git, or URL dependency, so registry stats are unavailable", + "declared_range": "Declared", + "resolved_version": "Latest", + "open_full_stats": "Open full stats" + } + }, "blog": { "title": "Blog", "heading": "blog", diff --git a/i18n/schema.json b/i18n/schema.json index b7793e498c..5c9e8efcff 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -76,6 +76,9 @@ "features": { "type": "string" }, + "tools": { + "type": "string" + }, "other": { "type": "string" }, @@ -124,6 +127,9 @@ "compare": { "type": "string" }, + "deps_stats": { + "type": "string" + }, "compare_from_package": { "type": "string" }, @@ -625,6 +631,9 @@ "compare": { "type": "string" }, + "tools": { + "type": "string" + }, "back": { "type": "string" }, @@ -646,6 +655,135 @@ }, "additionalProperties": false }, + "tools": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "tagline": { + "type": "string" + }, + "meta_title": { + "type": "string" + }, + "meta_description": { + "type": "string" + }, + "deps_stats": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "deps_stats": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "meta_title": { + "type": "string" + }, + "meta_description": { + "type": "string" + }, + "upload": { + "type": "object", + "properties": { + "section": { + "type": "string" + }, + "label": { + "type": "string" + }, + "hint": { + "type": "string" + }, + "clear": { + "type": "string" + }, + "invalid_package_json": { + "type": "string" + } + }, + "additionalProperties": false + }, + "workspace": { + "type": "object", + "properties": { + "section": { + "type": "string" + } + }, + "additionalProperties": false + }, + "dependencies": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "count": { + "type": "string" + }, + "filter_label": { + "type": "string" + }, + "filter_placeholder": { + "type": "string" + }, + "empty": { + "type": "string" + }, + "no_matches": { + "type": "string" + }, + "non_registry": { + "type": "string" + } + }, + "additionalProperties": false + }, + "stats": { + "type": "object", + "properties": { + "empty_title": { + "type": "string" + }, + "empty_description": { + "type": "string" + }, + "non_registry_title": { + "type": "string" + }, + "non_registry_description": { + "type": "string" + }, + "declared_range": { + "type": "string" + }, + "resolved_version": { + "type": "string" + }, + "open_full_stats": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, "blog": { "type": "object", "properties": { diff --git a/nuxt.config.ts b/nuxt.config.ts index 43b55b19cf..fe4095063f 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -221,6 +221,8 @@ export default defineNuxtConfig({ '/blog/**': { prerender: true }, '/noodles/**': { prerender: true }, '/sponsors': { prerender: true }, + '/tools': { prerender: true }, + '/tools/deps-stats': { prerender: true }, // proxy for insights '/_v/script.js': { proxy: 'https://npmx.dev/_vercel/insights/script.js', diff --git a/server/middleware/canonical-redirects.global.ts b/server/middleware/canonical-redirects.global.ts index 738612c2a7..0f3c1bf915 100644 --- a/server/middleware/canonical-redirects.global.ts +++ b/server/middleware/canonical-redirects.global.ts @@ -31,6 +31,7 @@ const pages = [ '/privacy', '/search', '/settings', + '/tools', '/translation-status', '/recharging', ] diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index e1b79d1656..ec296a28a3 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -196,6 +196,10 @@ import { CompareReplacementSuggestion, DateTime, DependencyPathPopup, + DepsStatsDependencyList, + DepsStatsDependencyStats, + DepsStatsDependencyStatsPanel, + DepsStatsPackageJsonUpload, FilterChips, FilterPanel, HeaderAccountMenu, @@ -2536,6 +2540,132 @@ describe('component accessibility audits', () => { }) }) + describe('DepsStatsPackageJsonUpload', () => { + it('should have no accessibility violations in empty state', async () => { + const component = await mountSuspended(DepsStatsPackageJsonUpload) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with an error', async () => { + const component = await mountSuspended(DepsStatsPackageJsonUpload, { + props: { error: 'Invalid package.json' }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with a selected file', async () => { + const component = await mountSuspended(DepsStatsPackageJsonUpload, { + props: { fileName: 'package.json' }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + + describe('DepsStatsDependencyList', () => { + it('should have no accessibility violations when empty', async () => { + const component = await mountSuspended(DepsStatsDependencyList, { + props: { + dependencies: [], + selectedName: null, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with dependencies', async () => { + const component = await mountSuspended(DepsStatsDependencyList, { + props: { + dependencies: [ + { + name: 'vue', + range: '^3.5.0', + packageName: 'vue', + category: 'dependencies', + nonRegistry: false, + }, + { + name: 'vitest', + range: '^3.0.0', + packageName: 'vitest', + category: 'devDependencies', + nonRegistry: false, + }, + { + name: 'local-pkg', + range: 'workspace:*', + packageName: 'local-pkg', + category: 'dependencies', + nonRegistry: true, + }, + { + name: 'alias-pkg', + range: '^1.0.0', + packageName: 'real-pkg', + category: 'dependencies', + nonRegistry: false, + }, + ], + selectedName: 'vue', + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + + describe('DepsStatsDependencyStats', () => { + it('should have no accessibility violations when empty', async () => { + const component = await mountSuspended(DepsStatsDependencyStats, { + props: { dependency: null }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations for a non-registry dependency', async () => { + const component = await mountSuspended(DepsStatsDependencyStats, { + props: { + dependency: { + name: 'local-pkg', + range: 'workspace:*', + packageName: 'local-pkg', + category: 'dependencies', + nonRegistry: true, + }, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + + describe('DepsStatsDependencyStatsPanel', () => { + it('should have no accessibility violations', async () => { + const component = await mountSuspended(DepsStatsDependencyStatsPanel, { + props: { + packageName: 'vue', + declaredRange: '^3.5.0', + }, + global: { + stubs: { + PackageTrendsChart: { + template: '
', + }, + PackageVersionDistribution: { + template: '
', + }, + }, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + // Compare feature components describe('CompareFacetSelector', () => { it('should have no accessibility violations', async () => { diff --git a/test/unit/app/utils/parse-package-json-deps.spec.ts b/test/unit/app/utils/parse-package-json-deps.spec.ts new file mode 100644 index 0000000000..84f33a9dc2 --- /dev/null +++ b/test/unit/app/utils/parse-package-json-deps.spec.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { + isNonRegistryRange, + parsePackageJsonDependencies, + parsePackageJsonText, + resolveNpmAlias, +} from '~/utils/parse-package-json-deps' + +describe('parse-package-json-deps', () => { + describe('isNonRegistryRange', () => { + it('detects local and remote protocols', () => { + expect(isNonRegistryRange('file:../local')).toBe(true) + expect(isNonRegistryRange('workspace:*')).toBe(true) + expect(isNonRegistryRange('link:./pkg')).toBe(true) + expect(isNonRegistryRange('git+https://github.com/org/repo.git')).toBe(true) + expect(isNonRegistryRange('https://example.com/pkg.tgz')).toBe(true) + expect(isNonRegistryRange('^1.2.3')).toBe(false) + expect(isNonRegistryRange('*')).toBe(false) + }) + }) + + describe('resolveNpmAlias', () => { + it('resolves unscoped and scoped npm aliases', () => { + expect(resolveNpmAlias('npm:lodash@4.17.21')).toEqual({ + packageName: 'lodash', + range: '4.17.21', + }) + expect(resolveNpmAlias('npm:@scope/pkg@^1.0.0')).toEqual({ + packageName: '@scope/pkg', + range: '^1.0.0', + }) + expect(resolveNpmAlias('^1.0.0')).toBeNull() + }) + }) + + describe('parsePackageJsonDependencies', () => { + it('parses dependency groups in order', () => { + const result = parsePackageJsonDependencies({ + name: 'demo', + version: '1.0.0', + dependencies: { vue: '^3.5.0', react: '^19.0.0' }, + devDependencies: { vitest: '^3.0.0' }, + peerDependencies: { typescript: '>=5' }, + optionalDependencies: { fsevents: '^2.3.0' }, + }) + + expect(result.name).toBe('demo') + expect(result.dependencies.map(d => `${d.category}:${d.name}`)).toEqual([ + 'dependencies:react', + 'dependencies:vue', + 'devDependencies:vitest', + 'peerDependencies:typescript', + 'optionalDependencies:fsevents', + ]) + }) + + it('resolves npm aliases and marks non-registry ranges', () => { + const result = parsePackageJsonDependencies({ + dependencies: { + alias: 'npm:lodash@^4.17.21', + local: 'file:../pkg', + }, + }) + + expect(result.dependencies).toEqual([ + { + name: 'alias', + range: '^4.17.21', + packageName: 'lodash', + category: 'dependencies', + nonRegistry: false, + }, + { + name: 'local', + range: 'file:../pkg', + packageName: 'local', + category: 'dependencies', + nonRegistry: true, + }, + ]) + }) + + it('throws for non-object JSON roots', () => { + expect(() => parsePackageJsonDependencies([])).toThrow(/expected a JSON object/) + expect(() => parsePackageJsonText('{')).toThrow(/could not parse JSON/) + }) + }) +}) From 3c5323dfd5ef122d62bd7f5e83da78e487f06bd8 Mon Sep 17 00:00:00 2001 From: Vordgi Date: Sat, 25 Jul 2026 12:42:47 +0100 Subject: [PATCH 2/5] feat: show labels for each dependency on deps-stats --- app/components/DepsStats/DependencyList.vue | 197 ++++++++++++++++-- .../npm/useDirectDependencyHealth.ts | 74 +++++++ .../api/registry/direct-deps-health.post.ts | 15 ++ server/utils/dependency-analysis.ts | 82 +++++++- shared/schemas/dependency-analysis.ts | 13 ++ shared/types/dependency-analysis.ts | 45 ++-- shared/utils/constants.ts | 3 + .../use-direct-dependency-health.spec.ts | 70 +++++++ 8 files changed, 463 insertions(+), 36 deletions(-) create mode 100644 app/composables/npm/useDirectDependencyHealth.ts create mode 100644 server/api/registry/direct-deps-health.post.ts create mode 100644 shared/schemas/dependency-analysis.ts create mode 100644 test/nuxt/composables/use-direct-dependency-health.spec.ts diff --git a/app/components/DepsStats/DependencyList.vue b/app/components/DepsStats/DependencyList.vue index 97667536c4..eb6251721a 100644 --- a/app/components/DepsStats/DependencyList.vue +++ b/app/components/DepsStats/DependencyList.vue @@ -1,5 +1,12 @@