[READ-ONLY] a fast, modern browser for the npm registry
at main 219 lines 5.7 kB view raw
1import type { PackageFileTree } from '#shared/types' 2 3/** 4 * Flattened file set for quick lookups. 5 * Maps file paths to true for existence checks. 6 */ 7export type FileSet = Set<string> 8 9/** 10 * Flatten a nested file tree into a set of file paths for quick lookups. 11 */ 12export 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 } else if (node.children) { 20 traverse(node.children) 21 } 22 } 23 } 24 25 traverse(tree) 26 return files 27} 28 29/** 30 * Normalize a path by resolving . and .. segments 31 */ 32function normalizePath(path: string): string { 33 const parts = path.split('/') 34 const result: string[] = [] 35 36 for (const part of parts) { 37 if (part === '.' || part === '') { 38 continue 39 } 40 if (part === '..') { 41 result.pop() 42 } else { 43 result.push(part) 44 } 45 } 46 47 return result.join('/') 48} 49 50/** 51 * Get the directory of a file path. 52 */ 53function dirname(path: string): string { 54 const lastSlash = path.lastIndexOf('/') 55 return lastSlash === -1 ? '' : path.substring(0, lastSlash) 56} 57 58/** 59 * Get file extension priority order based on source file type. 60 */ 61function getExtensionPriority(sourceFile: string): string[][] { 62 const ext = sourceFile.split('.').slice(1).join('.') 63 64 // Declaration files prefer other declaration files 65 if (ext === 'd.ts' || ext === 'd.mts' || ext === 'd.cts') { 66 return [ 67 [], // exact match first 68 ['.d.ts', '.d.mts', '.d.cts'], 69 ['.ts', '.mts', '.cts'], 70 ['.js', '.mjs', '.cjs'], 71 ['.tsx', '.jsx'], 72 ['.json'], 73 ] 74 } 75 76 // TypeScript files 77 if (ext === 'ts' || ext === 'tsx') { 78 return [[], ['.ts', '.tsx'], ['.d.ts'], ['.js', '.jsx'], ['.json']] 79 } 80 81 if (ext === 'mts') { 82 return [[], ['.mts'], ['.d.mts', '.d.ts'], ['.mjs', '.js'], ['.json']] 83 } 84 85 if (ext === 'cts') { 86 return [[], ['.cts'], ['.d.cts', '.d.ts'], ['.cjs', '.js'], ['.json']] 87 } 88 89 // JavaScript files 90 if (ext === 'js' || ext === 'jsx') { 91 return [[], ['.js', '.jsx'], ['.ts', '.tsx'], ['.json']] 92 } 93 94 if (ext === 'mjs') { 95 return [[], ['.mjs'], ['.js'], ['.mts', '.ts'], ['.json']] 96 } 97 98 if (ext === 'cjs') { 99 return [[], ['.cjs'], ['.js'], ['.cts', '.ts'], ['.json']] 100 } 101 102 // Default for other files (vue, svelte, etc.) 103 return [[], ['.ts', '.js'], ['.d.ts'], ['.json']] 104} 105 106/** 107 * Get index file extensions to try for directory imports. 108 */ 109function getIndexExtensions(sourceFile: string): string[] { 110 const ext = sourceFile.split('.').slice(1).join('.') 111 112 if (ext === 'd.ts' || ext === 'd.mts' || ext === 'd.cts') { 113 return ['index.d.ts', 'index.d.mts', 'index.d.cts', 'index.ts', 'index.js'] 114 } 115 116 if (ext === 'mts' || ext === 'mjs') { 117 return ['index.mts', 'index.mjs', 'index.ts', 'index.js'] 118 } 119 120 if (ext === 'cts' || ext === 'cjs') { 121 return ['index.cts', 'index.cjs', 'index.ts', 'index.js'] 122 } 123 124 if (ext === 'ts' || ext === 'tsx') { 125 return ['index.ts', 'index.tsx', 'index.js', 'index.jsx'] 126 } 127 128 return ['index.js', 'index.ts', 'index.mjs', 'index.cjs'] 129} 130 131export interface ResolvedImport { 132 /** The resolved file path (relative to package root) */ 133 path: string 134} 135 136/** 137 * Resolve a relative import specifier to an actual file path. 138 * 139 * @param specifier - The import specifier (e.g., './utils', '../types') 140 * @param currentFile - The current file path (e.g., 'dist/index.js') 141 * @param files - Set of all file paths in the package 142 * @returns The resolved path or null if not found 143 */ 144export function resolveRelativeImport( 145 specifier: string, 146 currentFile: string, 147 files: FileSet, 148): ResolvedImport | null { 149 // Remove quotes if present 150 const cleanSpecifier = specifier.replace(/^['"]|['"]$/g, '').trim() 151 152 // Only handle relative imports 153 if (!cleanSpecifier.startsWith('.')) { 154 return null 155 } 156 157 // Get the directory of the current file 158 const currentDir = dirname(currentFile) 159 160 // Resolve the path relative to current directory 161 const basePath = currentDir 162 ? normalizePath(`${currentDir}/${cleanSpecifier}`) 163 : normalizePath(cleanSpecifier) 164 165 // If path is empty or goes above root, return null 166 if (!basePath || basePath.startsWith('..')) { 167 return null 168 } 169 170 // Get extension priority based on source file 171 const extensionGroups = getExtensionPriority(currentFile) 172 const indexExtensions = getIndexExtensions(currentFile) 173 174 // Try each extension group in priority order 175 for (const extensions of extensionGroups) { 176 if (extensions.length === 0) { 177 // Try exact match 178 if (files.has(basePath)) { 179 return { path: basePath } 180 } 181 } else { 182 // Try with extensions 183 for (const ext of extensions) { 184 const pathWithExt = basePath + ext 185 if (files.has(pathWithExt)) { 186 return { path: pathWithExt } 187 } 188 } 189 } 190 } 191 192 // Try as directory with index file 193 for (const indexFile of indexExtensions) { 194 const indexPath = `${basePath}/${indexFile}` 195 if (files.has(indexPath)) { 196 return { path: indexPath } 197 } 198 } 199 200 return null 201} 202 203/** 204 * Create a resolver function bound to a specific file tree and current file. 205 */ 206export function createImportResolver( 207 files: FileSet, 208 currentFile: string, 209 packageName: string, 210 version: string, 211): (specifier: string) => string | null { 212 return (specifier: string) => { 213 const resolved = resolveRelativeImport(specifier, currentFile, files) 214 if (resolved) { 215 return `/package-code/${packageName}/v/${version}/${resolved.path}` 216 } 217 return null 218 } 219}