diff --git a/app/components/ConnectorModal.vue b/app/components/ConnectorModal.vue index 4364b29c6c..650f9313c8 100644 --- a/app/components/ConnectorModal.vue +++ b/app/components/ConnectorModal.vue @@ -6,7 +6,7 @@ const { isConnected, isConnecting, npmUser, error, hasOperations, connect, disco const tokenInput = shallowRef('') const portInput = shallowRef('31415') -const copied = shallowRef(false) +const { copied, copy } = useClipboard({ copiedDuring: 2000 }) async function handleConnect() { const port = Number.parseInt(portInput.value, 10) || 31415 @@ -26,11 +26,7 @@ function copyCommand() { if (portInput.value !== '31415') { command += ` --port ${portInput.value}` } - navigator.clipboard.writeText(command) - copied.value = true - setTimeout(() => { - copied.value = false - }, 2000) + copy(command) } const selectedPM = useSelectedPackageManager() diff --git a/app/composables/useInstallCommand.ts b/app/composables/useInstallCommand.ts index 666c7cd7cd..23094d4579 100644 --- a/app/composables/useInstallCommand.ts +++ b/app/composables/useInstallCommand.ts @@ -76,13 +76,11 @@ export function useInstallCommand( }) // Copy state - const copied = ref(false) + const { copied, copy } = useClipboard({ copiedDuring: 2000 }) async function copyInstallCommand() { if (!fullInstallCommand.value) return - await navigator.clipboard.writeText(fullInstallCommand.value) - copied.value = true - setTimeout(() => (copied.value = false), 2000) + await copy(fullInstallCommand.value) } return { diff --git a/app/composables/usePackageAnalysis.ts b/app/composables/usePackageAnalysis.ts index 43d788aff8..334e384462 100644 --- a/app/composables/usePackageAnalysis.ts +++ b/app/composables/usePackageAnalysis.ts @@ -1,4 +1,4 @@ -import type { ModuleFormat, TypesStatus } from '#shared/utils/package-analysis' +import type { ModuleFormat, TypesStatus, CreatePackageInfo } from '#shared/utils/package-analysis' export interface PackageAnalysisResponse { package: string @@ -9,6 +9,7 @@ export interface PackageAnalysisResponse { node?: string npm?: string } + createPackage?: CreatePackageInfo } /** diff --git a/app/pages/[...package].vue b/app/pages/[...package].vue index 2a9b769915..95393e9116 100644 --- a/app/pages/[...package].vue +++ b/app/pages/[...package].vue @@ -241,6 +241,132 @@ const { copyInstallCommand, } = useInstallCommand(packageName, requestedVersion, jsrInfo, typesPackageName) +// Executable detection for run command +const executableInfo = computed(() => { + if (!displayVersion.value || !pkg.value) return null + return getExecutableInfo(pkg.value.name, displayVersion.value.bin) +}) + +// Detect if package is binary-only (show only execute commands, no install) +const isBinaryOnly = computed(() => { + if (!displayVersion.value || !pkg.value) return false + return isBinaryOnlyPackage({ + name: pkg.value.name, + bin: displayVersion.value.bin, + main: displayVersion.value.main, + module: displayVersion.value.module, + exports: displayVersion.value.exports, + }) +}) + +// Detect if package uses create-* naming convention +const isCreatePkg = computed(() => { + if (!pkg.value) return false + return isCreatePackage(pkg.value.name) +}) + +// Run command parts for a specific command (local execute after install) +function getRunParts(command?: string) { + if (!pkg.value) return [] + return getRunCommandParts({ + packageName: pkg.value.name, + packageManager: selectedPM.value, + jsrInfo: jsrInfo.value, + command, + isBinaryOnly: false, // Local execute + }) +} + +// Execute command parts for binary-only packages (remote execute) +const executeCommandParts = computed(() => { + if (!pkg.value) return [] + return getExecuteCommandParts({ + packageName: pkg.value.name, + packageManager: selectedPM.value, + jsrInfo: jsrInfo.value, + isBinaryOnly: true, + isCreatePackage: isCreatePkg.value, + }) +}) + +// Full execute command string for copying +const executeCommand = computed(() => { + if (!pkg.value) return '' + return getExecuteCommand({ + packageName: pkg.value.name, + packageManager: selectedPM.value, + jsrInfo: jsrInfo.value, + isBinaryOnly: true, + isCreatePackage: isCreatePkg.value, + }) +}) + +// Copy execute command (for binary-only packages) +const { copied: executeCopied, copy: copyExecute } = useClipboard({ copiedDuring: 2000 }) +const copyExecuteCommand = () => copyExecute(executeCommand.value) + +// Get associated create-* package info (e.g., vite -> create-vite) +const createPackageInfo = computed(() => { + if (!packageAnalysis.value?.createPackage) return null + // Don't show if deprecated + if (packageAnalysis.value.createPackage.deprecated) return null + return packageAnalysis.value.createPackage +}) + +// Create command parts for associated create-* package +const createCommandParts = computed(() => { + if (!createPackageInfo.value) return [] + const pm = packageManagers.find(p => p.id === selectedPM.value) + if (!pm) return [] + + // Extract short name: create-vite -> vite + const createPkgName = createPackageInfo.value.packageName + let shortName: string + if (createPkgName.startsWith('@')) { + // @scope/create-foo -> foo + const slashIndex = createPkgName.indexOf('/') + const name = createPkgName.slice(slashIndex + 1) + shortName = name.startsWith('create-') ? name.slice('create-'.length) : name + } else { + // create-vite -> vite + shortName = createPkgName.startsWith('create-') + ? createPkgName.slice('create-'.length) + : createPkgName + } + + return [...pm.create.split(' '), shortName] +}) + +// Full create command string for copying +const createCommand = computed(() => { + return createCommandParts.value.join(' ') +}) + +// Copy create command +const { copied: createCopied, copy: copyCreate } = useClipboard({ copiedDuring: 2000 }) +const copyCreateCommand = () => copyCreate(createCommand.value) + +// Primary run command parts +const runCommandParts = computed(() => { + if (!executableInfo.value?.hasExecutable) return [] + return getRunParts(executableInfo.value.primaryCommand) +}) + +// Full run command string for copying +function getFullRunCommand(command?: string) { + if (!pkg.value) return '' + return getRunCommand({ + packageName: pkg.value.name, + packageManager: selectedPM.value, + jsrInfo: jsrInfo.value, + command, + }) +} + +// Copy run command +const { copied: runCopied, copy: copyRun } = useClipboard({ copiedDuring: 2000 }) +const copyRunCommand = (command?: string) => copyRun(getFullRunCommand(command)) + // Expandable description const descriptionExpanded = ref(false) const descriptionRef = useTemplateRef('descriptionRef') @@ -684,13 +810,96 @@ defineOgImageComponent('Package', { class="area-vulns" /> - -
+ +
+
+

Run

+ +
+ + + + +
+
+
+ +
+
+ + + +
+
+ +
+ $ + {{ i > 0 ? ' ' : '' }}{{ part }} + +
+
+
+
+
+ + +
-
- -
+
+ +
$ +
+
$ @@ -782,16 +1002,93 @@ defineOgImageComponent('Package', { View {{ typesPackageName }}
+ + + + + +
-
diff --git a/app/pages/code/[...path].vue b/app/pages/code/[...path].vue index cbf9c3b248..504b51d90c 100644 --- a/app/pages/code/[...path].vue +++ b/app/pages/code/[...path].vue @@ -228,9 +228,10 @@ function handleLineClick(lineNum: number, event: MouseEvent) { } // Copy link to current line(s) -async function copyPermalink() { +const { copied: permalinkCopied, copy: copyPermalink } = useClipboard({ copiedDuring: 2000 }) +function copyPermalinkUrl() { const url = new URL(window.location.href) - await navigator.clipboard.writeText(url.toString()) + copyPermalink(url.toString()) } // Canonical URL for this code page @@ -373,9 +374,9 @@ useSeoMeta({ v-if="selectedLines" type="button" class="px-2 py-1 font-mono text-xs text-fg-muted bg-bg-subtle border border-border rounded hover:text-fg hover:border-border-hover transition-colors" - @click="copyPermalink" + @click="copyPermalinkUrl" > - {{ $t('code.copy_link') }} + {{ permalinkCopied ? $t('common.copied') : $t('code.copy_link') }} p.id === options.packageManager) if (!pm) return [] - return [pm.execute, getPackageSpecifier(options)] + + // For create-* packages, use the shorthand create command + if (options.isCreatePackage) { + const shortName = getCreateShortName(options.packageName) + if (shortName !== options.packageName) { + return [...pm.create.split(' '), shortName] + } + } + + // Choose remote or local execute based on package type + const executeCmd = options.isBinaryOnly ? pm.executeRemote : pm.executeLocal + return [...executeCmd.split(' '), getPackageSpecifier(options)] } diff --git a/app/utils/run-command.ts b/app/utils/run-command.ts new file mode 100644 index 0000000000..d800632f2c --- /dev/null +++ b/app/utils/run-command.ts @@ -0,0 +1,153 @@ +import type { JsrPackageInfo } from '#shared/types/jsr' +import { getPackageSpecifier, packageManagers } from './install-command' +import type { PackageManagerId } from './install-command' + +/** + * Metadata needed to determine if a package is binary-only. + */ +export interface PackageMetadata { + name: string + bin?: string | Record + main?: string + module?: unknown + exports?: unknown +} + +/** + * Determine if a package is "binary-only" (executable without library entry points). + * Binary-only packages should show execute commands without install commands. + * + * A package is binary-only if: + * - Name starts with "create-" (e.g., create-vite) + * - Scoped name contains "/create-" (e.g., @vue/create-app) + * - Has bin field but no main, module, or exports fields + */ +export function isBinaryOnlyPackage(pkg: PackageMetadata): boolean { + const baseName = pkg.name.startsWith('@') ? pkg.name.split('/')[1] : pkg.name + + // Check create-* patterns + if (baseName?.startsWith('create-') || pkg.name.includes('/create-')) { + return true + } + + // Has bin but no entry points + const hasBin = + pkg.bin !== undefined && (typeof pkg.bin === 'string' || Object.keys(pkg.bin).length > 0) + const hasEntryPoint = !!pkg.main || !!pkg.module || !!pkg.exports + + return hasBin && !hasEntryPoint +} + +/** + * Check if a package uses the create-* naming convention. + */ +export function isCreatePackage(packageName: string): boolean { + const baseName = packageName.startsWith('@') ? packageName.split('/')[1] : packageName + return baseName?.startsWith('create-') || packageName.includes('/create-') || false +} + +/** + * Information about executable commands provided by a package. + */ +export interface ExecutableInfo { + /** Primary command name (typically the package name or first bin key) */ + primaryCommand: string + /** All available command names */ + commands: string[] + /** Whether this package has any executables */ + hasExecutable: boolean +} + +/** + * Extract executable command information from a package's bin field. + * Handles both string format ("bin": "./cli.js") and object format ("bin": { "cmd": "./cli.js" }). + */ +export function getExecutableInfo( + packageName: string, + bin: string | Record | undefined, +): ExecutableInfo { + if (!bin) { + return { primaryCommand: '', commands: [], hasExecutable: false } + } + + // String format: package name becomes the command + if (typeof bin === 'string') { + return { + primaryCommand: packageName, + commands: [packageName], + hasExecutable: true, + } + } + + // Object format: keys are command names + const commands = Object.keys(bin) + const firstCommand = commands[0] + if (!firstCommand) { + return { primaryCommand: '', commands: [], hasExecutable: false } + } + + // Prefer command matching package name if it exists, otherwise use first + const baseName = packageName.startsWith('@') ? packageName.split('/')[1] : packageName + const primaryCommand = baseName && commands.includes(baseName) ? baseName : firstCommand + + return { + primaryCommand, + commands, + hasExecutable: true, + } +} + +export interface RunCommandOptions { + packageName: string + packageManager: PackageManagerId + version?: string | null + jsrInfo?: JsrPackageInfo | null + /** Specific command to run (for packages with multiple bin entries) */ + command?: string + /** Whether this is a binary-only package (affects which execute command to use) */ + isBinaryOnly?: boolean +} + +/** + * Generate run command as an array of parts. + * First element is the package manager label (e.g., "pnpm"), rest are arguments. + * For example: ["pnpm", "exec", "eslint"] or ["pnpm", "dlx", "create-vite"] + */ +export function getRunCommandParts(options: RunCommandOptions): string[] { + const pm = packageManagers.find(p => p.id === options.packageManager) + if (!pm) return [] + + const spec = getPackageSpecifier(options) + + // Choose execute command based on package type + const executeCmd = options.isBinaryOnly ? pm.executeRemote : pm.executeLocal + const executeParts = executeCmd.split(' ') + + // For deno, always use the package specifier + if (options.packageManager === 'deno') { + return [...executeParts, spec] + } + + // For local execute with specific command name different from package name + // e.g., `pnpm exec tsc` for typescript package + if (options.command && options.command !== options.packageName) { + const baseName = options.packageName.startsWith('@') + ? options.packageName.split('/')[1] + : options.packageName + // If command matches base package name, use the package spec + if (options.command === baseName) { + return [...executeParts, spec] + } + // Otherwise use the command name directly + return [...executeParts, options.command] + } + + return [...executeParts, spec] +} + +/** + * Generate the full run command for a package. + */ +export function getRunCommand(options: RunCommandOptions): string { + return getRunCommandParts(options).join(' ') +} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 799aed2866..972908cd01 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -113,6 +113,14 @@ "copy_command": "Copy install command", "view_types": "View {package}" }, + "create": { + "title": "Create new project", + "copy_command": "Copy create command" + }, + "run": { + "title": "Run", + "locally": "Run locally" + }, "readme": { "title": "Readme", "no_readme": "No README available.", diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index 58df893f4f..5361ab4b1e 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -112,6 +112,14 @@ "copy_command": "复制安装命令", "view_types": "查看 {package}" }, + "create": { + "title": "创建新项目", + "copy_command": "复制创建命令" + }, + "run": { + "title": "运行", + "locally": "本地运行" + }, "readme": { "title": "Readme", "no_readme": "没有可用的 README。", diff --git a/server/api/registry/analysis/[...pkg].get.ts b/server/api/registry/analysis/[...pkg].get.ts index 51ad5cc785..4b9c88d0c8 100644 --- a/server/api/registry/analysis/[...pkg].get.ts +++ b/server/api/registry/analysis/[...pkg].get.ts @@ -4,10 +4,12 @@ import type { PackageAnalysis, ExtendedPackageJson, TypesPackageInfo, + CreatePackageInfo, } from '#shared/utils/package-analysis' import { analyzePackage, getTypesPackageName, + getCreatePackageName, hasBuiltInTypes, } from '#shared/utils/package-analysis' import { @@ -15,6 +17,7 @@ import { CACHE_MAX_AGE_ONE_DAY, ERROR_PACKAGE_ANALYSIS_FAILED, } from '#shared/utils/constants' +import { parseRepoUrl } from '#shared/utils/git-providers' /** Minimal packument data needed to check deprecation status */ interface MinimalPackument { @@ -51,7 +54,11 @@ export default defineCachedEventHandler( typesPackage = await fetchTypesPackageInfo(typesPkgName) } - const analysis = analyzePackage(pkg, { typesPackage }) + // Check for associated create-* package (e.g., vite -> create-vite, next -> create-next-app) + // Only show if the packages are actually associated (same maintainers or same org) + const createPackage = await findAssociatedCreatePackage(packageName, pkg) + + const analysis = analyzePackage(pkg, { typesPackage, createPackage }) return { package: packageName, @@ -110,6 +117,100 @@ async function fetchTypesPackageInfo(packageName: string): Promise + repository?: { url?: string } + deprecated?: string +} + +/** + * Get all possible create-* package name patterns for a given package. + * e.g., "next" -> ["create-next", "create-next-app"] + * e.g., "@scope/foo" -> ["@scope/create-foo", "@scope/create-foo-app"] + */ +function getCreatePackageNameCandidates(packageName: string): string[] { + const baseName = getCreatePackageName(packageName) + return [baseName, `${baseName}-app`] +} + +/** + * Find an associated create-* package by trying multiple naming patterns in parallel. + * Returns the first associated package found (preferring create-{name} over create-{name}-app). + */ +async function findAssociatedCreatePackage( + packageName: string, + basePkg: ExtendedPackageJson, +): Promise { + const candidates = getCreatePackageNameCandidates(packageName) + const results = await Promise.all(candidates.map(name => fetchCreatePackageInfo(name, basePkg))) + return results.find(r => r !== undefined) +} + +/** + * Fetch create-* package info including deprecation status. + * Validates that the create-* package is actually associated with the base package. + * Returns undefined if the package doesn't exist or isn't associated. + */ +async function fetchCreatePackageInfo( + createPkgName: string, + basePkg: ExtendedPackageJson, +): Promise { + try { + const encodedName = encodePackageName(createPkgName) + // Fetch /latest to get maintainers and repository for association validation + const createPkg = await $fetch(`${NPM_REGISTRY}/${encodedName}/latest`) + + // Validate that the packages are actually associated + if (!isAssociatedPackage(basePkg, createPkg)) { + return undefined + } + + return { + packageName: createPkgName, + deprecated: createPkg.deprecated, + } + } catch { + return undefined + } +} + +/** + * Check if two packages are associated (share maintainers or same repo owner). + */ +function isAssociatedPackage( + basePkg: { maintainers?: Array<{ name: string }>; repository?: { url?: string } }, + createPkg: { maintainers?: Array<{ name: string }>; repository?: { url?: string } }, +): boolean { + const baseMaintainers = new Set(basePkg.maintainers?.map(m => m.name.toLowerCase()) ?? []) + const createMaintainers = createPkg.maintainers?.map(m => m.name.toLowerCase()) ?? [] + const hasSharedMaintainer = createMaintainers.some(name => baseMaintainers.has(name)) + + return ( + hasSharedMaintainer || + hasSameRepositoryOwner(basePkg.repository?.url, createPkg.repository?.url) + ) +} + +/** + * Check if two repository URLs have the same owner (works with any git provider). + */ +function hasSameRepositoryOwner( + baseRepoUrl: string | undefined, + createRepoUrl: string | undefined, +): boolean { + if (!baseRepoUrl || !createRepoUrl) return false + + const baseRef = parseRepoUrl(baseRepoUrl) + const createRef = parseRepoUrl(createRepoUrl) + + if (!baseRef || !createRef) return false + if (baseRef.provider !== createRef.provider) return false + if (baseRef.host && createRef.host && baseRef.host !== createRef.host) return false + + return baseRef.owner.toLowerCase() === createRef.owner.toLowerCase() +} + export interface PackageAnalysisResponse extends PackageAnalysis { package: string version: string diff --git a/shared/utils/package-analysis.ts b/shared/utils/package-analysis.ts index d9994f0182..f4a767220f 100644 --- a/shared/utils/package-analysis.ts +++ b/shared/utils/package-analysis.ts @@ -16,6 +16,8 @@ export interface PackageAnalysis { node?: string npm?: string } + /** Associated create-* package if it exists */ + createPackage?: CreatePackageInfo } /** @@ -35,6 +37,10 @@ export interface ExtendedPackageJson { dependencies?: Record devDependencies?: Record peerDependencies?: Record + /** npm maintainers (returned by registry API) */ + maintainers?: Array<{ name: string; email?: string }> + /** Repository info (returned by registry API) */ + repository?: { url?: string; type?: string; directory?: string } } export type PackageExports = string | null | { [key: string]: PackageExports } | PackageExports[] @@ -169,14 +175,51 @@ function mergeExportsAnalysis(target: ExportsAnalysis, source: ExportsAnalysis): target.hasTypes = target.hasTypes || source.hasTypes } -/** - * Options for @types package info - */ -export interface TypesPackageInfo { +/** Info about a related package (@types or create-*) */ +export interface RelatedPackageInfo { packageName: string deprecated?: string } +export type TypesPackageInfo = RelatedPackageInfo +export type CreatePackageInfo = RelatedPackageInfo + +/** + * Get the create-* package name for a given package. + * e.g., "vite" -> "create-vite", "@scope/foo" -> "@scope/create-foo" + */ +export function getCreatePackageName(packageName: string): string { + if (packageName.startsWith('@')) { + // Scoped package: @scope/name -> @scope/create-name + const slashIndex = packageName.indexOf('/') + const scope = packageName.slice(0, slashIndex) + const name = packageName.slice(slashIndex + 1) + return `${scope}/create-${name}` + } + return `create-${packageName}` +} + +/** + * Extract the short name from a create-* package for display. + * e.g., "create-vite" -> "vite", "@scope/create-foo" -> "foo" + */ +export function getCreateShortName(createPackageName: string): string { + if (createPackageName.startsWith('@')) { + // @scope/create-foo -> foo + const slashIndex = createPackageName.indexOf('/') + const name = createPackageName.slice(slashIndex + 1) + if (name.startsWith('create-')) { + return name.slice('create-'.length) + } + return name + } + // create-vite -> vite + if (createPackageName.startsWith('create-')) { + return createPackageName.slice('create-'.length) + } + return createPackageName +} + /** * Detect TypeScript types status for a package */ @@ -246,6 +289,7 @@ export function getTypesPackageName(packageName: string): string { */ export interface AnalyzePackageOptions { typesPackage?: TypesPackageInfo + createPackage?: CreatePackageInfo } /** @@ -268,5 +312,6 @@ export function analyzePackage( npm: pkg.engines.npm, } : undefined, + createPackage: options?.createPackage, } } diff --git a/test/nuxt/composables/use-install-command.spec.ts b/test/nuxt/composables/use-install-command.spec.ts index bb091db6e7..0b2e81b58e 100644 --- a/test/nuxt/composables/use-install-command.spec.ts +++ b/test/nuxt/composables/use-install-command.spec.ts @@ -261,8 +261,7 @@ describe('useInstallCommand', () => { describe('copyInstallCommand', () => { it('should copy command to clipboard and set copied state', async () => { - const writeText = vi.fn().mockResolvedValue(undefined) - vi.stubGlobal('navigator', { clipboard: { writeText } }) + vi.useFakeTimers() const { copyInstallCommand, copied, fullInstallCommand } = useInstallCommand( 'vue', @@ -271,26 +270,28 @@ describe('useInstallCommand', () => { null, ) + expect(fullInstallCommand.value).toBe('npm install vue') expect(copied.value).toBe(false) + await copyInstallCommand() - expect(writeText).toHaveBeenCalledWith(fullInstallCommand.value) + // useClipboard sets copied to true after successful copy expect(copied.value).toBe(true) - // Wait for the timeout to reset copied - await new Promise(resolve => setTimeout(resolve, 2100)) + // Advance timers to reset copied (copiedDuring: 2000) + await vi.advanceTimersByTimeAsync(2100) expect(copied.value).toBe(false) + + vi.useRealTimers() }) it('should not copy when command is empty', async () => { - const writeText = vi.fn().mockResolvedValue(undefined) - vi.stubGlobal('navigator', { clipboard: { writeText } }) - const { copyInstallCommand, copied } = useInstallCommand(null, null, null, null) + expect(copied.value).toBe(false) await copyInstallCommand() - expect(writeText).not.toHaveBeenCalled() + // Should remain false since there was nothing to copy expect(copied.value).toBe(false) }) }) diff --git a/test/unit/install-command.spec.ts b/test/unit/install-command.spec.ts index ef56e83f02..e3c860a091 100644 --- a/test/unit/install-command.spec.ts +++ b/test/unit/install-command.spec.ts @@ -287,43 +287,106 @@ describe('install command generation', () => { }) }) - describe('getExecuteCommand', () => { - it('returns correct execute command for npm', () => { - const command = getExecuteCommand({ - packageName: 'esbuild', - packageManager: 'npm', - jsrInfo: jsrNotAvailable, + describe('getExecuteCommandParts', () => { + describe('local execute (isBinaryOnly: false)', () => { + it.each([ + ['npm', ['npx', 'eslint']], + ['pnpm', ['pnpm', 'exec', 'eslint']], + ['yarn', ['yarn', 'eslint']], + ['bun', ['bunx', 'eslint']], + ['deno', ['deno', 'run', 'npm:eslint']], + ['vlt', ['vlt', 'x', 'eslint']], + ] as const)('%s → %s', (pm, expected) => { + expect( + getExecuteCommandParts({ + packageName: 'eslint', + packageManager: pm, + isBinaryOnly: false, + }), + ).toEqual(expected) }) - expect(command).toBe('npx esbuild') }) - it('returns package manager specific execute command', () => { - const command = getExecuteCommand({ - packageName: 'esbuild', - packageManager: 'pnpm', - jsrInfo: jsrNotAvailable, + describe('remote execute (isBinaryOnly: true)', () => { + it.each([ + ['npm', ['npx', 'degit']], + ['pnpm', ['pnpm', 'dlx', 'degit']], + ['yarn', ['yarn', 'dlx', 'degit']], + ['bun', ['bunx', 'degit']], + ['deno', ['deno', 'run', 'npm:degit']], + ['vlt', ['vlt', 'x', 'degit']], + ] as const)('%s → %s', (pm, expected) => { + expect( + getExecuteCommandParts({ + packageName: 'degit', + packageManager: pm, + isBinaryOnly: true, + }), + ).toEqual(expected) }) - expect(command).toBe('pnpm dlx esbuild') }) - }) - describe('getExecuteCommandParts', () => { - it('returns correct parts for npm', () => { - const parts = getExecuteCommandParts({ - packageName: 'esbuild', - packageManager: 'npm', - jsrInfo: jsrNotAvailable, + describe('create-* packages (isCreatePackage: true)', () => { + it.each([ + ['npm', ['npm', 'create', 'vite']], + ['pnpm', ['pnpm', 'create', 'vite']], + ['yarn', ['yarn', 'create', 'vite']], + ['bun', ['bun', 'create', 'vite']], + ['deno', ['deno', 'run', 'vite']], + ['vlt', ['vlt', 'x', 'vite']], + ] as const)('%s → %s', (pm, expected) => { + expect( + getExecuteCommandParts({ + packageName: 'create-vite', + packageManager: pm, + isCreatePackage: true, + }), + ).toEqual(expected) }) - expect(parts).toEqual(['npx', 'esbuild']) }) - it('returns empty for unknown package manager', () => { - const parts = getExecuteCommandParts({ - packageName: 'esbuild', - packageManager: 'unknown' as never, - jsrInfo: jsrNotAvailable, + describe('scoped create-* packages', () => { + it('handles @scope/create-app pattern', () => { + expect( + getExecuteCommandParts({ + packageName: '@vue/create-app', + packageManager: 'npm', + isCreatePackage: true, + }), + ).toEqual(['npm', 'create', 'app']) }) - expect(parts).toEqual([]) + }) + }) + + describe('getExecuteCommand', () => { + it('generates full execute command string for local execute', () => { + expect( + getExecuteCommand({ + packageName: 'eslint', + packageManager: 'pnpm', + isBinaryOnly: false, + }), + ).toBe('pnpm exec eslint') + }) + + it('generates full execute command string for remote execute', () => { + expect( + getExecuteCommand({ + packageName: 'degit', + packageManager: 'pnpm', + isBinaryOnly: true, + }), + ).toBe('pnpm dlx degit') + }) + + it('generates create command for create-* packages', () => { + expect( + getExecuteCommand({ + packageName: 'create-vite', + packageManager: 'pnpm', + isCreatePackage: true, + }), + ).toBe('pnpm create vite') }) }) }) diff --git a/test/unit/package-analysis.spec.ts b/test/unit/package-analysis.spec.ts index 10303b2c63..18109382ac 100644 --- a/test/unit/package-analysis.spec.ts +++ b/test/unit/package-analysis.spec.ts @@ -3,6 +3,8 @@ import { analyzePackage, detectModuleFormat, detectTypesStatus, + getCreatePackageName, + getCreateShortName, getTypesPackageName, hasBuiltInTypes, } from '../../shared/utils/package-analysis' @@ -244,4 +246,65 @@ describe('analyzePackage', () => { deprecated: 'Use included types instead', }) }) + + it('includes createPackage when provided', () => { + const result = analyzePackage( + { name: 'vite', main: 'index.js' }, + { createPackage: { packageName: 'create-vite' } }, + ) + + expect(result.createPackage).toEqual({ packageName: 'create-vite' }) + }) + + it('includes deprecation info for createPackage', () => { + const result = analyzePackage( + { name: 'foo', main: 'index.js' }, + { createPackage: { packageName: 'create-foo', deprecated: 'Use different tool' } }, + ) + + expect(result.createPackage).toEqual({ + packageName: 'create-foo', + deprecated: 'Use different tool', + }) + }) +}) + +describe('getCreatePackageName', () => { + it('handles unscoped package', () => { + expect(getCreatePackageName('vite')).toBe('create-vite') + }) + + it('handles scoped package', () => { + expect(getCreatePackageName('@nuxt/app')).toBe('@nuxt/create-app') + }) + + it('handles single-word package', () => { + expect(getCreatePackageName('next')).toBe('create-next') + }) + + it('handles hyphenated package', () => { + expect(getCreatePackageName('solid-js')).toBe('create-solid-js') + }) +}) + +describe('getCreateShortName', () => { + it('extracts name from unscoped create-* package', () => { + expect(getCreateShortName('create-vite')).toBe('vite') + }) + + it('extracts name from scoped create-* package', () => { + expect(getCreateShortName('@vue/create-app')).toBe('app') + }) + + it('returns full name if not a create-* package', () => { + expect(getCreateShortName('vite')).toBe('vite') + }) + + it('handles scoped package without create- prefix', () => { + expect(getCreateShortName('@scope/foo')).toBe('foo') + }) + + it('extracts name from create-next-app style packages', () => { + expect(getCreateShortName('create-next-app')).toBe('next-app') + }) }) diff --git a/test/unit/run-command.spec.ts b/test/unit/run-command.spec.ts new file mode 100644 index 0000000000..2a5b61a340 --- /dev/null +++ b/test/unit/run-command.spec.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from 'vitest' +import { + getExecutableInfo, + getRunCommand, + getRunCommandParts, + isBinaryOnlyPackage, + isCreatePackage, +} from '../../app/utils/run-command' +import type { JsrPackageInfo } from '../../shared/types/jsr' + +describe('executable detection and run commands', () => { + const jsrNotAvailable: JsrPackageInfo = { exists: false } + + describe('getExecutableInfo', () => { + it('returns hasExecutable: false for undefined bin', () => { + const info = getExecutableInfo('some-package', undefined) + expect(info).toEqual({ + primaryCommand: '', + commands: [], + hasExecutable: false, + }) + }) + + it('handles string bin format (package name becomes command)', () => { + const info = getExecutableInfo('eslint', './bin/eslint.js') + expect(info).toEqual({ + primaryCommand: 'eslint', + commands: ['eslint'], + hasExecutable: true, + }) + }) + + it('handles object bin format with single command', () => { + const info = getExecutableInfo('cowsay', { cowsay: './index.js' }) + expect(info).toEqual({ + primaryCommand: 'cowsay', + commands: ['cowsay'], + hasExecutable: true, + }) + }) + + it('handles object bin format with multiple commands', () => { + const info = getExecutableInfo('typescript', { + tsc: './bin/tsc', + tsserver: './bin/tsserver', + }) + expect(info).toEqual({ + primaryCommand: 'tsc', + commands: ['tsc', 'tsserver'], + hasExecutable: true, + }) + }) + + it('prefers command matching package name as primary', () => { + const info = getExecutableInfo('eslint', { + 'eslint-cli': './cli.js', + 'eslint': './index.js', + }) + expect(info.primaryCommand).toBe('eslint') + }) + + it('prefers command matching base name for scoped packages', () => { + const info = getExecutableInfo('@scope/myapp', { + 'myapp': './index.js', + 'myapp-extra': './extra.js', + }) + expect(info.primaryCommand).toBe('myapp') + }) + + it('returns empty for empty bin object', () => { + const info = getExecutableInfo('some-package', {}) + expect(info).toEqual({ + primaryCommand: '', + commands: [], + hasExecutable: false, + }) + }) + }) + + describe('getRunCommandParts', () => { + // Default behavior uses local execute (for installed packages) + it.each([ + ['npm', ['npx', 'eslint']], + ['pnpm', ['pnpm', 'exec', 'eslint']], + ['yarn', ['yarn', 'eslint']], + ['bun', ['bunx', 'eslint']], + ['deno', ['deno', 'run', 'npm:eslint']], + ['vlt', ['vlt', 'x', 'eslint']], + ] as const)('%s (local) → %s', (pm, expected) => { + expect( + getRunCommandParts({ + packageName: 'eslint', + packageManager: pm, + jsrInfo: jsrNotAvailable, + }), + ).toEqual(expected) + }) + + // Binary-only packages use remote execute (download & run) + it.each([ + ['npm', ['npx', 'create-vite']], + ['pnpm', ['pnpm', 'dlx', 'create-vite']], + ['yarn', ['yarn', 'dlx', 'create-vite']], + ['bun', ['bunx', 'create-vite']], + ['deno', ['deno', 'run', 'npm:create-vite']], + ['vlt', ['vlt', 'x', 'create-vite']], + ] as const)('%s (remote) → %s', (pm, expected) => { + expect( + getRunCommandParts({ + packageName: 'create-vite', + packageManager: pm, + jsrInfo: jsrNotAvailable, + isBinaryOnly: true, + }), + ).toEqual(expected) + }) + + it('uses command name directly for multi-bin packages', () => { + const parts = getRunCommandParts({ + packageName: 'typescript', + packageManager: 'npm', + command: 'tsserver', + jsrInfo: jsrNotAvailable, + }) + // npx tsserver runs the tsserver command (not npx typescript/tsserver) + expect(parts).toEqual(['npx', 'tsserver']) + }) + + it('uses base name directly when command matches package base name', () => { + const parts = getRunCommandParts({ + packageName: '@scope/myapp', + packageManager: 'npm', + command: 'myapp', + jsrInfo: jsrNotAvailable, + }) + expect(parts).toEqual(['npx', '@scope/myapp']) + }) + + it('returns empty array for invalid package manager', () => { + const parts = getRunCommandParts({ + packageName: 'eslint', + packageManager: 'invalid' as any, + jsrInfo: jsrNotAvailable, + }) + expect(parts).toEqual([]) + }) + }) + + describe('getRunCommand', () => { + it('generates full run command string', () => { + expect( + getRunCommand({ + packageName: 'eslint', + packageManager: 'npm', + jsrInfo: jsrNotAvailable, + }), + ).toBe('npx eslint') + }) + + it('generates correct bun run command with specific command', () => { + expect( + getRunCommand({ + packageName: 'typescript', + packageManager: 'bun', + command: 'tsserver', + jsrInfo: jsrNotAvailable, + }), + ).toBe('bunx tsserver') + }) + + it('joined parts match getRunCommand output', () => { + const options = { + packageName: 'eslint', + packageManager: 'pnpm' as const, + jsrInfo: jsrNotAvailable, + } + const parts = getRunCommandParts(options) + const command = getRunCommand(options) + expect(parts.join(' ')).toBe(command) + }) + }) + + describe('isBinaryOnlyPackage', () => { + it('returns true for create-* packages', () => { + expect(isBinaryOnlyPackage({ name: 'create-vite' })).toBe(true) + expect(isBinaryOnlyPackage({ name: 'create-next-app' })).toBe(true) + }) + + it('returns true for scoped create packages', () => { + expect(isBinaryOnlyPackage({ name: '@vue/create-app' })).toBe(true) + expect(isBinaryOnlyPackage({ name: '@scope/create-something' })).toBe(true) + }) + + it('returns true for packages with bin but no entry points', () => { + expect( + isBinaryOnlyPackage({ + name: 'degit', + bin: { degit: './bin.js' }, + }), + ).toBe(true) + }) + + it('returns false for packages with bin AND entry points', () => { + expect( + isBinaryOnlyPackage({ + name: 'eslint', + bin: { eslint: './bin/eslint.js' }, + main: './lib/api.js', + }), + ).toBe(false) + }) + + it('returns false for packages with exports', () => { + expect( + isBinaryOnlyPackage({ + name: 'some-package', + bin: { cmd: './bin.js' }, + exports: { '.': './index.js' }, + }), + ).toBe(false) + }) + + it('returns false for packages without bin', () => { + expect( + isBinaryOnlyPackage({ + name: 'lodash', + main: './lodash.js', + }), + ).toBe(false) + }) + }) + + describe('isCreatePackage', () => { + it('returns true for create-* packages', () => { + expect(isCreatePackage('create-vite')).toBe(true) + expect(isCreatePackage('create-next-app')).toBe(true) + }) + + it('returns true for scoped create packages', () => { + expect(isCreatePackage('@vue/create-app')).toBe(true) + }) + + it('returns false for regular packages', () => { + expect(isCreatePackage('eslint')).toBe(false) + expect(isCreatePackage('lodash')).toBe(false) + expect(isCreatePackage('@scope/utils')).toBe(false) + }) + }) +}) diff --git a/tests/create-command.spec.ts b/tests/create-command.spec.ts new file mode 100644 index 0000000000..001dfa03f1 --- /dev/null +++ b/tests/create-command.spec.ts @@ -0,0 +1,178 @@ +import { expect, test } from '@nuxt/test-utils/playwright' + +test.describe('Create Command', () => { + test.describe('Visibility', () => { + test('/vite - should show create command (same maintainers)', async ({ page, goto }) => { + await goto('/vite', { waitUntil: 'domcontentloaded' }) + + // Create command section should be visible (SSR) + // Use specific container to avoid matching README code blocks + const createCommandSection = page.locator('.group\\/createcmd') + await expect(createCommandSection).toBeVisible() + await expect(createCommandSection.locator('code')).toContainText(/create vite/i) + + // Link to create-vite should be present (uses sr-only text, so check attachment not visibility) + await expect(page.locator('a[href="/create-vite"]')).toBeAttached() + }) + + test('/next - should show create command (shared maintainer, same repo)', async ({ + page, + goto, + }) => { + await goto('/next', { waitUntil: 'domcontentloaded' }) + + // Create command section should be visible (SSR) + // Use specific container to avoid matching README code blocks + const createCommandSection = page.locator('.group\\/createcmd') + await expect(createCommandSection).toBeVisible() + await expect(createCommandSection.locator('code')).toContainText(/create next-app/i) + + // Link to create-next-app should be present (uses sr-only text, so check attachment not visibility) + await expect(page.locator('a[href="/create-next-app"]')).toBeAttached() + }) + + test('/nuxt - should show create command (same maintainer, same org)', async ({ + page, + goto, + }) => { + await goto('/nuxt', { waitUntil: 'domcontentloaded' }) + + // Create command section should be visible (SSR) + // nuxt has create-nuxt package, so command is "npm create nuxt" + // Use specific container to avoid matching README code blocks + const createCommandSection = page.locator('.group\\/createcmd') + await expect(createCommandSection).toBeVisible() + await expect(createCommandSection.locator('code')).toContainText(/create nuxt/i) + }) + + test('/color - should NOT show create command (different maintainers)', async ({ + page, + goto, + }) => { + await goto('/color', { waitUntil: 'domcontentloaded' }) + + // Wait for package to load + await expect(page.locator('h1').filter({ hasText: 'color' })).toBeVisible() + + // Create command section should NOT be visible (different maintainers) + const createCommandSection = page.locator('.group\\/createcmd') + await expect(createCommandSection).not.toBeVisible() + }) + + test('/lodash - should NOT show create command (no create-lodash exists)', async ({ + page, + goto, + }) => { + await goto('/lodash', { waitUntil: 'domcontentloaded' }) + + // Wait for package to load + await expect(page.locator('h1').filter({ hasText: 'lodash' })).toBeVisible() + + // Create command section should NOT be visible (no create-lodash exists) + const createCommandSection = page.locator('.group\\/createcmd') + await expect(createCommandSection).not.toBeVisible() + }) + }) + + test.describe('Copy Functionality', () => { + test('hovering create command shows copy button', async ({ page, goto }) => { + await goto('/vite', { waitUntil: 'hydration' }) + + // Wait for package analysis API to load (create command requires this) + // First ensure the package page has loaded + await expect(page.locator('h1')).toContainText('vite') + + // Find the create command container (wait longer for API response) + const createCommandContainer = page.locator('.group\\/createcmd') + await expect(createCommandContainer).toBeVisible({ timeout: 15000 }) + + // Copy button should initially be hidden (opacity-0) + const copyButton = createCommandContainer.locator('button') + await expect(copyButton).toHaveCSS('opacity', '0') + + // Hover over the container + await createCommandContainer.hover() + + // Copy button should become visible + await expect(copyButton).toHaveCSS('opacity', '1') + }) + + test('clicking copy button copies create command and shows confirmation', async ({ + page, + goto, + context, + }) => { + // Grant clipboard permissions + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + + await goto('/vite', { waitUntil: 'hydration' }) + + // Find and hover over the create command container + const createCommandContainer = page.locator('.group\\/createcmd') + await createCommandContainer.hover() + + // Click the copy button + const copyButton = createCommandContainer.locator('button') + await copyButton.click() + + // Button text should change to "copied!" + await expect(copyButton).toContainText(/copied/i) + + // Verify clipboard content contains the create command + const clipboardContent = await page.evaluate(() => navigator.clipboard.readText()) + expect(clipboardContent).toMatch(/create vite/i) + + await expect(copyButton).toContainText(/copy/i, { timeout: 5000 }) + await expect(copyButton).not.toContainText(/copied/i) + }) + }) + + test.describe('Install Command Copy', () => { + test('hovering install command shows copy button', async ({ page, goto }) => { + await goto('/lodash', { waitUntil: 'hydration' }) + + // Find the install command container + const installCommandContainer = page.locator('.group\\/installcmd') + await expect(installCommandContainer).toBeVisible() + + // Copy button should initially be hidden + const copyButton = installCommandContainer.locator('button') + await expect(copyButton).toHaveCSS('opacity', '0') + + // Hover over the container + await installCommandContainer.hover() + + // Copy button should become visible + await expect(copyButton).toHaveCSS('opacity', '1') + }) + + test('clicking copy button copies install command and shows confirmation', async ({ + page, + goto, + context, + }) => { + // Grant clipboard permissions + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + + await goto('/lodash', { waitUntil: 'hydration' }) + + // Find and hover over the install command container + const installCommandContainer = page.locator('.group\\/installcmd') + await installCommandContainer.hover() + + // Click the copy button + const copyButton = installCommandContainer.locator('button') + await copyButton.click() + + // Button text should change to "copied!" + await expect(copyButton).toContainText(/copied/i) + + // Verify clipboard content contains the install command + const clipboardContent = await page.evaluate(() => navigator.clipboard.readText()) + expect(clipboardContent).toMatch(/install lodash|add lodash/i) + + await expect(copyButton).toContainText(/copy/i, { timeout: 5000 }) + await expect(copyButton).not.toContainText(/copied/i) + }) + }) +}) diff --git a/tests/docs.spec.ts b/tests/docs.spec.ts index 96c4a0aed8..1ef017f463 100644 --- a/tests/docs.spec.ts +++ b/tests/docs.spec.ts @@ -105,16 +105,11 @@ test.describe('API Documentation Pages', () => { test.describe('Version Selector', () => { test('version selector dropdown shows versions', async ({ page, goto }) => { - await goto('/docs/ufo/v/1.6.3', { waitUntil: 'networkidle' }) + await goto('/docs/ufo/v/1.6.3', { waitUntil: 'hydration' }) - // Find and click the version selector button + // Find and click the version selector button (wait for it to be visible) const versionButton = page.locator('header button').filter({ hasText: '1.6.3' }) - - // Skip if version selector not present (data might not be loaded) - if (!(await versionButton.isVisible())) { - test.skip() - return - } + await expect(versionButton).toBeVisible({ timeout: 10000 }) await versionButton.click() @@ -128,16 +123,11 @@ test.describe('Version Selector', () => { }) test('selecting a version navigates to that version', async ({ page, goto }) => { - await goto('/docs/ufo/v/1.6.3', { waitUntil: 'networkidle' }) + await goto('/docs/ufo/v/1.6.3', { waitUntil: 'hydration' }) - // Find and click the version selector button + // Find and click the version selector button (wait for it to be visible) const versionButton = page.locator('header button').filter({ hasText: '1.6.3' }) - - // Skip if version selector not present - if (!(await versionButton.isVisible())) { - test.skip() - return - } + await expect(versionButton).toBeVisible({ timeout: 10000 }) await versionButton.click() @@ -167,14 +157,11 @@ test.describe('Version Selector', () => { }) test('escape key closes version dropdown', async ({ page, goto }) => { - await goto('/docs/ufo/v/1.6.3', { waitUntil: 'networkidle' }) + await goto('/docs/ufo/v/1.6.3', { waitUntil: 'hydration' }) + // Wait for version button to be visible const versionButton = page.locator('header button').filter({ hasText: '1.6.3' }) - - if (!(await versionButton.isVisible())) { - test.skip() - return - } + await expect(versionButton).toBeVisible({ timeout: 10000 }) await versionButton.click()