Skip to content
10 changes: 9 additions & 1 deletion server/api/registry/file/[...pkg].get.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -27,6 +28,7 @@ interface PackageJson {
devDependencies?: Record<string, string>
peerDependencies?: Record<string, string>
optionalDependencies?: Record<string, string>
imports?: InternalImportsMap
}

/**
Expand Down Expand Up @@ -161,7 +163,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,
)
}
}

Expand Down
7 changes: 7 additions & 0 deletions server/utils/code-highlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,13 @@ export function linkifyModuleSpecifiers(html: string, options?: LinkifyOptions):
return resolveRelative(moduleSpecifier)
}

if (
(cleanSpec.startsWith('#') || cleanSpec.startsWith('~') || cleanSpec.startsWith('@/')) &&
resolveRelative
) {
return resolveRelative(moduleSpecifier)
}

// Not a relative import - check if it's an npm package
if (!isNpmPackage(moduleSpecifier)) {
return null
Expand Down
164 changes: 163 additions & 1 deletion server/utils/import-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,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('/')
}
Comment on lines +117 to +133
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle empty alias roots (@, ~, #) explicitly.

After normalisation, aliases like @/~/ become empty, and this segment-based lookup can return null for valid targets such as ./src (no empty segment), so aliased links still fail in common setups.

Proposed fix
 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 normalizedFilePath = filePath.replace(/\/+$/, '')
+  if (!normalizedFilePath) {
+    return null
+  }
+  if (alias === '') {
+    return normalizedFilePath
+  }
-  const segments = filePath.split('/')
+  const segments = normalizedFilePath.split('/')


/**
* Get index file extensions to try for directory imports.
*/
Expand Down Expand Up @@ -131,6 +162,10 @@ export interface ResolvedImport {
path: string
}

export type InternalImportTarget = string | { default?: string; import?: string } | null | undefined

export type InternalImportsMap = Record<string, InternalImportTarget>

/**
* Resolve a relative import specifier to an actual file path.
*
Expand Down Expand Up @@ -198,6 +233,129 @@ 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 normalizeAliasPrefix(value: string): string {
return value.replace(/^([#~@])\//, '$1')
}

function guessInternalImportTarget(
imports: InternalImportsMap,
specifier: string,
files: FileSet,
currentFile: string,
): string | null {
const normalizedSpecifier = normalizeAliasPrefix(specifier)

for (const [key, value] of Object.entries(imports)) {
const normalizedKey = normalizeAliasPrefix(key)
if (
normalizedSpecifier === normalizedKey ||
normalizedSpecifier.startsWith(`${normalizedKey}/`)
) {
const basePath = resolveAliasToDir(key, normalizeInternalImportTarget(value))
if (!basePath) continue

const suffix = normalizedSpecifier.slice(normalizedKey.length).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
Comment on lines +341 to +353
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add extension/index fallback for exact internal targets.

Exact imports[specifier] targets are currently accepted only on direct files.has(path). Extensionless or directory targets (for example ./dist/app) will incorrectly resolve to null even when resolvable files exist.

Proposed fix
   const path = normalizePath(target)
-  if (!path || path.startsWith('..') || !files.has(path)) {
+  if (!path || path.startsWith('..')) {
     return null
   }
 
-  return { path }
+  if (files.has(path)) {
+    return { path }
+  }
+
+  for (const extensions of getExtensionPriority(currentFile)) {
+    for (const ext of extensions) {
+      const candidate = `${path}${ext}`
+      if (files.has(candidate)) {
+        return { path: candidate }
+      }
+    }
+  }
+
+  for (const indexFile of getIndexExtensions(currentFile)) {
+    const candidate = `${path}/${indexFile}`
+    if (files.has(candidate)) {
+      return { path: candidate }
+    }
+  }
+
+  return null
 }

}

return { path }
}

/**
* Create a resolver function bound to a specific file tree and current file.
*/
Expand All @@ -206,9 +364,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}`
}
Expand Down
119 changes: 119 additions & 0 deletions test/unit/server/utils/import-resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { PackageFileTree } from '../../../../shared/types'
import {
createImportResolver,
flattenFileTree,
resolveInternalImport,
resolveRelativeImport,
} from '../../../../server/utils/import-resolver'

Expand Down Expand Up @@ -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<string>(['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<string>(['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<string>(['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<string>(['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<string>(['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<string>(['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<string>(['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<string>(['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')
})
})
Loading