forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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}