Skip to content

Commit f69bd6b

Browse files
committed
Add binary run scripts to package page
1 parent 64d0bc6 commit f69bd6b

3 files changed

Lines changed: 418 additions & 0 deletions

File tree

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

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { assertValidPackageName } from '#shared/utils/npm'
55
import { onKeyStroke } from '@vueuse/core'
66
import { joinURL } from 'ufo'
77
import { areUrlsEquivalent } from '#shared/utils/url'
8+
import { getExecutableInfo, getRunCommandParts, getRunCommand } from '~/utils/run-command'
89
910
definePageMeta({
1011
name: 'package',
@@ -232,6 +233,58 @@ const {
232233
copyInstallCommand,
233234
} = useInstallCommand(packageName, requestedVersion, jsrInfo, typesPackageName)
234235
236+
// Executable detection for run command
237+
const executableInfo = computed(() => {
238+
if (!displayVersion.value || !pkg.value) return null
239+
return getExecutableInfo(pkg.value.name, displayVersion.value.bin)
240+
})
241+
242+
// Run command expanded state (for packages with multiple bin commands)
243+
const runExpanded = ref(false)
244+
245+
// Run command parts for a specific command
246+
function getRunParts(command?: string) {
247+
if (!pkg.value) return []
248+
return getRunCommandParts({
249+
packageName: pkg.value.name,
250+
packageManager: selectedPM.value,
251+
jsrInfo: jsrInfo.value,
252+
command,
253+
})
254+
}
255+
256+
// Primary run command parts
257+
const runCommandParts = computed(() => {
258+
if (!executableInfo.value?.hasExecutable) return []
259+
return getRunParts(executableInfo.value.primaryCommand)
260+
})
261+
262+
// Full run command string for copying
263+
function getFullRunCommand(command?: string) {
264+
if (!pkg.value) return ''
265+
return getRunCommand({
266+
packageName: pkg.value.name,
267+
packageManager: selectedPM.value,
268+
jsrInfo: jsrInfo.value,
269+
command,
270+
})
271+
}
272+
273+
// Copy run command
274+
const runCopied = ref(false)
275+
const runCopiedCommand = ref<string | null>(null)
276+
async function copyRunCommand(command?: string) {
277+
const cmd = getFullRunCommand(command)
278+
if (!cmd) return
279+
await navigator.clipboard.writeText(cmd)
280+
runCopied.value = true
281+
runCopiedCommand.value = command || null
282+
setTimeout(() => {
283+
runCopied.value = false
284+
runCopiedCommand.value = null
285+
}, 2000)
286+
}
287+
235288
// Expandable description
236289
const descriptionExpanded = ref(false)
237290
const descriptionRef = useTemplateRef('descriptionRef')
@@ -763,6 +816,101 @@ defineOgImageComponent('Package', {
763816
</div>
764817
</section>
765818

819+
<!-- Run command section - only shown for packages with executables -->
820+
<section v-if="executableInfo?.hasExecutable" aria-labelledby="run-heading" class="mb-8">
821+
<h2 id="run-heading" class="text-xs text-fg-subtle uppercase tracking-wider mb-3">Run</h2>
822+
<div class="relative group">
823+
<div class="bg-[#0d0d0d] border border-border rounded-lg overflow-hidden">
824+
<!-- Terminal chrome with interactive green button for multiple commands -->
825+
<div class="flex gap-1.5 px-3 pt-2 sm:px-4 sm:pt-3">
826+
<span class="w-2.5 h-2.5 rounded-full bg-[#333]" />
827+
<span class="w-2.5 h-2.5 rounded-full bg-[#333]" />
828+
<ClientOnly>
829+
<button
830+
v-if="executableInfo.commands.length > 1"
831+
type="button"
832+
class="w-2.5 h-2.5 rounded-full transition-colors cursor-pointer"
833+
:class="runExpanded ? 'bg-green-400' : 'bg-green-500 hover:bg-green-400'"
834+
:title="
835+
runExpanded
836+
? 'Show less'
837+
: `Show all ${executableInfo.commands.length} commands`
838+
"
839+
:aria-expanded="runExpanded"
840+
@click="runExpanded = !runExpanded"
841+
/>
842+
<span v-else class="w-2.5 h-2.5 rounded-full bg-[#333]" />
843+
<template #fallback>
844+
<span class="w-2.5 h-2.5 rounded-full bg-[#333]" />
845+
</template>
846+
</ClientOnly>
847+
</div>
848+
849+
<!-- Primary command (always shown) -->
850+
<div
851+
class="flex items-center gap-2 px-3 pt-2 sm:px-4 sm:pt-3"
852+
:class="runExpanded ? 'pb-2' : 'pb-3 sm:pb-4'"
853+
>
854+
<span class="text-fg-subtle font-mono text-sm select-none">$</span>
855+
<code class="font-mono text-sm flex-1"
856+
><ClientOnly
857+
><span
858+
v-for="(part, i) in runCommandParts"
859+
:key="i"
860+
:class="i === 0 ? 'text-fg' : 'text-fg-muted'"
861+
>{{ i > 0 ? ' ' : '' }}{{ part }}</span
862+
><template #fallback
863+
><span class="text-fg">npx</span>{{ ' '
864+
}}<span class="text-fg-muted">{{
865+
executableInfo?.primaryCommand
866+
}}</span></template
867+
></ClientOnly
868+
></code
869+
>
870+
</div>
871+
872+
<!-- Additional commands (shown when expanded) -->
873+
<ClientOnly>
874+
<template v-if="runExpanded && executableInfo.commands.length > 1">
875+
<div
876+
v-for="cmd in executableInfo.commands.slice(1)"
877+
:key="cmd"
878+
class="flex items-center gap-2 px-3 py-2 sm:px-4 border-t border-border/50 group/cmd"
879+
>
880+
<span class="text-fg-subtle font-mono text-sm select-none">$</span>
881+
<code class="font-mono text-sm flex-1"
882+
><span
883+
v-for="(part, i) in getRunParts(cmd)"
884+
:key="i"
885+
:class="i === 0 ? 'text-fg' : 'text-fg-muted'"
886+
>{{ i > 0 ? ' ' : '' }}{{ part }}</span
887+
></code
888+
>
889+
<button
890+
type="button"
891+
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/cmd:opacity-100 hover:(text-fg border-border-hover) active:scale-95 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
892+
@click="copyRunCommand(cmd)"
893+
>
894+
{{ runCopied && runCopiedCommand === cmd ? 'copied!' : 'copy' }}
895+
</button>
896+
</div>
897+
</template>
898+
</ClientOnly>
899+
</div>
900+
<button
901+
type="button"
902+
class="absolute top-3 right-3 px-2 py-1 font-mono text-xs text-fg-muted bg-bg-subtle/80 border border-border rounded transition-colors duration-200 hover:(text-fg border-border-hover) active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
903+
@click="copyRunCommand(executableInfo?.primaryCommand)"
904+
>
905+
{{
906+
runCopied && runCopiedCommand === (executableInfo?.primaryCommand || null)
907+
? 'copied!'
908+
: 'copy'
909+
}}
910+
</button>
911+
</div>
912+
</section>
913+
766914
<!-- Two column layout for sidebar content -->
767915
<div class="grid lg:grid-cols-3 gap-8">
768916
<!-- Main content (README) -->

app/utils/run-command.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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+
* Information about executable commands provided by a package.
7+
*/
8+
export interface ExecutableInfo {
9+
/** Primary command name (typically the package name or first bin key) */
10+
primaryCommand: string
11+
/** All available command names */
12+
commands: string[]
13+
/** Whether this package has any executables */
14+
hasExecutable: boolean
15+
}
16+
17+
/**
18+
* Extract executable command information from a package's bin field.
19+
* Handles both string format ("bin": "./cli.js") and object format ("bin": { "cmd": "./cli.js" }).
20+
*/
21+
export function getExecutableInfo(
22+
packageName: string,
23+
bin: string | Record<string, string> | undefined,
24+
): ExecutableInfo {
25+
if (!bin) {
26+
return { primaryCommand: '', commands: [], hasExecutable: false }
27+
}
28+
29+
// String format: package name becomes the command
30+
if (typeof bin === 'string') {
31+
return {
32+
primaryCommand: packageName,
33+
commands: [packageName],
34+
hasExecutable: true,
35+
}
36+
}
37+
38+
// Object format: keys are command names
39+
const commands = Object.keys(bin)
40+
if (commands.length === 0) {
41+
return { primaryCommand: '', commands: [], hasExecutable: false }
42+
}
43+
44+
// Prefer command matching package name if it exists, otherwise use first
45+
const baseName = packageName.startsWith('@') ? packageName.split('/')[1] : packageName
46+
const primaryCommand = commands.includes(baseName) ? baseName : commands[0]
47+
48+
return {
49+
primaryCommand,
50+
commands,
51+
hasExecutable: true,
52+
}
53+
}
54+
55+
export interface RunCommandOptions {
56+
packageName: string
57+
packageManager: PackageManagerId
58+
version?: string | null
59+
jsrInfo?: JsrPackageInfo | null
60+
/** Specific command to run (for packages with multiple bin entries) */
61+
command?: string
62+
}
63+
64+
/**
65+
* Generate run command as an array of parts.
66+
* For example: ["npx", "eslint"] or ["bunx", "tsc"]
67+
*/
68+
export function getRunCommandParts(options: RunCommandOptions): string[] {
69+
const pm = packageManagers.find(p => p.id === options.packageManager)
70+
if (!pm) return []
71+
72+
const spec = getPackageSpecifier(options)
73+
74+
// For deno, always use the package specifier
75+
if (options.packageManager === 'deno') {
76+
return [pm.execute, spec]
77+
}
78+
79+
// For npx/bunx/pnpm dlx/yarn dlx/vlt x, the command name is what gets executed
80+
// e.g., `npx tsc` runs the tsc command from typescript package
81+
// If the command matches the package name (like eslint), use the package spec
82+
// Otherwise, use the command name directly (like tsc for typescript)
83+
if (options.command && options.command !== options.packageName) {
84+
const baseName = options.packageName.startsWith('@')
85+
? options.packageName.split('/')[1]
86+
: options.packageName
87+
// If command matches base package name, use the package spec
88+
if (options.command === baseName) {
89+
return [pm.execute, spec]
90+
}
91+
// Otherwise use the command name directly (e.g., npx tsc, not npx typescript/tsc)
92+
return [pm.execute, options.command]
93+
}
94+
95+
return [pm.execute, spec]
96+
}
97+
98+
/**
99+
* Generate the full run command for a package.
100+
*/
101+
export function getRunCommand(options: RunCommandOptions): string {
102+
return getRunCommandParts(options).join(' ')
103+
}

0 commit comments

Comments
 (0)