diff --git a/CHANGELOG.md b/CHANGELOG.md index 74fbaef0000..953f9cd172f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## Unreleased +* Output directory may now contain nested directories ([#224](https://github.com/evanw/esbuild/issues/224)) + + Note: This is a breaking change if you use multiple entry points from different directories. Output paths may change with this upgrade. + + Previously esbuild would fail to bundle multiple entry points with the same name because all output files were written to the same directory. This can happen if your entry points are in different nested directories like this: + + ``` + src/ + ├─ a/ + │ └─ page.js + └─ b/ + └─ page.js + ``` + + With this release, esbuild will now generate nested directories in the output directory that mirror the directory structure of the original entry points. This avoids collisions because the output files will now be in separate directories. The directory structure is mirrored relative to the [lowest common ancestor](https://en.wikipedia.org/wiki/Lowest_common_ancestor) among all entry point paths. This is the same behavior as [Parcel](https://github.com/parcel-bundler/parcel) and the TypeScript compiler. + * Silence errors about missing dependencies inside try/catch blocks ([#247](https://github.com/evanw/esbuild/issues/247)) This release makes it easier to use esbuild with libraries such as [debug](npmjs.com/package/debug) which contain a use of `require()` inside a `try`/`catch` statement for a module that isn't listed in its dependencies. Normally you need to mark the library as `--external` to silence this error. However, calling `require()` and catching errors is a common pattern for conditionally importing an unknown module, so now esbuild automatically treats the missing module as external in these cases. diff --git a/internal/bundler/bundler.go b/internal/bundler/bundler.go index df55bdcfcf7..4d30e2b9382 100644 --- a/internal/bundler/bundler.go +++ b/internal/bundler/bundler.go @@ -9,6 +9,8 @@ import ( "sort" "strings" "sync" + "unicode" + "unicode/utf8" "github.com/evanw/esbuild/internal/ast" "github.com/evanw/esbuild/internal/config" @@ -296,7 +298,7 @@ func baseNameForAvoidingCollisions(fs fs.FS, keyPath ast.Path, base string) stri // operating system we're on. We want to avoid absolute paths because they // will have different home directories. We also want to avoid path // separators because they are different on Windows. - toHash = []byte(strings.ReplaceAll(relPath, "\\", "/")) + toHash = []byte(lowerCaseAbsPathForWindows(strings.ReplaceAll(relPath, "\\", "/"))) } } if toHash == nil { @@ -541,6 +543,16 @@ func (b *Bundle) Compile(log logging.Log, options config.Options) []OutputFile { options.OutputFormat = config.FormatESModule } + // Determine the lowest common ancestor of all entry points + entryPointAbsPaths := make([]string, 0, len(b.entryPoints)) + for _, entryPoint := range b.entryPoints { + keyPath := b.sources[entryPoint].KeyPath + if keyPath.IsAbsolute { + entryPointAbsPaths = append(entryPointAbsPaths, keyPath.Text) + } + } + lcaAbsPath := lowestCommonAncestorDirectory(b.fs, entryPointAbsPaths) + type linkGroup struct { outputFiles []OutputFile reachableFiles []uint32 @@ -549,7 +561,7 @@ func (b *Bundle) Compile(log logging.Log, options config.Options) []OutputFile { var resultGroups []linkGroup if options.CodeSplitting { // If code splitting is enabled, link all entry points together - c := newLinkerContext(&options, log, b.fs, b.sources, b.files, b.entryPoints) + c := newLinkerContext(&options, log, b.fs, b.sources, b.files, b.entryPoints, lcaAbsPath) resultGroups = []linkGroup{{ outputFiles: c.link(), reachableFiles: c.reachableFiles, @@ -561,7 +573,7 @@ func (b *Bundle) Compile(log logging.Log, options config.Options) []OutputFile { for i, entryPoint := range b.entryPoints { waitGroup.Add(1) go func(i int, entryPoint uint32) { - c := newLinkerContext(&options, log, b.fs, b.sources, b.files, []uint32{entryPoint}) + c := newLinkerContext(&options, log, b.fs, b.sources, b.files, []uint32{entryPoint}, lcaAbsPath) resultGroups[i] = linkGroup{ outputFiles: c.link(), reachableFiles: c.reachableFiles, @@ -625,6 +637,50 @@ func (b *Bundle) Compile(log logging.Log, options config.Options) []OutputFile { return outputFiles } +func lowestCommonAncestorDirectory(fs fs.FS, absPaths []string) string { + if len(absPaths) == 0 { + return "" + } + + lowestAbsDir := fs.Dir(absPaths[0]) + + for _, absPath := range absPaths[1:] { + absDir := fs.Dir(absPath) + lastSlash := 0 + a := 0 + b := 0 + + for { + runeA, widthA := utf8.DecodeRuneInString(absDir[a:]) + runeB, widthB := utf8.DecodeRuneInString(lowestAbsDir[b:]) + boundaryA := widthA == 0 || runeA == '/' || runeA == '\\' + boundaryB := widthB == 0 || runeB == '/' || runeB == '\\' + + if boundaryA && boundaryB { + if widthA == 0 || widthB == 0 { + // Truncate to the smaller path if one path is a prefix of the other + lowestAbsDir = absDir[:a] + break + } else { + // Track the longest common directory so far + lastSlash = a + } + } else if boundaryA != boundaryB || unicode.ToLower(runeA) != unicode.ToLower(runeB) { + // If both paths are different at this point, stop and set the lowest so + // far to the common parent directory. Compare using a case-insensitive + // comparison to handle paths on Windows. + lowestAbsDir = absDir[:lastSlash] + break + } + + a += widthA + b += widthB + } + } + + return lowestAbsDir +} + func (b *Bundle) generateMetadataJSON(results []OutputFile) []byte { // Sort files by key path for determinism sorted := make(indexAndPathArray, 0, len(b.sources)) diff --git a/internal/bundler/bundler_splitting_test.go b/internal/bundler/bundler_splitting_test.go index c80d71b73b2..5d1e9e3681a 100644 --- a/internal/bundler/bundler_splitting_test.go +++ b/internal/bundler/bundler_splitting_test.go @@ -29,19 +29,19 @@ func TestSplittingSharedES6IntoES6(t *testing.T) { expected: map[string]string{ "/out/a.js": `import { foo -} from "./chunk.n2y-pUDL.js"; +} from "./chunk.xL6KqlYO.js"; // /a.js console.log(foo); `, "/out/b.js": `import { foo -} from "./chunk.n2y-pUDL.js"; +} from "./chunk.xL6KqlYO.js"; // /b.js console.log(foo); `, - "/out/chunk.n2y-pUDL.js": `// /shared.js + "/out/chunk.xL6KqlYO.js": `// /shared.js let foo = 123; export { @@ -75,7 +75,7 @@ func TestSplittingSharedCommonJSIntoES6(t *testing.T) { expected: map[string]string{ "/out/a.js": `import { require_shared -} from "./chunk.n2y-pUDL.js"; +} from "./chunk.xL6KqlYO.js"; // /a.js const {foo} = require_shared(); @@ -83,13 +83,13 @@ console.log(foo); `, "/out/b.js": `import { require_shared -} from "./chunk.n2y-pUDL.js"; +} from "./chunk.xL6KqlYO.js"; // /b.js const {foo: foo2} = require_shared(); console.log(foo2); `, - "/out/chunk.n2y-pUDL.js": `// /shared.js + "/out/chunk.xL6KqlYO.js": `// /shared.js var require_shared = __commonJS((exports) => { exports.foo = 123; }); @@ -185,21 +185,21 @@ func TestSplittingDynamicAndNotDynamicES6IntoES6(t *testing.T) { expected: map[string]string{ "/out/entry.js": `import { bar -} from "./chunk.t6dktdAy.js"; +} from "./chunk.-fk8OGuR.js"; // /entry.js import("./foo.js").then(({bar: b}) => console.log(bar, b)); `, "/out/foo.js": `import { bar -} from "./chunk.t6dktdAy.js"; +} from "./chunk.-fk8OGuR.js"; // /foo.js export { bar }; `, - "/out/chunk.t6dktdAy.js": `// /foo.js + "/out/chunk.-fk8OGuR.js": `// /foo.js let bar = 123; export { @@ -231,7 +231,7 @@ func TestSplittingDynamicAndNotDynamicCommonJSIntoES6(t *testing.T) { expected: map[string]string{ "/out/entry.js": `import { require_foo -} from "./chunk.t6dktdAy.js"; +} from "./chunk.-fk8OGuR.js"; // /entry.js const foo = __toModule(require_foo()); @@ -239,12 +239,12 @@ import("./foo.js").then(({default: {bar: b}}) => console.log(foo.bar, b)); `, "/out/foo.js": `import { require_foo -} from "./chunk.t6dktdAy.js"; +} from "./chunk.-fk8OGuR.js"; // /foo.js export default require_foo(); `, - "/out/chunk.t6dktdAy.js": `// /foo.js + "/out/chunk.-fk8OGuR.js": `// /foo.js var require_foo = __commonJS((exports) => { exports.bar = 123; }); @@ -287,7 +287,7 @@ func TestSplittingAssignToLocal(t *testing.T) { "/out/a.js": `import { foo, setFoo -} from "./chunk.n2y-pUDL.js"; +} from "./chunk.xL6KqlYO.js"; // /a.js setFoo(123); @@ -295,12 +295,12 @@ console.log(foo); `, "/out/b.js": `import { foo -} from "./chunk.n2y-pUDL.js"; +} from "./chunk.xL6KqlYO.js"; // /b.js console.log(foo); `, - "/out/chunk.n2y-pUDL.js": `// /shared.js + "/out/chunk.xL6KqlYO.js": `// /shared.js let foo; function setFoo(value) { foo = value; @@ -340,7 +340,7 @@ func TestSplittingSideEffectsWithoutDependencies(t *testing.T) { AbsOutputDir: "/out", }, expected: map[string]string{ - "/out/a.js": `import "./chunk.n2y-pUDL.js"; + "/out/a.js": `import "./chunk.xL6KqlYO.js"; // /shared.js let a = 1; @@ -348,7 +348,7 @@ let a = 1; // /a.js console.log(a); `, - "/out/b.js": `import "./chunk.n2y-pUDL.js"; + "/out/b.js": `import "./chunk.xL6KqlYO.js"; // /shared.js let b = 2; @@ -356,9 +356,60 @@ let b = 2; // /b.js console.log(b); `, - "/out/chunk.n2y-pUDL.js": `// /shared.js + "/out/chunk.xL6KqlYO.js": `// /shared.js console.log("side effect"); `, }, }) } + +func TestSplittingNestedDirectories(t *testing.T) { + expectBundled(t, bundled{ + files: map[string]string{ + "/Users/user/project/src/pages/pageA/page.js": ` + import x from "../shared.js" + console.log(x) + `, + "/Users/user/project/src/pages/pageB/page.js": ` + import x from "../shared.js" + console.log(-x) + `, + "/Users/user/project/src/pages/shared.js": ` + export default 123 + `, + }, + entryPaths: []string{ + "/Users/user/project/src/pages/pageA/page.js", + "/Users/user/project/src/pages/pageB/page.js", + }, + options: config.Options{ + IsBundling: true, + CodeSplitting: true, + OutputFormat: config.FormatESModule, + AbsOutputDir: "/Users/user/project/out", + }, + expected: map[string]string{ + "/Users/user/project/out/pageA/page.js": `import { + shared_default +} from "../chunk.UcWke4C2.js"; + +// /Users/user/project/src/pages/pageA/page.js +console.log(shared_default); +`, + "/Users/user/project/out/pageB/page.js": `import { + shared_default +} from "../chunk.UcWke4C2.js"; + +// /Users/user/project/src/pages/pageB/page.js +console.log(-shared_default); +`, + "/Users/user/project/out/chunk.UcWke4C2.js": `// /Users/user/project/src/pages/shared.js +var shared_default = 123; + +export { + shared_default +}; +`, + }, + }) +} diff --git a/internal/bundler/bundler_test.go b/internal/bundler/bundler_test.go index 2e14922d9f2..fec75d378fe 100644 --- a/internal/bundler/bundler_test.go +++ b/internal/bundler/bundler_test.go @@ -4843,7 +4843,20 @@ func TestMultipleEntryPointsSameNameCollision(t *testing.T) { IsBundling: true, AbsOutputDir: "/out/", }, - expectedCompileLog: "error: Two output files share the same path: /out/entry.js\n", + expected: map[string]string{ + "/out/a/entry.js": `// /common.js +let foo = 123; + +// /a/entry.js +console.log(foo); +`, + "/out/b/entry.js": `// /common.js +let foo = 123; + +// /b/entry.js +console.log(foo); +`, + }, }) } diff --git a/internal/bundler/linker.go b/internal/bundler/linker.go index 695d60b3f14..d1bd0a44d03 100644 --- a/internal/bundler/linker.go +++ b/internal/bundler/linker.go @@ -58,6 +58,7 @@ type linkerContext struct { files []file fileMeta []fileMeta hasErrors bool + lcaAbsPath string // We should avoid traversing all files in the bundle, because the linker // should be able to run a linking operation on a large bundle where only @@ -87,9 +88,13 @@ const ( // the same file. type fileMeta struct { partMeta []partMeta - entryPointName string entryPointStatus entryPointStatus + // The path of this entry point relative to the lowest common ancestor + // directory containing all entry points. Note: this must have OS-independent + // path separators (i.e. '/' not '\'). + entryPointRelPath string + // This is the index to the automatically-generated part containing code that // calls "__export(exports, { ... getters ... })". This is used to generate // getters on an exports object for ES6 export statements, and is both for @@ -232,7 +237,10 @@ type partRef struct { } type chunkMeta struct { - name string + // The path of this chunk relative to the output directory. Note: this must + // have OS-independent path separators (i.e. '/' not '\'). + relPath string + filesWithPartsInChunk map[uint32]bool entryBits bitSet @@ -247,7 +255,15 @@ type chunkMeta struct { crossChunkSuffixStmts []ast.Stmt } -func newLinkerContext(options *config.Options, log logging.Log, fs fs.FS, sources []logging.Source, files []file, entryPoints []uint32) linkerContext { +func newLinkerContext( + options *config.Options, + log logging.Log, + fs fs.FS, + sources []logging.Source, + files []file, + entryPoints []uint32, + lcaAbsPath string, +) linkerContext { // Clone information about symbols and files so we don't mutate the input data c := linkerContext{ options: options, @@ -259,6 +275,7 @@ func newLinkerContext(options *config.Options, log logging.Log, fs fs.FS, source fileMeta: make([]fileMeta, len(files)), symbols: ast.NewSymbolMap(len(files)), reachableFiles: findReachableFiles(sources, files, entryPoints), + lcaAbsPath: lcaAbsPath, } // Clone various things since we may mutate them later @@ -466,6 +483,26 @@ func (c *linkerContext) link() []OutputFile { return outputFiles } +func (c *linkerContext) relativePathBetweenChunks(fromChunk *chunkMeta, toRelPath string) string { + relPath, ok := c.fs.Rel(c.fs.Dir(fromChunk.relPath), toRelPath) + if !ok { + c.log.AddError(nil, ast.Loc{}, + fmt.Sprintf("Cannot traverse from chunk %q to chunk %q", fromChunk.relPath, toRelPath)) + return "" + } + + // Make sure to always use forward slashes, even on Windows + relPath = strings.ReplaceAll(relPath, "\\", "/") + + // Make sure the relative path doesn't start with a name, since that could + // be interpreted as a package path instead of a relative path + if !strings.HasPrefix(relPath, "./") && !strings.HasPrefix(relPath, "../") { + relPath = "./" + relPath + } + + return relPath +} + func (c *linkerContext) computeCrossChunkDependencies(chunks []chunkMeta) { if len(chunks) < 2 { // No need to compute cross-chunk dependencies if there can't be any @@ -502,7 +539,7 @@ func (c *linkerContext) computeCrossChunkDependencies(chunks []chunkMeta) { for _, importRecordIndex := range part.ImportRecordIndices { record := &file.ast.ImportRecords[importRecordIndex] if record.SourceIndex != nil && c.isExternalDynamicImport(record) { - record.Path.Text = "./" + c.fileMeta[*record.SourceIndex].entryPointName + record.Path.Text = c.relativePathBetweenChunks(&chunk, c.fileMeta[*record.SourceIndex].entryPointRelPath) record.SourceIndex = nil } } @@ -600,7 +637,7 @@ func (c *linkerContext) computeCrossChunkDependencies(chunks []chunkMeta) { importRecordIndex := uint32(len(crossChunkImportRecords)) crossChunkImportRecords = append(crossChunkImportRecords, ast.ImportRecord{ Kind: ast.ImportStmt, - Path: ast.Path{Text: "./" + chunks[crossChunkImport.chunkIndex].name}, + Path: ast.Path{Text: c.relativePathBetweenChunks(chunk, chunks[crossChunkImport.chunkIndex].relPath)}, }) if len(items) > 0 { // "import {a, b} from './chunk.js'" @@ -646,7 +683,7 @@ func (c *linkerContext) computeCrossChunkDependencies(chunks []chunkMeta) { type crossChunkImport struct { chunkIndex uint32 - chunkName string + sortingKey string sortedImportAliases crossChunkAliasArray } @@ -657,7 +694,7 @@ func (a crossChunkImportArray) Len() int { return len(a) } func (a crossChunkImportArray) Swap(i int, j int) { a[i], a[j] = a[j], a[i] } func (a crossChunkImportArray) Less(i int, j int) bool { - return a[i].chunkName < a[j].chunkName + return a[i].sortingKey < a[j].sortingKey } // Sort cross-chunk imports by chunk name for determinism @@ -667,7 +704,7 @@ func (c *linkerContext) sortedCrossChunkImports(chunks []chunkMeta, importsFromO for otherChunkIndex, importRefs := range importsFromOtherChunks { result = append(result, crossChunkImport{ chunkIndex: otherChunkIndex, - chunkName: chunks[otherChunkIndex].name, + sortingKey: chunks[otherChunkIndex].relPath, sortedImportAliases: c.sortedCrossChunkImportRefs(importRefs), }) } @@ -2023,18 +2060,28 @@ func (c *linkerContext) computeChunks() []chunkMeta { // Compute entry point names for i, entryPoint := range c.entryPoints { - var entryPointName string + var chunkRelPath string if c.options.AbsOutputFile != "" && c.fileMeta[entryPoint].entryPointStatus == entryPointUserSpecified { - entryPointName = c.fs.Base(c.options.AbsOutputFile) + chunkRelPath = c.fs.Base(c.options.AbsOutputFile) } else { source := c.sources[entryPoint] - if source.KeyPath.IsAbsolute { - entryPointName = c.stripKnownFileExtension(c.fs.Base(source.KeyPath.Text)) + ".js" + if !source.KeyPath.IsAbsolute { + chunkRelPath = source.IdentifierName + } else if relPath, ok := c.fs.Rel(c.lcaAbsPath, source.KeyPath.Text); ok { + chunkRelPath = relPath } else { - entryPointName = source.IdentifierName + ".js" + chunkRelPath = c.fs.Base(source.KeyPath.Text) + } + + // Swap the extension for ".js" + ext := c.fs.Ext(chunkRelPath) + if ext != ".js" { + chunkRelPath = chunkRelPath[:len(chunkRelPath)-len(ext)] + ".js" } } - c.fileMeta[entryPoint].entryPointName = entryPointName + + // Always use cross-platform path separators to avoid problems with Windows + c.fileMeta[entryPoint].entryPointRelPath = strings.ReplaceAll(chunkRelPath, "\\", "/") // Create a chunk for the entry point here to ensure that the chunk is // always generated even if the resulting file is empty @@ -2045,7 +2092,7 @@ func (c *linkerContext) computeChunks() []chunkMeta { isEntryPoint: true, sourceIndex: entryPoint, entryPointBit: uint(i), - name: entryPointName, + relPath: chunkRelPath, filesWithPartsInChunk: make(map[uint32]bool), } } @@ -2064,20 +2111,22 @@ func (c *linkerContext) computeChunks() []chunkMeta { isMultiPart := false for i, entryPoint := range c.entryPoints { if partMeta.entryBits.hasBit(uint(i)) { - if chunk.name != "" { - chunk.name = c.stripKnownFileExtension(chunk.name) + "_" + if chunk.relPath != "" { + chunk.relPath += "\x00" isMultiPart = true } - chunk.name += c.fileMeta[entryPoint].entryPointName + chunk.relPath += c.fileMeta[entryPoint].entryPointRelPath } } // Avoid really long automatically-generated chunk names if isMultiPart { - bytes := []byte(chunk.name) + // Always hash lowercase with forward slashes to avoid OS-specific + // path problems, specifically on Windows + bytes := []byte(lowerCaseAbsPathForWindows(chunk.relPath)) hashBytes := sha1.Sum(bytes) hash := base64.URLEncoding.EncodeToString(hashBytes[:])[:8] - chunk.name = "chunk." + hash + ".js" + chunk.relPath = "chunk." + hash + ".js" } chunk.entryBits = partMeta.entryBits @@ -2103,15 +2152,6 @@ func (c *linkerContext) computeChunks() []chunkMeta { return sortedChunks } -func (c *linkerContext) stripKnownFileExtension(name string) string { - for ext := range c.options.ExtensionToLoader { - if strings.HasSuffix(name, ext) { - return name[:len(name)-len(ext)] - } - } - return name -} - type chunkOrder struct { sourceIndex uint32 distance uint32 @@ -2856,7 +2896,7 @@ func (c *linkerContext) generateChunk(chunk chunkMeta) (results []OutputFile) { j.AddString("\n") } - jsAbsPath := c.fs.Join(c.options.AbsOutputDir, chunk.name) + jsAbsPath := c.fs.Join(c.options.AbsOutputDir, chunk.relPath) if c.options.SourceMap != config.SourceMapNone { sourceMap := c.generateSourceMapForChunk(compileResultsForSourceMap) diff --git a/internal/fs/fs.go b/internal/fs/fs.go index dd92dd14eb5..4b5bb9ebe33 100644 --- a/internal/fs/fs.go +++ b/internal/fs/fs.go @@ -119,7 +119,7 @@ func splitOnSlash(path string) (string, string) { func (*mockFS) Rel(base string, target string) (string, bool) { // Base cases - if base == "" { + if base == "" || base == "." { return target, true } if base == target { diff --git a/scripts/js-api-tests.js b/scripts/js-api-tests.js index ee30365851a..ef2bd7aaf74 100644 --- a/scripts/js-api-tests.js +++ b/scripts/js-api-tests.js @@ -170,6 +170,102 @@ let buildTests = { assert.strictEqual(sourceMap.version, 3) assert.strictEqual(js, `// scripts/.js-api-tests/writeFalse.js\nconsole.log();\n//# sourceMappingURL=writeFalse-out.js.map\n`) }, + + async splittingRelativeSameDir({ esbuild }) { + const inputA = path.join(testDir, 'splittingRelativeSameDir-a.js') + const inputB = path.join(testDir, 'splittingRelativeSameDir-b.js') + const inputCommon = path.join(testDir, 'splittingRelativeSameDir-common.js') + await util.promisify(fs.writeFile)(inputA, ` + import x from "./${path.basename(inputCommon)}" + console.log('a' + x) + `) + await util.promisify(fs.writeFile)(inputB, ` + import x from "./${path.basename(inputCommon)}" + console.log('b' + x) + `) + await util.promisify(fs.writeFile)(inputCommon, ` + export default 'common' + `) + const outdir = path.join(testDir, 'splittingRelativeSameDir-out') + const value = await esbuild.build({ entryPoints: [inputA, inputB], bundle: true, outdir, format: 'esm', splitting: true, write: false }) + assert.strictEqual(value.outputFiles.length, 3) + + // These should all use forward slashes, even on Windows + assert.strictEqual(Buffer.from(value.outputFiles[0].contents).toString(), `import { + splittingRelativeSameDir_common_default +} from "./chunk.4JtreZIq.js"; + +// scripts/.js-api-tests/splittingRelativeSameDir-a.js +console.log("a" + splittingRelativeSameDir_common_default); +`) + assert.strictEqual(Buffer.from(value.outputFiles[1].contents).toString(), `import { + splittingRelativeSameDir_common_default +} from "./chunk.4JtreZIq.js"; + +// scripts/.js-api-tests/splittingRelativeSameDir-b.js +console.log("b" + splittingRelativeSameDir_common_default); +`) + assert.strictEqual(Buffer.from(value.outputFiles[2].contents).toString(), `// scripts/.js-api-tests/splittingRelativeSameDir-common.js +var splittingRelativeSameDir_common_default = "common"; + +export { + splittingRelativeSameDir_common_default +}; +`) + + assert.strictEqual(value.outputFiles[0].path, path.join(outdir, path.basename(inputA))) + assert.strictEqual(value.outputFiles[1].path, path.join(outdir, path.basename(inputB))) + assert.strictEqual(value.outputFiles[2].path, path.join(outdir, 'chunk.4JtreZIq.js')) + }, + + async splittingRelativeNestedDir({ esbuild }) { + const inputA = path.join(testDir, 'splittingRelativeNestedDir-a/demo.js') + const inputB = path.join(testDir, 'splittingRelativeNestedDir-b/demo.js') + const inputCommon = path.join(testDir, 'splittingRelativeNestedDir-common.js') + await util.promisify(fs.mkdir)(path.dirname(inputA)).catch(x => x) + await util.promisify(fs.mkdir)(path.dirname(inputB)).catch(x => x) + await util.promisify(fs.writeFile)(inputA, ` + import x from "../${path.basename(inputCommon)}" + console.log('a' + x) + `) + await util.promisify(fs.writeFile)(inputB, ` + import x from "../${path.basename(inputCommon)}" + console.log('b' + x) + `) + await util.promisify(fs.writeFile)(inputCommon, ` + export default 'common' + `) + const outdir = path.join(testDir, 'splittingRelativeNestedDir-out') + const value = await esbuild.build({ entryPoints: [inputA, inputB], bundle: true, outdir, format: 'esm', splitting: true, write: false }) + assert.strictEqual(value.outputFiles.length, 3) + + // These should all use forward slashes, even on Windows + assert.strictEqual(Buffer.from(value.outputFiles[0].contents).toString(), `import { + splittingRelativeNestedDir_common_default +} from "../chunk._R_iWKlj.js"; + +// scripts/.js-api-tests/splittingRelativeNestedDir-a/demo.js +console.log("a" + splittingRelativeNestedDir_common_default); +`) + assert.strictEqual(Buffer.from(value.outputFiles[1].contents).toString(), `import { + splittingRelativeNestedDir_common_default +} from "../chunk._R_iWKlj.js"; + +// scripts/.js-api-tests/splittingRelativeNestedDir-b/demo.js +console.log("b" + splittingRelativeNestedDir_common_default); +`) + assert.strictEqual(Buffer.from(value.outputFiles[2].contents).toString(), `// scripts/.js-api-tests/splittingRelativeNestedDir-common.js +var splittingRelativeNestedDir_common_default = "common"; + +export { + splittingRelativeNestedDir_common_default +}; +`) + + assert.strictEqual(value.outputFiles[0].path, path.join(outdir, path.relative(testDir, inputA))) + assert.strictEqual(value.outputFiles[1].path, path.join(outdir, path.relative(testDir, inputB))) + assert.strictEqual(value.outputFiles[2].path, path.join(outdir, 'chunk._R_iWKlj.js')) + }, } async function futureSyntax(service, js, targetBelow, targetAbove) {