[READ-ONLY] a fast, modern browser for the npm registry

feat: link install scripts to relevant file in code tab (#975)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Bruce
Daniel Roe
and committed by
GitHub
77fd97e3 27a9e772

+164 -4
+39 -3
app/components/Package/InstallScripts.vue
··· 3 3 4 4 const props = defineProps<{ 5 5 packageName: string 6 + version: string 6 7 installScripts: { 7 8 scripts: ('preinstall' | 'install' | 'postinstall')[] 8 9 content?: Record<string, string> ··· 10 11 } 11 12 }>() 12 13 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 + 13 37 const outdatedNpxDeps = useOutdatedDependencies(() => props.installScripts.npxDependencies) 14 38 const hasNpxDeps = computed(() => Object.keys(props.installScripts.npxDependencies).length > 0) 15 39 const sortedNpxDeps = computed(() => { ··· 30 54 <div v-for="scriptName in installScripts.scripts" :key="scriptName"> 31 55 <dt class="font-mono text-xs text-fg-muted">{{ scriptName }}</dt> 32 56 <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" 35 58 :title="installScripts.content?.[scriptName]" 36 59 > 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> 38 74 </dd> 39 75 </div> 40 76 </dl>
+1
app/pages/package/[[org]]/[name].vue
··· 1172 1172 <PackageInstallScripts 1173 1173 v-if="displayVersion?.installScripts" 1174 1174 :package-name="pkg.name" 1175 + :version="displayVersion.version" 1175 1176 :install-scripts="displayVersion.installScripts" 1176 1177 /> 1177 1178
+64
app/utils/install-scripts.ts
··· 114 114 npxDependencies: extractNpxDependencies(scripts), 115 115 } 116 116 } 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 + }
+2
test/nuxt/a11y.spec.ts
··· 1993 1993 const component = await mountSuspended(PackageInstallScripts, { 1994 1994 props: { 1995 1995 packageName: 'esbuild', 1996 + version: '0.25.0', 1996 1997 installScripts: { 1997 1998 scripts: ['postinstall'], 1998 1999 content: { postinstall: 'node install.js' }, ··· 2008 2009 const component = await mountSuspended(PackageInstallScripts, { 2009 2010 props: { 2010 2011 packageName: 'husky', 2012 + version: '9.1.0', 2011 2013 installScripts: { 2012 2014 scripts: ['postinstall'], 2013 2015 content: { postinstall: 'husky install' },
+58 -1
test/unit/app/utils/install-scripts.spec.ts
··· 1 1 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' 3 7 4 8 describe('extractInstallScriptsInfo', () => { 5 9 it('returns null when no install scripts exist', () => { ··· 75 79 }) 76 80 }) 77 81 }) 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 + })