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
66 changes: 65 additions & 1 deletion app/components/Terminal/Install.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
<script setup lang="ts">
import type { JsrPackageInfo } from '#shared/types/jsr'
import type { DevDependencySuggestion } from '#shared/utils/dev-dependency'
import type { PackageManagerId } from '~/utils/install-command'

const props = defineProps<{
packageName: string
requestedVersion?: string | null
installVersionOverride?: string | null
jsrInfo?: JsrPackageInfo | null
devDependencySuggestion?: DevDependencySuggestion | null
typesPackageName?: string | null
executableInfo?: { hasExecutable: boolean; primaryCommand?: string } | null
createPackageInfo?: { packageName: string } | null
Expand All @@ -30,6 +32,20 @@ function getInstallPartsForPM(pmId: PackageManagerId) {
})
}

const devDependencySuggestion = computed(
() => props.devDependencySuggestion ?? { recommended: false as const },
)

function getDevInstallPartsForPM(pmId: PackageManagerId) {
return getInstallCommandParts({
packageName: props.packageName,
packageManager: pmId,
version: props.requestedVersion,
jsrInfo: props.jsrInfo,
dev: true,
})
}

// Generate run command parts for a specific package manager
function getRunPartsForPM(pmId: PackageManagerId, command?: string) {
return getRunCommandParts({
Expand Down Expand Up @@ -68,7 +84,7 @@ function getTypesInstallPartsForPM(pmId: PackageManagerId) {
const pm = packageManagers.find(p => p.id === pmId)
if (!pm) return []

const devFlag = pmId === 'bun' ? '-d' : '-D'
const devFlag = getDevDependencyFlag(pmId)
const pkgSpec = pmId === 'deno' ? `npm:${props.typesPackageName}` : props.typesPackageName

return [pm.label, pm.action, devFlag, pkgSpec]
Expand All @@ -95,6 +111,18 @@ const copyRunCommand = (command?: string) => copyRun(getFullRunCommand(command))

const { copied: createCopied, copy: copyCreate } = useClipboard({ copiedDuring: 2000 })
const copyCreateCommand = () => copyCreate(getFullCreateCommand())

const { copied: devInstallCopied, copy: copyDevInstall } = useClipboard({ copiedDuring: 2000 })
const copyDevInstallCommand = () =>
copyDevInstall(
getInstallCommand({
packageName: props.packageName,
packageManager: selectedPM.value,
version: props.requestedVersion,
jsrInfo: props.jsrInfo,
dev: true,
}),
)
</script>

<template>
Expand Down Expand Up @@ -133,6 +161,42 @@ const copyCreateCommand = () => copyCreate(getFullCreateCommand())
</button>
</div>

<!-- Suggested dev dependency install command -->
<template v-if="devDependencySuggestion.recommended">
<div class="flex items-center gap-2 pt-1 select-none">
<span class="text-fg-subtle font-mono text-sm"
># {{ $t('package.get_started.dev_dependency_hint') }}</span
>
</div>
<div
v-for="pm in packageManagers"
:key="`install-dev-${pm.id}`"
:data-pm-cmd="pm.id"
class="flex items-center gap-2 group/devinstallcmd min-w-0"
>
<span class="text-fg-subtle font-mono text-sm select-none shrink-0">$</span>
<code class="font-mono text-sm min-w-0"
><span
v-for="(part, i) in getDevInstallPartsForPM(pm.id)"
:key="i"
:class="i === 0 ? 'text-fg' : 'text-fg-muted'"
>{{ i > 0 ? ' ' : '' }}{{ part }}</span
></code
>
<ButtonBase
type="button"
size="small"
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"
:aria-label="$t('package.get_started.copy_dev_command')"
@click.stop="copyDevInstallCommand"
>
<span aria-live="polite">{{
devInstallCopied ? $t('common.copied') : $t('common.copy')
}}</span>
</ButtonBase>
</div>
</template>

<!-- @types package install - render all PM variants when types package exists -->
<template v-if="typesPackageName && showTypesInInstall">
<div
Expand Down
2 changes: 2 additions & 0 deletions app/composables/usePackageAnalysis.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { ModuleFormat, TypesStatus, CreatePackageInfo } from '#shared/utils/package-analysis'
import type { DevDependencySuggestion } from '#shared/utils/dev-dependency'

export interface PackageAnalysisResponse {
package: string
version: string
moduleFormat: ModuleFormat
types: TypesStatus
devDependencySuggestion: DevDependencySuggestion
engines?: {
node?: string
npm?: string
Expand Down
1 change: 1 addition & 0 deletions app/pages/package/[[org]]/[name].vue
Original file line number Diff line number Diff line change
Expand Up @@ -1234,6 +1234,7 @@ const showSkeleton = shallowRef(false)
:requested-version="requestedVersion"
:install-version-override="installVersionOverride"
:jsr-info="jsrInfo"
:dev-dependency-suggestion="packageAnalysis?.devDependencySuggestion"
:types-package-name="typesPackageName"
:executable-info="executableInfo"
:create-package-info="createPackageInfo"
Expand Down
8 changes: 7 additions & 1 deletion app/utils/install-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ export interface InstallCommandOptions {
packageManager: PackageManagerId
version?: string | null
jsrInfo?: JsrPackageInfo | null
dev?: boolean
}

export function getDevDependencyFlag(packageManager: PackageManagerId): '-D' | '-d' {
return packageManager === 'bun' ? '-d' : '-D'
}

/**
Expand Down Expand Up @@ -108,8 +113,9 @@ export function getInstallCommandParts(options: InstallCommandOptions): string[]

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

return [pm.label, pm.action, `${spec}${version}`]
return [pm.label, pm.action, ...devFlag, `${spec}${version}`]
}

export interface ExecuteCommandOptions extends InstallCommandOptions {
Expand Down
2 changes: 2 additions & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@
"title": "Get started",
"pm_label": "Package manager",
"copy_command": "Copy install command",
"copy_dev_command": "Copy dev install command",
"dev_dependency_hint": "Usually installed as a dev dependency",
"view_types": "View {package}"
},
"create": {
Expand Down
2 changes: 2 additions & 0 deletions i18n/locales/pl-PL.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@
"title": "Zacznij",
"pm_label": "Menedżer pakietów",
"copy_command": "Kopiuj komendę instalacji",
"copy_dev_command": "Kopiuj komendę instalacji dla dev",
"dev_dependency_hint": "Zazwyczaj instalowane jako zależność deweloperska",
"view_types": "Zobacz {package}"
},
"create": {
Expand Down
6 changes: 6 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,12 @@
"copy_command": {
"type": "string"
},
"copy_dev_command": {
"type": "string"
},
"dev_dependency_hint": {
"type": "string"
},
"view_types": {
"type": "string"
}
Expand Down
2 changes: 2 additions & 0 deletions lunaria/files/en-GB.json
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,8 @@
"title": "Get started",
"pm_label": "Package manager",
"copy_command": "Copy install command",
"copy_dev_command": "Copy dev install command",
"dev_dependency_hint": "Usually installed as a dev dependency",
"view_types": "View {package}"
},
"create": {
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 @@ -220,6 +220,8 @@
"title": "Get started",
"pm_label": "Package manager",
"copy_command": "Copy install command",
"copy_dev_command": "Copy dev install command",
"dev_dependency_hint": "Usually installed as a dev dependency",
"view_types": "View {package}"
},
"create": {
Expand Down
2 changes: 2 additions & 0 deletions lunaria/files/pl-PL.json
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,8 @@
"title": "Zacznij",
"pm_label": "Menedżer pakietów",
"copy_command": "Kopiuj komendę instalacji",
"copy_dev_command": "Kopiuj komendę instalacji dla dev",
"dev_dependency_hint": "Zazwyczaj instalowane jako zależność deweloperska",
"view_types": "Zobacz {package}"
},
"create": {
Expand Down
15 changes: 13 additions & 2 deletions server/api/registry/analysis/[...pkg].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import {
getCreatePackageName,
hasBuiltInTypes,
} from '#shared/utils/package-analysis'
import {
getDevDependencySuggestion,
type DevDependencySuggestion,
} from '#shared/utils/dev-dependency'
import {
NPM_REGISTRY,
CACHE_MAX_AGE_ONE_DAY,
Expand All @@ -21,6 +25,10 @@ import { parseRepoUrl } from '#shared/utils/git-providers'
import { encodePackageName } from '#shared/utils/npm'
import { getLatestVersion, getLatestVersionBatch } from 'fast-npm-meta'

interface AnalysisPackageJson extends ExtendedPackageJson {
readme?: string
}

export default defineCachedEventHandler(
async event => {
// Parse package name and optional version from path
Expand All @@ -38,7 +46,7 @@ export default defineCachedEventHandler(
// Fetch package data
const encodedName = encodePackageName(packageName)
const versionSuffix = version ? `/${version}` : '/latest'
const pkg = await $fetch<ExtendedPackageJson>(
const pkg = await $fetch<AnalysisPackageJson>(
`${NPM_REGISTRY}/${encodedName}${versionSuffix}`,
)

Expand All @@ -54,10 +62,12 @@ export default defineCachedEventHandler(
const createPackage = await findAssociatedCreatePackage(packageName, pkg)

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

return {
package: packageName,
version: pkg.version ?? version ?? 'latest',
devDependencySuggestion,
...analysis,
} satisfies PackageAnalysisResponse
} catch (error: unknown) {
Expand All @@ -72,7 +82,7 @@ export default defineCachedEventHandler(
swr: true,
getKey: event => {
const pkg = getRouterParam(event, 'pkg') ?? ''
return `analysis:v1:${pkg.replace(/\/+$/, '').trim()}`
return `analysis:v2:${pkg.replace(/\/+$/, '').trim()}`
},
},
)
Expand Down Expand Up @@ -209,4 +219,5 @@ function hasSameRepositoryOwner(
export interface PackageAnalysisResponse extends PackageAnalysis {
package: string
version: string
devDependencySuggestion: DevDependencySuggestion
}
110 changes: 110 additions & 0 deletions shared/utils/dev-dependency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
export type DevDependencySuggestionReason = 'known-package' | 'readme-hint'

export interface DevDependencySuggestion {
recommended: boolean
reason?: DevDependencySuggestionReason
}

const KNOWN_DEV_DEPENDENCY_PACKAGES = new Set<string>([
'biome',
'chai',
'eslint',
'esbuild',
'husky',
'jest',
'lint-staged',
'mocha',
'oxc',
'oxfmt',
'oxlint',
'playwright',
'prettier',
'rolldown',
'rollup',
'stylelint',
'ts-jest',
'ts-node',
'tsx',
'turbo',
'typescript',
'vite',
'vitest',
'webpack',
])

const KNOWN_DEV_DEPENDENCY_PACKAGE_PREFIXES = [
'@typescript-eslint/',
'eslint-',
'prettier-',
'vite-',
'webpack-',
'babel-',
]

function isKnownDevDependencyPackage(packageName: string): boolean {
const normalized = packageName.toLowerCase()
if (normalized.startsWith('@types/')) {
return true
}
// Match scoped packages by name segment, e.g. @scope/eslint-config
const namePart = normalized.includes('/') ? normalized.split('/').pop() : normalized
if (!namePart) return false

return (
KNOWN_DEV_DEPENDENCY_PACKAGES.has(normalized) ||
KNOWN_DEV_DEPENDENCY_PACKAGES.has(namePart) ||
KNOWN_DEV_DEPENDENCY_PACKAGE_PREFIXES.some(prefix =>
prefix.startsWith('@') ? normalized.startsWith(prefix) : namePart.startsWith(prefix),
)
)
}

function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

function hasReadmeDevInstallHint(packageName: string, readmeContent?: string | null): boolean {
if (!readmeContent) return false

const escapedName = escapeRegExp(packageName)
const escapedNpmName = escapeRegExp(`npm:${packageName}`)
const packageSpec = `(?:${escapedName}|${escapedNpmName})(?:@[\\w.-]+)?`

const patterns = [
// npm install -D pkg / pnpm add --save-dev pkg
new RegExp(
String.raw`(?:npm|pnpm|yarn|bun|vlt)\s+(?:install|add|i)\s+(?:--save-dev|--dev|-d)\s+${packageSpec}`,
'i',
),
// npm install pkg --save-dev / pnpm add pkg -D
new RegExp(
String.raw`(?:npm|pnpm|yarn|bun|vlt)\s+(?:install|add|i)\s+${packageSpec}\s+(?:--save-dev|--dev|-d)`,
'i',
),
// deno add -D npm:pkg
new RegExp(String.raw`deno\s+add\s+(?:--dev|-D)\s+${packageSpec}`, 'i'),
]

return patterns.some(pattern => pattern.test(readmeContent))
}

export function getDevDependencySuggestion(
packageName: string,
readmeContent?: string | null,
): DevDependencySuggestion {
if (isKnownDevDependencyPackage(packageName)) {
return {
recommended: true,
reason: 'known-package',
}
}

if (hasReadmeDevInstallHint(packageName, readmeContent)) {
return {
recommended: true,
reason: 'readme-hint',
}
}

return { recommended: false }
}
Loading
Loading