Skip to content

Commit 77fd97e

Browse files
bcyedanielroe
andauthored
feat: link install scripts to relevant file in code tab (#975)
Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent 27a9e77 commit 77fd97e

File tree

5 files changed

+164
-4
lines changed

5 files changed

+164
-4
lines changed

app/components/Package/InstallScripts.vue

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,37 @@ import { getOutdatedTooltip, getVersionClass } from '~/utils/npm/outdated-depend
33
44
const props = defineProps<{
55
packageName: string
6+
version: string
67
installScripts: {
78
scripts: ('preinstall' | 'install' | 'postinstall')[]
89
content?: Record<string, string>
910
npxDependencies: Record<string, string>
1011
}
1112
}>()
1213
14+
function getCodeLink(filePath: string): string {
15+
return `/code/${props.packageName}/v/${props.version}/${filePath}`
16+
}
17+
18+
const scriptParts = computed(() => {
19+
const parts: Record<string, { prefix: string | null; filePath: string | null; link: string }> = {}
20+
for (const scriptName of props.installScripts.scripts) {
21+
const content = props.installScripts.content?.[scriptName]
22+
if (!content) continue
23+
const parsed = parseNodeScript(content)
24+
if (parsed) {
25+
parts[scriptName] = {
26+
prefix: parsed.prefix,
27+
filePath: parsed.filePath,
28+
link: getCodeLink(parsed.filePath),
29+
}
30+
} else {
31+
parts[scriptName] = { prefix: null, filePath: null, link: getCodeLink('package.json') }
32+
}
33+
}
34+
return parts
35+
})
36+
1337
const outdatedNpxDeps = useOutdatedDependencies(() => props.installScripts.npxDependencies)
1438
const hasNpxDeps = computed(() => Object.keys(props.installScripts.npxDependencies).length > 0)
1539
const sortedNpxDeps = computed(() => {
@@ -30,11 +54,23 @@ const isExpanded = shallowRef(false)
3054
<div v-for="scriptName in installScripts.scripts" :key="scriptName">
3155
<dt class="font-mono text-xs text-fg-muted">{{ scriptName }}</dt>
3256
<dd
33-
tabindex="0"
34-
class="font-mono text-sm text-fg-subtle m-0 truncate focus:whitespace-normal focus:overflow-visible cursor-help rounded focus-visible:(outline-2 outline-accent outline-offset-2)"
57+
class="font-mono text-sm text-fg-subtle m-0 truncate"
3558
:title="installScripts.content?.[scriptName]"
3659
>
37-
{{ installScripts.content?.[scriptName] || $t('package.install_scripts.script_label') }}
60+
<template v-if="installScripts.content?.[scriptName] && scriptParts[scriptName]">
61+
<template v-if="scriptParts[scriptName].prefix">
62+
{{ scriptParts[scriptName].prefix
63+
}}<LinkBase :to="scriptParts[scriptName].link">{{
64+
scriptParts[scriptName].filePath
65+
}}</LinkBase>
66+
</template>
67+
<LinkBase v-else :to="scriptParts[scriptName].link">
68+
{{ installScripts.content[scriptName] }}
69+
</LinkBase>
70+
</template>
71+
<span v-else tabindex="0" class="cursor-help">
72+
{{ $t('package.install_scripts.script_label') }}
73+
</span>
3874
</dd>
3975
</div>
4076
</dl>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1172,6 +1172,7 @@ onKeyStroke(
11721172
<PackageInstallScripts
11731173
v-if="displayVersion?.installScripts"
11741174
:package-name="pkg.name"
1175+
:version="displayVersion.version"
11751176
:install-scripts="displayVersion.installScripts"
11761177
/>
11771178

app/utils/install-scripts.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,67 @@ export function extractInstallScriptsInfo(
114114
npxDependencies: extractNpxDependencies(scripts),
115115
}
116116
}
117+
118+
/**
119+
* Pattern to match scripts that are just `node <file-path>`
120+
* Captures the file path (relative paths with alphanumeric chars, dots, hyphens, underscores, and slashes)
121+
*/
122+
const NODE_SCRIPT_PATTERN = /^node\s+([\w./-]+)$/
123+
124+
/**
125+
* Get the file path for an install script link.
126+
* - If the script is `node <file-path>`, returns that file path
127+
* - Otherwise, returns 'package.json'
128+
*
129+
* @param scriptContent - The content of the script
130+
* @returns The file path to link to in the code tab
131+
*/
132+
export function getInstallScriptFilePath(scriptContent: string): string {
133+
const match = NODE_SCRIPT_PATTERN.exec(scriptContent)
134+
135+
if (match?.[1]) {
136+
// Script is `node <file-path>`, link to that file
137+
// Normalize path: strip leading ./
138+
const filePath = match[1].replace(/^\.\//, '')
139+
140+
// Fall back to package.json if path contains navigational elements (the client-side routing can't handle these well)
141+
if (filePath.includes('../') || filePath.includes('./')) {
142+
return 'package.json'
143+
}
144+
145+
return filePath
146+
}
147+
148+
// Default: link to package.json
149+
return 'package.json'
150+
}
151+
152+
/**
153+
* Parse an install script into a prefix and a linkable file path.
154+
* - If the script is `node <file-path>`, returns { prefix: 'node ', filePath: '<file-path>' }
155+
* so only the file path portion can be rendered as a link.
156+
* - Otherwise, returns null (the entire script content should link to package.json).
157+
*
158+
* @param scriptContent - The content of the script
159+
* @returns Parsed parts, or null if no node file path was extracted
160+
*/
161+
export function parseNodeScript(
162+
scriptContent: string,
163+
): { prefix: string; filePath: string } | null {
164+
const match = NODE_SCRIPT_PATTERN.exec(scriptContent)
165+
166+
if (match?.[1]) {
167+
const filePath = match[1].replace(/^\.\//, '')
168+
169+
// Fall back if path contains navigational elements
170+
if (filePath.includes('../') || filePath.includes('./')) {
171+
return null
172+
}
173+
174+
// Reconstruct the prefix (everything before the captured file path)
175+
const prefix = scriptContent.slice(0, match.index + match[0].indexOf(match[1]))
176+
return { prefix, filePath }
177+
}
178+
179+
return null
180+
}

test/nuxt/a11y.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1993,6 +1993,7 @@ describe('component accessibility audits', () => {
19931993
const component = await mountSuspended(PackageInstallScripts, {
19941994
props: {
19951995
packageName: 'esbuild',
1996+
version: '0.25.0',
19961997
installScripts: {
19971998
scripts: ['postinstall'],
19981999
content: { postinstall: 'node install.js' },
@@ -2008,6 +2009,7 @@ describe('component accessibility audits', () => {
20082009
const component = await mountSuspended(PackageInstallScripts, {
20092010
props: {
20102011
packageName: 'husky',
2012+
version: '9.1.0',
20112013
installScripts: {
20122014
scripts: ['postinstall'],
20132015
content: { postinstall: 'husky install' },

test/unit/app/utils/install-scripts.spec.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, it } from 'vitest'
2-
import { extractInstallScriptsInfo } from '../../../../app/utils/install-scripts'
2+
import {
3+
extractInstallScriptsInfo,
4+
getInstallScriptFilePath,
5+
parseNodeScript,
6+
} from '../../../../app/utils/install-scripts'
37

48
describe('extractInstallScriptsInfo', () => {
59
it('returns null when no install scripts exist', () => {
@@ -75,3 +79,56 @@ describe('extractInstallScriptsInfo', () => {
7579
})
7680
})
7781
})
82+
83+
describe('getInstallScriptFilePath', () => {
84+
it('returns file path when script is `node <file-path>`', () => {
85+
expect(getInstallScriptFilePath('node scripts/postinstall.js')).toBe('scripts/postinstall.js')
86+
})
87+
88+
it('returns package.json when script is not a simple node command', () => {
89+
expect(getInstallScriptFilePath('npx prisma generate')).toBe('package.json')
90+
})
91+
92+
it('strips leading ./ from relative paths', () => {
93+
expect(getInstallScriptFilePath('node ./scripts/setup.js')).toBe('scripts/setup.js')
94+
})
95+
96+
it('falls back to package.json for parent directory references', () => {
97+
expect(getInstallScriptFilePath('node ../scripts/setup.js')).toBe('package.json')
98+
expect(getInstallScriptFilePath('node ./scripts/../lib/setup.js')).toBe('package.json')
99+
})
100+
101+
it('returns package.json for bare node command without arguments', () => {
102+
expect(getInstallScriptFilePath('node')).toBe('package.json')
103+
expect(getInstallScriptFilePath('node ')).toBe('package.json')
104+
})
105+
})
106+
107+
describe('parseNodeScript', () => {
108+
it('returns prefix and filePath for node scripts', () => {
109+
expect(parseNodeScript('node scripts/postinstall.js')).toEqual({
110+
prefix: 'node ',
111+
filePath: 'scripts/postinstall.js',
112+
})
113+
})
114+
115+
it('strips leading ./ from file path', () => {
116+
expect(parseNodeScript('node ./scripts/setup.js')).toEqual({
117+
prefix: 'node ',
118+
filePath: 'scripts/setup.js',
119+
})
120+
})
121+
122+
it('returns null for non-node scripts', () => {
123+
expect(parseNodeScript('npx prisma generate')).toBeNull()
124+
})
125+
126+
it('returns null for bare node command', () => {
127+
expect(parseNodeScript('node')).toBeNull()
128+
expect(parseNodeScript('node ')).toBeNull()
129+
})
130+
131+
it('returns null for parent directory references', () => {
132+
expect(parseNodeScript('node ../scripts/setup.js')).toBeNull()
133+
})
134+
})

0 commit comments

Comments
 (0)