Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ec549f9
fix: apply limits to algolia and npm search output, fix visual issues
alex-key Mar 8, 2026
739a3a7
Merge branch 'main' of https://github.com/alex-key/npmx.dev into fix/…
alex-key Mar 8, 2026
d6acbe4
Merge branch 'main' of https://github.com/alex-key/npmx.dev into fix/…
alex-key Mar 10, 2026
4c453dd
fix: remove log, add typesafe condition in search
alex-key Mar 10, 2026
452bd47
Merge branch 'main' of https://github.com/alex-key/npmx.dev into fix/…
alex-key Mar 12, 2026
62b7f6e
fix: max available results hint fixed og search page
alex-key Mar 12, 2026
6407929
fix: improve results handling for algolia search, add types
alex-key Mar 13, 2026
f3e2fe6
Merge branch 'main' of https://github.com/alex-key/npmx.dev into fix/…
alex-key Mar 13, 2026
65b6d30
fix: add missing expression in search, fix empty results output
alex-key Mar 13, 2026
b336e9b
Merge branch 'main' into fix/search-results-limit
alex-key Mar 13, 2026
543e481
Merge branch 'main' into fix/search-results-limit
alex-key Mar 13, 2026
0d42e69
Merge branch 'main' into fix/search-results-limit
alex-key Mar 14, 2026
baeb512
Merge branch 'main' into fix/search-results-limit
alex-key Mar 17, 2026
60b8365
Merge branch 'main' of https://github.com/alex-key/npmx.dev into fix/…
alex-key Mar 19, 2026
21be9db
fix: resolve type conflicts in useSettings
alex-key Mar 19, 2026
50adc52
fix: align some classes in PaginationControls
alex-key Mar 19, 2026
88692bb
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 19, 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
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 thread
coderabbitai[bot] marked this conversation as resolved.

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 thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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> = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: doesn't the type annotation make the as const redundant?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, good catch, will fix when the rest is reviewed

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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 }) }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</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