Skip to content

Commit df58238

Browse files
committed
Calculate suggestion server side
1 parent f378ff6 commit df58238

File tree

9 files changed

+187
-160
lines changed

9 files changed

+187
-160
lines changed

app/components/Terminal/Install.vue

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
<script setup lang="ts">
22
import type { JsrPackageInfo } from '#shared/types/jsr'
3+
import type { DevDependencySuggestion } from '#shared/utils/dev-dependency'
34
import type { PackageManagerId } from '~/utils/install-command'
45
56
const props = defineProps<{
67
packageName: string
78
requestedVersion?: string | null
89
jsrInfo?: JsrPackageInfo | null
9-
readmeHtml?: string | null
10+
devDependencySuggestion?: DevDependencySuggestion | null
1011
typesPackageName?: string | null
1112
executableInfo?: { hasExecutable: boolean; primaryCommand?: string } | null
1213
createPackageInfo?: { packageName: string } | null
@@ -29,8 +30,8 @@ function getInstallPartsForPM(pmId: PackageManagerId) {
2930
})
3031
}
3132
32-
const devDependencySuggestion = computed(() =>
33-
getDevDependencySuggestion(props.packageName, props.readmeHtml),
33+
const devDependencySuggestion = computed(
34+
() => props.devDependencySuggestion ?? { recommended: false as const },
3435
)
3536
3637
function getDevInstallPartsForPM(pmId: PackageManagerId) {

app/composables/usePackageAnalysis.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { ModuleFormat, TypesStatus, CreatePackageInfo } from '#shared/utils/package-analysis'
2+
import type { DevDependencySuggestion } from '#shared/utils/dev-dependency'
23

34
export interface PackageAnalysisResponse {
45
package: string
56
version: string
67
moduleFormat: ModuleFormat
78
types: TypesStatus
9+
devDependencySuggestion: DevDependencySuggestion
810
engines?: {
911
node?: string
1012
npm?: string

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1046,7 +1046,7 @@ onKeyStroke(
10461046
:package-name="pkg.name"
10471047
:requested-version="requestedVersion"
10481048
:jsr-info="jsrInfo"
1049-
:readme-html="readmeData?.html"
1049+
:dev-dependency-suggestion="packageAnalysis?.devDependencySuggestion"
10501050
:types-package-name="typesPackageName"
10511051
:executable-info="executableInfo"
10521052
:create-package-info="createPackageInfo"

app/utils/install-command.ts

Lines changed: 0 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -71,111 +71,6 @@ export interface InstallCommandOptions {
7171
dev?: boolean
7272
}
7373

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

server/api/registry/analysis/[...pkg].get.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import {
1212
getCreatePackageName,
1313
hasBuiltInTypes,
1414
} from '#shared/utils/package-analysis'
15+
import {
16+
getDevDependencySuggestion,
17+
type DevDependencySuggestion,
18+
} from '#shared/utils/dev-dependency'
1519
import {
1620
NPM_REGISTRY,
1721
CACHE_MAX_AGE_ONE_DAY,
@@ -54,10 +58,12 @@ export default defineCachedEventHandler(
5458
const createPackage = await findAssociatedCreatePackage(packageName, pkg)
5559

5660
const analysis = analyzePackage(pkg, { typesPackage, createPackage })
61+
const devDependencySuggestion = getDevDependencySuggestion(packageName, pkg.readme)
5762

5863
return {
5964
package: packageName,
6065
version: pkg.version ?? version ?? 'latest',
66+
devDependencySuggestion,
6167
...analysis,
6268
} satisfies PackageAnalysisResponse
6369
} catch (error: unknown) {
@@ -72,7 +78,7 @@ export default defineCachedEventHandler(
7278
swr: true,
7379
getKey: event => {
7480
const pkg = getRouterParam(event, 'pkg') ?? ''
75-
return `analysis:v1:${pkg.replace(/\/+$/, '').trim()}`
81+
return `analysis:v2:${pkg.replace(/\/+$/, '').trim()}`
7682
},
7783
},
7884
)
@@ -209,4 +215,5 @@ function hasSameRepositoryOwner(
209215
export interface PackageAnalysisResponse extends PackageAnalysis {
210216
package: string
211217
version: string
218+
devDependencySuggestion: DevDependencySuggestion
212219
}

shared/utils/dev-dependency.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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+
}

shared/utils/package-analysis.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export interface ExtendedPackageJson {
3434
dependencies?: Record<string, string>
3535
devDependencies?: Record<string, string>
3636
peerDependencies?: Record<string, string>
37+
readme?: string
38+
readmeFilename?: string
3739
/** npm maintainers (returned by registry API) */
3840
maintainers?: Array<{ name: string; email?: string }>
3941
/** Repository info (returned by registry API) */

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

Lines changed: 0 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
getPackageSpecifier,
66
getExecuteCommand,
77
getExecuteCommandParts,
8-
getDevDependencySuggestion,
98
getDevDependencyFlag,
109
} from '../../../../app/utils/install-command'
1110
import type { JsrPackageInfo } from '../../../../shared/types/jsr'
@@ -285,55 +284,6 @@ describe('install command generation', () => {
285284
})
286285
})
287286

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('suggests dev dependency from README --dev flag hints', () => {
314-
const readmeHtml = '<p><code>yarn add --dev some-tool</code></p>'
315-
316-
expect(getDevDependencySuggestion('some-tool', readmeHtml)).toEqual({
317-
recommended: true,
318-
reason: 'readme-hint',
319-
})
320-
})
321-
322-
it('does not suggest dev dependency for runtime packages without hints', () => {
323-
expect(getDevDependencySuggestion('react')).toEqual({
324-
recommended: false,
325-
})
326-
})
327-
328-
it('does not suggest dev dependency when README hint targets a different package', () => {
329-
const readmeHtml = '<p>Install with <code>yarn add -D bar</code></p>'
330-
331-
expect(getDevDependencySuggestion('foo', readmeHtml)).toEqual({
332-
recommended: false,
333-
})
334-
})
335-
})
336-
337287
describe('getDevDependencyFlag', () => {
338288
it('returns lowercase flag only for bun', () => {
339289
expect(getDevDependencyFlag('bun')).toBe('-d')

0 commit comments

Comments
 (0)