Skip to content
Open
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
65 changes: 64 additions & 1 deletion app/components/Terminal/Install.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
<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
jsrInfo?: JsrPackageInfo | null
devDependencySuggestion?: DevDependencySuggestion | null
typesPackageName?: string | null
executableInfo?: { hasExecutable: boolean; primaryCommand?: string } | null
createPackageInfo?: { packageName: string } | null
Expand All @@ -28,6 +30,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 @@ -66,7 +82,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 @@ -93,6 +109,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 @@ -131,6 +159,41 @@ 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
>
<button
type="button"
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"
:aria-label="$t('package.get_started.copy_dev_command')"
Comment on lines +184 to +187
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove the inline focus-visible outline class from the new button.

Buttons should rely on the global focus-visible styling. Please drop focus-visible:outline-accent/70 from this new button class to keep consistency.

Proposed diff
-              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"
+              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 select-none"
Based on learnings In the npmx.dev project, ensure that focus-visible styling for button and select elements is implemented globally in app/assets/main.css with the rule: button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }. Do not apply per-element inline utility classes like focus-visible:outline-accent/70 on these elements in Vue templates or components (e.g., AccessControls.vue). Rely on the global rule for consistency and maintainability; only use inline focus-visible utilities when styling non-button/select elements or in exceptional cases outside the global scope.

@click.stop="copyDevInstallCommand"
>
<span aria-live="polite">{{
devInstallCopied ? $t('common.copied') : $t('common.copy')
}}</span>
</button>
</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 @@ -1046,6 +1046,7 @@ onKeyStroke(
:package-name="pkg.name"
:requested-version="requestedVersion"
: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 @@ -190,6 +190,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 @@ -180,6 +180,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
2 changes: 2 additions & 0 deletions lunaria/files/en-GB.json
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,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 @@ -190,6 +190,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 @@ -180,6 +180,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
9 changes: 8 additions & 1 deletion 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 Down Expand Up @@ -54,10 +58,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 +78,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 +215,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 }
}
2 changes: 2 additions & 0 deletions shared/utils/package-analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface ExtendedPackageJson {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
peerDependencies?: Record<string, string>
readme?: string
readmeFilename?: string
/** npm maintainers (returned by registry API) */
maintainers?: Array<{ name: string; email?: string }>
/** Repository info (returned by registry API) */
Expand Down
Loading
Loading