Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2099e51
feat(search): quick and dirty algolia prototype
Haroenv Jan 27, 2026
1c9df3b
quick improve
Haroenv Jan 27, 2026
6eee501
Merge branch 'main' into feat/algolia-search
danielroe Feb 8, 2026
555470b
feat: use algolia for search/org/user packages and add to settings
danielroe Feb 8, 2026
4f6c2df
Merge remote-tracking branch 'origin/main' into feat/algolia-search
danielroe Feb 8, 2026
192ef68
refactor: extract creds to runtime config
danielroe Feb 8, 2026
8cd2aa0
chore: bump algoliasearch version
danielroe Feb 8, 2026
8296a24
fix: refine and improve
danielroe Feb 8, 2026
0aa6f03
fix: speed up loading of org/user suggestions
danielroe Feb 8, 2026
4074e9b
fix: more fixes
danielroe Feb 8, 2026
0495cc5
fix: ssr render 404 page for orgs
danielroe Feb 8, 2026
114abf3
chore: tweak language
danielroe Feb 8, 2026
e5022a5
chore: update i18n key and reduce flakiness on suggestion
danielroe Feb 8, 2026
54e95b5
fix: address code review comments
danielroe Feb 8, 2026
2641a99
fix: address coderabbit reviews
danielroe Feb 8, 2026
cd38980
chore: add comment
danielroe Feb 8, 2026
80b2d51
ci: add default reporter alongside junit for visible test output
danielroe Feb 8, 2026
6d9abc6
fix: track active provider for npm fallback pagination and guard cach…
danielroe Feb 8, 2026
54f6135
fix: guard hasMore against empty username to prevent infinite loadAll…
danielroe Feb 8, 2026
212e175
fix: use npm as source of truth for org packages
danielroe Feb 8, 2026
3857d2e
test: use npm provider in tests (to avoid network calls)
danielroe Feb 8, 2026
2bb6ad9
fix: add nextTick before Enter keydown in PackageSelector tests for a…
danielroe Feb 8, 2026
e68e2b6
Merge remote-tracking branch 'origin/main' into feat/algolia-search
danielroe Feb 8, 2026
1c62206
fix: default to `npm` when testing
danielroe Feb 8, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ jobs:
run: pnpm install

- name: 🧪 Unit tests
run: pnpm test:unit run --coverage --reporter=junit --outputFile=test-report.junit.xml
run: pnpm test:unit run --coverage --reporter=default --reporter=junit --outputFile=test-report.junit.xml

- name: ⬆︎ Upload test results to Codecov
if: ${{ !cancelled() }}
Expand Down Expand Up @@ -115,7 +115,7 @@ jobs:
run: pnpm playwright install chromium-headless-shell

- name: 🧪 Component tests
run: pnpm test:nuxt run --coverage --reporter=junit --outputFile=test-report.junit.xml
run: pnpm test:nuxt run --coverage --reporter=default --reporter=junit --outputFile=test-report.junit.xml

- name: ⬆︎ Upload coverage reports to Codecov
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
Expand Down
117 changes: 117 additions & 0 deletions app/components/SearchProviderToggle.client.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<script setup lang="ts">
const { searchProvider, isAlgolia } = useSearchProvider()

const isOpen = shallowRef(false)
const toggleRef = useTemplateRef('toggleRef')

onClickOutside(toggleRef, () => {
isOpen.value = false
})

useEventListener('keydown', event => {
if (event.key === 'Escape' && isOpen.value) {
isOpen.value = false
}
})
</script>

<template>
<div ref="toggleRef" class="relative">
<ButtonBase
:aria-label="$t('settings.data_source.label')"
:aria-expanded="isOpen"
aria-haspopup="true"
size="small"
class="border-none w-8 h-8 !px-0 justify-center"
classicon="i-carbon:settings"
@click="isOpen = !isOpen"
/>

