@@ -75,6 +75,17 @@ export function parseSearchOperators(input: string): ParsedSearchOperators {
7575 result . text = cleanedText
7676 }
7777
78+ // Deduplicate keywords (case-insensitive)
79+ if ( result . keywords ) {
80+ const seen = new Set < string > ( )
81+ result . keywords = result . keywords . filter ( kw => {
82+ const lower = kw . toLowerCase ( )
83+ if ( seen . has ( lower ) ) return false
84+ seen . add ( lower )
85+ return true
86+ } )
87+ }
88+
7889 return result
7990}
8091
@@ -85,6 +96,13 @@ export function hasSearchOperators(parsed: ParsedSearchOperators): boolean {
8596 return ! ! ( parsed . name ?. length || parsed . description ?. length || parsed . keywords ?. length )
8697}
8798
99+ /**
100+ * Escape special regex characters in a string
101+ */
102+ function escapeRegExp ( str : string ) : string {
103+ return str . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' )
104+ }
105+
88106interface UseStructuredFiltersOptions {
89107 packages : Ref < NpmSearchResult [ ] >
90108 initialFilters ?: Partial < StructuredFilters >
@@ -140,7 +158,8 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) {
140158 const parsed = parseSearchOperators ( value )
141159
142160 filters . value . text = parsed . text ?? ''
143- filters . value . keywords = [ ...( parsed . keywords ?? [ ] ) ]
161+ // Deduplicate keywords (in case of both kw: and keyword: for same value)
162+ filters . value . keywords = parsed . keywords ?? [ ]
144163
145164 // Note: We intentionally don't reset other filters (security, downloadRange, etc.)
146165 // as those are not typically driven by the search query string structure
@@ -423,7 +442,38 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) {
423442
424443 function removeKeyword ( keyword : string ) {
425444 filters . value . keywords = filters . value . keywords . filter ( k => k !== keyword )
426- const newQ = searchQuery . value . replace ( new RegExp ( `keyword:${ keyword } ($| )` , 'g' ) , '' ) . trim ( )
445+
446+ // Need to handle both kw:xxx and keyword:xxx formats
447+ // Also handle comma-separated values like kw:foo,bar,baz
448+ let newQ = searchQuery . value
449+
450+ // First, try to remove standalone keyword:xxx or kw:xxx
451+ // Match: (kw|keyword):value followed by space or end of string
452+ newQ = newQ . replace ( new RegExp ( `\\b(?:kw|keyword):${ escapeRegExp ( keyword ) } (?=\\s|$)` , 'gi' ) , '' )
453+
454+ // Handle comma-separated values: remove the keyword from within a list
455+ // e.g., "kw:foo,bar,baz" should become "kw:foo,baz" if removing "bar"
456+ newQ = newQ . replace (
457+ new RegExp (
458+ `\\b((?:kw|keyword):)([^\\s]*,)?${ escapeRegExp ( keyword ) } (,[^\\s]*)?(?=\\s|$)` ,
459+ 'gi' ,
460+ ) ,
461+ ( match , prefix , before , after ) => {
462+ const beforePart = before ?. replace ( / , $ / , '' ) ?? ''
463+ const afterPart = after ?. replace ( / ^ , / , '' ) ?? ''
464+ if ( ! beforePart && ! afterPart ) {
465+ // This was the only keyword in the operator
466+ return ''
467+ }
468+ // Reconstruct with remaining keywords
469+ const separator = beforePart && afterPart ? ',' : ''
470+ return `${ prefix } ${ beforePart } ${ separator } ${ afterPart } `
471+ } ,
472+ )
473+
474+ // Clean up any double spaces and trim
475+ newQ = newQ . replace ( / \s + / g, ' ' ) . trim ( )
476+
427477 router . replace ( { query : { ...route . query , q : newQ || undefined } } )
428478 }
429479
0 commit comments