[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: support sorting algolia search results (#1227)

authored by

Daniel Roe and committed by
GitHub
8039e144 0c035c8b

+247 -180
+14 -9
app/components/Package/ListToolbar.vue
··· 30 30 activeFilters: FilterChip[] 31 31 /** When true, shows search-specific UI (relevance sort, no filters) */ 32 32 searchContext?: boolean 33 + /** Sort keys to force-disable (e.g. when the current provider doesn't support them) */ 34 + disabledSortKeys?: SortKey[] 33 35 }>() 34 36 35 37 const { t } = useI18n() ··· 58 60 const currentSort = computed(() => parseSortOption(sortOption.value)) 59 61 60 62 // Get available sort keys based on context 63 + const disabledSet = computed(() => new Set(props.disabledSortKeys ?? [])) 64 + 61 65 const availableSortKeys = computed(() => { 66 + const applyDisabled = (k: (typeof SORT_KEYS)[number]) => ({ 67 + ...k, 68 + disabled: k.disabled || disabledSet.value.has(k.key), 69 + }) 70 + 62 71 if (props.searchContext) { 63 - // In search context: show relevance (enabled) and others (disabled) 64 - return SORT_KEYS.filter(k => !k.searchOnly || k.key === 'relevance').map(k => 65 - Object.assign({}, k, { 66 - disabled: k.key !== 'relevance', 67 - }), 68 - ) 72 + // In search context: show relevance + non-disabled sorts (downloads, updated, name) 73 + return SORT_KEYS.filter(k => !k.searchOnly || k.key === 'relevance').map(applyDisabled) 69 74 } 70 75 // In org/user context: hide search-only sorts 71 - return SORT_KEYS.filter(k => !k.searchOnly) 76 + return SORT_KEYS.filter(k => !k.searchOnly).map(applyDisabled) 72 77 }) 73 78 74 79 // Handle sort key change from dropdown ··· 182 187 </div> 183 188 </div> 184 189 185 - <!-- Sort direction toggle (hidden in search context) --> 190 + <!-- Sort direction toggle --> 186 191 <button 187 - v-if="!searchContext" 192 + v-if="!searchContext || currentSort.key !== 'relevance'" 188 193 type="button" 189 194 class="p-1.5 rounded border border-border bg-bg-subtle text-fg-muted hover:text-fg hover:border-border-hover transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-2 focus-visible:ring-offset-bg" 190 195 :aria-label="$t('filters.sort.toggle_direction')"
+40
app/composables/npm/useAlgoliaSearch.ts
··· 229 229 } 230 230 } 231 231 232 + /** 233 + * Fetch metadata for specific packages by exact name. 234 + * Uses Algolia's getObjects REST API to look up packages by objectID 235 + * (which equals the package name in the npm-search index). 236 + */ 237 + async function getPackagesByName(packageNames: string[]): Promise<NpmSearchResponse> { 238 + if (packageNames.length === 0) { 239 + return { isStale: false, objects: [], total: 0, time: new Date().toISOString() } 240 + } 241 + 242 + // Algolia getObjects REST API: fetch up to 1000 objects by ID in a single request 243 + const response = await $fetch<{ results: (AlgoliaHit | null)[] }>( 244 + `https://${algolia.appId}-dsn.algolia.net/1/indexes/*/objects`, 245 + { 246 + method: 'POST', 247 + headers: { 248 + 'x-algolia-api-key': algolia.apiKey, 249 + 'x-algolia-application-id': algolia.appId, 250 + }, 251 + body: { 252 + requests: packageNames.map(name => ({ 253 + indexName, 254 + objectID: name, 255 + attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE, 256 + })), 257 + }, 258 + }, 259 + ) 260 + 261 + const hits = response.results.filter((r): r is AlgoliaHit => r !== null && 'name' in r) 262 + return { 263 + isStale: false, 264 + objects: hits.map(hitToSearchResult), 265 + total: hits.length, 266 + time: new Date().toISOString(), 267 + } 268 + } 269 + 232 270 return { 233 271 /** Search packages by text query */ 234 272 search, 235 273 /** Fetch all packages for an owner (org or user) */ 236 274 searchByOwner, 275 + /** Fetch metadata for specific packages by exact name */ 276 + getPackagesByName, 237 277 } 238 278 }
+6 -2
app/composables/npm/useNpmSearch.ts
··· 307 307 ) 308 308 309 309 // Re-search when provider changes 310 - watch(searchProvider, () => { 310 + watch(searchProvider, async () => { 311 311 cache.value = null 312 - asyncData.refresh() 312 + await asyncData.refresh() 313 + const targetSize = toValue(options).size 314 + if (targetSize) { 315 + await fetchMore(targetSize) 316 + } 313 317 }) 314 318 315 319 // Computed data that uses cache
+29 -124
app/composables/npm/useOrgPackages.ts
··· 1 - import type { NuxtApp } from '#app' 2 1 import type { NpmSearchResponse, NpmSearchResult, MinimalPackument } from '#shared/types' 3 2 import { emptySearchResponse, packumentToSearchResult } from './useNpmSearch' 4 3 import { mapWithConcurrency } from '#shared/utils/async' 5 4 6 5 /** 7 - * Fetch downloads for multiple packages. 8 - * Returns a map of package name -> weekly downloads. 9 - * Uses bulk API for unscoped packages, parallel individual requests for scoped. 10 - * Note: npm bulk downloads API does not support scoped packages. 11 - */ 12 - async function fetchBulkDownloads( 13 - $npmApi: NuxtApp['$npmApi'], 14 - packageNames: string[], 15 - options: Parameters<typeof $fetch>[1] = {}, 16 - ): Promise<Map<string, number>> { 17 - const downloads = new Map<string, number>() 18 - if (packageNames.length === 0) return downloads 19 - 20 - // Separate scoped and unscoped packages 21 - const scopedPackages = packageNames.filter(n => n.startsWith('@')) 22 - const unscopedPackages = packageNames.filter(n => !n.startsWith('@')) 23 - 24 - // Fetch unscoped packages via bulk API (max 128 per request) 25 - const bulkPromises: Promise<void>[] = [] 26 - const chunkSize = 100 27 - for (let i = 0; i < unscopedPackages.length; i += chunkSize) { 28 - const chunk = unscopedPackages.slice(i, i + chunkSize) 29 - bulkPromises.push( 30 - (async () => { 31 - try { 32 - const response = await $npmApi<Record<string, { downloads: number } | null>>( 33 - `/downloads/point/last-week/${chunk.join(',')}`, 34 - options, 35 - ) 36 - for (const [name, data] of Object.entries(response.data)) { 37 - if (data?.downloads !== undefined) { 38 - downloads.set(name, data.downloads) 39 - } 40 - } 41 - } catch { 42 - // Ignore errors - downloads are optional 43 - } 44 - })(), 45 - ) 46 - } 47 - 48 - // Fetch scoped packages in parallel batches (concurrency limit to avoid overwhelming the API) 49 - // Use Promise.allSettled to not fail on individual errors 50 - const scopedBatchSize = 20 // Concurrent requests per batch 51 - for (let i = 0; i < scopedPackages.length; i += scopedBatchSize) { 52 - const batch = scopedPackages.slice(i, i + scopedBatchSize) 53 - bulkPromises.push( 54 - (async () => { 55 - const results = await Promise.allSettled( 56 - batch.map(async name => { 57 - const encoded = encodePackageName(name) 58 - const { data } = await $npmApi<{ downloads: number }>( 59 - `/downloads/point/last-week/${encoded}`, 60 - ) 61 - return { name, downloads: data.downloads } 62 - }), 63 - ) 64 - for (const result of results) { 65 - if (result.status === 'fulfilled' && result.value.downloads !== undefined) { 66 - downloads.set(result.value.name, result.value.downloads) 67 - } 68 - } 69 - })(), 70 - ) 71 - } 72 - 73 - // Wait for all fetches to complete 74 - await Promise.all(bulkPromises) 75 - 76 - return downloads 77 - } 78 - 79 - /** 80 6 * Fetch all packages for an npm organization. 81 7 * 82 - * Always uses the npm registry's org endpoint as the source of truth for which 83 - * packages belong to the org. When Algolia is enabled, uses it to quickly fetch 84 - * metadata for those packages (instead of N+1 packument fetches). 8 + * 1. Gets the authoritative package list from the npm registry (single request) 9 + * 2. Fetches metadata from Algolia by exact name (single request) 10 + * 3. Falls back to individual packument fetches when Algolia is unavailable 85 11 */ 86 12 export function useOrgPackages(orgName: MaybeRefOrGetter<string>) { 87 13 const { searchProvider } = useSearchProvider() 88 - const { searchByOwner } = useAlgoliaSearch() 14 + const { getPackagesByName } = useAlgoliaSearch() 89 15 90 16 const asyncData = useLazyAsyncData( 91 17 () => `org-packages:${searchProvider.value}:${toValue(orgName)}`, 92 - async ({ $npmRegistry, $npmApi, ssrContext }, { signal }) => { 18 + async ({ $npmRegistry, ssrContext }, { signal }) => { 93 19 const org = toValue(orgName) 94 20 if (!org) { 95 21 return emptySearchResponse 96 22 } 97 23 98 - // Always get the authoritative package list from the npm registry. 99 - // Algolia's owner.name filter doesn't precisely match npm org membership 100 - // (e.g. it includes @nuxtjs/* packages for the @nuxt org). 24 + // Get the authoritative package list from the npm registry (single request) 101 25 let packageNames: string[] 102 26 try { 103 27 const { packages } = await $fetch<{ packages: string[]; count: number }>( ··· 126 50 return emptySearchResponse 127 51 } 128 52 129 - // --- Algolia fast path: use Algolia to get metadata for known packages --- 53 + // Fetch metadata + downloads from Algolia (single request via getObjects) 130 54 if (searchProvider.value === 'algolia') { 131 55 try { 132 - const response = await searchByOwner(org) 56 + const response = await getPackagesByName(packageNames) 133 57 if (response.objects.length > 0) { 134 - // Filter Algolia results to only include packages that are 135 - // actually in the org (per the npm registry's authoritative list) 136 - const orgPackageSet = new Set(packageNames.map(n => n.toLowerCase())) 137 - const filtered = response.objects.filter(obj => 138 - orgPackageSet.has(obj.package.name.toLowerCase()), 139 - ) 140 - 141 - if (filtered.length > 0) { 142 - return { 143 - ...response, 144 - objects: filtered, 145 - total: filtered.length, 146 - } 147 - } 58 + return response 148 59 } 149 60 } catch { 150 61 // Fall through to npm registry path 151 62 } 152 63 } 153 64 154 - // --- npm registry path: fetch packuments individually --- 155 - const [packuments, downloads] = await Promise.all([ 156 - (async () => { 157 - const results = await mapWithConcurrency( 158 - packageNames, 159 - async name => { 160 - try { 161 - const encoded = encodePackageName(name) 162 - const { data: pkg } = await $npmRegistry<MinimalPackument>(`/${encoded}`, { 163 - signal, 164 - }) 165 - return pkg 166 - } catch { 167 - return null 168 - } 169 - }, 170 - 10, 171 - ) 172 - return results.filter( 173 - (pkg): pkg is MinimalPackument => pkg !== null && !!pkg['dist-tags'], 174 - ) 175 - })(), 176 - fetchBulkDownloads($npmApi, packageNames, { signal }), 177 - ]) 65 + // npm fallback: fetch packuments individually 66 + const packuments = await mapWithConcurrency( 67 + packageNames, 68 + async name => { 69 + try { 70 + const encoded = encodePackageName(name) 71 + const { data: pkg } = await $npmRegistry<MinimalPackument>(`/${encoded}`, { 72 + signal, 73 + }) 74 + return pkg 75 + } catch { 76 + return null 77 + } 78 + }, 79 + 10, 80 + ) 178 81 179 - const results: NpmSearchResult[] = packuments.map(pkg => 180 - packumentToSearchResult(pkg, downloads.get(pkg.name)), 82 + const validPackuments = packuments.filter( 83 + (pkg): pkg is MinimalPackument => pkg !== null && !!pkg['dist-tags'], 181 84 ) 85 + 86 + const results: NpmSearchResult[] = validPackuments.map(pkg => packumentToSearchResult(pkg)) 182 87 183 88 return { 184 89 isStale: false,
+133 -45
app/pages/search.vue
··· 1 1 <script setup lang="ts"> 2 - import type { FilterChip } from '#shared/types/preferences' 2 + import type { FilterChip, SortKey } from '#shared/types/preferences' 3 + import { parseSortOption, PROVIDER_SORT_KEYS } from '#shared/types/preferences' 3 4 import { onKeyDown } from '@vueuse/core' 4 5 import { debounce } from 'perfect-debounce' 5 6 import { isValidNewPackageName, checkPackageExists } from '~/utils/package-name' ··· 8 9 9 10 const route = useRoute() 10 11 const router = useRouter() 12 + 13 + // Search provider 14 + const { search: algoliaSearch } = useAlgoliaSearch() 15 + const { isAlgolia } = useSearchProvider() 11 16 12 17 // Preferences (persisted to localStorage) 13 18 const { ··· 45 50 const pageSize = 25 46 51 const currentPage = shallowRef(1) 47 52 48 - // Calculate how many results we need based on current page and preferred page size 49 - const requestedSize = computed(() => { 50 - const numericPrefSize = preferredPageSize.value === 'all' ? 250 : preferredPageSize.value 51 - // Always fetch at least enough for the current page 52 - return Math.max(pageSize, currentPage.value * numericPrefSize) 53 - }) 54 - 55 53 // Get initial page from URL (for scroll restoration on reload) 56 54 const initialPage = computed(() => { 57 55 const p = Number.parseInt(normalizeSearchParam(route.query.page), 10) ··· 65 63 } 66 64 }) 67 65 68 - // Use incremental search with client-side caching 69 - const { 70 - data: results, 71 - status, 72 - isLoadingMore, 73 - hasMore, 74 - fetchMore, 75 - isRateLimited, 76 - } = useNpmSearch(query, () => ({ 77 - size: requestedSize.value, 78 - })) 79 - 80 66 // Results to display (directly from incremental search) 81 67 const rawVisibleResults = computed(() => results.value) 82 68 ··· 125 111 // Use structured filters for client-side refinement of search results 126 112 const resultsArray = computed(() => visibleResults.value?.objects ?? []) 127 113 114 + // All possible non-relevance sort keys 115 + const ALL_SORT_KEYS: SortKey[] = [ 116 + 'downloads-week', 117 + 'downloads-day', 118 + 'downloads-month', 119 + 'downloads-year', 120 + 'updated', 121 + 'name', 122 + 'quality', 123 + 'popularity', 124 + 'maintenance', 125 + 'score', 126 + ] 127 + 128 + // Disable sort keys the current provider can't meaningfully sort by 129 + const disabledSortKeys = computed<SortKey[]>(() => { 130 + const supported = PROVIDER_SORT_KEYS[isAlgolia.value ? 'algolia' : 'npm'] 131 + return ALL_SORT_KEYS.filter(k => !supported.has(k)) 132 + }) 133 + 128 134 // Minimal structured filters usage for search context (no client-side filtering) 129 135 const { 130 136 filters, 131 137 sortOption, 132 - sortedPackages, 133 138 availableKeywords, 134 139 activeFilters, 135 140 setTextFilter, ··· 148 153 initialSort: 'relevance-desc', // Default to search relevance 149 154 }) 150 155 151 - // Client-side filtered/sorted results for display 152 - // In search context, we always use server order (relevance) - no client-side filtering 156 + const isRelevanceSort = computed( 157 + () => sortOption.value === 'relevance-desc' || sortOption.value === 'relevance-asc', 158 + ) 159 + 160 + // Maximum eager-load sizes per provider for client-side sorting. 161 + // Algolia supports up to 1000 with offset/length pagination. 162 + // npm supports pagination via `from` parameter (no hard cap, but diminishing relevance). 163 + const EAGER_LOAD_SIZE = { algolia: 500, npm: 500 } as const 164 + 165 + // Calculate how many results we need based on current page and preferred page size 166 + const requestedSize = computed(() => { 167 + const numericPrefSize = preferredPageSize.value === 'all' ? 250 : preferredPageSize.value 168 + const base = Math.max(pageSize, currentPage.value * numericPrefSize) 169 + // When sorting by something other than relevance, fetch a large batch 170 + // so client-side sorting operates on a meaningful pool of matching results 171 + if (!isRelevanceSort.value) { 172 + const cap = isAlgolia.value ? EAGER_LOAD_SIZE.algolia : EAGER_LOAD_SIZE.npm 173 + return Math.max(base, cap) 174 + } 175 + return base 176 + }) 177 + 178 + // Reset to relevance sort when switching to a provider that doesn't support the current sort key 179 + watch(isAlgolia, algolia => { 180 + const { key } = parseSortOption(sortOption.value) 181 + const supported = PROVIDER_SORT_KEYS[algolia ? 'algolia' : 'npm'] 182 + if (!supported.has(key)) { 183 + sortOption.value = 'relevance-desc' 184 + } 185 + }) 186 + 187 + // Use incremental search with client-side caching 188 + const { 189 + data: results, 190 + status, 191 + isLoadingMore, 192 + hasMore, 193 + fetchMore, 194 + isRateLimited, 195 + } = useNpmSearch(query, () => ({ 196 + size: requestedSize.value, 197 + })) 198 + 199 + // Client-side sorted results for display 200 + // The search API already handles text filtering, so we only need to sort. 153 201 const displayResults = computed(() => { 154 - // When using relevance sort, return original server-sorted results 155 - if (sortOption.value === 'relevance-desc' || sortOption.value === 'relevance-asc') { 202 + if (isRelevanceSort.value) { 156 203 return resultsArray.value 157 204 } 158 205 159 - return sortedPackages.value 206 + // Sort the fetched results client-side — neither Algolia nor npm support 207 + // arbitrary sort orders server-side, so we fetch a large batch and sort here 208 + const { key, direction } = parseSortOption(sortOption.value) 209 + const multiplier = direction === 'asc' ? 1 : -1 210 + 211 + return [...resultsArray.value].sort((a, b) => { 212 + let diff: number 213 + switch (key) { 214 + case 'downloads-week': 215 + case 'downloads-day': 216 + case 'downloads-month': 217 + case 'downloads-year': 218 + diff = (a.downloads?.weekly ?? 0) - (b.downloads?.weekly ?? 0) 219 + break 220 + case 'updated': 221 + diff = new Date(a.package.date).getTime() - new Date(b.package.date).getTime() 222 + break 223 + case 'name': 224 + diff = a.package.name.localeCompare(b.package.name) 225 + break 226 + default: 227 + diff = 0 228 + } 229 + return diff * multiplier 230 + }) 160 231 }) 161 232 162 233 const resultCount = computed(() => displayResults.value.length) 234 + 235 + /** 236 + * The effective total for display and pagination purposes. 237 + * When sorting by non-relevance, we're working with a fetched subset (e.g. 250), 238 + * not the full Algolia total (e.g. 92,324). Show the actual working set size. 239 + */ 240 + const effectiveTotal = computed(() => { 241 + if (isRelevanceSort.value) { 242 + return visibleResults.value?.total ?? 0 243 + } 244 + // When sorting, the total is the number of results we actually fetched and sorted 245 + return displayResults.value.length 246 + }) 163 247 164 248 // Handle filter chip removal 165 249 function handleClearFilter(chip: FilterChip) { ··· 315 399 316 400 /** Cache for existence checks to avoid repeated API calls */ 317 401 const existenceCache = ref<Record<string, boolean | 'pending'>>({}) 318 - 319 - const { search: algoliaSearch } = useAlgoliaSearch() 320 - const { isAlgolia } = useSearchProvider() 321 402 322 403 /** 323 404 * Check if an org exists by searching for scoped packages (@orgname/...). ··· 773 854 :columns="columns" 774 855 v-model:pagination-mode="paginationMode" 775 856 v-model:page-size="preferredPageSize" 776 - :total-count="visibleResults.total" 857 + :total-count="effectiveTotal" 777 858 :filtered-count="displayResults.length" 778 859 :available-keywords="availableKeywords" 779 860 :active-filters="activeFilters" 861 + :disabled-sort-keys="disabledSortKeys" 780 862 search-context 781 863 @toggle-column="toggleColumn" 782 864 @reset-columns="resetColumns" ··· 789 871 @update:updated-within="setUpdatedWithin" 790 872 @toggle-keyword="toggleKeyword" 791 873 /> 792 - <!-- Show "Found X packages" (infinite scroll mode only) --> 874 + <!-- Show count status (infinite scroll mode only) --> 793 875 <p 794 876 v-if="viewMode === 'cards' && paginationMode === 'infinite'" 795 877 role="status" 796 878 class="text-fg-muted text-sm mt-4 font-mono" 797 879 > 798 - {{ 799 - $t( 800 - 'search.found_packages', 801 - { count: $n(visibleResults.total) }, 802 - visibleResults.total, 803 - ) 804 - }} 880 + <template v-if="isRelevanceSort"> 881 + {{ 882 + $t( 883 + 'search.found_packages', 884 + { count: $n(visibleResults.total) }, 885 + visibleResults.total, 886 + ) 887 + }} 888 + </template> 889 + <template v-else> 890 + {{ 891 + $t('search.found_packages_sorted', { count: $n(effectiveTotal) }, effectiveTotal) 892 + }} 893 + </template> 805 894 <span v-if="status === 'pending'" class="text-fg-subtle">{{ 806 895 $t('search.updating') 807 896 }}</span> 808 897 </p> 809 - <!-- Show "x of y packages" (paginated/table mode only) --> 898 + <!-- Show "x of y" (paginated/table mode only) --> 810 899 <p 811 900 v-if="viewMode === 'table' || paginationMode === 'paginated'" 812 901 role="status" ··· 816 905 $t( 817 906 'filters.count.showing_paginated', 818 907 { 819 - pageSize: 820 - preferredPageSize === 'all' ? $n(visibleResults.total) : preferredPageSize, 821 - count: $n(visibleResults.total), 908 + pageSize: preferredPageSize === 'all' ? $n(effectiveTotal) : preferredPageSize, 909 + count: $n(effectiveTotal), 822 910 }, 823 - visibleResults.total, 911 + effectiveTotal, 824 912 ) 825 913 }} 826 914 </p> 827 915 </div> 828 916 829 917 <!-- No results found --> 830 - <div v-else-if="status !== 'pending'" role="status" class="py-12"> 918 + <div v-else-if="status === 'success' || status === 'error'" role="status" class="py-12"> 831 919 <p class="text-fg-muted font-mono mb-6 text-center"> 832 920 {{ $t('search.no_results', { query }) }} 833 921 </p> ··· 890 978 v-model:mode="paginationMode" 891 979 v-model:page-size="preferredPageSize" 892 980 v-model:current-page="currentPage" 893 - :total-items="visibleResults?.total ?? displayResults.length" 981 + :total-items="effectiveTotal" 894 982 :view-mode="viewMode" 895 983 /> 896 984 </div>
+1
i18n/locales/en.json
··· 23 23 "button": "search", 24 24 "searching": "Searching...", 25 25 "found_packages": "No packages found | Found 1 package | Found {count} packages", 26 + "found_packages_sorted": "Sorting top {count} result | Sorting top {count} results", 26 27 "updating": "(updating...)", 27 28 "no_results": "No packages found for \"{query}\"", 28 29 "rate_limited": "Hit npm rate limit, try again in a moment",
+1
lunaria/files/en-GB.json
··· 23 23 "button": "search", 24 24 "searching": "Searching...", 25 25 "found_packages": "No packages found | Found 1 package | Found {count} packages", 26 + "found_packages_sorted": "Sorting top {count} result | Sorting top {count} results", 26 27 "updating": "(updating...)", 27 28 "no_results": "No packages found for \"{query}\"", 28 29 "rate_limited": "Hit npm rate limit, try again in a moment",
+1
lunaria/files/en-US.json
··· 23 23 "button": "search", 24 24 "searching": "Searching...", 25 25 "found_packages": "No packages found | Found 1 package | Found {count} packages", 26 + "found_packages_sorted": "Sorting top {count} result | Sorting top {count} results", 26 27 "updating": "(updating...)", 27 28 "no_results": "No packages found for \"{query}\"", 28 29 "rate_limited": "Hit npm rate limit, try again in a moment",
+22
shared/types/preferences.ts
··· 140 140 { key: 'downloads-year', defaultDirection: 'desc', disabled: true }, 141 141 { key: 'updated', defaultDirection: 'desc' }, 142 142 { key: 'name', defaultDirection: 'asc' }, 143 + // quality/popularity/maintenance: npm returns 1 for all, Algolia returns synthetic values. 144 + // Neither provider produces meaningful values for these. 143 145 { key: 'quality', defaultDirection: 'desc', disabled: true }, 144 146 { key: 'popularity', defaultDirection: 'desc', disabled: true }, 145 147 { key: 'maintenance', defaultDirection: 'desc', disabled: true }, 148 + // score.final === searchScore (identical to relevance), redundant sort key 146 149 { key: 'score', defaultDirection: 'desc', disabled: true }, 147 150 ] 151 + 152 + /** 153 + * Sort keys each search provider can meaningfully sort by. 154 + * 155 + * Both providers support: relevance (server-side order), updated, name. 156 + * 157 + * Algolia: has `downloadsLast30Days` for download sorting. 158 + * 159 + * npm: the search API now includes `downloads.weekly` and `downloads.monthly` 160 + * directly in results, so download sorting works here too. 161 + * 162 + * Neither provider returns useful quality/popularity/maintenance/score values: 163 + * - npm returns 1 for all detail scores, and score.final === searchScore (= relevance) 164 + * - Algolia returns synthetic values (quality: 0|1, maintenance: 0, score: 0) 165 + */ 166 + export const PROVIDER_SORT_KEYS: Record<'algolia' | 'npm', Set<SortKey>> = { 167 + algolia: new Set<SortKey>(['relevance', 'downloads-week', 'updated', 'name']), 168 + npm: new Set<SortKey>(['relevance', 'downloads-week', 'updated', 'name']), 169 + } 148 170 149 171 /** All valid sort keys for validation */ 150 172 const VALID_SORT_KEYS = new Set<SortKey>([