Skip to content

Commit

Permalink
fix #224: subdirs in output directory (breaking)
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Jul 11, 2020
1 parent 2930b7a commit 8a8aa78
Show file tree
Hide file tree
Showing 7 changed files with 325 additions and 53 deletions.
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
62 changes: 59 additions & 3 deletions internal/bundler/bundler.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"sort"
"strings"
"sync"
"unicode"
"unicode/utf8"

"github.com/evanw/esbuild/internal/ast"
"github.com/evanw/esbuild/internal/config"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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))
Expand Down
87 changes: 69 additions & 18 deletions internal/bundler/bundler_splitting_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.h1SRgmUt.js";
// /a.js
console.log(foo);
`,
"/out/b.js": `import {
foo
} from "./chunk.n2y-pUDL.js";
} from "./chunk.h1SRgmUt.js";
// /b.js
console.log(foo);
`,
"/out/chunk.n2y-pUDL.js": `// /shared.js
"/out/chunk.h1SRgmUt.js": `// /shared.js
let foo = 123;
export {
Expand Down Expand Up @@ -75,21 +75,21 @@ func TestSplittingSharedCommonJSIntoES6(t *testing.T) {
expected: map[string]string{
"/out/a.js": `import {
require_shared
} from "./chunk.n2y-pUDL.js";
} from "./chunk.h1SRgmUt.js";
// /a.js
const {foo} = require_shared();
console.log(foo);
`,
"/out/b.js": `import {
require_shared
} from "./chunk.n2y-pUDL.js";
} from "./chunk.h1SRgmUt.js";
// /b.js
const {foo: foo2} = require_shared();
console.log(foo2);
`,
"/out/chunk.n2y-pUDL.js": `// /shared.js
"/out/chunk.h1SRgmUt.js": `// /shared.js
var require_shared = __commonJS((exports) => {
exports.foo = 123;
});
Expand Down Expand Up @@ -185,21 +185,21 @@ func TestSplittingDynamicAndNotDynamicES6IntoES6(t *testing.T) {
expected: map[string]string{
"/out/entry.js": `import {
bar
} from "./chunk.t6dktdAy.js";
} from "./chunk.3OVLQCM7.js";
// /entry.js
import("./foo.js").then(({bar: b}) => console.log(bar, b));
`,
"/out/foo.js": `import {
bar
} from "./chunk.t6dktdAy.js";
} from "./chunk.3OVLQCM7.js";
// /foo.js
export {
bar
};
`,
"/out/chunk.t6dktdAy.js": `// /foo.js
"/out/chunk.3OVLQCM7.js": `// /foo.js
let bar = 123;
export {
Expand Down Expand Up @@ -231,20 +231,20 @@ func TestSplittingDynamicAndNotDynamicCommonJSIntoES6(t *testing.T) {
expected: map[string]string{
"/out/entry.js": `import {
require_foo
} from "./chunk.t6dktdAy.js";
} from "./chunk.3OVLQCM7.js";
// /entry.js
const foo = __toModule(require_foo());
import("./foo.js").then(({default: {bar: b}}) => console.log(foo.bar, b));
`,
"/out/foo.js": `import {
require_foo
} from "./chunk.t6dktdAy.js";
} from "./chunk.3OVLQCM7.js";
// /foo.js
export default require_foo();
`,
"/out/chunk.t6dktdAy.js": `// /foo.js
"/out/chunk.3OVLQCM7.js": `// /foo.js
var require_foo = __commonJS((exports) => {
exports.bar = 123;
});
Expand Down Expand Up @@ -287,20 +287,20 @@ func TestSplittingAssignToLocal(t *testing.T) {
"/out/a.js": `import {
foo,
setFoo
} from "./chunk.n2y-pUDL.js";
} from "./chunk.h1SRgmUt.js";
// /a.js
setFoo(123);
console.log(foo);
`,
"/out/b.js": `import {
foo
} from "./chunk.n2y-pUDL.js";
} from "./chunk.h1SRgmUt.js";
// /b.js
console.log(foo);
`,
"/out/chunk.n2y-pUDL.js": `// /shared.js
"/out/chunk.h1SRgmUt.js": `// /shared.js
let foo;
function setFoo(value) {
foo = value;
Expand Down Expand Up @@ -340,25 +340,76 @@ func TestSplittingSideEffectsWithoutDependencies(t *testing.T) {
AbsOutputDir: "/out",
},
expected: map[string]string{
"/out/a.js": `import "./chunk.n2y-pUDL.js";
"/out/a.js": `import "./chunk.h1SRgmUt.js";
// /shared.js
let a = 1;
// /a.js
console.log(a);
`,
"/out/b.js": `import "./chunk.n2y-pUDL.js";
"/out/b.js": `import "./chunk.h1SRgmUt.js";
// /shared.js
let b = 2;
// /b.js
console.log(b);
`,
"/out/chunk.n2y-pUDL.js": `// /shared.js
"/out/chunk.h1SRgmUt.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.HvShy9bm.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.HvShy9bm.js";
// /Users/user/project/src/pages/pageB/page.js
console.log(-shared_default);
`,
"/Users/user/project/out/chunk.HvShy9bm.js": `// /Users/user/project/src/pages/shared.js
var shared_default = 123;
export {
shared_default
};
`,
},
})
}
15 changes: 14 additions & 1 deletion internal/bundler/bundler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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);
`,
},
})
}

Expand Down
Loading

0 comments on commit 8a8aa78

Please sign in to comment.