Skip to content

Commit

Permalink
fix #415: extract paths from css url tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Sep 29, 2020
1 parent d10cb70 commit 74b71cf
Show file tree
Hide file tree
Showing 10 changed files with 389 additions and 32 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@

Whitespace around commas in CSS will now be pretty-printed when not minifying and removed when minifying. So `a , b` becomes `a, b` when pretty-printed and `a,b` when minified.

* Treat `url(...)` in CSS files as an import ([#415](https://github.com/evanw/esbuild/issues/415))

When bundling, the `url(...)` syntax in CSS now tries to resolve the URL as a path using the bundler's built in path resolution logic.

## 0.7.7

* Fix TypeScript decorators on static members
Expand Down
3 changes: 3 additions & 0 deletions internal/ast/ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ const (

// A CSS "@import" rule
AtImport

// A CSS "url(...)" token
URLToken
)

type ImportRecord struct {
Expand Down
46 changes: 40 additions & 6 deletions internal/bundler/bundler.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,16 +231,20 @@ func parseFile(args parseArgs) {
result.ok = ok

case config.LoaderText:
encoded := base64.StdEncoding.EncodeToString([]byte(source.Contents))
expr := js_ast.Expr{Data: &js_ast.EString{Value: js_lexer.StringToUTF16(source.Contents)}}
ast := js_parser.LazyExportAST(args.log, source, args.options, expr, "")
ast.URLForCSS = "data:text/plain;base64," + encoded
result.file.ignoreIfUnused = true
result.file.repr = &reprJS{ast: ast}
result.ok = true

case config.LoaderBase64:
mimeType := guessMimeType(args.fs.Ext(args.baseName), source.Contents)
encoded := base64.StdEncoding.EncodeToString([]byte(source.Contents))
expr := js_ast.Expr{Data: &js_ast.EString{Value: js_lexer.StringToUTF16(encoded)}}
ast := js_parser.LazyExportAST(args.log, source, args.options, expr, "")
ast.URLForCSS = "data:" + mimeType + ";base64," + encoded
result.file.ignoreIfUnused = true
result.file.repr = &reprJS{ast: ast}
result.ok = true
Expand All @@ -249,19 +253,18 @@ func parseFile(args parseArgs) {
encoded := base64.StdEncoding.EncodeToString([]byte(source.Contents))
expr := js_ast.Expr{Data: &js_ast.EString{Value: js_lexer.StringToUTF16(encoded)}}
ast := js_parser.LazyExportAST(args.log, source, args.options, expr, "__toBinary")
ast.URLForCSS = "data:application/octet-stream;base64," + encoded
result.file.ignoreIfUnused = true
result.file.repr = &reprJS{ast: ast}
result.ok = true

case config.LoaderDataURL:
mimeType := mime.TypeByExtension(args.fs.Ext(args.baseName))
if mimeType == "" {
mimeType = http.DetectContentType([]byte(source.Contents))
}
mimeType := guessMimeType(args.fs.Ext(args.baseName), source.Contents)
encoded := base64.StdEncoding.EncodeToString([]byte(source.Contents))
url := "data:" + strings.ReplaceAll(mimeType, "; ", ";") + ";base64," + encoded
url := "data:" + mimeType + ";base64," + encoded
expr := js_ast.Expr{Data: &js_ast.EString{Value: js_lexer.StringToUTF16(url)}}
ast := js_parser.LazyExportAST(args.log, source, args.options, expr, "")
ast.URLForCSS = url
result.file.ignoreIfUnused = true
result.file.repr = &reprJS{ast: ast}
result.ok = true
Expand All @@ -279,6 +282,7 @@ func parseFile(args parseArgs) {
// Export the resulting relative path as a string
expr := js_ast.Expr{Data: &js_ast.EString{Value: js_lexer.StringToUTF16(baseName)}}
ast := js_parser.LazyExportAST(args.log, source, args.options, expr, "")
ast.URLForCSS = baseName
result.file.ignoreIfUnused = true
result.file.repr = &reprJS{ast: ast}
result.ok = true
Expand Down Expand Up @@ -409,6 +413,16 @@ func parseFile(args parseArgs) {
args.results <- result
}

func guessMimeType(extension string, contents string) string {
mimeType := mime.TypeByExtension(extension)
if mimeType == "" {
mimeType = http.DetectContentType([]byte(contents))
}

// Turn "text/plain; charset=utf-8" into "text/plain;charset=utf-8"
return strings.ReplaceAll(mimeType, "; ", ";")
}

func extractSourceMapFromComment(log logger.Log, fs fs.FS, res resolver.Resolver, source *logger.Source, comment js_ast.Span) (logger.Path, *string) {
// Data URL
if strings.HasPrefix(comment.Text, "data:") {
Expand Down Expand Up @@ -696,12 +710,32 @@ func ScanBundle(log logger.Log, fs fs.FS, res resolver.Resolver, entryPaths []st
}

// Importing a JavaScript file from a CSS file is not allowed.
if _, ok := result.file.repr.(*reprCSS); ok {
switch record.Kind {
case ast.AtImport:
otherFile := &results[*record.SourceIndex].file
if _, ok := otherFile.repr.(*reprJS); ok {
log.AddRangeError(&result.file.source, record.Range,
fmt.Sprintf("Cannot import %q into a CSS file", otherFile.source.PrettyPath))
}

case ast.URLToken:
otherFile := &results[*record.SourceIndex].file
switch otherRepr := otherFile.repr.(type) {
case *reprCSS:
log.AddRangeError(&result.file.source, record.Range,
fmt.Sprintf("Cannot use %q as a URL", otherFile.source.PrettyPath))

case *reprJS:
if otherRepr.ast.URLForCSS != "" {
// Inline the URL if the loader supports URLs
record.Path.Text = otherRepr.ast.URLForCSS
record.Path.Namespace = ""
record.SourceIndex = nil
} else {
log.AddRangeError(&result.file.source, record.Range,
fmt.Sprintf("Cannot use %q as a URL", otherFile.source.PrettyPath))
}
}
}

// If an import from a JavaScript file targets a CSS file, generate a
Expand Down
183 changes: 183 additions & 0 deletions internal/bundler/bundler_css_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,186 @@ func TestImportJSONFromCSS(t *testing.T) {
`,
})
}

func TestMissingImportURLInCSS(t *testing.T) {
css_suite.expectBundled(t, bundled{
files: map[string]string{
"/src/entry.css": `
a { background: url(./one.png); }
b { background: url("./two.png"); }
`,
},
entryPaths: []string{"/src/entry.css"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputDir: "/out",
},
expectedScanLog: `/src/entry.css: error: Could not resolve "./one.png"
/src/entry.css: error: Could not resolve "./two.png"
`,
})
}

func TestExternalImportURLInCSS(t *testing.T) {
css_suite.expectBundled(t, bundled{
files: map[string]string{
"/src/entry.css": `
div:after {
content: 'If this is recognized, the path should become "../src/external.png"';
background: url(./external.png);
}
`,
},
entryPaths: []string{"/src/entry.css"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputDir: "/out",
ExternalModules: config.ExternalModules{
AbsPaths: map[string]bool{
"/src/external.png": true,
},
},
},
})
}

func TestInvalidImportURLInCSS(t *testing.T) {
css_suite.expectBundled(t, bundled{
files: map[string]string{
"/entry.css": `
a {
background: url(./js.js);
background: url("./jsx.jsx");
background: url(./ts.ts);
background: url('./tsx.tsx');
background: url(./json.json);
background: url(./css.css);
}
`,
"/js.js": `export default 123`,
"/jsx.jsx": `export default 123`,
"/ts.ts": `export default 123`,
"/tsx.tsx": `export default 123`,
"/json.json": `{ "test": true }`,
"/css.css": `a { color: red }`,
},
entryPaths: []string{"/entry.css"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputDir: "/out",
},
expectedScanLog: `/entry.css: error: Cannot use "/js.js" as a URL
/entry.css: error: Cannot use "/jsx.jsx" as a URL
/entry.css: error: Cannot use "/ts.ts" as a URL
/entry.css: error: Cannot use "/tsx.tsx" as a URL
/entry.css: error: Cannot use "/json.json" as a URL
/entry.css: error: Cannot use "/css.css" as a URL
`,
})
}

func TestTextImportURLInCSSText(t *testing.T) {
css_suite.expectBundled(t, bundled{
files: map[string]string{
"/entry.css": `
a {
background: url(./example.txt);
}
`,
"/example.txt": `This is some text.`,
},
entryPaths: []string{"/entry.css"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputDir: "/out",
},
})
}

func TestDataURLImportURLInCSS(t *testing.T) {
css_suite.expectBundled(t, bundled{
files: map[string]string{
"/entry.css": `
a {
background: url(./example.png);
}
`,
"/example.png": "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A",
},
entryPaths: []string{"/entry.css"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputDir: "/out",
ExtensionToLoader: map[string]config.Loader{
".css": config.LoaderCSS,
".png": config.LoaderDataURL,
},
},
})
}

func TestBinaryImportURLInCSS(t *testing.T) {
css_suite.expectBundled(t, bundled{
files: map[string]string{
"/entry.css": `
a {
background: url(./example.png);
}
`,
"/example.png": "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A",
},
entryPaths: []string{"/entry.css"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputDir: "/out",
ExtensionToLoader: map[string]config.Loader{
".css": config.LoaderCSS,
".png": config.LoaderBinary,
},
},
})
}

func TestBase64ImportURLInCSS(t *testing.T) {
css_suite.expectBundled(t, bundled{
files: map[string]string{
"/entry.css": `
a {
background: url(./example.png);
}
`,
"/example.png": "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A",
},
entryPaths: []string{"/entry.css"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputDir: "/out",
ExtensionToLoader: map[string]config.Loader{
".css": config.LoaderCSS,
".png": config.LoaderBase64,
},
},
})
}

func TestFileImportURLInCSS(t *testing.T) {
css_suite.expectBundled(t, bundled{
files: map[string]string{
"/entry.css": `
a {
background: url(./example.png);
}
`,
"/example.png": "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A",
},
entryPaths: []string{"/entry.css"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputDir: "/out",
ExtensionToLoader: map[string]config.Loader{
".css": config.LoaderCSS,
".png": config.LoaderFile,
},
},
})
}
49 changes: 49 additions & 0 deletions internal/bundler/snapshots/snapshots_css.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
TestBase64ImportURLInCSS
---------- /out/entry.css ----------
/* /entry.css */
a {
background: url(data:image/png;base64,iVBORw0KGgo=);
}

================================================================================
TestBinaryImportURLInCSS
---------- /out/entry.css ----------
/* /entry.css */
a {
background: url(data:application/octet-stream;base64,iVBORw0KGgo=);
}

================================================================================
TestCSSAtImport
---------- /out.css ----------
/* /shared.css */
Expand Down Expand Up @@ -67,6 +83,31 @@ console.log(void 0);
color: red;
}

================================================================================
TestDataURLImportURLInCSS
---------- /out/entry.css ----------
/* /entry.css */
a {
background: url(data:image/png;base64,iVBORw0KGgo=);
}

================================================================================
TestExternalImportURLInCSS
---------- /out/entry.css ----------
/* /src/entry.css */
div:after {
content: 'If this is recognized, the path should become "../src/external.png"';
background: url(../src/external.png);
}

================================================================================
TestFileImportURLInCSS
---------- /out/entry.css ----------
/* /entry.css */
a {
background: url(example.JSXM4U43.png);
}

================================================================================
TestImportCSSFromJS
---------- /out/entry.js ----------
Expand All @@ -92,3 +133,11 @@ console.log("b");
.b {
color: blue;
}

================================================================================
TestTextImportURLInCSSText
---------- /out/entry.css ----------
/* /entry.css */
a {
background: url(data:text/plain;base64,VGhpcyBpcyBzb21lIHRleHQu);
}
4 changes: 4 additions & 0 deletions internal/css_ast/css_ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ type Token struct {
// implicit and is not stored.
Children *[]Token // 8 bytes

// URL tokens have an associated import record at the top-level of the AST.
// This index points to that import record.
ImportRecordIndex uint32 // 4 bytes

// This will never be "TWhitespace" because whitespace isn't stored as a
// token directly. Instead it is stored in "HasWhitespaceAfter" on the
// previous token.
Expand Down
Loading

0 comments on commit 74b71cf

Please sign in to comment.