forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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}