Skip to content
Merged
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
23 changes: 14 additions & 9 deletions app/components/Package/ListToolbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const props = defineProps<{
activeFilters: FilterChip[]
/** When true, shows search-specific UI (relevance sort, no filters) */
searchContext?: boolean
/** Sort keys to force-disable (e.g. when the current provider doesn't support them) */
disabledSortKeys?: SortKey[]
}>()

const { t } = useI18n()
Expand Down Expand Up @@ -58,17 +60,20 @@ const showingFiltered = computed(() => props.filteredCount !== props.totalCount)
const currentSort = computed(() => parseSortOption(sortOption.value))

// Get available sort keys based on context
const disabledSet = computed(() => new Set(props.disabledSortKeys ?? []))

const availableSortKeys = computed(() => {
const applyDisabled = (k: (typeof SORT_KEYS)[number]) => ({
...k,
disabled: k.disabled || disabledSet.value.has(k.key),
})

if (props.searchContext) {
// In search context: show relevance (enabled) and others (disabled)
return SORT_KEYS.filter(k => !k.searchOnly || k.key === 'relevance').map(k =>
Object.assign({}, k, {
disabled: k.key !== 'relevance',
}),
)
// In search context: show relevance + non-disabled sorts (downloads, updated, name)
return SORT_KEYS.filter(k => !k.searchOnly || k.key === 'relevance').map(applyDisabled)
}
// In org/user context: hide search-only sorts
return SORT_KEYS.filter(k => !k.searchOnly)
return SORT_KEYS.filter(k => !k.searchOnly).map(applyDisabled)
})

// Handle sort key change from dropdown
Expand Down Expand Up @@ -182,9 +187,9 @@ function getSortKeyLabelKey(key: SortKey): string {
</div>
</div>

<!-- Sort direction toggle (hidden in search context) -->
<!-- Sort direction toggle -->
<button
v-if="!searchContext"
v-if="!searchContext || currentSort.key !== 'relevance'"
type="button"
class="p-1.5 rounded border border-border bg-bg-subtle text-fg-muted hover:text-fg hover:border-border-hover transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-2 focus-visible:ring-offset-bg"
:aria-label="$t('filters.sort.toggle_direction')"
Comment on lines +190 to 195
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

Remove per-button focus-visible utilities; rely on the global focus styling.
Line 194 adds focus-visible utility classes to a button, which conflicts with the project’s global focus-visible rule for buttons and selects.

✅ Suggested change
-            class="p-1.5 rounded border border-border bg-bg-subtle text-fg-muted hover:text-fg hover:border-border-hover transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-2 focus-visible:ring-offset-bg"
+            class="p-1.5 rounded border border-border bg-bg-subtle text-fg-muted hover:text-fg hover:border-border-hover transition-colors duration-200"

Based on learnings: In the npmx.dev project, ensure that focus-visible styling for button and select elements is implemented globally in app/assets/main.css with the rule: button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }. Do not apply per-element inline focus-visible utility classes like focus-visible:outline-accent/70 on these elements.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<!-- Sort direction toggle -->
<button
v-if="!searchContext"
v-if="!searchContext || currentSort.key !== 'relevance'"
type="button"
class="p-1.5 rounded border border-border bg-bg-subtle text-fg-muted hover:text-fg hover:border-border-hover transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-2 focus-visible:ring-offset-bg"
:aria-label="$t('filters.sort.toggle_direction')"
<!-- Sort direction toggle -->
<button
v-if="!searchContext || currentSort.key !== 'relevance'"
type="button"
class="p-1.5 rounded border border-border bg-bg-subtle text-fg-muted hover:text-fg hover:border-border-hover transition-colors duration-200"
:aria-label="$t('filters.sort.toggle_direction')"

Expand Down
40 changes: 40 additions & 0 deletions app/composables/npm/useAlgoliaSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,10 +229,50 @@ export function useAlgoliaSearch() {
}
}

