Skip to content
Open
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
1 change: 0 additions & 1 deletion app/components/Package/Card.vue
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,6 @@ const numberFormatter = useNumberFormatter()
</div>

<ul
role="list"
v-if="result.package.keywords?.length"
:aria-label="$t('package.card.keywords')"
class="relative z-10 flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-border list-none m-0 p-0 pointer-events-none items-center"
Expand Down
14 changes: 11 additions & 3 deletions app/components/PaginationControls.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import type { PageSize, PaginationMode, ViewMode } from '#shared/types/preferences'
import { PAGE_SIZE_OPTIONS } from '#shared/types/preferences'

const ALL_PAGES_VISIBLE_TRESHOLD = 7

const props = defineProps<{
totalItems: number
/** When in table view, force pagination mode (no infinite scroll for tables) */
Expand Down Expand Up @@ -63,7 +65,7 @@ const visiblePages = computed(() => {
const current = currentPage.value
const pages: (number | 'ellipsis')[] = []

if (total <= 7) {
if (total <= ALL_PAGES_VISIBLE_TRESHOLD) {
// Show all pages
for (let i = 1; i <= total; i++) {
pages.push(i)
Expand Down Expand Up @@ -97,6 +99,11 @@ const visiblePages = computed(() => {
return pages
})

// disable last page button to prevent TOO MANY REQUESTS error
function isPageButtonDisabled(page: number): boolean {
return totalPages.value > ALL_PAGES_VISIBLE_TRESHOLD && page > currentPage.value + 2
}
Comment on lines +102 to +105
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Expose the new disabled state visually on numbered page buttons.

The guard works, but the disabled page button still looks like a normal clickable button. That makes the new last-page restriction feel broken rather than intentional.

🎯 Proposed fix
-            class="min-w-[32px] h-8 px-2 font-mono text-sm rounded transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1"
+            class="min-w-[32px] h-8 px-2 font-mono text-sm rounded transition-colors duration-200 disabled:opacity-40 disabled:cursor-not-allowed focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1"

Also applies to: 207-208


function handlePageSizeChange(event: Event) {
const target = event.target as HTMLSelectElement
const value = target.value
Expand Down Expand Up @@ -167,8 +174,8 @@ function handlePageSizeChange(event: Event) {
<span class="text-sm font-mono text-fg-muted">
{{
$t('filters.pagination.showing', {
start: startItem,
end: endItem,
start: $n(startItem),
end: $n(endItem),
total: $n(totalItems),
})
}}
Expand Down Expand Up @@ -197,6 +204,7 @@ function handlePageSizeChange(event: Event) {
<button
v-else
type="button"
:disabled="isPageButtonDisabled(page)"
class="min-w-[32px] h-8 px-2 font-mono text-sm rounded transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1"
:class="
page === currentPage
Expand Down
1 change: 1 addition & 0 deletions app/composables/npm/search-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function emptySearchResponse(): NpmSearchResponse {
return {
objects: [],
total: 0,
totalUnlimited: 0,
isStale: false,
time: new Date().toISOString(),
}
Expand Down
6 changes: 4 additions & 2 deletions app/composables/npm/useAlgoliaSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,8 @@ export function useAlgoliaSearch() {
return {
isStale: false,
objects: response.hits.map(hitToSearchResult),
total: response.nbHits ?? 0,
totalUnlimited: response.nbHits ?? 0,
total: Math.min(SEARCH_ENGINE_HITS_LIMIT.algolia, response.nbHits ?? 0),
Comment on lines +168 to +169
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Keep the Algolia cap contract consistent across helpers.

search() now returns a capped total plus totalUnlimited, but searchByOwner() in this file still returns the raw serverTotal as total. For owners with more than 1,000 packages, that will show a larger count than the list can ever contain, with no way to reach the missing results.

Suggested follow-up
-    const max = options.maxResults ?? 1000
+    const max = options.maxResults ?? SEARCH_ENGINE_HITS_LIMIT.algolia
…
     return {
       isStale: false,
       objects: allHits.map(hitToSearchResult),
-      total: serverTotal,
+      total: Math.min(max, serverTotal),
+      totalUnlimited: serverTotal,
       time: new Date().toISOString(),
     }

time: new Date().toISOString(),
}
}
Expand Down Expand Up @@ -326,7 +327,8 @@ export function useAlgoliaSearch() {
const searchResult: NpmSearchResponse = {
isStale: false,
objects: mainResponse.hits.map(hitToSearchResult),
total: mainResponse.nbHits ?? 0,
total: Math.min(SEARCH_ENGINE_HITS_LIMIT.algolia, mainResponse.nbHits ?? 0),
totalUnlimited: mainResponse.nbHits ?? 0,
time: new Date().toISOString(),
}

Expand Down
81 changes: 44 additions & 37 deletions app/composables/npm/useSearch.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import type { NpmSearchResponse, NpmSearchResult } from '#shared/types'
import type { SearchProvider } from '~/composables/useSettings'
import type { NpmSearchResponse, NpmSearchResult, SearchProvider } from '#shared/types'
import type { AlgoliaMultiSearchChecks } from './useAlgoliaSearch'
import { type SearchSuggestion, emptySearchResponse, parseSuggestionIntent } from './search-utils'
import { isValidNewPackageName, checkPackageExists } from '~/utils/package-name'

export const SEARCH_ENGINE_HITS_LIMIT: Record<SearchProvider, number> = {
algolia: 1000,
npm: 5000,
} as const

function emptySearchPayload() {
return {
searchResponse: emptySearchResponse(),
Expand All @@ -25,6 +29,14 @@ export interface UseSearchConfig {
suggestions?: boolean
}

interface SearchResponseCache {
query: string
provider: SearchProvider
objects: NpmSearchResult[]
totalUnlimited: number
total: number
}

export function useSearch(
query: MaybeRefOrGetter<string>,
searchProvider: MaybeRefOrGetter<SearchProvider>,
Expand All @@ -38,12 +50,7 @@ export function useSearch(
checkUserExists: checkUserNpm,
} = useNpmSearch()

const cache = shallowRef<{
query: string
provider: SearchProvider
objects: NpmSearchResult[]
total: number
} | null>(null)
const cache = shallowRef<SearchResponseCache | null>(null)

const isLoadingMore = shallowRef(false)
const isRateLimited = shallowRef(false)
Expand All @@ -54,6 +61,23 @@ export function useSearch(
const existenceCache = shallowRef<Record<string, boolean>>({})
const suggestionRequestId = shallowRef(0)

function setCache(objects: NpmSearchResult[] | null, total: number = 0): void {
if (objects === null) {
cache.value = null
return
}

const provider = toValue(searchProvider)

cache.value = {
query: toValue(query),
provider,
objects,
totalUnlimited: total,
total: Math.min(total, SEARCH_ENGINE_HITS_LIMIT[provider]),
}
}

/**
* Determine which extra checks to include in the Algolia multi-search.
* Returns `undefined` when nothing uncached needs checking.
Expand Down Expand Up @@ -154,7 +178,7 @@ export function useSearch(
}

const opts = toValue(options)
cache.value = null
setCache(null)

if (provider === 'algolia') {
const checks = config.suggestions ? buildAlgoliaChecks(q) : undefined
Expand Down Expand Up @@ -197,12 +221,7 @@ export function useSearch(
return emptySearchPayload()
}

cache.value = {
query: q,
provider,
objects: response.objects,
total: response.total,
}
setCache(response.objects, response.total)

isRateLimited.value = false
return {
Expand Down Expand Up @@ -230,25 +249,20 @@ export function useSearch(
const provider = toValue(searchProvider)

if (!q) {
cache.value = null
setCache(null)
return
}

if (cache.value && (cache.value.query !== q || cache.value.provider !== provider)) {
cache.value = null
setCache(null)
await asyncData.refresh()
return
}

// Seed cache from asyncData for Algolia (which skips cache on initial fetch)
if (!cache.value && asyncData.data.value) {
const { searchResponse } = asyncData.data.value
cache.value = {
query: q,
provider,
objects: [...searchResponse.objects],
total: searchResponse.total,
}
setCache([...searchResponse.objects], searchResponse.total)
}

const currentCount = cache.value?.objects.length ?? 0
Expand All @@ -270,25 +284,17 @@ export function useSearch(
if (cache.value && cache.value.query === q && cache.value.provider === provider) {
const existingNames = new Set(cache.value.objects.map(obj => obj.package.name))
const newObjects = response.objects.filter(obj => !existingNames.has(obj.package.name))
cache.value = {
query: q,
provider,
objects: [...cache.value.objects, ...newObjects],
total: response.total,
}

setCache([...cache.value.objects, ...newObjects], response.total)
} else {
cache.value = {
query: q,
provider,
objects: response.objects,
total: response.total,
}
setCache(response.objects, response.total)
}

if (
cache.value &&
cache.value.objects.length < targetSize &&
cache.value.objects.length < cache.value.total
cache.value.objects.length < cache.value.total &&
cache.value.objects.length < SEARCH_ENGINE_HITS_LIMIT[provider] // additional protection from infinite loop
) {
await fetchMore(targetSize)
}
Expand All @@ -310,7 +316,7 @@ export function useSearch(
watch(
() => toValue(searchProvider),
async () => {
cache.value = null
setCache(null)
existenceCache.value = {}
await asyncData.refresh()
const targetSize = toValue(options).size
Expand All @@ -326,6 +332,7 @@ export function useSearch(
isStale: false,
objects: cache.value.objects,
total: cache.value.total,
totalUnlimited: cache.value.totalUnlimited,
time: new Date().toISOString(),
}
}
Expand Down
4 changes: 2 additions & 2 deletions app/composables/npm/useUserPackages.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { NpmSearchResponse, NpmSearchResult } from '#shared/types'
import type { NpmSearchResponse, NpmSearchResult, SearchProvider } from '#shared/types'
import { emptySearchResponse } from './search-utils'

/** Default page size for incremental loading (npm registry path) */
Expand Down Expand Up @@ -38,7 +38,7 @@ export function useUserPackages(username: MaybeRefOrGetter<string>) {

/** Tracks which provider actually served the current data (may differ from
* searchProvider when Algolia returns empty and we fall through to npm) */
const activeProvider = shallowRef<'npm' | 'algolia'>(searchProviderValue.value)
const activeProvider = shallowRef<SearchProvider>(searchProviderValue.value)

const cache = shallowRef<{
username: string
Expand Down
2 changes: 1 addition & 1 deletion app/composables/useGlobalSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
)

// Updates URL when search query changes (immediately for instantSearch or after Enter hit otherwise)
const updateUrlQueryImpl = (value: string, provider: 'npm' | 'algolia') => {
const updateUrlQueryImpl = (value: string, provider: SearchProvider) => {
const isSameQuery = route.query.q === value && route.query.p === provider
// Don't navigate away from pages that use ?q for local filtering
if ((pagesWithLocalFilter.has(route.name as string) && place === 'content') || isSameQuery) {
Expand Down
4 changes: 1 addition & 3 deletions app/composables/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,13 @@ import type { RemovableRef } from '@vueuse/core'
import { useLocalStorage } from '@vueuse/core'
import { ACCENT_COLORS } from '#shared/utils/constants'
import type { LocaleObject } from '@nuxtjs/i18n'
import type { SearchProvider } from '#shared/types'
import { BACKGROUND_THEMES } from '#shared/utils/constants'

type BackgroundThemeId = keyof typeof BACKGROUND_THEMES

type AccentColorId = keyof typeof ACCENT_COLORS.light

/** Available search providers */
export type SearchProvider = 'npm' | 'algolia'

/**
* Application settings stored in localStorage
*/
Expand Down
23 changes: 22 additions & 1 deletion app/pages/search.vue
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,20 @@ const effectiveTotal = computed(() => {
return displayResults.value.length
})

const resultsLimitAppliedText = computed<string>(() => {
const totalUnlimited = visibleResults.value?.totalUnlimited ?? 0

if (isRelevanceSort.value && effectiveTotal.value < totalUnlimited) {
const total = { total: $n(totalUnlimited) }

return searchProvider.value === 'npm'
? $t('search.more_results_available_npm', total)
: $t('search.more_results_available_algolia', total)
}
// do not show hint if results limit is not reached
return ''
})

// Handle filter chip removal
function handleClearFilter(chip: FilterChip) {
clearFilter(chip)
Expand Down Expand Up @@ -784,12 +798,19 @@ onBeforeUnmount(() => {
effectiveTotal,
)
}}
<TooltipApp
v-if="resultsLimitAppliedText"
position="top"
:text="resultsLimitAppliedText"
>
<span class="i-lucide:info w-3 h-3 text-fg-subtle cursor-help" aria-hidden="true" />
</TooltipApp>
</p>
</div>

<div v-else-if="status === 'success' || status === 'error'" class="py-12">
<p class="text-fg-muted font-mono mb-6 text-center">
{{ $t('search.no_results', { query }) }}
{{ $t('search.no_results', { query: committedQuery }) }}
</p>

<div v-if="validatedSuggestions.length > 0" class="max-w-md mx-auto mb-6 space-y-3">
Expand Down
4 changes: 3 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@
"instant_search_off": "off",
"instant_search_turn_on": "turn on",
"instant_search_turn_off": "turn off",
"instant_search_advisory": "{label} {state} — {action}"
"instant_search_advisory": "{label} {state} — {action}",
"more_results_available_npm": "There are {total} results for this search, but npm registry limits search output. Refine your query to narrow results.",
"more_results_available_algolia": "There are {total} results for this search. Algolia search is capped at 1,000 — switch to npm Registry for up to 5,000 results, or refine your query."
},
"nav": {
"main_navigation": "Main",
Expand Down
6 changes: 6 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,12 @@
},
"instant_search_advisory": {
"type": "string"
},
"more_results_available_npm": {
"type": "string"
},
"more_results_available_algolia": {
"type": "string"
}
},
"additionalProperties": false
Expand Down
3 changes: 3 additions & 0 deletions shared/types/npm-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ export interface PackageVersionInfo {
deprecated?: string
}

export type SearchProvider = 'npm' | 'algolia'

/**
* Person/contact type extracted from @npm/types Contact interface
* Used for maintainers, authors, publishers
Expand All @@ -122,6 +124,7 @@ export interface NpmPerson {
export interface NpmSearchResponse {
isStale: boolean
objects: NpmSearchResult[]
totalUnlimited?: number
total: number
time: string
}
Expand Down
4 changes: 3 additions & 1 deletion shared/types/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* Used for configurable columns, filtering, sorting, and pagination
*/

import type { SearchProvider } from './npm-registry'

// View modes
export type ViewMode = 'cards' | 'table'

Expand Down Expand Up @@ -163,7 +165,7 @@ export const SORT_KEYS: SortKeyConfig[] = [
* - npm returns 1 for all detail scores, and score.final === searchScore (= relevance)
* - Algolia returns synthetic values (quality: 0|1, maintenance: 0, score: 0)
*/
export const PROVIDER_SORT_KEYS: Record<'algolia' | 'npm', Set<SortKey>> = {
export const PROVIDER_SORT_KEYS: Record<SearchProvider, Set<SortKey>> = {
algolia: new Set<SortKey>(['relevance', 'downloads-week', 'updated', 'name']),
npm: new Set<SortKey>(['relevance', 'downloads-week', 'updated', 'name']),
}
Expand Down
Loading