diff --git a/app/components/PackageCard.vue b/app/components/PackageCard.vue index 3430576f95..65de7836d7 100644 --- a/app/components/PackageCard.vue +++ b/app/components/PackageCard.vue @@ -1,5 +1,5 @@ + + diff --git a/app/pages/search.vue b/app/pages/search.vue index 42c0ed70c8..7b99836239 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -52,62 +52,6 @@ const packageListRef = useTemplateRef('packageListRef') const resultCount = computed(() => visibleResults.value?.objects.length ?? 0) -function clampIndex(next: number) { - if (resultCount.value <= 0) return 0 - return Math.max(0, Math.min(resultCount.value - 1, next)) -} - -function scrollToSelectedResult() { - // Use virtualizer's scrollToIndex to ensure the item is rendered and visible - packageListRef.value?.scrollToIndex(selectedIndex.value) -} - -function focusSelectedResult() { - // First ensure the item is rendered by scrolling to it - scrollToSelectedResult() - // Then focus it after a tick to allow rendering - nextTick(() => { - const el = document.querySelector(`[data-result-index="${selectedIndex.value}"]`) - el?.focus() - }) -} - -function handleResultsKeydown(e: KeyboardEvent) { - if (resultCount.value <= 0) return - - const isFromInput = (e.target as HTMLElement).tagName === 'INPUT' - - if (e.key === 'ArrowDown') { - e.preventDefault() - selectedIndex.value = clampIndex(selectedIndex.value + 1) - // Only move focus if already in results, not when typing in search input - if (isFromInput) { - scrollToSelectedResult() - } else { - focusSelectedResult() - } - return - } - - if (e.key === 'ArrowUp') { - e.preventDefault() - selectedIndex.value = clampIndex(selectedIndex.value - 1) - if (isFromInput) { - scrollToSelectedResult() - } else { - focusSelectedResult() - } - return - } - - if (e.key === 'Enter') { - const el = document.querySelector(`[data-result-index="${selectedIndex.value}"]`) - if (!el) return - e.preventDefault() - el.click() - } -} - // Track if page just loaded (for hiding "Searching..." during view transition) const hasInteracted = ref(false) onMounted(() => { @@ -165,13 +109,40 @@ const isQueryContinuation = computed(() => { }) // Show cached results while loading if it's a continuation query -const visibleResults = computed(() => { +const rawVisibleResults = computed(() => { if (status.value === 'pending' && isQueryContinuation.value && cachedResults.value) { return cachedResults.value } return results.value }) +/** + * Reorder results to put exact package name match at the top + */ +const visibleResults = computed(() => { + const raw = rawVisibleResults.value + if (!raw) return raw + + const q = query.value.trim().toLowerCase() + if (!q) return raw + + // Find exact match index + const exactIdx = raw.objects.findIndex(r => r.package.name.toLowerCase() === q) + if (exactIdx <= 0) return raw // Already at top or not found + + // Move exact match to top + const reordered = [...raw.objects] + const [exactMatch] = reordered.splice(exactIdx, 1) + if (exactMatch) { + reordered.unshift(exactMatch) + } + + return { + ...raw, + objects: reordered, + } +}) + // Should we show the loading spinner? const showSearching = computed(() => { // Don't show during initial page load (view transition) @@ -209,11 +180,6 @@ function handlePageChange(page: number) { updateUrlPage(page) } -function handleSelect(index: number) { - if (index < 0) return - selectedIndex.value = clampIndex(index) -} - // Reset pages when query changes watch(query, () => { loadedPages.value = 1 @@ -326,6 +292,386 @@ const showClaimPrompt = computed(() => { // Modal state for claiming a package const claimModalOpen = ref(false) +/** + * 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 /^[a-z0-9_-]+$/i.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>({}) + +const NPM_REGISTRY = 'https://registry.npmjs.org' + +interface NpmSearchResponse { + total: number + objects: Array<{ package: { name: string } }> +} + +/** + * Check if an org exists by searching for packages with @orgname scope + * Uses the search API which has CORS enabled + */ +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 { + // Search for packages in the @org scope + const response = await $fetch(`${NPM_REGISTRY}/-/v1/search`, { + query: { text: `@${name}`, size: 5 }, + }) + // Verify at least one result actually starts with @orgname/ + const scopePrefix = `@${name.toLowerCase()}/` + 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 + * Uses the search API which has CORS enabled + */ +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 = ref(false) + +/** Debounced function to validate suggestions */ +const validateSuggestions = debounce(async (parsed: ParsedQuery) => { + if (!parsed.type || !parsed.name) { + validatedSuggestions.value = [] + return + } + + suggestionsLoading.value = true + const suggestions: ValidatedSuggestion[] = [] + + try { + if (parsed.type === 'user') { + const exists = await checkUserExists(parsed.name) + if (exists) { + suggestions.push({ type: 'user', name: parsed.name, exists: true }) + } + } else if (parsed.type === 'org') { + const exists = await checkOrgExists(parsed.name) + 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), + ]) + // 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 { + suggestionsLoading.value = false + } + + validatedSuggestions.value = suggestions +}, 200) + +// Validate suggestions when query changes +watch( + parsedQuery, + parsed => { + validateSuggestions(parsed) + }, + { immediate: true }, +) + +/** Check if there's an exact package match in results */ +const hasExactPackageMatch = computed(() => { + const q = query.value.trim().toLowerCase() + if (!q || !visibleResults.value) return false + return visibleResults.value.objects.some(r => r.package.name.toLowerCase() === q) +}) + +/** Check if query is an exact org match (e.g., @nuxt matches org nuxt) */ +const isExactOrgQuery = computed(() => { + const q = query.value.trim() + if (!q.startsWith('@') || q.includes('/')) return false + const orgName = q.slice(1).toLowerCase() + return validatedSuggestions.value.some( + s => s.type === 'org' && s.name.toLowerCase() === orgName && s.exists, + ) +}) + +/** Determine which item should be highlighted as exact match */ +const exactMatchType = computed<'package' | 'org' | 'user' | null>(() => { + // Package match takes priority + if (hasExactPackageMatch.value) return 'package' + // Then org match for @org queries + if (isExactOrgQuery.value) return 'org' + // Could extend to user matches for ~user queries + const q = query.value.trim() + if (q.startsWith('~')) { + const userName = q.slice(1).toLowerCase() + if ( + validatedSuggestions.value.some( + s => s.type === 'user' && s.name.toLowerCase() === userName && s.exists, + ) + ) { + return 'user' + } + } + return null +}) + +/** + * Selection uses negative indices for suggestions, positive for packages + * -2 = first suggestion, -1 = second suggestion, 0+ = package indices + */ +const suggestionCount = computed(() => validatedSuggestions.value.length) +const totalSelectableCount = computed(() => suggestionCount.value + resultCount.value) + +/** Unified selected index: negative for suggestions, 0+ for packages */ +const unifiedSelectedIndex = ref(0) + +/** Convert unified index to suggestion index (0-based) or null */ +function toSuggestionIndex(unified: number): number | null { + if (unified < 0 && unified >= -suggestionCount.value) { + return suggestionCount.value + unified + } + return null +} + +/** Convert unified index to package index or null */ +function toPackageIndex(unified: number): number | null { + if (unified >= 0 && unified < resultCount.value) { + return unified + } + return null +} + +/** Clamp unified index to valid range */ +function clampUnifiedIndex(next: number): number { + const min = -suggestionCount.value + const max = Math.max(0, resultCount.value - 1) + if (totalSelectableCount.value <= 0) return 0 + return Math.max(min, Math.min(max, next)) +} + +// Keep legacy selectedIndex in sync for PackageList +watch(unifiedSelectedIndex, unified => { + const pkgIndex = toPackageIndex(unified) + selectedIndex.value = pkgIndex ?? -1 +}) + +// Initialize selection to exact match when results load +watch( + [visibleResults, validatedSuggestions, exactMatchType], + () => { + if (exactMatchType.value === 'package') { + // Find the exact match package index + const q = query.value.trim().toLowerCase() + const idx = + visibleResults.value?.objects.findIndex(r => r.package.name.toLowerCase() === q) ?? -1 + if (idx >= 0) { + unifiedSelectedIndex.value = idx + return + } + } + if (exactMatchType.value === 'org') { + // Select the org suggestion + const orgIdx = validatedSuggestions.value.findIndex(s => s.type === 'org') + if (orgIdx >= 0) { + unifiedSelectedIndex.value = -(suggestionCount.value - orgIdx) + return + } + } + if (exactMatchType.value === 'user') { + // Select the user suggestion + const userIdx = validatedSuggestions.value.findIndex(s => s.type === 'user') + if (userIdx >= 0) { + unifiedSelectedIndex.value = -(suggestionCount.value - userIdx) + return + } + } + // Default to first item (first suggestion if any, else first package) + unifiedSelectedIndex.value = suggestionCount.value > 0 ? -suggestionCount.value : 0 + }, + { immediate: true }, +) + +// Reset selection when query changes +watch(query, () => { + // Will be re-initialized by the watch above when results load + unifiedSelectedIndex.value = 0 +}) + +function scrollToSelectedItem() { + const pkgIndex = toPackageIndex(unifiedSelectedIndex.value) + if (pkgIndex !== null) { + packageListRef.value?.scrollToIndex(pkgIndex) + } +} + +function focusSelectedItem() { + const suggIdx = toSuggestionIndex(unifiedSelectedIndex.value) + const pkgIdx = toPackageIndex(unifiedSelectedIndex.value) + + nextTick(() => { + if (suggIdx !== null) { + const el = document.querySelector(`[data-suggestion-index="${suggIdx}"]`) + el?.focus() + } else if (pkgIdx !== null) { + scrollToSelectedItem() + nextTick(() => { + const el = document.querySelector(`[data-result-index="${pkgIdx}"]`) + el?.focus() + }) + } + }) +} + +function handleResultsKeydown(e: KeyboardEvent) { + if (totalSelectableCount.value <= 0) return + + const isFromInput = (e.target as HTMLElement).tagName === 'INPUT' + + if (e.key === 'ArrowDown') { + e.preventDefault() + unifiedSelectedIndex.value = clampUnifiedIndex(unifiedSelectedIndex.value + 1) + if (isFromInput) { + scrollToSelectedItem() + } else { + focusSelectedItem() + } + return + } + + if (e.key === 'ArrowUp') { + e.preventDefault() + unifiedSelectedIndex.value = clampUnifiedIndex(unifiedSelectedIndex.value - 1) + if (isFromInput) { + scrollToSelectedItem() + } else { + focusSelectedItem() + } + return + } + + if (e.key === 'Enter') { + const suggIdx = toSuggestionIndex(unifiedSelectedIndex.value) + const pkgIdx = toPackageIndex(unifiedSelectedIndex.value) + + if (suggIdx !== null) { + const el = document.querySelector(`[data-suggestion-index="${suggIdx}"]`) + if (el) { + e.preventDefault() + el.click() + } + } else if (pkgIdx !== null) { + const el = document.querySelector(`[data-result-index="${pkgIdx}"]`) + if (el) { + e.preventDefault() + el.click() + } + } + } +} + +function handleSuggestionSelect(index: number) { + // Convert suggestion index to unified index + unifiedSelectedIndex.value = -(suggestionCount.value - index) +} + +function handlePackageSelect(index: number) { + if (index < 0) return + unifiedSelectedIndex.value = index +} + useSeoMeta({ title: () => (query.value ? `Search: ${query.value} - npmx` : 'Search Packages - npmx'), }) @@ -401,6 +747,23 @@ defineOgImageComponent('Default', {
+ +
+ +
+
-
-

+

+

{{ $t('search.no_results', { query }) }}

+ +
+ +
+ -
+

{{ $t('search.want_to_claim') }}

diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 6ded643f85..933122d20c 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -26,7 +26,14 @@ "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", + "exact_match": "exact", + "suggestion": { + "user": "user", + "org": "org", + "view_user_packages": "View packages by this user", + "view_org_packages": "View packages by this organization" + } }, "nav": { "popular_packages": "Popular packages",