Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 187 additions & 7 deletions app/composables/useNpmRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import type {
NpmPerson,
PackageVersionInfo,
} from '#shared/types'
import {
liteClient as algoliasearch,
type LiteClient,
type SearchResponse,
} from 'algoliasearch/lite'
import type { ReleaseType } from 'semver'
import { maxSatisfying, prerelease, major, minor, diff, gt, compare } from 'semver'
import { isExactVersion } from '~/utils/versions'
Expand Down Expand Up @@ -41,15 +46,190 @@ async function fetchCachedPackument(name: string): Promise<Packument | null> {
return promise
}

const ALGOLIA_SEARCH = true
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

put in a proper global flags system

let searchClient: LiteClient
if (ALGOLIA_SEARCH) {
searchClient = algoliasearch('OFCNCOG2CU', 'f54e21fa3a2a0160595bb058179bfb1e')
}

type SearchOptions = {
size?: number
from?: number
quality?: number
popularity?: number
maintenance?: number
}

interface Owner {
name: string
email?: string
avatar?: string
link?: string
}

interface Repo {
url: string
host: string
user: string
project: string
path: string
head?: string
branch?: string
}

interface GithubRepo {
user: string
project: string
path: string
head: string
}

type TsType =
| {
ts: 'definitely-typed'
definitelyTyped: string
}
| {
ts: 'included' | false | { possible: true }
}

type ModuleType = 'cjs' | 'esm' | 'none' | 'unknown'

type StyleType = string | 'none'

type ComputedMeta = {
computedKeywords: string[]
computedMetadata: Record<string, unknown>
}

type GetUser = {
name: string
email?: string
}

type AlgoliaSearchResult = {
objectID: string
rev: string
name: string
downloadsLast30Days: number
downloadsRatio: number
humanDownloadsLast30Days: string
jsDelivrHits: number
popular: boolean
version: string
versions: Record<string, string>
tags: Record<string, string>
description: string | null
dependencies: Record<string, string>
devDependencies: Record<string, string>
originalAuthor?: GetUser
repository: Repo | null
githubRepo: GithubRepo | null
gitHead: string | null
readme: string
owner: Owner | null
deprecated: boolean | string
isDeprecated: boolean
deprecatedReason: string | null
isSecurityHeld: boolean
homepage: string | null
license: string | null
keywords: string[]
computedKeywords: ComputedMeta['computedKeywords']
computedMetadata: ComputedMeta['computedMetadata']
created: number
modified: number
lastPublisher: Owner | null
owners: Owner[]
bin: Record<string, string>
dependents: number
types: TsType
moduleTypes: ModuleType[]
styleTypes: StyleType[]
humanDependents: string
changelogFilename: string | null
lastCrawl: string
_revision: number
_searchInternal: {
alternativeNames: string[]
popularAlternativeNames: string[]
}
}

