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

feat: suggest installing dependency as devDependency when appropriate (#1052)

authored by

Wojciech Maj and committed by
GitHub
0475e6fa 11c33f2b

+323 -4
+65 -1
app/components/Terminal/Install.vue
··· 1 1 <script setup lang="ts"> 2 2 import type { JsrPackageInfo } from '#shared/types/jsr' 3 + import type { DevDependencySuggestion } from '#shared/utils/dev-dependency' 3 4 import type { PackageManagerId } from '~/utils/install-command' 4 5 5 6 const props = defineProps<{ ··· 7 8 requestedVersion?: string | null 8 9 installVersionOverride?: string | null 9 10 jsrInfo?: JsrPackageInfo | null 11 + devDependencySuggestion?: DevDependencySuggestion | null 10 12 typesPackageName?: string | null 11 13 executableInfo?: { hasExecutable: boolean; primaryCommand?: string } | null 12 14 createPackageInfo?: { packageName: string } | null ··· 30 32 }) 31 33 } 32 34 35 + const devDependencySuggestion = computed( 36 + () => props.devDependencySuggestion ?? { recommended: false as const }, 37 + ) 38 + 39 + function getDevInstallPartsForPM(pmId: PackageManagerId) { 40 + return getInstallCommandParts({ 41 + packageName: props.packageName, 42 + packageManager: pmId, 43 + version: props.requestedVersion, 44 + jsrInfo: props.jsrInfo, 45 + dev: true, 46 + }) 47 + } 48 + 33 49 // Generate run command parts for a specific package manager 34 50 function getRunPartsForPM(pmId: PackageManagerId, command?: string) { 35 51 return getRunCommandParts({ ··· 68 84 const pm = packageManagers.find(p => p.id === pmId) 69 85 if (!pm) return [] 70 86 71 - const devFlag = pmId === 'bun' ? '-d' : '-D' 87 + const devFlag = getDevDependencyFlag(pmId) 72 88 const pkgSpec = pmId === 'deno' ? `npm:${props.typesPackageName}` : props.typesPackageName 73 89 74 90 return [pm.label, pm.action, devFlag, pkgSpec] ··· 95 111 96 112 const { copied: createCopied, copy: copyCreate } = useClipboard({ copiedDuring: 2000 }) 97 113 const copyCreateCommand = () => copyCreate(getFullCreateCommand()) 114 + 115 + const { copied: devInstallCopied, copy: copyDevInstall } = useClipboard({ copiedDuring: 2000 }) 116 + const copyDevInstallCommand = () => 117 + copyDevInstall( 118 + getInstallCommand({ 119 + packageName: props.packageName, 120 + packageManager: selectedPM.value, 121 + version: props.requestedVersion, 122 + jsrInfo: props.jsrInfo, 123 + dev: true, 124 + }), 125 + ) 98 126 </script> 99 127 100 128 <template> ··· 132 160 <span aria-live="polite">{{ copied ? $t('common.copied') : $t('common.copy') }}</span> 133 161 </button> 134 162 </div> 163 + 164 + <!-- Suggested dev dependency install command --> 165 + <template v-if="devDependencySuggestion.recommended"> 166 + <div class="flex items-center gap-2 pt-1 select-none"> 167 + <span class="text-fg-subtle font-mono text-sm" 168 + ># {{ $t('package.get_started.dev_dependency_hint') }}</span 169 + > 170 + </div> 171 + <div 172 + v-for="pm in packageManagers" 173 + :key="`install-dev-${pm.id}`" 174 + :data-pm-cmd="pm.id" 175 + class="flex items-center gap-2 group/devinstallcmd min-w-0" 176 + > 177 + <span class="text-fg-subtle font-mono text-sm select-none shrink-0">$</span> 178 + <code class="font-mono text-sm min-w-0" 179 + ><span 180 + v-for="(part, i) in getDevInstallPartsForPM(pm.id)" 181 + :key="i" 182 + :class="i === 0 ? 'text-fg' : 'text-fg-muted'" 183 + >{{ i > 0 ? ' ' : '' }}{{ part }}</span 184 + ></code 185 + > 186 + <ButtonBase 187 + type="button" 188 + size="small" 189 + class="text-fg-muted bg-bg-subtle/80 border-border opacity-0 group-hover/devinstallcmd:opacity-100 active:scale-95 focus-visible:opacity-100 select-none" 190 + :aria-label="$t('package.get_started.copy_dev_command')" 191 + @click.stop="copyDevInstallCommand" 192 + > 193 + <span aria-live="polite">{{ 194 + devInstallCopied ? $t('common.copied') : $t('common.copy') 195 + }}</span> 196 + </ButtonBase> 197 + </div> 198 + </template> 135 199 136 200 <!-- @types package install - render all PM variants when types package exists --> 137 201 <template v-if="typesPackageName && showTypesInInstall">
+2
app/composables/usePackageAnalysis.ts
··· 1 1 import type { ModuleFormat, TypesStatus, CreatePackageInfo } from '#shared/utils/package-analysis' 2 + import type { DevDependencySuggestion } from '#shared/utils/dev-dependency' 2 3 3 4 export interface PackageAnalysisResponse { 4 5 package: string 5 6 version: string 6 7 moduleFormat: ModuleFormat 7 8 types: TypesStatus 9 + devDependencySuggestion: DevDependencySuggestion 8 10 engines?: { 9 11 node?: string 10 12 npm?: string
+1
app/pages/package/[[org]]/[name].vue
··· 1234 1234 :requested-version="requestedVersion" 1235 1235 :install-version-override="installVersionOverride" 1236 1236 :jsr-info="jsrInfo" 1237 + :dev-dependency-suggestion="packageAnalysis?.devDependencySuggestion" 1237 1238 :types-package-name="typesPackageName" 1238 1239 :executable-info="executableInfo" 1239 1240 :create-package-info="createPackageInfo"
+7 -1
app/utils/install-command.ts
··· 68 68 packageManager: PackageManagerId 69 69 version?: string | null 70 70 jsrInfo?: JsrPackageInfo | null 71 + dev?: boolean 72 + } 73 + 74 + export function getDevDependencyFlag(packageManager: PackageManagerId): '-D' | '-d' { 75 + return packageManager === 'bun' ? '-d' : '-D' 71 76 } 72 77 73 78 /** ··· 108 113 109 114 const spec = getPackageSpecifier(options) 110 115 const version = options.version ? `@${options.version}` : '' 116 + const devFlag = options.dev ? [getDevDependencyFlag(options.packageManager)] : [] 111 117 112 - return [pm.label, pm.action, `${spec}${version}`] 118 + return [pm.label, pm.action, ...devFlag, `${spec}${version}`] 113 119 } 114 120 115 121 export interface ExecuteCommandOptions extends InstallCommandOptions {
+2
i18n/locales/en.json
··· 221 221 "title": "Get started", 222 222 "pm_label": "Package manager", 223 223 "copy_command": "Copy install command", 224 + "copy_dev_command": "Copy dev install command", 225 + "dev_dependency_hint": "Usually installed as a dev dependency", 224 226 "view_types": "View {package}" 225 227 }, 226 228 "create": {
+2
i18n/locales/pl-PL.json
··· 221 221 "title": "Zacznij", 222 222 "pm_label": "Menedżer pakietów", 223 223 "copy_command": "Kopiuj komendę instalacji", 224 + "copy_dev_command": "Kopiuj komendę instalacji dla dev", 225 + "dev_dependency_hint": "Zazwyczaj instalowane jako zależność deweloperska", 224 226 "view_types": "Zobacz {package}" 225 227 }, 226 228 "create": {
+6
i18n/schema.json
··· 667 667 "copy_command": { 668 668 "type": "string" 669 669 }, 670 + "copy_dev_command": { 671 + "type": "string" 672 + }, 673 + "dev_dependency_hint": { 674 + "type": "string" 675 + }, 670 676 "view_types": { 671 677 "type": "string" 672 678 }
+2
lunaria/files/en-GB.json
··· 220 220 "title": "Get started", 221 221 "pm_label": "Package manager", 222 222 "copy_command": "Copy install command", 223 + "copy_dev_command": "Copy dev install command", 224 + "dev_dependency_hint": "Usually installed as a dev dependency", 223 225 "view_types": "View {package}" 224 226 }, 225 227 "create": {
+2
lunaria/files/en-US.json
··· 220 220 "title": "Get started", 221 221 "pm_label": "Package manager", 222 222 "copy_command": "Copy install command", 223 + "copy_dev_command": "Copy dev install command", 224 + "dev_dependency_hint": "Usually installed as a dev dependency", 223 225 "view_types": "View {package}" 224 226 }, 225 227 "create": {
+2
lunaria/files/pl-PL.json
··· 220 220 "title": "Zacznij", 221 221 "pm_label": "Menedżer pakietów", 222 222 "copy_command": "Kopiuj komendę instalacji", 223 + "copy_dev_command": "Kopiuj komendę instalacji dla dev", 224 + "dev_dependency_hint": "Zazwyczaj instalowane jako zależność deweloperska", 223 225 "view_types": "Zobacz {package}" 224 226 }, 225 227 "create": {
+13 -2
server/api/registry/analysis/[...pkg].get.ts
··· 13 13 hasBuiltInTypes, 14 14 } from '#shared/utils/package-analysis' 15 15 import { 16 + getDevDependencySuggestion, 17 + type DevDependencySuggestion, 18 + } from '#shared/utils/dev-dependency' 19 + import { 16 20 NPM_REGISTRY, 17 21 CACHE_MAX_AGE_ONE_DAY, 18 22 ERROR_PACKAGE_ANALYSIS_FAILED, ··· 21 25 import { encodePackageName } from '#shared/utils/npm' 22 26 import { getLatestVersion, getLatestVersionBatch } from 'fast-npm-meta' 23 27 28 + interface AnalysisPackageJson extends ExtendedPackageJson { 29 + readme?: string 30 + } 31 + 24 32 export default defineCachedEventHandler( 25 33 async event => { 26 34 // Parse package name and optional version from path ··· 38 46 // Fetch package data 39 47 const encodedName = encodePackageName(packageName) 40 48 const versionSuffix = version ? `/${version}` : '/latest' 41 - const pkg = await $fetch<ExtendedPackageJson>( 49 + const pkg = await $fetch<AnalysisPackageJson>( 42 50 `${NPM_REGISTRY}/${encodedName}${versionSuffix}`, 43 51 ) 44 52 ··· 54 62 const createPackage = await findAssociatedCreatePackage(packageName, pkg) 55 63 56 64 const analysis = analyzePackage(pkg, { typesPackage, createPackage }) 65 + const devDependencySuggestion = getDevDependencySuggestion(packageName, pkg.readme) 57 66 58 67 return { 59 68 package: packageName, 60 69 version: pkg.version ?? version ?? 'latest', 70 + devDependencySuggestion, 61 71 ...analysis, 62 72 } satisfies PackageAnalysisResponse 63 73 } catch (error: unknown) { ··· 72 82 swr: true, 73 83 getKey: event => { 74 84 const pkg = getRouterParam(event, 'pkg') ?? '' 75 - return `analysis:v1:${pkg.replace(/\/+$/, '').trim()}` 85 + return `analysis:v2:${pkg.replace(/\/+$/, '').trim()}` 76 86 }, 77 87 }, 78 88 ) ··· 209 219 export interface PackageAnalysisResponse extends PackageAnalysis { 210 220 package: string 211 221 version: string 222 + devDependencySuggestion: DevDependencySuggestion 212 223 }
+110
shared/utils/dev-dependency.ts
··· 1 + export type DevDependencySuggestionReason = 'known-package' | 'readme-hint' 2 + 3 + export interface DevDependencySuggestion { 4 + recommended: boolean 5 + reason?: DevDependencySuggestionReason 6 + } 7 + 8 + const KNOWN_DEV_DEPENDENCY_PACKAGES = new Set<string>([ 9 + 'biome', 10 + 'chai', 11 + 'eslint', 12 + 'esbuild', 13 + 'husky', 14 + 'jest', 15 + 'lint-staged', 16 + 'mocha', 17 + 'oxc', 18 + 'oxfmt', 19 + 'oxlint', 20 + 'playwright', 21 + 'prettier', 22 + 'rolldown', 23 + 'rollup', 24 + 'stylelint', 25 + 'ts-jest', 26 + 'ts-node', 27 + 'tsx', 28 + 'turbo', 29 + 'typescript', 30 + 'vite', 31 + 'vitest', 32 + 'webpack', 33 + ]) 34 + 35 + const KNOWN_DEV_DEPENDENCY_PACKAGE_PREFIXES = [ 36 + '@typescript-eslint/', 37 + 'eslint-', 38 + 'prettier-', 39 + 'vite-', 40 + 'webpack-', 41 + 'babel-', 42 + ] 43 + 44 + function isKnownDevDependencyPackage(packageName: string): boolean { 45 + const normalized = packageName.toLowerCase() 46 + if (normalized.startsWith('@types/')) { 47 + return true 48 + } 49 + // Match scoped packages by name segment, e.g. @scope/eslint-config 50 + const namePart = normalized.includes('/') ? normalized.split('/').pop() : normalized 51 + if (!namePart) return false 52 + 53 + return ( 54 + KNOWN_DEV_DEPENDENCY_PACKAGES.has(normalized) || 55 + KNOWN_DEV_DEPENDENCY_PACKAGES.has(namePart) || 56 + KNOWN_DEV_DEPENDENCY_PACKAGE_PREFIXES.some(prefix => 57 + prefix.startsWith('@') ? normalized.startsWith(prefix) : namePart.startsWith(prefix), 58 + ) 59 + ) 60 + } 61 + 62 + function escapeRegExp(text: string): string { 63 + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 64 + } 65 + 66 + function hasReadmeDevInstallHint(packageName: string, readmeContent?: string | null): boolean { 67 + if (!readmeContent) return false 68 + 69 + const escapedName = escapeRegExp(packageName) 70 + const escapedNpmName = escapeRegExp(`npm:${packageName}`) 71 + const packageSpec = `(?:${escapedName}|${escapedNpmName})(?:@[\\w.-]+)?` 72 + 73 + const patterns = [ 74 + // npm install -D pkg / pnpm add --save-dev pkg 75 + new RegExp( 76 + String.raw`(?:npm|pnpm|yarn|bun|vlt)\s+(?:install|add|i)\s+(?:--save-dev|--dev|-d)\s+${packageSpec}`, 77 + 'i', 78 + ), 79 + // npm install pkg --save-dev / pnpm add pkg -D 80 + new RegExp( 81 + String.raw`(?:npm|pnpm|yarn|bun|vlt)\s+(?:install|add|i)\s+${packageSpec}\s+(?:--save-dev|--dev|-d)`, 82 + 'i', 83 + ), 84 + // deno add -D npm:pkg 85 + new RegExp(String.raw`deno\s+add\s+(?:--dev|-D)\s+${packageSpec}`, 'i'), 86 + ] 87 + 88 + return patterns.some(pattern => pattern.test(readmeContent)) 89 + } 90 + 91 + export function getDevDependencySuggestion( 92 + packageName: string, 93 + readmeContent?: string | null, 94 + ): DevDependencySuggestion { 95 + if (isKnownDevDependencyPackage(packageName)) { 96 + return { 97 + recommended: true, 98 + reason: 'known-package', 99 + } 100 + } 101 + 102 + if (hasReadmeDevInstallHint(packageName, readmeContent)) { 103 + return { 104 + recommended: true, 105 + reason: 'readme-hint', 106 + } 107 + } 108 + 109 + return { recommended: false } 110 + }
+49
test/unit/app/utils/install-command.spec.ts
··· 5 5 getPackageSpecifier, 6 6 getExecuteCommand, 7 7 getExecuteCommandParts, 8 + getDevDependencyFlag, 8 9 } from '../../../../app/utils/install-command' 9 10 import type { JsrPackageInfo } from '../../../../shared/types/jsr' 10 11 ··· 124 125 }) 125 126 }) 126 127 128 + describe('dev dependency installs', () => { 129 + it.each([ 130 + ['npm', 'npm install -D eslint'], 131 + ['pnpm', 'pnpm add -D eslint'], 132 + ['yarn', 'yarn add -D eslint'], 133 + ['bun', 'bun add -d eslint'], 134 + ['deno', 'deno add -D npm:eslint'], 135 + ['vlt', 'vlt install -D eslint'], 136 + ] as const)('%s → %s', (pm, expected) => { 137 + expect( 138 + getInstallCommand({ 139 + packageName: 'eslint', 140 + packageManager: pm, 141 + jsrInfo: jsrNotAvailable, 142 + dev: true, 143 + }), 144 + ).toBe(expected) 145 + }) 146 + }) 147 + 127 148 describe('scoped package on JSR without version', () => { 128 149 it.each([ 129 150 ['npm', 'npm install @trpc/server'], ··· 203 224 expect(parts).toEqual(['npm', 'install', 'lodash@4.17.21']) 204 225 }) 205 226 227 + it('returns correct parts for npm with dev flag', () => { 228 + const parts = getInstallCommandParts({ 229 + packageName: 'eslint', 230 + packageManager: 'npm', 231 + jsrInfo: jsrNotAvailable, 232 + dev: true, 233 + }) 234 + expect(parts).toEqual(['npm', 'install', '-D', 'eslint']) 235 + }) 236 + 206 237 it('returns correct parts for deno with jsr: prefix when available', () => { 207 238 const parts = getInstallCommandParts({ 208 239 packageName: '@trpc/server', ··· 212 243 expect(parts).toEqual(['deno', 'add', 'jsr:@trpc/server']) 213 244 }) 214 245 246 + it('returns correct parts for bun with lowercase dev flag', () => { 247 + const parts = getInstallCommandParts({ 248 + packageName: 'eslint', 249 + packageManager: 'bun', 250 + jsrInfo: jsrNotAvailable, 251 + dev: true, 252 + }) 253 + expect(parts).toEqual(['bun', 'add', '-d', 'eslint']) 254 + }) 255 + 215 256 it('returns correct parts for deno with npm: prefix when not on JSR', () => { 216 257 const parts = getInstallCommandParts({ 217 258 packageName: 'lodash', ··· 240 281 const parts = getInstallCommandParts(options) 241 282 const command = getInstallCommand(options) 242 283 expect(parts.join(' ')).toBe(command) 284 + }) 285 + }) 286 + 287 + describe('getDevDependencyFlag', () => { 288 + it('returns lowercase flag only for bun', () => { 289 + expect(getDevDependencyFlag('bun')).toBe('-d') 290 + expect(getDevDependencyFlag('npm')).toBe('-D') 291 + expect(getDevDependencyFlag('deno')).toBe('-D') 243 292 }) 244 293 }) 245 294
+60
test/unit/shared/utils/dev-dependency.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { getDevDependencySuggestion } from '../../../../shared/utils/dev-dependency' 3 + 4 + describe('getDevDependencySuggestion', () => { 5 + it('suggests dev dependency for known tooling packages', () => { 6 + expect(getDevDependencySuggestion('eslint')).toEqual({ 7 + recommended: true, 8 + reason: 'known-package', 9 + }) 10 + expect(getDevDependencySuggestion('@types/node')).toEqual({ 11 + recommended: true, 12 + reason: 'known-package', 13 + }) 14 + expect(getDevDependencySuggestion('@typescript-eslint/parser')).toEqual({ 15 + recommended: true, 16 + reason: 'known-package', 17 + }) 18 + }) 19 + 20 + it('suggests dev dependency from README install command hints', () => { 21 + const readme = '<p>Install with <code>npm install --save-dev some-tool</code></p>' 22 + 23 + expect(getDevDependencySuggestion('some-tool', readme)).toEqual({ 24 + recommended: true, 25 + reason: 'readme-hint', 26 + }) 27 + }) 28 + 29 + it('suggests dev dependency from README --dev flag hints', () => { 30 + const readme = '<p><code>yarn add --dev some-tool</code></p>' 31 + 32 + expect(getDevDependencySuggestion('some-tool', readme)).toEqual({ 33 + recommended: true, 34 + reason: 'readme-hint', 35 + }) 36 + }) 37 + 38 + it('suggests dev dependency from README deno -D hints', () => { 39 + const readme = '<p><code>deno add -D npm:some-tool</code></p>' 40 + 41 + expect(getDevDependencySuggestion('some-tool', readme)).toEqual({ 42 + recommended: true, 43 + reason: 'readme-hint', 44 + }) 45 + }) 46 + 47 + it('does not suggest dev dependency for runtime packages without hints', () => { 48 + expect(getDevDependencySuggestion('react')).toEqual({ 49 + recommended: false, 50 + }) 51 + }) 52 + 53 + it('does not suggest when README hint targets a different package', () => { 54 + const readme = '<p>Install with <code>yarn add -D bar</code></p>' 55 + 56 + expect(getDevDependencySuggestion('foo', readme)).toEqual({ 57 + recommended: false, 58 + }) 59 + }) 60 + })