diff --git a/app/composables/useParsedSearchQuery.ts b/app/composables/useParsedSearchQuery.ts new file mode 100644 index 0000000000..8bd885467e --- /dev/null +++ b/app/composables/useParsedSearchQuery.ts @@ -0,0 +1,21 @@ +import type { ParsedSearchQuery } from '~/utils/search' +import { parseSearchQuery } from '~/utils/search' + +type ParsedSearchQueryRef = { + [K in keyof Required]: Ref +} + +/** + * Wrapper around `parseSearchQuery` that makes it reactive. + */ +export function useParsedSearchQuery(query: MaybeRefOrGetter): 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), + } +} diff --git a/app/pages/search.vue b/app/pages/search.vue index 6209335792..9010734a57 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -52,6 +52,16 @@ const { } = useGlobalSearch() const query = computed(() => searchQuery.value) +const { + scope: packageScope, + name: queryPackageName, + trailing: queryTrailing, +} = useParsedSearchQuery(committedQuery) + +const versionStrippedQuery = computed(() => + `${queryPackageName.value}${queryTrailing.value ?? ''}`.trim(), +) + // Track if page just loaded (for hiding "Searching..." during view transition) const hasInteracted = shallowRef(false) onMounted(() => { @@ -102,7 +112,7 @@ const visibleResults = computed(() => { 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 } } @@ -207,7 +217,7 @@ const { suggestions: validatedSuggestions, packageAvailability, } = useSearch( - committedQuery, + versionStrippedQuery, searchProvider, () => ({ size: requestedSize.value, @@ -306,14 +316,6 @@ const isValidPackageName = computed(() => isValidNewPackageName(query.value.trim // 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>({}) @@ -372,7 +374,7 @@ const claimPackageModalRef = useTemplateRef('claimPackageModalRef') /** 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) }) @@ -502,13 +504,13 @@ function handleResultsKeydown(e: KeyboardEvent) { // Check if first result matches the input value exactly const firstResult = displayResults.value[0] - if (firstResult?.package.name === inputValue) { + if (firstResult?.package.name === committedQuery.value) { pendingEnterQuery.value = null return navigateToPackage(firstResult.package.name) } // No match yet - store input value, watcher will handle navigation when results arrive - pendingEnterQuery.value = inputValue + pendingEnterQuery.value = committedQuery.value return } @@ -638,8 +640,8 @@ const rawLiveRegionMessage = computed(() => { } if (status.value === 'success' || status.value === 'error') { - if (displayResults.value.length === 0 && query.value) { - return $t('search.no_results', { query: query.value }) + if (displayResults.value.length === 0 && versionStrippedQuery.value) { + return $t('search.no_results', { query: versionStrippedQuery.value }) } } @@ -838,7 +840,7 @@ onBeforeUnmount(() => {

- {{ $t('search.no_results', { query }) }} + {{ $t('search.no_results', { query: versionStrippedQuery }) }}

{ `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( + /^(?:@(?[^/]+)\/)?(?[^/@ ]+)(?:@(?[^ ]*))?(?.*)/, + ) + 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 } +} diff --git a/test/unit/app/utils/search.spec.ts b/test/unit/app/utils/search.spec.ts new file mode 100644 index 0000000000..2c2274982e --- /dev/null +++ b/test/unit/app/utils/search.spec.ts @@ -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', + }) + }) +})