/**
* 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).
*/
async function getPackagesByName(packageNames: string[]): Promise<NpmSearchResponse> {
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`,
{
method: 'POST',
headers: {
'x-algolia-api-key': algolia.apiKey,
'x-algolia-application-id': algolia.appId,
},
body: {
requests: packageNames.map(name => ({
indexName,
objectID: name,
attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE,
})),
},
},
)

const hits = response.results.filter((r): r is AlgoliaHit => r !== null && 'name' in r)
return {
isStale: false,
objects: hits.map(hitToSearchResult),
total: hits.length,
time: new Date().toISOString(),
}
}

return {
/** Search packages by text query */
search,
/** Fetch all packages for an owner (org or user) */
searchByOwner,
/** Fetch metadata for specific packages by exact name */
getPackagesByName,
}
}
8 changes: 6 additions & 2 deletions app/composables/npm/useNpmSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,9 +307,13 @@ export function useNpmSearch(
)

// Re-search when provider changes
watch(searchProvider, () => {
watch(searchProvider, async () => {
cache.value = null
asyncData.refresh()
await asyncData.refresh()
const targetSize = toValue(options).size
if (targetSize) {
await fetchMore(targetSize)
}
})

// Computed data that uses cache
Expand Down
153 changes: 29 additions & 124 deletions app/composables/npm/useOrgPackages.ts
Original file line number Diff line number Diff line change
@@ -1,103 +1,27 @@
import type { NuxtApp } from '#app'
import type { NpmSearchResponse, NpmSearchResult, MinimalPackument } from '#shared/types'
import { emptySearchResponse, packumentToSearchResult } from './useNpmSearch'
import { mapWithConcurrency } from '#shared/utils/async'

/**
* Fetch downloads for multiple packages.
* Returns a map of package name -> weekly downloads.
* Uses bulk API for unscoped packages, parallel individual requests for scoped.
* Note: npm bulk downloads API does not support scoped packages.
*/
async function fetchBulkDownloads(
$npmApi: NuxtApp['$npmApi'],
packageNames: string[],
options: Parameters<typeof $fetch>[1] = {},
): Promise<Map<string, number>> {
const downloads = new Map<string, number>()
if (packageNames.length === 0) return downloads

// Separate scoped and unscoped packages
const scopedPackages = packageNames.filter(n => n.startsWith('@'))
const unscopedPackages = packageNames.filter(n => !n.startsWith('@'))

// Fetch unscoped packages via bulk API (max 128 per request)
const bulkPromises: Promise<void>[] = []
const chunkSize = 100
for (let i = 0; i < unscopedPackages.length; i += chunkSize) {
const chunk = unscopedPackages.slice(i, i + chunkSize)
bulkPromises.push(
(async () => {
try {
const response = await $npmApi<Record<string, { downloads: number } | null>>(
`/downloads/point/last-week/${chunk.join(',')}`,
options,
)
for (const [name, data] of Object.entries(response.data)) {
if (data?.downloads !== undefined) {
downloads.set(name, data.downloads)
}
}
} catch {
// Ignore errors - downloads are optional
}
})(),
)
}

// Fetch scoped packages in parallel batches (concurrency limit to avoid overwhelming the API)
// Use Promise.allSettled to not fail on individual errors
const scopedBatchSize = 20 // Concurrent requests per batch
for (let i = 0; i < scopedPackages.length; i += scopedBatchSize) {
const batch = scopedPackages.slice(i, i + scopedBatchSize)
bulkPromises.push(
(async () => {
const results = await Promise.allSettled(
batch.map(async name => {
const encoded = encodePackageName(name)
const { data } = await $npmApi<{ downloads: number }>(
`/downloads/point/last-week/${encoded}`,
)
return { name, downloads: data.downloads }
}),
)
for (const result of results) {
if (result.status === 'fulfilled' && result.value.downloads !== undefined) {
downloads.set(result.value.name, result.value.downloads)
}
}
})(),
)
}

// Wait for all fetches to complete
await Promise.all(bulkPromises)

return downloads
}

/**
* Fetch all packages for an npm organization.
*
* Always uses the npm registry's org endpoint as the source of truth for which
* packages belong to the org. When Algolia is enabled, uses it to quickly fetch
* metadata for those packages (instead of N+1 packument fetches).
* 1. Gets the authoritative package list from the npm registry (single request)
* 2. Fetches metadata from Algolia by exact name (single request)
* 3. Falls back to individual packument fetches when Algolia is unavailable
*/
export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
const { searchProvider } = useSearchProvider()
const { searchByOwner } = useAlgoliaSearch()
const { getPackagesByName } = useAlgoliaSearch()

const asyncData = useLazyAsyncData(
() => `org-packages:${searchProvider.value}:${toValue(orgName)}`,
async ({ $npmRegistry, $npmApi, ssrContext }, { signal }) => {
async ({ $npmRegistry, ssrContext }, { signal }) => {
const org = toValue(orgName)
if (!org) {
return emptySearchResponse
}

// Always get the authoritative package list from the npm registry.
// Algolia's owner.name filter doesn't precisely match npm org membership
// (e.g. it includes @nuxtjs/* packages for the @nuxt org).
// Get the authoritative package list from the npm registry (single request)
let packageNames: string[]
try {
const { packages } = await $fetch<{ packages: string[]; count: number }>(
Expand Down Expand Up @@ -126,60 +50,41 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
return emptySearchResponse
}

// --- Algolia fast path: use Algolia to get metadata for known packages ---
// Fetch metadata + downloads from Algolia (single request via getObjects)
if (searchProvider.value === 'algolia') {
try {
const response = await searchByOwner(org)
const response = await getPackagesByName(packageNames)
if (response.objects.length > 0) {
// Filter Algolia results to only include packages that are
// actually in the org (per the npm registry's authoritative list)
const orgPackageSet = new Set(packageNames.map(n => n.toLowerCase()))
const filtered = response.objects.filter(obj =>
orgPackageSet.has(obj.package.name.toLowerCase()),
)

if (filtered.length > 0) {
return {
...response,
objects: filtered,
total: filtered.length,
}
}
return response
}
} catch {
// Fall through to npm registry path
}
}

// --- npm registry path: fetch packuments individually ---
const [packuments, downloads] = await Promise.all([
(async () => {
const results = await mapWithConcurrency(
packageNames,
async name => {
try {
const encoded = encodePackageName(name)
const { data: pkg } = await $npmRegistry<MinimalPackument>(`/${encoded}`, {
signal,
})
return pkg
} catch {
return null
}
},
10,
)
return results.filter(
(pkg): pkg is MinimalPackument => pkg !== null && !!pkg['dist-tags'],
)
})(),
fetchBulkDownloads($npmApi, packageNames, { signal }),
])
// npm fallback: fetch packuments individually
const packuments = await mapWithConcurrency(
packageNames,
async name => {
try {
const encoded = encodePackageName(name)
const { data: pkg } = await $npmRegistry<MinimalPackument>(`/${encoded}`, {
signal,
})
return pkg
} catch {
return null
}
},
10,
)

const results: NpmSearchResult[] = packuments.map(pkg =>
packumentToSearchResult(pkg, downloads.get(pkg.name)),
const validPackuments = packuments.filter(
(pkg): pkg is MinimalPackument => pkg !== null && !!pkg['dist-tags'],
)

const results: NpmSearchResult[] = validPackuments.map(pkg => packumentToSearchResult(pkg))

return {
isStale: false,
objects: results,
Expand Down
Loading
Loading