Skip to content

Commit 9977171

Browse files
committed
refactor: extract parsePackageSpecifier as a shared util
1 parent 1e71cb6 commit 9977171

File tree

4 files changed

+70
-153
lines changed

4 files changed

+70
-153
lines changed

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
const route = useRoute()
@@ -422,16 +423,6 @@ function focusElement(el: HTMLElement) {
422423
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
423424
}
424425
425-
// Parse "pkg@version" from search input (e.g. "esbuild@0.25.12", "@angular/core@^18")
426-
function parsePackageAtVersion(input: string): { name: string; version?: string } {
427-
const atIndex = input.startsWith('@') ? input.indexOf('@', 1) : input.indexOf('@')
428-
if (atIndex > 0) {
429-
const version = input.slice(atIndex + 1)
430-
if (version) return { name: input.slice(0, atIndex), version }
431-
}
432-
return { name: input }
433-
}
434-
435426
// Navigate to package page
436427
async function navigateToPackage(packageName: string) {
437428
await navigateTo(packageRoute(packageName))
@@ -471,7 +462,7 @@ function handleResultsKeydown(e: KeyboardEvent) {
471462
if (!inputValue) return
472463
473464
// Handle "pkg@version" format (e.g. "esbuild@0.25.12", "@angular/core@^18")
474-
const { name, version } = parsePackageAtVersion(inputValue)
465+
const { name, version } = parsePackageSpecifier(inputValue)
475466
if (version) {
476467
return navigateTo(packageRoute(name, version))
477468
}

modules/runtime/server/cache.ts

Lines changed: 2 additions & 37 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
/**
@@ -64,42 +65,6 @@ function getFixturePath(type: FixtureType, name: string): string {
6465
return `${dir}:${filename.replace(/\//g, ':')}`
6566
}
6667

67-
/**
68-
* Parse a scoped package name with optional version.
69-
* Handles formats like: @scope/name, @scope/name@version, name, name@version
70-
*/
71-
function parseScopedPackageWithVersion(input: string): { name: string; version?: string } {
72-
if (input.startsWith('@')) {
73-
// Scoped package: @scope/name or @scope/name@version
74-
const slashIndex = input.indexOf('/')
75-
if (slashIndex === -1) {
76-
// Invalid format like just "@scope"
77-
return { name: input }
78-
}
79-
const afterSlash = input.slice(slashIndex + 1)
80-
const atIndex = afterSlash.indexOf('@')
81-
if (atIndex === -1) {
82-
// @scope/name (no version)
83-
return { name: input }
84-
}
85-
// @scope/name@version
86-
return {
87-
name: input.slice(0, slashIndex + 1 + atIndex),
88-
version: afterSlash.slice(atIndex + 1),
89-
}
90-
}
91-
92-
// Unscoped package: name or name@version
93-
const atIndex = input.indexOf('@')
94-
if (atIndex === -1) {
95-
return { name: input }
96-
}
97-
return {
98-
name: input.slice(0, atIndex),
99-
version: input.slice(atIndex + 1),
100-
}
101-
}
102-
10368
function getMockForUrl(url: string): MockResult | null {
10469
let urlObj: URL
10570
try {
@@ -174,7 +139,7 @@ function getMockForUrl(url: string): MockResult | null {
174139
const packageMatch = decodeURIComponent(pathname).match(/^\/v1\/packages\/npm\/(.+)$/)
175140
if (packageMatch?.[1]) {
176141
const pkgWithVersion = packageMatch[1]
177-
const parsed = parseScopedPackageWithVersion(pkgWithVersion)
142+
const parsed = parsePackageSpecifier(pkgWithVersion)
178143
return {
179144
data: {
180145
type: 'npm',

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
const vIndex = segments.indexOf('v')
Lines changed: 41 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,126 +1,62 @@
11
import { describe, expect, it } from 'vitest'
2-
import { parsePackageParam } from '../../../../shared/utils/parse-package-param'
2+
import { 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' })
8839
})
8940

90-
describe('edge cases', () => {
91-
it('handles package name that looks like a version marker', () => {
92-
// Package named "v" shouldn't be confused with version separator
93-
const result = parsePackageParam('v')
94-
expect(result).toEqual({
95-
packageName: 'v',
96-
version: undefined,
97-
rest: [],
98-
})
99-
})
41+
it('returns name only for bare scope', () => {
42+
expect(parsePackageSpecifier('@angular')).toEqual({ name: '@angular' })
43+
})
10044

101-
it('handles version segment without actual version', () => {
102-
// "v" at the end without a version after it
103-
const result = parsePackageParam('vue/v')
104-
expect(result).toEqual({
105-
packageName: 'vue/v',
106-
version: undefined,
107-
rest: [],
108-
})
109-
})
45+
it('handles trailing @ with no version', () => {
46+
expect(parsePackageSpecifier('esbuild@')).toEqual({ name: 'esbuild@' })
47+
})
11048

111-
it('handles package with "v" in the name followed by version', () => {
112-
const result = parsePackageParam('vueuse/v/12.0.0')
113-
expect(result).toEqual({
114-
packageName: 'vueuse',
115-
version: '12.0.0',
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 empty rest when file path is empty', () => {
121-
const result = parsePackageParam('react/v/18.2.0')
122-
expect(result.rest).toEqual([])
123-
expect(result.rest.length).toBe(0)
56+
it('parses dist-tag as version', () => {
57+
expect(parsePackageSpecifier('nuxt@latest')).toEqual({
58+
name: 'nuxt',
59+
version: 'latest',
12460
})
12561
})
12662
})

0 commit comments

Comments
 (0)