From c25cafca86d06b38b77450cc8839af39566d3036 Mon Sep 17 00:00:00 2001 From: eryue0220 Date: Fri, 13 Mar 2026 10:24:51 +0800 Subject: [PATCH 1/4] wip --- server/api/registry/file/[...pkg].get.ts | 11 ++- server/utils/code-highlight.ts | 4 ++ server/utils/import-resolver.ts | 59 +++++++++++++++- .../unit/server/utils/import-resolver.spec.ts | 70 +++++++++++++++++++ 4 files changed, 142 insertions(+), 2 deletions(-) diff --git a/server/api/registry/file/[...pkg].get.ts b/server/api/registry/file/[...pkg].get.ts index c1dd76c1b3..2700517148 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, + ) } } @@ -200,6 +208,7 @@ export default defineCachedEventHandler( { // File content for a specific version never changes - cache permanently maxAge: CACHE_MAX_AGE_ONE_YEAR, // 1 year + shouldBypassCache: () => import.meta.dev, getKey: event => { const pkg = getRouterParam(event, 'pkg') ?? '' return `file:v${CACHE_VERSION}:${pkg.replace(/\/+$/, '').trim()}` 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..8e62ef4a01 100644 --- a/server/utils/import-resolver.ts +++ b/server/utils/import-resolver.ts @@ -133,6 +133,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 +204,55 @@ 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 +} + +/** + * 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, + imports: InternalImportsMap | undefined, + files: FileSet, +): ResolvedImport | null { + const cleanSpecifier = specifier.replace(/^['"]|['"]$/g, '').trim() + + if ((!cleanSpecifier.startsWith('#') && !cleanSpecifier.startsWith('~')) || !imports) { + return null + } + + const target = normalizeInternalImportTarget(imports[cleanSpecifier]) + console.log('resolved internal import', imports, cleanSpecifier, target) + 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 +261,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, internalImports, files) + const resolved = 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..36a3f4a4fa 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,73 @@ 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('resolveInternalSpecifier', () => { + it('resolves exact imports map matches to files in the package', () => { + const files = new Set(['dist/app/nuxt.js']) + + const resolved = resolveInternalImport( + '#app/nuxt', + { + '#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', + { + '#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', + { + '#app/nuxt': './dist/app/nuxt.js', + }, + files, + ) + + expect(resolved).toBeNull() + }) + + it('returns null for unknown internal specifiers', () => { + const files = new Set(['dist/app/nuxt.js']) + + const resolved = resolveInternalImport( + '#app/nuxt', + { + '#app': './dist/app/index.js', + }, + files, + ) + + expect(resolved).toBeNull() + }) }) From 83503cc7bcb0e933f1817400e073a81071cd1f6d Mon Sep 17 00:00:00 2001 From: eryue0220 Date: Fri, 13 Mar 2026 12:03:01 +0800 Subject: [PATCH 2/4] fix issues --- server/utils/import-resolver.ts | 98 ++++++++++++++++++- .../unit/server/utils/import-resolver.spec.ts | 8 +- 2 files changed, 100 insertions(+), 6 deletions(-) diff --git a/server/utils/import-resolver.ts b/server/utils/import-resolver.ts index 8e62ef4a01..b7c4d1bb29 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. */ @@ -222,6 +253,60 @@ function normalizeInternalImportTarget(target: InternalImportTarget): string | n 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' @@ -230,6 +315,7 @@ function normalizeInternalImportTarget(target: InternalImportTarget): string | n */ export function resolveInternalImport( specifier: string, + currentFile: string, imports: InternalImportsMap | undefined, files: FileSet, ): ResolvedImport | null { @@ -239,8 +325,12 @@ export function resolveInternalImport( return null } - const target = normalizeInternalImportTarget(imports[cleanSpecifier]) - console.log('resolved internal import', imports, cleanSpecifier, target) + const importTarget = normalizeInternalImportTarget(imports[cleanSpecifier]) + const target = + importTarget != null + ? importTarget + : guessInternalImportTarget(imports, cleanSpecifier, files, currentFile) + if (!target || !target.startsWith('./')) { return null } @@ -265,8 +355,8 @@ export function createImportResolver( ): (specifier: string) => string | null { return (specifier: string) => { const relativeResolved = resolveRelativeImport(specifier, currentFile, files) - const internalResolved = resolveInternalImport(specifier, internalImports, files) - const resolved = relativeResolved ?? internalResolved + 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 36a3f4a4fa..9cab40267f 100644 --- a/test/unit/server/utils/import-resolver.spec.ts +++ b/test/unit/server/utils/import-resolver.spec.ts @@ -197,6 +197,7 @@ describe('resolveInternalSpecifier', () => { const resolved = resolveInternalImport( '#app/nuxt', + 'dist/index.js', { '#app/nuxt': './dist/app/nuxt.js', }, @@ -211,6 +212,7 @@ describe('resolveInternalSpecifier', () => { const resolved = resolveInternalImport( '#app/nuxt', + 'dist/index.js', { '#app/nuxt': { import: './dist/app/nuxt.js' }, }, @@ -225,6 +227,7 @@ describe('resolveInternalSpecifier', () => { const resolved = resolveInternalImport( '#app/nuxt', + 'dist/index.js', { '#app/nuxt': './dist/app/nuxt.js', }, @@ -234,17 +237,18 @@ describe('resolveInternalSpecifier', () => { expect(resolved).toBeNull() }) - it('returns null for unknown internal specifiers', () => { + it('resolves prefix matches with extension resolution via guessInternalImportTarget', () => { const files = new Set(['dist/app/nuxt.js']) const resolved = resolveInternalImport( '#app/nuxt', + 'dist/index.js', { '#app': './dist/app/index.js', }, files, ) - expect(resolved).toBeNull() + expect(resolved?.path).toBe('dist/app/nuxt.js') }) }) From 9ac82cca0b546f7a733557bbd3522f675f20c84f Mon Sep 17 00:00:00 2001 From: eryue0220 Date: Fri, 13 Mar 2026 16:33:51 +0800 Subject: [PATCH 3/4] add test cases --- server/utils/import-resolver.ts | 7 ++- .../unit/server/utils/import-resolver.spec.ts | 53 +++++++++++++++++-- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/server/utils/import-resolver.ts b/server/utils/import-resolver.ts index b7c4d1bb29..7008b387f8 100644 --- a/server/utils/import-resolver.ts +++ b/server/utils/import-resolver.ts @@ -321,7 +321,12 @@ export function resolveInternalImport( ): ResolvedImport | null { const cleanSpecifier = specifier.replace(/^['"]|['"]$/g, '').trim() - if ((!cleanSpecifier.startsWith('#') && !cleanSpecifier.startsWith('~')) || !imports) { + if ( + (!cleanSpecifier.startsWith('#') && + !cleanSpecifier.startsWith('~') && + !cleanSpecifier.startsWith('@')) || + !imports + ) { return null } diff --git a/test/unit/server/utils/import-resolver.spec.ts b/test/unit/server/utils/import-resolver.spec.ts index 9cab40267f..97d4664b45 100644 --- a/test/unit/server/utils/import-resolver.spec.ts +++ b/test/unit/server/utils/import-resolver.spec.ts @@ -191,7 +191,7 @@ describe('createImportResolver', () => { }) }) -describe('resolveInternalSpecifier', () => { +describe('resolveInternalImport', () => { it('resolves exact imports map matches to files in the package', () => { const files = new Set(['dist/app/nuxt.js']) @@ -238,10 +238,10 @@ describe('resolveInternalSpecifier', () => { }) it('resolves prefix matches with extension resolution via guessInternalImportTarget', () => { - const files = new Set(['dist/app/nuxt.js']) + const files = new Set(['dist/app/components/button.js']) const resolved = resolveInternalImport( - '#app/nuxt', + '#app/components/button.js', 'dist/index.js', { '#app': './dist/app/index.js', @@ -249,6 +249,51 @@ describe('resolveInternalSpecifier', () => { files, ) - expect(resolved?.path).toBe('dist/app/nuxt.js') + 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') }) }) From e6a8d1605c1b5b1b1b009ca0bb77e41dfb2c8d1f Mon Sep 17 00:00:00 2001 From: eryue0220 Date: Fri, 13 Mar 2026 16:44:38 +0800 Subject: [PATCH 4/4] remove test code --- server/api/registry/file/[...pkg].get.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/api/registry/file/[...pkg].get.ts b/server/api/registry/file/[...pkg].get.ts index 2700517148..f1ea01dc22 100644 --- a/server/api/registry/file/[...pkg].get.ts +++ b/server/api/registry/file/[...pkg].get.ts @@ -208,7 +208,6 @@ export default defineCachedEventHandler( { // File content for a specific version never changes - cache permanently maxAge: CACHE_MAX_AGE_ONE_YEAR, // 1 year - shouldBypassCache: () => import.meta.dev, getKey: event => { const pkg = getRouterParam(event, 'pkg') ?? '' return `file:v${CACHE_VERSION}:${pkg.replace(/\/+$/, '').trim()}`