-
-
Notifications
You must be signed in to change notification settings - Fork 424
feat: support algolia for package search + org/user package listing #1204
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
2099e51
1c9df3b
6eee501
555470b
4f6c2df
192ef68
8cd2aa0
8296a24
0aa6f03
4074e9b
0495cc5
114abf3
e5022a5
54e95b5
2641a99
cd38980
80b2d51
6d9abc6
54f6135
212e175
3857d2e
2bb6ad9
e68e2b6
1c62206
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| <script setup lang="ts"> | ||
| const { searchProvider, isAlgolia } = useSearchProvider() | ||
|
|
||
| const isOpen = shallowRef(false) | ||
| const toggleRef = useTemplateRef('toggleRef') | ||
|
|
||
| onClickOutside(toggleRef, () => { | ||
| isOpen.value = false | ||
| }) | ||
|
|
||
| useEventListener('keydown', event => { | ||
| if (event.key === 'Escape' && isOpen.value) { | ||
| isOpen.value = false | ||
| } | ||
| }) | ||
| </script> | ||
|
|
||
| <template> | ||
| <div ref="toggleRef" class="relative"> | ||
| <ButtonBase | ||
| :aria-label="$t('settings.search_provider')" | ||
| :aria-expanded="isOpen" | ||
| aria-haspopup="true" | ||
| size="small" | ||
| class="border-none w-8 h-8 !px-0 justify-center" | ||
| classicon="i-carbon:settings" | ||
| @click="isOpen = !isOpen" | ||
| /> | ||
|
|
||
| <Transition | ||
| enter-active-class="transition-all duration-150" | ||
| leave-active-class="transition-all duration-100" | ||
| enter-from-class="opacity-0 translate-y-1" | ||
| leave-to-class="opacity-0 translate-y-1" | ||
| > | ||
| <div | ||
| v-if="isOpen" | ||
| class="absolute inset-ie-0 top-full pt-2 w-72 z-50" | ||
| role="menu" | ||
| :aria-label="$t('settings.search_provider')" | ||
| > | ||
| <div | ||
| class="bg-bg-subtle/80 backdrop-blur-sm border border-border-subtle rounded-lg shadow-lg shadow-bg-elevated/50 overflow-hidden p-1" | ||
| > | ||
| <!-- npm Registry option --> | ||
| <button | ||
| type="button" | ||
| role="menuitem" | ||
| class="w-full flex items-start gap-3 px-3 py-2.5 rounded-md text-start transition-colors hover:bg-bg-muted" | ||
| :class="[!isAlgolia ? 'bg-bg-muted' : '']" | ||
| @click=" | ||
| () => { | ||
| searchProvider = 'npm' | ||
| isOpen = false | ||
| } | ||
| " | ||
| > | ||
| <span | ||
| class="i-carbon:catalog w-4 h-4 mt-0.5 shrink-0" | ||
| :class="!isAlgolia ? 'text-accent' : 'text-fg-muted'" | ||
| aria-hidden="true" | ||
| /> | ||
| <div class="min-w-0 flex-1"> | ||
| <div class="text-sm font-medium" :class="!isAlgolia ? 'text-fg' : 'text-fg-muted'"> | ||
| {{ $t('settings.search_provider_npm') }} | ||
| </div> | ||
| <p class="text-xs text-fg-subtle mt-0.5"> | ||
| {{ $t('settings.search_provider_npm_description') }} | ||
| </p> | ||
| </div> | ||
| </button> | ||
|
|
||
| <!-- Algolia option --> | ||
| <button | ||
| type="button" | ||
| role="menuitem" | ||
| class="w-full flex items-start gap-3 px-3 py-2.5 rounded-md text-start transition-colors hover:bg-bg-muted" | ||
| :class="[isAlgolia ? 'bg-bg-muted' : '']" | ||
| @click=" | ||
| () => { | ||
| searchProvider = 'algolia' | ||
| isOpen = false | ||
| } | ||
| " | ||
| > | ||
| <span | ||
| class="i-carbon:search w-4 h-4 mt-0.5 shrink-0" | ||
| :class="isAlgolia ? 'text-accent' : 'text-fg-muted'" | ||
| aria-hidden="true" | ||
| /> | ||
| <div class="min-w-0 flex-1"> | ||
| <div class="text-sm font-medium" :class="isAlgolia ? 'text-fg' : 'text-fg-muted'"> | ||
| {{ $t('settings.search_provider_algolia') }} | ||
| </div> | ||
| <p class="text-xs text-fg-subtle mt-0.5"> | ||
| {{ $t('settings.search_provider_algolia_description') }} | ||
| </p> | ||
| </div> | ||
| </button> | ||
|
|
||
| <!-- Algolia attribution --> | ||
| <div v-if="isAlgolia" class="border-t border-border mx-1 mt-1 pt-2 pb-1"> | ||
| <a | ||
| href="https://www.algolia.com/developers" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| class="text-xs text-fg-subtle hover:text-fg-muted transition-colors inline-flex items-center gap-1 px-2" | ||
| > | ||
| {{ $t('search.algolia_disclaimer') }} | ||
| <span class="i-carbon:launch w-3 h-3" aria-hidden="true" /> | ||
| </a> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </Transition> | ||
| </div> | ||
| </template> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| <template> | ||
| <div class="relative"> | ||
| <div class="flex items-center justify-center w-8 h-8 rounded-md text-fg-subtle"> | ||
| <span class="i-carbon:settings w-4 h-4" aria-hidden="true" /> | ||
| </div> | ||
| </div> | ||
| </template> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,232 @@ | ||
| import type { NpmSearchResponse, NpmSearchResult } from '#shared/types' | ||
| import { | ||
| liteClient as algoliasearch, | ||
| type LiteClient, | ||
| type SearchResponse, | ||
| } from 'algoliasearch/lite' | ||
|
|
||
| /** | ||
| * Singleton Algolia client, keyed by appId to handle config changes. | ||
| */ | ||
| let _searchClient: LiteClient | null = null | ||
| let _configuredAppId: string | null = null | ||
|
|
||
| function getOrCreateClient(appId: string, apiKey: string): LiteClient { | ||
| if (!_searchClient || _configuredAppId !== appId) { | ||
| _searchClient = algoliasearch(appId, apiKey) | ||
| _configuredAppId = appId | ||
| } | ||
| return _searchClient | ||
| } | ||
|
|
||
| interface AlgoliaOwner { | ||
| name: string | ||
| email?: string | ||
| avatar?: string | ||
| link?: string | ||
| } | ||
|
|
||
| interface AlgoliaRepo { | ||
| url: string | ||
| host: string | ||
| user: string | ||
| project: string | ||
| path: string | ||
| head?: string | ||
| branch?: string | ||
| } | ||
|
|
||
| /** | ||
| * Shape of a hit from the Algolia `npm-search` index. | ||
| * Only includes fields we retrieve via `attributesToRetrieve`. | ||
| */ | ||
| interface AlgoliaHit { | ||
| objectID: string | ||
| name: string | ||
| version: string | ||
| description: string | null | ||
| modified: number | ||
| homepage: string | null | ||
| repository: AlgoliaRepo | null | ||
| owners: AlgoliaOwner[] | null | ||
| downloadsLast30Days: number | ||
| downloadsRatio: number | ||
| popular: boolean | ||
| keywords: string[] | ||
| deprecated: boolean | string | ||
| isDeprecated: boolean | ||
| license: string | null | ||
| } | ||
|
|
||
| /** Fields we always request from Algolia to keep payload small */ | ||
| const ATTRIBUTES_TO_RETRIEVE = [ | ||
| 'name', | ||
| 'version', | ||
| 'description', | ||
| 'modified', | ||
| 'homepage', | ||
| 'repository', | ||
| 'owners', | ||
| 'downloadsLast30Days', | ||
| 'downloadsRatio', | ||
| 'popular', | ||
| 'keywords', | ||
| 'deprecated', | ||
| 'isDeprecated', | ||
| 'license', | ||
| ] | ||
|
|
||
| function hitToSearchResult(hit: AlgoliaHit): NpmSearchResult { | ||
| return { | ||
| package: { | ||
| name: hit.name, | ||
| version: hit.version, | ||
| description: hit.description || '', | ||
| keywords: hit.keywords, | ||
| date: new Date(hit.modified).toISOString(), | ||
| links: { | ||
| npm: `https://www.npmjs.com/package/${hit.name}`, | ||
| homepage: hit.homepage || undefined, | ||
| repository: hit.repository?.url || undefined, | ||
| }, | ||
| maintainers: hit.owners | ||
| ? hit.owners.map(owner => ({ | ||
| name: owner.name, | ||
| email: owner.email, | ||
| })) | ||
| : [], | ||
| }, | ||
| score: { | ||
| final: 0, | ||
| detail: { | ||
| quality: hit.popular ? 1 : 0, | ||
| popularity: hit.downloadsRatio, | ||
| maintenance: 0, | ||
| }, | ||
| }, | ||
| searchScore: 0, | ||
| downloads: { | ||
| weekly: Math.round(hit.downloadsLast30Days / 4.3), | ||
| }, | ||
| updated: new Date(hit.modified).toISOString(), | ||
| } | ||
| } | ||
|
|
||
| export interface AlgoliaSearchOptions { | ||
| /** Number of results */ | ||
| size?: number | ||
| /** Offset for pagination */ | ||
| from?: number | ||
| /** Algolia filters expression (e.g. 'owner.name:username') */ | ||
| filters?: string | ||
| } | ||
|
|
||
| /** | ||
| * Composable that provides Algolia search functions for npm packages. | ||
| * | ||
| * Must be called during component setup (or inside another composable) | ||
| * because it reads from `useRuntimeConfig()`. The returned functions | ||
| * are safe to call at any time (event handlers, async callbacks, etc.). | ||
| */ | ||
| export function useAlgoliaSearch() { | ||
| const { algolia } = useRuntimeConfig().public | ||
| const client = getOrCreateClient(algolia.appId, algolia.apiKey) | ||
| const indexName = algolia.indexName | ||
|
|
||
| /** | ||
| * Search npm packages via Algolia. | ||
| * Returns results in the same NpmSearchResponse format as the npm registry API. | ||
| */ | ||
| async function search( | ||
| query: string, | ||
| options: AlgoliaSearchOptions = {}, | ||
| ): Promise<NpmSearchResponse> { | ||
| const { results } = await client.search([ | ||
| { | ||
| indexName, | ||
| params: { | ||
| query, | ||
| offset: options.from, | ||
| length: options.size, | ||
| filters: options.filters || '', | ||
| analyticsTags: ['npmx.dev'], | ||
| attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE, | ||
| attributesToHighlight: [], | ||
| }, | ||
| }, | ||
| ]) | ||
|
|
||
| const response = results[0] as SearchResponse<AlgoliaHit> | undefined | ||
| if (!response) { | ||
| return { isStale: false, objects: [], total: 0, time: new Date().toISOString() } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't we do some additional processing in this case? It seems this is a case where we won't figure out whether nothing was actually found or a server error |
||
| } | ||
|
|
||
| return { | ||
| isStale: false, | ||
| objects: response.hits.map(hitToSearchResult), | ||
| total: response.nbHits ?? 0, | ||
| time: new Date().toISOString(), | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Fetch all packages for an Algolia owner (org or user). | ||
| * Uses `owner.name` filter for efficient server-side filtering. | ||
| */ | ||
| async function searchByOwner( | ||
| ownerName: string, | ||
| options: { maxResults?: number } = {}, | ||
| ): Promise<NpmSearchResponse> { | ||
| const max = options.maxResults ?? 1000 | ||
|
|
||
| const allHits: AlgoliaHit[] = [] | ||
| let offset = 0 | ||
| const batchSize = 200 | ||
|
|
||
| // Algolia supports up to 1000 results per query with offset/length pagination | ||
| while (offset < max) { | ||
| const length = Math.min(batchSize, max - offset) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems the logic is broken here a bit. It should probably be sth like Because right now, the maximum downloaded is 200, and the total could end up in the minus |
||
|
|
||
| const { results } = await client.search([ | ||
| { | ||
| indexName, | ||
| params: { | ||
| query: '', | ||
| offset, | ||
| length, | ||
| filters: `owner.name:${ownerName}`, | ||
| analyticsTags: ['npmx.dev'], | ||
| attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE, | ||
| attributesToHighlight: [], | ||
| }, | ||
| }, | ||
| ]) | ||
|
|
||
| const response = results[0] as SearchResponse<AlgoliaHit> | undefined | ||
| if (!response) break | ||
|
|
||
| allHits.push(...response.hits) | ||
|
|
||
| // If we got fewer than requested, we've exhausted all results | ||
| if (response.hits.length < length || allHits.length >= (response.nbHits ?? 0)) { | ||
| break | ||
| } | ||
|
|
||
| offset += length | ||
| } | ||
|
|
||
| return { | ||
| isStale: false, | ||
| objects: allHits.map(hitToSearchResult), | ||
| total: allHits.length, | ||
| time: new Date().toISOString(), | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| /** Search packages by text query */ | ||
| search, | ||
| /** Fetch all packages for an owner (org or user) */ | ||
| searchByOwner, | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now they are glued and it is noticeable when hovering.