Skip to content

Commit 9edf83d

Browse files
danielroeHaroenv
andauthored
feat: use algolia for package search + org/user package listing (#1204)
Co-authored-by: Haroen Viaene <hello@haroen.me>
1 parent f14f074 commit 9edf83d

File tree

17 files changed

+1229
-165
lines changed

17 files changed

+1229
-165
lines changed
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
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>
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
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+
}

0 commit comments

Comments
 (0)