Skip to content

Commit 2e5f52c

Browse files
committed
feat: suggest installing dependency as devDependency when appropriate
Closes #530
1 parent 2273d3b commit 2e5f52c

File tree

9 files changed

+269
-2
lines changed

9 files changed

+269
-2
lines changed

app/components/Terminal/Install.vue

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const props = defineProps<{
66
packageName: string
77
requestedVersion?: string | null
88
jsrInfo?: JsrPackageInfo | null
9+
readmeHtml?: string | null
910
typesPackageName?: string | null
1011
executableInfo?: { hasExecutable: boolean; primaryCommand?: string } | null
1112
createPackageInfo?: { packageName: string } | null
@@ -28,6 +29,20 @@ function getInstallPartsForPM(pmId: PackageManagerId) {
2829
})
2930
}
3031
32+
const devDependencySuggestion = computed(() =>
33+
getDevDependencySuggestion(props.packageName, props.readmeHtml),
34+
)
35+
36+
function getDevInstallPartsForPM(pmId: PackageManagerId) {
37+
return getInstallCommandParts({
38+
packageName: props.packageName,
39+
packageManager: pmId,
40+
version: props.requestedVersion,
41+
jsrInfo: props.jsrInfo,
42+
dev: true,
43+
})
44+
}
45+
3146
// Generate run command parts for a specific package manager
3247
function getRunPartsForPM(pmId: PackageManagerId, command?: string) {
3348
return getRunCommandParts({
@@ -66,7 +81,7 @@ function getTypesInstallPartsForPM(pmId: PackageManagerId) {
6681
const pm = packageManagers.find(p => p.id === pmId)
6782
if (!pm) return []
6883
69-
const devFlag = pmId === 'bun' ? '-d' : '-D'
84+
const devFlag = getDevDependencyFlag(pmId)
7085
const pkgSpec = pmId === 'deno' ? `npm:${props.typesPackageName}` : props.typesPackageName
7186
7287
return [pm.label, pm.action, devFlag, pkgSpec]
@@ -93,6 +108,18 @@ const copyRunCommand = (command?: string) => copyRun(getFullRunCommand(command))
93108
94109
const { copied: createCopied, copy: copyCreate } = useClipboard({ copiedDuring: 2000 })
95110
const copyCreateCommand = () => copyCreate(getFullCreateCommand())
111+
112+
const { copied: devInstallCopied, copy: copyDevInstall } = useClipboard({ copiedDuring: 2000 })
113+
const copyDevInstallCommand = () =>
114+
copyDevInstall(
115+
getInstallCommand({
116+
packageName: props.packageName,
117+
packageManager: selectedPM.value,
118+
version: props.requestedVersion,
119+
jsrInfo: props.jsrInfo,
120+
dev: true,
121+
}),
122+
)
96123
</script>
97124

98125
<template>
@@ -131,6 +158,41 @@ const copyCreateCommand = () => copyCreate(getFullCreateCommand())
131158
</button>
132159
</div>
133160

161+
<!-- Suggested dev dependency install command -->
162+
<template v-if="devDependencySuggestion.recommended">
163+
<div class="flex items-center gap-2 pt-1 select-none">
164+
<span class="text-fg-subtle font-mono text-sm"
165+
># {{ $t('package.get_started.dev_dependency_hint') }}</span
166+
>
167+
</div>
168+
<div
169+
v-for="pm in packageManagers"
170+
:key="`install-dev-${pm.id}`"
171+
:data-pm-cmd="pm.id"
172+
class="flex items-center gap-2 group/devinstallcmd min-w-0"
173+
>
174+
<span class="text-fg-subtle font-mono text-sm select-none shrink-0">$</span>
175+
<code class="font-mono text-sm min-w-0"
176+
><span
177+
v-for="(part, i) in getDevInstallPartsForPM(pm.id)"
178+
:key="i"
179+
:class="i === 0 ? 'text-fg' : 'text-fg-muted'"
180+
>{{ i > 0 ? ' ' : '' }}{{ part }}</span
181+
></code
182+
>
183+
<button
184+
type="button"
185+
class="px-2 py-0.5 font-mono text-xs text-fg-muted bg-bg-subtle/80 border border-border rounded transition-colors duration-200 opacity-0 group-hover/devinstallcmd:opacity-100 hover:(text-fg border-border-hover) active:scale-95 focus-visible:opacity-100 focus-visible:outline-accent/70 select-none"
186+
:aria-label="$t('package.get_started.copy_dev_command')"
187+
@click.stop="copyDevInstallCommand"
188+
>
189+
<span aria-live="polite">{{
190+
devInstallCopied ? $t('common.copied') : $t('common.copy')
191+
}}</span>
192+
</button>
193+
</div>
194+
</template>
195+
134196
<!-- @types package install - render all PM variants when types package exists -->
135197
<template v-if="typesPackageName && showTypesInInstall">
136198
<div

app/pages/package/[...package].vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,6 +1092,7 @@ onKeyStroke(
10921092
:package-name="pkg.name"
10931093
:requested-version="requestedVersion"
10941094
:jsr-info="jsrInfo"
1095+
:readme-html="readmeData?.html"
10951096
:types-package-name="typesPackageName"
10961097
:executable-info="executableInfo"
10971098
:create-package-info="createPackageInfo"

app/utils/install-command.ts

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,117 @@ export interface InstallCommandOptions {
6868
packageManager: PackageManagerId
6969
version?: string | null
7070
jsrInfo?: JsrPackageInfo | null
71+
dev?: boolean
72+
}
73+
74+
export type DevDependencySuggestionReason = 'known-package' | 'readme-hint'
75+
76+
export interface DevDependencySuggestion {
77+
recommended: boolean
78+
reason?: DevDependencySuggestionReason
79+
}
80+
81+
const KNOWN_DEV_DEPENDENCY_PACKAGES = new Set<string>([
82+
'@types',
83+
'biome',
84+
'chai',
85+
'eslint',
86+
'esbuild',
87+
'husky',
88+
'jest',
89+
'lint-staged',
90+
'mocha',
91+
'oxc',
92+
'oxfmt',
93+
'oxlint',
94+
'playwright',
95+
'prettier',
96+
'rolldown',
97+
'rollup',
98+
'stylelint',
99+
'ts-jest',
100+
'ts-node',
101+
'tsx',
102+
'turbo',
103+
'typescript',
104+
'vite',
105+
'vitest',
106+
'webpack',
107+
])
108+
109+
function isKnownDevDependencyPackage(packageName: string): boolean {
110+
const normalized = packageName.toLowerCase()
111+
if (normalized.startsWith('@types/')) {
112+
return true
113+
}
114+
// Match scoped packages by name segment, e.g. @scope/eslint-config
115+
const namePart = normalized.includes('/') ? normalized.split('/').pop() : normalized
116+
if (!namePart) return false
117+
118+
return (
119+
KNOWN_DEV_DEPENDENCY_PACKAGES.has(normalized) ||
120+
KNOWN_DEV_DEPENDENCY_PACKAGES.has(namePart) ||
121+
normalized.startsWith('@typescript-eslint/') ||
122+
namePart.startsWith('eslint-') ||
123+
namePart.startsWith('prettier-') ||
124+
namePart.startsWith('vite-') ||
125+
namePart.startsWith('webpack-') ||
126+
namePart.startsWith('babel-')
127+
)
128+
}
129+
130+
function escapeRegExp(text: string): string {
131+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
132+
}
133+
134+
function hasReadmeDevInstallHint(packageName: string, readmeHtml?: string | null): boolean {
135+
if (!readmeHtml) return false
136+
137+
const escapedName = escapeRegExp(packageName)
138+
const escapedNpmName = escapeRegExp(`npm:${packageName}`)
139+
const packageSpec = `(?:${escapedName}|${escapedNpmName})(?:@[\\w.-]+)?`
140+
141+
const patterns = [
142+
// npm install -D pkg / pnpm add --save-dev pkg
143+
new RegExp(
144+
String.raw`(?:npm|pnpm|yarn|bun|vlt)\s+(?:install|add|i)\s+(?:--save-dev|-d)\s+${packageSpec}`,
145+
'i',
146+
),
147+
// npm install pkg --save-dev / pnpm add pkg -D
148+
new RegExp(
149+
String.raw`(?:npm|pnpm|yarn|bun|vlt)\s+(?:install|add|i)\s+${packageSpec}\s+(?:--save-dev|-d)`,
150+
'i',
151+
),
152+
// deno add -D npm:pkg
153+
new RegExp(String.raw`deno\s+add\s+(?:--dev|-d)\s+${packageSpec}`, 'i'),
154+
]
155+
156+
return patterns.some(pattern => pattern.test(readmeHtml))
157+
}
158+
159+
export function getDevDependencySuggestion(
160+
packageName: string,
161+
readmeHtml?: string | null,
162+
): DevDependencySuggestion {
163+
if (isKnownDevDependencyPackage(packageName)) {
164+
return {
165+
recommended: true,
166+
reason: 'known-package',
167+
}
168+
}
169+
170+
if (hasReadmeDevInstallHint(packageName, readmeHtml)) {
171+
return {
172+
recommended: true,
173+
reason: 'readme-hint',
174+
}
175+
}
176+
177+
return { recommended: false }
178+
}
179+
180+
export function getDevDependencyFlag(packageManager: PackageManagerId): '-D' | '-d' {
181+
return packageManager === 'bun' ? '-d' : '-D'
71182
}
72183

73184
/**
@@ -108,8 +219,9 @@ export function getInstallCommandParts(options: InstallCommandOptions): string[]
108219

109220
const spec = getPackageSpecifier(options)
110221
const version = options.version ? `@${options.version}` : ''
222+
const devFlag = options.dev ? [getDevDependencyFlag(options.packageManager)] : []
111223

112-
return [pm.label, pm.action, `${spec}${version}`]
224+
return [pm.label, pm.action, ...devFlag, `${spec}${version}`]
113225
}
114226

115227
export interface ExecuteCommandOptions extends InstallCommandOptions {

i18n/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,8 @@
199199
"title": "Get started",
200200
"pm_label": "Package manager",
201201
"copy_command": "Copy install command",
202+
"copy_dev_command": "Copy dev install command",
203+
"dev_dependency_hint": "Usually installed as a dev dependency",
202204
"view_types": "View {package}"
203205
},
204206
"create": {

i18n/locales/pl-PL.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@
189189
"title": "Zacznij",
190190
"pm_label": "Menedżer pakietów",
191191
"copy_command": "Kopiuj komendę instalacji",
192+
"copy_dev_command": "Kopiuj komendę instalacji dla dev",
193+
"dev_dependency_hint": "Zazwyczaj instalowane jako zależność deweloperska",
192194
"view_types": "Zobacz {package}"
193195
},
194196
"create": {

lunaria/files/en-GB.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,8 @@
199199
"title": "Get started",
200200
"pm_label": "Package manager",
201201
"copy_command": "Copy install command",
202+
"copy_dev_command": "Copy dev install command",
203+
"dev_dependency_hint": "Usually installed as a dev dependency",
202204
"view_types": "View {package}"
203205
},
204206
"create": {

lunaria/files/en-US.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,8 @@
199199
"title": "Get started",
200200
"pm_label": "Package manager",
201201
"copy_command": "Copy install command",
202+
"copy_dev_command": "Copy dev install command",
203+
"dev_dependency_hint": "Usually installed as a dev dependency",
202204
"view_types": "View {package}"
203205
},
204206
"create": {

lunaria/files/pl-PL.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@
189189
"title": "Zacznij",
190190
"pm_label": "Menedżer pakietów",
191191
"copy_command": "Kopiuj komendę instalacji",
192+
"copy_dev_command": "Kopiuj komendę instalacji dla dev",
193+
"dev_dependency_hint": "Zazwyczaj instalowane jako zależność deweloperska",
192194
"view_types": "Zobacz {package}"
193195
},
194196
"create": {

test/unit/app/utils/install-command.spec.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
getPackageSpecifier,
66
getExecuteCommand,
77
getExecuteCommandParts,
8+
getDevDependencySuggestion,
9+
getDevDependencyFlag,
810
} from '../../../../app/utils/install-command'
911
import type { JsrPackageInfo } from '../../../../shared/types/jsr'
1012

@@ -124,6 +126,26 @@ describe('install command generation', () => {
124126
})
125127
})
126128

129+
describe('dev dependency installs', () => {
130+
it.each([
131+
['npm', 'npm install -D eslint'],
132+
['pnpm', 'pnpm add -D eslint'],
133+
['yarn', 'yarn add -D eslint'],
134+
['bun', 'bun add -d eslint'],
135+
['deno', 'deno add -D npm:eslint'],
136+
['vlt', 'vlt install -D eslint'],
137+
] as const)('%s → %s', (pm, expected) => {
138+
expect(
139+
getInstallCommand({
140+
packageName: 'eslint',
141+
packageManager: pm,
142+
jsrInfo: jsrNotAvailable,
143+
dev: true,
144+
}),
145+
).toBe(expected)
146+
})
147+
})
148+
127149
describe('scoped package on JSR without version', () => {
128150
it.each([
129151
['npm', 'npm install @trpc/server'],
@@ -203,6 +225,16 @@ describe('install command generation', () => {
203225
expect(parts).toEqual(['npm', 'install', 'lodash@4.17.21'])
204226
})
205227

228+
it('returns correct parts for npm with dev flag', () => {
229+
const parts = getInstallCommandParts({
230+
packageName: 'eslint',
231+
packageManager: 'npm',
232+
jsrInfo: jsrNotAvailable,
233+
dev: true,
234+
})
235+
expect(parts).toEqual(['npm', 'install', '-D', 'eslint'])
236+
})
237+
206238
it('returns correct parts for deno with jsr: prefix when available', () => {
207239
const parts = getInstallCommandParts({
208240
packageName: '@trpc/server',
@@ -212,6 +244,16 @@ describe('install command generation', () => {
212244
expect(parts).toEqual(['deno', 'add', 'jsr:@trpc/server'])
213245
})
214246

247+
it('returns correct parts for bun with lowercase dev flag', () => {
248+
const parts = getInstallCommandParts({
249+
packageName: 'eslint',
250+
packageManager: 'bun',
251+
jsrInfo: jsrNotAvailable,
252+
dev: true,
253+
})
254+
expect(parts).toEqual(['bun', 'add', '-d', 'eslint'])
255+
})
256+
215257
it('returns correct parts for deno with npm: prefix when not on JSR', () => {
216258
const parts = getInstallCommandParts({
217259
packageName: 'lodash',
@@ -243,6 +285,46 @@ describe('install command generation', () => {
243285
})
244286
})
245287

288+
describe('dev dependency suggestion heuristic', () => {
289+
it('suggests dev dependency for known tooling packages', () => {
290+
expect(getDevDependencySuggestion('eslint')).toEqual({
291+
recommended: true,
292+
reason: 'known-package',
293+
})
294+
expect(getDevDependencySuggestion('@types/node')).toEqual({
295+
recommended: true,
296+
reason: 'known-package',
297+
})
298+
expect(getDevDependencySuggestion('@typescript-eslint/parser')).toEqual({
299+
recommended: true,
300+
reason: 'known-package',
301+
})
302+
})
303+
304+
it('suggests dev dependency from README install command hints', () => {
305+
const readmeHtml = '<p>Install with <code>npm install --save-dev some-tool</code></p>'
306+
307+
expect(getDevDependencySuggestion('some-tool', readmeHtml)).toEqual({
308+
recommended: true,
309+
reason: 'readme-hint',
310+
})
311+
})
312+
313+
it('does not suggest dev dependency for runtime packages without hints', () => {
314+
expect(getDevDependencySuggestion('react')).toEqual({
315+
recommended: false,
316+
})
317+
})
318+
})
319+
320+
describe('getDevDependencyFlag', () => {
321+
it('returns lowercase flag only for bun', () => {
322+
expect(getDevDependencyFlag('bun')).toBe('-d')
323+
expect(getDevDependencyFlag('npm')).toBe('-D')
324+
expect(getDevDependencyFlag('deno')).toBe('-D')
325+
})
326+
})
327+
246328
describe('edge cases', () => {
247329
it('handles null jsrInfo same as not available for deno', () => {
248330
expect(

0 commit comments

Comments
 (0)