diff --git a/CHANGELOG.md b/CHANGELOG.md index 0100f80f2dc..482776623de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## Unreleased + +* Add support for node's "pattern trailers" syntax ([#2569](https://github.com/evanw/esbuild/issues/2569)) + + After esbuild implemented node's `exports` feature in `package.json`, node changed the feature to also allow text after `*` wildcards in patterns. Previously the `*` was required to be at the end of the pattern. It lets you do something like this: + + ```json + { + "exports": { + "./features/*": "./features/*.js", + "./features/*.js": "./features/*.js" + } + } + ``` + + With this release, esbuild now supports these types of patterns too. + ## 0.15.9 * Fix an obscure npm package installation issue with `--omit=optional` ([#2558](https://github.com/evanw/esbuild/issues/2558)) diff --git a/internal/bundler/bundler_packagejson_test.go b/internal/bundler/bundler_packagejson_test.go index fb1d9946b51..d6bbd8d5f51 100644 --- a/internal/bundler/bundler_packagejson_test.go +++ b/internal/bundler/bundler_packagejson_test.go @@ -1822,6 +1822,9 @@ func TestPackageJsonExportsWildcard(t *testing.T) { } } `, + "/Users/user/project/node_modules/pkg1/file.js": ` + console.log('SUCCESS') + `, "/Users/user/project/node_modules/pkg1/file2.js": ` console.log('SUCCESS') `, @@ -1831,10 +1834,6 @@ func TestPackageJsonExportsWildcard(t *testing.T) { Mode: config.ModeBundle, AbsOutputFile: "/Users/user/project/out.js", }, - expectedScanLog: `Users/user/project/src/entry.js: ERROR: Could not resolve "pkg1/foo" -Users/user/project/node_modules/pkg1/package.json: NOTE: The path "./foo" is not exported by package "pkg1": -NOTE: You can mark the path "pkg1/foo" as external to exclude it from the bundle, which will remove this error. -`, }) } @@ -2139,6 +2138,47 @@ NOTE: You can mark the path "pkg/path/to/other/file" as external to exclude it f }) } +func TestPackageJsonExportsPatternTrailers(t *testing.T) { + packagejson_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/Users/user/project/src/entry.js": ` + import 'pkg/path/foo.js/bar.js' + import 'pkg2/features/abc' + import 'pkg2/features/xyz.js' + `, + "/Users/user/project/node_modules/pkg/package.json": ` + { + "exports": { + "./path/*/bar.js": "./dir/baz-*" + } + } + `, + "/Users/user/project/node_modules/pkg/dir/baz-foo.js": ` + console.log('works') + `, + "/Users/user/project/node_modules/pkg2/package.json": ` + { + "exports": { + "./features/*": "./public/*.js", + "./features/*.js": "./public/*.js" + } + } + `, + "/Users/user/project/node_modules/pkg2/public/abc.js": ` + console.log('abc') + `, + "/Users/user/project/node_modules/pkg2/public/xyz.js": ` + console.log('xyz') + `, + }, + entryPaths: []string{"/Users/user/project/src/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputFile: "/Users/user/project/out.js", + }, + }) +} + func TestPackageJsonImports(t *testing.T) { packagejson_suite.expectBundled(t, bundled{ files: map[string]string{ diff --git a/internal/bundler/snapshots/snapshots_packagejson.txt b/internal/bundler/snapshots/snapshots_packagejson.txt index ba5fffec40e..6755f448ee4 100644 --- a/internal/bundler/snapshots/snapshots_packagejson.txt +++ b/internal/bundler/snapshots/snapshots_packagejson.txt @@ -609,6 +609,18 @@ console.log("SUCCESS"); // Users/user/project/node_modules/pkg2/1/bar.js console.log("SUCCESS"); +================================================================================ +TestPackageJsonExportsPatternTrailers +---------- /Users/user/project/out.js ---------- +// Users/user/project/node_modules/pkg/dir/baz-foo.js +console.log("works"); + +// Users/user/project/node_modules/pkg2/public/abc.js +console.log("abc"); + +// Users/user/project/node_modules/pkg2/public/xyz.js +console.log("xyz"); + ================================================================================ TestPackageJsonExportsRequireOverImport ---------- /Users/user/project/out.js ---------- @@ -622,6 +634,15 @@ var require_require = __commonJS({ // Users/user/project/src/entry.js require_require(); +================================================================================ +TestPackageJsonExportsWildcard +---------- /Users/user/project/out.js ---------- +// Users/user/project/node_modules/pkg1/file.js +console.log("SUCCESS"); + +// Users/user/project/node_modules/pkg1/file2.js +console.log("SUCCESS"); + ================================================================================ TestPackageJsonImportSelfUsingImport ---------- /Users/user/project/out.js ---------- diff --git a/internal/resolver/package_json.go b/internal/resolver/package_json.go index f133ff14a3a..c997c906345 100644 --- a/internal/resolver/package_json.go +++ b/internal/resolver/package_json.go @@ -560,7 +560,56 @@ func (a expansionKeysArray) Len() int { return len(a) } func (a expansionKeysArray) Swap(i int, j int) { a[i], a[j] = a[j], a[i] } func (a expansionKeysArray) Less(i int, j int) bool { - return len(a[i].key) > len(a[j].key) + // Assert: keyA ends with "/" or contains only a single "*". + // Assert: keyB ends with "/" or contains only a single "*". + keyA := a[i].key + keyB := a[j].key + + // Let baseLengthA be the index of "*" in keyA plus one, if keyA contains "*", or the length of keyA otherwise. + // Let baseLengthB be the index of "*" in keyB plus one, if keyB contains "*", or the length of keyB otherwise. + starA := strings.IndexByte(keyA, '*') + starB := strings.IndexByte(keyB, '*') + var baseLengthA int + var baseLengthB int + if starA >= 0 { + baseLengthA = starA + } else { + baseLengthA = len(keyA) + } + if starB >= 0 { + baseLengthB = starB + } else { + baseLengthB = len(keyB) + } + + // If baseLengthA is greater than baseLengthB, return -1. + // If baseLengthB is greater than baseLengthA, return 1. + if baseLengthA > baseLengthB { + return true + } + if baseLengthB > baseLengthA { + return false + } + + // If keyA does not contain "*", return 1. + // If keyB does not contain "*", return -1. + if starA < 0 { + return false + } + if starB < 0 { + return true + } + + // If the length of keyA is greater than the length of keyB, return -1. + // If the length of keyB is greater than the length of keyA, return 1. + if len(keyA) > len(keyB) { + return true + } + if len(keyB) > len(keyA) { + return false + } + + return false } func (entry pjEntry) valueForKey(key string) (pjEntry, bool) { @@ -638,15 +687,16 @@ func parseImportsExportsMap(source logger.Source, log logger.Log, json js_ast.Ex value: visit(property.ValueOrNil), } - if strings.HasSuffix(key, "/") || strings.HasSuffix(key, "*") { + if strings.HasSuffix(key, "/") || strings.IndexByte(key, '*') >= 0 { expansionKeys = append(expansionKeys, entry) } mapData[i] = entry } - // Let expansionKeys be the list of keys of matchObj ending in "/" or "*", - // sorted by length descending. + // Let expansionKeys be the list of keys of matchObj either ending in "/" + // or containing only a single "*", sorted by the sorting function + // PATTERN_KEY_COMPARE which orders in descending order of specificity. sort.Stable(expansionKeys) return pjEntry{ @@ -860,7 +910,8 @@ func (r resolverQuery) esmPackageImportsExportsResolve( r.debugLogs.addNote(fmt.Sprintf("Checking object path map for %q", matchKey)) } - if !strings.HasSuffix(matchKey, "*") { + // If matchKey is a key of matchObj and does not end in "/" or contain "*", then + if !strings.HasSuffix(matchKey, "/") && strings.IndexByte(matchKey, '*') < 0 { if target, ok := matchObj.valueForKey(matchKey); ok { if r.debugLogs != nil { r.debugLogs.addNote(fmt.Sprintf("Found exact match for %q", matchKey)) @@ -870,31 +921,46 @@ func (r resolverQuery) esmPackageImportsExportsResolve( } for _, expansion := range matchObj.expansionKeys { - // If expansionKey ends in "*" and matchKey starts with but is not equal to - // the substring of expansionKey excluding the last "*" character - if strings.HasSuffix(expansion.key, "*") { - if substr := expansion.key[:len(expansion.key)-1]; strings.HasPrefix(matchKey, substr) && matchKey != substr { + // If expansionKey contains "*", set patternBase to the substring of + // expansionKey up to but excluding the first "*" character + if star := strings.IndexByte(expansion.key, '*'); star >= 0 { + patternBase := expansion.key[:star] + + // If patternBase is not null and matchKey starts with but is not equal + // to patternBase, then + if strings.HasPrefix(matchKey, patternBase) { + // Let patternTrailer be the substring of expansionKey from the index + // after the first "*" character. + patternTrailer := expansion.key[star+1:] + + // If patternTrailer has zero length, or if matchKey ends with + // patternTrailer and the length of matchKey is greater than or + // equal to the length of expansionKey, then + if patternTrailer == "" || (strings.HasSuffix(matchKey, patternTrailer) && len(matchKey) >= len(expansion.key)) { + target := expansion.value + subpath := matchKey[len(patternBase) : len(matchKey)-len(patternTrailer)] + if r.debugLogs != nil { + r.debugLogs.addNote(fmt.Sprintf("The key %q matched with %q left over", expansion.key, subpath)) + } + return r.esmPackageTargetResolve(packageURL, target, subpath, true, isImports, conditions) + } + } + } else { + // Otherwise if patternBase is null and matchKey starts with + // expansionKey, then + if strings.HasPrefix(matchKey, expansion.key) { target := expansion.value - subpath := matchKey[len(expansion.key)-1:] + subpath := matchKey[len(expansion.key):] if r.debugLogs != nil { r.debugLogs.addNote(fmt.Sprintf("The key %q matched with %q left over", expansion.key, subpath)) } - return r.esmPackageTargetResolve(packageURL, target, subpath, true, isImports, conditions) - } - } - - if strings.HasPrefix(matchKey, expansion.key) { - target := expansion.value - subpath := matchKey[len(expansion.key):] - if r.debugLogs != nil { - r.debugLogs.addNote(fmt.Sprintf("The key %q matched with %q left over", expansion.key, subpath)) - } - result, status, debug := r.esmPackageTargetResolve(packageURL, target, subpath, false, isImports, conditions) - if status == pjStatusExact || status == pjStatusExactEndsWithStar { - // Return the object { resolved, exact: false }. - status = pjStatusInexact + result, status, debug := r.esmPackageTargetResolve(packageURL, target, subpath, false, isImports, conditions) + if status == pjStatusExact || status == pjStatusExactEndsWithStar { + // Return the object { resolved, exact: false }. + status = pjStatusInexact + } + return result, status, debug } - return result, status, debug } if r.debugLogs != nil {