<Transition
enter-active-class="transition-all duration-150"
leave-active-class="transition-all duration-100"
enter-from-class="opacity-0 translate-y-1"
leave-to-class="opacity-0 translate-y-1"
>
<div
v-if="isOpen"
class="absolute inset-ie-0 top-full pt-2 w-72 z-50"
role="menu"
:aria-label="$t('settings.data_source.label')"
>
<div
class="bg-bg-subtle/80 backdrop-blur-sm border border-border-subtle rounded-lg shadow-lg shadow-bg-elevated/50 overflow-hidden p-1"
>
<!-- npm Registry option -->
<button
type="button"
role="menuitem"
class="w-full flex items-start gap-3 px-3 py-2.5 rounded-md text-start transition-colors hover:bg-bg-muted"
:class="[!isAlgolia ? 'bg-bg-muted' : '']"
@click="
() => {
searchProvider = 'npm'
isOpen = false
}
"
>
<span
class="i-carbon:catalog w-4 h-4 mt-0.5 shrink-0"
:class="!isAlgolia ? 'text-accent' : 'text-fg-muted'"
aria-hidden="true"
/>
<div class="min-w-0 flex-1">
<div class="text-sm font-medium" :class="!isAlgolia ? 'text-fg' : 'text-fg-muted'">
{{ $t('settings.data_source.npm') }}
</div>
<p class="text-xs text-fg-subtle mt-0.5">
{{ $t('settings.data_source.npm_description') }}
</p>
</div>
</button>

<!-- Algolia option -->
<button
type="button"
role="menuitem"
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"
:class="[isAlgolia ? 'bg-bg-muted' : '']"
@click="
() => {
searchProvider = 'algolia'
isOpen = false
}
"
>
<span
class="i-carbon:search w-4 h-4 mt-0.5 shrink-0"
:class="isAlgolia ? 'text-accent' : 'text-fg-muted'"
aria-hidden="true"
/>
<div class="min-w-0 flex-1">
<div class="text-sm font-medium" :class="isAlgolia ? 'text-fg' : 'text-fg-muted'">
{{ $t('settings.data_source.algolia') }}
</div>
<p class="text-xs text-fg-subtle mt-0.5">
{{ $t('settings.data_source.algolia_description') }}
</p>
</div>
</button>

<!-- Algolia attribution -->
<div v-if="isAlgolia" class="border-t border-border mx-1 mt-1 pt-2 pb-1">
<a
href="https://www.algolia.com/developers"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-fg-subtle hover:text-fg-muted transition-colors inline-flex items-center gap-1 px-2"
>
{{ $t('search.algolia_disclaimer') }}
<span class="i-carbon:launch w-3 h-3" aria-hidden="true" />
</a>
</div>
</div>
</div>
</Transition>
</div>
</template>
7 changes: 7 additions & 0 deletions app/components/SearchProviderToggle.server.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<template>
<div class="relative">
<div class="flex items-center justify-center w-8 h-8 rounded-md text-fg-subtle">
<span class="i-carbon:settings w-4 h-4" aria-hidden="true" />
</div>
</div>
</template>
238 changes: 238 additions & 0 deletions app/composables/npm/useAlgoliaSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import type { NpmSearchResponse, NpmSearchResult } from '#shared/types'
import {
liteClient as algoliasearch,
type LiteClient,
type SearchResponse,
} from 'algoliasearch/lite'

/**
* Singleton Algolia client, keyed by appId to handle config changes.
*/
let _searchClient: LiteClient | null = null
let _configuredAppId: string | null = null

function getOrCreateClient(appId: string, apiKey: string): LiteClient {
if (!_searchClient || _configuredAppId !== appId) {
_searchClient = algoliasearch(appId, apiKey)
_configuredAppId = appId
}
return _searchClient
}

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

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

/**
* Shape of a hit from the Algolia `npm-search` index.
* Only includes fields we retrieve via `attributesToRetrieve`.
*/
interface AlgoliaHit {
objectID: string
name: string
version: string
description: string | null
modified: number
homepage: string | null
repository: AlgoliaRepo | null
owners: AlgoliaOwner[] | null
downloadsLast30Days: number
downloadsRatio: number
popular: boolean
keywords: string[]
deprecated: boolean | string
isDeprecated: boolean
license: string | null
}

