405 lines
12 KiB
Plaintext
405 lines
12 KiB
Plaintext
import _debug from 'debug'
|
|
import * as fs from 'fs'
|
|
import globRex from 'globrex'
|
|
import { resolve } from 'path'
|
|
import type { 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'
|
|
|
|
const debug = _debug('vite-tsconfig-paths')
|
|
|
|
const noMatch = [undefined, false] as [undefined, false]
|
|
|
|
type ViteResolve = (id: string, importer: string) => Promise<string | undefined>
|
|
|
|
type Resolver = (
|
|
viteResolve: ViteResolve,
|
|
id: string,
|
|
importer: string
|
|
) => Promise<[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) {
|
|
return dir == 'node_modules' || dir == '.git'
|
|
},
|
|
})
|
|
|
|
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 (importer && !relativeImportRE.test(id) && !isAbsolute(id)) {
|
|
// 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 = 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)
|
|
}
|
|
}
|
|
},
|
|
}
|
|
|
|
function createResolver(project: TSConfckParseResult): Resolver | null {
|
|
const configPath = normalizePath(project.tsconfigFile)
|
|
const config = project.tsconfig as {
|
|
files?: string[]
|
|
include?: string[]
|
|
exclude?: string[]
|
|
compilerOptions?: CompilerOptions
|
|
}
|
|
|
|
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) => viteResolve(join(baseUrl, id), importer)
|
|
: undefined
|
|
|
|
let resolveId: InternalResolver
|
|
if (paths) {
|
|
const pathMappings = resolvePathMappings(
|
|
paths,
|
|
baseUrl ?? dirname(configPath)
|
|
)
|
|
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]
|
|
})
|
|
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,
|
|
config.exclude,
|
|
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) => {
|
|
// Skip virtual modules.
|
|
if (id.includes('\0')) {
|
|
return noMatch
|
|
}
|
|
|
|
importer = normalizePath(importer)
|
|
const importerFile = importer.replace(/[#?].+$/, '')
|
|
|
|
// Ignore importers with unsupported extensions.
|
|
if (!importerExtRE.test(importerFile)) {
|
|
return noMatch
|
|
}
|
|
|
|
// Respect the include/exclude properties.
|
|
const relativeImporterFile = relative(configDir, importerFile)
|
|
if (!isIncludedRelative(relativeImporterFile)) {
|
|
return noMatch
|
|
}
|
|
|
|
// 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 path = resolutionCache.get(id)
|
|
if (!path) {
|
|
path = await resolveId(viteResolve, id, importer)
|
|
if (path) {
|
|
resolutionCache.set(id, path)
|
|
debug(`resolved:`, {
|
|
id,
|
|
importer,
|
|
resolvedId: path,
|
|
configPath,
|
|
})
|
|
}
|
|
}
|
|
return [path && suffix ? path + suffix : path, 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
|
|
}
|