UNPKG

vite-tsconfig-paths

Version:

Vite resolver for TypeScript compilerOptions.paths

644 lines (575 loc) 18 kB
import globRex from 'globrex' import * as fs from 'node:fs' import { readdir } from 'node:fs/promises' import { isAbsolute, join, relative } from 'node:path' import { inspect } from 'node:util' import * as tsconfck from 'tsconfck' import * as vite from 'vite' import { debug } from './debug' import { LogFileWriter } from './logFile' import { resolvePathMappings } from './mappings' import type { NormalizedPath } from './path' import * as path from './path' import { Directory, PluginOptions, Project, Resolver, ViteResolve, } from './types' const notApplicable = [undefined, false] as const const notFound = [undefined, true] as const /** A directory with no projects. */ const emptyDirectory: Directory = { projects: Object.freeze([]) as any, lazyDiscovery: false, } type Logger = Pick<vite.Logger, 'error' | 'hasErrorLogged'> export type TsconfigResolvers = ReturnType<typeof createTsconfigResolvers> export function createTsconfigResolvers({ projectRoot, workspaceRoot, skip = () => false, logFile, logger, ...opts }: Omit<PluginOptions, 'root' | 'logFile'> & { projectRoot: string workspaceRoot: string logFile?: LogFileWriter | null logger: Logger }) { let initializing: Promise<void> | undefined let directoryCache: Map<string, Directory> let resolversByProject: WeakMap<Project, Resolver> let isFirstParseError = true 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 } } } const configNames = opts.configNames || ['tsconfig.json', 'jsconfig.json'] debug( 'Only tsconfig files with a name in this list are discoverable:', configNames ) const parseProject = async ( tsconfigFile: string ): Promise<Project | null> => { tsconfigFile = path.normalize(tsconfigFile) try { return ( hasTypeScriptDep ? await tsconfck.parseNative(tsconfigFile) : await tsconfck.parse(tsconfigFile) ) as Project } catch (error: any) { if (opts.ignoreConfigErrors) { debug('[!] Failed to parse tsconfig file at %s', tsconfigFile) if (isFirstParseError) { debug('Remove the `ignoreConfigErrors` option to see the error.') } } else { logger.error( '[tsconfig-paths] An error occurred while parsing "' + tsconfigFile + '". See below for details.' + (isFirstParseError ? ' To disable this message, set the `ignoreConfigErrors` option to true.' : ''), { error } ) if (!logger.hasErrorLogged(error)) { console.error(error) } } isFirstParseError = false return null } } // File watcher hooks let onBeforeAddProject: ((project: Project) => void) | undefined let onParseError: ((tsconfigFile: string) => void) | undefined const addProject = (project: Project, data?: Directory) => { const tsconfigFile = project.tsconfigFile const dir = path.normalize(path.dirname(tsconfigFile)) data ??= directoryCache.get(dir) // Sanity check if (data?.projects.some((p) => p.tsconfigFile === tsconfigFile)) { return } onBeforeAddProject?.(project) // Referenced projects must be added first, so they can override // the parent project's paths if both are in the same directory. if (project.referenced) { project.referenced.forEach((projectRef) => { addProject(projectRef) }) // Ensure the latest directory data is used. One of the project // references may have updated it. data = directoryCache.get(dir) } const resolver = createResolver(project, opts, logFile) if (resolver) { resolversByProject.set(project, resolver) } if (!data || data === emptyDirectory) { directoryCache.set( dir, (data = { projects: [], lazyDiscovery: null, }) ) } data.projects.push(project) } const loadProject = async (tsconfigFile: string, data?: Directory) => { const project = await parseProject(tsconfigFile) if (project) { addProject(project, data) } else { onParseError?.(tsconfigFile) } } // Ensure a deterministic order. const sortProjects = (projects: Project[]) => { projects.sort((left, right) => left.tsconfigFile.localeCompare(right.tsconfigFile) ) } const processConfigFile = async ( dir: NormalizedPath, name: string, data = directoryCache.get(dir) ): Promise<void> => { if (!data) { return // Wait to be loaded on-demand. } const file = path.join(dir, name as NormalizedPath) if (data.projects.some((p) => p.tsconfigFile === file)) { return } await loadProject(file, data) } const loadEagerProjects = async () => { let projectPaths: string[] if (opts.projects) { projectPaths = opts.projects.map((file) => { if (!file.endsWith('.json')) { file = join(file, 'tsconfig.json') } return path.resolve(projectRoot, file) }) } else { if (opts.projectDiscovery === 'lazy') { return } projectPaths = await tsconfck.findAll(workspaceRoot, { configNames, skip, }) } debug('Eagerly parsing these projects:', projectPaths) await Promise.all(Array.from(new Set(projectPaths), (p) => loadProject(p))) for (const data of directoryCache.values()) { sortProjects(data.projects) } } const resetResolvers = () => { directoryCache = new Map() resolversByProject = new WeakMap() initializing = loadEagerProjects() } // Only used when projectDiscovery is 'lazy'. const discoverProjects = async (dir: NormalizedPath, data: Directory) => { debug('Searching directory for tsconfig files:', dir) const names = await readdir(dir).catch(() => []) await Promise.all( names .filter((name) => configNames.includes(name)) .map((name) => { return processConfigFile(dir, name, data) }) ) if (data.projects.length) { sortProjects(data.projects) if (debug.enabled) { debug( `Directory "${dir}" contains the following tsconfig files:`, data.projects.map((p) => path.basename(p.tsconfigFile)) ) } } else { // No projects found. Reduce memory usage with a stand-in. directoryCache.set(dir, emptyDirectory) debug('No tsconfig files found in directory:', dir) } } const getResolvers = async function* ( importer: string ): AsyncIterable<Resolver> { await initializing let dir = path.normalize(importer) const { root } = path.parse(dir) while (dir !== (dir = path.dirname(dir)) && dir !== root) { let data = directoryCache.get(dir) if (opts.projectDiscovery === 'lazy') { if (!data) { if (skip(path.basename(dir))) { directoryCache.set(dir, emptyDirectory) continue } directoryCache.set( dir, (data = { projects: [], lazyDiscovery: null, }) ) } await (data.lazyDiscovery ??= discoverProjects(dir, data)) } else if (!data) { continue } for (const project of data.projects) { const resolver = resolversByProject.get(project) if (resolver) { yield resolver } } } } const watchProjects = (watcher: vite.FSWatcher) => { onBeforeAddProject = (project) => { watcher.add(project.tsconfigFile) project.extended?.forEach((parent) => { watcher.add(parent.tsconfigFile) }) } onParseError = (tsconfigFile) => { // Try again if the file changes. watcher.add(tsconfigFile) } watcher.on('all', (event, file) => { const normalizedFile = path.normalize(file) if ( !normalizedFile.endsWith('.json') || !path.isAbsolute(normalizedFile) ) { return } if (event === 'add') { if (configNames.includes(path.basename(normalizedFile))) { processConfigFile( path.dirname(normalizedFile), path.basename(normalizedFile) ).catch(console.error) } } else if (event === 'change' || event === 'unlink') { invalidateConfigFile( path.dirname(normalizedFile), path.basename(normalizedFile), event ) } }) function invalidateConfigFile( dir: NormalizedPath, name: string, event: 'change' | 'unlink' ): void { const data = directoryCache.get(dir) if (!data) { return } const file = path.join(dir, name as NormalizedPath) const index = data.projects.findIndex( (project) => project.tsconfigFile === file ) if (index !== -1) { const project = data.projects[index] debug( `Unloading project because of ${event} event:`, project.tsconfigFile ) resolversByProject.delete(project) data.projects.splice(index, 1) if (event === 'change') { if (opts.projectDiscovery === 'lazy') { data.lazyDiscovery = null } else { loadProject(project.tsconfigFile, data) .then(() => { sortProjects(data.projects) }) .catch(console.error) } } } } } return { reset: resetResolvers, get: getResolvers, watch: watchProjects, } } function createResolver( project: Project, opts: { loose?: boolean }, logFile?: LogFileWriter | null ): Resolver | null { const configPath = project.tsconfigFile const config = project.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 compilerOptions = config.compilerOptions || {} const { baseUrl, paths } = compilerOptions type InternalResolver = ( viteResolve: ViteResolve, id: string, importer: string ) => Promise<string | undefined> const resolveWithBaseUrl: InternalResolver | undefined = baseUrl ? async (viteResolve, id, importer) => { if (id[0] === '/') { return } const absoluteId = join(baseUrl, id) const resolvedId = await viteResolve(absoluteId, importer) if (resolvedId) { logFile?.write('resolvedWithBaseUrl', { importer, id, resolvedId, configPath, }) return resolvedId } } : undefined let resolveId: InternalResolver if (paths) { const pathsRootDir = resolvePathsRootDir(project) const pathMappings = resolvePathMappings(paths, pathsRootDir) const resolveWithPaths: InternalResolver = async ( viteResolve, id, importer ) => { const candidates = logFile ? ([] as string[]) : null 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] }) candidates?.push(mappedId) const resolvedId = await viteResolve(mappedId, importer) if (resolvedId) { logFile?.write('resolvedWithPaths', { importer, id, resolvedId, configPath, }) return resolvedId } } } logFile?.write('notFound', { importer, id, candidates, configPath, }) } if (resolveWithBaseUrl) { resolveId = async (viteResolve, id, importer) => (await resolveWithPaths(viteResolve, id, importer)) ?? (await resolveWithBaseUrl(viteResolve, id, importer)) } else { resolveId = resolveWithPaths } } else if (resolveWithBaseUrl) { resolveId = resolveWithBaseUrl } else { debug(`[!] Skipping "${configPath}" as no paths or baseUrl are defined.`) return null } const configDir = path.normalize(path.dirname(configPath)) let outDir = compilerOptions.outDir && path.normalize(compilerOptions.outDir) // When `tsconfck.parseNative` is used, the outDir is absolute, which // is not what `getIncluder` expects. if (outDir && path.isAbsolute(outDir)) { outDir = path.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 ? /$/ : compilerOptions.allowJs || path.basename(configPath).startsWith('jsconfig.') ? /\.(astro|mdx|svelte|vue|[mc]?[jt]sx?)$/ : /\.[mc]?tsx?$/ const resolutionCache = new Map<string, string>() return async (viteResolve, id, importer) => { // Remove query and hash parameters from the importer path. const importerFile = path.normalize(importer.replace(/[#?].+$/, '')) // Ignore importers with unsupported extensions. if (!importerExtRE.test(importerFile)) { logFile?.write('unsupportedExtension', { importer, id }) return notApplicable } // Respect the include/exclude properties. const relativeImporterFile = path.relative(configDir, importerFile) if (!isIncludedRelative(relativeImporterFile)) { logFile?.write('configMismatch', { importer, id, configPath }) 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) { logFile?.write('resolvedFromCache', { importer, id, resolvedId, configPath, }) } else { resolvedId = await resolveId(viteResolve, id, importer) if (!resolvedId) { return notFound } resolutionCache.set(id, resolvedId) } // Restore the suffix if one was removed earlier. if (suffix) { resolvedId += suffix } return [resolvedId, true] } } function resolvePathsRootDir(project: Project): string { if (project.result) { const { options } = project.result if (options && typeof options.pathsBasePath === 'string') { return options.pathsBasePath } return path.dirname(project.tsconfigFile) } const baseUrl = project.tsconfig.compilerOptions?.baseUrl if (baseUrl) { return baseUrl } const projectWithPaths = project.extended?.find( (project) => project.tsconfig.compilerOptions?.paths ) return path.dirname((projectWithPaths ?? project).tsconfigFile) } 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) if (debug.enabled) { debug(`Compiled tsconfig globs:`, { include: { globs: includePaths, regexes: includers, }, exclude: { globs: excludePaths, regexes: excluders, }, }) } return (id: string) => { id = id.replace(/\?.+$/, '') if (!path.relativeImportRE.test(id)) { id = './' + id } const test = (glob: RegExp) => glob.test(id) return includers.some(test) && !excluders.some(test) } } return () => true } function addCompiledGlob(this: RegExp[], glob: string) { const endsWithGlob = glob.split('/').pop()!.includes('*') const relativeGlob = path.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 }