diff --git a/server/api/registry/file/[...pkg].get.ts b/server/api/registry/file/[...pkg].get.ts index c1dd76c1b3..f1ea01dc22 100644 --- a/server/api/registry/file/[...pkg].get.ts +++ b/server/api/registry/file/[...pkg].get.ts @@ -1,4 +1,5 @@ import * as v from 'valibot' +import type { InternalImportsMap } from '#server/utils/import-resolver' import { PackageFileQuerySchema } from '#shared/schemas/package' import type { ReadmeResponse } from '#shared/types/readme' import { @@ -27,6 +28,7 @@ interface PackageJson { devDependencies?: Record peerDependencies?: Record optionalDependencies?: Record + imports?: InternalImportsMap } /** @@ -159,7 +161,13 @@ export default defineCachedEventHandler( // Create resolver for relative imports if (fileTreeResponse) { const files = flattenFileTree(fileTreeResponse.tree) - resolveRelative = createImportResolver(files, filePath, packageName, version) + resolveRelative = createImportResolver( + files, + filePath, + packageName, + version, + pkgJson?.imports, + ) } } diff --git a/server/utils/code-highlight.ts b/server/utils/code-highlight.ts index cbb7669af1..ed996e01e5 100644 --- a/server/utils/code-highlight.ts +++ b/server/utils/code-highlight.ts @@ -178,6 +178,10 @@ export function linkifyModuleSpecifiers(html: string, options?: LinkifyOptions): return resolveRelative(moduleSpecifier) } + if ((cleanSpec.startsWith('#') || cleanSpec.startsWith('~')) && resolveRelative) { + return resolveRelative(moduleSpecifier) + } + // Not a relative import - check if it's an npm package if (!isNpmPackage(moduleSpecifier)) { return null diff --git a/server/utils/import-resolver.ts b/server/utils/import-resolver.ts index 38be249329..7008b387f8 100644 --- a/server/utils/import-resolver.ts +++ b/server/utils/import-resolver.ts @@ -103,6 +103,37 @@ function getExtensionPriority(sourceFile: string): string[][] { return [[], ['.ts', '.js'], ['.d.ts'], ['.json']] } +/** + * Resolve an alias specifier to the directory path within a file path. + * Supports #, ~, and @ prefixes (e.g. #app, ~/app, @/app). + * The alias must match a path segment exactly (no partial matches). + */ +export function resolveAliasToDir(aliasSpec: string, filePath?: string | null): string | null { + if ( + (!aliasSpec.startsWith('#') && !aliasSpec.startsWith('~') && !aliasSpec.startsWith('@')) || + !filePath + ) { + return null + } + + // Support #app, #/app, ~app, ~/app, @app, @/app + const alias = aliasSpec.replace(/^[#~@]\/?/, '') + const segments = filePath.split('/') + + let lastMatchIndex = -1 + for (let i = 0; i < segments.length; i++) { + if (segments[i] === alias) { + lastMatchIndex = i + } + } + + if (lastMatchIndex === -1) { + return null + } + + return segments.slice(0, lastMatchIndex + 1).join('/') +} + /** * Get index file extensions to try for directory imports. */ @@ -133,6 +164,10 @@ export interface ResolvedImport { path: string } +export type InternalImportTarget = string | { default?: string; import?: string } | null | undefined + +export type InternalImportsMap = Record + /** * Resolve a relative import specifier to an actual file path. * @@ -200,6 +235,119 @@ export function resolveRelativeImport( return null } +function normalizeInternalImportTarget(target: InternalImportTarget): string | null { + if (typeof target === 'string') { + return target + } + + if (target && typeof target === 'object') { + if (typeof target.import === 'string') { + return target.import + } + + if (typeof target.default === 'string') { + return target.default + } + } + + return null +} + +function guessInternalImportTarget( + imports: InternalImportsMap, + specifier: string, + files: FileSet, + currentFile: string, +): string | null { + for (const [key, value] of Object.entries(imports)) { + if (specifier.startsWith(key)) { + const basePath = resolveAliasToDir(key, normalizeInternalImportTarget(value)) + if (!basePath) continue + + const suffix = specifier.substring(key.length).trim().replace(/^\//, '') + const pathWithoutExt = suffix ? `${basePath}/${suffix}` : basePath + + const toCheckPath = (p: string) => files.has(normalizePath(p)) || files.has(p) + + // Path already has an extension-like suffix on the last segment - return as is if exists + const filename = pathWithoutExt.split('/').pop() ?? '' + if (filename.includes('.') && !filename.endsWith('.')) { + if (toCheckPath(pathWithoutExt)) { + return pathWithoutExt.startsWith('./') ? pathWithoutExt : `./${pathWithoutExt}` + } + return null + } + + // Try adding extensions based on currentFile type + const extensionGroups = getExtensionPriority(currentFile) + for (const extensions of extensionGroups) { + if (extensions.length === 0) { + if (toCheckPath(pathWithoutExt)) { + return pathWithoutExt.startsWith('./') ? pathWithoutExt : `./${pathWithoutExt}` + } + } else { + for (const ext of extensions) { + const pathWithExt = pathWithoutExt + ext + if (toCheckPath(pathWithExt)) { + return pathWithExt.startsWith('./') ? pathWithExt : `./${pathWithExt}` + } + } + } + } + + // Try as directory with index file + for (const indexFile of getIndexExtensions(currentFile)) { + const indexPath = `${pathWithoutExt}/${indexFile}` + if (toCheckPath(indexPath)) { + return indexPath.startsWith('./') ? indexPath : `./${indexPath}` + } + } + } + } + return null +} + +/** + * import ... from '#components/Button.vue' + * import ... from '#/components/Button.vue' + * import ... from '~/components/Button.vue' + * import ... from '~components/Button.vue' + */ +export function resolveInternalImport( + specifier: string, + currentFile: string, + imports: InternalImportsMap | undefined, + files: FileSet, +): ResolvedImport | null { + const cleanSpecifier = specifier.replace(/^['"]|['"]$/g, '').trim() + + if ( + (!cleanSpecifier.startsWith('#') && + !cleanSpecifier.startsWith('~') && + !cleanSpecifier.startsWith('@')) || + !imports + ) { + return null + } + + const importTarget = normalizeInternalImportTarget(imports[cleanSpecifier]) + const target = + importTarget != null + ? importTarget + : guessInternalImportTarget(imports, cleanSpecifier, files, currentFile) + + if (!target || !target.startsWith('./')) { + return null + } + + const path = normalizePath(target) + if (!path || path.startsWith('..') || !files.has(path)) { + return null + } + + return { path } +} + /** * Create a resolver function bound to a specific file tree and current file. */ @@ -208,9 +356,13 @@ export function createImportResolver( currentFile: string, packageName: string, version: string, + internalImports?: InternalImportsMap, ): (specifier: string) => string | null { return (specifier: string) => { - const resolved = resolveRelativeImport(specifier, currentFile, files) + const relativeResolved = resolveRelativeImport(specifier, currentFile, files) + const internalResolved = resolveInternalImport(specifier, currentFile, internalImports, files) + const resolved = relativeResolved != null ? relativeResolved : internalResolved + if (resolved) { return `/package-code/${packageName}/v/${version}/${resolved.path}` } diff --git a/test/unit/server/utils/import-resolver.spec.ts b/test/unit/server/utils/import-resolver.spec.ts index 840a013d2a..97d4664b45 100644 --- a/test/unit/server/utils/import-resolver.spec.ts +++ b/test/unit/server/utils/import-resolver.spec.ts @@ -3,6 +3,7 @@ import type { PackageFileTree } from '../../../../shared/types' import { createImportResolver, flattenFileTree, + resolveInternalImport, resolveRelativeImport, } from '../../../../server/utils/import-resolver' @@ -177,4 +178,122 @@ describe('createImportResolver', () => { expect(url).toBe('/package-code/@scope/pkg/v/1.2.3/dist/utils.js') }) + + it('resolves package imports aliases to code browser URLs', () => { + const files = new Set(['dist/app/nuxt.js']) + const resolver = createImportResolver(files, 'dist/index.js', 'nuxt', '4.3.1', { + '#app/nuxt': './dist/app/nuxt.js', + }) + + const url = resolver('#app/nuxt') + + expect(url).toBe('/package-code/nuxt/v/4.3.1/dist/app/nuxt.js') + }) +}) + +describe('resolveInternalImport', () => { + it('resolves exact imports map matches to files in the package', () => { + const files = new Set(['dist/app/nuxt.js']) + + const resolved = resolveInternalImport( + '#app/nuxt', + 'dist/index.js', + { + '#app/nuxt': './dist/app/nuxt.js', + }, + files, + ) + + expect(resolved?.path).toBe('dist/app/nuxt.js') + }) + + it('supports import condition objects', () => { + const files = new Set(['dist/app/nuxt.js']) + + const resolved = resolveInternalImport( + '#app/nuxt', + 'dist/index.js', + { + '#app/nuxt': { import: './dist/app/nuxt.js' }, + }, + files, + ) + + expect(resolved?.path).toBe('dist/app/nuxt.js') + }) + + it('returns null when the target file does not exist', () => { + const files = new Set(['dist/app/index.js']) + + const resolved = resolveInternalImport( + '#app/nuxt', + 'dist/index.js', + { + '#app/nuxt': './dist/app/nuxt.js', + }, + files, + ) + + expect(resolved).toBeNull() + }) + + it('resolves prefix matches with extension resolution via guessInternalImportTarget', () => { + const files = new Set(['dist/app/components/button.js']) + + const resolved = resolveInternalImport( + '#app/components/button.js', + 'dist/index.js', + { + '#app': './dist/app/index.js', + }, + files, + ) + + expect(resolved?.path).toBe('dist/app/components/button.js') + }) + + it('resolves file that could not found in the files', () => { + const files = new Set(['dist/app/index.js']) + + const resolved = resolveInternalImport( + '#app/components/button.js', + 'dist/index.js', + { + '#app': './dist/app/index.js', + }, + files, + ) + + expect(resolved).toBeNull() + }) + + it('resolves file that prefix is "~/"', () => { + const files = new Set(['dist/app/components/button.js']) + + const resolved = resolveInternalImport( + '~/app/components/button.js', + 'dist/index.js', + { + '~/app': './dist/app/index.js', + }, + files, + ) + + expect(resolved?.path).toBe('dist/app/components/button.js') + }) + + it('resolves file that prefix is "@/"', () => { + const files = new Set(['dist/app/components/button.js']) + + const resolved = resolveInternalImport( + '@/app/components/button.js', + 'dist/index.js', + { + '@/app': './dist/app/index.js', + }, + files, + ) + + expect(resolved?.path).toBe('dist/app/components/button.js') + }) })