-
Notifications
You must be signed in to change notification settings - Fork 92
feat(search): quick and dirty algolia prototype #174
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
base: main
Are you sure you want to change the base?
Changes from all commits
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 |
|---|---|---|
|
|
@@ -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' | ||
|
|
@@ -41,15 +46,190 @@ async function fetchCachedPackument(name: string): Promise<Packument | null> { | |
| return promise | ||
| } | ||
|
|
||
| const ALGOLIA_SEARCH = true | ||
| 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 => ({ | ||
|
Author
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. 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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -139,8 +139,9 @@ onMounted(() => { | |
| searchInputRef.value?.focus() | ||
| }) | ||
|
|
||
| const ALGOLIA = true | ||
|
Author
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. 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, | ||
| })) | ||
|
|
@@ -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"> | ||
|
Author
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. undo auto-formatting? |
||
| {{ t('search.button') }} | ||
| </button> | ||
| </div> | ||
| </div> | ||
| </form> | ||
|
|
@@ -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" | ||
|
|
@@ -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 --> | ||
|
|
@@ -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" | ||
|
|
@@ -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> | ||
|
|
||
|
|
||
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.
put in a proper global flags system