diff --git a/app/components/Header/SearchBox.vue b/app/components/Header/SearchBox.vue index 2822fd2b1..51e2c3567 100644 --- a/app/components/Header/SearchBox.vue +++ b/app/components/Header/SearchBox.vue @@ -15,6 +15,7 @@ const emit = defineEmits(['blur', 'focus']) const router = useRouter() const route = useRoute() +const { isAlgolia } = useSearchProvider() const isSearchFocused = shallowRef(false) @@ -28,8 +29,7 @@ const searchQuery = shallowRef(normalizeSearchParam(route.query.q)) // Pages that have their own local filter using ?q const pagesWithLocalFilter = new Set(['~username', 'org']) -// Debounced URL update for search query -const updateUrlQuery = debounce((value: string) => { +function updateUrlQueryImpl(value: string) { // Don't navigate away from pages that use ?q for local filtering if (pagesWithLocalFilter.has(route.name as string)) { return @@ -48,9 +48,18 @@ const updateUrlQuery = debounce((value: string) => { q: value, }, }) -}, 250) +} + +const updateUrlQueryNpm = debounce(updateUrlQueryImpl, 250) +const updateUrlQueryAlgolia = debounce(updateUrlQueryImpl, 80) + +const updateUrlQuery = Object.assign( + (value: string) => (isAlgolia.value ? updateUrlQueryAlgolia : updateUrlQueryNpm)(value), + { + flush: () => (isAlgolia.value ? updateUrlQueryAlgolia : updateUrlQueryNpm).flush(), + }, +) -// Watch input and debounce URL updates watch(searchQuery, value => { updateUrlQuery(value) }) diff --git a/app/composables/npm/search-utils.ts b/app/composables/npm/search-utils.ts index f7fda1445..f5dbc298c 100644 --- a/app/composables/npm/search-utils.ts +++ b/app/composables/npm/search-utils.ts @@ -1,8 +1,5 @@ import type { NpmSearchResponse, NpmSearchResult, PackageMetaResponse } from '#shared/types' -/** - * Convert a lightweight package-meta API response to a search result for display. - */ export function metaToSearchResult(meta: PackageMetaResponse): NpmSearchResult { return { package: { @@ -31,3 +28,36 @@ export function emptySearchResponse(): NpmSearchResponse { time: new Date().toISOString(), } } + +export interface SearchSuggestion { + type: 'user' | 'org' + name: string + exists: boolean +} + +export type SuggestionIntent = 'user' | 'org' | 'both' | null + +export function isValidNpmName(name: string): boolean { + if (!name || name.length === 0 || name.length > 214) return false + if (!/^[a-z0-9]/i.test(name)) return false + return /^[\w-]+$/.test(name) +} + +/** Parse a search query into a suggestion intent (`~user`, `@org`, or plain `both`). */ +export function parseSuggestionIntent(query: string): { intent: SuggestionIntent; name: string } { + const q = query.trim() + if (!q) return { intent: null, name: '' } + + if (q.startsWith('~')) { + const name = q.slice(1) + return isValidNpmName(name) ? { intent: 'user', name } : { intent: null, name: '' } + } + + if (q.startsWith('@')) { + if (q.includes('/')) return { intent: null, name: '' } + const name = q.slice(1) + return isValidNpmName(name) ? { intent: 'org', name } : { intent: null, name: '' } + } + + return isValidNpmName(q) ? { intent: 'both', name: q } : { intent: null, name: '' } +} diff --git a/app/composables/npm/useAlgoliaSearch.ts b/app/composables/npm/useAlgoliaSearch.ts index 75928b582..eb10a2e7a 100644 --- a/app/composables/npm/useAlgoliaSearch.ts +++ b/app/composables/npm/useAlgoliaSearch.ts @@ -2,12 +2,10 @@ import type { NpmSearchResponse, NpmSearchResult } from '#shared/types' import { liteClient as algoliasearch, type LiteClient, + type SearchQuery, 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 @@ -36,10 +34,7 @@ interface AlgoliaRepo { branch?: string } -/** - * Shape of a hit from the Algolia `npm-search` index. - * Only includes fields we retrieve via `attributesToRetrieve`. - */ +/** Shape of a hit from the Algolia `npm-search` index. */ interface AlgoliaHit { objectID: string name: string @@ -58,7 +53,6 @@ interface AlgoliaHit { license: string | null } -/** Fields we always request from Algolia to keep payload small */ const ATTRIBUTES_TO_RETRIEVE = [ 'name', 'version', @@ -76,6 +70,8 @@ const ATTRIBUTES_TO_RETRIEVE = [ 'license', ] +const EXISTENCE_CHECK_ATTRS = ['name'] + function hitToSearchResult(hit: AlgoliaHit): NpmSearchResult { return { package: { @@ -113,38 +109,43 @@ function hitToSearchResult(hit: AlgoliaHit): NpmSearchResult { } export interface AlgoliaSearchOptions { - /** Number of results */ size?: number - /** Offset for pagination */ from?: number - /** Algolia filters expression (e.g. 'owner.name:username') */ filters?: string } +/** Extra checks bundled into a single multi-search request. */ +export interface AlgoliaMultiSearchChecks { + name?: string + checkOrg?: boolean + checkUser?: boolean + checkPackage?: string +} + +export interface AlgoliaSearchWithSuggestionsResult { + search: NpmSearchResponse + orgExists: boolean + userExists: boolean + packageExists: boolean | null +} + /** - * 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.). + * Composable providing Algolia search for npm packages. + * Must be called during component setup. */ 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 { - const { results } = await client.search([ - { - indexName, - params: { + const { results } = await client.search({ + requests: [ + { + indexName, query, offset: options.from, length: options.size, @@ -152,9 +153,9 @@ export function useAlgoliaSearch() { analyticsTags: ['npmx.dev'], attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE, attributesToHighlight: [], - }, - }, - ]) + } satisfies SearchQuery, + ], + }) const response = results[0] as SearchResponse | undefined if (!response) { @@ -169,10 +170,7 @@ export function useAlgoliaSearch() { } } - /** - * Fetch all packages for an Algolia owner (org or user). - * Uses `owner.name` filter for efficient server-side filtering. - */ + /** Fetch all packages for an owner using `owner.name` filter with pagination. */ async function searchByOwner( ownerName: string, options: { maxResults?: number } = {}, @@ -184,17 +182,15 @@ export function useAlgoliaSearch() { 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: { + const { results } = await client.search({ + requests: [ + { + indexName, query: '', offset, length, @@ -202,9 +198,9 @@ export function useAlgoliaSearch() { analyticsTags: ['npmx.dev'], attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE, attributesToHighlight: [], - }, - }, - ]) + } satisfies SearchQuery, + ], + }) const response = results[0] as SearchResponse | undefined if (!response) break @@ -212,7 +208,6 @@ export function useAlgoliaSearch() { 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 } @@ -223,23 +218,17 @@ export function useAlgoliaSearch() { return { isStale: false, objects: allHits.map(hitToSearchResult), - // Use server total so callers can detect truncation (allHits.length < total) total: serverTotal, time: new Date().toISOString(), } } - /** - * Fetch metadata for specific packages by exact name. - * Uses Algolia's getObjects REST API to look up packages by objectID - * (which equals the package name in the npm-search index). - */ + /** Fetch metadata for specific packages by exact name using Algolia's getObjects API. */ async function getPackagesByName(packageNames: string[]): Promise { if (packageNames.length === 0) { return { isStale: false, objects: [], total: 0, time: new Date().toISOString() } } - // Algolia getObjects REST API: fetch up to 1000 objects by ID in a single request const response = await $fetch<{ results: (AlgoliaHit | null)[] }>( `https://${algolia.appId}-dsn.algolia.net/1/indexes/*/objects`, { @@ -267,12 +256,107 @@ export function useAlgoliaSearch() { } } + /** + * Combined search + org/user/package existence checks in a single + * Algolia multi-search request. + */ + async function searchWithSuggestions( + query: string, + options: AlgoliaSearchOptions = {}, + checks?: AlgoliaMultiSearchChecks, + ): Promise { + const requests: SearchQuery[] = [ + { + indexName, + query, + offset: options.from, + length: options.size, + filters: options.filters || '', + analyticsTags: ['npmx.dev'], + attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE, + attributesToHighlight: [], + }, + ] + + const orgQueryIndex = checks?.checkOrg && checks.name ? requests.length : -1 + if (checks?.checkOrg && checks.name) { + requests.push({ + indexName, + query: `"@${checks.name}"`, + length: 1, + analyticsTags: ['npmx.dev'], + attributesToRetrieve: EXISTENCE_CHECK_ATTRS, + attributesToHighlight: [], + }) + } + + const userQueryIndex = checks?.checkUser && checks.name ? requests.length : -1 + if (checks?.checkUser && checks.name) { + requests.push({ + indexName, + query: '', + filters: `owner.name:${checks.name}`, + length: 1, + analyticsTags: ['npmx.dev'], + attributesToRetrieve: EXISTENCE_CHECK_ATTRS, + attributesToHighlight: [], + }) + } + + const packageQueryIndex = checks?.checkPackage ? requests.length : -1 + if (checks?.checkPackage) { + requests.push({ + indexName, + query: '', + filters: `objectID:${checks.checkPackage}`, + length: 1, + analyticsTags: ['npmx.dev'], + attributesToRetrieve: EXISTENCE_CHECK_ATTRS, + attributesToHighlight: [], + }) + } + + const { results } = await client.search({ requests }) + + const mainResponse = results[0] as SearchResponse | undefined + if (!mainResponse) { + throw new Error('Algolia returned an empty response') + } + + const searchResult: NpmSearchResponse = { + isStale: false, + objects: mainResponse.hits.map(hitToSearchResult), + total: mainResponse.nbHits ?? 0, + time: new Date().toISOString(), + } + + let orgExists = false + if (orgQueryIndex >= 0 && checks?.name) { + const orgResponse = results[orgQueryIndex] as SearchResponse | undefined + const scopePrefix = `@${checks.name.toLowerCase()}/` + orgExists = + orgResponse?.hits?.some(h => h.name?.toLowerCase().startsWith(scopePrefix)) ?? false + } + + let userExists = false + if (userQueryIndex >= 0) { + const userResponse = results[userQueryIndex] as SearchResponse | undefined + userExists = (userResponse?.nbHits ?? 0) > 0 + } + + let packageExists: boolean | null = null + if (packageQueryIndex >= 0) { + const pkgResponse = results[packageQueryIndex] as SearchResponse | undefined + packageExists = (pkgResponse?.nbHits ?? 0) > 0 + } + + return { search: searchResult, orgExists, userExists, packageExists } + } + return { - /** Search packages by text query */ search, - /** Fetch all packages for an owner (org or user) */ + searchWithSuggestions, searchByOwner, - /** Fetch metadata for specific packages by exact name */ getPackagesByName, } } diff --git a/app/composables/npm/useNpmSearch.ts b/app/composables/npm/useNpmSearch.ts index cb4b1ef28..ebf70e56d 100644 --- a/app/composables/npm/useNpmSearch.ts +++ b/app/composables/npm/useNpmSearch.ts @@ -2,41 +2,50 @@ import type { NpmSearchResponse, PackageMetaResponse } from '#shared/types' import { emptySearchResponse, metaToSearchResult } from './search-utils' export interface NpmSearchOptions { - /** Number of results */ size?: number - /** Offset for pagination */ from?: number } +async function checkOrgExists(name: string): Promise { + try { + const scopePrefix = `@${name.toLowerCase()}/` + const response = await $fetch<{ + total: number + objects: Array<{ package: { name: string } }> + }>(`${NPM_REGISTRY}/-/v1/search`, { query: { text: `@${name}`, size: 5 } }) + return response.objects.some(obj => obj.package.name.toLowerCase().startsWith(scopePrefix)) + } catch { + return false + } +} + +async function checkUserExists(name: string): Promise { + try { + const response = await $fetch<{ total: number }>(`${NPM_REGISTRY}/-/v1/search`, { + query: { text: `maintainer:${name}`, size: 1 }, + }) + return response.total > 0 + } catch { + return false + } +} + /** - * Composable that provides npm registry search functions. - * - * Mirrors the API shape of `useAlgoliaSearch` so that `useSearch` can - * swap between providers without branching on implementation details. - * - * Must be called during component setup (or inside another composable) - * because it reads from `useNuxtApp()`. The returned functions are safe - * to call at any time (event handlers, async callbacks, etc.). + * Composable providing npm registry search. + * Must be called during component setup. */ export function useNpmSearch() { const { $npmRegistry } = useNuxtApp() /** - * Search npm packages via the npm registry API. - * Returns results in the same `NpmSearchResponse` format as `useAlgoliaSearch`. - * - * Single-character queries are handled specially: they fetch lightweight - * metadata from a server-side proxy instead of a search, because the - * search API returns poor results for single-char terms. The proxy - * fetches the full packument + download counts server-side and returns - * only the fields needed for package cards. + * Search npm packages. Single-character queries fetch lightweight metadata + * via a server proxy since the search API returns poor results for them. */ async function search( query: string, options: NpmSearchOptions = {}, signal?: AbortSignal, ): Promise { - // Single-character: fetch lightweight metadata via server proxy if (query.length === 1) { try { const meta = await $fetch( @@ -57,7 +66,6 @@ export function useNpmSearch() { } } - // Standard search const params = new URLSearchParams() params.set('text', query) params.set('size', String(options.size ?? 25)) @@ -75,7 +83,8 @@ export function useNpmSearch() { } return { - /** Search packages by text query */ search, + checkOrgExists, + checkUserExists, } } diff --git a/app/composables/npm/useSearch.ts b/app/composables/npm/useSearch.ts index 54522754b..6a804dcdb 100644 --- a/app/composables/npm/useSearch.ts +++ b/app/composables/npm/useSearch.ts @@ -1,19 +1,34 @@ import type { NpmSearchResponse, NpmSearchResult } from '#shared/types' import type { SearchProvider } from '~/composables/useSettings' -import { emptySearchResponse } from './search-utils' +import type { AlgoliaMultiSearchChecks } from './useAlgoliaSearch' +import { type SearchSuggestion, emptySearchResponse, parseSuggestionIntent } from './search-utils' +import { isValidNewPackageName, checkPackageExists } from '~/utils/package-name' export interface SearchOptions { - /** Number of results to fetch */ size?: number } +export interface UseSearchConfig { + /** + * Enable org/user suggestion and package-availability checks alongside search. + * Algolia bundles these into the same multi-search request. + * npm runs them as separate API calls in parallel. + */ + suggestions?: boolean +} + export function useSearch( query: MaybeRefOrGetter, options: MaybeRefOrGetter = {}, + config: UseSearchConfig = {}, ) { const { searchProvider } = useSearchProvider() - const { search: searchAlgolia } = useAlgoliaSearch() - const { search: searchNpm } = useNpmSearch() + const { search: searchAlgolia, searchWithSuggestions: algoliaMultiSearch } = useAlgoliaSearch() + const { + search: searchNpm, + checkOrgExists: checkOrgNpm, + checkUserExists: checkUserNpm, + } = useNpmSearch() const cache = shallowRef<{ query: string @@ -23,9 +38,102 @@ export function useSearch( } | null>(null) const isLoadingMore = shallowRef(false) - const isRateLimited = ref(false) + const suggestions = shallowRef([]) + const suggestionsLoading = shallowRef(false) + const packageAvailability = shallowRef<{ name: string; available: boolean } | null>(null) + const existenceCache = shallowRef>({}) + let suggestionRequestId = 0 + + /** + * Determine which extra checks to include in the Algolia multi-search. + * Returns `undefined` when nothing uncached needs checking. + */ + function buildAlgoliaChecks(q: string): AlgoliaMultiSearchChecks | undefined { + if (!config.suggestions) return undefined + + const { intent, name } = parseSuggestionIntent(q) + const lowerName = name.toLowerCase() + + const checks: AlgoliaMultiSearchChecks = {} + let hasChecks = false + + if (intent && name) { + const wantOrg = intent === 'org' || intent === 'both' + const wantUser = intent === 'user' || intent === 'both' + + if (wantOrg && existenceCache.value[`org:${lowerName}`] === undefined) { + checks.name = name + checks.checkOrg = true + hasChecks = true + } + if (wantUser && existenceCache.value[`user:${lowerName}`] === undefined) { + checks.name = name + checks.checkUser = true + hasChecks = true + } + } + + const trimmed = q.trim() + if (isValidNewPackageName(trimmed)) { + checks.checkPackage = trimmed + hasChecks = true + } + + return hasChecks ? checks : undefined + } + + /** + * Update suggestion and package-availability state from multi-search results. + * Only writes to the cache for checks that were actually sent; reads from + * existing cache for the rest. + */ + function processAlgoliaChecks( + q: string, + checks: AlgoliaMultiSearchChecks | undefined, + result: { orgExists: boolean; userExists: boolean; packageExists: boolean | null }, + ) { + const { intent, name } = parseSuggestionIntent(q) + + if (intent && name) { + const lowerName = name.toLowerCase() + const wantOrg = intent === 'org' || intent === 'both' + const wantUser = intent === 'user' || intent === 'both' + + const updates: Record = {} + if (checks?.checkOrg) updates[`org:${lowerName}`] = result.orgExists + if (checks?.checkUser) updates[`user:${lowerName}`] = result.userExists + if (Object.keys(updates).length > 0) { + existenceCache.value = { ...existenceCache.value, ...updates } + } + + // Prefer org over user when both match (orgs always match owner.name too) + const isOrg = wantOrg && existenceCache.value[`org:${lowerName}`] + const isUser = wantUser && existenceCache.value[`user:${lowerName}`] + + const newSuggestions: SearchSuggestion[] = [] + if (isOrg) { + newSuggestions.push({ type: 'org', name, exists: true }) + } + if (isUser && !isOrg) { + newSuggestions.push({ type: 'user', name, exists: true }) + } + suggestions.value = newSuggestions + } else { + suggestions.value = [] + } + + const trimmed = q.trim() + if (result.packageExists !== null && isValidNewPackageName(trimmed)) { + packageAvailability.value = { name: trimmed, available: !result.packageExists } + } else if (!isValidNewPackageName(trimmed)) { + packageAvailability.value = null + } + + suggestionsLoading.value = false + } + const asyncData = useLazyAsyncData( () => `search:${searchProvider.value}:${toValue(query)}`, async (_nuxtApp, { signal }) => { @@ -38,27 +146,31 @@ export function useSearch( } const opts = toValue(options) - cache.value = null if (provider === 'algolia') { - const response = await searchAlgolia(q, { - size: opts.size ?? 25, - }) + const checks = config.suggestions ? buildAlgoliaChecks(q) : undefined - if (q !== toValue(query)) { - return emptySearchResponse() + if (config.suggestions) { + suggestionsLoading.value = true + const result = await algoliaMultiSearch(q, { size: opts.size ?? 25 }, checks) + + if (q !== toValue(query)) { + return emptySearchResponse() + } + + isRateLimited.value = false + processAlgoliaChecks(q, checks, result) + return result.search } - isRateLimited.value = false + const response = await searchAlgolia(q, { size: opts.size ?? 25 }) - cache.value = { - query: q, - provider, - objects: response.objects, - total: response.total, + if (q !== toValue(query)) { + return emptySearchResponse() } + isRateLimited.value = false return response } @@ -77,10 +189,8 @@ export function useSearch( } isRateLimited.value = false - return response } catch (error: unknown) { - // npm 429 responses lack CORS headers, so the browser reports "Failed to fetch" const errorMessage = (error as { message?: string })?.message || String(error) const isRateLimitError = errorMessage.includes('Failed to fetch') || errorMessage.includes('429') @@ -110,6 +220,17 @@ export function useSearch( return } + // Seed cache from asyncData for Algolia (which skips cache on initial fetch) + if (!cache.value && asyncData.data.value) { + const d = asyncData.data.value + cache.value = { + query: q, + provider, + objects: [...d.objects], + total: d.total, + } + } + const currentCount = cache.value?.objects.length ?? 0 const total = cache.value?.total ?? Infinity @@ -168,6 +289,7 @@ export function useSearch( watch(searchProvider, async () => { cache.value = null + existenceCache.value = {} await asyncData.refresh() const targetSize = toValue(options).size if (targetSize) { @@ -198,17 +320,119 @@ export function useSearch( return cache.value.objects.length < cache.value.total }) + // npm suggestion checking (Algolia handles suggestions inside the search handler above) + if (config.suggestions) { + async function validateSuggestionsNpm(q: string) { + const requestId = ++suggestionRequestId + const { intent, name } = parseSuggestionIntent(q) + + const trimmed = q.trim() + if (isValidNewPackageName(trimmed)) { + checkPackageExists(trimmed) + .then(exists => { + if (trimmed === toValue(query).trim()) { + packageAvailability.value = { name: trimmed, available: !exists } + } + }) + .catch(() => { + packageAvailability.value = null + }) + } else { + packageAvailability.value = null + } + + if (!intent || !name) { + suggestions.value = [] + suggestionsLoading.value = false + return + } + + suggestionsLoading.value = true + const result: SearchSuggestion[] = [] + const lowerName = name.toLowerCase() + + try { + const wantOrg = intent === 'org' || intent === 'both' + const wantUser = intent === 'user' || intent === 'both' + + const promises: Promise[] = [] + + if (wantOrg && existenceCache.value[`org:${lowerName}`] === undefined) { + promises.push( + checkOrgNpm(name) + .then(exists => { + existenceCache.value = { ...existenceCache.value, [`org:${lowerName}`]: exists } + }) + .catch(() => { + existenceCache.value = { ...existenceCache.value, [`org:${lowerName}`]: false } + }), + ) + } + + if (wantUser && existenceCache.value[`user:${lowerName}`] === undefined) { + promises.push( + checkUserNpm(name) + .then(exists => { + existenceCache.value = { ...existenceCache.value, [`user:${lowerName}`]: exists } + }) + .catch(() => { + existenceCache.value = { ...existenceCache.value, [`user:${lowerName}`]: false } + }), + ) + } + + if (promises.length > 0) { + await Promise.all(promises) + } + + if (requestId !== suggestionRequestId) return + + const isOrg = wantOrg && existenceCache.value[`org:${lowerName}`] + const isUser = wantUser && existenceCache.value[`user:${lowerName}`] + + if (isOrg) { + result.push({ type: 'org', name, exists: true }) + } + if (isUser && !isOrg) { + result.push({ type: 'user', name, exists: true }) + } + } finally { + if (requestId === suggestionRequestId) { + suggestionsLoading.value = false + } + } + + if (requestId === suggestionRequestId) { + suggestions.value = result + } + } + + watch( + () => toValue(query), + q => { + if (searchProvider.value !== 'algolia') { + validateSuggestionsNpm(q) + } + }, + { immediate: true }, + ) + + watch(searchProvider, () => { + if (searchProvider.value !== 'algolia') { + validateSuggestionsNpm(toValue(query)) + } + }) + } + return { ...asyncData, - /** Reactive search results (uses cache in incremental mode) */ data, - /** Whether currently loading more results */ isLoadingMore, - /** Whether there are more results available */ hasMore, - /** Manually fetch more results up to target size */ fetchMore, - /** Whether the search was rate limited by npm (429 error) */ isRateLimited: readonly(isRateLimited), + suggestions: readonly(suggestions), + suggestionsLoading: readonly(suggestionsLoading), + packageAvailability: readonly(packageAvailability), } } diff --git a/app/pages/index.vue b/app/pages/index.vue index 66ea291a3..187a05679 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -2,6 +2,8 @@ import { debounce } from 'perfect-debounce' import { SHOWCASED_FRAMEWORKS } from '~/utils/frameworks' +const { isAlgolia } = useSearchProvider() + const searchQuery = shallowRef('') const isSearchFocused = shallowRef(false) @@ -18,9 +20,18 @@ async function search() { } } -const handleInput = isTouchDevice() - ? search - : debounce(search, 250, { leading: true, trailing: true }) +const handleInputNpm = debounce(search, 250, { leading: true, trailing: true }) +const handleInputAlgolia = debounce(search, 80, { leading: true, trailing: true }) + +function handleInput() { + if (isTouchDevice()) { + search() + } else if (isAlgolia.value) { + handleInputAlgolia() + } else { + handleInputNpm() + } +} useSeoMeta({ title: () => $t('seo.home.title'), diff --git a/app/pages/search.vue b/app/pages/search.vue index 297423e91..24e23663e 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -3,7 +3,7 @@ import type { FilterChip, SortKey } from '#shared/types/preferences' import { parseSortOption, PROVIDER_SORT_KEYS } from '#shared/types/preferences' import { onKeyDown } from '@vueuse/core' import { debounce } from 'perfect-debounce' -import { isValidNewPackageName, checkPackageExists } from '~/utils/package-name' +import { isValidNewPackageName } from '~/utils/package-name' import { isPlatformSpecificPackage } from '~/utils/platform-packages' import { normalizeSearchParam } from '#shared/utils/url' @@ -11,7 +11,6 @@ const route = useRoute() const router = useRouter() // Search provider -const { search: algoliaSearch } = useAlgoliaSearch() const { isAlgolia } = useSearchProvider() // Preferences (persisted to localStorage) @@ -184,7 +183,7 @@ watch(isAlgolia, algolia => { } }) -// Use incremental search with client-side caching +// Use incremental search with client-side caching + org/user suggestions const { data: results, status, @@ -192,9 +191,15 @@ const { hasMore, fetchMore, isRateLimited, -} = useSearch(query, () => ({ - size: requestedSize.value, -})) + suggestions: validatedSuggestions, + packageAvailability, +} = useSearch( + query, + () => ({ + size: requestedSize.value, + }), + { suggestions: true }, +) // Client-side sorted results for display // The search API already handles text filtering, so we only need to sort. @@ -280,41 +285,6 @@ watch(query, () => { // Check if current query could be a valid package name const isValidPackageName = computed(() => isValidNewPackageName(query.value.trim())) -// Check if package name is available (doesn't exist on npm) -const packageAvailability = shallowRef<{ name: string; available: boolean } | null>(null) - -// Debounced check for package availability -const checkAvailability = debounce(async (name: string) => { - if (!isValidNewPackageName(name)) { - packageAvailability.value = null - return - } - - try { - const exists = await checkPackageExists(name) - // Only update if this is still the current query - if (name === query.value.trim()) { - packageAvailability.value = { name, available: !exists } - } - } catch { - packageAvailability.value = null - } -}, 300) - -// Trigger availability check when query changes -watch( - query, - q => { - const trimmed = q.trim() - if (isValidNewPackageName(trimmed)) { - checkAvailability(trimmed) - } else { - packageAvailability.value = null - } - }, - { immediate: true }, -) - // Get connector state const { isConnected, npmUser, listOrgUsers } = useConnector() @@ -377,227 +347,6 @@ const showClaimPrompt = computed(() => { const claimPackageModalRef = useTemplateRef('claimPackageModalRef') -/** - * Check if a string is a valid npm username/org name - * npm usernames: 1-214 characters, lowercase, alphanumeric, hyphen, underscore - * Must not start with hyphen or underscore - */ -function isValidNpmName(name: string): boolean { - if (!name || name.length === 0 || name.length > 214) return false - // Must start with alphanumeric - if (!/^[a-z0-9]/i.test(name)) return false - // Can contain alphanumeric, hyphen, underscore - return /^[\w-]+$/.test(name) -} - -/** Validated user/org suggestion */ -interface ValidatedSuggestion { - type: 'user' | 'org' - name: string - exists: boolean -} - -/** Cache for existence checks to avoid repeated API calls */ -const existenceCache = ref>({}) - -/** - * Check if an org exists by searching for scoped packages (@orgname/...). - * When Algolia is active, searches for `@name/` scoped packages via text query. - * Falls back to npm registry search API otherwise. - */ -async function checkOrgExists(name: string): Promise { - const cacheKey = `org:${name.toLowerCase()}` - if (cacheKey in existenceCache.value) { - const cached = existenceCache.value[cacheKey] - return cached === true - } - existenceCache.value[cacheKey] = 'pending' - try { - const scopePrefix = `@${name.toLowerCase()}/` - - if (isAlgolia.value) { - // Algolia: search for scoped packages — use the scope as a text query - // and verify a result actually starts with @name/ - const response = await algoliaSearch(`@${name}`, { size: 5 }) - const exists = response.objects.some(obj => - obj.package.name.toLowerCase().startsWith(scopePrefix), - ) - existenceCache.value[cacheKey] = exists - return exists - } - - // npm registry: search for packages in the @org scope - const response = await $fetch<{ total: number; objects: Array<{ package: { name: string } }> }>( - `${NPM_REGISTRY}/-/v1/search`, - { query: { text: `@${name}`, size: 5 } }, - ) - const exists = response.objects.some(obj => - obj.package.name.toLowerCase().startsWith(scopePrefix), - ) - existenceCache.value[cacheKey] = exists - return exists - } catch { - existenceCache.value[cacheKey] = false - return false - } -} - -/** - * Check if a user exists by searching for packages they maintain. - * Always uses the npm registry `maintainer:` search because Algolia's - * `owner.name` field represents the org/account, not individual maintainers, - * and cannot reliably distinguish users from orgs. - */ -async function checkUserExists(name: string): Promise { - const cacheKey = `user:${name.toLowerCase()}` - if (cacheKey in existenceCache.value) { - const cached = existenceCache.value[cacheKey] - return cached === true - } - existenceCache.value[cacheKey] = 'pending' - try { - const response = await $fetch<{ total: number }>(`${NPM_REGISTRY}/-/v1/search`, { - query: { text: `maintainer:${name}`, size: 1 }, - }) - const exists = response.total > 0 - existenceCache.value[cacheKey] = exists - return exists - } catch { - existenceCache.value[cacheKey] = false - return false - } -} - -/** - * Parse the search query to extract potential user/org name - */ -interface ParsedQuery { - type: 'user' | 'org' | 'both' | null - name: string -} - -const parsedQuery = computed(() => { - const q = query.value.trim() - if (!q) return { type: null, name: '' } - - // Query starts with ~ - explicit user search - if (q.startsWith('~')) { - const name = q.slice(1) - if (isValidNpmName(name)) { - return { type: 'user', name } - } - return { type: null, name: '' } - } - - // Query starts with @ - org search (without slash) - if (q.startsWith('@')) { - // If it contains a slash, it's a scoped package search - if (q.includes('/')) return { type: null, name: '' } - const name = q.slice(1) - if (isValidNpmName(name)) { - return { type: 'org', name } - } - return { type: null, name: '' } - } - - // Plain query - could be user, org, or package - if (isValidNpmName(q)) { - return { type: 'both', name: q } - } - - return { type: null, name: '' } -}) - -/** Validated suggestions (only those that exist) */ -const validatedSuggestions = ref([]) -const suggestionsLoading = shallowRef(false) - -/** Counter to discard stale async results when query changes rapidly */ -let suggestionRequestId = 0 - -/** Validate suggestions (check org/user existence) */ -async function validateSuggestionsImpl(parsed: ParsedQuery) { - const requestId = ++suggestionRequestId - - if (!parsed.type || !parsed.name) { - validatedSuggestions.value = [] - suggestionsLoading.value = false - return - } - - suggestionsLoading.value = true - const suggestions: ValidatedSuggestion[] = [] - - try { - if (parsed.type === 'user') { - const exists = await checkUserExists(parsed.name) - if (requestId !== suggestionRequestId) return - if (exists) { - suggestions.push({ type: 'user', name: parsed.name, exists: true }) - } - } else if (parsed.type === 'org') { - const exists = await checkOrgExists(parsed.name) - if (requestId !== suggestionRequestId) return - if (exists) { - suggestions.push({ type: 'org', name: parsed.name, exists: true }) - } - } else if (parsed.type === 'both') { - // Check both in parallel - const [orgExists, userExists] = await Promise.all([ - checkOrgExists(parsed.name), - checkUserExists(parsed.name), - ]) - if (requestId !== suggestionRequestId) return - // Org first (more common) - if (orgExists) { - suggestions.push({ type: 'org', name: parsed.name, exists: true }) - } - if (userExists) { - suggestions.push({ type: 'user', name: parsed.name, exists: true }) - } - } - } finally { - // Only clear loading if this is still the active request - if (requestId === suggestionRequestId) { - suggestionsLoading.value = false - } - } - - if (requestId === suggestionRequestId) { - validatedSuggestions.value = suggestions - } -} - -// Debounce lightly for npm (extra API calls are slower), skip debounce for Algolia (fast) -const validateSuggestionsDebounced = debounce(validateSuggestionsImpl, 100) - -// Validate suggestions when query changes -watch( - parsedQuery, - parsed => { - if (isAlgolia.value) { - // Algolia existence checks are fast - fire immediately - validateSuggestionsImpl(parsed) - } else { - validateSuggestionsDebounced(parsed) - } - }, - { immediate: true }, -) - -// Re-validate suggestions and clear caches when provider changes -watch(isAlgolia, () => { - // Cancel any pending debounced validation from the previous provider - validateSuggestionsDebounced.cancel?.() - // Clear existence cache since results may differ between providers - existenceCache.value = {} - // Re-validate with current query - const parsed = parsedQuery.value - if (parsed.type) { - validateSuggestionsImpl(parsed) - } -}) - /** Check if there's an exact package match in results */ const hasExactPackageMatch = computed(() => { const q = query.value.trim().toLowerCase()