···11-import type { NuxtApp } from '#app'
21import type { NpmSearchResponse, NpmSearchResult, MinimalPackument } from '#shared/types'
32import { emptySearchResponse, packumentToSearchResult } from './useNpmSearch'
43import { mapWithConcurrency } from '#shared/utils/async'
5465/**
77- * Fetch downloads for multiple packages.
88- * Returns a map of package name -> weekly downloads.
99- * Uses bulk API for unscoped packages, parallel individual requests for scoped.
1010- * Note: npm bulk downloads API does not support scoped packages.
1111- */
1212-async function fetchBulkDownloads(
1313- $npmApi: NuxtApp['$npmApi'],
1414- packageNames: string[],
1515- options: Parameters<typeof $fetch>[1] = {},
1616-): Promise<Map<string, number>> {
1717- const downloads = new Map<string, number>()
1818- if (packageNames.length === 0) return downloads
1919-2020- // Separate scoped and unscoped packages
2121- const scopedPackages = packageNames.filter(n => n.startsWith('@'))
2222- const unscopedPackages = packageNames.filter(n => !n.startsWith('@'))
2323-2424- // Fetch unscoped packages via bulk API (max 128 per request)
2525- const bulkPromises: Promise<void>[] = []
2626- const chunkSize = 100
2727- for (let i = 0; i < unscopedPackages.length; i += chunkSize) {
2828- const chunk = unscopedPackages.slice(i, i + chunkSize)
2929- bulkPromises.push(
3030- (async () => {
3131- try {
3232- const response = await $npmApi<Record<string, { downloads: number } | null>>(
3333- `/downloads/point/last-week/${chunk.join(',')}`,
3434- options,
3535- )
3636- for (const [name, data] of Object.entries(response.data)) {
3737- if (data?.downloads !== undefined) {
3838- downloads.set(name, data.downloads)
3939- }
4040- }
4141- } catch {
4242- // Ignore errors - downloads are optional
4343- }
4444- })(),
4545- )
4646- }
4747-4848- // Fetch scoped packages in parallel batches (concurrency limit to avoid overwhelming the API)
4949- // Use Promise.allSettled to not fail on individual errors
5050- const scopedBatchSize = 20 // Concurrent requests per batch
5151- for (let i = 0; i < scopedPackages.length; i += scopedBatchSize) {
5252- const batch = scopedPackages.slice(i, i + scopedBatchSize)
5353- bulkPromises.push(
5454- (async () => {
5555- const results = await Promise.allSettled(
5656- batch.map(async name => {
5757- const encoded = encodePackageName(name)
5858- const { data } = await $npmApi<{ downloads: number }>(
5959- `/downloads/point/last-week/${encoded}`,
6060- )
6161- return { name, downloads: data.downloads }
6262- }),
6363- )
6464- for (const result of results) {
6565- if (result.status === 'fulfilled' && result.value.downloads !== undefined) {
6666- downloads.set(result.value.name, result.value.downloads)
6767- }
6868- }
6969- })(),
7070- )
7171- }
7272-7373- // Wait for all fetches to complete
7474- await Promise.all(bulkPromises)
7575-7676- return downloads
7777-}
7878-7979-/**
806 * Fetch all packages for an npm organization.
817 *
8282- * Always uses the npm registry's org endpoint as the source of truth for which
8383- * packages belong to the org. When Algolia is enabled, uses it to quickly fetch
8484- * metadata for those packages (instead of N+1 packument fetches).
88+ * 1. Gets the authoritative package list from the npm registry (single request)
99+ * 2. Fetches metadata from Algolia by exact name (single request)
1010+ * 3. Falls back to individual packument fetches when Algolia is unavailable
8511 */
8612export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
8713 const { searchProvider } = useSearchProvider()
8888- const { searchByOwner } = useAlgoliaSearch()
1414+ const { getPackagesByName } = useAlgoliaSearch()
89159016 const asyncData = useLazyAsyncData(
9117 () => `org-packages:${searchProvider.value}:${toValue(orgName)}`,
9292- async ({ $npmRegistry, $npmApi, ssrContext }, { signal }) => {
1818+ async ({ $npmRegistry, ssrContext }, { signal }) => {
9319 const org = toValue(orgName)
9420 if (!org) {
9521 return emptySearchResponse
9622 }
97239898- // Always get the authoritative package list from the npm registry.
9999- // Algolia's owner.name filter doesn't precisely match npm org membership
100100- // (e.g. it includes @nuxtjs/* packages for the @nuxt org).
2424+ // Get the authoritative package list from the npm registry (single request)
10125 let packageNames: string[]
10226 try {
10327 const { packages } = await $fetch<{ packages: string[]; count: number }>(
···12650 return emptySearchResponse
12751 }
12852129129- // --- Algolia fast path: use Algolia to get metadata for known packages ---
5353+ // Fetch metadata + downloads from Algolia (single request via getObjects)
13054 if (searchProvider.value === 'algolia') {
13155 try {
132132- const response = await searchByOwner(org)
5656+ const response = await getPackagesByName(packageNames)
13357 if (response.objects.length > 0) {
134134- // Filter Algolia results to only include packages that are
135135- // actually in the org (per the npm registry's authoritative list)
136136- const orgPackageSet = new Set(packageNames.map(n => n.toLowerCase()))
137137- const filtered = response.objects.filter(obj =>
138138- orgPackageSet.has(obj.package.name.toLowerCase()),
139139- )
140140-141141- if (filtered.length > 0) {
142142- return {
143143- ...response,
144144- objects: filtered,
145145- total: filtered.length,
146146- }
147147- }
5858+ return response
14859 }
14960 } catch {
15061 // Fall through to npm registry path
15162 }
15263 }
15364154154- // --- npm registry path: fetch packuments individually ---
155155- const [packuments, downloads] = await Promise.all([
156156- (async () => {
157157- const results = await mapWithConcurrency(
158158- packageNames,
159159- async name => {
160160- try {
161161- const encoded = encodePackageName(name)
162162- const { data: pkg } = await $npmRegistry<MinimalPackument>(`/${encoded}`, {
163163- signal,
164164- })
165165- return pkg
166166- } catch {
167167- return null
168168- }
169169- },
170170- 10,
171171- )
172172- return results.filter(
173173- (pkg): pkg is MinimalPackument => pkg !== null && !!pkg['dist-tags'],
174174- )
175175- })(),
176176- fetchBulkDownloads($npmApi, packageNames, { signal }),
177177- ])
6565+ // npm fallback: fetch packuments individually
6666+ const packuments = await mapWithConcurrency(
6767+ packageNames,
6868+ async name => {
6969+ try {
7070+ const encoded = encodePackageName(name)
7171+ const { data: pkg } = await $npmRegistry<MinimalPackument>(`/${encoded}`, {
7272+ signal,
7373+ })
7474+ return pkg
7575+ } catch {
7676+ return null
7777+ }
7878+ },
7979+ 10,
8080+ )
17881179179- const results: NpmSearchResult[] = packuments.map(pkg =>
180180- packumentToSearchResult(pkg, downloads.get(pkg.name)),
8282+ const validPackuments = packuments.filter(
8383+ (pkg): pkg is MinimalPackument => pkg !== null && !!pkg['dist-tags'],
18184 )
8585+8686+ const results: NpmSearchResult[] = validPackuments.map(pkg => packumentToSearchResult(pkg))
1828718388 return {
18489 isStale: false,
+133-45
app/pages/search.vue
···11<script setup lang="ts">
22-import type { FilterChip } from '#shared/types/preferences'
22+import type { FilterChip, SortKey } from '#shared/types/preferences'
33+import { parseSortOption, PROVIDER_SORT_KEYS } from '#shared/types/preferences'
34import { onKeyDown } from '@vueuse/core'
45import { debounce } from 'perfect-debounce'
56import { isValidNewPackageName, checkPackageExists } from '~/utils/package-name'
···89910const route = useRoute()
1011const router = useRouter()
1212+1313+// Search provider
1414+const { search: algoliaSearch } = useAlgoliaSearch()
1515+const { isAlgolia } = useSearchProvider()
11161217// Preferences (persisted to localStorage)
1318const {
···4550const pageSize = 25
4651const currentPage = shallowRef(1)
47524848-// Calculate how many results we need based on current page and preferred page size
4949-const requestedSize = computed(() => {
5050- const numericPrefSize = preferredPageSize.value === 'all' ? 250 : preferredPageSize.value
5151- // Always fetch at least enough for the current page
5252- return Math.max(pageSize, currentPage.value * numericPrefSize)
5353-})
5454-5553// Get initial page from URL (for scroll restoration on reload)
5654const initialPage = computed(() => {
5755 const p = Number.parseInt(normalizeSearchParam(route.query.page), 10)
···6563 }
6664})
67656868-// Use incremental search with client-side caching
6969-const {
7070- data: results,
7171- status,
7272- isLoadingMore,
7373- hasMore,
7474- fetchMore,
7575- isRateLimited,
7676-} = useNpmSearch(query, () => ({
7777- size: requestedSize.value,
7878-}))
7979-8066// Results to display (directly from incremental search)
8167const rawVisibleResults = computed(() => results.value)
8268···125111// Use structured filters for client-side refinement of search results
126112const resultsArray = computed(() => visibleResults.value?.objects ?? [])
127113114114+// All possible non-relevance sort keys
115115+const ALL_SORT_KEYS: SortKey[] = [
116116+ 'downloads-week',
117117+ 'downloads-day',
118118+ 'downloads-month',
119119+ 'downloads-year',
120120+ 'updated',
121121+ 'name',
122122+ 'quality',
123123+ 'popularity',
124124+ 'maintenance',
125125+ 'score',
126126+]
127127+128128+// Disable sort keys the current provider can't meaningfully sort by
129129+const disabledSortKeys = computed<SortKey[]>(() => {
130130+ const supported = PROVIDER_SORT_KEYS[isAlgolia.value ? 'algolia' : 'npm']
131131+ return ALL_SORT_KEYS.filter(k => !supported.has(k))
132132+})
133133+128134// Minimal structured filters usage for search context (no client-side filtering)
129135const {
130136 filters,
131137 sortOption,
132132- sortedPackages,
133138 availableKeywords,
134139 activeFilters,
135140 setTextFilter,
···148153 initialSort: 'relevance-desc', // Default to search relevance
149154})
150155151151-// Client-side filtered/sorted results for display
152152-// In search context, we always use server order (relevance) - no client-side filtering
156156+const isRelevanceSort = computed(
157157+ () => sortOption.value === 'relevance-desc' || sortOption.value === 'relevance-asc',
158158+)
159159+160160+// Maximum eager-load sizes per provider for client-side sorting.
161161+// Algolia supports up to 1000 with offset/length pagination.
162162+// npm supports pagination via `from` parameter (no hard cap, but diminishing relevance).
163163+const EAGER_LOAD_SIZE = { algolia: 500, npm: 500 } as const
164164+165165+// Calculate how many results we need based on current page and preferred page size
166166+const requestedSize = computed(() => {
167167+ const numericPrefSize = preferredPageSize.value === 'all' ? 250 : preferredPageSize.value
168168+ const base = Math.max(pageSize, currentPage.value * numericPrefSize)
169169+ // When sorting by something other than relevance, fetch a large batch
170170+ // so client-side sorting operates on a meaningful pool of matching results
171171+ if (!isRelevanceSort.value) {
172172+ const cap = isAlgolia.value ? EAGER_LOAD_SIZE.algolia : EAGER_LOAD_SIZE.npm
173173+ return Math.max(base, cap)
174174+ }
175175+ return base
176176+})
177177+178178+// Reset to relevance sort when switching to a provider that doesn't support the current sort key
179179+watch(isAlgolia, algolia => {
180180+ const { key } = parseSortOption(sortOption.value)
181181+ const supported = PROVIDER_SORT_KEYS[algolia ? 'algolia' : 'npm']
182182+ if (!supported.has(key)) {
183183+ sortOption.value = 'relevance-desc'
184184+ }
185185+})
186186+187187+// Use incremental search with client-side caching
188188+const {
189189+ data: results,
190190+ status,
191191+ isLoadingMore,
192192+ hasMore,
193193+ fetchMore,
194194+ isRateLimited,
195195+} = useNpmSearch(query, () => ({
196196+ size: requestedSize.value,
197197+}))
198198+199199+// Client-side sorted results for display
200200+// The search API already handles text filtering, so we only need to sort.
153201const displayResults = computed(() => {
154154- // When using relevance sort, return original server-sorted results
155155- if (sortOption.value === 'relevance-desc' || sortOption.value === 'relevance-asc') {
202202+ if (isRelevanceSort.value) {
156203 return resultsArray.value
157204 }
158205159159- return sortedPackages.value
206206+ // Sort the fetched results client-side — neither Algolia nor npm support
207207+ // arbitrary sort orders server-side, so we fetch a large batch and sort here
208208+ const { key, direction } = parseSortOption(sortOption.value)
209209+ const multiplier = direction === 'asc' ? 1 : -1
210210+211211+ return [...resultsArray.value].sort((a, b) => {
212212+ let diff: number
213213+ switch (key) {
214214+ case 'downloads-week':
215215+ case 'downloads-day':
216216+ case 'downloads-month':
217217+ case 'downloads-year':
218218+ diff = (a.downloads?.weekly ?? 0) - (b.downloads?.weekly ?? 0)
219219+ break
220220+ case 'updated':
221221+ diff = new Date(a.package.date).getTime() - new Date(b.package.date).getTime()
222222+ break
223223+ case 'name':
224224+ diff = a.package.name.localeCompare(b.package.name)
225225+ break
226226+ default:
227227+ diff = 0
228228+ }
229229+ return diff * multiplier
230230+ })
160231})
161232162233const resultCount = computed(() => displayResults.value.length)
234234+235235+/**
236236+ * The effective total for display and pagination purposes.
237237+ * When sorting by non-relevance, we're working with a fetched subset (e.g. 250),
238238+ * not the full Algolia total (e.g. 92,324). Show the actual working set size.
239239+ */
240240+const effectiveTotal = computed(() => {
241241+ if (isRelevanceSort.value) {
242242+ return visibleResults.value?.total ?? 0
243243+ }
244244+ // When sorting, the total is the number of results we actually fetched and sorted
245245+ return displayResults.value.length
246246+})
163247164248// Handle filter chip removal
165249function handleClearFilter(chip: FilterChip) {
···315399316400/** Cache for existence checks to avoid repeated API calls */
317401const existenceCache = ref<Record<string, boolean | 'pending'>>({})
318318-319319-const { search: algoliaSearch } = useAlgoliaSearch()
320320-const { isAlgolia } = useSearchProvider()
321402322403/**
323404 * Check if an org exists by searching for scoped packages (@orgname/...).
···773854 :columns="columns"
774855 v-model:pagination-mode="paginationMode"
775856 v-model:page-size="preferredPageSize"
776776- :total-count="visibleResults.total"
857857+ :total-count="effectiveTotal"
777858 :filtered-count="displayResults.length"
778859 :available-keywords="availableKeywords"
779860 :active-filters="activeFilters"
861861+ :disabled-sort-keys="disabledSortKeys"
780862 search-context
781863 @toggle-column="toggleColumn"
782864 @reset-columns="resetColumns"
···789871 @update:updated-within="setUpdatedWithin"
790872 @toggle-keyword="toggleKeyword"
791873 />
792792- <!-- Show "Found X packages" (infinite scroll mode only) -->
874874+ <!-- Show count status (infinite scroll mode only) -->
793875 <p
794876 v-if="viewMode === 'cards' && paginationMode === 'infinite'"
795877 role="status"
796878 class="text-fg-muted text-sm mt-4 font-mono"
797879 >
798798- {{
799799- $t(
800800- 'search.found_packages',
801801- { count: $n(visibleResults.total) },
802802- visibleResults.total,
803803- )
804804- }}
880880+ <template v-if="isRelevanceSort">
881881+ {{
882882+ $t(
883883+ 'search.found_packages',
884884+ { count: $n(visibleResults.total) },
885885+ visibleResults.total,
886886+ )
887887+ }}
888888+ </template>
889889+ <template v-else>
890890+ {{
891891+ $t('search.found_packages_sorted', { count: $n(effectiveTotal) }, effectiveTotal)
892892+ }}
893893+ </template>
805894 <span v-if="status === 'pending'" class="text-fg-subtle">{{
806895 $t('search.updating')
807896 }}</span>
808897 </p>
809809- <!-- Show "x of y packages" (paginated/table mode only) -->
898898+ <!-- Show "x of y" (paginated/table mode only) -->
810899 <p
811900 v-if="viewMode === 'table' || paginationMode === 'paginated'"
812901 role="status"
···816905 $t(
817906 'filters.count.showing_paginated',
818907 {
819819- pageSize:
820820- preferredPageSize === 'all' ? $n(visibleResults.total) : preferredPageSize,
821821- count: $n(visibleResults.total),
908908+ pageSize: preferredPageSize === 'all' ? $n(effectiveTotal) : preferredPageSize,
909909+ count: $n(effectiveTotal),
822910 },
823823- visibleResults.total,
911911+ effectiveTotal,
824912 )
825913 }}
826914 </p>
827915 </div>
828916829917 <!-- No results found -->
830830- <div v-else-if="status !== 'pending'" role="status" class="py-12">
918918+ <div v-else-if="status === 'success' || status === 'error'" role="status" class="py-12">
831919 <p class="text-fg-muted font-mono mb-6 text-center">
832920 {{ $t('search.no_results', { query }) }}
833921 </p>
···890978 v-model:mode="paginationMode"
891979 v-model:page-size="preferredPageSize"
892980 v-model:current-page="currentPage"
893893- :total-items="visibleResults?.total ?? displayResults.length"
981981+ :total-items="effectiveTotal"
894982 :view-mode="viewMode"
895983 />
896984 </div>
+1
i18n/locales/en.json
···2323 "button": "search",
2424 "searching": "Searching...",
2525 "found_packages": "No packages found | Found 1 package | Found {count} packages",
2626+ "found_packages_sorted": "Sorting top {count} result | Sorting top {count} results",
2627 "updating": "(updating...)",
2728 "no_results": "No packages found for \"{query}\"",
2829 "rate_limited": "Hit npm rate limit, try again in a moment",
+1
lunaria/files/en-GB.json
···2323 "button": "search",
2424 "searching": "Searching...",
2525 "found_packages": "No packages found | Found 1 package | Found {count} packages",
2626+ "found_packages_sorted": "Sorting top {count} result | Sorting top {count} results",
2627 "updating": "(updating...)",
2728 "no_results": "No packages found for \"{query}\"",
2829 "rate_limited": "Hit npm rate limit, try again in a moment",
+1
lunaria/files/en-US.json
···2323 "button": "search",
2424 "searching": "Searching...",
2525 "found_packages": "No packages found | Found 1 package | Found {count} packages",
2626+ "found_packages_sorted": "Sorting top {count} result | Sorting top {count} results",
2627 "updating": "(updating...)",
2728 "no_results": "No packages found for \"{query}\"",
2829 "rate_limited": "Hit npm rate limit, try again in a moment",
+22
shared/types/preferences.ts
···140140 { key: 'downloads-year', defaultDirection: 'desc', disabled: true },
141141 { key: 'updated', defaultDirection: 'desc' },
142142 { key: 'name', defaultDirection: 'asc' },
143143+ // quality/popularity/maintenance: npm returns 1 for all, Algolia returns synthetic values.
144144+ // Neither provider produces meaningful values for these.
143145 { key: 'quality', defaultDirection: 'desc', disabled: true },
144146 { key: 'popularity', defaultDirection: 'desc', disabled: true },
145147 { key: 'maintenance', defaultDirection: 'desc', disabled: true },
148148+ // score.final === searchScore (identical to relevance), redundant sort key
146149 { key: 'score', defaultDirection: 'desc', disabled: true },
147150]
151151+152152+/**
153153+ * Sort keys each search provider can meaningfully sort by.
154154+ *
155155+ * Both providers support: relevance (server-side order), updated, name.
156156+ *
157157+ * Algolia: has `downloadsLast30Days` for download sorting.
158158+ *
159159+ * npm: the search API now includes `downloads.weekly` and `downloads.monthly`
160160+ * directly in results, so download sorting works here too.
161161+ *
162162+ * Neither provider returns useful quality/popularity/maintenance/score values:
163163+ * - npm returns 1 for all detail scores, and score.final === searchScore (= relevance)
164164+ * - Algolia returns synthetic values (quality: 0|1, maintenance: 0, score: 0)
165165+ */
166166+export const PROVIDER_SORT_KEYS: Record<'algolia' | 'npm', Set<SortKey>> = {
167167+ algolia: new Set<SortKey>(['relevance', 'downloads-week', 'updated', 'name']),
168168+ npm: new Set<SortKey>(['relevance', 'downloads-week', 'updated', 'name']),
169169+}
148170149171/** All valid sort keys for validation */
150172const VALID_SORT_KEYS = new Set<SortKey>([