[READ-ONLY] a fast, modern browser for the npm registry
at main 491 lines 15 kB view raw
1/** 2 * Filter pipeline and sorting logic for package lists 3 */ 4import type { NpmSearchResult } from '#shared/types/npm-registry' 5import type { 6 DownloadRange, 7 FilterChip, 8 SearchScope, 9 SecurityFilter, 10 SortOption, 11 StructuredFilters, 12 UpdatedWithin, 13} from '#shared/types/preferences' 14import { 15 DEFAULT_FILTERS, 16 DOWNLOAD_RANGES, 17 parseSortOption, 18 UPDATED_WITHIN_OPTIONS, 19} from '#shared/types/preferences' 20 21/** 22 * Parsed search operators from text input 23 */ 24export interface ParsedSearchOperators { 25 name?: string[] 26 description?: string[] 27 keywords?: string[] 28 text?: string // Remaining text without operators 29} 30 31/** 32 * Parse search operators from text input. 33 * Supports: name:, desc:/description:, kw:/keyword: 34 * Multiple values can be comma-separated: kw:foo,bar 35 * Remaining text is treated as a general search term. 36 * 37 * Example: "name:react kw:typescript,hooks some text" 38 * Returns: { name: ['react'], keywords: ['typescript', 'hooks'], text: 'some text' } 39 */ 40export function parseSearchOperators(input: string): ParsedSearchOperators { 41 const result: ParsedSearchOperators = {} 42 43 // Regex to match operators: name:value, desc:value, description:value, kw:value, keyword:value 44 // Value continues until whitespace or next operator 45 const operatorRegex = /\b(name|desc|description|kw|keyword):(\S+)/gi 46 47 let remaining = input 48 let match 49 50 while ((match = operatorRegex.exec(input)) !== null) { 51 const [fullMatch, operator, value] = match 52 if (!operator || !value) continue 53 54 const values = value 55 .split(',') 56 .map(v => v.trim()) 57 .filter(Boolean) 58 59 const normalizedOp = operator.toLowerCase() 60 if (normalizedOp === 'name') { 61 result.name = [...(result.name ?? []), ...values] 62 } else if (normalizedOp === 'desc' || normalizedOp === 'description') { 63 result.description = [...(result.description ?? []), ...values] 64 } else if (normalizedOp === 'kw' || normalizedOp === 'keyword') { 65 result.keywords = [...(result.keywords ?? []), ...values] 66 } 67 68 // Remove matched operator from remaining text 69 remaining = remaining.replace(fullMatch, '') 70 } 71 72 // Clean up remaining text 73 const cleanedText = remaining.trim().replace(/\s+/g, ' ') 74 if (cleanedText) { 75 result.text = cleanedText 76 } 77 78 return result 79} 80 81/** 82 * Check if parsed operators has any content 83 */ 84export function hasSearchOperators(parsed: ParsedSearchOperators): boolean { 85 return !!(parsed.name?.length || parsed.description?.length || parsed.keywords?.length) 86} 87 88interface UseStructuredFiltersOptions { 89 packages: Ref<NpmSearchResult[]> 90 searchQueryModel?: Ref<string> 91 initialFilters?: Partial<StructuredFilters> 92 initialSort?: SortOption 93} 94 95// Pure filter predicates (no closure dependencies) 96function matchesKeywords(pkg: NpmSearchResult, keywords: string[]): boolean { 97 if (keywords.length === 0) return true 98 const pkgKeywords = new Set((pkg.package.keywords ?? []).map(k => k.toLowerCase())) 99 // AND logic: package must have ALL selected keywords (case-insensitive) 100 return keywords.every(k => pkgKeywords.has(k.toLowerCase())) 101} 102 103function matchesSecurity(pkg: NpmSearchResult, security: SecurityFilter): boolean { 104 if (security === 'all') return true 105 const hasWarnings = (pkg.flags?.insecure ?? 0) > 0 106 if (security === 'secure') return !hasWarnings 107 if (security === 'warnings') return hasWarnings 108 return true 109} 110 111/** 112 * Composable for structured filtering and sorting of package lists 113 * 114 */ 115export function useStructuredFilters(options: UseStructuredFiltersOptions) { 116 const route = useRoute() 117 const router = useRouter() 118 const { packages, initialFilters, initialSort, searchQueryModel } = options 119 const { t } = useI18n() 120 121 const searchQuery = shallowRef(normalizeSearchParam(route.query.q)) 122 watch( 123 () => route.query.q, 124 urlQuery => { 125 const value = normalizeSearchParam(urlQuery) 126 if (searchQuery.value !== value) { 127 searchQuery.value = value 128 } 129 }, 130 ) 131 132 // Filter state 133 const filters = ref<StructuredFilters>({ 134 ...DEFAULT_FILTERS, 135 ...initialFilters, 136 }) 137 138 // Sort state 139 const sortOption = shallowRef<SortOption>(initialSort ?? 'updated-desc') 140 141 // Available keywords extracted from all packages 142 const availableKeywords = computed(() => { 143 const keywordCounts = new Map<string, number>() 144 for (const pkg of packages.value) { 145 const keywords = pkg.package.keywords ?? [] 146 for (const keyword of keywords) { 147 keywordCounts.set(keyword, (keywordCounts.get(keyword) ?? 0) + 1) 148 } 149 } 150 // Sort by count descending 151 return Array.from(keywordCounts.entries()) 152 .sort((a, b) => b[1] - a[1]) 153 .map(([keyword]) => keyword) 154 }) 155 156 // Filter predicates 157 function matchesTextFilter(pkg: NpmSearchResult, text: string, scope: SearchScope): boolean { 158 if (!text) return true 159 160 const pkgName = pkg.package.name.toLowerCase() 161 const pkgDescription = (pkg.package.description ?? '').toLowerCase() 162 const pkgKeywords = (pkg.package.keywords ?? []).map(k => k.toLowerCase()) 163 164 // When scope is 'all', parse and handle operators 165 if (scope === 'all') { 166 const parsed = parseSearchOperators(text) 167 168 // If operators are present, use structured matching 169 if (hasSearchOperators(parsed)) { 170 // All specified operators must match (AND logic between operator types) 171 // Within each operator, any value can match (OR logic within operator) 172 173 if (parsed.name?.length) { 174 const nameMatches = parsed.name.some(n => pkgName.includes(n.toLowerCase())) 175 if (!nameMatches) return false 176 } 177 178 if (parsed.description?.length) { 179 const descMatches = parsed.description.some(d => pkgDescription.includes(d.toLowerCase())) 180 if (!descMatches) return false 181 } 182 183 if (parsed.keywords?.length) { 184 const kwMatches = parsed.keywords.some(kw => 185 pkgKeywords.some(pk => pk.includes(kw.toLowerCase())), 186 ) 187 if (!kwMatches) return false 188 } 189 190 // If there's remaining text, it must match somewhere 191 if (parsed.text) { 192 const textLower = parsed.text.toLowerCase() 193 const textMatches = 194 pkgName.includes(textLower) || 195 pkgDescription.includes(textLower) || 196 pkgKeywords.some(k => k.includes(textLower)) 197 if (!textMatches) return false 198 } 199 200 return true 201 } 202 203 // No operators - fall through to standard 'all' search 204 const lower = text.toLowerCase() 205 return ( 206 pkgName.includes(lower) || 207 pkgDescription.includes(lower) || 208 pkgKeywords.some(k => k.includes(lower)) 209 ) 210 } 211 212 // Non-'all' scopes - simple matching 213 const lower = text.toLowerCase() 214 switch (scope) { 215 case 'name': 216 return pkgName.includes(lower) 217 case 'description': 218 return pkgDescription.includes(lower) 219 case 'keywords': 220 return pkgKeywords.some(k => k.includes(lower)) 221 default: 222 return pkgName.includes(lower) 223 } 224 } 225 226 function matchesDownloadRange(pkg: NpmSearchResult, range: DownloadRange): boolean { 227 if (range === 'any') return true 228 const downloads = pkg.downloads?.weekly ?? 0 229 const config = DOWNLOAD_RANGES.find(r => r.value === range) 230 if (!config) return true 231 if (config.min !== undefined && downloads < config.min) return false 232 if (config.max !== undefined && downloads >= config.max) return false 233 return true 234 } 235 236 function matchesUpdatedWithin(pkg: NpmSearchResult, within: UpdatedWithin): boolean { 237 if (within === 'any') return true 238 const config = UPDATED_WITHIN_OPTIONS.find(o => o.value === within) 239 if (!config?.days) return true 240 241 const updatedDate = new Date(pkg.package.date) 242 const cutoff = new Date() 243 cutoff.setDate(cutoff.getDate() - config.days) 244 return updatedDate >= cutoff 245 } 246 247 // Apply all filters 248 const filteredPackages = computed(() => { 249 return packages.value.filter(pkg => { 250 if (!matchesTextFilter(pkg, filters.value.text, filters.value.searchScope)) return false 251 if (!matchesDownloadRange(pkg, filters.value.downloadRange)) return false 252 if (!matchesKeywords(pkg, filters.value.keywords)) return false 253 if (!matchesSecurity(pkg, filters.value.security)) return false 254 if (!matchesUpdatedWithin(pkg, filters.value.updatedWithin)) return false 255 return true 256 }) 257 }) 258 259 // Sort comparators 260 function comparePackages(a: NpmSearchResult, b: NpmSearchResult, option: SortOption): number { 261 const { key, direction } = parseSortOption(option) 262 const multiplier = direction === 'asc' ? 1 : -1 263 264 let diff: number 265 switch (key) { 266 case 'downloads-week': 267 diff = (a.downloads?.weekly ?? 0) - (b.downloads?.weekly ?? 0) 268 break 269 case 'downloads-day': 270 case 'downloads-month': 271 case 'downloads-year': 272 // Not yet implemented - fall back to weekly 273 diff = (a.downloads?.weekly ?? 0) - (b.downloads?.weekly ?? 0) 274 break 275 case 'updated': 276 diff = new Date(a.package.date).getTime() - new Date(b.package.date).getTime() 277 break 278 case 'name': 279 diff = a.package.name.localeCompare(b.package.name) 280 break 281 case 'quality': 282 diff = (a.score?.detail?.quality ?? 0) - (b.score?.detail?.quality ?? 0) 283 break 284 case 'popularity': 285 diff = (a.score?.detail?.popularity ?? 0) - (b.score?.detail?.popularity ?? 0) 286 break 287 case 'maintenance': 288 diff = (a.score?.detail?.maintenance ?? 0) - (b.score?.detail?.maintenance ?? 0) 289 break 290 case 'score': 291 diff = (a.score?.final ?? 0) - (b.score?.final ?? 0) 292 break 293 case 'relevance': 294 // Relevance preserves server order (already sorted by search relevance) 295 diff = 0 296 break 297 default: 298 diff = 0 299 } 300 301 return diff * multiplier 302 } 303 304 // Apply sorting to filtered results 305 const sortedPackages = computed(() => { 306 return [...filteredPackages.value].sort((a, b) => comparePackages(a, b, sortOption.value)) 307 }) 308 309 // i18n key mappings for filter chip values 310 const downloadRangeLabels = computed<Record<DownloadRange, string>>(() => ({ 311 'any': t('filters.download_range.any'), 312 'lt100': t('filters.download_range.lt100'), 313 '100-1k': t('filters.download_range.100_1k'), 314 '1k-10k': t('filters.download_range.1k_10k'), 315 '10k-100k': t('filters.download_range.10k_100k'), 316 'gt100k': t('filters.download_range.gt100k'), 317 })) 318 319 const securityLabels = computed<Record<SecurityFilter, string>>(() => ({ 320 all: t('filters.security_options.all'), 321 secure: t('filters.security_options.secure'), 322 warnings: t('filters.security_options.insecure'), 323 })) 324 325 const updatedWithinLabels = computed<Record<UpdatedWithin, string>>(() => ({ 326 any: t('filters.updated.any'), 327 week: t('filters.updated.week'), 328 month: t('filters.updated.month'), 329 quarter: t('filters.updated.quarter'), 330 year: t('filters.updated.year'), 331 })) 332 333 // Active filter chips for display 334 const activeFilters = computed<FilterChip[]>(() => { 335 const chips: FilterChip[] = [] 336 337 if (filters.value.text) { 338 chips.push({ 339 id: 'text', 340 type: 'text', 341 label: t('filters.chips.search'), 342 value: filters.value.text, 343 }) 344 } 345 346 if (filters.value.downloadRange !== 'any') { 347 chips.push({ 348 id: 'downloadRange', 349 type: 'downloadRange', 350 label: t('filters.chips.downloads'), 351 value: downloadRangeLabels.value[filters.value.downloadRange], 352 }) 353 } 354 355 for (const keyword of filters.value.keywords) { 356 chips.push({ 357 id: `keyword-${keyword}`, 358 type: 'keywords', 359 label: t('filters.chips.keyword'), 360 value: keyword, 361 }) 362 } 363 364 if (filters.value.security !== 'all') { 365 chips.push({ 366 id: 'security', 367 type: 'security', 368 label: t('filters.chips.security'), 369 value: securityLabels.value[filters.value.security], 370 }) 371 } 372 373 if (filters.value.updatedWithin !== 'any') { 374 chips.push({ 375 id: 'updatedWithin', 376 type: 'updatedWithin', 377 label: t('filters.chips.updated'), 378 value: updatedWithinLabels.value[filters.value.updatedWithin], 379 }) 380 } 381 382 return chips 383 }) 384 385 // Check if any filters are active 386 const hasActiveFilters = computed(() => activeFilters.value.length > 0) 387 388 // Filter update helpers 389 function setTextFilter(text: string) { 390 filters.value.text = text 391 } 392 393 function setSearchScope(scope: SearchScope) { 394 filters.value.searchScope = scope 395 } 396 397 function setDownloadRange(range: DownloadRange) { 398 filters.value.downloadRange = range 399 } 400 401 function addKeyword(keyword: string) { 402 if (!filters.value.keywords.includes(keyword)) { 403 filters.value.keywords = [...filters.value.keywords, keyword] 404 const newQ = searchQuery.value 405 ? `${searchQuery.value.trim()} keyword:${keyword}` 406 : `keyword:${keyword}` 407 router.replace({ query: { ...route.query, q: newQ } }) 408 409 if (searchQueryModel) searchQueryModel.value = newQ 410 } 411 } 412 413 function removeKeyword(keyword: string) { 414 filters.value.keywords = filters.value.keywords.filter(k => k !== keyword) 415 const newQ = searchQuery.value.replace(new RegExp(`keyword:${keyword}($| )`, 'g'), '').trim() 416 router.replace({ query: { ...route.query, q: newQ || undefined } }) 417 if (searchQueryModel) searchQueryModel.value = newQ 418 } 419 420 function toggleKeyword(keyword: string) { 421 if (filters.value.keywords.includes(keyword)) { 422 removeKeyword(keyword) 423 } else { 424 addKeyword(keyword) 425 } 426 } 427 428 function setSecurity(security: SecurityFilter) { 429 filters.value.security = security 430 } 431 432 function setUpdatedWithin(within: UpdatedWithin) { 433 filters.value.updatedWithin = within 434 } 435 436 function clearFilter(chip: FilterChip) { 437 switch (chip.type) { 438 case 'text': 439 filters.value.text = '' 440 break 441 case 'downloadRange': 442 filters.value.downloadRange = 'any' 443 break 444 case 'keywords': 445 removeKeyword(chip.value as string) 446 break 447 case 'security': 448 filters.value.security = 'all' 449 break 450 case 'updatedWithin': 451 filters.value.updatedWithin = 'any' 452 break 453 } 454 } 455 456 function clearAllFilters() { 457 filters.value = { ...DEFAULT_FILTERS } 458 } 459 460 function setSort(option: SortOption) { 461 sortOption.value = option 462 } 463 464 return { 465 // State 466 filters, 467 sortOption, 468 469 // Derived 470 filteredPackages, 471 sortedPackages, 472 availableKeywords, 473 activeFilters, 474 hasActiveFilters, 475 476 // Filter setters 477 setTextFilter, 478 setSearchScope, 479 setDownloadRange, 480 addKeyword, 481 removeKeyword, 482 toggleKeyword, 483 setSecurity, 484 setUpdatedWithin, 485 clearFilter, 486 clearAllFilters, 487 488 // Sort setter 489 setSort, 490 } 491}