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
3 changes: 3 additions & 0 deletions app/composables/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@ export interface AppSettings {
includeTypesInInstall: boolean
/** Accent color theme */
accentColorId: AccentColorId | null
/** Hide platform-specific packages (e.g., @scope/pkg-linux-x64) from search results */
hidePlatformPackages: boolean
}

const DEFAULT_SETTINGS: AppSettings = {
relativeDates: false,
includeTypesInInstall: true,
accentColorId: null,
hidePlatformPackages: true,
}

const STORAGE_KEY = 'npmx-settings'
Expand Down
26 changes: 21 additions & 5 deletions app/pages/search.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { formatNumber } from '#imports'
import { debounce } from 'perfect-debounce'
import { isValidNewPackageName, checkPackageExists } from '~/utils/package-name'
import { isPlatformSpecificPackage } from '~/utils/platform-packages'

const route = useRoute()
const router = useRouter()
Expand Down Expand Up @@ -119,22 +120,37 @@ const rawVisibleResults = computed(() => {
return results.value
})

// Settings for platform package filtering
const { settings } = useSettings()

/**
* Reorder results to put exact package name match at the top
* Reorder results to put exact package name match at the top,
* and optionally filter out platform-specific packages.
*/
const visibleResults = computed(() => {
const raw = rawVisibleResults.value
if (!raw) return raw

let objects = raw.objects

// Filter out platform-specific packages if setting is enabled
if (settings.value.hidePlatformPackages) {
objects = objects.filter(r => !isPlatformSpecificPackage(r.package.name))
}

const q = query.value.trim().toLowerCase()
if (!q) return raw
if (!q) {
return objects === raw.objects ? raw : { ...raw, objects }
}

// 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
const exactIdx = objects.findIndex(r => r.package.name.toLowerCase() === q)
if (exactIdx <= 0) {
return objects === raw.objects ? raw : { ...raw, objects }
}

// Move exact match to top
const reordered = [...raw.objects]
const reordered = [...objects]
const [exactMatch] = reordered.splice(exactIdx, 1)
if (exactMatch) {
reordered.unshift(exactMatch)
Expand Down
37 changes: 37 additions & 0 deletions app/pages/settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,43 @@ defineOgImageComponent('Default', {
{{ $t('settings.include_types_description') }}
</p>
</div>

<!-- Divider -->
<div class="border-t border-border" />

<!-- Hide platform-specific packages toggle -->
<div class="space-y-2">
<button
type="button"
class="w-full flex items-center justify-between gap-4 group"
role="switch"
:aria-checked="settings.hidePlatformPackages"
@click="settings.hidePlatformPackages = !settings.hidePlatformPackages"
>
<span class="text-sm text-fg font-medium text-left">
{{ $t('settings.hide_platform_packages') }}
</span>
<span
class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out motion-reduce:transition-none shadow-sm cursor-pointer"
:class="
settings.hidePlatformPackages ? 'bg-accent' : 'bg-bg border border-border'
"
aria-hidden="true"
>
<span
class="pointer-events-none inline-block h-5 w-5 rounded-full shadow-sm ring-0 transition-transform duration-200 ease-in-out motion-reduce:transition-none"
:class="
settings.hidePlatformPackages
? 'translate-x-5 bg-bg'
: 'translate-x-0 bg-fg-muted'
"
/>
</span>
</button>
<p class="text-sm text-fg-muted">
{{ $t('settings.hide_platform_packages_description') }}
</p>
</div>
</div>
</section>

Expand Down
75 changes: 75 additions & 0 deletions app/utils/platform-packages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Detects if a package name is a platform-specific native binary package.
* These are typically optional dependencies that contain native binaries
* for specific OS/architecture combinations (e.g., @oxlint/win32-x64, esbuild-darwin-arm64).
* Sourced from searches for esbuild, and the napi-rs build triplets support matrix.
*/

const PLATFORMS = new Set([
'win32',
'darwin',
'linux',
'android',
'freebsd',
'openbsd',
'netbsd',
'sunos',
'aix',
])

const ARCHITECTURES = new Set([
'x64',
'arm64',
'arm',
'ia32',
'ppc64',
'ppc64le',
's390x',
'riscv64',
'mips64el',
'loong64',
])

const ABI_SUFFIXES = new Set(['gnu', 'musl', 'msvc', 'gnueabihf'])

/**
* Checks if a package name is a platform-specific native binary package.
* Matches patterns like:
* - @scope/pkg-win32-x64
* - @scope/pkg-linux-arm64-gnu
* - pkg-darwin-arm64
* - @rollup/rollup-linux-x64-musl
*
* @param name - The full package name (including scope if present)
* @returns true if the package appears to be a platform-specific binary
*/
export function isPlatformSpecificPackage(name: string): boolean {
const unscopedName = name.startsWith('@') ? (name.split('/')[1] ?? '') : name
if (!unscopedName) return false

const parts = unscopedName.split('-')
if (parts.length < 2) return false

// Look for OS-arch pattern anywhere in the name as suffix parts
// e.g., "pkg-linux-x64-gnu" -> ["pkg", "linux", "x64", "gnu"]
for (let i = 0; i < parts.length - 1; i++) {
const os = parts[i]
const arch = parts[i + 1]

if (os && arch && PLATFORMS.has(os) && ARCHITECTURES.has(arch)) {
// Optional ABI suffix check (next part if exists)
const abiSuffix = parts[i + 2]
if (abiSuffix && !ABI_SUFFIXES.has(abiSuffix)) {
// NOTE: Has an extra part after arch but it's not a known ABI - might be a false positive??
// but still consider it a match if OS+arch pattern is found at the end
if (i + 2 === parts.length - 1) {
// Extra unknown suffix at the end - be conservative
continue
}
}
return true
}
}

return false
}
2 changes: 2 additions & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@
"relative_dates_description": "Show \"3 days ago\" instead of full dates",
"include_types": "Include {'@'}types in install",
"include_types_description": "Add {'@'}types package to install commands for untyped packages",
"hide_platform_packages": "Hide platform-specific packages in search",
"hide_platform_packages_description": "Hide native binary packages like {'@'}esbuild/linux-x64 from results",
"theme": "Theme",
"theme_light": "Light",
"theme_dark": "Dark",
Expand Down
2 changes: 2 additions & 0 deletions lunaria/files/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@
"relative_dates_description": "Show \"3 days ago\" instead of full dates",
"include_types": "Include {'@'}types in install",
"include_types_description": "Add {'@'}types package to install commands for untyped packages",
"hide_platform_packages": "Hide platform-specific packages in search",
"hide_platform_packages_description": "Hide native binary packages like {'@'}esbuild/linux-x64 from results",
"theme": "Theme",
"theme_light": "Light",
"theme_dark": "Dark",
Expand Down
149 changes: 149 additions & 0 deletions test/unit/platform-packages.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest'
import { isPlatformSpecificPackage } from '../../app/utils/platform-packages'

describe('isPlatformSpecificPackage', () => {
describe('standard platform packages', () => {
it.each([
'esbuild-linux-x64',
'esbuild-darwin-arm64',
'esbuild-win32-x64',
'esbuild-win32-ia32',
'esbuild-freebsd-x64',
'esbuild-android-arm64',
])('detects "%s" as platform-specific', name => {
expect(isPlatformSpecificPackage(name)).toBe(true)
})
})

describe('scoped platform packages', () => {
it.each([
'@oxlint/win32-x64',
'@oxlint/linux-arm64',
'@oxlint/darwin-arm64',
'@swc/core-win32-x64-msvc',
'@swc/core-linux-x64-gnu',
'@swc/core-linux-arm64-musl',
'@rollup/rollup-linux-x64-gnu',
'@rollup/rollup-darwin-arm64',
'@rollup/rollup-win32-x64-msvc',
'@esbuild/linux-x64',
'@esbuild/darwin-arm64',
'@esbuild/win32-ia32',
])('detects "%s" as platform-specific', name => {
expect(isPlatformSpecificPackage(name)).toBe(true)
})
})

describe('packages with ABI suffixes', () => {
it.each([
'pkg-linux-x64-gnu',
'pkg-linux-x64-musl',
'pkg-win32-x64-msvc',
'pkg-win32-arm64-msvc',
'pkg-linux-arm-gnueabihf',
])('detects "%s" with ABI suffix as platform-specific', name => {
expect(isPlatformSpecificPackage(name)).toBe(true)
})
})

describe('all platform combinations', () => {
it.each([
// Windows variants
'pkg-win32-x64',
'pkg-win32-arm64',
'pkg-win32-ia32',
// macOS variants
'pkg-darwin-x64',
'pkg-darwin-arm64',
// Linux variants
'pkg-linux-x64',
'pkg-linux-arm64',
'pkg-linux-arm',
'pkg-linux-ia32',
'pkg-linux-ppc64',
'pkg-linux-ppc64le',
'pkg-linux-s390x',
'pkg-linux-riscv64',
'pkg-linux-mips64el',
'pkg-linux-loong64',
// Android
'pkg-android-arm64',
'pkg-android-arm',
'pkg-android-x64',
// BSD variants
'pkg-freebsd-x64',
'pkg-freebsd-arm64',
'pkg-openbsd-x64',
'pkg-netbsd-x64',
// Others
'pkg-sunos-x64',
'pkg-aix-ppc64',
])('detects "%s" as platform-specific', name => {
expect(isPlatformSpecificPackage(name)).toBe(true)
})
})

describe('false positives - should NOT match', () => {
it.each([
'linux-tips',
'node-linux',
'darwin-utils',
'win32-api',
'android-sdk',
'express',
'react',
'vue',
'@types/node',
'@babel/core',
'lodash',
'typescript',
'eslint',
'prettier',
'platform-tools',
'arch-decision-records',
'arm-controller',
'x64-utils',
])('does NOT detect "%s" as platform-specific', name => {
expect(isPlatformSpecificPackage(name)).toBe(false)
})
})

describe('edge cases', () => {
it('returns false for empty string', () => {
expect(isPlatformSpecificPackage('')).toBe(false)
})

it('returns false for scoped package with empty name', () => {
expect(isPlatformSpecificPackage('@scope/')).toBe(false)
})

it('returns false for single-part names', () => {
expect(isPlatformSpecificPackage('linux')).toBe(false)
expect(isPlatformSpecificPackage('x64')).toBe(false)
})

it('returns false for package with only OS, no arch', () => {
expect(isPlatformSpecificPackage('pkg-linux')).toBe(false)
expect(isPlatformSpecificPackage('pkg-darwin')).toBe(false)
expect(isPlatformSpecificPackage('pkg-win32')).toBe(false)
})

it('returns false for package with only arch, no OS', () => {
expect(isPlatformSpecificPackage('pkg-x64')).toBe(false)
expect(isPlatformSpecificPackage('pkg-arm64')).toBe(false)
})

it('is conservative with OS-arch in middle of name followed by unknown suffix', () => {
// These have unknown suffixes after the arch, so we're conservative
expect(isPlatformSpecificPackage('my-linux-x64-bindings')).toBe(false)
expect(isPlatformSpecificPackage('@scope/my-darwin-arm64-lib')).toBe(false)
})

it('is conservative with unknown suffixes at the end', () => {
// Unknown suffix after arch at the very end - should be conservative
expect(isPlatformSpecificPackage('pkg-linux-x64-unknown')).toBe(false)
// But if there are more parts after, still matches
expect(isPlatformSpecificPackage('pkg-linux-x64-foo-bar')).toBe(true)
})
})
})