[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.

feat: use algolia for package search + org/user package listing (#1204)

Co-authored-by: Haroen Viaene <hello@haroen.me>

authored by

Daniel Roe
Haroen Viaene
and committed by
GitHub
9edf83de f14f0746

+1228 -164
+117
app/components/SearchProviderToggle.client.vue
··· 1 + <script setup lang="ts"> 2 + const { searchProvider, isAlgolia } = useSearchProvider() 3 + 4 + const isOpen = shallowRef(false) 5 + const toggleRef = useTemplateRef('toggleRef') 6 + 7 + onClickOutside(toggleRef, () => { 8 + isOpen.value = false 9 + }) 10 + 11 + useEventListener('keydown', event => { 12 + if (event.key === 'Escape' && isOpen.value) { 13 + isOpen.value = false 14 + } 15 + }) 16 + </script> 17 + 18 + <template> 19 + <div ref="toggleRef" class="relative"> 20 + <ButtonBase 21 + :aria-label="$t('settings.data_source.label')" 22 + :aria-expanded="isOpen" 23 + aria-haspopup="true" 24 + size="small" 25 + class="border-none w-8 h-8 !px-0 justify-center" 26 + classicon="i-carbon:settings" 27 + @click="isOpen = !isOpen" 28 + /> 29 + 30 + <Transition 31 + enter-active-class="transition-all duration-150" 32 + leave-active-class="transition-all duration-100" 33 + enter-from-class="opacity-0 translate-y-1" 34 + leave-to-class="opacity-0 translate-y-1" 35 + > 36 + <div 37 + v-if="isOpen" 38 + class="absolute inset-ie-0 top-full pt-2 w-72 z-50" 39 + role="menu" 40 + :aria-label="$t('settings.data_source.label')" 41 + > 42 + <div 43 + class="bg-bg-subtle/80 backdrop-blur-sm border border-border-subtle rounded-lg shadow-lg shadow-bg-elevated/50 overflow-hidden p-1" 44 + > 45 + <!-- npm Registry option --> 46 + <button 47 + type="button" 48 + role="menuitem" 49 + class="w-full flex items-start gap-3 px-3 py-2.5 rounded-md text-start transition-colors hover:bg-bg-muted" 50 + :class="[!isAlgolia ? 'bg-bg-muted' : '']" 51 + @click=" 52 + () => { 53 + searchProvider = 'npm' 54 + isOpen = false 55 + } 56 + " 57 + > 58 + <span 59 + class="i-carbon:catalog w-4 h-4 mt-0.5 shrink-0" 60 + :class="!isAlgolia ? 'text-accent' : 'text-fg-muted'" 61 + aria-hidden="true" 62 + /> 63 + <div class="min-w-0 flex-1"> 64 + <div class="text-sm font-medium" :class="!isAlgolia ? 'text-fg' : 'text-fg-muted'"> 65 + {{ $t('settings.data_source.npm') }} 66 + </div> 67 + <p class="text-xs text-fg-subtle mt-0.5"> 68 + {{ $t('settings.data_source.npm_description') }} 69 + </p> 70 + </div> 71 + </button> 72 + 73 + <!-- Algolia option --> 74 + <button 75 + type="button" 76 + role="menuitem" 77 + class="w-full flex items-start gap-3 px-3 py-2.5 rounded-md text-start transition-colors hover:bg-bg-muted mt-1" 78 + :class="[isAlgolia ? 'bg-bg-muted' : '']" 79 + @click=" 80 + () => { 81 + searchProvider = 'algolia' 82 + isOpen = false 83 + } 84 + " 85 + > 86 + <span 87 + class="i-carbon:search w-4 h-4 mt-0.5 shrink-0" 88 + :class="isAlgolia ? 'text-accent' : 'text-fg-muted'" 89 + aria-hidden="true" 90 + /> 91 + <div class="min-w-0 flex-1"> 92 + <div class="text-sm font-medium" :class="isAlgolia ? 'text-fg' : 'text-fg-muted'"> 93 + {{ $t('settings.data_source.algolia') }} 94 + </div> 95 + <p class="text-xs text-fg-subtle mt-0.5"> 96 + {{ $t('settings.data_source.algolia_description') }} 97 + </p> 98 + </div> 99 + </button> 100 + 101 + <!-- Algolia attribution --> 102 + <div v-if="isAlgolia" class="border-t border-border mx-1 mt-1 pt-2 pb-1"> 103 + <a 104 + href="https://www.algolia.com/developers" 105 + target="_blank" 106 + rel="noopener noreferrer" 107 + class="text-xs text-fg-subtle hover:text-fg-muted transition-colors inline-flex items-center gap-1 px-2" 108 + > 109 + {{ $t('search.algolia_disclaimer') }} 110 + <span class="i-carbon:launch w-3 h-3" aria-hidden="true" /> 111 + </a> 112 + </div> 113 + </div> 114 + </div> 115 + </Transition> 116 + </div> 117 + </template>
+7
app/components/SearchProviderToggle.server.vue
··· 1 + <template> 2 + <div class="relative"> 3 + <div class="flex items-center justify-center w-8 h-8 rounded-md text-fg-subtle"> 4 + <span class="i-carbon:settings w-4 h-4" aria-hidden="true" /> 5 + </div> 6 + </div> 7 + </template>
+238
app/composables/npm/useAlgoliaSearch.ts
··· 1 + import type { NpmSearchResponse, NpmSearchResult } from '#shared/types' 2 + import { 3 + liteClient as algoliasearch, 4 + type LiteClient, 5 + type SearchResponse, 6 + } from 'algoliasearch/lite' 7 + 8 + /** 9 + * Singleton Algolia client, keyed by appId to handle config changes. 10 + */ 11 + let _searchClient: LiteClient | null = null 12 + let _configuredAppId: string | null = null 13 + 14 + function getOrCreateClient(appId: string, apiKey: string): LiteClient { 15 + if (!_searchClient || _configuredAppId !== appId) { 16 + _searchClient = algoliasearch(appId, apiKey) 17 + _configuredAppId = appId 18 + } 19 + return _searchClient 20 + } 21 + 22 + interface AlgoliaOwner { 23 + name: string 24 + email?: string 25 + avatar?: string 26 + link?: string 27 + } 28 + 29 + interface AlgoliaRepo { 30 + url: string 31 + host: string 32 + user: string 33 + project: string 34 + path: string 35 + head?: string 36 + branch?: string 37 + } 38 + 39 + /** 40 + * Shape of a hit from the Algolia `npm-search` index. 41 + * Only includes fields we retrieve via `attributesToRetrieve`. 42 + */ 43 + interface AlgoliaHit { 44 + objectID: string 45 + name: string 46 + version: string 47 + description: string | null 48 + modified: number 49 + homepage: string | null 50 + repository: AlgoliaRepo | null 51 + owners: AlgoliaOwner[] | null 52 + downloadsLast30Days: number 53 + downloadsRatio: number 54 + popular: boolean 55 + keywords: string[] 56 + deprecated: boolean | string 57 + isDeprecated: boolean 58 + license: string | null 59 + } 60 + 61 + /** Fields we always request from Algolia to keep payload small */ 62 + const ATTRIBUTES_TO_RETRIEVE = [ 63 + 'name', 64 + 'version', 65 + 'description', 66 + 'modified', 67 + 'homepage', 68 + 'repository', 69 + 'owners', 70 + 'downloadsLast30Days', 71 + 'downloadsRatio', 72 + 'popular', 73 + 'keywords', 74 + 'deprecated', 75 + 'isDeprecated', 76 + 'license', 77 + ] 78 + 79 + function hitToSearchResult(hit: AlgoliaHit): NpmSearchResult { 80 + return { 81 + package: { 82 + name: hit.name, 83 + version: hit.version, 84 + description: hit.description || '', 85 + keywords: hit.keywords, 86 + date: new Date(hit.modified).toISOString(), 87 + links: { 88 + npm: `https://www.npmjs.com/package/${hit.name}`, 89 + homepage: hit.homepage || undefined, 90 + repository: hit.repository?.url || undefined, 91 + }, 92 + maintainers: hit.owners 93 + ? hit.owners.map(owner => ({ 94 + name: owner.name, 95 + email: owner.email, 96 + })) 97 + : [], 98 + }, 99 + score: { 100 + final: 0, 101 + detail: { 102 + quality: hit.popular ? 1 : 0, 103 + popularity: hit.downloadsRatio, 104 + maintenance: 0, 105 + }, 106 + }, 107 + searchScore: 0, 108 + downloads: { 109 + weekly: Math.round(hit.downloadsLast30Days / 4.3), 110 + }, 111 + updated: new Date(hit.modified).toISOString(), 112 + } 113 + } 114 + 115 + export interface AlgoliaSearchOptions { 116 + /** Number of results */ 117 + size?: number 118 + /** Offset for pagination */ 119 + from?: number 120 + /** Algolia filters expression (e.g. 'owner.name:username') */ 121 + filters?: string 122 + } 123 + 124 + /** 125 + * Composable that provides Algolia search functions for npm packages. 126 + * 127 + * Must be called during component setup (or inside another composable) 128 + * because it reads from `useRuntimeConfig()`. The returned functions 129 + * are safe to call at any time (event handlers, async callbacks, etc.). 130 + */ 131 + export function useAlgoliaSearch() { 132 + const { algolia } = useRuntimeConfig().public 133 + const client = getOrCreateClient(algolia.appId, algolia.apiKey) 134 + const indexName = algolia.indexName 135 + 136 + /** 137 + * Search npm packages via Algolia. 138 + * Returns results in the same NpmSearchResponse format as the npm registry API. 139 + */ 140 + async function search( 141 + query: string, 142 + options: AlgoliaSearchOptions = {}, 143 + ): Promise<NpmSearchResponse> { 144 + const { results } = await client.search([ 145 + { 146 + indexName, 147 + params: { 148 + query, 149 + offset: options.from, 150 + length: options.size, 151 + filters: options.filters || '', 152 + analyticsTags: ['npmx.dev'], 153 + attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE, 154 + attributesToHighlight: [], 155 + }, 156 + }, 157 + ]) 158 + 159 + const response = results[0] as SearchResponse<AlgoliaHit> | undefined 160 + if (!response) { 161 + throw new Error('Algolia returned an empty response') 162 + } 163 + 164 + return { 165 + isStale: false, 166 + objects: response.hits.map(hitToSearchResult), 167 + total: response.nbHits ?? 0, 168 + time: new Date().toISOString(), 169 + } 170 + } 171 + 172 + /** 173 + * Fetch all packages for an Algolia owner (org or user). 174 + * Uses `owner.name` filter for efficient server-side filtering. 175 + */ 176 + async function searchByOwner( 177 + ownerName: string, 178 + options: { maxResults?: number } = {}, 179 + ): Promise<NpmSearchResponse> { 180 + const max = options.maxResults ?? 1000 181 + 182 + const allHits: AlgoliaHit[] = [] 183 + let offset = 0 184 + let serverTotal = 0 185 + const batchSize = 200 186 + 187 + // Algolia supports up to 1000 results per query with offset/length pagination 188 + while (offset < max) { 189 + // Cap at both the configured max and the server's actual total (once known) 190 + const remaining = serverTotal > 0 ? Math.min(max, serverTotal) - offset : max - offset 191 + if (remaining <= 0) break 192 + const length = Math.min(batchSize, remaining) 193 + 194 + const { results } = await client.search([ 195 + { 196 + indexName, 197 + params: { 198 + query: '', 199 + offset, 200 + length, 201 + filters: `owner.name:${ownerName}`, 202 + analyticsTags: ['npmx.dev'], 203 + attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE, 204 + attributesToHighlight: [], 205 + }, 206 + }, 207 + ]) 208 + 209 + const response = results[0] as SearchResponse<AlgoliaHit> | undefined 210 + if (!response) break 211 + 212 + serverTotal = response.nbHits ?? 0 213 + allHits.push(...response.hits) 214 + 215 + // If we got fewer than requested, we've exhausted all results 216 + if (response.hits.length < length || allHits.length >= serverTotal) { 217 + break 218 + } 219 + 220 + offset += length 221 + } 222 + 223 + return { 224 + isStale: false, 225 + objects: allHits.map(hitToSearchResult), 226 + // Use server total so callers can detect truncation (allHits.length < total) 227 + total: serverTotal, 228 + time: new Date().toISOString(), 229 + } 230 + } 231 + 232 + return { 233 + /** Search packages by text query */ 234 + search, 235 + /** Fetch all packages for an owner (org or user) */ 236 + searchByOwner, 237 + } 238 + }
+96 -34
app/composables/npm/useNpmSearch.ts
··· 5 5 NpmDownloadCount, 6 6 MinimalPackument, 7 7 } from '#shared/types' 8 + import type { SearchProvider } from '~/composables/useSettings' 8 9 9 10 /** 10 11 * Convert packument to search result format for display ··· 55 56 options: MaybeRefOrGetter<NpmSearchOptions> = {}, 56 57 ) { 57 58 const { $npmRegistry } = useNuxtApp() 59 + const { searchProvider } = useSearchProvider() 60 + const { search: searchAlgolia } = useAlgoliaSearch() 58 61 59 62 // Client-side cache 60 63 const cache = shallowRef<{ 61 64 query: string 65 + provider: SearchProvider 62 66 objects: NpmSearchResult[] 63 67 total: number 64 68 } | null>(null) ··· 73 77 let lastSearch: NpmSearchResponse | undefined = undefined 74 78 75 79 const asyncData = useLazyAsyncData( 76 - () => `search:incremental:${toValue(query)}`, 80 + () => `search:${searchProvider.value}:${toValue(query)}`, 77 81 async ({ $npmRegistry, $npmApi }, { signal }) => { 78 82 const q = toValue(query) 83 + const provider = searchProvider.value 79 84 80 85 if (!q.trim()) { 81 86 isRateLimited.value = false ··· 88 93 // Reset cache for new query (but don't reset rate limit yet - only on success) 89 94 cache.value = null 90 95 96 + // --- Algolia path (client-side only) --- 97 + if (provider === 'algolia') { 98 + const response = await searchAlgolia(q, { 99 + size: opts.size ?? 25, 100 + }) 101 + 102 + if (q !== toValue(query)) { 103 + return emptySearchResponse 104 + } 105 + 106 + isRateLimited.value = false 107 + 108 + cache.value = { 109 + query: q, 110 + provider, 111 + objects: response.objects, 112 + total: response.total, 113 + } 114 + 115 + return response 116 + } 117 + 118 + // --- npm registry path --- 91 119 const params = new URLSearchParams() 92 120 params.set('text', q) 93 - // Use requested size for initial fetch 94 121 params.set('size', String(opts.size ?? 25)) 95 122 96 123 try { ··· 116 143 117 144 cache.value = { 118 145 query: q, 146 + provider, 119 147 objects: [result], 120 148 total: 1, 121 149 } ··· 144 172 145 173 cache.value = { 146 174 query: q, 175 + provider, 147 176 objects: response.objects, 148 177 total: response.total, 149 178 } ··· 169 198 { default: () => lastSearch || emptySearchResponse }, 170 199 ) 171 200 172 - // Fetch more results incrementally (only used in incremental mode) 201 + // Fetch more results incrementally 173 202 async function fetchMore(targetSize: number): Promise<void> { 174 203 const q = toValue(query).trim() 204 + const provider = searchProvider.value 205 + 175 206 if (!q) { 176 207 cache.value = null 177 208 return 178 209 } 179 210 180 - // If query changed, reset cache (shouldn't happen, but safety check) 181 - if (cache.value && cache.value.query !== q) { 211 + // If query or provider changed, reset cache 212 + if (cache.value && (cache.value.query !== q || cache.value.provider !== provider)) { 182 213 cache.value = null 183 214 await asyncData.refresh() 184 215 return ··· 195 226 isLoadingMore.value = true 196 227 197 228 try { 198 - // Fetch from where we left off - calculate size needed 199 229 const from = currentCount 200 230 const size = Math.min(targetSize - currentCount, total - currentCount) 201 231 202 - const params = new URLSearchParams() 203 - params.set('text', q) 204 - params.set('size', String(size)) 205 - params.set('from', String(from)) 206 - 207 - const { data: response } = await $npmRegistry<NpmSearchResponse>( 208 - `/-/v1/search?${params.toString()}`, 209 - {}, 210 - 60, 211 - ) 232 + if (provider === 'algolia') { 233 + // Algolia incremental fetch 234 + const response = await searchAlgolia(q, { size, from }) 212 235 213 - // Update cache 214 - if (cache.value && cache.value.query === q) { 215 - const existingNames = new Set(cache.value.objects.map(obj => obj.package.name)) 216 - const newObjects = response.objects.filter(obj => !existingNames.has(obj.package.name)) 217 - cache.value = { 218 - query: q, 219 - objects: [...cache.value.objects, ...newObjects], 220 - total: response.total, 236 + if (cache.value && cache.value.query === q && cache.value.provider === provider) { 237 + const existingNames = new Set(cache.value.objects.map(obj => obj.package.name)) 238 + const newObjects = response.objects.filter(obj => !existingNames.has(obj.package.name)) 239 + cache.value = { 240 + query: q, 241 + provider, 242 + objects: [...cache.value.objects, ...newObjects], 243 + total: response.total, 244 + } 245 + } else { 246 + cache.value = { 247 + query: q, 248 + provider, 249 + objects: response.objects, 250 + total: response.total, 251 + } 221 252 } 222 253 } else { 223 - cache.value = { 224 - query: q, 225 - objects: response.objects, 226 - total: response.total, 254 + // npm registry incremental fetch 255 + const params = new URLSearchParams() 256 + params.set('text', q) 257 + params.set('size', String(size)) 258 + params.set('from', String(from)) 259 + 260 + const { data: response } = await $npmRegistry<NpmSearchResponse>( 261 + `/-/v1/search?${params.toString()}`, 262 + {}, 263 + 60, 264 + ) 265 + 266 + if (cache.value && cache.value.query === q && cache.value.provider === provider) { 267 + const existingNames = new Set(cache.value.objects.map(obj => obj.package.name)) 268 + const newObjects = response.objects.filter(obj => !existingNames.has(obj.package.name)) 269 + cache.value = { 270 + query: q, 271 + provider, 272 + objects: [...cache.value.objects, ...newObjects], 273 + total: response.total, 274 + } 275 + } else { 276 + cache.value = { 277 + query: q, 278 + provider, 279 + objects: response.objects, 280 + total: response.total, 281 + } 227 282 } 228 283 } 229 284 230 285 // If we still need more, fetch again recursively 231 286 if ( 287 + cache.value && 232 288 cache.value.objects.length < targetSize && 233 289 cache.value.objects.length < cache.value.total 234 290 ) { ··· 239 295 } 240 296 } 241 297 242 - // Watch for size increases in incremental mode 298 + // Watch for size increases 243 299 watch( 244 300 () => toValue(options).size, 245 301 async (newSize, oldSize) => { ··· 250 306 }, 251 307 ) 252 308 253 - // Computed data that uses cache in incremental mode 309 + // Re-search when provider changes 310 + watch(searchProvider, () => { 311 + cache.value = null 312 + asyncData.refresh() 313 + }) 314 + 315 + // Computed data that uses cache 254 316 const data = computed<NpmSearchResponse | null>(() => { 255 317 if (cache.value) { 256 318 return { ··· 269 331 }) 270 332 } 271 333 272 - // Whether there are more results available on the server (incremental mode only) 334 + // Whether there are more results available 273 335 const hasMore = computed(() => { 274 336 if (!cache.value) return true 275 337 return cache.value.objects.length < cache.value.total ··· 279 341 ...asyncData, 280 342 /** Reactive search results (uses cache in incremental mode) */ 281 343 data, 282 - /** Whether currently loading more results (incremental mode only) */ 344 + /** Whether currently loading more results */ 283 345 isLoadingMore, 284 - /** Whether there are more results available (incremental mode only) */ 346 + /** Whether there are more results available */ 285 347 hasMore, 286 - /** Manually fetch more results up to target size (incremental mode only) */ 348 + /** Manually fetch more results up to target size */ 287 349 fetchMore, 288 350 /** Whether the search was rate limited by npm (429 error) */ 289 351 isRateLimited: readonly(isRateLimited),
+44 -11
app/composables/npm/useOrgPackages.ts
··· 77 77 } 78 78 79 79 /** 80 - * Fetch all packages for an npm organization 81 - * Returns search-result-like objects for compatibility with PackageList 80 + * Fetch all packages for an npm organization. 81 + * 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). 82 85 */ 83 86 export function useOrgPackages(orgName: MaybeRefOrGetter<string>) { 87 + const { searchProvider } = useSearchProvider() 88 + const { searchByOwner } = useAlgoliaSearch() 89 + 84 90 const asyncData = useLazyAsyncData( 85 - () => `org-packages:${toValue(orgName)}`, 86 - async ({ $npmRegistry, $npmApi }, { signal }) => { 91 + () => `org-packages:${searchProvider.value}:${toValue(orgName)}`, 92 + async ({ $npmRegistry, $npmApi, ssrContext }, { signal }) => { 87 93 const org = toValue(orgName) 88 94 if (!org) { 89 95 return emptySearchResponse 90 96 } 91 97 92 - // Get all package names in the org 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). 93 101 let packageNames: string[] 94 102 try { 95 103 const { packages } = await $fetch<{ packages: string[]; count: number }>( ··· 100 108 } catch (err) { 101 109 // Check if this is a 404 (org not found) 102 110 if (err && typeof err === 'object' && 'statusCode' in err && err.statusCode === 404) { 103 - throw createError({ 111 + const error = createError({ 104 112 statusCode: 404, 105 113 statusMessage: 'Organization not found', 106 114 message: `The organization "@${org}" does not exist on npm`, 107 115 }) 116 + if (import.meta.server) { 117 + ssrContext!.payload.error = error 118 + } 119 + throw error 108 120 } 109 121 // For other errors (network, etc.), return empty array to be safe 110 122 packageNames = [] ··· 114 126 return emptySearchResponse 115 127 } 116 128 117 - // Fetch packuments and downloads in parallel 129 + // --- Algolia fast path: use Algolia to get metadata for known packages --- 130 + if (searchProvider.value === 'algolia') { 131 + try { 132 + const response = await searchByOwner(org) 133 + 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 + } 148 + } 149 + } catch { 150 + // Fall through to npm registry path 151 + } 152 + } 153 + 154 + // --- npm registry path: fetch packuments individually --- 118 155 const [packuments, downloads] = await Promise.all([ 119 - // Fetch packuments with concurrency limit 120 156 (async () => { 121 157 const results = await mapWithConcurrency( 122 158 packageNames, ··· 133 169 }, 134 170 10, 135 171 ) 136 - // Filter out any unpublished packages (missing dist-tags) 137 172 return results.filter( 138 173 (pkg): pkg is MinimalPackument => pkg !== null && !!pkg['dist-tags'], 139 174 ) 140 175 })(), 141 - // Fetch downloads in bulk 142 176 fetchBulkDownloads($npmApi, packageNames, { signal }), 143 177 ]) 144 178 145 - // Convert to search results with download data 146 179 const results: NpmSearchResult[] = packuments.map(pkg => 147 180 packumentToSearchResult(pkg, downloads.get(pkg.name)), 148 181 )
+243
app/composables/npm/useUserPackages.ts
··· 1 + import type { NpmSearchResponse, NpmSearchResult } from '#shared/types' 2 + import { emptySearchResponse } from './useNpmSearch' 3 + 4 + /** Default page size for incremental loading (npm registry path) */ 5 + const PAGE_SIZE = 50 as const 6 + 7 + /** npm search API practical limit for maintainer queries */ 8 + const MAX_RESULTS = 250 9 + 10 + /** 11 + * Fetch packages for a given npm user/maintainer. 12 + * 13 + * The composable handles all loading strategy internally based on the active 14 + * search provider. Consumers get a uniform interface regardless of provider: 15 + * 16 + * - **Algolia**: Fetches all packages at once via `owner.name` filter (fast). 17 + * - **npm**: Incrementally paginates through `maintainer:` search results. 18 + * 19 + * @example 20 + * ```ts 21 + * const { data, status, hasMore, isLoadingMore, loadMore } = useUserPackages(username) 22 + * ``` 23 + */ 24 + export function useUserPackages(username: MaybeRefOrGetter<string>) { 25 + const { searchProvider } = useSearchProvider() 26 + // this is only used in npm path, but we need to extract it when the composable runs 27 + const { $npmRegistry } = useNuxtApp() 28 + const { searchByOwner } = useAlgoliaSearch() 29 + 30 + // --- Incremental loading state (npm path) --- 31 + const currentPage = shallowRef(1) 32 + 33 + /** Tracks which provider actually served the current data (may differ from 34 + * searchProvider when Algolia returns empty and we fall through to npm) */ 35 + const activeProvider = shallowRef<'npm' | 'algolia'>(searchProvider.value) 36 + 37 + const cache = shallowRef<{ 38 + username: string 39 + objects: NpmSearchResult[] 40 + total: number 41 + } | null>(null) 42 + 43 + const isLoadingMore = shallowRef(false) 44 + 45 + const asyncData = useLazyAsyncData( 46 + () => `user-packages:${searchProvider.value}:${toValue(username)}`, 47 + async ({ $npmRegistry }, { signal }) => { 48 + const user = toValue(username) 49 + if (!user) { 50 + return emptySearchResponse 51 + } 52 + 53 + const provider = searchProvider.value 54 + 55 + // --- Algolia: fetch all at once --- 56 + if (provider === 'algolia') { 57 + try { 58 + const response = await searchByOwner(user) 59 + 60 + // Guard against stale response (user/provider changed during await) 61 + if (user !== toValue(username) || provider !== searchProvider.value) { 62 + return emptySearchResponse 63 + } 64 + 65 + // If Algolia returns results, use them. If empty, fall through to npm 66 + // registry which uses `maintainer:` search (matches all maintainers, 67 + // not just the primary owner that Algolia's owner.name indexes). 68 + if (response.objects.length > 0) { 69 + activeProvider.value = 'algolia' 70 + cache.value = { 71 + username: user, 72 + objects: response.objects, 73 + total: response.total, 74 + } 75 + return response 76 + } 77 + } catch { 78 + // Fall through to npm registry path on Algolia failure 79 + } 80 + } 81 + 82 + // --- npm registry: initial page (or Algolia fallback) --- 83 + activeProvider.value = 'npm' 84 + cache.value = null 85 + currentPage.value = 1 86 + 87 + const params = new URLSearchParams() 88 + params.set('text', `maintainer:${user}`) 89 + params.set('size', String(PAGE_SIZE)) 90 + 91 + const { data: response, isStale } = await $npmRegistry<NpmSearchResponse>( 92 + `/-/v1/search?${params.toString()}`, 93 + { signal }, 94 + 60, 95 + ) 96 + 97 + // Guard against stale response (user/provider changed during await) 98 + if (user !== toValue(username) || provider !== searchProvider.value) { 99 + return emptySearchResponse 100 + } 101 + 102 + cache.value = { 103 + username: user, 104 + objects: response.objects, 105 + total: response.total, 106 + } 107 + 108 + return { ...response, isStale } 109 + }, 110 + { default: () => emptySearchResponse }, 111 + ) 112 + // --- Fetch more (npm path only) --- 113 + /** 114 + * Fetch the next page of results from npm registry. 115 + * @param manageLoadingState - When false, caller manages isLoadingMore (used by loadAll to prevent flicker) 116 + */ 117 + async function fetchMore(manageLoadingState = true): Promise<void> { 118 + const user = toValue(username) 119 + // Use activeProvider: if Algolia fell through to npm, we still need pagination 120 + if (!user || activeProvider.value !== 'npm') return 121 + 122 + if (cache.value && cache.value.username !== user) { 123 + cache.value = null 124 + await asyncData.refresh() 125 + return 126 + } 127 + 128 + const currentCount = cache.value?.objects.length ?? 0 129 + const total = Math.min(cache.value?.total ?? Infinity, MAX_RESULTS) 130 + 131 + if (currentCount >= total) return 132 + 133 + if (manageLoadingState) isLoadingMore.value = true 134 + 135 + try { 136 + const from = currentCount 137 + const size = Math.min(PAGE_SIZE, total - currentCount) 138 + 139 + const params = new URLSearchParams() 140 + params.set('text', `maintainer:${user}`) 141 + params.set('size', String(size)) 142 + params.set('from', String(from)) 143 + 144 + const { data: response } = await $npmRegistry<NpmSearchResponse>( 145 + `/-/v1/search?${params.toString()}`, 146 + {}, 147 + 60, 148 + ) 149 + 150 + // Guard against stale response 151 + if (user !== toValue(username) || activeProvider.value !== 'npm') return 152 + 153 + if (cache.value && cache.value.username === user) { 154 + const existingNames = new Set(cache.value.objects.map(obj => obj.package.name)) 155 + const newObjects = response.objects.filter(obj => !existingNames.has(obj.package.name)) 156 + cache.value = { 157 + username: user, 158 + objects: [...cache.value.objects, ...newObjects], 159 + total: response.total, 160 + } 161 + } else { 162 + cache.value = { 163 + username: user, 164 + objects: response.objects, 165 + total: response.total, 166 + } 167 + } 168 + } finally { 169 + if (manageLoadingState) isLoadingMore.value = false 170 + } 171 + } 172 + 173 + /** Load the next page of results (no-op if all loaded or using Algolia) */ 174 + async function loadMore(): Promise<void> { 175 + if (isLoadingMore.value || !hasMore.value) return 176 + currentPage.value++ 177 + await fetchMore() 178 + } 179 + 180 + /** Load all remaining results at once (e.g. when user starts filtering) */ 181 + async function loadAll(): Promise<void> { 182 + if (!hasMore.value) return 183 + 184 + isLoadingMore.value = true 185 + try { 186 + while (hasMore.value) { 187 + await fetchMore(false) 188 + } 189 + } finally { 190 + isLoadingMore.value = false 191 + } 192 + } 193 + 194 + // asyncdata will automatically rerun due to key, but we need to reset cache/page 195 + // when provider changes 196 + watch(searchProvider, newProvider => { 197 + cache.value = null 198 + currentPage.value = 1 199 + activeProvider.value = newProvider 200 + }) 201 + 202 + // Computed data that uses cache (only if it belongs to the current username) 203 + const data = computed<NpmSearchResponse | null>(() => { 204 + const user = toValue(username) 205 + if (cache.value && cache.value.username === user) { 206 + return { 207 + isStale: false, 208 + objects: cache.value.objects, 209 + total: cache.value.total, 210 + time: new Date().toISOString(), 211 + } 212 + } 213 + return asyncData.data.value 214 + }) 215 + 216 + /** Whether there are more results available to load (npm path only) */ 217 + const hasMore = computed(() => { 218 + if (!toValue(username)) return false 219 + // Algolia fetches everything in one request; only npm needs pagination 220 + if (activeProvider.value !== 'npm') return false 221 + if (!cache.value) return true 222 + // npm path: more available if we haven't hit the server total or our cap 223 + const fetched = cache.value.objects.length 224 + const available = cache.value.total 225 + return fetched < available && fetched < MAX_RESULTS 226 + }) 227 + 228 + return { 229 + ...asyncData, 230 + /** Reactive package results */ 231 + data, 232 + /** Whether currently loading more results */ 233 + isLoadingMore, 234 + /** Whether there are more results available */ 235 + hasMore, 236 + /** Load next page of results */ 237 + loadMore, 238 + /** Load all remaining results (for filter/sort) */ 239 + loadAll, 240 + /** Default page size (for display) */ 241 + pageSize: PAGE_SIZE, 242 + } 243 + }
+32
app/composables/useSettings.ts
··· 8 8 9 9 type AccentColorId = keyof typeof ACCENT_COLORS.light 10 10 11 + /** Available search providers */ 12 + export type SearchProvider = 'npm' | 'algolia' 13 + 11 14 /** 12 15 * Application settings stored in localStorage 13 16 */ ··· 24 27 hidePlatformPackages: boolean 25 28 /** User-selected locale */ 26 29 selectedLocale: LocaleObject['code'] | null 30 + /** Search provider for package search */ 31 + searchProvider: SearchProvider 27 32 sidebar: { 28 33 collapsed: string[] 29 34 } ··· 36 41 hidePlatformPackages: true, 37 42 selectedLocale: null, 38 43 preferredBackgroundTheme: null, 44 + searchProvider: import.meta.test ? 'npm' : 'algolia', 39 45 sidebar: { 40 46 collapsed: [], 41 47 }, ··· 102 108 accentColors, 103 109 selectedAccentColor: computed(() => settings.value.accentColorId), 104 110 setAccentColor, 111 + } 112 + } 113 + 114 + /** 115 + * Composable for managing the search provider setting. 116 + */ 117 + export function useSearchProvider() { 118 + const { settings } = useSettings() 119 + 120 + const searchProvider = computed({ 121 + get: () => settings.value.searchProvider, 122 + set: (value: SearchProvider) => { 123 + settings.value.searchProvider = value 124 + }, 125 + }) 126 + 127 + const isAlgolia = computed(() => searchProvider.value === 'algolia') 128 + 129 + function toggle() { 130 + searchProvider.value = searchProvider.value === 'npm' ? 'algolia' : 'npm' 131 + } 132 + 133 + return { 134 + searchProvider, 135 + isAlgolia, 136 + toggle, 105 137 } 106 138 } 107 139
+73 -23
app/pages/search.vue
··· 75 75 isRateLimited, 76 76 } = useNpmSearch(query, () => ({ 77 77 size: requestedSize.value, 78 - incremental: true, 79 78 })) 80 79 81 80 // Results to display (directly from incremental search) ··· 317 316 /** Cache for existence checks to avoid repeated API calls */ 318 317 const existenceCache = ref<Record<string, boolean | 'pending'>>({}) 319 318 320 - interface NpmSearchResponse { 321 - total: number 322 - objects: Array<{ package: { name: string } }> 323 - } 319 + const { search: algoliaSearch } = useAlgoliaSearch() 320 + const { isAlgolia } = useSearchProvider() 324 321 325 322 /** 326 - * Check if an org exists by searching for packages with @orgname scope 327 - * Uses the search API which has CORS enabled 323 + * Check if an org exists by searching for scoped packages (@orgname/...). 324 + * When Algolia is active, searches for `@name/` scoped packages via text query. 325 + * Falls back to npm registry search API otherwise. 328 326 */ 329 327 async function checkOrgExists(name: string): Promise<boolean> { 330 328 const cacheKey = `org:${name.toLowerCase()}` ··· 334 332 } 335 333 existenceCache.value[cacheKey] = 'pending' 336 334 try { 337 - // Search for packages in the @org scope 338 - const response = await $fetch<NpmSearchResponse>(`${NPM_REGISTRY}/-/v1/search`, { 339 - query: { text: `@${name}`, size: 5 }, 340 - }) 341 - // Verify at least one result actually starts with @orgname/ 342 335 const scopePrefix = `@${name.toLowerCase()}/` 336 + 337 + if (isAlgolia.value) { 338 + // Algolia: search for scoped packages — use the scope as a text query 339 + // and verify a result actually starts with @name/ 340 + const response = await algoliaSearch(`@${name}`, { size: 5 }) 341 + const exists = response.objects.some(obj => 342 + obj.package.name.toLowerCase().startsWith(scopePrefix), 343 + ) 344 + existenceCache.value[cacheKey] = exists 345 + return exists 346 + } 347 + 348 + // npm registry: search for packages in the @org scope 349 + const response = await $fetch<{ total: number; objects: Array<{ package: { name: string } }> }>( 350 + `${NPM_REGISTRY}/-/v1/search`, 351 + { query: { text: `@${name}`, size: 5 } }, 352 + ) 343 353 const exists = response.objects.some(obj => 344 354 obj.package.name.toLowerCase().startsWith(scopePrefix), 345 355 ) ··· 352 362 } 353 363 354 364 /** 355 - * Check if a user exists by searching for packages they maintain 356 - * Uses the search API which has CORS enabled 365 + * Check if a user exists by searching for packages they maintain. 366 + * Always uses the npm registry `maintainer:` search because Algolia's 367 + * `owner.name` field represents the org/account, not individual maintainers, 368 + * and cannot reliably distinguish users from orgs. 357 369 */ 358 370 async function checkUserExists(name: string): Promise<boolean> { 359 371 const cacheKey = `user:${name.toLowerCase()}` ··· 419 431 const validatedSuggestions = ref<ValidatedSuggestion[]>([]) 420 432 const suggestionsLoading = shallowRef(false) 421 433 422 - /** Debounced function to validate suggestions */ 423 - const validateSuggestions = debounce(async (parsed: ParsedQuery) => { 434 + /** Counter to discard stale async results when query changes rapidly */ 435 + let suggestionRequestId = 0 436 + 437 + /** Validate suggestions (check org/user existence) */ 438 + async function validateSuggestionsImpl(parsed: ParsedQuery) { 439 + const requestId = ++suggestionRequestId 440 + 424 441 if (!parsed.type || !parsed.name) { 425 442 validatedSuggestions.value = [] 443 + suggestionsLoading.value = false 426 444 return 427 445 } 428 446 ··· 432 450 try { 433 451 if (parsed.type === 'user') { 434 452 const exists = await checkUserExists(parsed.name) 453 + if (requestId !== suggestionRequestId) return 435 454 if (exists) { 436 455 suggestions.push({ type: 'user', name: parsed.name, exists: true }) 437 456 } 438 457 } else if (parsed.type === 'org') { 439 458 const exists = await checkOrgExists(parsed.name) 459 + if (requestId !== suggestionRequestId) return 440 460 if (exists) { 441 461 suggestions.push({ type: 'org', name: parsed.name, exists: true }) 442 462 } ··· 446 466 checkOrgExists(parsed.name), 447 467 checkUserExists(parsed.name), 448 468 ]) 469 + if (requestId !== suggestionRequestId) return 449 470 // Org first (more common) 450 471 if (orgExists) { 451 472 suggestions.push({ type: 'org', name: parsed.name, exists: true }) ··· 455 476 } 456 477 } 457 478 } finally { 458 - suggestionsLoading.value = false 479 + // Only clear loading if this is still the active request 480 + if (requestId === suggestionRequestId) { 481 + suggestionsLoading.value = false 482 + } 459 483 } 460 484 461 - validatedSuggestions.value = suggestions 462 - }, 200) 485 + if (requestId === suggestionRequestId) { 486 + validatedSuggestions.value = suggestions 487 + } 488 + } 489 + 490 + // Debounce lightly for npm (extra API calls are slower), skip debounce for Algolia (fast) 491 + const validateSuggestionsDebounced = debounce(validateSuggestionsImpl, 100) 463 492 464 493 // Validate suggestions when query changes 465 494 watch( 466 495 parsedQuery, 467 496 parsed => { 468 - validateSuggestions(parsed) 497 + if (isAlgolia.value) { 498 + // Algolia existence checks are fast - fire immediately 499 + validateSuggestionsImpl(parsed) 500 + } else { 501 + validateSuggestionsDebounced(parsed) 502 + } 469 503 }, 470 504 { immediate: true }, 471 505 ) 506 + 507 + // Re-validate suggestions and clear caches when provider changes 508 + watch(isAlgolia, () => { 509 + // Cancel any pending debounced validation from the previous provider 510 + validateSuggestionsDebounced.cancel?.() 511 + // Clear existence cache since results may differ between providers 512 + existenceCache.value = {} 513 + // Re-validate with current query 514 + const parsed = parsedQuery.value 515 + if (parsed.type) { 516 + validateSuggestionsImpl(parsed) 517 + } 518 + }) 472 519 473 520 /** Check if there's an exact package match in results */ 474 521 const hasExactPackageMatch = computed(() => { ··· 663 710 <template> 664 711 <main class="flex-1 py-8" :class="{ 'overflow-x-hidden': viewMode !== 'table' }"> 665 712 <div class="container-sm"> 666 - <h1 class="font-mono text-2xl sm:text-3xl font-medium mb-4"> 667 - {{ $t('search.title') }} 668 - </h1> 713 + <div class="flex items-center justify-between gap-4 mb-4"> 714 + <h1 class="font-mono text-2xl sm:text-3xl font-medium"> 715 + {{ $t('search.title') }} 716 + </h1> 717 + <SearchProviderToggle /> 718 + </div> 669 719 670 720 <section v-if="query"> 671 721 <!-- Initial loading (only after user interaction, not during view transition) -->
+66
app/pages/settings.vue
··· 148 148 </div> 149 149 </section> 150 150 151 + <!-- DATA SOURCE Section --> 152 + <section> 153 + <h2 class="text-xs text-fg-muted uppercase tracking-wider mb-4"> 154 + {{ $t('settings.sections.search') }} 155 + </h2> 156 + <div class="bg-bg-subtle border border-border rounded-lg p-4 sm:p-6"> 157 + <div class="space-y-2"> 158 + <label for="search-provider-select" class="block text-sm text-fg font-medium"> 159 + {{ $t('settings.data_source.label') }} 160 + </label> 161 + <p class="text-xs text-fg-muted mb-3"> 162 + {{ $t('settings.data_source.description') }} 163 + </p> 164 + 165 + <ClientOnly> 166 + <select 167 + id="search-provider-select" 168 + :value="settings.searchProvider" 169 + class="w-full sm:w-auto min-w-48 bg-bg border border-border rounded-md px-3 py-2 text-sm text-fg cursor-pointer duration-200 transition-colors hover:border-fg-subtle" 170 + @change=" 171 + settings.searchProvider = ($event.target as HTMLSelectElement) 172 + .value as typeof settings.searchProvider 173 + " 174 + > 175 + <option value="npm"> 176 + {{ $t('settings.data_source.npm') }} 177 + </option> 178 + <option value="algolia"> 179 + {{ $t('settings.data_source.algolia') }} 180 + </option> 181 + </select> 182 + <template #fallback> 183 + <select 184 + id="search-provider-select" 185 + disabled 186 + class="w-full sm:w-auto min-w-48 bg-bg border border-border rounded-md px-3 py-2 text-sm text-fg opacity-50 cursor-wait duration-200 transition-colors hover:border-fg-subtle" 187 + > 188 + <option>{{ $t('common.loading') }}</option> 189 + </select> 190 + </template> 191 + </ClientOnly> 192 + 193 + <!-- Provider description --> 194 + <p class="text-xs text-fg-subtle mt-2"> 195 + {{ 196 + settings.searchProvider === 'algolia' 197 + ? $t('settings.data_source.algolia_description') 198 + : $t('settings.data_source.npm_description') 199 + }} 200 + </p> 201 + 202 + <!-- Algolia attribution --> 203 + <a 204 + v-if="settings.searchProvider === 'algolia'" 205 + href="https://www.algolia.com/developers" 206 + target="_blank" 207 + rel="noopener noreferrer" 208 + class="inline-flex items-center gap-1 text-xs text-fg-subtle hover:text-fg-muted transition-colors mt-2" 209 + > 210 + {{ $t('search.algolia_disclaimer') }} 211 + <span class="i-carbon:launch w-3 h-3" aria-hidden="true" /> 212 + </a> 213 + </div> 214 + </div> 215 + </section> 216 + 151 217 <section> 152 218 <h2 class="text-xs text-fg-muted uppercase tracking-wider mb-4"> 153 219 {{ $t('settings.sections.language') }}
+17 -59
app/pages/~[username]/index.vue
··· 7 7 8 8 const username = computed(() => route.params.username) 9 9 10 - // Infinite scroll state 11 - const pageSize = 50 12 - const maxResults = 250 // npm API hard limit 13 - const currentPage = shallowRef(1) 14 - 15 - // Get initial page from URL (for scroll restoration on reload) 16 - const initialPage = computed(() => { 17 - const p = Number.parseInt(normalizeSearchParam(route.query.page), 10) 18 - return Number.isNaN(p) ? 1 : Math.max(1, p) 19 - }) 20 - 21 10 // Debounced URL update for page and filter/sort 22 11 const updateUrl = debounce((updates: { page?: number; filter?: string; sort?: string }) => { 23 12 router.replace({ ··· 38 27 (normalizeSearchParam(route.query.sort) as SortOption) || 'downloads', 39 28 ) 40 29 41 - // Track if we've loaded all results (one-way flag, doesn't reset) 42 - // Initialize to true if URL already has filter/sort params 43 - const hasLoadedAll = shallowRef( 44 - Boolean(route.query.q) || 45 - (route.query.sort && normalizeSearchParam(route.query.sort) !== 'downloads'), 46 - ) 47 - 48 30 // Update URL when filter/sort changes (debounced) 49 31 const debouncedUpdateUrl = debounce((filter: string, sort: string) => { 50 32 updateUrl({ filter, sort }) 51 33 }, 300) 52 34 35 + // Load all results when user starts filtering/sorting (so client-side filter works on full set) 53 36 watch([filterText, sortOption], ([filter, sort]) => { 54 - // Once user interacts with filter/sort, load all results 55 - if (!hasLoadedAll.value && (filter !== '' || sort !== 'downloads')) { 56 - hasLoadedAll.value = true 37 + if (filter !== '' || sort !== 'downloads') { 38 + loadAll() 57 39 } 58 40 debouncedUpdateUrl(filter, sort) 59 41 }) 60 42 61 - // Search for packages by this maintainer 62 - const searchQuery = computed(() => `maintainer:${username.value}`) 63 - 64 - // Request size: load all if user has interacted with filter/sort, otherwise paginate 65 - const requestSize = computed(() => (hasLoadedAll.value ? maxResults : pageSize * currentPage.value)) 66 - 43 + // Fetch packages (composable manages pagination & provider dispatch internally) 67 44 const { 68 45 data: results, 69 46 status, 70 47 error, 71 48 isLoadingMore, 72 - hasMore: apiHasMore, 73 - fetchMore, 74 - } = useNpmSearch(searchQuery, () => ({ 75 - size: requestSize.value, 76 - })) 49 + hasMore, 50 + loadMore, 51 + loadAll, 52 + pageSize, 53 + } = useUserPackages(username) 77 54 78 - // Initialize current page from URL on mount 79 - onMounted(() => { 80 - if (initialPage.value > 1) { 81 - currentPage.value = initialPage.value 82 - } 55 + // Get initial page from URL (for scroll restoration on reload) 56 + const initialPage = computed(() => { 57 + const p = Number.parseInt(normalizeSearchParam(route.query.page), 10) 58 + return Number.isNaN(p) ? 1 : Math.max(1, p) 83 59 }) 84 60 85 61 // Get the base packages list ··· 132 108 filteredAndSortedPackages.value.reduce((sum, pkg) => sum + (pkg.downloads?.weekly ?? 0), 0), 133 109 ) 134 110 135 - // Check if there are potentially more results 136 - const hasMore = computed(() => { 137 - if (!results.value) return false 138 - // Don't show "load more" when we've already loaded all 139 - if (hasLoadedAll.value) return false 140 - // Use API's hasMore, but cap at maxResults 141 - if (!apiHasMore.value) return false 142 - return results.value.objects.length < maxResults 143 - }) 144 - 145 - async function loadMore() { 146 - if (isLoadingMore.value || !hasMore.value) return 147 - currentPage.value++ 148 - await fetchMore(requestSize.value) 149 - } 150 - 151 111 // Update URL when page changes from scrolling 152 112 function handlePageChange(page: number) { 153 113 updateUrl({ page, filter: filterText.value, sort: sortOption.value }) ··· 155 115 156 116 // Reset state when username changes 157 117 watch(username, () => { 158 - currentPage.value = 1 159 118 filterText.value = '' 160 119 sortOption.value = 'downloads' 161 - hasLoadedAll.value = false 162 120 }) 163 121 164 122 useSeoMeta({ ··· 217 175 </div> 218 176 </header> 219 177 220 - <!-- Loading state --> 178 + <!-- Loading state (only on initial load, not when we already have data) --> 221 179 <LoadingSpinner 222 - v-if="status === 'pending' && currentPage === 1" 180 + v-if="status === 'pending' && packages.length === 0 && !error" 223 181 :text="$t('common.loading_packages')" 224 182 /> 225 183 226 184 <!-- Error state --> 227 - <div v-else-if="status === 'error'" role="alert" class="py-12 text-center"> 185 + <div v-else-if="error || status === 'error'" role="alert" class="py-12 text-center"> 228 186 <p class="text-fg-muted mb-4"> 229 187 {{ error?.message ?? $t('user.page.failed_to_load') }} 230 188 </p> ··· 260 218 v-else 261 219 :results="filteredAndSortedPackages" 262 220 :has-more="hasMore" 263 - :is-loading="isLoadingMore || (status === 'pending' && currentPage > 1)" 221 + :is-loading="isLoadingMore" 264 222 :page-size="pageSize" 265 223 :initial-page="initialPage" 266 224 @load-more="loadMore"
+10
i18n/locales/en.json
··· 36 36 "claim_button": "Claim \"{name}\"", 37 37 "want_to_claim": "Want to claim this package name?", 38 38 "start_typing": "Start typing to search packages", 39 + "algolia_disclaimer": "Powered by Algolia", 39 40 "exact_match": "exact", 40 41 "suggestion": { 41 42 "user": "user", ··· 63 64 "sections": { 64 65 "appearance": "Appearance", 65 66 "display": "Display", 67 + "search": "Data source", 66 68 "language": "Language" 69 + }, 70 + "data_source": { 71 + "label": "Data source", 72 + "description": "Choose where npmx gets search data. Individual package pages always use the npm registry directly.", 73 + "npm": "npm Registry", 74 + "npm_description": "Fetches search, org and user listings directly from the official npm registry. Authoritative, but can be slower.", 75 + "algolia": "Algolia", 76 + "algolia_description": "Uses Algolia for faster search, org and user pages." 67 77 }, 68 78 "relative_dates": "Relative dates", 69 79 "include_types": "Include {'@'}types in install",
+10
lunaria/files/en-GB.json
··· 36 36 "claim_button": "Claim \"{name}\"", 37 37 "want_to_claim": "Want to claim this package name?", 38 38 "start_typing": "Start typing to search packages", 39 + "algolia_disclaimer": "Powered by Algolia", 39 40 "exact_match": "exact", 40 41 "suggestion": { 41 42 "user": "user", ··· 63 64 "sections": { 64 65 "appearance": "Appearance", 65 66 "display": "Display", 67 + "search": "Data source", 66 68 "language": "Language" 69 + }, 70 + "data_source": { 71 + "label": "Data source", 72 + "description": "Choose where npmx gets search data. Individual package pages always use the npm registry directly.", 73 + "npm": "npm Registry", 74 + "npm_description": "Fetches search, org and user listings directly from the official npm registry. Authoritative, but can be slower.", 75 + "algolia": "Algolia", 76 + "algolia_description": "Uses Algolia for faster search, org and user pages." 67 77 }, 68 78 "relative_dates": "Relative dates", 69 79 "include_types": "Include {'@'}types in install",
+10
lunaria/files/en-US.json
··· 36 36 "claim_button": "Claim \"{name}\"", 37 37 "want_to_claim": "Want to claim this package name?", 38 38 "start_typing": "Start typing to search packages", 39 + "algolia_disclaimer": "Powered by Algolia", 39 40 "exact_match": "exact", 40 41 "suggestion": { 41 42 "user": "user", ··· 63 64 "sections": { 64 65 "appearance": "Appearance", 65 66 "display": "Display", 67 + "search": "Data source", 66 68 "language": "Language" 69 + }, 70 + "data_source": { 71 + "label": "Data source", 72 + "description": "Choose where npmx gets search data. Individual package pages always use the npm registry directly.", 73 + "npm": "npm Registry", 74 + "npm_description": "Fetches search, org and user listings directly from the official npm registry. Authoritative, but can be slower.", 75 + "algolia": "Algolia", 76 + "algolia_description": "Uses Algolia for faster search, org and user pages." 67 77 }, 68 78 "relative_dates": "Relative dates", 69 79 "include_types": "Include {'@'}types in install",
+8
nuxt.config.ts
··· 33 33 redisRestUrl: process.env.UPSTASH_KV_REST_API_URL || process.env.KV_REST_API_URL || '', 34 34 redisRestToken: process.env.UPSTASH_KV_REST_API_TOKEN || process.env.KV_REST_API_TOKEN || '', 35 35 }, 36 + public: { 37 + // Algolia npm-search index (maintained by Algolia & jsDelivr, used by yarnpkg.com et al.) 38 + algolia: { 39 + appId: 'OFCNCOG2CU', 40 + apiKey: 'f54e21fa3a2a0160595bb058179bfb1e', 41 + indexName: 'npm-search', 42 + }, 43 + }, 36 44 }, 37 45 38 46 devtools: { enabled: true },
+1
package.json
··· 78 78 "@vueuse/nuxt": "14.2.0", 79 79 "@vueuse/router": "^14.2.0", 80 80 "@vueuse/shared": "14.2.0", 81 + "algoliasearch": "5.48.0", 81 82 "defu": "6.1.4", 82 83 "fast-npm-meta": "1.0.0", 83 84 "focus-trap": "^7.8.0",
+238 -37
pnpm-lock.yaml
··· 122 122 '@vueuse/shared': 123 123 specifier: 14.2.0 124 124 version: 14.2.0(vue@3.5.27(typescript@5.9.3)) 125 + algoliasearch: 126 + specifier: 5.48.0 127 + version: 5.48.0 125 128 defu: 126 129 specifier: 6.1.4 127 130 version: 6.1.4 ··· 340 343 341 344 packages: 342 345 346 + '@algolia/abtesting@1.14.0': 347 + resolution: {integrity: sha512-cZfj+1Z1dgrk3YPtNQNt0H9Rr67P8b4M79JjUKGS0d7/EbFbGxGgSu6zby5f22KXo3LT0LZa4O2c6VVbupJuDg==} 348 + engines: {node: '>= 14.0.0'} 349 + 350 + '@algolia/client-abtesting@5.48.0': 351 + resolution: {integrity: sha512-n17WSJ7vazmM6yDkWBAjY12J8ERkW9toOqNgQ1GEZu/Kc4dJDJod1iy+QP5T/UlR3WICgZDi/7a/VX5TY5LAPQ==} 352 + engines: {node: '>= 14.0.0'} 353 + 354 + '@algolia/client-analytics@5.48.0': 355 + resolution: {integrity: sha512-v5bMZMEqW9U2l40/tTAaRyn4AKrYLio7KcRuHmLaJtxuJAhvZiE7Y62XIsF070juz4MN3eyvfQmI+y5+OVbZuA==} 356 + engines: {node: '>= 14.0.0'} 357 + 358 + '@algolia/client-common@5.48.0': 359 + resolution: {integrity: sha512-7H3DgRyi7UByScc0wz7EMrhgNl7fKPDjKX9OcWixLwCj7yrRXDSIzwunykuYUUO7V7HD4s319e15FlJ9CQIIFQ==} 360 + engines: {node: '>= 14.0.0'} 361 + 362 + '@algolia/client-insights@5.48.0': 363 + resolution: {integrity: sha512-tXmkB6qrIGAXrtRYHQNpfW0ekru/qymV02bjT0w5QGaGw0W91yT+53WB6dTtRRsIrgS30Al6efBvyaEosjZ5uw==} 364 + engines: {node: '>= 14.0.0'} 365 + 366 + '@algolia/client-personalization@5.48.0': 367 + resolution: {integrity: sha512-4tXEsrdtcBZbDF73u14Kb3otN+xUdTVGop1tBjict+Rc/FhsJQVIwJIcTrOJqmvhtBfc56Bu65FiVOnpAZCxcw==} 368 + engines: {node: '>= 14.0.0'} 369 + 370 + '@algolia/client-query-suggestions@5.48.0': 371 + resolution: {integrity: sha512-unzSUwWFpsDrO8935RhMAlyK0Ttua/5XveVIwzfjs5w+GVBsHgIkbOe8VbBJccMU/z1LCwvu1AY3kffuSLAR5Q==} 372 + engines: {node: '>= 14.0.0'} 373 + 374 + '@algolia/client-search@5.48.0': 375 + resolution: {integrity: sha512-RB9bKgYTVUiOcEb5bOcZ169jiiVW811dCsJoLT19DcbbFmU4QaK0ghSTssij35QBQ3SCOitXOUrHcGgNVwS7sQ==} 376 + engines: {node: '>= 14.0.0'} 377 + 378 + '@algolia/ingestion@1.48.0': 379 + resolution: {integrity: sha512-rhoSoPu+TDzDpvpk3cY/pYgbeWXr23DxnAIH/AkN0dUC+GCnVIeNSQkLaJ+CL4NZ51cjLIjksrzb4KC5Xu+ktw==} 380 + engines: {node: '>= 14.0.0'} 381 + 382 + '@algolia/monitoring@1.48.0': 383 + resolution: {integrity: sha512-aSe6jKvWt+8VdjOaq2ERtsXp9+qMXNJ3mTyTc1VMhNfgPl7ArOhRMRSQ8QBnY8ZL4yV5Xpezb7lAg8pdGrrulg==} 384 + engines: {node: '>= 14.0.0'} 385 + 386 + '@algolia/recommend@5.48.0': 387 + resolution: {integrity: sha512-p9tfI1bimAaZrdiVExL/dDyGUZ8gyiSHsktP1ZWGzt5hXpM3nhv4tSjyHtXjEKtA0UvsaHKwSfFE8aAAm1eIQA==} 388 + engines: {node: '>= 14.0.0'} 389 + 390 + '@algolia/requester-browser-xhr@5.48.0': 391 + resolution: {integrity: sha512-XshyfpsQB7BLnHseMinp3fVHOGlTv6uEHOzNK/3XrEF9mjxoZAcdVfY1OCXObfwRWX5qXZOq8FnrndFd44iVsQ==} 392 + engines: {node: '>= 14.0.0'} 393 + 394 + '@algolia/requester-fetch@5.48.0': 395 + resolution: {integrity: sha512-Q4XNSVQU89bKNAPuvzSYqTH9AcbOOiIo6AeYMQTxgSJ2+uvT78CLPMG89RIIloYuAtSfE07s40OLV50++l1Bbw==} 396 + engines: {node: '>= 14.0.0'} 397 + 398 + '@algolia/requester-node-http@5.48.0': 399 + resolution: {integrity: sha512-ZgxV2+5qt3NLeUYBTsi6PLyHcENQWC0iFppFZekHSEDA2wcLdTUjnaJzimTEULHIvJuLRCkUs4JABdhuJktEag==} 400 + engines: {node: '>= 14.0.0'} 401 + 343 402 '@alloc/quick-lru@5.2.0': 344 403 resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} 345 404 engines: {node: '>=10'} ··· 483 542 resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==} 484 543 engines: {node: '>=6.9.0'} 485 544 545 + '@babel/compat-data@7.29.0': 546 + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} 547 + engines: {node: '>=6.9.0'} 548 + 486 549 '@babel/core@7.29.0': 487 550 resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} 488 551 engines: {node: '>=6.9.0'} ··· 677 740 peerDependencies: 678 741 '@babel/core': ^7.0.0-0 679 742 680 - '@babel/plugin-transform-async-generator-functions@7.28.6': 681 - resolution: {integrity: sha512-9knsChgsMzBV5Yh3kkhrZNxH3oCYAfMBkNNaVN4cP2RVlFPe8wYdwwcnOsAbkdDoV9UjFtOXWrWB52M8W4jNeA==} 743 + '@babel/plugin-transform-async-generator-functions@7.29.0': 744 + resolution: {integrity: sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==} 682 745 engines: {node: '>=6.9.0'} 683 746 peerDependencies: 684 747 '@babel/core': ^7.0.0-0 ··· 743 806 peerDependencies: 744 807 '@babel/core': ^7.0.0-0 745 808 746 - '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.28.6': 747 - resolution: {integrity: sha512-5suVoXjC14lUN6ZL9OLKIHCNVWCrqGqlmEp/ixdXjvgnEl/kauLvvMO/Xw9NyMc95Joj1AeLVPVMvibBgSoFlA==} 809 + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0': 810 + resolution: {integrity: sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==} 748 811 engines: {node: '>=6.9.0'} 749 812 peerDependencies: 750 813 '@babel/core': ^7.0.0 ··· 821 884 peerDependencies: 822 885 '@babel/core': ^7.0.0-0 823 886 824 - '@babel/plugin-transform-modules-systemjs@7.28.5': 825 - resolution: {integrity: sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==} 887 + '@babel/plugin-transform-modules-systemjs@7.29.0': 888 + resolution: {integrity: sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==} 826 889 engines: {node: '>=6.9.0'} 827 890 peerDependencies: 828 891 '@babel/core': ^7.0.0-0 ··· 833 896 peerDependencies: 834 897 '@babel/core': ^7.0.0-0 835 898 836 - '@babel/plugin-transform-named-capturing-groups-regex@7.27.1': 837 - resolution: {integrity: sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==} 899 + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0': 900 + resolution: {integrity: sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==} 838 901 engines: {node: '>=6.9.0'} 839 902 peerDependencies: 840 903 '@babel/core': ^7.0.0 ··· 905 968 peerDependencies: 906 969 '@babel/core': ^7.0.0-0 907 970 908 - '@babel/plugin-transform-regenerator@7.28.6': 909 - resolution: {integrity: sha512-eZhoEZHYQLL5uc1gS5e9/oTknS0sSSAtd5TkKMUp3J+S/CaUjagc0kOUPsEbDmMeva0nC3WWl4SxVY6+OBuxfw==} 971 + '@babel/plugin-transform-regenerator@7.29.0': 972 + resolution: {integrity: sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==} 910 973 engines: {node: '>=6.9.0'} 911 974 peerDependencies: 912 975 '@babel/core': ^7.0.0-0 ··· 983 1046 peerDependencies: 984 1047 '@babel/core': ^7.0.0 985 1048 986 - '@babel/preset-env@7.28.6': 987 - resolution: {integrity: sha512-GaTI4nXDrs7l0qaJ6Rg06dtOXTBCG6TMDB44zbqofCIC4PqC7SEvmFFtpxzCDw9W5aJ7RKVshgXTLvLdBFV/qw==} 1049 + '@babel/preset-env@7.29.0': 1050 + resolution: {integrity: sha512-fNEdfc0yi16lt6IZo2Qxk3knHVdfMYX33czNb4v8yWhemoBhibCpQK/uYHtSKIiO+p/zd3+8fYVXhQdOVV608w==} 988 1051 engines: {node: '>=6.9.0'} 989 1052 peerDependencies: 990 1053 '@babel/core': ^7.0.0-0 ··· 1747 1810 resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} 1748 1811 engines: {node: 20 || >=22} 1749 1812 1813 + '@isaacs/brace-expansion@5.0.1': 1814 + resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==} 1815 + engines: {node: 20 || >=22} 1816 + 1750 1817 '@isaacs/cliui@8.0.2': 1751 1818 resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} 1752 1819 engines: {node: '>=12'} 1820 + 1821 + '@isaacs/cliui@9.0.0': 1822 + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} 1823 + engines: {node: '>=18'} 1753 1824 1754 1825 '@isaacs/fs-minipass@4.0.1': 1755 1826 resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} ··· 4575 4646 ajv@8.17.1: 4576 4647 resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} 4577 4648 4649 + algoliasearch@5.48.0: 4650 + resolution: {integrity: sha512-aD8EQC6KEman6/S79FtPdQmB7D4af/etcRL/KwiKFKgAE62iU8c5PeEQvpvIcBPurC3O/4Lj78nOl7ZcoazqSw==} 4651 + engines: {node: '>= 14.0.0'} 4652 + 4578 4653 alien-signals@3.1.2: 4579 4654 resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==} 4580 4655 ··· 4707 4782 peerDependencies: 4708 4783 '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 4709 4784 4710 - babel-plugin-polyfill-corejs3@0.13.0: 4711 - resolution: {integrity: sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==} 4785 + babel-plugin-polyfill-corejs3@0.14.0: 4786 + resolution: {integrity: sha512-AvDcMxJ34W4Wgy4KBIIePQTAOP1Ie2WFwkQp3dB7FQ/f0lI5+nM96zUnYEOE1P9sEg0es5VCP0HxiWu5fUHZAQ==} 4712 4787 peerDependencies: 4713 4788 '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 4714 4789 ··· 5437 5512 resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} 5438 5513 engines: {node: '>=10.13.0'} 5439 5514 5515 + enhanced-resolve@5.19.0: 5516 + resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} 5517 + engines: {node: '>=10.13.0'} 5518 + 5440 5519 entities@4.5.0: 5441 5520 resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} 5442 5521 engines: {node: '>=0.12'} ··· 6499 6578 jackspeak@3.4.3: 6500 6579 resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} 6501 6580 6502 - jackspeak@4.1.1: 6503 - resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} 6581 + jackspeak@4.2.3: 6582 + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} 6504 6583 engines: {node: 20 || >=22} 6505 6584 6506 6585 jake@10.9.4: ··· 7119 7198 7120 7199 minimatch@10.1.1: 7121 7200 resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} 7201 + engines: {node: 20 || >=22} 7202 + 7203 + minimatch@10.1.2: 7204 + resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} 7122 7205 engines: {node: 20 || >=22} 7123 7206 7124 7207 minimatch@3.1.2: ··· 9646 9729 9647 9730 snapshots: 9648 9731 9732 + '@algolia/abtesting@1.14.0': 9733 + dependencies: 9734 + '@algolia/client-common': 5.48.0 9735 + '@algolia/requester-browser-xhr': 5.48.0 9736 + '@algolia/requester-fetch': 5.48.0 9737 + '@algolia/requester-node-http': 5.48.0 9738 + 9739 + '@algolia/client-abtesting@5.48.0': 9740 + dependencies: 9741 + '@algolia/client-common': 5.48.0 9742 + '@algolia/requester-browser-xhr': 5.48.0 9743 + '@algolia/requester-fetch': 5.48.0 9744 + '@algolia/requester-node-http': 5.48.0 9745 + 9746 + '@algolia/client-analytics@5.48.0': 9747 + dependencies: 9748 + '@algolia/client-common': 5.48.0 9749 + '@algolia/requester-browser-xhr': 5.48.0 9750 + '@algolia/requester-fetch': 5.48.0 9751 + '@algolia/requester-node-http': 5.48.0 9752 + 9753 + '@algolia/client-common@5.48.0': {} 9754 + 9755 + '@algolia/client-insights@5.48.0': 9756 + dependencies: 9757 + '@algolia/client-common': 5.48.0 9758 + '@algolia/requester-browser-xhr': 5.48.0 9759 + '@algolia/requester-fetch': 5.48.0 9760 + '@algolia/requester-node-http': 5.48.0 9761 + 9762 + '@algolia/client-personalization@5.48.0': 9763 + dependencies: 9764 + '@algolia/client-common': 5.48.0 9765 + '@algolia/requester-browser-xhr': 5.48.0 9766 + '@algolia/requester-fetch': 5.48.0 9767 + '@algolia/requester-node-http': 5.48.0 9768 + 9769 + '@algolia/client-query-suggestions@5.48.0': 9770 + dependencies: 9771 + '@algolia/client-common': 5.48.0 9772 + '@algolia/requester-browser-xhr': 5.48.0 9773 + '@algolia/requester-fetch': 5.48.0 9774 + '@algolia/requester-node-http': 5.48.0 9775 + 9776 + '@algolia/client-search@5.48.0': 9777 + dependencies: 9778 + '@algolia/client-common': 5.48.0 9779 + '@algolia/requester-browser-xhr': 5.48.0 9780 + '@algolia/requester-fetch': 5.48.0 9781 + '@algolia/requester-node-http': 5.48.0 9782 + 9783 + '@algolia/ingestion@1.48.0': 9784 + dependencies: 9785 + '@algolia/client-common': 5.48.0 9786 + '@algolia/requester-browser-xhr': 5.48.0 9787 + '@algolia/requester-fetch': 5.48.0 9788 + '@algolia/requester-node-http': 5.48.0 9789 + 9790 + '@algolia/monitoring@1.48.0': 9791 + dependencies: 9792 + '@algolia/client-common': 5.48.0 9793 + '@algolia/requester-browser-xhr': 5.48.0 9794 + '@algolia/requester-fetch': 5.48.0 9795 + '@algolia/requester-node-http': 5.48.0 9796 + 9797 + '@algolia/recommend@5.48.0': 9798 + dependencies: 9799 + '@algolia/client-common': 5.48.0 9800 + '@algolia/requester-browser-xhr': 5.48.0 9801 + '@algolia/requester-fetch': 5.48.0 9802 + '@algolia/requester-node-http': 5.48.0 9803 + 9804 + '@algolia/requester-browser-xhr@5.48.0': 9805 + dependencies: 9806 + '@algolia/client-common': 5.48.0 9807 + 9808 + '@algolia/requester-fetch@5.48.0': 9809 + dependencies: 9810 + '@algolia/client-common': 5.48.0 9811 + 9812 + '@algolia/requester-node-http@5.48.0': 9813 + dependencies: 9814 + '@algolia/client-common': 5.48.0 9815 + 9649 9816 '@alloc/quick-lru@5.2.0': {} 9650 9817 9651 9818 '@antfu/install-pkg@1.1.0': ··· 9932 10099 9933 10100 '@babel/compat-data@7.28.6': {} 9934 10101 10102 + '@babel/compat-data@7.29.0': {} 10103 + 9935 10104 '@babel/core@7.29.0': 9936 10105 dependencies: 9937 10106 '@babel/code-frame': 7.29.0 ··· 10173 10342 '@babel/core': 7.29.0 10174 10343 '@babel/helper-plugin-utils': 7.28.6 10175 10344 10176 - '@babel/plugin-transform-async-generator-functions@7.28.6(@babel/core@7.29.0)': 10345 + '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.0)': 10177 10346 dependencies: 10178 10347 '@babel/core': 7.29.0 10179 10348 '@babel/helper-plugin-utils': 7.28.6 ··· 10254 10423 '@babel/core': 7.29.0 10255 10424 '@babel/helper-plugin-utils': 7.28.6 10256 10425 10257 - '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.28.6(@babel/core@7.29.0)': 10426 + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': 10258 10427 dependencies: 10259 10428 '@babel/core': 7.29.0 10260 10429 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) ··· 10336 10505 transitivePeerDependencies: 10337 10506 - supports-color 10338 10507 10339 - '@babel/plugin-transform-modules-systemjs@7.28.5(@babel/core@7.29.0)': 10508 + '@babel/plugin-transform-modules-systemjs@7.29.0(@babel/core@7.29.0)': 10340 10509 dependencies: 10341 10510 '@babel/core': 7.29.0 10342 10511 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) ··· 10354 10523 transitivePeerDependencies: 10355 10524 - supports-color 10356 10525 10357 - '@babel/plugin-transform-named-capturing-groups-regex@7.27.1(@babel/core@7.29.0)': 10526 + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': 10358 10527 dependencies: 10359 10528 '@babel/core': 7.29.0 10360 10529 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) ··· 10434 10603 '@babel/core': 7.29.0 10435 10604 '@babel/helper-plugin-utils': 7.28.6 10436 10605 10437 - '@babel/plugin-transform-regenerator@7.28.6(@babel/core@7.29.0)': 10606 + '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.0)': 10438 10607 dependencies: 10439 10608 '@babel/core': 7.29.0 10440 10609 '@babel/helper-plugin-utils': 7.28.6 ··· 10512 10681 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) 10513 10682 '@babel/helper-plugin-utils': 7.28.6 10514 10683 10515 - '@babel/preset-env@7.28.6(@babel/core@7.29.0)': 10684 + '@babel/preset-env@7.29.0(@babel/core@7.29.0)': 10516 10685 dependencies: 10517 - '@babel/compat-data': 7.28.6 10686 + '@babel/compat-data': 7.29.0 10518 10687 '@babel/core': 7.29.0 10519 10688 '@babel/helper-compilation-targets': 7.28.6 10520 10689 '@babel/helper-plugin-utils': 7.28.6 ··· 10529 10698 '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) 10530 10699 '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.0) 10531 10700 '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) 10532 - '@babel/plugin-transform-async-generator-functions': 7.28.6(@babel/core@7.29.0) 10701 + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.0) 10533 10702 '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.0) 10534 10703 '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.0) 10535 10704 '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) ··· 10540 10709 '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) 10541 10710 '@babel/plugin-transform-dotall-regex': 7.28.6(@babel/core@7.29.0) 10542 10711 '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.29.0) 10543 - '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.28.6(@babel/core@7.29.0) 10712 + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) 10544 10713 '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.0) 10545 10714 '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.0) 10546 10715 '@babel/plugin-transform-exponentiation-operator': 7.28.6(@babel/core@7.29.0) ··· 10553 10722 '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.0) 10554 10723 '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.29.0) 10555 10724 '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) 10556 - '@babel/plugin-transform-modules-systemjs': 7.28.5(@babel/core@7.29.0) 10725 + '@babel/plugin-transform-modules-systemjs': 7.29.0(@babel/core@7.29.0) 10557 10726 '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.29.0) 10558 - '@babel/plugin-transform-named-capturing-groups-regex': 7.27.1(@babel/core@7.29.0) 10727 + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) 10559 10728 '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.29.0) 10560 10729 '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) 10561 10730 '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.0) ··· 10567 10736 '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) 10568 10737 '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) 10569 10738 '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.0) 10570 - '@babel/plugin-transform-regenerator': 7.28.6(@babel/core@7.29.0) 10739 + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.0) 10571 10740 '@babel/plugin-transform-regexp-modifiers': 7.28.6(@babel/core@7.29.0) 10572 10741 '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.29.0) 10573 10742 '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) ··· 10581 10750 '@babel/plugin-transform-unicode-sets-regex': 7.28.6(@babel/core@7.29.0) 10582 10751 '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.0) 10583 10752 babel-plugin-polyfill-corejs2: 0.4.15(@babel/core@7.29.0) 10584 - babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.0) 10753 + babel-plugin-polyfill-corejs3: 0.14.0(@babel/core@7.29.0) 10585 10754 babel-plugin-polyfill-regenerator: 0.6.6(@babel/core@7.29.0) 10586 10755 core-js-compat: 3.48.0 10587 10756 semver: 6.3.1 ··· 11182 11351 dependencies: 11183 11352 '@isaacs/balanced-match': 4.0.1 11184 11353 11354 + '@isaacs/brace-expansion@5.0.1': 11355 + dependencies: 11356 + '@isaacs/balanced-match': 4.0.1 11357 + 11185 11358 '@isaacs/cliui@8.0.2': 11186 11359 dependencies: 11187 11360 string-width: 5.1.2 ··· 11190 11363 strip-ansi-cjs: strip-ansi@6.0.1 11191 11364 wrap-ansi: 8.1.0 11192 11365 wrap-ansi-cjs: wrap-ansi@7.0.0 11366 + 11367 + '@isaacs/cliui@9.0.0': {} 11193 11368 11194 11369 '@isaacs/fs-minipass@4.0.1': 11195 11370 dependencies: ··· 14539 14714 json-schema-traverse: 1.0.0 14540 14715 require-from-string: 2.0.2 14541 14716 14717 + algoliasearch@5.48.0: 14718 + dependencies: 14719 + '@algolia/abtesting': 1.14.0 14720 + '@algolia/client-abtesting': 5.48.0 14721 + '@algolia/client-analytics': 5.48.0 14722 + '@algolia/client-common': 5.48.0 14723 + '@algolia/client-insights': 5.48.0 14724 + '@algolia/client-personalization': 5.48.0 14725 + '@algolia/client-query-suggestions': 5.48.0 14726 + '@algolia/client-search': 5.48.0 14727 + '@algolia/ingestion': 1.48.0 14728 + '@algolia/monitoring': 1.48.0 14729 + '@algolia/recommend': 5.48.0 14730 + '@algolia/requester-browser-xhr': 5.48.0 14731 + '@algolia/requester-fetch': 5.48.0 14732 + '@algolia/requester-node-http': 5.48.0 14733 + 14542 14734 alien-signals@3.1.2: {} 14543 14735 14544 14736 ansi-escapes@7.2.0: ··· 14689 14881 14690 14882 babel-plugin-polyfill-corejs2@0.4.15(@babel/core@7.29.0): 14691 14883 dependencies: 14692 - '@babel/compat-data': 7.28.6 14884 + '@babel/compat-data': 7.29.0 14693 14885 '@babel/core': 7.29.0 14694 14886 '@babel/helper-define-polyfill-provider': 0.6.6(@babel/core@7.29.0) 14695 14887 semver: 6.3.1 14696 14888 transitivePeerDependencies: 14697 14889 - supports-color 14698 14890 14699 - babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.0): 14891 + babel-plugin-polyfill-corejs3@0.14.0(@babel/core@7.29.0): 14700 14892 dependencies: 14701 14893 '@babel/core': 7.29.0 14702 14894 '@babel/helper-define-polyfill-provider': 0.6.6(@babel/core@7.29.0) ··· 15479 15671 graceful-fs: 4.2.11 15480 15672 tapable: 2.3.0 15481 15673 15674 + enhanced-resolve@5.19.0: 15675 + dependencies: 15676 + graceful-fs: 4.2.11 15677 + tapable: 2.3.0 15678 + 15482 15679 entities@4.5.0: {} 15483 15680 15484 15681 entities@6.0.1: {} ··· 16206 16403 glob@11.1.0: 16207 16404 dependencies: 16208 16405 foreground-child: 3.3.1 16209 - jackspeak: 4.1.1 16210 - minimatch: 10.1.1 16406 + jackspeak: 4.2.3 16407 + minimatch: 10.1.2 16211 16408 minipass: 7.1.2 16212 16409 package-json-from-dist: 1.0.1 16213 16410 path-scurry: 2.0.1 ··· 16878 17075 optionalDependencies: 16879 17076 '@pkgjs/parseargs': 0.11.0 16880 17077 16881 - jackspeak@4.1.1: 17078 + jackspeak@4.2.3: 16882 17079 dependencies: 16883 - '@isaacs/cliui': 8.0.2 17080 + '@isaacs/cliui': 9.0.0 16884 17081 16885 17082 jake@10.9.4: 16886 17083 dependencies: ··· 17635 17832 minimatch@10.1.1: 17636 17833 dependencies: 17637 17834 '@isaacs/brace-expansion': 5.0.0 17835 + 17836 + minimatch@10.1.2: 17837 + dependencies: 17838 + '@isaacs/brace-expansion': 5.0.1 17638 17839 17639 17840 minimatch@3.1.2: 17640 17841 dependencies: ··· 20903 21104 acorn-import-phases: 1.0.4(acorn@8.15.0) 20904 21105 browserslist: 4.28.1 20905 21106 chrome-trace-event: 1.0.4 20906 - enhanced-resolve: 5.18.4 21107 + enhanced-resolve: 5.19.0 20907 21108 es-module-lexer: 2.0.0 20908 21109 eslint-scope: 5.1.1 20909 21110 events: 3.3.0 ··· 21003 21204 dependencies: 21004 21205 '@apideck/better-ajv-errors': 0.3.6(ajv@8.17.1) 21005 21206 '@babel/core': 7.29.0 21006 - '@babel/preset-env': 7.28.6(@babel/core@7.29.0) 21207 + '@babel/preset-env': 7.29.0(@babel/core@7.29.0) 21007 21208 '@babel/runtime': 7.28.6 21008 21209 '@rollup/plugin-babel': 5.3.1(@babel/core@7.29.0)(rollup@2.79.2) 21009 21210 '@rollup/plugin-node-resolve': 15.3.1(rollup@2.79.2)
+18
test/nuxt/a11y.spec.ts
··· 138 138 ProvenanceBadge, 139 139 Readme, 140 140 ReadmeTocDropdown, 141 + SearchProviderToggle, 141 142 SearchSuggestionCard, 142 143 SettingsAccentColorPicker, 143 144 SettingsBgThemePicker, ··· 159 160 import HeaderAccountMenuServer from '~/components/Header/AccountMenu.server.vue' 160 161 import ToggleServer from '~/components/Settings/Toggle.server.vue' 161 162 import PackageDownloadAnalytics from '~/components/Package/DownloadAnalytics.vue' 163 + import SearchProviderToggleServer from '~/components/SearchProviderToggle.server.vue' 162 164 163 165 describe('component accessibility audits', () => { 164 166 describe('DateTime', () => { ··· 2160 2162 const component = await mountSuspended(SearchSuggestionCard, { 2161 2163 props: { type: 'user', name: 'exactuser', isExactMatch: true }, 2162 2164 }) 2165 + const results = await runAxe(component) 2166 + expect(results.violations).toEqual([]) 2167 + }) 2168 + }) 2169 + 2170 + describe('SearchProviderToggle', () => { 2171 + it('should have no accessibility violations', async () => { 2172 + const component = await mountSuspended(SearchProviderToggle) 2173 + const results = await runAxe(component) 2174 + expect(results.violations).toEqual([]) 2175 + }) 2176 + }) 2177 + 2178 + describe('SearchProviderToggle.server', () => { 2179 + it('should have no accessibility violations', async () => { 2180 + const component = await mountSuspended(SearchProviderToggleServer) 2163 2181 const results = await runAxe(component) 2164 2182 expect(results.violations).toEqual([]) 2165 2183 })