Skip to content

Commit 98cb2ed

Browse files
committed
fix #3782: support ${configDir} in tsconfig.json
1 parent 8e6603b commit 98cb2ed

5 files changed

Lines changed: 160 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,22 @@
2020
import tasty from "./tasty.bagel" with { type: "bagel" }
2121
```
2222
23+
* Support `${configDir}` in `tsconfig.json` files ([#3782](https://github.com/evanw/esbuild/issues/3782))
24+
25+
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:
26+
27+
```json
28+
{
29+
"compilerOptions": {
30+
"paths": {
31+
"js/*": ["${configDir}/dist/js/*"]
32+
}
33+
}
34+
}
35+
```
36+
37+
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.
38+
2339
* Fix internal error with `--supported:object-accessors=false` ([#3794](https://github.com/evanw/esbuild/issues/3794))
2440

2541
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`.

internal/bundler_tests/bundler_tsconfig_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2654,3 +2654,98 @@ func TestTsconfigPackageJsonExportsYarnPnP(t *testing.T) {
26542654
},
26552655
})
26562656
}
2657+
2658+
func TestTsconfigJsonConfigDirBaseURL(t *testing.T) {
2659+
tsconfig_suite.expectBundled(t, bundled{
2660+
files: map[string]string{
2661+
"/Users/user/project/src/entry.js": `
2662+
import "foo/bar"
2663+
`,
2664+
"/Users/user/project/lib/foo/bar": `
2665+
console.log('works')
2666+
`,
2667+
"/Users/user/project/src/tsconfig.json": `
2668+
{
2669+
"extends": "@scope/configs/tsconfig"
2670+
}
2671+
`,
2672+
"/Users/user/project/node_modules/@scope/configs/tsconfig.json": `
2673+
{
2674+
"compilerOptions": {
2675+
"baseUrl": "${configDir}../lib"
2676+
}
2677+
}
2678+
`,
2679+
},
2680+
entryPaths: []string{"/Users/user/project/src/entry.js"},
2681+
options: config.Options{
2682+
Mode: config.ModeBundle,
2683+
AbsOutputFile: "/Users/user/project/out.js",
2684+
},
2685+
})
2686+
}
2687+
2688+
func TestTsconfigJsonConfigDirPaths(t *testing.T) {
2689+
tsconfig_suite.expectBundled(t, bundled{
2690+
files: map[string]string{
2691+
"/Users/user/project/src/entry.js": `
2692+
import "library/foo/bar"
2693+
`,
2694+
"/Users/user/project/lib/foo/bar": `
2695+
console.log('works')
2696+
`,
2697+
"/Users/user/project/src/tsconfig.json": `
2698+
{
2699+
"extends": "@scope/configs/tsconfig"
2700+
}
2701+
`,
2702+
"/Users/user/project/node_modules/@scope/configs/tsconfig.json": `
2703+
{
2704+
"compilerOptions": {
2705+
"paths": {
2706+
"library/*": ["${configDir}../lib/*"]
2707+
}
2708+
}
2709+
}
2710+
`,
2711+
},
2712+
entryPaths: []string{"/Users/user/project/src/entry.js"},
2713+
options: config.Options{
2714+
Mode: config.ModeBundle,
2715+
AbsOutputFile: "/Users/user/project/out.js",
2716+
},
2717+
})
2718+
}
2719+
2720+
func TestTsconfigJsonConfigDirBaseURLInheritedPaths(t *testing.T) {
2721+
tsconfig_suite.expectBundled(t, bundled{
2722+
files: map[string]string{
2723+
"/Users/user/project/src/entry.js": `
2724+
import "library/foo/bar"
2725+
`,
2726+
"/Users/user/project/lib/foo/bar": `
2727+
console.log('works')
2728+
`,
2729+
"/Users/user/project/src/tsconfig.json": `
2730+
{
2731+
"extends": "@scope/configs/tsconfig"
2732+
}
2733+
`,
2734+
"/Users/user/project/node_modules/@scope/configs/tsconfig.json": `
2735+
{
2736+
"compilerOptions": {
2737+
"baseUrl": "${configDir}..",
2738+
"paths": {
2739+
"library/*": ["./lib/*"]
2740+
}
2741+
}
2742+
}
2743+
`,
2744+
},
2745+
entryPaths: []string{"/Users/user/project/src/entry.js"},
2746+
options: config.Options{
2747+
Mode: config.ModeBundle,
2748+
AbsOutputFile: "/Users/user/project/out.js",
2749+
},
2750+
})
2751+
}

