Skip to content

Commit db110a6

Browse files
committed
refactor: extract parsePackageSpecifier as a shared util
1 parent 538a5fe commit db110a6

4 files changed

Lines changed: 128 additions & 159 deletions

File tree

app/pages/search.vue

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { onKeyDown } from '@vueuse/core'
55
import { debounce } from 'perfect-debounce'
66
import { isValidNewPackageName } from '~/utils/package-name'
77
import { isPlatformSpecificPackage } from '~/utils/platform-packages'
8+
import { parsePackageSpecifier } from '#shared/utils/parse-package-param'
89
import { normalizeSearchParam } from '#shared/utils/url'
910
1011
definePageMeta({
@@ -441,16 +442,6 @@ function focusElement(el: HTMLElement) {
441442
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
442443
}
443444
444-
// Parse "pkg@version" from search input (e.g. "esbuild@0.25.12", "@angular/core@^18")
445-
function parsePackageAtVersion(input: string): { name: string; version?: string } {
446-
const atIndex = input.startsWith('@') ? input.indexOf('@', 1) : input.indexOf('@')
447-
if (atIndex > 0) {
448-
const version = input.slice(atIndex + 1)
449-
if (version) return { name: input.slice(0, atIndex), version }
450-
}
451-
return { name: input }
452-
}
453-
454445
// Navigate to package page
455446
async function navigateToPackage(packageName: string) {
456447
await navigateTo(packageRoute(packageName))
@@ -505,7 +496,7 @@ function handleResultsKeydown(e: KeyboardEvent) {
505496
if (!inputValue) return
506497
507498
// Handle "pkg@version" format (e.g. "esbuild@0.25.12", "@angular/core@^18")
508-
const { name, version } = parsePackageAtVersion(inputValue)
499+
const { name, version } = parsePackageSpecifier(inputValue)
509500
if (version) {
510501
return navigateTo(packageRoute(name, version))
511502
}

modules/runtime/server/cache.ts

Lines changed: 51 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import process from 'node:process'
22
import type { CachedFetchResult } from '#shared/utils/fetch-cache-config'
3+
import { parsePackageSpecifier } from '#shared/utils/parse-package-param'
34
import { createFetch } from 'ofetch'
45

56
/**
@@ -65,42 +66,6 @@ function getFixturePath(type: FixtureType, name: string): string {
6566
return `${dir}:${filename.replace(/\//g, ':')}`
6667
}
6768

68-
/**
69-
* Parse a scoped package name with optional version.
70-
* Handles formats like: @scope/name, @scope/name@version, name, name@version
71-
*/
72-
function parseScopedPackageWithVersion(input: string): { name: string; version?: string } {
73-
if (input.startsWith('@')) {
74-
// Scoped package: @scope/name or @scope/name@version
75-
const slashIndex = input.indexOf('/')
76-
if (slashIndex === -1) {
77-
// Invalid format like just "@scope"
78-
return { name: input }
79-
}
80-
const afterSlash = input.slice(slashIndex + 1)
81-
const atIndex = afterSlash.indexOf('@')
82-
if (atIndex === -1) {
83-
// @scope/name (no version)
84-
return { name: input }
85-
}
86-
// @scope/name@version
87-
return {
88-
name: input.slice(0, slashIndex + 1 + atIndex),
89-
version: afterSlash.slice(atIndex + 1),
90-
}
91-
}
92-
93-
// Unscoped package: name or name@version
94-
const atIndex = input.indexOf('@')
95-
if (atIndex === -1) {
96-
return { name: input }
97-
}
98-
return {
99-
name: input.slice(0, atIndex),
100-
version: input.slice(atIndex + 1),
101-
}
102-
}
103-
10469
function getMockForUrl(url: string): MockResult | null {
10570
const urlObj = URL.parse(url)
10671
if (!urlObj) return null
@@ -137,6 +102,56 @@ function getMockForUrl(url: string): MockResult | null {
137102
}
138103
}
139104

105+
// npms.io API - return mock package score data
106+
if (host === 'api.npms.io') {
107+
const packageMatch = decodeURIComponent(pathname).match(/^\/v2\/package\/(.+)$/)
108+
if (packageMatch?.[1]) {
109+
return {
110+
data: {
111+
analyzedAt: new Date().toISOString(),
112+
collected: {
113+
metadata: { name: packageMatch[1] },
114+
},
115+
score: {
116+
final: 0.75,
117+
detail: {
118+
quality: 0.8,
119+
popularity: 0.7,
120+
maintenance: 0.75,
121+
},
122+
},
123+
},
124+
}
125+
}
126+
}
127+
128+
// jsdelivr CDN - return 404 for README files, etc.
129+
if (host === 'cdn.jsdelivr.net') {
130+
// Return null data which will cause a 404 - README files are optional
131+
return { data: null }
132+
}
133+
134+
// jsdelivr data API - return mock file listing
135+
if (host === 'data.jsdelivr.com') {
136+
const packageMatch = decodeURIComponent(pathname).match(/^\/v1\/packages\/npm\/(.+)$/)
137+
if (packageMatch?.[1]) {
138+
const pkgWithVersion = packageMatch[1]
139+
const parsed = parsePackageSpecifier(pkgWithVersion)
140+
return {
141+
data: {
142+
type: 'npm',
143+
name: parsed.name,
144+
version: parsed.version || 'latest',
145+
files: [
146+
{ name: 'package.json', hash: 'abc123', size: 1000 },
147+
{ name: 'index.js', hash: 'def456', size: 500 },
148+
{ name: 'README.md', hash: 'ghi789', size: 2000 },
149+
],
150+
},
151+
}
152+
}
153+
}
154+
140155
// Gravatar API - return 404 (avatars not needed in tests)
141156
if (host === 'www.gravatar.com') {
142157
return { data: null }

shared/utils/parse-package-param.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,31 @@ export interface ParsedPackageParams {
3535
* // { packageName: '@nuxt/kit', version: '1.0.0', rest: ['src', 'index.ts'] }
3636
* ```
3737
*/
38+
/**
39+
* Parse a "pkg@version" specifier string into name and optional version.
40+
* Handles scoped packages correctly (the scope `@` is not treated as a version separator).
41+
*
42+
* @example
43+
* ```ts
44+
* parsePackageSpecifier('esbuild@0.25.12')
45+
* // { name: 'esbuild', version: '0.25.12' }
46+
*
47+
* parsePackageSpecifier('@angular/core@^18')
48+
* // { name: '@angular/core', version: '^18' }
49+
*
50+
* parsePackageSpecifier('react')
51+
* // { name: 'react' }
52+
* ```
53+
*/
54+
export function parsePackageSpecifier(input: string): { name: string; version?: string } {
55+
const atIndex = input.startsWith('@') ? input.indexOf('@', 1) : input.indexOf('@')
56+
if (atIndex > 0) {
57+
const version = input.slice(atIndex + 1)
58+
if (version) return { name: input.slice(0, atIndex), version }
59+
}
60+
return { name: input }
61+
}
62+
3863
export function parsePackageParam(pkgParam: string): ParsedPackageParams {
3964
const segments = pkgParam.split('/')
4065
let vIndex = segments.indexOf('v')
Lines changed: 50 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,135 +1,73 @@
11
import { describe, expect, it } from 'vitest'
2-
import { parsePackageParam } from '#shared/utils/parse-package-param'
2+
import { parsePackageParam, parsePackageSpecifier } from '#shared/utils/parse-package-param'
33

4-
describe('parsePackageParam', () => {
5-
describe('unscoped packages', () => {
6-
it('parses package name without version', () => {
7-
const result = parsePackageParam('vue')
8-
expect(result).toEqual({
9-
packageName: 'vue',
10-
version: undefined,
11-
rest: [],
12-
})
13-
})
14-
15-
it('parses package name with version', () => {
16-
const result = parsePackageParam('vue/v/3.4.0')
17-
expect(result).toEqual({
18-
packageName: 'vue',
19-
version: '3.4.0',
20-
rest: [],
21-
})
22-
})
23-
24-
it('parses package name with prerelease version', () => {
25-
const result = parsePackageParam('nuxt/v/4.0.0-rc.1')
26-
expect(result).toEqual({
27-
packageName: 'nuxt',
28-
version: '4.0.0-rc.1',
29-
rest: [],
30-
})
31-
})
32-
33-
it('parses package name with version and file path', () => {
34-
const result = parsePackageParam('vue/v/3.4.0/src/index.ts')
35-
expect(result).toEqual({
36-
packageName: 'vue',
37-
version: '3.4.0',
38-
rest: ['src', 'index.ts'],
39-
})
4+
describe('parsePackageSpecifier', () => {
5+
it('parses unscoped package with exact version', () => {
6+
expect(parsePackageSpecifier('esbuild@0.25.12')).toEqual({
7+
name: 'esbuild',
8+
version: '0.25.12',
409
})
10+
})
4111

42-
it('parses package name with version and nested file path', () => {
43-
const result = parsePackageParam('lodash/v/4.17.21/lib/fp/map.js')
44-
expect(result).toEqual({
45-
packageName: 'lodash',
46-
version: '4.17.21',
47-
rest: ['lib', 'fp', 'map.js'],
48-
})
12+
it('parses unscoped package with caret range', () => {
13+
expect(parsePackageSpecifier('react@^18.0.0')).toEqual({
14+
name: 'react',
15+
version: '^18.0.0',
4916
})
5017
})
5118

52-
describe('scoped packages', () => {
53-
it('parses scoped package name without version', () => {
54-
const result = parsePackageParam('@nuxt/kit')
55-
expect(result).toEqual({
56-
packageName: '@nuxt/kit',
57-
version: undefined,
58-
rest: [],
59-
})
19+
it('parses scoped package with exact version', () => {
20+
expect(parsePackageSpecifier('@angular/core@18.0.0')).toEqual({
21+
name: '@angular/core',
22+
version: '18.0.0',
6023
})
24+
})
6125

62-
it('parses scoped package name with version', () => {
63-
const result = parsePackageParam('@nuxt/kit/v/1.0.0')
64-
expect(result).toEqual({
65-
packageName: '@nuxt/kit',
66-
version: '1.0.0',
67-
rest: [],
68-
})
26+
it('parses scoped package with range', () => {
27+
expect(parsePackageSpecifier('@angular/core@^18')).toEqual({
28+
name: '@angular/core',
29+
version: '^18',
6930
})
31+
})
7032

71-
it('parses scoped package name with version and file path', () => {
72-
const result = parsePackageParam('@vue/compiler-sfc/v/3.5.0/dist/index.d.ts')
73-
expect(result).toEqual({
74-
packageName: '@vue/compiler-sfc',
75-
version: '3.5.0',
76-
rest: ['dist', 'index.d.ts'],
77-
})
78-
})
33+
it('returns name only for unscoped package without version', () => {
34+
expect(parsePackageSpecifier('esbuild')).toEqual({ name: 'esbuild' })
35+
})
7936

80-
it('parses deeply nested scoped packages', () => {
81-
const result = parsePackageParam('@types/node/v/22.0.0')
82-
expect(result).toEqual({
83-
packageName: '@types/node',
84-
version: '22.0.0',
85-
rest: [],
86-
})
87-
})
37+
it('returns name only for scoped package without version', () => {
38+
expect(parsePackageSpecifier('@angular/core')).toEqual({ name: '@angular/core' })
39+
})
8840

89-
it('parses scoped package names whose package segment is literally v', () => {
90-
const result = parsePackageParam('@scope/v/v/1.2.3/dist/index.js')
91-
expect(result).toEqual({
92-
packageName: '@scope/v',
93-
version: '1.2.3',
94-
rest: ['dist', 'index.js'],
95-
})
96-
})
41+
it('returns name only for bare scope', () => {
42+
expect(parsePackageSpecifier('@angular')).toEqual({ name: '@angular' })
9743
})
9844

99-
describe('edge cases', () => {
100-
it('handles package name that looks like a version marker', () => {
101-
// Package named "v" shouldn't be confused with version separator
102-
const result = parsePackageParam('v')
103-
expect(result).toEqual({
104-
packageName: 'v',
105-
version: undefined,
106-
rest: [],
107-
})
108-
})
45+
it('handles trailing @ with no version', () => {
46+
expect(parsePackageSpecifier('esbuild@')).toEqual({ name: 'esbuild@' })
47+
})
10948

110-
it('handles version segment without actual version', () => {
111-
// "v" at the end without a version after it
112-
const result = parsePackageParam('vue/v')
113-
expect(result).toEqual({
114-
packageName: 'vue/v',
115-
version: undefined,
116-
rest: [],
117-
})
49+
it('parses version with union range', () => {
50+
expect(parsePackageSpecifier('@angular/core@^18 || ^19')).toEqual({
51+
name: '@angular/core',
52+
version: '^18 || ^19',
11853
})
54+
})
11955

120-
it('handles package with "v" in the name followed by version', () => {
121-
const result = parsePackageParam('vueuse/v/12.0.0')
122-
expect(result).toEqual({
123-
packageName: 'vueuse',
124-
version: '12.0.0',
125-
rest: [],
126-
})
56+
it('parses dist-tag as version', () => {
57+
expect(parsePackageSpecifier('nuxt@latest')).toEqual({
58+
name: 'nuxt',
59+
version: 'latest',
12760
})
61+
})
62+
})
12863

129-
it('handles empty rest when file path is empty', () => {
130-
const result = parsePackageParam('react/v/18.2.0')
131-
expect(result.rest).toEqual([])
132-
expect(result.rest.length).toBe(0)
64+
describe('parsePackageParam', () => {
65+
it('parses scoped package names whose package segment is literally v', () => {
66+
const result = parsePackageParam('@scope/v/v/1.2.3/dist/index.js')
67+
expect(result).toEqual({
68+
packageName: '@scope/v',
69+
version: '1.2.3',
70+
rest: ['dist', 'index.js'],
13371
})
13472
})
13573
})

0 commit comments

Comments
 (0)