Skip to content

Commit 3c026bc

Browse files
vinnymacdanielroe
andauthored
feat: add binary run scripts to package page (#209)
Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent 34e9077 commit 3c026bc

17 files changed

Lines changed: 1294 additions & 102 deletions

File tree

app/components/ConnectorModal.vue

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const { isConnected, isConnecting, npmUser, error, hasOperations, connect, disco
66
77
const tokenInput = shallowRef('')
88
const portInput = shallowRef('31415')
9-
const copied = shallowRef(false)
9+
const { copied, copy } = useClipboard({ copiedDuring: 2000 })
1010
1111
async function handleConnect() {
1212
const port = Number.parseInt(portInput.value, 10) || 31415
@@ -26,11 +26,7 @@ function copyCommand() {
2626
if (portInput.value !== '31415') {
2727
command += ` --port ${portInput.value}`
2828
}
29-
navigator.clipboard.writeText(command)
30-
copied.value = true
31-
setTimeout(() => {
32-
copied.value = false
33-
}, 2000)
29+
copy(command)
3430
}
3531
3632
const selectedPM = useSelectedPackageManager()

app/composables/useInstallCommand.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,11 @@ export function useInstallCommand(
7676
})
7777

7878
// Copy state
79-
const copied = ref(false)
79+
const { copied, copy } = useClipboard({ copiedDuring: 2000 })
8080

8181
async function copyInstallCommand() {
8282
if (!fullInstallCommand.value) return
83-
await navigator.clipboard.writeText(fullInstallCommand.value)
84-
copied.value = true
85-
setTimeout(() => (copied.value = false), 2000)
83+
await copy(fullInstallCommand.value)
8684
}
8785

8886
return {

app/composables/usePackageAnalysis.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ModuleFormat, TypesStatus } from '#shared/utils/package-analysis'
1+
import type { ModuleFormat, TypesStatus, CreatePackageInfo } from '#shared/utils/package-analysis'
22

33
export interface PackageAnalysisResponse {
44
package: string
@@ -9,6 +9,7 @@ export interface PackageAnalysisResponse {
99
node?: string
1010
npm?: string
1111
}
12+
createPackage?: CreatePackageInfo
1213
}
1314

1415
/**

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

Lines changed: 312 additions & 15 deletions
Large diffs are not rendered by default.

app/pages/code/[...path].vue

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,9 +228,10 @@ function handleLineClick(lineNum: number, event: MouseEvent) {
228228
}
229229
230230
// Copy link to current line(s)
231-
async function copyPermalink() {
231+
const { copied: permalinkCopied, copy: copyPermalink } = useClipboard({ copiedDuring: 2000 })
232+
function copyPermalinkUrl() {
232233
const url = new URL(window.location.href)
233-
await navigator.clipboard.writeText(url.toString())
234+
copyPermalink(url.toString())
234235
}
235236
236237
// Canonical URL for this code page
@@ -373,9 +374,9 @@ useSeoMeta({
373374
v-if="selectedLines"
374375
type="button"
375376
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"
376-
@click="copyPermalink"
377+
@click="copyPermalinkUrl"
377378
>
378-
{{ $t('code.copy_link') }}
379+
{{ permalinkCopied ? $t('common.copied') : $t('code.copy_link') }}
379380
</button>
380381
<a
381382
:href="`https://cdn.jsdelivr.net/npm/${packageName}@${version}/${filePath}`"

app/utils/install-command.ts

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,62 @@
11
import type { JsrPackageInfo } from '#shared/types/jsr'
2+
import { getCreateShortName } from '#shared/utils/package-analysis'
23

34
// @unocss-include
45
export const packageManagers = [
56
{
67
id: 'npm',
78
label: 'npm',
89
action: 'install',
9-
execute: 'npx',
10+
executeLocal: 'npx',
11+
executeRemote: 'npx',
12+
create: 'npm create',
1013
icon: 'i-simple-icons:npm',
1114
},
1215
{
1316
id: 'pnpm',
1417
label: 'pnpm',
1518
action: 'add',
16-
execute: 'pnpm dlx',
19+
executeLocal: 'pnpm exec',
20+
executeRemote: 'pnpm dlx',
21+
create: 'pnpm create',
1722
icon: 'i-simple-icons:pnpm',
1823
},
1924
{
2025
id: 'yarn',
2126
label: 'yarn',
2227
action: 'add',
23-
execute: 'yarn dlx',
28+
executeLocal: 'yarn',
29+
executeRemote: 'yarn dlx',
30+
create: 'yarn create',
2431
icon: 'i-simple-icons:yarn',
2532
},
26-
{ id: 'bun', label: 'bun', action: 'add', execute: 'bunx', icon: 'i-simple-icons:bun' },
33+
{
34+
id: 'bun',
35+
label: 'bun',
36+
action: 'add',
37+
executeLocal: 'bunx',
38+
executeRemote: 'bunx',
39+
create: 'bun create',
40+
icon: 'i-simple-icons:bun',
41+
},
2742
{
2843
id: 'deno',
2944
label: 'deno',
3045
action: 'add',
31-
execute: 'deno run',
46+
executeLocal: 'deno run',
47+
executeRemote: 'deno run',
48+
create: 'deno run',
3249
icon: 'i-simple-icons:deno',
3350
},
34-
{ id: 'vlt', label: 'vlt', action: 'install', execute: 'vlt x', icon: 'i-custom-vlt' },
51+
{
52+
id: 'vlt',
53+
label: 'vlt',
54+
action: 'install',
55+
executeLocal: 'vlt x',
56+
executeRemote: 'vlt x',
57+
create: 'vlt x',
58+
icon: 'i-custom-vlt',
59+
},
3560
] as const
3661

3762
export type PackageManagerId = (typeof packageManagers)[number]['id']
@@ -85,12 +110,30 @@ export function getInstallCommandParts(options: InstallCommandOptions): string[]
85110
return [pm.label, pm.action, `${spec}${version}`]
86111
}
87112

88-
export function getExecuteCommand(options: InstallCommandOptions): string {
113+
export interface ExecuteCommandOptions extends InstallCommandOptions {
114+
/** Whether this is a binary-only package (download & run vs local run) */
115+
isBinaryOnly?: boolean
116+
/** Whether this is a create-* package (uses shorthand create command) */
117+
isCreatePackage?: boolean
118+
}
119+
120+
export function getExecuteCommand(options: ExecuteCommandOptions): string {
89121
return getExecuteCommandParts(options).join(' ')
90122
}
91123

92-
export function getExecuteCommandParts(options: InstallCommandOptions): string[] {
124+
export function getExecuteCommandParts(options: ExecuteCommandOptions): string[] {
93125
const pm = packageManagers.find(p => p.id === options.packageManager)
94126
if (!pm) return []
95-
return [pm.execute, getPackageSpecifier(options)]
127+
128+
// For create-* packages, use the shorthand create command
129+
if (options.isCreatePackage) {
130+
const shortName = getCreateShortName(options.packageName)
131+
if (shortName !== options.packageName) {
132+
return [...pm.create.split(' '), shortName]
133+
}
134+
}
135+
136+
// Choose remote or local execute based on package type
137+
const executeCmd = options.isBinaryOnly ? pm.executeRemote : pm.executeLocal
138+
return [...executeCmd.split(' '), getPackageSpecifier(options)]
96139
}

app/utils/run-command.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import type { JsrPackageInfo } from '#shared/types/jsr'
2+
import { getPackageSpecifier, packageManagers } from './install-command'
3+
import type { PackageManagerId } from './install-command'
4+
5+
/**
6+
* Metadata needed to determine if a package is binary-only.
7+
*/
8+
export interface PackageMetadata {
9+
name: string
10+
bin?: string | Record<string, string>
11+
main?: string
12+
module?: unknown
13+
exports?: unknown
14+
}
15+
16+
/**
17+
* Determine if a package is "binary-only" (executable without library entry points).
18+
* Binary-only packages should show execute commands without install commands.
19+
*
20+
* A package is binary-only if:
21+
* - Name starts with "create-" (e.g., create-vite)
22+
* - Scoped name contains "/create-" (e.g., @vue/create-app)
23+
* - Has bin field but no main, module, or exports fields
24+
*/
25+
export function isBinaryOnlyPackage(pkg: PackageMetadata): boolean {
26+
const baseName = pkg.name.startsWith('@') ? pkg.name.split('/')[1] : pkg.name
27+
28+
// Check create-* patterns
29+
if (baseName?.startsWith('create-') || pkg.name.includes('/create-')) {
30+
return true
31+
}
32+
33+
// Has bin but no entry points
34+
const hasBin =
35+
pkg.bin !== undefined && (typeof pkg.bin === 'string' || Object.keys(pkg.bin).length > 0)
36+
const hasEntryPoint = !!pkg.main || !!pkg.module || !!pkg.exports
37+
38+
return hasBin && !hasEntryPoint
39+
}
40+
41+
/**
42+
* Check if a package uses the create-* naming convention.
43+
*/
44+
export function isCreatePackage(packageName: string): boolean {
45+
const baseName = packageName.startsWith('@') ? packageName.split('/')[1] : packageName
46+
return baseName?.startsWith('create-') || packageName.includes('/create-') || false
47+
}
48+
49+
/**
50+
* Information about executable commands provided by a package.
51+
*/
52+
export interface ExecutableInfo {
53+
/** Primary command name (typically the package name or first bin key) */
54+
primaryCommand: string
55+
/** All available command names */
56+
commands: string[]
57+
/** Whether this package has any executables */
58+
hasExecutable: boolean
59+
}
60+
61+
/**
62+
* Extract executable command information from a package's bin field.
63+
* Handles both string format ("bin": "./cli.js") and object format ("bin": { "cmd": "./cli.js" }).
64+
*/
65+
export function getExecutableInfo(
66+
packageName: string,
67+
bin: string | Record<string, string> | undefined,
68+
): ExecutableInfo {
69+
if (!bin) {
70+
return { primaryCommand: '', commands: [], hasExecutable: false }
71+
}
72+
73+
// String format: package name becomes the command
74+
if (typeof bin === 'string') {
75+
return {
76+
primaryCommand: packageName,
77+
commands: [packageName],
78+
hasExecutable: true,
79+
}
80+
}
81+
82+
// Object format: keys are command names
83+
const commands = Object.keys(bin)
84+
const firstCommand = commands[0]
85+
if (!firstCommand) {
86+
return { primaryCommand: '', commands: [], hasExecutable: false }
87+
}
88+
89+
// Prefer command matching package name if it exists, otherwise use first
90+
const baseName = packageName.startsWith('@') ? packageName.split('/')[1] : packageName
91+
const primaryCommand = baseName && commands.includes(baseName) ? baseName : firstCommand
92+
93+
return {
94+
primaryCommand,
95+
commands,
96+
hasExecutable: true,
97+
}
98+
}
99+
100+
export interface RunCommandOptions {
101+
packageName: string
102+
packageManager: PackageManagerId
103+
version?: string | null
104+
jsrInfo?: JsrPackageInfo | null
105+
/** Specific command to run (for packages with multiple bin entries) */
106+
command?: string
107+
/** Whether this is a binary-only package (affects which execute command to use) */
108+
isBinaryOnly?: boolean
109+
}
110+
111+
/**
112+
* Generate run command as an array of parts.
113+
* First element is the package manager label (e.g., "pnpm"), rest are arguments.
114+
* For example: ["pnpm", "exec", "eslint"] or ["pnpm", "dlx", "create-vite"]
115+
*/
116+
export function getRunCommandParts(options: RunCommandOptions): string[] {
117+
const pm = packageManagers.find(p => p.id === options.packageManager)
118+
if (!pm) return []
119+
120+
const spec = getPackageSpecifier(options)
121+
122+
// Choose execute command based on package type
123+
const executeCmd = options.isBinaryOnly ? pm.executeRemote : pm.executeLocal
124+
const executeParts = executeCmd.split(' ')
125+
126+
// For deno, always use the package specifier
127+
if (options.packageManager === 'deno') {
128+
return [...executeParts, spec]
129+
}
130+
131+
// For local execute with specific command name different from package name
132+
// e.g., `pnpm exec tsc` for typescript package
133+
if (options.command && options.command !== options.packageName) {
134+
const baseName = options.packageName.startsWith('@')
135+
? options.packageName.split('/')[1]
136+
: options.packageName
137+
// If command matches base package name, use the package spec
138+
if (options.command === baseName) {
139+
return [...executeParts, spec]
140+
}
141+
// Otherwise use the command name directly
142+
return [...executeParts, options.command]
143+
}
144+
145+
return [...executeParts, spec]
146+
}
147+
148+
/**
149+
* Generate the full run command for a package.
150+
*/
151+
export function getRunCommand(options: RunCommandOptions): string {
152+
return getRunCommandParts(options).join(' ')
153+
}

i18n/locales/en.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,14 @@
113113
"copy_command": "Copy install command",
114114
"view_types": "View {package}"
115115
},
116+
"create": {
117+
"title": "Create new project",
118+
"copy_command": "Copy create command"
119+
},
120+
"run": {
121+
"title": "Run",
122+
"locally": "Run locally"
123+
},
116124
"readme": {
117125
"title": "Readme",
118126
"no_readme": "No README available.",

i18n/locales/zh-CN.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,14 @@
112112
"copy_command": "复制安装命令",
113113
"view_types": "查看 {package}"
114114
},
115+
"create": {
116+
"title": "创建新项目",
117+
"copy_command": "复制创建命令"
118+
},
119+
"run": {
120+
"title": "运行",
121+
"locally": "本地运行"
122+
},
115123
"readme": {
116124
"title": "Readme",
117125
"no_readme": "没有可用的 README。",

0 commit comments

Comments
 (0)