From 953dae945b265df7d9728dbd961f7a27dce941cd Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Sun, 9 Jun 2024 13:23:25 -0400 Subject: [PATCH] fix #3797: import attributes and glob-style import --- CHANGELOG.md | 23 +++++++++++++ internal/bundler/bundler.go | 27 ++++++++++------ internal/bundler_tests/bundler_loader_test.go | 15 +++++++++ .../snapshots/snapshots_loader.txt | 23 +++++++++++++ scripts/plugin-tests.js | 32 +++++++++++++++++++ 5 files changed, 110 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fc60330c70..66764c65ead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,29 @@ import tasty from "./tasty.bagel" with { type: "bagel" } ``` +* Support import attributes with glob-style imports ([#3797](https://github.com/evanw/esbuild/issues/3797)) + + This release adds support for import attributes (the `with` option) to glob-style imports (dynamic imports with certain string literal patterns as paths). These imports previously didn't support import attributes due to an oversight. So code like this will now work correctly: + + ```ts + async function loadLocale(locale: string): Locale { + const data = await import(`./locales/${locale}.data`, { with: { type: 'json' } }) + return unpackLocale(locale, data) + } + ``` + + Previously this didn't work even though esbuild normally supports forcing the JSON loader using an import attribute. Attempting to do this used to result in the following error: + + ``` + ✘ [ERROR] No loader is configured for ".data" files: locales/en-US.data + + example.ts:2:28: + 2 │ const data = await import(`./locales/${locale}.data`, { with: { type: 'json' } }) + ╵ ~~~~~~~~~~~~~~~~~~~~~~~~~~ + ``` + + In addition, this change means plugins can now access the contents of `with` for glob-style imports. + * 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: diff --git a/internal/bundler/bundler.go b/internal/bundler/bundler.go index 31684b85960..67d9e41f54a 100644 --- a/internal/bundler/bundler.go +++ b/internal/bundler/bundler.go @@ -435,6 +435,16 @@ func parseFile(args parseArgs) { continue } + // Encode the import attributes + var attrs logger.ImportAttributes + if record.AssertOrWith != nil && record.AssertOrWith.Keyword == ast.WithKeyword { + data := make(map[string]string, len(record.AssertOrWith.Entries)) + for _, entry := range record.AssertOrWith.Entries { + data[helpers.UTF16ToString(entry.Key)] = helpers.UTF16ToString(entry.Value) + } + attrs = logger.EncodeImportAttributes(data) + } + // Special-case glob pattern imports if record.GlobPattern != nil { prettyPath := helpers.GlobPatternToString(record.GlobPattern.Parts) @@ -451,6 +461,13 @@ func parseFile(args parseArgs) { if result.globResolveResults == nil { result.globResolveResults = make(map[uint32]globResolveResult) } + for key, result := range results { + result.PathPair.Primary.ImportAttributes = attrs + if result.PathPair.HasSecondary() { + result.PathPair.Secondary.ImportAttributes = attrs + } + results[key] = result + } result.globResolveResults[uint32(importRecordIndex)] = globResolveResult{ resolveResults: results, absPath: args.fs.Join(absResolveDir, "(glob)"), @@ -469,16 +486,6 @@ func parseFile(args parseArgs) { continue } - // Encode the import attributes - var attrs logger.ImportAttributes - if record.AssertOrWith != nil && record.AssertOrWith.Keyword == ast.WithKeyword { - data := make(map[string]string, len(record.AssertOrWith.Entries)) - for _, entry := range record.AssertOrWith.Entries { - data[helpers.UTF16ToString(entry.Key)] = helpers.UTF16ToString(entry.Value) - } - attrs = logger.EncodeImportAttributes(data) - } - // Cache the path in case it's imported multiple times in this file cacheKey := cacheKey{ kind: record.Kind, diff --git a/internal/bundler_tests/bundler_loader_test.go b/internal/bundler_tests/bundler_loader_test.go index 7ed20b7b0ee..73e81dc4fb5 100644 --- a/internal/bundler_tests/bundler_loader_test.go +++ b/internal/bundler_tests/bundler_loader_test.go @@ -1303,6 +1303,21 @@ func TestWithTypeJSONOverrideLoader(t *testing.T) { }) } +func TestWithTypeJSONOverrideLoaderGlob(t *testing.T) { + loader_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/entry.js": ` + import("./foo" + bar, { with: { type: 'json' } }).then(console.log) + `, + "/foo.js": `{ "this is json not js": true }`, + }, + entryPaths: []string{"/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + }, + }) +} + func TestWithBadType(t *testing.T) { loader_suite.expectBundled(t, bundled{ files: map[string]string{ diff --git a/internal/bundler_tests/snapshots/snapshots_loader.txt b/internal/bundler_tests/snapshots/snapshots_loader.txt index 59995d13b86..38164783a0e 100644 --- a/internal/bundler_tests/snapshots/snapshots_loader.txt +++ b/internal/bundler_tests/snapshots/snapshots_loader.txt @@ -1035,3 +1035,26 @@ var foo_default = { "this is json not js": true }; // entry.js console.log(foo_default); + +================================================================================ +TestWithTypeJSONOverrideLoaderGlob +---------- entry.js ---------- +// foo.js +var foo_exports = {}; +__export(foo_exports, { + default: () => foo_default +}); +var foo_default; +var init_foo = __esm({ + "foo.js"() { + foo_default = { "this is json not js": true }; + } +}); + +// import("./foo*") in entry.js +var globImport_foo = __glob({ + "./foo.js": () => Promise.resolve().then(() => (init_foo(), foo_exports)) +}); + +// entry.js +globImport_foo("./foo" + bar).then(console.log); diff --git a/scripts/plugin-tests.js b/scripts/plugin-tests.js index 3467c6322ea..c0bb9bd2edd 100644 --- a/scripts/plugin-tests.js +++ b/scripts/plugin-tests.js @@ -2562,6 +2562,38 @@ console.log(foo_default, foo_default2); `) }, + async importAttributesOnLoadGlob({ esbuild, testDir }) { + const entry = path.join(testDir, 'entry.js') + const foo = path.join(testDir, 'foo.js') + await writeFileAsync(entry, ` + Promise.all([ + import('./foo' + js, { with: { type: 'cheese' } }), + import('./foo' + js, { with: { pizza: 'true' } }), + ]).then(resolve) + `) + await writeFileAsync(foo, `export default 123`) + const result = await esbuild.build({ + entryPoints: [entry], + bundle: true, + format: 'esm', + charset: 'utf8', + write: false, + plugins: [{ + name: 'name', + setup(build) { + build.onLoad({ filter: /.*/ }, args => { + if (args.with.type === 'cheese') return { contents: `export default "🧀"` } + if (args.with.pizza === 'true') return { contents: `export default "🍕"` } + }) + }, + }], + }) + const callback = new Function('js', 'resolve', result.outputFiles[0].text) + const [cheese, pizza] = await new Promise(resolve => callback('.js', resolve)) + assert.strictEqual(cheese.default, '🧀') + assert.strictEqual(pizza.default, '🍕') + }, + async importAttributesResolve({ esbuild }) { const onResolve = [] const resolve = []