[READ-ONLY] a fast, modern browser for the npm registry

feat: add toggle to hide platform-specific packages in search (#285)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

+289 -5
+3
app/composables/useSettings.ts
··· 14 14 includeTypesInInstall: boolean 15 15 /** Accent color theme */ 16 16 accentColorId: AccentColorId | null 17 + /** Hide platform-specific packages (e.g., @scope/pkg-linux-x64) from search results */ 18 + hidePlatformPackages: boolean 17 19 } 18 20 19 21 const DEFAULT_SETTINGS: AppSettings = { 20 22 relativeDates: false, 21 23 includeTypesInInstall: true, 22 24 accentColorId: null, 25 + hidePlatformPackages: true, 23 26 } 24 27 25 28 const STORAGE_KEY = 'npmx-settings'
+21 -5
app/pages/search.vue
··· 2 2 import { formatNumber } from '#imports' 3 3 import { debounce } from 'perfect-debounce' 4 4 import { isValidNewPackageName, checkPackageExists } from '~/utils/package-name' 5 + import { isPlatformSpecificPackage } from '~/utils/platform-packages' 5 6 6 7 const route = useRoute() 7 8 const router = useRouter() ··· 119 120 return results.value 120 121 }) 121 122 123 + // Settings for platform package filtering 124 + const { settings } = useSettings() 125 + 122 126 /** 123 - * Reorder results to put exact package name match at the top 127 + * Reorder results to put exact package name match at the top, 128 + * and optionally filter out platform-specific packages. 124 129 */ 125 130 const visibleResults = computed(() => { 126 131 const raw = rawVisibleResults.value 127 132 if (!raw) return raw 133 + 134 + let objects = raw.objects 135 + 136 + // Filter out platform-specific packages if setting is enabled 137 + if (settings.value.hidePlatformPackages) { 138 + objects = objects.filter(r => !isPlatformSpecificPackage(r.package.name)) 139 + } 128 140 129 141 const q = query.value.trim().toLowerCase() 130 - if (!q) return raw 142 + if (!q) { 143 + return objects === raw.objects ? raw : { ...raw, objects } 144 + } 131 145 132 146 // Find exact match index 133 - const exactIdx = raw.objects.findIndex(r => r.package.name.toLowerCase() === q) 134 - if (exactIdx <= 0) return raw // Already at top or not found 147 + const exactIdx = objects.findIndex(r => r.package.name.toLowerCase() === q) 148 + if (exactIdx <= 0) { 149 + return objects === raw.objects ? raw : { ...raw, objects } 150 + } 135 151 136 152 // Move exact match to top 137 - const reordered = [...raw.objects] 153 + const reordered = [...objects] 138 154 const [exactMatch] = reordered.splice(exactIdx, 1) 139 155 if (exactMatch) { 140 156 reordered.unshift(exactMatch)
+37
app/pages/settings.vue
··· 163 163 {{ $t('settings.include_types_description') }} 164 164 </p> 165 165 </div> 166 + 167 + <!-- Divider --> 168 + <div class="border-t border-border" /> 169 + 170 + <!-- Hide platform-specific packages toggle --> 171 + <div class="space-y-2"> 172 + <button 173 + type="button" 174 + class="w-full flex items-center justify-between gap-4 group" 175 + role="switch" 176 + :aria-checked="settings.hidePlatformPackages" 177 + @click="settings.hidePlatformPackages = !settings.hidePlatformPackages" 178 + > 179 + <span class="text-sm text-fg font-medium text-left"> 180 + {{ $t('settings.hide_platform_packages') }} 181 + </span> 182 + <span 183 + 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" 184 + :class=" 185 + settings.hidePlatformPackages ? 'bg-accent' : 'bg-bg border border-border' 186 + " 187 + aria-hidden="true" 188 + > 189 + <span 190 + 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" 191 + :class=" 192 + settings.hidePlatformPackages 193 + ? 'translate-x-5 bg-bg' 194 + : 'translate-x-0 bg-fg-muted' 195 + " 196 + /> 197 + </span> 198 + </button> 199 + <p class="text-sm text-fg-muted"> 200 + {{ $t('settings.hide_platform_packages_description') }} 201 + </p> 202 + </div> 166 203 </div> 167 204 </section> 168 205
+75
app/utils/platform-packages.ts
··· 1 + /** 2 + * Detects if a package name is a platform-specific native binary package. 3 + * These are typically optional dependencies that contain native binaries 4 + * for specific OS/architecture combinations (e.g., @oxlint/win32-x64, esbuild-darwin-arm64). 5 + * Sourced from searches for esbuild, and the napi-rs build triplets support matrix. 6 + */ 7 + 8 + const PLATFORMS = new Set([ 9 + 'win32', 10 + 'darwin', 11 + 'linux', 12 + 'android', 13 + 'freebsd', 14 + 'openbsd', 15 + 'netbsd', 16 + 'sunos', 17 + 'aix', 18 + ]) 19 + 20 + const ARCHITECTURES = new Set([ 21 + 'x64', 22 + 'arm64', 23 + 'arm', 24 + 'ia32', 25 + 'ppc64', 26 + 'ppc64le', 27 + 's390x', 28 + 'riscv64', 29 + 'mips64el', 30 + 'loong64', 31 + ]) 32 + 33 + const ABI_SUFFIXES = new Set(['gnu', 'musl', 'msvc', 'gnueabihf']) 34 + 35 + /** 36 + * Checks if a package name is a platform-specific native binary package. 37 + * Matches patterns like: 38 + * - @scope/pkg-win32-x64 39 + * - @scope/pkg-linux-arm64-gnu 40 + * - pkg-darwin-arm64 41 + * - @rollup/rollup-linux-x64-musl 42 + * 43 + * @param name - The full package name (including scope if present) 44 + * @returns true if the package appears to be a platform-specific binary 45 + */ 46 + export function isPlatformSpecificPackage(name: string): boolean { 47 + const unscopedName = name.startsWith('@') ? (name.split('/')[1] ?? '') : name 48 + if (!unscopedName) return false 49 + 50 + const parts = unscopedName.split('-') 51 + if (parts.length < 2) return false 52 + 53 + // Look for OS-arch pattern anywhere in the name as suffix parts 54 + // e.g., "pkg-linux-x64-gnu" -> ["pkg", "linux", "x64", "gnu"] 55 + for (let i = 0; i < parts.length - 1; i++) { 56 + const os = parts[i] 57 + const arch = parts[i + 1] 58 + 59 + if (os && arch && PLATFORMS.has(os) && ARCHITECTURES.has(arch)) { 60 + // Optional ABI suffix check (next part if exists) 61 + const abiSuffix = parts[i + 2] 62 + if (abiSuffix && !ABI_SUFFIXES.has(abiSuffix)) { 63 + // NOTE: Has an extra part after arch but it's not a known ABI - might be a false positive?? 64 + // but still consider it a match if OS+arch pattern is found at the end 65 + if (i + 2 === parts.length - 1) { 66 + // Extra unknown suffix at the end - be conservative 67 + continue 68 + } 69 + } 70 + return true 71 + } 72 + } 73 + 74 + return false 75 + }
+2
i18n/locales/en.json
··· 59 59 "relative_dates_description": "Show \"3 days ago\" instead of full dates", 60 60 "include_types": "Include {'@'}types in install", 61 61 "include_types_description": "Add {'@'}types package to install commands for untyped packages", 62 + "hide_platform_packages": "Hide platform-specific packages in search", 63 + "hide_platform_packages_description": "Hide native binary packages like {'@'}esbuild/linux-x64 from results", 62 64 "theme": "Theme", 63 65 "theme_light": "Light", 64 66 "theme_dark": "Dark",
+2
lunaria/files/en-US.json
··· 59 59 "relative_dates_description": "Show \"3 days ago\" instead of full dates", 60 60 "include_types": "Include {'@'}types in install", 61 61 "include_types_description": "Add {'@'}types package to install commands for untyped packages", 62 + "hide_platform_packages": "Hide platform-specific packages in search", 63 + "hide_platform_packages_description": "Hide native binary packages like {'@'}esbuild/linux-x64 from results", 62 64 "theme": "Theme", 63 65 "theme_light": "Light", 64 66 "theme_dark": "Dark",
+149
test/unit/platform-packages.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { isPlatformSpecificPackage } from '../../app/utils/platform-packages' 3 + 4 + describe('isPlatformSpecificPackage', () => { 5 + describe('standard platform packages', () => { 6 + it.each([ 7 + 'esbuild-linux-x64', 8 + 'esbuild-darwin-arm64', 9 + 'esbuild-win32-x64', 10 + 'esbuild-win32-ia32', 11 + 'esbuild-freebsd-x64', 12 + 'esbuild-android-arm64', 13 + ])('detects "%s" as platform-specific', name => { 14 + expect(isPlatformSpecificPackage(name)).toBe(true) 15 + }) 16 + }) 17 + 18 + describe('scoped platform packages', () => { 19 + it.each([ 20 + '@oxlint/win32-x64', 21 + '@oxlint/linux-arm64', 22 + '@oxlint/darwin-arm64', 23 + '@swc/core-win32-x64-msvc', 24 + '@swc/core-linux-x64-gnu', 25 + '@swc/core-linux-arm64-musl', 26 + '@rollup/rollup-linux-x64-gnu', 27 + '@rollup/rollup-darwin-arm64', 28 + '@rollup/rollup-win32-x64-msvc', 29 + '@esbuild/linux-x64', 30 + '@esbuild/darwin-arm64', 31 + '@esbuild/win32-ia32', 32 + ])('detects "%s" as platform-specific', name => { 33 + expect(isPlatformSpecificPackage(name)).toBe(true) 34 + }) 35 + }) 36 + 37 + describe('packages with ABI suffixes', () => { 38 + it.each([ 39 + 'pkg-linux-x64-gnu', 40 + 'pkg-linux-x64-musl', 41 + 'pkg-win32-x64-msvc', 42 + 'pkg-win32-arm64-msvc', 43 + 'pkg-linux-arm-gnueabihf', 44 + ])('detects "%s" with ABI suffix as platform-specific', name => { 45 + expect(isPlatformSpecificPackage(name)).toBe(true) 46 + }) 47 + }) 48 + 49 + describe('all platform combinations', () => { 50 + it.each([ 51 + // Windows variants 52 + 'pkg-win32-x64', 53 + 'pkg-win32-arm64', 54 + 'pkg-win32-ia32', 55 + // macOS variants 56 + 'pkg-darwin-x64', 57 + 'pkg-darwin-arm64', 58 + // Linux variants 59 + 'pkg-linux-x64', 60 + 'pkg-linux-arm64', 61 + 'pkg-linux-arm', 62 + 'pkg-linux-ia32', 63 + 'pkg-linux-ppc64', 64 + 'pkg-linux-ppc64le', 65 + 'pkg-linux-s390x', 66 + 'pkg-linux-riscv64', 67 + 'pkg-linux-mips64el', 68 + 'pkg-linux-loong64', 69 + // Android 70 + 'pkg-android-arm64', 71 + 'pkg-android-arm', 72 + 'pkg-android-x64', 73 + // BSD variants 74 + 'pkg-freebsd-x64', 75 + 'pkg-freebsd-arm64', 76 + 'pkg-openbsd-x64', 77 + 'pkg-netbsd-x64', 78 + // Others 79 + 'pkg-sunos-x64', 80 + 'pkg-aix-ppc64', 81 + ])('detects "%s" as platform-specific', name => { 82 + expect(isPlatformSpecificPackage(name)).toBe(true) 83 + }) 84 + }) 85 + 86 + describe('false positives - should NOT match', () => { 87 + it.each([ 88 + 'linux-tips', 89 + 'node-linux', 90 + 'darwin-utils', 91 + 'win32-api', 92 + 'android-sdk', 93 + 'express', 94 + 'react', 95 + 'vue', 96 + '@types/node', 97 + '@babel/core', 98 + 'lodash', 99 + 'typescript', 100 + 'eslint', 101 + 'prettier', 102 + 'platform-tools', 103 + 'arch-decision-records', 104 + 'arm-controller', 105 + 'x64-utils', 106 + ])('does NOT detect "%s" as platform-specific', name => { 107 + expect(isPlatformSpecificPackage(name)).toBe(false) 108 + }) 109 + }) 110 + 111 + describe('edge cases', () => { 112 + it('returns false for empty string', () => { 113 + expect(isPlatformSpecificPackage('')).toBe(false) 114 + }) 115 + 116 + it('returns false for scoped package with empty name', () => { 117 + expect(isPlatformSpecificPackage('@scope/')).toBe(false) 118 + }) 119 + 120 + it('returns false for single-part names', () => { 121 + expect(isPlatformSpecificPackage('linux')).toBe(false) 122 + expect(isPlatformSpecificPackage('x64')).toBe(false) 123 + }) 124 + 125 + it('returns false for package with only OS, no arch', () => { 126 + expect(isPlatformSpecificPackage('pkg-linux')).toBe(false) 127 + expect(isPlatformSpecificPackage('pkg-darwin')).toBe(false) 128 + expect(isPlatformSpecificPackage('pkg-win32')).toBe(false) 129 + }) 130 + 131 + it('returns false for package with only arch, no OS', () => { 132 + expect(isPlatformSpecificPackage('pkg-x64')).toBe(false) 133 + expect(isPlatformSpecificPackage('pkg-arm64')).toBe(false) 134 + }) 135 + 136 + it('is conservative with OS-arch in middle of name followed by unknown suffix', () => { 137 + // These have unknown suffixes after the arch, so we're conservative 138 + expect(isPlatformSpecificPackage('my-linux-x64-bindings')).toBe(false) 139 + expect(isPlatformSpecificPackage('@scope/my-darwin-arm64-lib')).toBe(false) 140 + }) 141 + 142 + it('is conservative with unknown suffixes at the end', () => { 143 + // Unknown suffix after arch at the very end - should be conservative 144 + expect(isPlatformSpecificPackage('pkg-linux-x64-unknown')).toBe(false) 145 + // But if there are more parts after, still matches 146 + expect(isPlatformSpecificPackage('pkg-linux-x64-foo-bar')).toBe(true) 147 + }) 148 + }) 149 + })