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

Configure Feed

Select the types of activity you want to include in your feed.

feat: allow clicking to dependencies/relative paths in file tree

+657 -62
+15 -1
app/components/CodeViewer.vue
··· 125 125 transition: background-color 0.1s; 126 126 } 127 127 128 - /* Highlighted lines in code content */ 128 + /* Highlighted lines in code content - extend full width with negative margin */ 129 129 .code-content :deep(.line.highlighted) { 130 130 background: rgb(234 179 8 / 0.2); /* yellow-500/20 */ 131 131 margin: 0 -1rem; 132 132 padding: 0 1rem; 133 + } 134 + 135 + /* Clickable import links */ 136 + .code-content :deep(.import-link) { 137 + color: inherit; 138 + text-decoration: underline; 139 + text-decoration-color: transparent; 140 + text-underline-offset: 2px; 141 + transition: text-decoration-color 0.15s; 142 + cursor: pointer; 143 + } 144 + 145 + .code-content :deep(.import-link:hover) { 146 + text-decoration-color: currentColor; 133 147 } 134 148 </style>
+2
package.json
··· 38 38 "nuxt-og-image": "^5.1.13", 39 39 "perfect-debounce": "^2.1.0", 40 40 "sanitize-html": "^2.17.0", 41 + "semver": "^7.7.3", 41 42 "shiki": "^3.21.0", 42 43 "ufo": "^1.6.3", 43 44 "unplugin-vue-router": "^0.19.2", ··· 50 51 "@nuxt/test-utils": "3.23.0", 51 52 "@playwright/test": "1.57.0", 52 53 "@types/sanitize-html": "^2.16.0", 54 + "@types/semver": "^7.7.1", 53 55 "@unocss/nuxt": "^66.6.0", 54 56 "@unocss/preset-wind4": "^66.6.0", 55 57 "@vite-pwa/assets-generator": "^1.0.2",
+11
pnpm-lock.yaml
··· 49 49 sanitize-html: 50 50 specifier: ^2.17.0 51 51 version: 2.17.0 52 + semver: 53 + specifier: ^7.7.3 54 + version: 7.7.3 52 55 shiki: 53 56 specifier: ^3.21.0 54 57 version: 3.21.0 ··· 80 83 '@types/sanitize-html': 81 84 specifier: ^2.16.0 82 85 version: 2.16.0 86 + '@types/semver': 87 + specifier: ^7.7.1 88 + version: 7.7.1 83 89 '@unocss/nuxt': 84 90 specifier: ^66.6.0 85 91 version: 66.6.0(magicast@0.5.1)(postcss@8.5.6)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)) ··· 2620 2626 2621 2627 '@types/sanitize-html@2.16.0': 2622 2628 resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==} 2629 + 2630 + '@types/semver@7.7.1': 2631 + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} 2623 2632 2624 2633 '@types/trusted-types@2.0.7': 2625 2634 resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} ··· 9793 9802 '@types/sanitize-html@2.16.0': 9794 9803 dependencies: 9795 9804 htmlparser2: 8.0.2 9805 + 9806 + '@types/semver@7.7.1': {} 9796 9807 9797 9808 '@types/trusted-types@2.0.7': {} 9798 9809
+70 -2
server/api/registry/file/[...pkg].get.ts
··· 1 + const CACHE_VERSION = 2 2 + 1 3 // Maximum file size to fetch and highlight (500KB) 2 4 const MAX_FILE_SIZE = 500 * 1024 5 + 6 + // Languages that benefit from import linking 7 + const IMPORT_LANGUAGES = new Set([ 8 + 'javascript', 'typescript', 'jsx', 'tsx', 9 + 'vue', 'svelte', 'astro', 10 + ]) 11 + 12 + interface PackageJson { 13 + dependencies?: Record<string, string> 14 + devDependencies?: Record<string, string> 15 + peerDependencies?: Record<string, string> 16 + optionalDependencies?: Record<string, string> 17 + } 18 + 19 + /** 20 + * Fetch package.json from jsDelivr to get dependency info 21 + */ 22 + async function fetchPackageJson(packageName: string, version: string): Promise<PackageJson | null> { 23 + try { 24 + const url = `https://cdn.jsdelivr.net/npm/${packageName}@${version}/package.json` 25 + const response = await fetch(url) 26 + if (!response.ok) return null 27 + return await response.json() as PackageJson 28 + } 29 + catch { 30 + return null 31 + } 32 + } 3 33 4 34 /** 5 35 * Fetch file content from jsDelivr CDN. ··· 73 103 try { 74 104 const content = await fetchFileContent(packageName, version, filePath) 75 105 const language = getLanguageFromPath(filePath) 76 - const html = await highlightCode(content, language) 106 + 107 + // For JS/TS files, resolve dependency versions and relative imports for linking 108 + let dependencies: Record<string, { version: string }> | undefined 109 + let resolveRelative: ((specifier: string) => string | null) | undefined 110 + 111 + if (IMPORT_LANGUAGES.has(language)) { 112 + // Fetch package.json and file tree in parallel 113 + const [pkgJson, fileTreeResponse] = await Promise.all([ 114 + fetchPackageJson(packageName, version), 115 + getPackageFileTree(packageName, version).catch(() => null), 116 + ]) 117 + 118 + // Resolve npm dependency versions 119 + if (pkgJson) { 120 + // Merge all dependency types 121 + const allDeps: Record<string, string> = { 122 + ...pkgJson.dependencies, 123 + ...pkgJson.peerDependencies, 124 + ...pkgJson.optionalDependencies, 125 + // Note: excluding devDependencies as they're less likely to be imported in dist files 126 + } 127 + 128 + if (Object.keys(allDeps).length > 0) { 129 + const resolved: Record<string, string> = await resolveDependencyVersions(allDeps) 130 + dependencies = {} 131 + for (const [name, ver] of Object.entries(resolved)) { 132 + dependencies[name] = { version: ver } 133 + } 134 + } 135 + } 136 + 137 + // Create resolver for relative imports 138 + if (fileTreeResponse) { 139 + const files = flattenFileTree(fileTreeResponse.tree) 140 + resolveRelative = createImportResolver(files, filePath, packageName, version) 141 + } 142 + } 143 + 144 + const html = await highlightCode(content, language, { dependencies, resolveRelative }) 77 145 78 146 return { 79 147 package: packageName, ··· 96 164 maxAge: 60 * 60, // Cache for 1 hour (files don't change for a given version) 97 165 getKey: (event) => { 98 166 const pkg = getRouterParam(event, 'pkg') ?? '' 99 - return `file:${pkg}` 167 + return `file:v${CACHE_VERSION}:${pkg}` 100 168 }, 101 169 }, 102 170 )
+1 -57
server/api/registry/files/[...pkg].get.ts
··· 1 - import type { JsDelivrPackageResponse, JsDelivrFileNode, PackageFileTree, PackageFileTreeResponse } from '#shared/types' 2 - 3 - /** 4 - * Fetch the file tree from jsDelivr API. 5 - * Returns a nested tree structure of all files in the package. 6 - */ 7 - async function fetchFileTree(packageName: string, version: string): Promise<JsDelivrPackageResponse> { 8 - const url = `https://data.jsdelivr.com/v1/packages/npm/${packageName}@${version}` 9 - const response = await fetch(url) 10 - 11 - if (!response.ok) { 12 - if (response.status === 404) { 13 - throw createError({ statusCode: 404, message: 'Package or version not found' }) 14 - } 15 - throw createError({ statusCode: 502, message: 'Failed to fetch file list from jsDelivr' }) 16 - } 17 - 18 - return response.json() 19 - } 20 - 21 - /** 22 - * Convert jsDelivr nested structure to our PackageFileTree format 23 - */ 24 - function convertToFileTree(nodes: JsDelivrFileNode[], parentPath: string = ''): PackageFileTree[] { 25 - const result: PackageFileTree[] = [] 26 - 27 - for (const node of nodes) { 28 - const path = parentPath ? `${parentPath}/${node.name}` : node.name 29 - 30 - if (node.type === 'directory') { 31 - result.push({ 32 - name: node.name, 33 - path, 34 - type: 'directory', 35 - children: node.files ? convertToFileTree(node.files, path) : [], 36 - }) 37 - } 38 - else { 39 - result.push({ 40 - name: node.name, 41 - path, 42 - type: 'file', 43 - size: node.size, 44 - }) 45 - } 46 - } 47 - 48 - // Sort: directories first, then files, alphabetically within each group 49 - result.sort((a, b) => { 50 - if (a.type !== b.type) { 51 - return a.type === 'directory' ? -1 : 1 52 - } 53 - return a.name.localeCompare(b.name) 54 - }) 55 - 56 - return result 57 - } 1 + import type { PackageFileTreeResponse } from '#shared/types' 58 2 59 3 /** 60 4 * Returns the file tree for a package version.
+161 -2
server/utils/code-highlight.ts
··· 1 + import { isBuiltin } from 'node:module' 1 2 // File extension to language mapping 2 3 const EXTENSION_MAP: Record<string, string> = { 3 4 // JavaScript/TypeScript ··· 92 93 } 93 94 94 95 /** 96 + * Check if a module specifier is an npm package (not a relative/absolute path or Node built-in) 97 + */ 98 + function isNpmPackage(specifier: string): boolean { 99 + // Remove quotes 100 + const pkg = specifier.replace(/^['"]|['"]$/g, '').trim() 101 + // Relative or absolute paths 102 + if (pkg.startsWith('.') || pkg.startsWith('/')) return false 103 + // Node built-ins with node: prefix 104 + if (pkg.startsWith('node:')) return false 105 + // Node built-ins without prefix 106 + if (isBuiltin(pkg)) return false 107 + // Empty 108 + if (!pkg) return false 109 + return true 110 + } 111 + 112 + /** 113 + * Extract the package name from a module specifier (handles scoped packages and subpaths) 114 + */ 115 + function getPackageName(specifier: string): string { 116 + const pkg = specifier.replace(/^['"]|['"]$/g, '').trim() 117 + // Scoped package: @scope/name or @scope/name/subpath 118 + if (pkg.startsWith('@')) { 119 + const parts = pkg.split('/') 120 + if (parts[0] && parts[1]) { 121 + return `${parts[0]}/${parts[1]}` 122 + } 123 + } 124 + // Regular package: name or name/subpath 125 + const firstSlash = pkg.indexOf('/') 126 + if (firstSlash > 0) { 127 + return pkg.substring(0, firstSlash) 128 + } 129 + return pkg 130 + } 131 + 132 + /** 133 + * Resolved dependency info for linking imports to specific versions 134 + */ 135 + export interface ResolvedDependency { 136 + version: string 137 + } 138 + 139 + /** 140 + * Map of package name to resolved version for import linking 141 + */ 142 + export type DependencyVersions = Record<string, ResolvedDependency> 143 + 144 + /** 145 + * Function to resolve relative imports to URLs 146 + */ 147 + export type RelativeImportResolver = (specifier: string) => string | null 148 + 149 + interface LinkifyOptions { 150 + dependencies?: DependencyVersions 151 + resolveRelative?: RelativeImportResolver 152 + } 153 + 154 + /** 155 + * Make import/export module specifiers clickable links to package code browser. 156 + * Handles: 157 + * - import ... from 'package' 158 + * - export ... from 'package' 159 + * - import 'package' (side-effect imports) 160 + * - require('package') 161 + * - import('package') - dynamic imports 162 + * - Relative imports (./foo, ../bar) when resolver is provided 163 + * 164 + * @param html - The HTML to process 165 + * @param options - Dependencies map and optional relative import resolver 166 + */ 167 + function linkifyImports(html: string, options?: LinkifyOptions): string { 168 + const { dependencies, resolveRelative } = options ?? {} 169 + 170 + const getHref = (moduleSpecifier: string): string | null => { 171 + const cleanSpec = moduleSpecifier.replace(/^['"]|['"]$/g, '').trim() 172 + 173 + // Try relative import resolution first 174 + if (cleanSpec.startsWith('.') && resolveRelative) { 175 + return resolveRelative(moduleSpecifier) 176 + } 177 + 178 + // Not a relative import - check if it's an npm package 179 + if (!isNpmPackage(moduleSpecifier)) { 180 + return null 181 + } 182 + 183 + const packageName = getPackageName(moduleSpecifier) 184 + const dep = dependencies?.[packageName] 185 + if (dep) { 186 + // Link to code browser with resolved version 187 + return `/package/code/${packageName}/v/${dep.version}` 188 + } 189 + // Fall back to package page if not a known dependency 190 + return `/package/${packageName}` 191 + } 192 + 193 + // Match: from keyword span followed by string span containing module specifier 194 + // Pattern: <span style="...">from</span><span style="..."> 'module'</span> 195 + let result = html.replace( 196 + /(<span[^>]*>from<\/span>)(<span[^>]*>) (['"][^'"]+['"])<\/span>/g, 197 + (match, fromSpan, stringSpanOpen, moduleSpecifier) => { 198 + const href = getHref(moduleSpecifier) 199 + if (!href) return match 200 + return `${fromSpan}${stringSpanOpen} <a href="${href}" class="import-link">${moduleSpecifier}</a></span>` 201 + }, 202 + ) 203 + 204 + // Match: side-effect imports like `import 'package'` 205 + // Pattern: <span>import</span><span> 'module'</span> 206 + // But NOT: import ... from, import(, or import { 207 + result = result.replace( 208 + /(<span[^>]*>import<\/span>)(<span[^>]*>) (['"][^'"]+['"])<\/span>/g, 209 + (match, importSpan, stringSpanOpen, moduleSpecifier) => { 210 + const href = getHref(moduleSpecifier) 211 + if (!href) return match 212 + return `${importSpan}${stringSpanOpen} <a href="${href}" class="import-link">${moduleSpecifier}</a></span>` 213 + }, 214 + ) 215 + 216 + // Match: require( or import( followed by string 217 + // Pattern: <span> require</span><span>(</span><span>'module'</span> 218 + // or: <span>import</span><span>(</span><span>'module'</span> 219 + // Note: require often has a leading space in the span from Shiki 220 + result = result.replace( 221 + /(<span[^>]*>)\s*(require|import)(<\/span>)(<span[^>]*>\(<\/span>)(<span[^>]*>)(['"][^'"]+['"])<\/span>/g, 222 + (match, spanOpen, keyword, spanClose, parenSpan, stringSpanOpen, moduleSpecifier) => { 223 + const href = getHref(moduleSpecifier) 224 + if (!href) return match 225 + return `${spanOpen}${keyword}${spanClose}${parenSpan}${stringSpanOpen}<a href="${href}" class="import-link">${moduleSpecifier}</a></span>` 226 + }, 227 + ) 228 + 229 + return result 230 + } 231 + 232 + // Languages that support import/export statements 233 + const IMPORT_LANGUAGES = new Set([ 234 + 'javascript', 'typescript', 'jsx', 'tsx', 235 + 'vue', 'svelte', 'astro', 236 + ]) 237 + 238 + export interface HighlightOptions { 239 + /** Map of dependency names to resolved versions for import linking */ 240 + dependencies?: DependencyVersions 241 + /** Resolver function for relative imports (./foo, ../bar) */ 242 + resolveRelative?: RelativeImportResolver 243 + } 244 + 245 + /** 95 246 * Highlight code using Shiki with line-by-line output for line highlighting. 96 247 * Each line is wrapped in a span.line for individual line highlighting. 97 248 */ 98 - export async function highlightCode(code: string, language: string): Promise<string> { 249 + export async function highlightCode(code: string, language: string, options?: HighlightOptions): Promise<string> { 99 250 const shiki = await getShikiHighlighter() 100 251 const loadedLangs = shiki.getLoadedLanguages() 101 252 102 253 // Use Shiki if language is loaded 103 254 if (loadedLangs.includes(language as never)) { 104 255 try { 105 - const html = shiki.codeToHtml(code, { 256 + let html = shiki.codeToHtml(code, { 106 257 lang: language, 107 258 theme: 'github-dark', 108 259 }) 260 + 261 + // Make import statements clickable for JS/TS languages 262 + if (IMPORT_LANGUAGES.has(language)) { 263 + html = linkifyImports(html, { 264 + dependencies: options?.dependencies, 265 + resolveRelative: options?.resolveRelative, 266 + }) 267 + } 109 268 110 269 // Check if Shiki already outputs .line spans (newer versions do) 111 270 if (html.includes('<span class="line">')) {
+73
server/utils/file-tree.ts
··· 1 + import type { JsDelivrPackageResponse, JsDelivrFileNode, PackageFileTree, PackageFileTreeResponse } from '#shared/types' 2 + 3 + /** 4 + * Fetch the file tree from jsDelivr API. 5 + * Returns a nested tree structure of all files in the package. 6 + */ 7 + export async function fetchFileTree(packageName: string, version: string): Promise<JsDelivrPackageResponse> { 8 + const url = `https://data.jsdelivr.com/v1/packages/npm/${packageName}@${version}` 9 + const response = await fetch(url) 10 + 11 + if (!response.ok) { 12 + if (response.status === 404) { 13 + throw createError({ statusCode: 404, message: 'Package or version not found' }) 14 + } 15 + throw createError({ statusCode: 502, message: 'Failed to fetch file list from jsDelivr' }) 16 + } 17 + 18 + return response.json() 19 + } 20 + 21 + /** 22 + * Convert jsDelivr nested structure to our PackageFileTree format 23 + */ 24 + export function convertToFileTree(nodes: JsDelivrFileNode[], parentPath: string = ''): PackageFileTree[] { 25 + const result: PackageFileTree[] = [] 26 + 27 + for (const node of nodes) { 28 + const path = parentPath ? `${parentPath}/${node.name}` : node.name 29 + 30 + if (node.type === 'directory') { 31 + result.push({ 32 + name: node.name, 33 + path, 34 + type: 'directory', 35 + children: node.files ? convertToFileTree(node.files, path) : [], 36 + }) 37 + } 38 + else { 39 + result.push({ 40 + name: node.name, 41 + path, 42 + type: 'file', 43 + size: node.size, 44 + }) 45 + } 46 + } 47 + 48 + // Sort: directories first, then files, alphabetically within each group 49 + result.sort((a, b) => { 50 + if (a.type !== b.type) { 51 + return a.type === 'directory' ? -1 : 1 52 + } 53 + return a.name.localeCompare(b.name) 54 + }) 55 + 56 + return result 57 + } 58 + 59 + /** 60 + * Fetch and convert file tree for a package version. 61 + * Returns the full response including tree and metadata. 62 + */ 63 + export async function getPackageFileTree(packageName: string, version: string): Promise<PackageFileTreeResponse> { 64 + const jsDelivrData = await fetchFileTree(packageName, version) 65 + const tree = convertToFileTree(jsDelivrData.files) 66 + 67 + return { 68 + package: packageName, 69 + version, 70 + default: jsDelivrData.default ?? undefined, 71 + tree, 72 + } 73 + }
+262
server/utils/import-resolver.ts
··· 1 + import type { PackageFileTree } from '#shared/types' 2 + 3 + /** 4 + * Flattened file set for quick lookups. 5 + * Maps file paths to true for existence checks. 6 + */ 7 + export type FileSet = Set<string> 8 + 9 + /** 10 + * Flatten a nested file tree into a set of file paths for quick lookups. 11 + */ 12 + export function flattenFileTree(tree: PackageFileTree[]): FileSet { 13 + const files = new Set<string>() 14 + 15 + function traverse(nodes: PackageFileTree[]) { 16 + for (const node of nodes) { 17 + if (node.type === 'file') { 18 + files.add(node.path) 19 + } 20 + else if (node.children) { 21 + traverse(node.children) 22 + } 23 + } 24 + } 25 + 26 + traverse(tree) 27 + return files 28 + } 29 + 30 + /** 31 + * Normalize a path by resolving . and .. segments 32 + */ 33 + function normalizePath(path: string): string { 34 + const parts = path.split('/') 35 + const result: string[] = [] 36 + 37 + for (const part of parts) { 38 + if (part === '.' || part === '') { 39 + continue 40 + } 41 + if (part === '..') { 42 + result.pop() 43 + } 44 + else { 45 + result.push(part) 46 + } 47 + } 48 + 49 + return result.join('/') 50 + } 51 + 52 + /** 53 + * Get the directory of a file path. 54 + */ 55 + function dirname(path: string): string { 56 + const lastSlash = path.lastIndexOf('/') 57 + return lastSlash === -1 ? '' : path.substring(0, lastSlash) 58 + } 59 + 60 + /** 61 + * Get file extension priority order based on source file type. 62 + */ 63 + function getExtensionPriority(sourceFile: string): string[][] { 64 + const ext = sourceFile.split('.').slice(1).join('.') 65 + 66 + // Declaration files prefer other declaration files 67 + if (ext === 'd.ts' || ext === 'd.mts' || ext === 'd.cts') { 68 + return [ 69 + [], // exact match first 70 + ['.d.ts', '.d.mts', '.d.cts'], 71 + ['.ts', '.mts', '.cts'], 72 + ['.js', '.mjs', '.cjs'], 73 + ['.tsx', '.jsx'], 74 + ['.json'], 75 + ] 76 + } 77 + 78 + // TypeScript files 79 + if (ext === 'ts' || ext === 'tsx') { 80 + return [ 81 + [], 82 + ['.ts', '.tsx'], 83 + ['.d.ts'], 84 + ['.js', '.jsx'], 85 + ['.json'], 86 + ] 87 + } 88 + 89 + if (ext === 'mts') { 90 + return [ 91 + [], 92 + ['.mts'], 93 + ['.d.mts', '.d.ts'], 94 + ['.mjs', '.js'], 95 + ['.json'], 96 + ] 97 + } 98 + 99 + if (ext === 'cts') { 100 + return [ 101 + [], 102 + ['.cts'], 103 + ['.d.cts', '.d.ts'], 104 + ['.cjs', '.js'], 105 + ['.json'], 106 + ] 107 + } 108 + 109 + // JavaScript files 110 + if (ext === 'js' || ext === 'jsx') { 111 + return [ 112 + [], 113 + ['.js', '.jsx'], 114 + ['.ts', '.tsx'], 115 + ['.json'], 116 + ] 117 + } 118 + 119 + if (ext === 'mjs') { 120 + return [ 121 + [], 122 + ['.mjs'], 123 + ['.js'], 124 + ['.mts', '.ts'], 125 + ['.json'], 126 + ] 127 + } 128 + 129 + if (ext === 'cjs') { 130 + return [ 131 + [], 132 + ['.cjs'], 133 + ['.js'], 134 + ['.cts', '.ts'], 135 + ['.json'], 136 + ] 137 + } 138 + 139 + // Default for other files (vue, svelte, etc.) 140 + return [ 141 + [], 142 + ['.ts', '.js'], 143 + ['.d.ts'], 144 + ['.json'], 145 + ] 146 + } 147 + 148 + /** 149 + * Get index file extensions to try for directory imports. 150 + */ 151 + function getIndexExtensions(sourceFile: string): string[] { 152 + const ext = sourceFile.split('.').slice(1).join('.') 153 + 154 + if (ext === 'd.ts' || ext === 'd.mts' || ext === 'd.cts') { 155 + return ['index.d.ts', 'index.d.mts', 'index.d.cts', 'index.ts', 'index.js'] 156 + } 157 + 158 + if (ext === 'mts' || ext === 'mjs') { 159 + return ['index.mts', 'index.mjs', 'index.ts', 'index.js'] 160 + } 161 + 162 + if (ext === 'cts' || ext === 'cjs') { 163 + return ['index.cts', 'index.cjs', 'index.ts', 'index.js'] 164 + } 165 + 166 + if (ext === 'ts' || ext === 'tsx') { 167 + return ['index.ts', 'index.tsx', 'index.js', 'index.jsx'] 168 + } 169 + 170 + return ['index.js', 'index.ts', 'index.mjs', 'index.cjs'] 171 + } 172 + 173 + export interface ResolvedImport { 174 + /** The resolved file path (relative to package root) */ 175 + path: string 176 + } 177 + 178 + /** 179 + * Resolve a relative import specifier to an actual file path. 180 + * 181 + * @param specifier - The import specifier (e.g., './utils', '../types') 182 + * @param currentFile - The current file path (e.g., 'dist/index.js') 183 + * @param files - Set of all file paths in the package 184 + * @returns The resolved path or null if not found 185 + */ 186 + export function resolveRelativeImport( 187 + specifier: string, 188 + currentFile: string, 189 + files: FileSet, 190 + ): ResolvedImport | null { 191 + // Remove quotes if present 192 + const cleanSpecifier = specifier.replace(/^['"]|['"]$/g, '').trim() 193 + 194 + // Only handle relative imports 195 + if (!cleanSpecifier.startsWith('.')) { 196 + return null 197 + } 198 + 199 + // Get the directory of the current file 200 + const currentDir = dirname(currentFile) 201 + 202 + // Resolve the path relative to current directory 203 + const basePath = currentDir 204 + ? normalizePath(`${currentDir}/${cleanSpecifier}`) 205 + : normalizePath(cleanSpecifier) 206 + 207 + // If path is empty or goes above root, return null 208 + if (!basePath || basePath.startsWith('..')) { 209 + return null 210 + } 211 + 212 + // Get extension priority based on source file 213 + const extensionGroups = getExtensionPriority(currentFile) 214 + const indexExtensions = getIndexExtensions(currentFile) 215 + 216 + // Try each extension group in priority order 217 + for (const extensions of extensionGroups) { 218 + if (extensions.length === 0) { 219 + // Try exact match 220 + if (files.has(basePath)) { 221 + return { path: basePath } 222 + } 223 + } 224 + else { 225 + // Try with extensions 226 + for (const ext of extensions) { 227 + const pathWithExt = basePath + ext 228 + if (files.has(pathWithExt)) { 229 + return { path: pathWithExt } 230 + } 231 + } 232 + } 233 + } 234 + 235 + // Try as directory with index file 236 + for (const indexFile of indexExtensions) { 237 + const indexPath = `${basePath}/${indexFile}` 238 + if (files.has(indexPath)) { 239 + return { path: indexPath } 240 + } 241 + } 242 + 243 + return null 244 + } 245 + 246 + /** 247 + * Create a resolver function bound to a specific file tree and current file. 248 + */ 249 + export function createImportResolver( 250 + files: FileSet, 251 + currentFile: string, 252 + packageName: string, 253 + version: string, 254 + ): (specifier: string) => string | null { 255 + return (specifier: string) => { 256 + const resolved = resolveRelativeImport(specifier, currentFile, files) 257 + if (resolved) { 258 + return `/package/code/${packageName}/v/${version}/${resolved.path}` 259 + } 260 + return null 261 + } 262 + }
+62
server/utils/npm.ts
··· 1 1 import type { Packument, NpmSearchResponse, NpmDownloadCount } from '#shared/types' 2 + import { maxSatisfying, prerelease } from 'semver' 2 3 3 4 const NPM_REGISTRY = 'https://registry.npmjs.org' 4 5 const NPM_API = 'https://api.npmjs.org' ··· 49 50 getKey: (name: string, period: string) => `${name}:${period}`, 50 51 }, 51 52 ) 53 + 54 + /** 55 + * Check if a version constraint explicitly includes a prerelease tag. 56 + * e.g., "^1.0.0-alpha" or ">=2.0.0-beta.1" include prereleases 57 + */ 58 + function constraintIncludesPrerelease(constraint: string): boolean { 59 + // Look for prerelease identifiers in the constraint 60 + return /-(alpha|beta|rc|next|canary|dev|preview|pre|experimental)/i.test(constraint) 61 + || /-\d/.test(constraint) // e.g., -0, -1 62 + } 63 + 64 + /** 65 + * Resolve a semver version constraint to the best matching version. 66 + * Returns the highest version that satisfies the constraint, or null if none match. 67 + * 68 + * By default, excludes prerelease versions unless the constraint explicitly 69 + * includes a prerelease tag (e.g., "^1.0.0-beta"). 70 + */ 71 + export async function resolveVersionConstraint( 72 + packageName: string, 73 + constraint: string, 74 + ): Promise<string | null> { 75 + try { 76 + const packument = await fetchNpmPackage(packageName) 77 + let versions = Object.keys(packument.versions) 78 + 79 + // Filter out prerelease versions unless constraint explicitly includes one 80 + if (!constraintIncludesPrerelease(constraint)) { 81 + versions = versions.filter(v => !prerelease(v)) 82 + } 83 + 84 + return maxSatisfying(versions, constraint) 85 + } 86 + catch { 87 + return null 88 + } 89 + } 90 + 91 + /** 92 + * Resolve multiple dependency constraints to their best matching versions. 93 + * Returns a map of package name to resolved version. 94 + */ 95 + export async function resolveDependencyVersions( 96 + dependencies: Record<string, string>, 97 + ): Promise<Record<string, string>> { 98 + const entries = Object.entries(dependencies) 99 + const results = await Promise.all( 100 + entries.map(async ([name, constraint]) => { 101 + const resolved = await resolveVersionConstraint(name, constraint) 102 + return [name, resolved] as const 103 + }), 104 + ) 105 + 106 + const resolved: Record<string, string> = {} 107 + for (const [name, version] of results) { 108 + if (version) { 109 + resolved[name] = version 110 + } 111 + } 112 + return resolved 113 + }