Skip to content

Commit e660183

Browse files
committed
Add binary run scripts to package page
1 parent 2df63c6 commit e660183

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
@@ -3,6 +3,7 @@ import { joinURL } from 'ufo'
33
import type { PackumentVersion, NpmVersionDist, ReadmeResponse } from '#shared/types'
44
import type { JsrPackageInfo } from '#shared/types/jsr'
55
import { assertValidPackageName } from '#shared/utils/npm'
6+
import { getExecutableInfo, getRunCommandParts, getRunCommand } from '~/utils/run-command'
67
78
definePageMeta({
89
name: 'package',
@@ -240,6 +241,58 @@ async function copyInstallCommand() {
240241
setTimeout(() => (copied.value = false), 2000)
241242
}
242243
244+
// Executable detection for run command
245+
const executableInfo = computed(() => {
246+
if (!displayVersion.value || !pkg.value) return null
247+
return getExecutableInfo(pkg.value.name, displayVersion.value.bin)
248+
})
249+
250+
// Run command expanded state (for packages with multiple bin commands)
251+
const runExpanded = ref(false)
252+
253+
// Run command parts for a specific command
254+
function getRunParts(command?: string) {
255+
if (!pkg.value) return []
256+
return getRunCommandParts({
257+
packageName: pkg.value.name,
258+
packageManager: selectedPM.value,
259+
jsrInfo: jsrInfo.value,
260+
command,
261+
})
262+
}
263+
264+
// Primary run command parts
265+
const runCommandParts = computed(() => {
266+
if (!executableInfo.value?.hasExecutable) return []
267+
return getRunParts(executableInfo.value.primaryCommand)
268+
})
269+
270+
// Full run command string for copying
271+
function getFullRunCommand(command?: string) {
272+
if (!pkg.value) return ''
273+
return getRunCommand({
274+
packageName: pkg.value.name,
275+
packageManager: selectedPM.value,
276+
jsrInfo: jsrInfo.value,
277+
command,
278+
})
279+
}
280+
281+
// Copy run command
282+
const runCopied = ref(false)
283+
const runCopiedCommand = ref<string | null>(null)
284+
async function copyRunCommand(command?: string) {
285+
const cmd = getFullRunCommand(command)
286+
if (!cmd) return
287+
await navigator.clipboard.writeText(cmd)
288+
runCopied.value = true
289+
runCopiedCommand.value = command || null
290+
setTimeout(() => {
291+
runCopied.value = false
292+
runCopiedCommand.value = null
293+
}, 2000)
294+
}
295+
243296
// Expandable description
244297
const descriptionExpanded = ref(false)
245298
const descriptionRef = ref<HTMLDivElement>()
@@ -681,6 +734,101 @@ defineOgImageComponent('Package', {
681734
</div>
682735
</section>
683736

737+
<!-- Run command section - only shown for packages with executables -->
738+
<section v-if="executableInfo?.hasExecutable" aria-labelledby="run-heading" class="mb-8">
739+
<h2 id="run-heading" class="text-xs text-fg-subtle uppercase tracking-wider mb-3">Run</h2>
740+
<div class="relative group">
741+
<div class="bg-[#0d0d0d] border border-border rounded-lg overflow-hidden">
742+
<!-- Terminal chrome with interactive green button for multiple commands -->
743+
<div class="flex gap-1.5 px-3 pt-2 sm:px-4 sm:pt-3">
744+
<span class="w-2.5 h-2.5 rounded-full bg-[#333]" />
745+
<span class="w-2.5 h-2.5 rounded-full bg-[#333]" />
746+
<ClientOnly>
747+
<button
748+
v-if="executableInfo.commands.length > 1"
749+
type="button"
750+
class="w-2.5 h-2.5 rounded-full transition-colors cursor-pointer"
751+
:class="runExpanded ? 'bg-green-400' : 'bg-green-500 hover:bg-green-400'"
752+
:title="
753+
runExpanded
754+
? 'Show less'
755+
: `Show all ${executableInfo.commands.length} commands`
756+
"
757+
:aria-expanded="runExpanded"
758+
@click="runExpanded = !runExpanded"
759+
/>
760+
<span v-else class="w-2.5 h-2.5 rounded-full bg-[#333]" />
761+
<template #fallback>
762+
<span class="w-2.5 h-2.5 rounded-full bg-[#333]" />
763+
</template>
764+
</ClientOnly>
765+
</div>
766+
767+
<!-- Primary command (always shown) -->
768+
<div
769+
class="flex items-center gap-2 px-3 pt-2 sm:px-4 sm:pt-3"
770+
:class="runExpanded ? 'pb-2' : 'pb-3 sm:pb-4'"
771+
>
772+
<span class="text-fg-subtle font-mono text-sm select-none">$</span>
773+
<code class="font-mono text-sm flex-1"
774+
><ClientOnly
775+
><span
776+
v-for="(part, i) in runCommandParts"
777+
:key="i"
778+
:class="i === 0 ? 'text-fg' : 'text-fg-muted'"
779+
>{{ i > 0 ? ' ' : '' }}{{ part }}</span
780+
><template #fallback
781+
><span class="text-fg">npx</span>{{ ' '
782+
}}<span class="text-fg-muted">{{
783+
executableInfo?.primaryCommand
784+
}}</span></template
785+
></ClientOnly
786+
></code
787+
>
788+
</div>
789+
790+
<!-- Additional commands (shown when expanded) -->
791+
<ClientOnly>
792+
<template v-if="runExpanded && executableInfo.commands.length > 1">
793+
<div
794+
v-for="cmd in executableInfo.commands.slice(1)"
795+
:key="cmd"
796+
class="flex items-center gap-2 px-3 py-2 sm:px-4 border-t border-border/50 group/cmd"
797+
>
798+
<span class="text-fg-subtle font-mono text-sm select-none">$</span>
799+
<code class="font-mono text-sm flex-1"
800+
><span
801+
v-for="(part, i) in getRunParts(cmd)"
802+
:key="i"
803+
:class="i === 0 ? 'text-fg' : 'text-fg-muted'"
804+
>{{ i > 0 ? ' ' : '' }}{{ part }}</span
805+
></code
806+
>
807+
<button
808+
type="button"
809+
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"
810+
@click="copyRunCommand(cmd)"
811+
>
812+
{{ runCopied && runCopiedCommand === cmd ? 'copied!' : 'copy' }}
813+
</button>
814+
</div>
815+
</template>
816+
</ClientOnly>
817+
</div>
818+
<button
819+
type="button"
820+
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"
821+
@click="copyRunCommand(executableInfo?.primaryCommand)"
822+
>
823+
{{
824+
runCopied && runCopiedCommand === (executableInfo?.primaryCommand || null)
825+
? 'copied!'
826+
: 'copy'
827+
}}
828+
</button>
829+
</div>
830+
</section>
831+
684832
<!-- Two column layout for sidebar content -->
685833
<div class="grid lg:grid-cols-3 gap-8">
686834
<!-- 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)