Skip to content

Commit 2099e51

Browse files
committed
feat(search): quick and dirty algolia prototype
ref: #32
1 parent 9a001d0 commit 2099e51

File tree

5 files changed

+376
-18
lines changed

5 files changed

+376
-18
lines changed

app/composables/useNpmRegistry.ts

Lines changed: 174 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import type {
88
NpmPerson,
99
PackageVersionInfo,
1010
} from '#shared/types'
11+
import {
12+
liteClient as algoliasearch,
13+
type LiteClient,
14+
type SearchResponse,
15+
} from 'algoliasearch/lite'
1116
import type { ReleaseType } from 'semver'
1217
import { maxSatisfying, prerelease, major, minor, diff, gt, compare } from 'semver'
1318
import { isExactVersion } from '~/utils/versions'
@@ -41,15 +46,177 @@ async function fetchCachedPackument(name: string): Promise<Packument | null> {
4146
return promise
4247
}
4348

49+
const ALGOLIA_SEARCH = true
50+
let searchClient: LiteClient
51+
if (ALGOLIA_SEARCH) {
52+
searchClient = algoliasearch('OFCNCOG2CU', 'f54e21fa3a2a0160595bb058179bfb1e')
53+
}
54+
55+
type SearchOptions = {
56+
size?: number
57+
from?: number
58+
quality?: number
59+
popularity?: number
60+
maintenance?: number
61+
}
62+
63+
interface Owner {
64+
name: string
65+
email?: string
66+
avatar?: string
67+
link?: string
68+
}
69+
70+
interface Repo {
71+
url: string
72+
host: string
73+
user: string
74+
project: string
75+
path: string
76+
head?: string
77+
branch?: string
78+
}
79+
80+
interface GithubRepo {
81+
user: string
82+
project: string
83+
path: string
84+
head: string
85+
}
86+
87+
type TsType =
88+
| {
89+
ts: 'definitely-typed'
90+
definitelyTyped: string
91+
}
92+
| {
93+
ts: 'included' | false | { possible: true }
94+
}
95+
96+
type ModuleType = 'cjs' | 'esm' | 'none' | 'unknown'
97+
98+
type StyleType = string | 'none'
99+
100+
type ComputedMeta = {
101+
computedKeywords: string[]
102+
computedMetadata: Record<string, unknown>
103+
}
104+
105+
type GetUser = {
106+
name: string
107+
email?: string
108+
}
109+
110+
type AlgoliaSearchResult = {
111+
objectID: string
112+
rev: string
113+
name: string
114+
downloadsLast30Days: number
115+
downloadsRatio: number
116+
humanDownloadsLast30Days: string
117+
jsDelivrHits: number
118+
popular: boolean
119+
version: string
120+
versions: Record<string, string>
121+
tags: Record<string, string>
122+
description: string | null
123+
dependencies: Record<string, string>
124+
devDependencies: Record<string, string>
125+
originalAuthor?: GetUser
126+
repository: Repo | null
127+
githubRepo: GithubRepo | null
128+
gitHead: string | null
129+
readme: string
130+
owner: Owner | null
131+
deprecated: boolean | string
132+
isDeprecated: boolean
133+
deprecatedReason: string | null
134+
isSecurityHeld: boolean
135+
homepage: string | null
136+
license: string | null
137+
keywords: string[]
138+
computedKeywords: ComputedMeta['computedKeywords']
139+
computedMetadata: ComputedMeta['computedMetadata']
140+
created: number
141+
modified: number
142+
lastPublisher: Owner | null
143+
owners: Owner[]
144+
bin: Record<string, string>
145+
dependents: number
146+
types: TsType
147+
moduleTypes: ModuleType[]
148+
styleTypes: StyleType[]
149+
humanDependents: string
150+
changelogFilename: string | null
151+
lastCrawl: string
152+
_revision: number
153+
_searchInternal: {
154+
alternativeNames: string[]
155+
popularAlternativeNames: string[]
156+
}
157+
}
158+
44159
async function searchNpmPackages(
45160
query: string,
46-
options: {
47-
size?: number
48-
from?: number
49-
quality?: number
50-
popularity?: number
51-
maintenance?: number
52-
} = {},
161+
options: SearchOptions = {},
162+
): Promise<NpmSearchResponse> {
163+
if (ALGOLIA_SEARCH) {
164+
return searchClient
165+
.search([
166+
{
167+
indexName: 'npm-search',
168+
params: {
169+
query,
170+
hitsPerPage: options.size || 20,
171+
page: options.from ? Math.floor(options.from / (options.size || 20)) : 0,
172+
filters: '',
173+
analyticsTags: ['npmx.dev'],
174+
},
175+
},
176+
])
177+
.then(({ results }) => {
178+
const response = results[0] as SearchResponse<AlgoliaSearchResult>
179+
return {
180+
objects: response.hits.map<NpmSearchResult>(hit => ({
181+
package: {
182+
name: hit.name,
183+
version: hit.version,
184+
description: hit.description || '',
185+
date: new Date(hit.modified).toISOString(),
186+
links: {
187+
npm: `https://www.npmjs.com/package/${hit.name}`,
188+
homepage: hit.homepage || undefined,
189+
repository: hit.repository?.url || undefined,
190+
},
191+
maintainers: hit.owners
192+
? hit.owners.map(owner => ({
193+
name: owner.name,
194+
email: owner.email,
195+
}))
196+
: [],
197+
},
198+
score: {
199+
final: 0,
200+
detail: {
201+
quality: hit.popular ? 1 : 0,
202+
popularity: hit.downloadsRatio,
203+
maintenance: 0,
204+
},
205+
},
206+
searchScore: 0,
207+
updated: new Date(hit.modified).toISOString(),
208+
})),
209+
total: response.nbHits!,
210+
time: new Date().toISOString(),
211+
}
212+
})
213+
}
214+
return await searchNpmPackagesViaRegistry(query, options)
215+
}
216+
217+
async function searchNpmPackagesViaRegistry(
218+
query: string,
219+
options: SearchOptions,
53220
): Promise<NpmSearchResponse> {
54221
const params = new URLSearchParams()
55222
params.set('text', query)

app/pages/search.vue

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,9 @@ onMounted(() => {
139139
searchInputRef.value?.focus()
140140
})
141141
142+
const ALGOLIA = true
142143
// fetch all pages up to current
143-
const { data: results, status } = useNpmSearch(query, () => ({
144+
const { data: results, status } = useNpmSearch(ALGOLIA ? inputValue : query, () => ({
144145
size: pageSize * loadedPages.value,
145146
from: 0,
146147
}))
@@ -387,7 +388,9 @@ defineOgImageComponent('Default', {
387388
<span class="i-carbon-close-large block w-3.5 h-3.5" aria-hidden="true" />
388389
</button>
389390
<!-- Hidden submit button for accessibility (form must have submit button per WCAG) -->
390-
<button type="submit" class="sr-only">{{ t('search.button') }}</button>
391+
<button type="submit" class="sr-only">
392+
{{ t('search.button') }}
393+
</button>
391394
</div>
392395
</div>
393396
</form>
@@ -411,7 +414,9 @@ defineOgImageComponent('Default', {
411414
<p class="font-mono text-sm text-fg">
412415
{{ t('search.not_taken', { name: query }) }}
413416
</p>
414-
<p class="text-xs text-fg-muted mt-0.5">{{ t('search.claim_prompt') }}</p>
417+
<p class="text-xs text-fg-muted mt-0.5">
418+
{{ t('search.claim_prompt') }}
419+
</p>
415420
</div>
416421
<button
417422
type="button"
@@ -425,12 +430,28 @@ defineOgImageComponent('Default', {
425430
<p
426431
v-if="visibleResults.total > 0"
427432
role="status"
428-
class="text-fg-muted text-sm mb-6 font-mono"
433+
class="text-fg-muted text-sm mb-6 font-mono flex flex-wrap items-center gap-x-2 gap-y-1"
429434
>
430-
{{ t('search.found_packages', { count: formatNumber(visibleResults.total) }) }}
431-
<span v-if="status === 'pending'" class="text-fg-subtle">{{
432-
t('search.updating')
433-
}}</span>
435+
<span>
436+
{{
437+
t('search.found_packages', {
438+
count: formatNumber(visibleResults.total),
439+
})
440+
}}
441+
<span v-if="status === 'pending' && !ALGOLIA" class="text-fg-subtle">{{
442+
t('search.updating')
443+
}}</span>
444+
</span>
445+
<span v-if="ALGOLIA">
446+
<a
447+
href="https://www.algolia.com/developers"
448+
target="_blank"
449+
rel="noopener noreferrer"
450+
class="underline hover:text-fg text-xs align-middle ml-2"
451+
>
452+
{{ t('search.algolia_disclaimer') }}
453+
</a>
454+
</span>
434455
</p>
435456

436457
<!-- No results found -->
@@ -442,7 +463,9 @@ defineOgImageComponent('Default', {
442463
<!-- Offer to claim the package name if it's valid -->
443464
<div v-if="showClaimPrompt" class="max-w-md mx-auto">
444465
<div class="p-4 bg-bg-subtle border border-border rounded-lg">
445-
<p class="text-sm text-fg-muted mb-3">{{ t('search.want_to_claim') }}</p>
466+
<p class="text-sm text-fg-muted mb-3">
467+
{{ t('search.want_to_claim') }}
468+
</p>
446469
<button
447470
type="button"
448471
class="px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
@@ -473,7 +496,9 @@ defineOgImageComponent('Default', {
473496
</section>
474497

475498
<section v-else class="py-20 text-center">
476-
<p class="text-fg-subtle font-mono text-sm">{{ t('search.start_typing') }}</p>
499+
<p class="text-fg-subtle font-mono text-sm">
500+
{{ t('search.start_typing') }}
501+
</p>
477502
</section>
478503
</div>
479504

i18n/locales/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
"claim_prompt": "Claim this package name on npm",
2727
"claim_button": "Claim \"{name}\"",
2828
"want_to_claim": "Want to claim this package name?",
29-
"start_typing": "Start typing to search packages"
29+
"start_typing": "Start typing to search packages",
30+
"algolia_disclaimer": "Search powered by Algolia"
3031
},
3132
"nav": {
3233
"popular_packages": "Popular packages",

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"@shikijs/themes": "^3.21.0",
3838
"@vueuse/core": "^14.1.0",
3939
"@vueuse/nuxt": "14.1.0",
40+
"algoliasearch": "^5.47.0",
4041
"nuxt": "^4.3.0",
4142
"nuxt-og-image": "^5.1.13",
4243
"perfect-debounce": "^2.1.0",

0 commit comments

Comments
 (0)