async function searchNpmPackages(
query: string,
options: {
size?: number
from?: number
quality?: number
popularity?: number
maintenance?: number
} = {},
options: SearchOptions = {},
): Promise<NpmSearchResponse> {
if (ALGOLIA_SEARCH) {
return searchClient
.search([
{
indexName: 'npm-search',
params: {
query,
offset: options.from,
length: options.size,
filters: '',
analyticsTags: ['npmx.dev'],
attributesToRetrieve: [
'name',
'version',
'description',
'modified',
'homepage',
'repository',
'owners',
'downloadsRatio',
'popular',
],
// TODO: actually use this in PackageCard, but requires the splitting and re-joining logic as in InstantSearch and conditional based on ALGOLIA boolean
attributesToHighlight: ['name', 'description'],
},
},
])
.then(({ results }) => {
const response = results[0] as SearchResponse<AlgoliaSearchResult>
return {
objects: response.hits.map<NpmSearchResult>(hit => ({
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clean this mapping a bit, somewhat superfluous now.

package: {
name: hit.name,
version: hit.version,
description: hit.description || '',
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,
updated: new Date(hit.modified).toISOString(),
})),
total: response.nbHits!,
time: new Date().toISOString(),
}
})
}
return await searchNpmPackagesViaRegistry(query, options)
}

async function searchNpmPackagesViaRegistry(
query: string,
options: SearchOptions,
): Promise<NpmSearchResponse> {
const params = new URLSearchParams()
params.set('text', query)
Expand Down
45 changes: 35 additions & 10 deletions app/pages/search.vue
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,9 @@ onMounted(() => {
searchInputRef.value?.focus()
})

const ALGOLIA = true
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

put in a proper global flags system

// fetch all pages up to current
const { data: results, status } = useNpmSearch(query, () => ({
const { data: results, status } = useNpmSearch(ALGOLIA ? inputValue : query, () => ({
size: pageSize * loadedPages.value,
from: 0,
}))
Expand Down Expand Up @@ -387,7 +388,9 @@ defineOgImageComponent('Default', {
<span class="i-carbon-close-large block w-3.5 h-3.5" aria-hidden="true" />
</button>
<!-- Hidden submit button for accessibility (form must have submit button per WCAG) -->
<button type="submit" class="sr-only">{{ t('search.button') }}</button>
<button type="submit" class="sr-only">
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

undo auto-formatting?

{{ t('search.button') }}
</button>
</div>
</div>
</form>
Expand All @@ -411,7 +414,9 @@ defineOgImageComponent('Default', {
<p class="font-mono text-sm text-fg">
{{ t('search.not_taken', { name: query }) }}
</p>
<p class="text-xs text-fg-muted mt-0.5">{{ t('search.claim_prompt') }}</p>
<p class="text-xs text-fg-muted mt-0.5">
{{ t('search.claim_prompt') }}
</p>
</div>
<button
type="button"
Expand All @@ -425,12 +430,28 @@ defineOgImageComponent('Default', {
<p
v-if="visibleResults.total > 0"
role="status"
class="text-fg-muted text-sm mb-6 font-mono"
class="text-fg-muted text-sm mb-6 font-mono flex flex-wrap items-center gap-x-2 gap-y-1"
>
{{ t('search.found_packages', { count: formatNumber(visibleResults.total) }) }}
<span v-if="status === 'pending'" class="text-fg-subtle">{{
t('search.updating')
}}</span>
<span>
{{
t('search.found_packages', {
count: formatNumber(visibleResults.total),
})
}}
<span v-if="status === 'pending' && !ALGOLIA" class="text-fg-subtle">{{
t('search.updating')
}}</span>
</span>
<span v-if="ALGOLIA">
<a
href="https://www.algolia.com/developers"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-fg text-xs align-middle ml-2"
>
{{ t('search.algolia_disclaimer') }}
</a>
</span>
</p>

<!-- No results found -->
Expand All @@ -442,7 +463,9 @@ defineOgImageComponent('Default', {
<!-- Offer to claim the package name if it's valid -->
<div v-if="showClaimPrompt" class="max-w-md mx-auto">
<div class="p-4 bg-bg-subtle border border-border rounded-lg">
<p class="text-sm text-fg-muted mb-3">{{ t('search.want_to_claim') }}</p>
<p class="text-sm text-fg-muted mb-3">
{{ t('search.want_to_claim') }}
</p>
<button
type="button"
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"
Expand Down Expand Up @@ -473,7 +496,9 @@ defineOgImageComponent('Default', {
</section>

<section v-else class="py-20 text-center">
<p class="text-fg-subtle font-mono text-sm">{{ t('search.start_typing') }}</p>
<p class="text-fg-subtle font-mono text-sm">
{{ t('search.start_typing') }}
</p>
</section>
</div>

Expand Down
3 changes: 2 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"claim_prompt": "Claim this package name on npm",
"claim_button": "Claim \"{name}\"",
"want_to_claim": "Want to claim this package name?",
"start_typing": "Start typing to search packages"
"start_typing": "Start typing to search packages",
"algolia_disclaimer": "Search powered by Algolia"
},
"nav": {
"popular_packages": "Popular packages",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@shikijs/themes": "^3.21.0",
"@vueuse/core": "^14.1.0",
"@vueuse/nuxt": "14.1.0",
"algoliasearch": "^5.47.0",
"nuxt": "^4.3.0",
"nuxt-og-image": "^5.1.13",
"perfect-debounce": "^2.1.0",
Expand Down
Loading