From 4bb897fb3be805cb186b75a15138e6bbfde2f18d Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Mon, 7 Aug 2023 13:11:27 -0400 Subject: [PATCH] fix #953, fix #3137: advanced css `@import` syntax --- CHANGELOG.md | 20 ++ internal/ast/ast.go | 9 +- internal/bundler/bundler.go | 5 +- internal/bundler_tests/bundler_css_test.go | 77 +++++- .../bundler_tests/snapshots/snapshots_css.txt | 235 ++++++++++++++++++ internal/css_ast/css_ast.go | 39 ++- internal/css_parser/css_parser.go | 4 +- internal/linker/linker.go | 201 +++++++++++---- pkg/api/api_impl.go | 2 +- 9 files changed, 524 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf69ded85d2..552953905fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## Unreleased + +* Support advanced CSS `@import` rules ([#953](https://github.com/evanw/esbuild/issues/953), [#3137](https://github.com/evanw/esbuild/issues/3137)) + + CSS `@import` statements have been extended to allow additional trailing tokens after the import path. These tokens sort of make the imported file behave as if it were wrapped in a `@layer`, `@supports`, and/or `@media` rule. Here are some examples: + + ```css + @import url(foo.css); + @import url(foo.css) layer; + @import url(foo.css) layer(bar); + @import url(foo.css) layer(bar) supports(display: flex); + @import url(foo.css) layer(bar) supports(display: flex) print; + @import url(foo.css) layer(bar) print; + @import url(foo.css) supports(display: flex); + @import url(foo.css) supports(display: flex) print; + @import url(foo.css) print; + ``` + + You can read more about this advanced syntax [here](https://developer.mozilla.org/en-US/docs/Web/CSS/@import). With this release, esbuild will now bundle `@import` rules with these trailing tokens and will wrap the imported files in the corresponding rules. Note that this now means a given imported file can potentially appear in multiple places in the bundle. However, esbuild will still only load it once (e.g. on-load plugins will only run once per file, not once per import). + ## 0.18.19 * Implement `composes` from CSS modules ([#20](https://github.com/evanw/esbuild/issues/20)) diff --git a/internal/ast/ast.go b/internal/ast/ast.go index efb8d562bd1..51af803bb95 100644 --- a/internal/ast/ast.go +++ b/internal/ast/ast.go @@ -32,9 +32,6 @@ const ( // A CSS "@import" rule ImportAt - // A CSS "@import" rule with import conditions - ImportAtConditional - // A CSS "composes" declaration ImportComposesFrom @@ -52,7 +49,7 @@ func (kind ImportKind) StringForMetafile() string { return "dynamic-import" case ImportRequireResolve: return "require-resolve" - case ImportAt, ImportAtConditional: + case ImportAt: return "import-rule" case ImportComposesFrom: return "composes-from" @@ -67,7 +64,7 @@ func (kind ImportKind) StringForMetafile() string { func (kind ImportKind) IsFromCSS() bool { switch kind { - case ImportAt, ImportAtConditional, ImportComposesFrom, ImportURL: + case ImportAt, ImportComposesFrom, ImportURL: return true } return false @@ -75,7 +72,7 @@ func (kind ImportKind) IsFromCSS() bool { func (kind ImportKind) MustResolveToCSS() bool { switch kind { - case ImportAt, ImportAtConditional, ImportComposesFrom: + case ImportAt, ImportComposesFrom: return true } return false diff --git a/internal/bundler/bundler.go b/internal/bundler/bundler.go index 6176fb2281f..e57e53c37b3 100644 --- a/internal/bundler/bundler.go +++ b/internal/bundler/bundler.go @@ -2019,7 +2019,7 @@ func (s *scanner) processScannedFiles(entryPointMeta []graph.EntryPoint) []scann otherFile.inputFile.Source.PrettyPath, config.LoaderToString[otherFile.inputFile.Loader])}}) } - case ast.ImportAt, ast.ImportAtConditional: + case ast.ImportAt: // Using a JavaScript file with CSS "@import" is not allowed if _, ok := otherFile.inputFile.Repr.(*graph.JSRepr); ok && otherFile.inputFile.Loader != config.LoaderEmpty { s.log.AddErrorWithNotes(&tracker, record.Range, @@ -2027,9 +2027,6 @@ func (s *scanner) processScannedFiles(entryPointMeta []graph.EntryPoint) []scann []logger.MsgData{{Text: fmt.Sprintf( "An \"@import\" rule can only be used to import another CSS file and %q is not a CSS file (it was loaded with the %q loader).", otherFile.inputFile.Source.PrettyPath, config.LoaderToString[otherFile.inputFile.Loader])}}) - } else if record.Kind == ast.ImportAtConditional { - s.log.AddError(&tracker, record.Range, - "Bundling with conditional \"@import\" rules is not currently supported") } case ast.ImportURL: diff --git a/internal/bundler_tests/bundler_css_test.go b/internal/bundler_tests/bundler_css_test.go index 42ede9a2fa4..c6a3dd132ad 100644 --- a/internal/bundler_tests/bundler_css_test.go +++ b/internal/bundler_tests/bundler_css_test.go @@ -1347,16 +1347,85 @@ func TestCSSAtImportConditionsBundleExternalConditionWithURL(t *testing.T) { func TestCSSAtImportConditionsBundle(t *testing.T) { css_suite.expectBundled(t, bundled{ files: map[string]string{ - "/entry.css": `@import "./print.css" print;`, - "/print.css": `body { color: red }`, + "/entry.css": ` + @import url(http://example.com/foo.css); + @import url(http://example.com/foo.css) layer; + @import url(http://example.com/foo.css) layer(layer-name); + @import url(http://example.com/foo.css) layer(layer-name) supports(supports-condition); + @import url(http://example.com/foo.css) layer(layer-name) supports(supports-condition) list-of-media-queries; + @import url(http://example.com/foo.css) layer(layer-name) list-of-media-queries; + @import url(http://example.com/foo.css) supports(supports-condition); + @import url(http://example.com/foo.css) supports(supports-condition) list-of-media-queries; + @import url(http://example.com/foo.css) list-of-media-queries; + + @import url(foo.css); + @import url(foo.css) layer; + @import url(foo.css) layer(layer-name); + @import url(foo.css) layer(layer-name) supports(supports-condition); + @import url(foo.css) layer(layer-name) supports(supports-condition) list-of-media-queries; + @import url(foo.css) layer(layer-name) list-of-media-queries; + @import url(foo.css) supports(supports-condition); + @import url(foo.css) supports(supports-condition) list-of-media-queries; + @import url(foo.css) list-of-media-queries; + + @import url(empty-1.css) layer(empty-1); + @import url(empty-2.css) supports(empty: 2); + @import url(empty-3.css) (empty: 3); + + @import "nested-layer.css" layer(outer); + @import "nested-layer.css" supports(outer: true); + @import "nested-layer.css" (outer: true); + @import "nested-supports.css" layer(outer); + @import "nested-supports.css" supports(outer: true); + @import "nested-supports.css" (outer: true); + @import "nested-media.css" layer(outer); + @import "nested-media.css" supports(outer: true); + @import "nested-media.css" (outer: true); + `, + + "/foo.css": `body { color: red }`, + + "/empty-1.css": ``, + "/empty-2.css": ``, + "/empty-3.css": ``, + + "/nested-layer.css": `@import "foo.css" layer(inner);`, + "/nested-supports.css": `@import "foo.css" supports(inner: true);`, + "/nested-media.css": `@import "foo.css" (inner: true);`, }, entryPaths: []string{"/entry.css"}, options: config.Options{ Mode: config.ModeBundle, AbsOutputFile: "/out.css", }, - expectedScanLog: `entry.css: ERROR: Bundling with conditional "@import" rules is not currently supported -`, + }) +} + +func TestCSSAtImportConditionsWithImportRecordsBundle(t *testing.T) { + // This tests that esbuild correctly clones the import records for all import + // condition tokens. If they aren't cloned correctly, then something will + // likely crash with an out-of-bounds error. + css_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/entry.css": ` + @import url(foo.css) supports(background: url(a.png)); + @import url(foo.css) supports(background: url(b.png)) list-of-media-queries; + @import url(foo.css) layer(layer-name) supports(background: url(a.png)); + @import url(foo.css) layer(layer-name) supports(background: url(b.png)) list-of-media-queries; + `, + "/foo.css": `body { color: red }`, + "/a.png": `A`, + "/b.png": `B`, + }, + entryPaths: []string{"/entry.css"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputFile: "/out.css", + ExtensionToLoader: map[string]config.Loader{ + ".css": config.LoaderCSS, + ".png": config.LoaderBase64, + }, + }, }) } diff --git a/internal/bundler_tests/snapshots/snapshots_css.txt b/internal/bundler_tests/snapshots/snapshots_css.txt index 3e90693070c..c5b056ccab4 100644 --- a/internal/bundler_tests/snapshots/snapshots_css.txt +++ b/internal/bundler_tests/snapshots/snapshots_css.txt @@ -86,6 +86,200 @@ TestCSSAtImport color: red; } +================================================================================ +TestCSSAtImportConditionsBundle +---------- /out.css ---------- +@import "http://example.com/foo.css"; +@import "http://example.com/foo.css" layer; +@import "http://example.com/foo.css" layer(layer-name); +@import "http://example.com/foo.css" layer(layer-name) supports(supports-condition); +@import "http://example.com/foo.css" layer(layer-name) supports(supports-condition) list-of-media-queries; +@import "http://example.com/foo.css" layer(layer-name) list-of-media-queries; +@import "http://example.com/foo.css" supports(supports-condition); +@import "http://example.com/foo.css" list-of-media-queries; + +/* foo.css */ +body { + color: red; +} + +/* foo.css */ +@layer { + body { + color: red; + } +} + +/* foo.css */ +@layer layer-name { + body { + color: red; + } +} + +/* foo.css */ +@supports (supports-condition) { + @layer layer-name { + body { + color: red; + } + } +} + +/* foo.css */ +@media list-of-media-queries { + @supports (supports-condition) { + @layer layer-name { + body { + color: red; + } + } + } +} + +/* foo.css */ +@media list-of-media-queries { + @layer layer-name { + body { + color: red; + } + } +} + +/* foo.css */ +@supports (supports-condition) { + body { + color: red; + } +} + +/* foo.css */ +@media list-of-media-queries { + @supports (supports-condition) { + body { + color: red; + } + } +} + +/* foo.css */ +@media list-of-media-queries { + body { + color: red; + } +} + +/* empty-1.css */ +@layer empty-1; + +/* empty-2.css */ + +/* empty-3.css */ + +/* foo.css */ +@layer outer { + @layer inner { + body { + color: red; + } + } +} + +/* nested-layer.css */ +@layer outer; + +/* foo.css */ +@supports (outer: true) { + @layer inner { + body { + color: red; + } + } +} + +/* nested-layer.css */ + +/* foo.css */ +@media (outer: true) { + @layer inner { + body { + color: red; + } + } +} + +/* nested-layer.css */ + +/* foo.css */ +@layer outer { + @supports (inner: true) { + body { + color: red; + } + } +} + +/* nested-supports.css */ +@layer outer; + +/* foo.css */ +@supports (outer: true) { + @supports (inner: true) { + body { + color: red; + } + } +} + +/* nested-supports.css */ + +/* foo.css */ +@media (outer: true) { + @supports (inner: true) { + body { + color: red; + } + } +} + +/* nested-supports.css */ + +/* foo.css */ +@layer outer { + @media (inner: true) { + body { + color: red; + } + } +} + +/* nested-media.css */ +@layer outer; + +/* foo.css */ +@supports (outer: true) { + @media (inner: true) { + body { + color: red; + } + } +} + +/* nested-media.css */ + +/* foo.css */ +@media (outer: true) { + @media (inner: true) { + body { + color: red; + } + } +} + +/* nested-media.css */ + +/* entry.css */ + ================================================================================ TestCSSAtImportConditionsBundleExternal ---------- /out.css ---------- @@ -105,6 +299,47 @@ TestCSSAtImportConditionsNoBundle ---------- /out.css ---------- @import "./print.css" print; +================================================================================ +TestCSSAtImportConditionsWithImportRecordsBundle +---------- /out.css ---------- +/* foo.css */ +@supports (background: url(a.png)) { + body { + color: red; + } +} + +/* foo.css */ +@media list-of-media-queries { + @supports (background: url(b.png)) { + body { + color: red; + } + } +} + +/* foo.css */ +@supports (background: url(a.png)) { + @layer layer-name { + body { + color: red; + } + } +} + +/* foo.css */ +@media list-of-media-queries { + @supports (background: url(b.png)) { + @layer layer-name { + body { + color: red; + } + } + } +} + +/* entry.css */ + ================================================================================ TestCSSAtImportExtensionOrderCollision ---------- /out.css ---------- diff --git a/internal/css_ast/css_ast.go b/internal/css_ast/css_ast.go index 50bd0a09998..b606142ee99 100644 --- a/internal/css_ast/css_ast.go +++ b/internal/css_ast/css_ast.go @@ -362,7 +362,15 @@ func CloneTokensWithImportRecords( tokensIn []Token, importRecordsIn []ast.ImportRecord, tokensOut []Token, importRecordsOut []ast.ImportRecord, ) ([]Token, []ast.ImportRecord) { + // Preallocate the output array if we can + if tokensOut == nil { + tokensOut = make([]Token, 0, len(tokensIn)) + } + for _, t := range tokensIn { + // Clear the source mapping if this token is being used in another file + t.Loc.Start = 0 + // If this is a URL token, also clone the import record if t.Kind == css_lexer.TURL { importRecordIndex := uint32(len(importRecordsOut)) @@ -433,17 +441,36 @@ func (r *RAtCharset) Hash() (uint32, bool) { } type ImportConditions struct { + // The syntax for "@import" has been extended with optional conditions that + // behave as if the imported file was wrapped in a "@layer", "@supports", + // and/or "@media" rule. The possible syntax combinations are as follows: + // + // @import url(...); + // @import url(...) layer; + // @import url(...) layer(layer-name); + // @import url(...) layer(layer-name) supports(supports-condition); + // @import url(...) layer(layer-name) supports(supports-condition) list-of-media-queries; + // @import url(...) layer(layer-name) list-of-media-queries; + // @import url(...) supports(supports-condition); + // @import url(...) supports(supports-condition) list-of-media-queries; + // @import url(...) list-of-media-queries; + // + // From: https://developer.mozilla.org/en-US/docs/Web/CSS/@import#syntax + Media []Token + + // These two fields will only ever have zero or one tokens. However, they are + // implemented as arrays for convenience because most of esbuild's helper + // functions that operate on tokens take arrays instead of individual tokens. Layers []Token Supports []Token - Media []Token } -func (conditions *ImportConditions) CloneWithImportRecords(importRecordsIn []ast.ImportRecord, importRecordsOut []ast.ImportRecord) (*ImportConditions, []ast.ImportRecord) { +func (c *ImportConditions) CloneWithImportRecords(importRecordsIn []ast.ImportRecord, importRecordsOut []ast.ImportRecord) (ImportConditions, []ast.ImportRecord) { result := ImportConditions{} - result.Layers, importRecordsOut = CloneTokensWithImportRecords(conditions.Layers, importRecordsIn, nil, importRecordsOut) - result.Supports, importRecordsOut = CloneTokensWithImportRecords(conditions.Supports, importRecordsIn, nil, importRecordsOut) - result.Media, importRecordsOut = CloneTokensWithImportRecords(conditions.Media, importRecordsIn, nil, importRecordsOut) - return &result, importRecordsOut + result.Layers, importRecordsOut = CloneTokensWithImportRecords(c.Layers, importRecordsIn, nil, importRecordsOut) + result.Supports, importRecordsOut = CloneTokensWithImportRecords(c.Supports, importRecordsIn, nil, importRecordsOut) + result.Media, importRecordsOut = CloneTokensWithImportRecords(c.Media, importRecordsIn, nil, importRecordsOut) + return result, importRecordsOut } type RAtImport struct { diff --git a/internal/css_parser/css_parser.go b/internal/css_parser/css_parser.go index dcf6ef31689..8e0581ab7f0 100644 --- a/internal/css_parser/css_parser.go +++ b/internal/css_parser/css_parser.go @@ -1154,12 +1154,10 @@ abortRuleParser: break // Avoid parsing an invalid "@import" rule } conditions.Media = p.convertTokens(p.tokens[importConditionsStart:p.index]) - kind := ast.ImportAt // Insert or remove whitespace before the first token var importConditions *css_ast.ImportConditions if len(conditions.Media) > 0 { - kind = ast.ImportAtConditional importConditions = &conditions // Handle "layer()" @@ -1192,7 +1190,7 @@ abortRuleParser: p.expect(css_lexer.TSemicolon) importRecordIndex := uint32(len(p.importRecords)) p.importRecords = append(p.importRecords, ast.ImportRecord{ - Kind: kind, + Kind: ast.ImportAt, Path: logger.Path{Text: path}, Range: r, }) diff --git a/internal/linker/linker.go b/internal/linker/linker.go index e5f9ef8e71d..01701edd9e5 100644 --- a/internal/linker/linker.go +++ b/internal/linker/linker.go @@ -189,12 +189,12 @@ type chunkReprJS struct { type chunkReprCSS struct { externalImportsInOrder []externalImportCSS - filesInChunkInOrder []uint32 + filesInChunkInOrder []cssImportOrder } type externalImportCSS struct { path logger.Path - conditions *css_ast.ImportConditions + conditions css_ast.ImportConditions conditionImportRecords []ast.ImportRecord } @@ -668,8 +668,8 @@ func (c *linkerContext) generateChunksInParallel(additionalFiles []graph.OutputF commentPrefix = "//" case *chunkReprCSS: - for _, sourceIndex := range chunkRepr.filesInChunkInOrder { - outputFiles = append(outputFiles, c.graph.Files[sourceIndex].InputFile.AdditionalFiles...) + for _, entry := range chunkRepr.filesInChunkInOrder { + outputFiles = append(outputFiles, c.graph.Files[entry.sourceIndex].InputFile.AdditionalFiles...) } commentPrefix = "/*" commentSuffix = " */" @@ -3321,6 +3321,12 @@ func (c *linkerContext) findImportedCSSFilesInJSOrder(entryPoint uint32) (order return } +type cssImportOrder struct { + sourceIndex uint32 + conditions []css_ast.ImportConditions + conditionImportRecords []ast.ImportRecord +} + // CSS files are traversed in depth-first reversed reverse preorder. This is // because unlike JavaScript import statements, CSS "@import" rules are // evaluated every time instead of just the first time. However, evaluating a @@ -3335,24 +3341,32 @@ func (c *linkerContext) findImportedCSSFilesInJSOrder(entryPoint uint32) (order // // If A imports B and then C, B imports D, and C imports D, then the CSS // traversal order is B D C A. -func (c *linkerContext) findImportedFilesInCSSOrder(entryPoints []uint32) (externalOrder []externalImportCSS, internalOrder []uint32) { +func (c *linkerContext) findImportedFilesInCSSOrder(entryPoints []uint32) (externalOrder []externalImportCSS, internalOrder []cssImportOrder) { type externalImportsCSS struct { conditions []css_ast.ImportConditions } - visited := make(map[uint32]bool) externals := make(map[logger.Path]externalImportsCSS) - var visit func(uint32) + var visit func(uint32, map[uint32]bool, []css_ast.ImportConditions, []ast.ImportRecord) // Include this file and all files it imports - visit = func(sourceIndex uint32) { + visit = func( + sourceIndex uint32, + visited map[uint32]bool, + wrappingConditions []css_ast.ImportConditions, + wrappingImportRecords []ast.ImportRecord, + ) { if !visited[sourceIndex] { visited[sourceIndex] = true repr := c.graph.Files[sourceIndex].InputFile.Repr.(*graph.CSSRepr) topLevelRules := repr.AST.Rules // Iterate in reverse preorder (will be reversed again later) - internalOrder = append(internalOrder, sourceIndex) + internalOrder = append(internalOrder, cssImportOrder{ + sourceIndex: sourceIndex, + conditions: wrappingConditions, + conditionImportRecords: wrappingImportRecords, + }) // Iterate in the inverse order of "composes" directives. Note that the // order doesn't matter for these because the output order is explicitly @@ -3360,7 +3374,7 @@ func (c *linkerContext) findImportedFilesInCSSOrder(entryPoints []uint32) (exter records := repr.AST.ImportRecords for i := len(records) - 1; i >= 0; i-- { if record := &records[i]; record.Kind == ast.ImportComposesFrom && record.SourceIndex.IsValid() { - visit(record.SourceIndex.GetIndex()) + visit(record.SourceIndex.GetIndex(), visited, wrappingConditions, wrappingImportRecords) } } @@ -3368,33 +3382,78 @@ func (c *linkerContext) findImportedFilesInCSSOrder(entryPoints []uint32) (exter outer: for i := len(topLevelRules) - 1; i >= 0; i-- { if atImport, ok := topLevelRules[i].Data.(*css_ast.RAtImport); ok { - if record := &repr.AST.ImportRecords[atImport.ImportRecordIndex]; record.SourceIndex.IsValid() { - // Follow internal dependencies - visit(record.SourceIndex.GetIndex()) - } else if (record.Flags & ast.WasLoadedWithEmptyLoader) == 0 { - // Record external dependencies + record := &repr.AST.ImportRecords[atImport.ImportRecordIndex] + + // Follow internal dependencies + if record.SourceIndex.IsValid() { + nestedVisited := visited + nestedConditions := wrappingConditions + nestedImportRecords := wrappingImportRecords + + // If this import has conditions, fork our state so that the entire + // imported stylesheet subtree is wrapped in all of the conditions + if atImport.ImportConditions != nil { + // Fork our state + nestedVisited = make(map[uint32]bool) + for sourceIndex := range visited { + nestedVisited[sourceIndex] = true + } + nestedConditions = append([]css_ast.ImportConditions{}, nestedConditions...) + nestedImportRecords = append([]ast.ImportRecord{}, nestedImportRecords...) + + // Clone these import conditions and append them to the state + var conditions css_ast.ImportConditions + conditions, nestedImportRecords = atImport.ImportConditions.CloneWithImportRecords(repr.AST.ImportRecords, nestedImportRecords) + nestedConditions = append(nestedConditions, conditions) + } + + visit(record.SourceIndex.GetIndex(), nestedVisited, nestedConditions, nestedImportRecords) + continue + } + + // Record external dependencies + if (record.Flags & ast.WasLoadedWithEmptyLoader) == 0 { external := externals[record.Path] + // This is stored as a pointer to save space. But it's easier to + // compare against if we don't have to test for nil. So convert + // it from a pointer to a value. var before css_ast.ImportConditions - if conditions := atImport.ImportConditions; conditions != nil { - before = *conditions + if atImport.ImportConditions != nil { + before = *atImport.ImportConditions } - // Skip this rule if a later rule masks it - for _, after := range external.conditions { - if css_ast.TokensEqualIgnoringWhitespace(before.Layers, after.Layers) { - if len(after.Supports) == 0 && len(after.Media) == 0 { - // If the later one doesn't have any conditions, only keep - // the later one. The later one will mask the effects of the - // earlier one regardless of whether the earlier one has any - // conditions or not. Only do this if the layers are equal. - continue outer + // Skip this rule if a later rule masks it. Note that we avoid + // skipping rules with layers because this code tries to keep + // the last instance of each import (since usually that's the + // only one that matters in CSS) but layers take effect for the + // first instance instead of the last. + if len(before.Layers) == 0 { + for _, after := range external.conditions { + if len(after.Layers) > 0 { + continue } + sameSupports := css_ast.TokensEqualIgnoringWhitespace(before.Supports, after.Supports) + sameMedia := css_ast.TokensEqualIgnoringWhitespace(before.Media, after.Media) + // If the import conditions are exactly equal, then only keep // the later one. The earlier one will have no effect. - if css_ast.TokensEqualIgnoringWhitespace(before.Supports, after.Supports) && - css_ast.TokensEqualIgnoringWhitespace(before.Media, after.Media) { + if sameSupports && sameMedia { + continue outer + } + + // If the media conditions are exactly equal and the later one + // doesn't have any supports conditions, then the later one will + // apply in all cases where the earlier one applies. + if sameMedia && len(after.Supports) == 0 { + continue outer + } + + // If the supports conditions are exactly equal and the later one + // doesn't have any media conditions, then the later one will + // apply in all cases where the earlier one applies. + if sameSupports && len(after.Media) == 0 { continue outer } } @@ -3403,16 +3462,16 @@ func (c *linkerContext) findImportedFilesInCSSOrder(entryPoints []uint32) (exter external.conditions = append(external.conditions, before) externals[record.Path] = external - var conditions *css_ast.ImportConditions - var conditionImportRecords []ast.ImportRecord + var conditions css_ast.ImportConditions + var importRecords []ast.ImportRecord if atImport.ImportConditions != nil { - conditions, conditionImportRecords = atImport.ImportConditions.CloneWithImportRecords(repr.AST.ImportRecords, conditionImportRecords) + conditions, importRecords = atImport.ImportConditions.CloneWithImportRecords(repr.AST.ImportRecords, importRecords) } externalOrder = append(externalOrder, externalImportCSS{ path: record.Path, conditions: conditions, - conditionImportRecords: conditionImportRecords, + conditionImportRecords: importRecords, }) } } @@ -3421,8 +3480,9 @@ func (c *linkerContext) findImportedFilesInCSSOrder(entryPoints []uint32) (exter } // Include all files reachable from any entry point + visited := make(map[uint32]bool) for i := len(entryPoints) - 1; i >= 0; i-- { - visit(entryPoints[i]) + visit(entryPoints[i], visited, nil, nil) } // Reverse the order afterward when traversing in CSS order @@ -3476,8 +3536,8 @@ func (c *linkerContext) computeChunks() { if cssSourceIndices := c.findImportedCSSFilesInJSOrder(entryPoint.SourceIndex); len(cssSourceIndices) > 0 { externalOrder, internalOrder := c.findImportedFilesInCSSOrder(cssSourceIndices) cssFilesWithPartsInChunk := make(map[uint32]bool) - for _, sourceIndex := range internalOrder { - cssFilesWithPartsInChunk[uint32(sourceIndex)] = true + for _, entry := range internalOrder { + cssFilesWithPartsInChunk[uint32(entry.sourceIndex)] = true } cssChunks[key] = chunkInfo{ entryBits: entryBits, @@ -3495,8 +3555,8 @@ func (c *linkerContext) computeChunks() { case *graph.CSSRepr: externalOrder, internalOrder := c.findImportedFilesInCSSOrder([]uint32{entryPoint.SourceIndex}) - for _, sourceIndex := range internalOrder { - chunk.filesWithPartsInChunk[uint32(sourceIndex)] = true + for _, entry := range internalOrder { + chunk.filesWithPartsInChunk[uint32(entry.sourceIndex)] = true } chunk.chunkRepr = &chunkReprCSS{ externalImportsInOrder: externalOrder, @@ -5621,8 +5681,8 @@ func (c *linkerContext) generateChunkCSS(chunkIndex int, chunkWaitGroup *sync.Wa remover = css_parser.MakeDuplicateRuleMangler(c.graph.Symbols) } for i := len(chunkRepr.filesInChunkInOrder) - 1; i >= 0; i-- { - sourceIndex := chunkRepr.filesInChunkInOrder[i] - file := &c.graph.Files[sourceIndex] + entry := chunkRepr.filesInChunkInOrder[i] + file := &c.graph.Files[entry.sourceIndex] ast := file.InputFile.Repr.(*graph.CSSRepr).AST // Filter out "@charset" and "@import" rules @@ -5638,9 +5698,60 @@ func (c *linkerContext) generateChunkCSS(chunkIndex int, chunkWaitGroup *sync.Wa rules = append(rules, rule) } + for i := len(entry.conditions) - 1; i >= 0; i-- { + conditions := entry.conditions[i] + + // Generate "@layer" wrappers. Note that empty "@layer" rules still have + // a side effect (they set the layer order) so they cannot be removed. + for _, t := range conditions.Layers { + var prelude []css_ast.Token + if t.Children != nil { + prelude = *t.Children + } + if len(rules) == 0 { + // Generate "@layer foo;" instead of "@layer foo {}" + rules = nil + } + prelude, ast.ImportRecords = css_ast.CloneTokensWithImportRecords(prelude, entry.conditionImportRecords, nil, ast.ImportRecords) + rules = []css_ast.Rule{{Data: &css_ast.RKnownAt{ + AtToken: "layer", + Prelude: prelude, + Rules: rules, + }}} + } + + // Generate "@supports" wrappers. This is not done if the rule block is + // empty because empty "@supports" rules have no effect. + if len(rules) > 0 { + for _, t := range conditions.Supports { + t.Kind = css_lexer.TOpenParen + t.Text = "(" + var prelude []css_ast.Token + prelude, ast.ImportRecords = css_ast.CloneTokensWithImportRecords([]css_ast.Token{t}, entry.conditionImportRecords, nil, ast.ImportRecords) + rules = []css_ast.Rule{{Data: &css_ast.RKnownAt{ + AtToken: "supports", + Prelude: prelude, + Rules: rules, + }}} + } + } + + // Generate "@media" wrappers. This is not done if the rule block is + // empty because empty "@media" rules have no effect. + if len(rules) > 0 && len(conditions.Media) > 0 { + var prelude []css_ast.Token + prelude, ast.ImportRecords = css_ast.CloneTokensWithImportRecords(conditions.Media, entry.conditionImportRecords, nil, ast.ImportRecords) + rules = []css_ast.Rule{{Data: &css_ast.RKnownAt{ + AtToken: "media", + Prelude: prelude, + Rules: rules, + }}} + } + } + // Remove top-level duplicate rules across files if c.options.MinifySyntax { - rules = remover.RemoveDuplicateRulesInPlace(sourceIndex, rules, ast.ImportRecords) + rules = remover.RemoveDuplicateRulesInPlace(entry.sourceIndex, rules, ast.ImportRecords) } ast.Rules = rules @@ -5651,7 +5762,7 @@ func (c *linkerContext) generateChunkCSS(chunkIndex int, chunkWaitGroup *sync.Wa // Generate CSS for each file in parallel timer.Begin("Print CSS files") waitGroup := sync.WaitGroup{} - for i, sourceIndex := range chunkRepr.filesInChunkInOrder { + for i, entry := range chunkRepr.filesInChunkInOrder { // Create a goroutine for this file waitGroup.Add(1) go func(i int, sourceIndex uint32, compileResult *compileResultCSS) { @@ -5686,7 +5797,7 @@ func (c *linkerContext) generateChunkCSS(chunkIndex int, chunkWaitGroup *sync.Wa compileResult.PrintResult = css_printer.Print(asts[i], c.graph.Symbols, cssOptions) compileResult.sourceIndex = sourceIndex waitGroup.Done() - }(i, sourceIndex, &compileResults[i]) + }(i, entry.sourceIndex, &compileResults[i]) } waitGroup.Wait() @@ -5720,8 +5831,10 @@ 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 external.conditions != nil { - conditions, tree.ImportRecords = external.conditions.CloneWithImportRecords(external.conditionImportRecords, tree.ImportRecords) + if len(external.conditions.Layers) > 0 || len(external.conditions.Supports) > 0 || len(external.conditions.Media) > 0 { + var clone css_ast.ImportConditions + clone, tree.ImportRecords = external.conditions.CloneWithImportRecords(external.conditionImportRecords, tree.ImportRecords) + conditions = &clone } tree.Rules = append(tree.Rules, css_ast.Rule{Data: &css_ast.RAtImport{ ImportRecordIndex: uint32(len(tree.ImportRecords)), diff --git a/pkg/api/api_impl.go b/pkg/api/api_impl.go index 53504eaa418..8dcb2e51af8 100644 --- a/pkg/api/api_impl.go +++ b/pkg/api/api_impl.go @@ -1887,7 +1887,7 @@ func importKindToResolveKind(kind ast.ImportKind) ResolveKind { return ResolveJSDynamicImport case ast.ImportRequireResolve: return ResolveJSRequireResolve - case ast.ImportAt, ast.ImportAtConditional: + case ast.ImportAt: return ResolveCSSImportRule case ast.ImportComposesFrom: return ResolveCSSComposesFrom