Skip to content

Commit 555470b

Browse files
committed
feat: use algolia for search/org/user packages and add to settings
1 parent 6eee501 commit 555470b

File tree

14 files changed

+832
-104
lines changed

14 files changed

+832
-104
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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+
<button
21+
type="button"
22+
class="flex items-center justify-center w-8 h-8 rounded-md text-fg-subtle hover:text-fg hover:bg-bg-elevated transition-colors duration-200 focus-visible:outline-accent/70"
23+
:aria-label="$t('settings.search_provider')"
24+
:aria-expanded="isOpen"
25+
aria-haspopup="true"
26+
@click="isOpen = !isOpen"
27+
>
28+
<span class="i-carbon:settings w-4 h-4" aria-hidden="true" />
29+
</button>
30+
31+
<Transition
32+
enter-active-class="transition-all duration-150"
33+
leave-active-class="transition-all duration-100"
34+
enter-from-class="opacity-0 translate-y-1"
35+
leave-to-class="opacity-0 translate-y-1"
36+
>
37+
<div
38+
v-if="isOpen"
39+
class="absolute inset-ie-0 top-full pt-2 w-72 z-50"
40+
role="menu"
41+
:aria-label="$t('settings.search_provider')"
42+
>
43+
<div
44+
class="bg-bg-subtle/80 backdrop-blur-sm border border-border-subtle rounded-lg shadow-lg shadow-bg-elevated/50 overflow-hidden p-1"
45+
>
46+
<!-- npm Registry option -->
47+
<button
48+
type="button"
49+
role="menuitem"
50+
class="w-full flex items-start gap-3 px-3 py-2.5 rounded-md text-start transition-colors hover:bg-bg-muted"
51+
:class="[!isAlgolia ? 'bg-bg-muted' : '']"
52+
@click="
53+
() => {
54+
searchProvider = 'npm'
55+
isOpen = false
56+
}
57+
"
58+
>
59+
<span
60+
class="i-carbon:catalog w-4 h-4 mt-0.5 shrink-0"
61+
:class="!isAlgolia ? 'text-accent' : 'text-fg-muted'"
62+
aria-hidden="true"
63+
/>
64+
<div class="min-w-0 flex-1">
65+
<div class="text-sm font-medium" :class="!isAlgolia ? 'text-fg' : 'text-fg-muted'">
66+
{{ $t('settings.search_provider_npm') }}
67+
</div>
68+
<p class="text-xs text-fg-subtle mt-0.5">
69+
{{ $t('settings.search_provider_npm_description') }}
70+
</p>
71+
</div>
72+
</button>
73+
74+
<!-- Algolia option -->
75+
<button
76+
type="button"
77+
role="menuitem"
78+
class="w-full flex items-start gap-3 px-3 py-2.5 rounded-md text-start transition-colors hover:bg-bg-muted"
79+
:class="[isAlgolia ? 'bg-bg-muted' : '']"
80+
@click="
81+
() => {
82+
searchProvider = 'algolia'
83+
isOpen = false
84+
}
85+
"
86+
>
87+
<span
88+
class="i-carbon:search w-4 h-4 mt-0.5 shrink-0"
89+
:class="isAlgolia ? 'text-accent' : 'text-fg-muted'"
90+
aria-hidden="true"
91+
/>
92+
<div class="min-w-0 flex-1">
93+
<div class="text-sm font-medium" :class="isAlgolia ? 'text-fg' : 'text-fg-muted'">
94+
{{ $t('settings.search_provider_algolia') }}
95+
</div>
96+
<p class="text-xs text-fg-subtle mt-0.5">
97+
{{ $t('settings.search_provider_algolia_description') }}
98+
</p>
99+
</div>
100+
</button>
101+
102+
<!-- Algolia attribution -->
103+
<div v-if="isAlgolia" class="border-t border-border mx-1 mt-1 pt-2 pb-1">
104+
<a
105+
href="https://www.algolia.com/developers"
106+
target="_blank"
107+
rel="noopener noreferrer"
108+
class="text-xs text-fg-subtle hover:text-fg-muted transition-colors inline-flex items-center gap-1 px-2"
109+
>
110+
{{ $t('search.algolia_disclaimer') }}
111+
<span class="i-carbon:launch w-3 h-3" aria-hidden="true" />
112+
</a>
113+
</div>
114+
</div>
115+
</div>
116+
</Transition>
117+
</div>
118+
</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: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
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+
* Algolia search client for npm packages.
10+
* Uses npm's public Algolia index (same as npmjs.com).
11+
*/
12+
let _searchClient: LiteClient | null = null
13+
14+
function getAlgoliaClient(): LiteClient {
15+
if (!_searchClient) {
16+
// npm's public search-only Algolia credentials (same as npmjs.com uses)
17+
_searchClient = algoliasearch('OFCNCOG2CU', 'f54e21fa3a2a0160595bb058179bfb1e')
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+
* Search npm packages via Algolia.
126+
* Returns results in the same NpmSearchResponse format as the npm registry API.
127+
*/
128+
export async function searchAlgolia(
129+
query: string,
130+
options: AlgoliaSearchOptions = {},
131+
): Promise<NpmSearchResponse> {
132+
const client = getAlgoliaClient()
133+
134+
const { results } = await client.search([
135+
{
136+
indexName: 'npm-search',
137+
params: {
138+
query,
139+
offset: options.from,
140+
length: options.size,
141+
filters: options.filters || '',
142+
analyticsTags: ['npmx.dev'],
143+
attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE,
144+
attributesToHighlight: [],
145+
},
146+
},
147+
])
148+
149+
const response = results[0] as SearchResponse<AlgoliaHit>
150+
151+
return {
152+
isStale: false,
153+
objects: response.hits.map(hitToSearchResult),
154+
total: response.nbHits!,
155+
time: new Date().toISOString(),
156+
}
157+
}
158+
159+
/**
160+
* Fetch all packages in an Algolia scope (org or user).
161+
* Uses facet filters for efficient server-side filtering.
162+
*
163+
* For orgs: filters by `owner.name:orgname` which matches scoped packages.
164+
* For users: filters by `owner.name:username` which matches maintainer.
165+
*/
166+
export async function searchAlgoliaByOwner(
167+
ownerName: string,
168+
options: { maxResults?: number } = {},
169+
): Promise<NpmSearchResponse> {
170+
const client = getAlgoliaClient()
171+
const max = options.maxResults ?? 1000
172+
173+
const allHits: AlgoliaHit[] = []
174+
let offset = 0
175+
const batchSize = 200
176+
177+
// Algolia supports up to 1000 results per query with offset/length pagination
178+
while (offset < max) {
179+
const length = Math.min(batchSize, max - offset)
180+
181+
const { results } = await client.search([
182+
{
183+
indexName: 'npm-search',
184+
params: {
185+
query: '',
186+
offset,
187+
length,
188+
filters: `owner.name:${ownerName}`,
189+
analyticsTags: ['npmx.dev'],
190+
attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE,
191+
attributesToHighlight: [],
192+
},
193+
},
194+
])
195+
196+
const response = results[0] as SearchResponse<AlgoliaHit>
197+
allHits.push(...response.hits)
198+
199+
// If we got fewer than requested, we've exhausted all results
200+
if (response.hits.length < length || allHits.length >= response.nbHits!) {
201+
break
202+
}
203+
204+
offset += length
205+
}
206+
207+
return {
208+
isStale: false,
209+
objects: allHits.map(hitToSearchResult),
210+
total: allHits.length,
211+
time: new Date().toISOString(),
212+
}
213+
}

0 commit comments

Comments
 (0)