Skip to content

Commit

Permalink
support data url imports in js and css
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Mar 7, 2021
1 parent 9dc94a3 commit eb414db
Show file tree
Hide file tree
Showing 9 changed files with 338 additions and 44 deletions.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@

This usually doesn't need special handling from esbuild's `--keep-names` option because esbuild doesn't modify field names, so the `.name` property will not change. However, esbuild will relocate the field initializer if the configured language target doesn't support class fields (e.g. `--target=es6`). In that case the `.name` property wasn't preserved even when `--keep-names` was specified. This bug has been fixed. Now the `.name` property should be preserved in this case as long as you enable `--keep-names`.
* Enable importing certain data URLs in CSS and JavaScript
You can now import data URLs of type `text/css` using a CSS `@import` rule and import data URLs of type `text/javascript` and `application/json` using a JavaScript `import` statement. For example, doing this is now possible:
```js
import 'data:text/javascript,console.log("hello!");';
import _ from 'data:application/json,"world!"';
```
This is for compatibility with node which [supports this feature natively](https://nodejs.org/docs/latest/api/esm.html#esm_data_imports). Importing from a data URL is sometimes useful for injecting code to be evaluated before an external import without needing to generate a separate imported file.
## 0.8.56
* Fix a discrepancy with esbuild's `tsconfig.json` implementation ([#913](https://github.com/evanw/esbuild/issues/913))
Expand Down
77 changes: 42 additions & 35 deletions internal/bundler/bundler.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@ import (
"crypto/sha1"
"encoding/base32"
"encoding/base64"
"errors"
"fmt"
"mime"
"net/http"
"net/url"
"sort"
"strings"
"sync"
Expand Down Expand Up @@ -293,7 +291,7 @@ func parseFile(args parseArgs) {
case config.LoaderDataURL:
mimeType := guessMimeType(ext, source.Contents)
encoded := base64.StdEncoding.EncodeToString([]byte(source.Contents))
url := "data:" + mimeType + ";base64," + encoded
url := fmt.Sprintf("data:%s;base64,%s", mimeType, encoded)
expr := js_ast.Expr{Data: &js_ast.EString{Value: js_lexer.StringToUTF16(url)}}
ast := js_parser.LazyExportAST(args.log, source, js_parser.OptionsFromConfig(&args.options), expr, "")
ast.URLForCSS = url
Expand Down Expand Up @@ -355,10 +353,10 @@ func parseFile(args parseArgs) {

default:
var message string
if ext != "" {
if source.KeyPath.Namespace == "file" && ext != "" {
message = fmt.Sprintf("No loader is configured for %q files: %s", ext, source.PrettyPath)
} else {
message = fmt.Sprintf("File could not be loaded: %s", source.PrettyPath)
message = fmt.Sprintf("Do not know how to load path: %s", source.PrettyPath)
}
args.log.AddRangeError(args.importSource, args.importPathRange, message)
}
Expand Down Expand Up @@ -527,8 +525,8 @@ func extractSourceMapFromComment(
absResolveDir string,
) (logger.Path, *string) {
// Support data URLs
if strings.HasPrefix(comment.Text, "data:") {
if contents, err := dataFromDataURL(comment.Text); err == nil {
if parsed, ok := resolver.ParseDataURL(comment.Text); ok {
if contents, err := parsed.DecodeData(); err == nil {
return logger.Path{Text: source.PrettyPath, IgnoredSuffix: "#sourceMappingURL"}, &contents
} else {
log.AddRangeWarning(source, comment.Range, fmt.Sprintf("Unsupported source map comment: %s", err.Error()))
Expand Down Expand Up @@ -557,32 +555,6 @@ func extractSourceMapFromComment(
return logger.Path{}, nil
}

func dataFromDataURL(dataURL string) (string, error) {
if strings.HasPrefix(dataURL, "data:") {
if comma := strings.IndexByte(dataURL, ','); comma != -1 {
b64 := ";base64,"

// Try to read base64 data
if pos := comma - len(b64) + 1; pos >= 0 && dataURL[pos:pos+len(b64)] == b64 {
bytes, err := base64.StdEncoding.DecodeString(dataURL[comma+1:])
if err != nil {
return "", fmt.Errorf("Could not decode base64 data: %s", err.Error())
}
return string(bytes), nil
}

// Try to read percent-escaped data
content, err := url.QueryUnescape(dataURL[comma+1:])
if err != nil {
return "", fmt.Errorf("Could not decode percent-escaped data: %s", err.Error())
}
return content, nil
}
}

return "", errors.New("Invalid data URL")
}

func sanetizeLocation(res resolver.Resolver, loc *logger.MsgLocation) {
if loc != nil {
if loc.Namespace == "" {
Expand Down Expand Up @@ -843,6 +815,30 @@ func runOnLoadPlugins(
}
}

// Native support for data URLs. This is supported natively by node:
// https://nodejs.org/docs/latest/api/esm.html#esm_data_imports
if source.KeyPath.Namespace == "dataurl" {
if parsed, ok := resolver.ParseDataURL(source.KeyPath.Text); ok {
if mimeType := parsed.DecodeMIMEType(); mimeType != resolver.MIMETypeUnsupported {
if contents, err := parsed.DecodeData(); err != nil {
log.AddRangeError(importSource, importPathRange,
fmt.Sprintf("Could not load data URL: %s", err.Error()))
return loaderPluginResult{loader: config.LoaderNone}, true
} else {
source.Contents = contents
switch mimeType {
case resolver.MIMETypeTextCSS:
return loaderPluginResult{loader: config.LoaderCSS}, true
case resolver.MIMETypeTextJavaScript:
return loaderPluginResult{loader: config.LoaderJS}, true
case resolver.MIMETypeApplicationJSON:
return loaderPluginResult{loader: config.LoaderJSON}, true
}
}
}
}
}

// Otherwise, fail to load the path
return loaderPluginResult{loader: config.LoaderNone}, true
}
Expand Down Expand Up @@ -994,6 +990,17 @@ func (s *scanner) maybeParseFile(
skipResolve = true
}

// Special-case pretty-printed paths for data URLs
if path.Namespace == "dataurl" {
if _, ok := resolver.ParseDataURL(path.Text); ok {
prettyPath = path.Text
if len(prettyPath) > 64 {
prettyPath = prettyPath[:64] + "..."
}
prettyPath = fmt.Sprintf("<%s>", prettyPath)
}
}

go parseFile(parseArgs{
fs: s.fs,
log: s.log,
Expand Down Expand Up @@ -1250,8 +1257,8 @@ func (s *scanner) scanAllDependencies() {
path := resolveResult.PathPair.Primary
if !resolveResult.IsExternal {
// Handle a path within the bundle
prettyPath := s.res.PrettyPath(path)
sourceIndex := s.maybeParseFile(*resolveResult, prettyPath, &result.file.source, record.Range, resolveResult.PluginData, inputKindNormal, nil)
sourceIndex := s.maybeParseFile(*resolveResult, s.res.PrettyPath(path),
&result.file.source, record.Range, resolveResult.PluginData, inputKindNormal, nil)
record.SourceIndex = ast.MakeIndex32(sourceIndex)
} else {
// If the path to the external module is relative to the source
Expand Down
2 changes: 1 addition & 1 deletion internal/bundler/bundler_default_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1085,7 +1085,7 @@ func TestRequireBadExtension(t *testing.T) {
Mode: config.ModeBundle,
AbsOutputFile: "/out.js",
},
expectedScanLog: `entry.js: error: File could not be loaded: test
expectedScanLog: `entry.js: error: Do not know how to load path: test
`,
})
}
Expand Down
114 changes: 114 additions & 0 deletions internal/bundler/bundler_loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -446,3 +446,117 @@ func TestLoaderFromExtensionWithQueryParameter(t *testing.T) {
},
})
}

func TestLoaderDataURLTextCSS(t *testing.T) {
default_suite.expectBundled(t, bundled{
files: map[string]string{
"/entry.css": `
@import "data:text/css,body{color:%72%65%64}";
@import "data:text/css;base64,Ym9keXtiYWNrZ3JvdW5kOmJsdWV9";
@import "data:text/css;charset=UTF-8,body{color:%72%65%64}";
@import "data:text/css;charset=UTF-8;base64,Ym9keXtiYWNrZ3JvdW5kOmJsdWV9";
`,
},
entryPaths: []string{"/entry.css"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputDir: "/out",
},
})
}

func TestLoaderDataURLTextCSSCannotImport(t *testing.T) {
default_suite.expectBundled(t, bundled{
files: map[string]string{
"/entry.css": `
@import "data:text/css,@import './other.css';";
`,
"/other.css": `
div { should-not-be-imported: true }
`,
},
entryPaths: []string{"/entry.css"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputDir: "/out",
},
expectedScanLog: `<data:text/css,@import './other.css';>: error: Could not resolve "./other.css"
`,
})
}

func TestLoaderDataURLTextJavaScript(t *testing.T) {
default_suite.expectBundled(t, bundled{
files: map[string]string{
"/entry.js": `
import "data:text/javascript,console.log('%31%32%33')";
import "data:text/javascript;base64,Y29uc29sZS5sb2coMjM0KQ==";
import "data:text/javascript;charset=UTF-8,console.log(%31%32%33)";
import "data:text/javascript;charset=UTF-8;base64,Y29uc29sZS5sb2coMjM0KQ==";
`,
},
entryPaths: []string{"/entry.js"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputDir: "/out",
},
})
}

func TestLoaderDataURLTextJavaScriptCannotImport(t *testing.T) {
default_suite.expectBundled(t, bundled{
files: map[string]string{
"/entry.js": `
import "data:text/javascript,import './other.js'"
`,
"/other.js": `
shouldNotBeImported = true
`,
},
entryPaths: []string{"/entry.js"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputDir: "/out",
},
expectedScanLog: `<data:text/javascript,import './other.js'>: error: Could not resolve "./other.js"
`,
})
}

func TestLoaderDataURLApplicationJSON(t *testing.T) {
default_suite.expectBundled(t, bundled{
files: map[string]string{
"/entry.js": `
import a from 'data:application/json,"%31%32%33"';
import b from 'data:application/json;base64,eyJ3b3JrcyI6dHJ1ZX0=';
import c from 'data:application/json;charset=UTF-8,%31%32%33';
import d from 'data:application/json;charset=UTF-8;base64,eyJ3b3JrcyI6dHJ1ZX0=';
console.log([
a, b, c, d,
])
`,
},
entryPaths: []string{"/entry.js"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputDir: "/out",
},
})
}

func TestLoaderDataURLUnknownMIME(t *testing.T) {
default_suite.expectBundled(t, bundled{
files: map[string]string{
"/entry.js": `
import a from 'data:some/thing;what,someData%31%32%33';
import b from 'data:other/thing;stuff;base64,c29tZURhdGEyMzQ=';
console.log(a, b)
`,
},
entryPaths: []string{"/entry.js"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputDir: "/out",
},
})
}
73 changes: 73 additions & 0 deletions internal/bundler/snapshots/snapshots_default.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,79 @@ var clsExprKeep = /* @__PURE__ */ __name(class {
}, "keep");
new clsExprKeep();

================================================================================
TestLoaderDataURLApplicationJSON
---------- /out/entry.js ----------
// <data:application/json,"%31%32%33">
var json_31_32_33_default = "123";

// <data:application/json;base64,eyJ3b3JrcyI6dHJ1ZX0=>
var works = true;
var json_base64_eyJ3b3JrcyI6dHJ1ZX0_default = {works};

// <data:application/json;charset=UTF-8,%31%32%33>
var json_charset_UTF_8_31_32_33_default = 123;

// <data:application/json;charset=UTF-8;base64,eyJ3b3JrcyI6dHJ1ZX0=>
var works2 = true;
var json_charset_UTF_8_base64_eyJ3b3JrcyI6dHJ1ZX0_default = {works: works2};

// entry.js
console.log([
json_31_32_33_default,
json_base64_eyJ3b3JrcyI6dHJ1ZX0_default,
json_charset_UTF_8_31_32_33_default,
json_charset_UTF_8_base64_eyJ3b3JrcyI6dHJ1ZX0_default
]);

================================================================================
TestLoaderDataURLTextCSS
---------- /out/entry.css ----------
/* <data:text/css,body{color:%72%65%64}> */
body {
color: red;
}

/* <data:text/css;base64,Ym9keXtiYWNrZ3JvdW5kOmJsdWV9> */
body {
background: blue;
}

/* <data:text/css;charset=UTF-8,body{color:%72%65%64}> */
body {
color: red;
}

/* <data:text/css;charset=UTF-8;base64,Ym9keXtiYWNrZ3JvdW5kOmJsdWV9> */
body {
background: blue;
}

/* entry.css */

================================================================================
TestLoaderDataURLTextJavaScript
---------- /out/entry.js ----------
// <data:text/javascript,console.log('%31%32%33')>
console.log("123");

// <data:text/javascript;base64,Y29uc29sZS5sb2coMjM0KQ==>
console.log(234);

// <data:text/javascript;charset=UTF-8,console.log(%31%32%33)>
console.log(123);

// <data:text/javascript;charset=UTF-8;base64,Y29uc29sZS5sb2coMjM0KQ...>
console.log(234);

================================================================================
TestLoaderDataURLUnknownMIME
---------- /out/entry.js ----------
// entry.js
import a from "data:some/thing;what,someData%31%32%33";
import b from "data:other/thing;stuff;base64,c29tZURhdGEyMzQ=";
console.log(a, b);

================================================================================
TestLoaderFileWithQueryParameter
---------- /out/file.JAWLBT6L.txt ----------
Expand Down
Loading

0 comments on commit eb414db

Please sign in to comment.