Skip to content

Commit

Permalink
fix #3782: support ${configDir} in tsconfig.json
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Jun 8, 2024
1 parent 8e6603b commit 98cb2ed
Show file tree
Hide file tree
Showing 5 changed files with 160 additions and 27 deletions.
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@
import tasty from "./tasty.bagel" with { type: "bagel" }
```

* Support `${configDir}` in `tsconfig.json` files ([#3782](https://github.com/evanw/esbuild/issues/3782))

This adds support for a new feature from the upcoming TypeScript 5.5 release. The character sequence `${configDir}` is now respected at the start of `baseUrl` and `paths` values, which are used by esbuild during bundling to correctly map import paths to file system paths. This feature lets base `tsconfig.json` files specified via `extends` refer to the directory of the top-level `tsconfig.json` file. Here is an example:

```json
{
"compilerOptions": {
"paths": {
"js/*": ["${configDir}/dist/js/*"]
}
}
}
```

You can read more in [TypeScript's blog post about their upcoming 5.5 release](https://devblogs.microsoft.com/typescript/announcing-typescript-5-5-rc/#the-configdir-template-variable-for-configuration-files). Note that this feature does not make use of template literals (you need to use `"${configDir}/dist/js/*"` not `` `${configDir}/dist/js/*` ``). The syntax for `tsconfig.json` is still just JSON with comments, and JSON syntax does not allow template literals. This feature only recognizes `${configDir}` in strings for certain path-like properties, and only at the beginning of the string.

* Fix internal error with `--supported:object-accessors=false` ([#3794](https://github.com/evanw/esbuild/issues/3794))

This release fixes a regression in 0.21.0 where some code that was added to esbuild's internal runtime library of helper functions for JavaScript decorators fails to parse when you configure esbuild with `--supported:object-accessors=false`. The reason is that esbuild introduced code that does `{ get [name]() {} }` which uses both the `object-extensions` feature for the `[name]` and the `object-accessors` feature for the `get`, but esbuild was incorrectly only checking for `object-extensions` and not for `object-accessors`. Additional tests have been added to avoid this type of issue in the future. A workaround for this issue in earlier releases is to also add `--supported:object-extensions=false`.
Expand Down
95 changes: 95 additions & 0 deletions internal/bundler_tests/bundler_tsconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2654,3 +2654,98 @@ func TestTsconfigPackageJsonExportsYarnPnP(t *testing.T) {
},
})
}

func TestTsconfigJsonConfigDirBaseURL(t *testing.T) {
tsconfig_suite.expectBundled(t, bundled{
files: map[string]string{
"/Users/user/project/src/entry.js": `
import "foo/bar"
`,
"/Users/user/project/lib/foo/bar": `
console.log('works')
`,
"/Users/user/project/src/tsconfig.json": `
{
"extends": "@scope/configs/tsconfig"
}
`,
"/Users/user/project/node_modules/@scope/configs/tsconfig.json": `
{
"compilerOptions": {
"baseUrl": "${configDir}../lib"
}
}
`,
},
entryPaths: []string{"/Users/user/project/src/entry.js"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputFile: "/Users/user/project/out.js",
},
})
}

func TestTsconfigJsonConfigDirPaths(t *testing.T) {
tsconfig_suite.expectBundled(t, bundled{
files: map[string]string{
"/Users/user/project/src/entry.js": `
import "library/foo/bar"
`,
"/Users/user/project/lib/foo/bar": `
console.log('works')
`,
"/Users/user/project/src/tsconfig.json": `
{
"extends": "@scope/configs/tsconfig"
}
`,
"/Users/user/project/node_modules/@scope/configs/tsconfig.json": `
{
"compilerOptions": {
"paths": {
"library/*": ["${configDir}../lib/*"]
}
}
}
`,
},
entryPaths: []string{"/Users/user/project/src/entry.js"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputFile: "/Users/user/project/out.js",
},
})
}

func TestTsconfigJsonConfigDirBaseURLInheritedPaths(t *testing.T) {
tsconfig_suite.expectBundled(t, bundled{
files: map[string]string{
"/Users/user/project/src/entry.js": `
import "library/foo/bar"
`,
"/Users/user/project/lib/foo/bar": `
console.log('works')
`,
"/Users/user/project/src/tsconfig.json": `
{
"extends": "@scope/configs/tsconfig"
}
`,
"/Users/user/project/node_modules/@scope/configs/tsconfig.json": `
{
"compilerOptions": {
"baseUrl": "${configDir}..",
"paths": {
"library/*": ["./lib/*"]
}
}
}
`,
},
entryPaths: []string{"/Users/user/project/src/entry.js"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputFile: "/Users/user/project/out.js",
},
})
}
18 changes: 18 additions & 0 deletions internal/bundler_tests/snapshots/snapshots_tsconfig.txt
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,24 @@ var require_util = __commonJS({
var import_util = __toESM(require_util());
console.log((0, import_util.default)());

================================================================================
TestTsconfigJsonConfigDirBaseURL
---------- /Users/user/project/out.js ----------
// Users/user/project/lib/foo/bar
console.log("works");

================================================================================
TestTsconfigJsonConfigDirBaseURLInheritedPaths
---------- /Users/user/project/out.js ----------
// Users/user/project/lib/foo/bar
console.log("works");

================================================================================
TestTsconfigJsonConfigDirPaths
---------- /Users/user/project/out.js ----------
// Users/user/project/lib/foo/bar
console.log("works");

================================================================================
TestTsconfigJsonExtends
---------- /out.js ----------
Expand Down
34 changes: 13 additions & 21 deletions internal/resolver/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,14 +331,14 @@ func NewResolver(call config.APICall, fs fs.FS, log logger.Log, caches *cache.Ca
if r.log.Level <= logger.LevelDebug {
r.debugLogs = &debugLogs{what: fmt.Sprintf("Resolving tsconfig file %q", options.TSConfigPath)}
}
res.tsConfigOverride, err = r.parseTSConfig(options.TSConfigPath, visited)
res.tsConfigOverride, err = r.parseTSConfig(options.TSConfigPath, visited, fs.Dir(options.TSConfigPath))
} else {
source := logger.Source{
KeyPath: logger.Path{Text: fs.Join(fs.Cwd(), "<tsconfig.json>"), Namespace: "file"},
PrettyPath: "<tsconfig.json>",
Contents: options.TSConfigRaw,
}
res.tsConfigOverride, err = r.parseTSConfigFromSource(source, visited)
res.tsConfigOverride, err = r.parseTSConfigFromSource(source, visited, fs.Cwd())
}
if err != nil {
if err == syscall.ENOENT {
Expand Down Expand Up @@ -1164,7 +1164,7 @@ var errParseErrorAlreadyLogged = errors.New("(error already logged)")
//
// Nested calls may also return "parseErrorImportCycle". In that case the
// caller is responsible for logging an appropriate error message.
func (r resolverQuery) parseTSConfig(file string, visited map[string]bool) (*TSConfigJSON, error) {
func (r resolverQuery) parseTSConfig(file string, visited map[string]bool, configDir string) (*TSConfigJSON, error) {
// Resolve any symlinks first before parsing the file
if !r.options.PreserveSymlinks {
if real, ok := r.fs.EvalSymlinks(file); ok {
Expand Down Expand Up @@ -1199,15 +1199,15 @@ func (r resolverQuery) parseTSConfig(file string, visited map[string]bool) (*TSC
PrettyPath: PrettyPath(r.fs, keyPath),
Contents: contents,
}
return r.parseTSConfigFromSource(source, visited)
return r.parseTSConfigFromSource(source, visited, configDir)
}

func (r resolverQuery) parseTSConfigFromSource(source logger.Source, visited map[string]bool) (*TSConfigJSON, error) {
func (r resolverQuery) parseTSConfigFromSource(source logger.Source, visited map[string]bool, configDir string) (*TSConfigJSON, error) {
tracker := logger.MakeLineColumnTracker(&source)
fileDir := r.fs.Dir(source.KeyPath.Text)
isExtends := len(visited) > 1

result := ParseTSConfigJSON(r.log, source, &r.caches.JSONCache, func(extends string, extendsRange logger.Range) *TSConfigJSON {
result := ParseTSConfigJSON(r.log, source, &r.caches.JSONCache, r.fs, fileDir, configDir, func(extends string, extendsRange logger.Range) *TSConfigJSON {
if visited == nil {
// If this is nil, then we're in a "transform" API call. In that case we
// deliberately skip processing "extends" fields. This is because the
Expand Down Expand Up @@ -1295,7 +1295,7 @@ func (r resolverQuery) parseTSConfigFromSource(source logger.Source, visited map
// Check the "exports" map
if packageJSON := r.parsePackageJSON(result.pkgDirPath); packageJSON != nil && packageJSON.exportsMap != nil {
if absolute, ok, _ := r.esmResolveAlgorithm(result.pkgIdent, "."+result.pkgSubpath, packageJSON, result.pkgDirPath, source.KeyPath.Text); ok {
base, err := r.parseTSConfig(absolute.Primary.Text, visited)
base, err := r.parseTSConfig(absolute.Primary.Text, visited, configDir)
if result, shouldReturn := maybeFinishOurSearch(base, err, absolute.Primary.Text); shouldReturn {
return result
}
Expand Down Expand Up @@ -1355,7 +1355,7 @@ func (r resolverQuery) parseTSConfigFromSource(source logger.Source, visited map
// This is a very abbreviated version of our ESM resolution
if status == pjStatusExact || status == pjStatusExactEndsWithStar {
fileToCheck := r.fs.Join(pkgDir, resolvedPath)
base, err := r.parseTSConfig(fileToCheck, visited)
base, err := r.parseTSConfig(fileToCheck, visited, configDir)

if result, shouldReturn := maybeFinishOurSearch(base, err, fileToCheck); shouldReturn {
return result
Expand All @@ -1369,7 +1369,7 @@ func (r resolverQuery) parseTSConfigFromSource(source logger.Source, visited map

filesToCheck := []string{r.fs.Join(join, "tsconfig.json"), join, join + ".json"}
for _, fileToCheck := range filesToCheck {
base, err := r.parseTSConfig(fileToCheck, visited)
base, err := r.parseTSConfig(fileToCheck, visited, configDir)

// Explicitly ignore matches if they are directories instead of files
if err != nil && err != syscall.ENOENT {
Expand Down Expand Up @@ -1417,7 +1417,7 @@ func (r resolverQuery) parseTSConfigFromSource(source logger.Source, visited map
if !r.fs.IsAbs(extendsFile) {
extendsFile = r.fs.Join(fileDir, extendsFile)
}
base, err := r.parseTSConfig(extendsFile, visited)
base, err := r.parseTSConfig(extendsFile, visited, configDir)

// TypeScript's handling of "extends" has some specific edge cases. We
// must only try adding ".json" if it's not already present, which is
Expand All @@ -1429,7 +1429,7 @@ func (r resolverQuery) parseTSConfigFromSource(source logger.Source, visited map
extendsBase := r.fs.Base(extendsFile)
if entry, _ := entries.Get(extendsBase); entry == nil || entry.Kind(r.fs) != fs.FileEntry {
if entry, _ := entries.Get(extendsBase + ".json"); entry != nil && entry.Kind(r.fs) == fs.FileEntry {
base, err = r.parseTSConfig(extendsFile+".json", visited)
base, err = r.parseTSConfig(extendsFile+".json", visited, configDir)
}
}
}
Expand Down Expand Up @@ -1458,14 +1458,6 @@ func (r resolverQuery) parseTSConfigFromSource(source logger.Source, visited map
return nil, errParseErrorAlreadyLogged
}

if result.BaseURL != nil && !r.fs.IsAbs(*result.BaseURL) {
*result.BaseURL = r.fs.Join(fileDir, *result.BaseURL)
}

if result.Paths != nil && !r.fs.IsAbs(result.BaseURLForPaths) {
result.BaseURLForPaths = r.fs.Join(fileDir, result.BaseURLForPaths)
}

// Now that we have parsed the entire "tsconfig.json" file, filter out any
// paths that are invalid due to being a package-style path without a base
// URL specified. This must be done here instead of when we're parsing the
Expand Down Expand Up @@ -1612,7 +1604,7 @@ func (r resolverQuery) dirInfoUncached(path string) *dirInfo {
// many other tools anyway. So now these files are ignored.
if tsConfigPath != "" && !info.isInsideNodeModules {
var err error
info.enclosingTSConfigJSON, err = r.parseTSConfig(tsConfigPath, make(map[string]bool))
info.enclosingTSConfigJSON, err = r.parseTSConfig(tsConfigPath, make(map[string]bool), r.fs.Dir(tsConfigPath))
if err != nil {
if err == syscall.ENOENT {
r.log.AddError(nil, logger.Range{}, fmt.Sprintf("Cannot find tsconfig file %q",
Expand Down Expand Up @@ -2094,7 +2086,7 @@ func (r resolverQuery) matchTSConfigPaths(tsConfigJSON *TSConfigJSON, path strin
}

if r.debugLogs != nil {
r.debugLogs.addNote(fmt.Sprintf("Using %q as \"baseURL\"", absBaseURL))
r.debugLogs.addNote(fmt.Sprintf("Using %q as \"baseUrl\"", absBaseURL))
}

// Check for exact matches first
Expand Down
24 changes: 18 additions & 6 deletions internal/resolver/tsconfig_json.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/evanw/esbuild/internal/cache"
"github.com/evanw/esbuild/internal/config"
"github.com/evanw/esbuild/internal/fs"
"github.com/evanw/esbuild/internal/helpers"
"github.com/evanw/esbuild/internal/js_ast"
"github.com/evanw/esbuild/internal/js_lexer"
Expand Down Expand Up @@ -95,6 +96,9 @@ func ParseTSConfigJSON(
log logger.Log,
source logger.Source,
jsonCache *cache.JSONCache,
fs fs.FS,
fileDir string,
configDir string,
extends func(string, logger.Range) *TSConfigJSON,
) *TSConfigJSON {
// Unfortunately "tsconfig.json" isn't actually JSON. It's some other
Expand Down Expand Up @@ -138,6 +142,10 @@ func ParseTSConfigJSON(
// Parse "baseUrl"
if valueJSON, _, ok := getProperty(compilerOptionsJSON, "baseUrl"); ok {
if value, ok := getString(valueJSON); ok {
value = getSubstitutedPathWithConfigDirTemplate(fs, value, configDir)
if !fs.IsAbs(value) {
value = fs.Join(fileDir, value)
}
result.BaseURL = &value
}
}
Expand Down Expand Up @@ -301,12 +309,7 @@ func ParseTSConfigJSON(
// Parse "paths"
if valueJSON, _, ok := getProperty(compilerOptionsJSON, "paths"); ok {
if paths, ok := valueJSON.Data.(*js_ast.EObject); ok {
hasBaseURL := result.BaseURL != nil
if hasBaseURL {
result.BaseURLForPaths = *result.BaseURL
} else {
result.BaseURLForPaths = "."
}
result.BaseURLForPaths = fileDir
result.Paths = &TSConfigPaths{Source: source, Map: make(map[string][]TSConfigPath)}
for _, prop := range paths.Properties {
if key, ok := getString(prop.Key); ok {
Expand Down Expand Up @@ -339,6 +342,7 @@ func ParseTSConfigJSON(
for _, item := range array.Items {
if str, ok := getString(item); ok {
if isValidTSConfigPathPattern(str, log, &source, &tracker, item.Loc) {
str = getSubstitutedPathWithConfigDirTemplate(fs, str, configDir)
result.Paths.Map[key] = append(result.Paths.Map[key], TSConfigPath{Text: str, Loc: item.Loc})
}
}
Expand Down Expand Up @@ -387,6 +391,14 @@ func ParseTSConfigJSON(
return &result
}

// See: https://github.com/microsoft/TypeScript/pull/58042
func getSubstitutedPathWithConfigDirTemplate(fs fs.FS, value string, basePath string) string {
if strings.HasPrefix(value, "${configDir}") {
return fs.Join(basePath, "./"+value[12:])
}
return value
}

func parseMemberExpressionForJSX(log logger.Log, source *logger.Source, tracker *logger.LineColumnTracker, loc logger.Loc, text string) []string {
if text == "" {
return nil
Expand Down

0 comments on commit 98cb2ed

Please sign in to comment.