From cd010915ffc252e6ac64ac9f45a8dbaf7c14a6ef Mon Sep 17 00:00:00 2001 From: Codefoxdev Date: Tue, 24 Feb 2026 19:21:28 +0100 Subject: [PATCH 1/9] feat: parse search query to extact essential package info --- app/pages/search.vue | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/pages/search.vue b/app/pages/search.vue index dcb2853829..5dd929a052 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -33,6 +33,24 @@ const updateUrlPage = debounce((page: number) => { const { model: searchQuery, provider: searchProvider } = useGlobalSearch() const query = computed(() => searchQuery.value) +// Parses the raw search query to extract package info, such as scope, name, version. +// Uses this info to provide a strippedQuery (the query, but without version info) that is used for searching. +const parsedQuery = computed(() => { + const q = query.value.trim() + // Regex matches a (un)scoped package and optionally extracts versioning info 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 { scope: null, name: q, version: null, strippedQuery: q } + + const [, scope, specifier, version, trailing] = match + // Reconstruct the query without the version info, essentially stripping the version data: + // anything directly after the @ for the version specifier is stripped. + const name = scope ? `@${scope}/${specifier}` : (specifier ?? '') + const strippedQuery = `${name} ${trailing ?? ''}`.trim() + + return { scope: scope ?? null, name: name, version: version || null, strippedQuery } +}) + // Track if page just loaded (for hiding "Searching..." during view transition) const hasInteracted = shallowRef(false) onMounted(() => { From c32e8457fa05a8264f71985fe111db2e43e656ad Mon Sep 17 00:00:00 2001 From: Codefoxdev Date: Tue, 24 Feb 2026 19:27:23 +0100 Subject: [PATCH 2/9] refactor: update the relevant components to use the new stripped query --- app/pages/search.vue | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/app/pages/search.vue b/app/pages/search.vue index 5dd929a052..dba340ebac 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -51,6 +51,9 @@ const parsedQuery = computed(() => { return { scope: scope ?? null, name: name, version: version || null, strippedQuery } }) +const packageScope = computed(() => parsedQuery.value.scope) +const strippedQuery = computed(() => parsedQuery.value.strippedQuery) + // Track if page just loaded (for hiding "Searching..." during view transition) const hasInteracted = shallowRef(false) onMounted(() => { @@ -210,7 +213,7 @@ const { suggestions: validatedSuggestions, packageAvailability, } = useSearch( - query, + strippedQuery, searchProvider, () => ({ size: requestedSize.value, @@ -305,14 +308,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>({}) @@ -685,7 +680,7 @@ defineOgImageComponent('Default', {

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

From 123a992644d150b00ab5c46a6f7a396ecb410ec1 Mon Sep 17 00:00:00 2001 From: Codefoxdev Date: Tue, 24 Feb 2026 20:06:30 +0100 Subject: [PATCH 3/9] refactor: update regex query to use named capture groups --- app/pages/search.vue | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/pages/search.vue b/app/pages/search.vue index dba340ebac..a31a851747 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -39,10 +39,12 @@ const parsedQuery = computed(() => { const q = query.value.trim() // Regex matches a (un)scoped package and optionally extracts versioning info using the following syntax: @scope/specifier@version // It makes use of 4 capture groups to extract this info. - const match = q.match(/^(?:@([^/]+)\/)?([^/@ ]+)(?:@([^ ]*))?(.*)/) + const match = q.match( + /^(?:@(?[^/]+)\/)?(?[^/@ ]+)(?:@(?[^ ]*))?(?.*)/, + ) if (!match) return { scope: null, name: q, version: null, strippedQuery: q } - const [, scope, specifier, version, trailing] = match + const { scope, specifier, version, trailing } = match.groups ?? {} // Reconstruct the query without the version info, essentially stripping the version data: // anything directly after the @ for the version specifier is stripped. const name = scope ? `@${scope}/${specifier}` : (specifier ?? '') From 5bd90d38a4b41d7ff95ea7ea01e733db8b301f36 Mon Sep 17 00:00:00 2001 From: Codefoxdev Date: Thu, 30 Apr 2026 22:06:06 +0200 Subject: [PATCH 4/9] refactor: extract parsing logic to seperate util file --- app/composables/useParsedSearchQuery.ts | 21 +++++++++++ app/pages/search.vue | 39 +++++++------------- app/utils/search.ts | 47 +++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 26 deletions(-) create mode 100644 app/composables/useParsedSearchQuery.ts create mode 100644 app/utils/search.ts 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 a31a851747..9d2bfa64a2 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -33,28 +33,15 @@ const updateUrlPage = debounce((page: number) => { const { model: searchQuery, provider: searchProvider } = useGlobalSearch() const query = computed(() => searchQuery.value) -// Parses the raw search query to extract package info, such as scope, name, version. -// Uses this info to provide a strippedQuery (the query, but without version info) that is used for searching. -const parsedQuery = computed(() => { - const q = query.value.trim() - // Regex matches a (un)scoped package and optionally extracts versioning info 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 { scope: null, name: q, version: null, strippedQuery: q } - - const { scope, specifier, version, trailing } = match.groups ?? {} - // Reconstruct the query without the version info, essentially stripping the version data: - // anything directly after the @ for the version specifier is stripped. - const name = scope ? `@${scope}/${specifier}` : (specifier ?? '') - const strippedQuery = `${name} ${trailing ?? ''}`.trim() - - return { scope: scope ?? null, name: name, version: version || null, strippedQuery } -}) +const { + scope: packageScope, + name: packageName, + trailing: queryTrailing, +} = useParsedSearchQuery(query) -const packageScope = computed(() => parsedQuery.value.scope) -const strippedQuery = computed(() => parsedQuery.value.strippedQuery) +const versionStrippedQuery = computed(() => + `${packageName.value} ${queryTrailing.value ?? ''}`.trim(), +) // Track if page just loaded (for hiding "Searching..." during view transition) const hasInteracted = shallowRef(false) @@ -103,7 +90,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 } } @@ -215,7 +202,7 @@ const { suggestions: validatedSuggestions, packageAvailability, } = useSearch( - strippedQuery, + versionStrippedQuery, searchProvider, () => ({ size: requestedSize.value, @@ -362,7 +349,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) }) @@ -682,7 +669,7 @@ defineOgImageComponent('Default', {

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

@@ -718,7 +705,7 @@ defineOgImageComponent('Default', { `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 } +} From 9d50b404a44b39cb6f3b3fccad142895f5f732a9 Mon Sep 17 00:00:00 2001 From: Codefoxdev Date: Thu, 30 Apr 2026 22:16:22 +0200 Subject: [PATCH 5/9] test: add unit tests for search utils --- test/unit/app/utils/search.spec.ts | 68 ++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 test/unit/app/utils/search.spec.ts 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', + }) + }) +}) From 0e1aafddf9f7bcf2fbf6b87e0ff687fcce69d22f Mon Sep 17 00:00:00 2001 From: Codefoxdev Date: Thu, 30 Apr 2026 22:23:40 +0200 Subject: [PATCH 6/9] fix: remove accidental whitespace in version-stripped query --- app/pages/search.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/search.vue b/app/pages/search.vue index 9d2bfa64a2..0a80b4267a 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -40,7 +40,7 @@ const { } = useParsedSearchQuery(query) const versionStrippedQuery = computed(() => - `${packageName.value} ${queryTrailing.value ?? ''}`.trim(), + `${packageName.value}${queryTrailing.value ?? ''}`.trim(), ) // Track if page just loaded (for hiding "Searching..." during view transition) From b1adf61bd98700f443f8b08f7b3c31ca9cd4684b Mon Sep 17 00:00:00 2001 From: Codefoxdev Date: Sun, 17 May 2026 22:15:08 +0200 Subject: [PATCH 7/9] fix: update package navigation logic --- app/pages/search.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/pages/search.vue b/app/pages/search.vue index c3429e9e01..a36d156c1f 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -56,7 +56,7 @@ const { scope: packageScope, name: packageName, trailing: queryTrailing, -} = useParsedSearchQuery(query) +} = useParsedSearchQuery(committedQuery) const versionStrippedQuery = computed(() => `${packageName.value}${queryTrailing.value ?? ''}`.trim(), @@ -504,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 } From d98d1f984c6aff11c9aa409745368c7acec9eff4 Mon Sep 17 00:00:00 2001 From: Codefoxdev Date: Sun, 17 May 2026 22:16:08 +0200 Subject: [PATCH 8/9] fix: screen reader announcing different string from screen results --- app/pages/search.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/pages/search.vue b/app/pages/search.vue index a36d156c1f..38122bbd05 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -640,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 }) } } From fb6ca60e83875d122bf559d51bf7b21156db9554 Mon Sep 17 00:00:00 2001 From: Codefoxdev Date: Sun, 17 May 2026 22:23:10 +0200 Subject: [PATCH 9/9] fix: shadowed variable name --- app/pages/search.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/pages/search.vue b/app/pages/search.vue index 38122bbd05..9010734a57 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -54,12 +54,12 @@ const query = computed(() => searchQuery.value) const { scope: packageScope, - name: packageName, + name: queryPackageName, trailing: queryTrailing, } = useParsedSearchQuery(committedQuery) const versionStrippedQuery = computed(() => - `${packageName.value}${queryTrailing.value ?? ''}`.trim(), + `${queryPackageName.value}${queryTrailing.value ?? ''}`.trim(), ) // Track if page just loaded (for hiding "Searching..." during view transition)