/** Fields we always request from Algolia to keep payload small */
const ATTRIBUTES_TO_RETRIEVE = [
'name',
'version',
'description',
'modified',
'homepage',
'repository',
'owners',
'downloadsLast30Days',
'downloadsRatio',
'popular',
'keywords',
'deprecated',
'isDeprecated',
'license',
]

function hitToSearchResult(hit: AlgoliaHit): NpmSearchResult {
return {
package: {
name: hit.name,
version: hit.version,
description: hit.description || '',
keywords: hit.keywords,
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,
downloads: {
weekly: Math.round(hit.downloadsLast30Days / 4.3),
},
updated: new Date(hit.modified).toISOString(),
}
}

export interface AlgoliaSearchOptions {
/** Number of results */
size?: number
/** Offset for pagination */
from?: number
/** Algolia filters expression (e.g. 'owner.name:username') */
filters?: string
}

/**
* Composable that provides Algolia search functions for npm packages.
*
* Must be called during component setup (or inside another composable)
* because it reads from `useRuntimeConfig()`. The returned functions
* are safe to call at any time (event handlers, async callbacks, etc.).
*/
export function useAlgoliaSearch() {
const { algolia } = useRuntimeConfig().public
const client = getOrCreateClient(algolia.appId, algolia.apiKey)
const indexName = algolia.indexName

/**
* Search npm packages via Algolia.
* Returns results in the same NpmSearchResponse format as the npm registry API.
*/
async function search(
query: string,
options: AlgoliaSearchOptions = {},
): Promise<NpmSearchResponse> {
const { results } = await client.search([
{
indexName,
params: {
query,
offset: options.from,
length: options.size,
filters: options.filters || '',
analyticsTags: ['npmx.dev'],
attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE,
attributesToHighlight: [],
},
},
])

const response = results[0] as SearchResponse<AlgoliaHit> | undefined
if (!response) {
throw new Error('Algolia returned an empty response')
}

return {
isStale: false,
objects: response.hits.map(hitToSearchResult),
total: response.nbHits ?? 0,
time: new Date().toISOString(),
}
}

/**
* Fetch all packages for an Algolia owner (org or user).
* Uses `owner.name` filter for efficient server-side filtering.
*/
async function searchByOwner(
ownerName: string,
options: { maxResults?: number } = {},
): Promise<NpmSearchResponse> {
const max = options.maxResults ?? 1000

const allHits: AlgoliaHit[] = []
let offset = 0
let serverTotal = 0
const batchSize = 200

// Algolia supports up to 1000 results per query with offset/length pagination
while (offset < max) {
// Cap at both the configured max and the server's actual total (once known)
const remaining = serverTotal > 0 ? Math.min(max, serverTotal) - offset : max - offset
if (remaining <= 0) break
const length = Math.min(batchSize, remaining)

const { results } = await client.search([
{
indexName,
params: {
query: '',
offset,
length,
filters: `owner.name:${ownerName}`,
analyticsTags: ['npmx.dev'],
attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE,
attributesToHighlight: [],
},
},
])

const response = results[0] as SearchResponse<AlgoliaHit> | undefined
if (!response) break

serverTotal = response.nbHits ?? 0
allHits.push(...response.hits)

// If we got fewer than requested, we've exhausted all results
if (response.hits.length < length || allHits.length >= serverTotal) {
break
}

offset += length
}

return {
isStale: false,
objects: allHits.map(hitToSearchResult),
// Use server total so callers can detect truncation (allHits.length < total)
total: serverTotal,
time: new Date().toISOString(),
}
}

return {
/** Search packages by text query */
search,
/** Fetch all packages for an owner (org or user) */
searchByOwner,
}
}
Loading
Loading