Skip to content
Open
21 changes: 21 additions & 0 deletions app/composables/useParsedSearchQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { ParsedSearchQuery } from '~/utils/search'
import { parseSearchQuery } from '~/utils/search'

type ParsedSearchQueryRef = {
[K in keyof Required<ParsedSearchQuery>]: Ref<ParsedSearchQuery[K]>
}

/**
* Wrapper around `parseSearchQuery` that makes it reactive.
*/
export function useParsedSearchQuery(query: MaybeRefOrGetter<string>): ParsedSearchQueryRef {
const parsed = computed(() => parseSearchQuery(toValue(query)))

return {
name: computed(() => parsed.value.name),
specifier: computed(() => parsed.value.specifier),
scope: computed(() => parsed.value.scope),
version: computed(() => parsed.value.version),
trailing: computed(() => parsed.value.trailing),
}
}
28 changes: 15 additions & 13 deletions app/pages/search.vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@
} = useGlobalSearch()
const query = computed(() => searchQuery.value)

const {
scope: packageScope,
name: packageName,
trailing: queryTrailing,
} = useParsedSearchQuery(query)

const versionStrippedQuery = computed(() =>
`${packageName.value}${queryTrailing.value ?? ''}`.trim(),
)

// Track if page just loaded (for hiding "Searching..." during view transition)
const hasInteracted = shallowRef(false)
onMounted(() => {
Expand Down Expand Up @@ -102,7 +112,7 @@
objects = objects.filter(r => !isPlatformSpecificPackage(r.package.name))
}

const q = query.value.trim().toLowerCase()
const q = versionStrippedQuery.value.trim().toLowerCase()
if (!q) {
return objects === raw.objects ? raw : { ...raw, objects }
}
Expand Down Expand Up @@ -207,7 +217,7 @@
suggestions: validatedSuggestions,
packageAvailability,
} = useSearch(
committedQuery,
versionStrippedQuery,
searchProvider,
() => ({
size: requestedSize.value,
Expand Down Expand Up @@ -306,14 +316,6 @@
// Get connector state
const { isConnected, npmUser, listOrgUsers } = useConnector()

// Check if this is a scoped package and extract scope
const packageScope = computed(() => {
const q = query.value.trim()
if (!q.startsWith('@')) return null
const match = q.match(/^@([^/]+)\//)
return match ? match[1] : null
})

// Track org membership for scoped packages
const orgMembership = ref<Record<string, boolean>>({})

Expand Down Expand Up @@ -372,7 +374,7 @@

/** Check if there's an exact package match in results */
const hasExactPackageMatch = computed(() => {
const q = query.value.trim().toLowerCase()
const q = versionStrippedQuery.value.trim().toLowerCase()
if (!q || !visibleResults.value) return false
return visibleResults.value.objects.some(r => r.package.name.toLowerCase() === q)
})
Expand Down Expand Up @@ -445,7 +447,7 @@
}

// Navigate to package page
async function navigateToPackage(packageName: string) {

Check warning on line 450 in app/pages/search.vue

View workflow job for this annotation

GitHub Actions / 🤖 Autofix code

eslint(no-shadow)

'packageName' is already declared in the upper scope.

Check warning on line 450 in app/pages/search.vue

View workflow job for this annotation

GitHub Actions / 🔠 Lint project

eslint(no-shadow)

'packageName' is already declared in the upper scope.
await navigateTo(packageRoute(packageName))
}

Expand Down Expand Up @@ -838,7 +840,7 @@

<div v-else-if="status === 'success' || status === 'error'" class="py-12">
<p class="text-fg-muted font-mono mb-6 text-center">
{{ $t('search.no_results', { query }) }}
{{ $t('search.no_results', { query: versionStrippedQuery }) }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</p>

<Transition
Expand Down Expand Up @@ -878,7 +880,7 @@
<PackageList
v-show="displayResults.length > 0 && !isRateLimited"
:results="displayResults"
:search-query="query"
:search-query="versionStrippedQuery"
:filters="filters"
search-context
heading-level="h2"
Expand Down
47 changes: 47 additions & 0 deletions app/utils/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
export interface ParsedSearchQuery {
/**
* The package name, in the format:
* - unscoped: `nuxt`
* - scoped: `@nuxt/devtools`
*/
name: string
/**
* The package specifier, e.g.
* - `nuxt` -> `nuxt`
* - `@nuxt/devtools` -> `devtools`
*/
specifier: string
/**
* The package scope (or org), e.g.
* - `nuxt` -> `undefined`
* - `@nuxt/devtools` -> `nuxt`
*/
scope?: string
/**
* Optionally, the version info if specified using the syntax:
* - `nuxt@^4.0.0` -> `^4.0.0`
* - `@nuxt/devtools@latest` -> `latest`
*/
version?: string
/**
* The untrimmed trailing text after the package query.
*/
trailing?: string
}

export function parseSearchQuery(query: string): ParsedSearchQuery {
const q = query.trim()

// Regex matches a (un)scoped package and optionally extracts versioning info and trailing text using the following syntax: @scope/specifier@version
// It makes use of 4 capture groups to extract this info.
const match = q.match(
/^(?:@(?<scope>[^/]+)\/)?(?<specifier>[^/@ ]+)(?:@(?<version>[^ ]*))?(?<trailing>.*)/,
)
if (!match) return { name: q, specifier: q }

const { scope, specifier, version, trailing } = match.groups ?? {}
if (!specifier) return { name: q, specifier: q }

const name = scope ? `@${scope}/${specifier}` : specifier
return { name, specifier, scope, version, trailing }
}
68 changes: 68 additions & 0 deletions test/unit/app/utils/search.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { parseSearchQuery } from '../../../../app/utils/search'

describe('parseSearchQuery', () => {
it('parses unscoped package names', () => {
expect(parseSearchQuery('nuxt')).toEqual({
name: 'nuxt',
specifier: 'nuxt',
scope: undefined,
version: undefined,
trailing: '',
})
})

it('parses scoped package names', () => {
expect(parseSearchQuery('@nuxt/devtools')).toEqual({
name: '@nuxt/devtools',
specifier: 'devtools',
scope: 'nuxt',
version: undefined,
trailing: '',
})
})

it('parses unscoped package names with version', () => {
expect(parseSearchQuery('nuxt@^4.0.0')).toEqual({
name: 'nuxt',
specifier: 'nuxt',
scope: undefined,
version: '^4.0.0',
trailing: '',
})
expect(parseSearchQuery('next@15.3.0-canary.1')).toEqual({
name: 'next',
specifier: 'next',
scope: undefined,
version: '15.3.0-canary.1',
trailing: '',
})
})

it('parses scoped package names with version', () => {
expect(parseSearchQuery('@nuxt/devtools@latest')).toEqual({
name: '@nuxt/devtools',
specifier: 'devtools',
scope: 'nuxt',
version: 'latest',
trailing: '',
})
})

it('returns trailing text', () => {
expect(parseSearchQuery('nuxt keyword:frontend')).toEqual({
name: 'nuxt',
specifier: 'nuxt',
scope: undefined,
version: undefined,
trailing: ' keyword:frontend',
})
expect(parseSearchQuery('@nuxt/devtools@latest keyword:devtools')).toEqual({
name: '@nuxt/devtools',
specifier: 'devtools',
scope: 'nuxt',
version: 'latest',
trailing: ' keyword:devtools',
})
})
})
Loading