Skip to content

Commit

Permalink
css: handle external @import condition chains
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Aug 11, 2023
1 parent d81d759 commit 83917cf
Show file tree
Hide file tree
Showing 5 changed files with 121 additions and 21 deletions.
6 changes: 1 addition & 5 deletions internal/bundler/bundler.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,11 +299,7 @@ func parseFile(args parseArgs) {

case config.LoaderDataURL:
mimeType := guessMimeType(ext, source.Contents)
encoded := base64.StdEncoding.EncodeToString([]byte(source.Contents))
url := fmt.Sprintf("data:%s;base64,%s", mimeType, encoded)
if percentURL, ok := helpers.EncodeStringAsPercentEscapedDataURL(mimeType, source.Contents); ok && len(percentURL) < len(url) {
url = percentURL
}
url := helpers.EncodeStringAsShortestDataURL(mimeType, source.Contents)
expr := js_ast.Expr{Data: &js_ast.EString{Value: helpers.StringToUTF16(url)}}
ast := js_parser.LazyExportAST(args.log, source, js_parser.OptionsFromConfig(&args.options), expr, "")
ast.URLForCSS = url
Expand Down
24 changes: 24 additions & 0 deletions internal/bundler_tests/bundler_css_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1967,6 +1967,30 @@ func TestCSSAtImportConditionsAtLayerBundleAlternatingLayerOnImport(t *testing.T
})
}

func TestCSSAtImportConditionsChainExternal(t *testing.T) {
css_suite.expectBundled(t, bundled{
files: map[string]string{
"/entry.css": `
@import "a.css" layer(a) not print;
`,
"/a.css": `
@import "http://example.com/external1.css";
@import "b.css" layer(b) not tv;
@import "http://example.com/external2.css" layer(a2);
`,
"/b.css": `
@import "http://example.com/external3.css";
@import "http://example.com/external4.css" layer(b2);
`,
},
entryPaths: []string{"/entry.css"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputFile: "/out.css",
},
})
}