internal/bundler_tests/snapshots/snapshots_tsconfig.txt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,24 @@ var require_util = __commonJS({
260260
var import_util = __toESM(require_util());
261261
console.log((0, import_util.default)());
262262

263+
================================================================================
264+
TestTsconfigJsonConfigDirBaseURL
265+
---------- /Users/user/project/out.js ----------
266+
// Users/user/project/lib/foo/bar
267+
console.log("works");
268+
269+
================================================================================
270+
TestTsconfigJsonConfigDirBaseURLInheritedPaths
271+
---------- /Users/user/project/out.js ----------
272+
// Users/user/project/lib/foo/bar
273+
console.log("works");
274+
275+
================================================================================
276+
TestTsconfigJsonConfigDirPaths
277+
---------- /Users/user/project/out.js ----------
278+
// Users/user/project/lib/foo/bar
279+
console.log("works");
280+
263281
================================================================================
264282
TestTsconfigJsonExtends
265283
---------- /out.js ----------

internal/resolver/resolver.go

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -331,14 +331,14 @@ func NewResolver(call config.APICall, fs fs.FS, log logger.Log, caches *cache.Ca
331331
if r.log.Level <= logger.LevelDebug {
332332
r.debugLogs = &debugLogs{what: fmt.Sprintf("Resolving tsconfig file %q", options.TSConfigPath)}
333333
}
334-
res.tsConfigOverride, err = r.parseTSConfig(options.TSConfigPath, visited)
334+
res.tsConfigOverride, err = r.parseTSConfig(options.TSConfigPath, visited, fs.Dir(options.TSConfigPath))
335335
} else {
336336
source := logger.Source{
337337
KeyPath: logger.Path{Text: fs.Join(fs.Cwd(), "<tsconfig.json>"), Namespace: "file"},
338338
PrettyPath: "<tsconfig.json>",
339339
Contents: options.TSConfigRaw,
340340
}
341-
res.tsConfigOverride, err = r.parseTSConfigFromSource(source, visited)
341+
res.tsConfigOverride, err = r.parseTSConfigFromSource(source, visited, fs.Cwd())
342342
}
343343
if err != nil {
344344
if err == syscall.ENOENT {
@@ -1164,7 +1164,7 @@ var errParseErrorAlreadyLogged = errors.New("(error already logged)")
11641164
//
11651165
// Nested calls may also return "parseErrorImportCycle". In that case the
11661166
// caller is responsible for logging an appropriate error message.
1167-
func (r resolverQuery) parseTSConfig(file string, visited map[string]bool) (*TSConfigJSON, error) {
1167+
func (r resolverQuery) parseTSConfig(file string, visited map[string]bool, configDir string) (*TSConfigJSON, error) {
11681168
// Resolve any symlinks first before parsing the file
11691169
if !r.options.PreserveSymlinks {
11701170
if real, ok := r.fs.EvalSymlinks(file); ok {
@@ -1199,15 +1199,15 @@ func (r resolverQuery) parseTSConfig(file string, visited map[string]bool) (*TSC
11991199
PrettyPath: PrettyPath(r.fs, keyPath),
12001200
Contents: contents,
12011201
}
1202-
return r.parseTSConfigFromSource(source, visited)
1202+
return r.parseTSConfigFromSource(source, visited, configDir)
12031203
}
12041204

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

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

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

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

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

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

1461-
if result.BaseURL != nil && !r.fs.IsAbs(*result.BaseURL) {
1462-
*result.BaseURL = r.fs.Join(fileDir, *result.BaseURL)
1463-
}
1464-
1465-
if result.Paths != nil && !r.fs.IsAbs(result.BaseURLForPaths) {
1466-
result.BaseURLForPaths = r.fs.Join(fileDir, result.BaseURLForPaths)
1467-
}
1468-
14691461
// Now that we have parsed the entire "tsconfig.json" file, filter out any
14701462
// paths that are invalid due to being a package-style path without a base
14711463
// URL specified. This must be done here instead of when we're parsing the
@@ -1612,7 +1604,7 @@ func (r resolverQuery) dirInfoUncached(path string) *dirInfo {
16121604
// many other tools anyway. So now these files are ignored.
16131605
if tsConfigPath != "" && !info.isInsideNodeModules {
16141606
var err error
1615-
info.enclosingTSConfigJSON, err = r.parseTSConfig(tsConfigPath, make(map[string]bool))
1607+
info.enclosingTSConfigJSON, err = r.parseTSConfig(tsConfigPath, make(map[string]bool), r.fs.Dir(tsConfigPath))
16161608
if err != nil {
16171609
if err == syscall.ENOENT {
16181610
r.log.AddError(nil, logger.Range{}, fmt.Sprintf("Cannot find tsconfig file %q",
@@ -2094,7 +2086,7 @@ func (r resolverQuery) matchTSConfigPaths(tsConfigJSON *TSConfigJSON, path strin
20942086
}
20952087

20962088
if r.debugLogs != nil {
2097-
r.debugLogs.addNote(fmt.Sprintf("Using %q as \"baseURL\"", absBaseURL))
2089+
r.debugLogs.addNote(fmt.Sprintf("Using %q as \"baseUrl\"", absBaseURL))
20982090
}
20992091

21002092
// Check for exact matches first

internal/resolver/tsconfig_json.go

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"github.com/evanw/esbuild/internal/cache"
88
"github.com/evanw/esbuild/internal/config"
9+
"github.com/evanw/esbuild/internal/fs"
910
"github.com/evanw/esbuild/internal/helpers"
1011
"github.com/evanw/esbuild/internal/js_ast"
1112
"github.com/evanw/esbuild/internal/js_lexer"
@@ -95,6 +96,9 @@ func ParseTSConfigJSON(
9596
log logger.Log,
9697
source logger.Source,
9798
jsonCache *cache.JSONCache,
99+
fs fs.FS,
100+
fileDir string,
101+
configDir string,
98102
extends func(string, logger.Range) *TSConfigJSON,
99103
) *TSConfigJSON {
100104
// Unfortunately "tsconfig.json" isn't actually JSON. It's some other
@@ -138,6 +142,10 @@ func ParseTSConfigJSON(
138142
// Parse "baseUrl"
139143
if valueJSON, _, ok := getProperty(compilerOptionsJSON, "baseUrl"); ok {
140144
if value, ok := getString(valueJSON); ok {
145+
value = getSubstitutedPathWithConfigDirTemplate(fs, value, configDir)
146+
if !fs.IsAbs(value) {
147+
value = fs.Join(fileDir, value)
148+
}
141149
result.BaseURL = &value
142150
}
143151
}
@@ -301,12 +309,7 @@ func ParseTSConfigJSON(
301309
// Parse "paths"
302310
if valueJSON, _, ok := getProperty(compilerOptionsJSON, "paths"); ok {
303311
if paths, ok := valueJSON.Data.(*js_ast.EObject); ok {
304-
hasBaseURL := result.BaseURL != nil
305-
if hasBaseURL {
306-
result.BaseURLForPaths = *result.BaseURL
307-
} else {
308-
result.BaseURLForPaths = "."
309-
}
312+
result.BaseURLForPaths = fileDir
310313
result.Paths = &TSConfigPaths{Source: source, Map: make(map[string][]TSConfigPath)}
311314
for _, prop := range paths.Properties {
312315
if key, ok := getString(prop.Key); ok {
@@ -339,6 +342,7 @@ func ParseTSConfigJSON(
339342
for _, item := range array.Items {
340343
if str, ok := getString(item); ok {
341344
if isValidTSConfigPathPattern(str, log, &source, &tracker, item.Loc) {
345+
str = getSubstitutedPathWithConfigDirTemplate(fs, str, configDir)
342346
result.Paths.Map[key] = append(result.Paths.Map[key], TSConfigPath{Text: str, Loc: item.Loc})
343347
}
344348
}
@@ -387,6 +391,14 @@ func ParseTSConfigJSON(
387391
return &result
388392
}
389393

394+
// See: https://github.com/microsoft/TypeScript/pull/58042
395+
func getSubstitutedPathWithConfigDirTemplate(fs fs.FS, value string, basePath string) string {
396+
if strings.HasPrefix(value, "${configDir}") {
397+
return fs.Join(basePath, "./"+value[12:])
398+
}
399+
return value
400+
}
401+
390402
func parseMemberExpressionForJSX(log logger.Log, source *logger.Source, tracker *logger.LineColumnTracker, loc logger.Loc, text string) []string {
391403
if text == "" {
392404
return nil

0 commit comments

Comments
 (0)