UNPKG

vite-tsconfig-paths

Version:

Vite resolver for TypeScript compilerOptions.paths

471 lines (418 loc) 14.7 kB
import _debug from 'debug' import * as fs from 'fs' import globRex from 'globrex' import { resolve } from 'path' import type { TSConfckParseNativeResult, TSConfckParseOptions, TSConfckParseResult, } from 'tsconfck' import type { CompilerOptions } from 'typescript' import { inspect } from 'util' import { normalizePath, Plugin, searchForWorkspaceRoot } from 'vite' import { resolvePathMappings } from './mappings' import { basename, dirname, isAbsolute, join, relative } from './path' import { PluginOptions } from './types' import { debug, debugResolve } from './debug' const notApplicable = [undefined, false] as const const notFound = [undefined, true] as const type ViteResolve = (id: string, importer: string) => Promise<string | undefined> type Resolver = ( viteResolve: ViteResolve, id: string, importer: string ) => Promise<readonly [resolved: string | undefined, matched: boolean]> export type { PluginOptions } export default (opts: PluginOptions = {}): Plugin => { let resolversByDir: Record<string, Resolver[]> return { name: 'vite-tsconfig-paths', enforce: 'pre', async configResolved(config) { let projectRoot = config.root let workspaceRoot!: string let { root } = opts if (root) { root = resolve(projectRoot, root) } else { workspaceRoot = searchForWorkspaceRoot(projectRoot) } debug('options.root ==', root) debug('project root ==', projectRoot) debug('workspace root ==', workspaceRoot) // The "root" option overrides both of these. if (root) { projectRoot = root workspaceRoot = root } const tsconfck = await import('tsconfck') const projects = opts.projects ? opts.projects.map((file) => { if (!file.endsWith('.json')) { file = join(file, 'tsconfig.json') } return resolve(projectRoot, file) }) : await tsconfck.findAll(workspaceRoot, { configNames: opts.configNames || ['tsconfig.json', 'jsconfig.json'], skip(dir) { if (dir === '.git' || dir === 'node_modules') { return true } if (typeof opts.skip === 'function') { return opts.skip(dir) } return false }, }) debug('projects:', projects) let hasTypeScriptDep = false if (opts.parseNative) { try { const pkgJson = fs.readFileSync( join(workspaceRoot, 'package.json'), 'utf8' ) const pkg = JSON.parse(pkgJson) const deps = { ...pkg.dependencies, ...pkg.devDependencies } hasTypeScriptDep = 'typescript' in deps } catch (e: any) { if (e.code != 'ENOENT') { throw e } } } let firstError: any const parseOptions: TSConfckParseOptions = { cache: new tsconfck.TSConfckCache(), } const parsedProjects = new Set( await Promise.all( projects.map((tsconfigFile) => { if (tsconfigFile === null) { debug('tsconfig file not found:', tsconfigFile) return null } return ( hasTypeScriptDep ? tsconfck.parseNative(tsconfigFile, parseOptions) : tsconfck.parse(tsconfigFile, parseOptions) ).catch((error) => { if (opts.ignoreConfigErrors) { debug('tsconfig file caused a parsing error:', tsconfigFile) } else { config.logger.error( '[tsconfig-paths] An error occurred while parsing "' + tsconfigFile + '". See below for details.' + (firstError ? '' : ' To disable this message, set the `ignoreConfigErrors` option to true.'), { error } ) if (config.logger.hasErrorLogged(error)) { console.error(error) } firstError = error } return null }) }) ) ) resolversByDir = {} parsedProjects.forEach((project) => { if (!project) { return } // Don't create a resolver for projects with a references array. // Instead, create a resolver for each project in that array. if (project.referenced) { project.referenced.forEach((projectRef) => { parsedProjects.add(projectRef) }) // Reinsert the parent project so it's tried last. This is // important because project references can be used to // override the parent project. parsedProjects.delete(project) parsedProjects.add(project) project.referenced = undefined } else { const resolver = createResolver(project) if (resolver) { const projectDir = normalizePath(dirname(project.tsconfigFile)) const resolvers = (resolversByDir[projectDir] ||= []) resolvers.push(resolver) } } }) }, async resolveId(id, importer, options) { if (debugResolve.enabled) { debugResolve('resolving:', { id, importer }) } if (!importer) { debugResolve('importer is empty or undefined. skipping...') return } if (relativeImportRE.test(id)) { debugResolve('id is a relative import. skipping...') return } if (isAbsolute(id)) { debugResolve('id is an absolute path. skipping...') return } if (id.includes('\0')) { debugResolve('id is a virtual module. skipping...') return } // For Vite 4 and under, skipSelf needs to be set. const resolveOptions = { ...options, skipSelf: true } const viteResolve: ViteResolve = async (id, importer) => (await this.resolve(id, importer, resolveOptions))?.id let prevProjectDir: string | undefined let projectDir = normalizePath(dirname(importer)) // Find the nearest directory with a matching tsconfig file. loop: while (projectDir && projectDir != prevProjectDir) { const resolvers = resolversByDir[projectDir] if (resolvers) { for (const resolve of resolvers) { const [resolved, matched] = await resolve(viteResolve, id, importer) if (resolved) { return resolved } if (matched) { // Once a matching resolver is found, stop looking. break loop } } } prevProjectDir = projectDir projectDir = dirname(prevProjectDir) } }, } type TsConfig = { files?: string[] include?: string[] exclude?: string[] compilerOptions?: CompilerOptions } function resolvePathsRootDir( project: TSConfckParseResult | TSConfckParseNativeResult ): string { if ('result' in project) { return ( project.result.options?.pathsBasePath ?? dirname(project.tsconfigFile) ) } const baseUrl = (project.tsconfig as TsConfig).compilerOptions?.baseUrl if (baseUrl) { return baseUrl } const projectWithPaths = project.extended?.find( (p) => (p.tsconfig as TsConfig).compilerOptions?.paths ) return dirname((projectWithPaths ?? project).tsconfigFile) } function createResolver( project: TSConfckParseResult | TSConfckParseNativeResult ): Resolver | null { const configPath = normalizePath(project.tsconfigFile) const config = project.tsconfig as TsConfig debug('config loaded:', inspect({ configPath, config }, false, 10, true)) // Sometimes a tsconfig is not meant to be used for path resolution, // but rather for pointing to other tsconfig files and possibly // being extended by them. This is represented by an explicitly // empty "files" array and a missing/empty "include" array. if (config.files?.length == 0 && !config.include?.length) { debug( `[!] skipping "${configPath}" as no files can be matched since "files" is empty and "include" is missing or empty` ) return null } const options = config.compilerOptions || {} const { baseUrl, paths } = options if (!baseUrl && !paths) { debug(`[!] missing baseUrl and paths: "${configPath}"`) return null } type InternalResolver = ( viteResolve: ViteResolve, id: string, importer: string ) => Promise<string | undefined> const resolveWithBaseUrl: InternalResolver | undefined = baseUrl ? (viteResolve, id, importer) => { const absoluteId = join(baseUrl, id) debugResolve('trying with baseUrl:', absoluteId) return viteResolve(absoluteId, importer) } : undefined let resolveId: InternalResolver if (paths) { const pathsRootDir = resolvePathsRootDir(project) const pathMappings = resolvePathMappings(paths, pathsRootDir) const resolveWithPaths: InternalResolver = async ( viteResolve, id, importer ) => { for (const mapping of pathMappings) { const match = id.match(mapping.pattern) if (!match) { continue } for (let pathTemplate of mapping.paths) { let starCount = 0 const mappedId = pathTemplate.replace(/\*/g, () => { // There may exist more globs in the path template than in // the match pattern. In that case, we reuse the final // glob match. const matchIndex = Math.min(++starCount, match.length - 1) return match[matchIndex] }) debugResolve('found match, trying to resolve:', mappedId) const resolved = await viteResolve(mappedId, importer) if (resolved) { return resolved } } } } if (resolveWithBaseUrl) { resolveId = (viteResolve, id, importer) => resolveWithPaths(viteResolve, id, importer).then((resolved) => { return resolved ?? resolveWithBaseUrl(viteResolve, id, importer) }) } else { resolveId = resolveWithPaths } } else { resolveId = resolveWithBaseUrl! } const configDir = dirname(configPath) // When `tsconfck.parseNative` is used, the outDir is absolute, // which is not what `getIncluder` expects. let { outDir } = options if (outDir && isAbsolute(outDir)) { outDir = relative(configDir, outDir) } const isIncludedRelative = getIncluder( config.include?.map((p) => ensureRelative(configDir, p)), config.exclude?.map((p) => ensureRelative(configDir, p)), outDir ) const importerExtRE = opts.loose ? /./ : options.allowJs || basename(configPath).startsWith('jsconfig.') ? jsLikeRE : /\.[mc]?tsx?$/ const resolutionCache = new Map<string, string>() return async (viteResolve, id, importer) => { // Ideally, Vite would normalize the importer path for us. importer = normalizePath(importer) // Remove query and hash parameters from the importer path. const importerFile = importer.replace(/[#?].+$/, '') // Ignore importers with unsupported extensions. if (!importerExtRE.test(importerFile)) { debugResolve('importer has unsupported extension. skipping...') return notApplicable } // Respect the include/exclude properties. const relativeImporterFile = relative(configDir, importerFile) if (!isIncludedRelative(relativeImporterFile)) { debugResolve('importer is not included. skipping...') return notApplicable } // Find and remove Vite's suffix (e.g. "?url") if present. // If the path is resolved, the suffix will be added back. const suffix = /\?.+$/.exec(id)?.[0] if (suffix) { id = id.slice(0, -suffix.length) } let resolvedId = resolutionCache.get(id) if (!resolvedId) { resolvedId = await resolveId(viteResolve, id, importer) if (!resolvedId) { return notFound } resolutionCache.set(id, resolvedId) if (debugResolve.enabled) { debugResolve('resolved without error:', { id, importer, resolvedId, configPath, }) } } // Restore the suffix if one was removed earlier. if (suffix) { resolvedId += suffix } return [resolvedId, true] } } } const jsLikeRE = /\.(vue|svelte|mdx|[mc]?[jt]sx?)$/ const relativeImportRE = /^\.\.?(\/|$)/ const defaultInclude = ['**/*'] const defaultExclude = [ '**/node_modules', '**/bower_components', '**/jspm_packages', ] /** * The returned function does not support absolute paths. * Be sure to call `path.relative` on your path first. */ function getIncluder( includePaths = defaultInclude, excludePaths = defaultExclude, outDir?: string ) { if (outDir) { excludePaths = excludePaths.concat(outDir) } if (includePaths.length || excludePaths.length) { const includers: RegExp[] = [] const excluders: RegExp[] = [] includePaths.forEach(addCompiledGlob, includers) excludePaths.forEach(addCompiledGlob, excluders) debug(`compiled globs:`, { includers, excluders }) return (path: string) => { path = path.replace(/\?.+$/, '') if (!relativeImportRE.test(path)) { path = './' + path } const test = (glob: RegExp) => glob.test(path) return includers.some(test) && !excluders.some(test) } } return () => true } function addCompiledGlob(this: RegExp[], glob: string) { const endsWithGlob = glob.split('/').pop()!.includes('*') const relativeGlob = relativeImportRE.test(glob) ? glob : './' + glob if (endsWithGlob) { this.push(compileGlob(relativeGlob)) } else { // Append a globstar to possible directories. this.push(compileGlob(relativeGlob + '/**')) // Try to match specific files (must have file extension). if (/\.\w+$/.test(glob)) { this.push(compileGlob(relativeGlob)) } } } function compileGlob(glob: string) { return globRex(glob, { extended: true, globstar: true, }).regex } function ensureRelative(dir: string, path: string) { return isAbsolute(path) ? relative(dir, path) : path }