// This test mainly just makes sure that this scenario doesn't crash
func TestCSSAndJavaScriptCodeSplittingIssue1064(t *testing.T) {
css_suite.expectBundled(t, bundled{
Expand Down
24 changes: 24 additions & 0 deletions internal/bundler_tests/snapshots/snapshots_css.txt
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,30 @@ TestCSSAtImportConditionsBundleExternalConditionWithURL

/* entry.css */

================================================================================
TestCSSAtImportConditionsChainExternal
---------- /out.css ----------
@import "http://example.com/external1.css" layer(a) not print;
@import 'data:text/css,@import "http://example.com/external3.css" layer(b) not tv;' layer(a) not print;
@import "data:text/css,@import 'data:text/css,@import \"http://example.com/external4.css\" layer(b2);' layer(b) not tv;" layer(a) not print;
@import 'data:text/css,@import "http://example.com/external2.css" layer(a2);' layer(a) not print;

/* b.css */
@media not print {
@layer a {
@media not tv {
@layer b;
}
}
}

/* a.css */
@media not print {
@layer a;
}

/* entry.css */

================================================================================
TestCSSAtImportConditionsFromExternalRepo
---------- /out/001/default/style.css ----------
Expand Down
12 changes: 12 additions & 0 deletions internal/helpers/dataurl.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
package helpers

import (
"encoding/base64"
"fmt"
"strings"
"unicode/utf8"
)

// Returns the shorter of either a base64-encoded or percent-escaped data URL
func EncodeStringAsShortestDataURL(mimeType string, text string) string {
encoded := base64.StdEncoding.EncodeToString([]byte(text))
url := fmt.Sprintf("data:%s;base64,%s", mimeType, encoded)
if percentURL, ok := EncodeStringAsPercentEscapedDataURL(mimeType, text); ok && len(percentURL) < len(url) {
return percentURL
}
return url
}

// See "scripts/dataurl-escapes.html" for how this was derived
func EncodeStringAsPercentEscapedDataURL(mimeType string, text string) (string, bool) {
hex := "0123456789ABCDEF"
Expand Down
76 changes: 60 additions & 16 deletions internal/linker/linker.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ type chunkReprCSS struct {

type externalImportCSS struct {
path logger.Path
conditions css_ast.ImportConditions
conditions []css_ast.ImportConditions
conditionImportRecords []ast.ImportRecord
}

Expand Down Expand Up @@ -3408,15 +3408,17 @@ func (c *linkerContext) findImportedFilesInCSSOrder(entryPoints []uint32) (exter

// Record external dependencies
if (record.Flags & ast.WasLoadedWithEmptyLoader) == 0 {
var conditions css_ast.ImportConditions
var importRecords []ast.ImportRecord
allConditions := append([]css_ast.ImportConditions{}, wrappingConditions...)
allImportRecords := append([]ast.ImportRecord{}, wrappingImportRecords...)
if atImport.ImportConditions != nil {
conditions, importRecords = atImport.ImportConditions.CloneWithImportRecords(repr.AST.ImportRecords, importRecords)
var conditions css_ast.ImportConditions
conditions, allImportRecords = atImport.ImportConditions.CloneWithImportRecords(repr.AST.ImportRecords, allImportRecords)
allConditions = append(allConditions, conditions)
}
externalOrder = append(externalOrder, externalImportCSS{
path: record.Path,
conditions: conditions,
conditionImportRecords: importRecords,
conditions: allConditions,
conditionImportRecords: allImportRecords,
})
}
}
Expand Down Expand Up @@ -3529,7 +3531,7 @@ func (c *linkerContext) findImportedFilesInCSSOrder(entryPoints []uint32) (exter
order := externalOrder[i]
duplicates := externalDuplicates[order.path]
for _, j := range duplicates {
if isConditionalImportRedundant([]css_ast.ImportConditions{order.conditions}, []css_ast.ImportConditions{externalOrder[j].conditions}) {
if isConditionalImportRedundant(order.conditions, externalOrder[j].conditions) {
isRedundantDueTo[i] = ast.MakeIndex32(uint32(j))
continue nextExternal
}
Expand Down Expand Up @@ -3562,13 +3564,27 @@ func (c *linkerContext) findImportedFilesInCSSOrder(entryPoints []uint32) (exter
continue
}

// Remove this redundant entry if it has no layers. THIS ASSUMES THAT
// EXTERNAL IMPORTS DO NOT CONTAIN "@layer" INFORMATION. While this is not
// necessarily a correct assumption, there are other ordering things that
// are also not correct about external "@import" rules when bundling (e.g.
// CSS doesn't allow you to put an "@import" rule in the middle of a file
// so we have to hoist them all to the top) so we don't worry about this.
if len(order.conditions.Layers) == 0 {
// Remove this redundant entry either if it has no named layers
// or if it's wrapped in an anonymous layer without a name.
//
// Note: This assumes that external imports do not contain "@layer"
// information. While this is not necessarily a correct assumption,
// there are other ordering things that are also not correct about
// external "@import" rules when bundling (e.g. CSS doesn't allow
// you to put an "@import" rule in the middle of a file so we have
// to hoist them all to the top) so we don't worry about this.
hasNamedLayers := false
hasAnonymousLayer := false
for _, conditions := range order.conditions {
if len(conditions.Layers) == 1 {
if t := conditions.Layers[0]; t.Children == nil || len(*t.Children) == 0 {
hasAnonymousLayer = true
} else {
hasNamedLayers = true
}
}
}
if !hasNamedLayers || hasAnonymousLayer {
continue
}

Expand Down Expand Up @@ -6015,10 +6031,38 @@ func (c *linkerContext) generateChunkCSS(chunkIndex int, chunkWaitGroup *sync.Wa
// rules must come first or the browser will just ignore them.
for _, external := range chunkRepr.externalImportsInOrder {
var conditions *css_ast.ImportConditions
if len(external.conditions.Layers) > 0 || len(external.conditions.Supports) > 0 || len(external.conditions.Media) > 0 {
if len(external.conditions) > 0 {
var clone css_ast.ImportConditions
clone, tree.ImportRecords = external.conditions.CloneWithImportRecords(external.conditionImportRecords, tree.ImportRecords)
clone, tree.ImportRecords = external.conditions[0].CloneWithImportRecords(external.conditionImportRecords, tree.ImportRecords)
conditions = &clone

// Handling a chain of nested conditions is complicated. We can't
// necessarily join them together because a) there may be multiple
// layer names and b) layer names are only supposed to be inserted
// into the layer order if the parent conditions are applied.
//
// Instead we handle them by preserving the "@import" nesting using
// imports of data URL stylesheets. This may seem strange but I think
// this is the only way to do this in CSS.
for i := len(external.conditions) - 1; i > 0; i-- {
astImport := css_ast.AST{
Rules: []css_ast.Rule{{Data: &css_ast.RAtImport{
ImportRecordIndex: uint32(len(external.conditionImportRecords)),
ImportConditions: &external.conditions[i],
}}},
ImportRecords: append(external.conditionImportRecords, ast.ImportRecord{
Kind: ast.ImportAt,
Path: external.path,
}),
}
astResult := css_printer.Print(astImport, c.graph.Symbols, css_printer.Options{
MinifyWhitespace: c.options.MinifyWhitespace,
ASCIIOnly: c.options.ASCIIOnly,
})
external.path = logger.Path{
Text: helpers.EncodeStringAsShortestDataURL("text/css", string(bytes.TrimSpace(astResult.CSS))),
}
}
}
tree.Rules = append(tree.Rules, css_ast.Rule{Data: &css_ast.RAtImport{
ImportRecordIndex: uint32(len(tree.ImportRecords)),
Expand Down

0 comments on commit 83917cf

Please sign in to comment.