From fd1ddfa9408f6bf4b7067c1cb9e5d90911d7e310 Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Tue, 18 Jul 2023 00:12:40 -0400 Subject: [PATCH] css: implement bare `:global` and `:local` --- internal/bundler_tests/bundler_css_test.go | 45 ++++++ .../bundler_tests/snapshots/snapshots_css.txt | 97 ++++++++++++ internal/css_ast/css_ast.go | 17 +++ internal/css_parser/css_parser.go | 7 + internal/css_parser/css_parser_selector.go | 140 ++++++++++++------ 5 files changed, 262 insertions(+), 44 deletions(-) diff --git a/internal/bundler_tests/bundler_css_test.go b/internal/bundler_tests/bundler_css_test.go index a17fdf15a49..0a9eff37bd1 100644 --- a/internal/bundler_tests/bundler_css_test.go +++ b/internal/bundler_tests/bundler_css_test.go @@ -344,6 +344,10 @@ func TestImportCSSFromJSLocalVsGlobal(t *testing.T) { div:global(+ .GLOBAL_A):hover { color: #019 } div:local(+ .local_a):hover { color: #01A } + + :global.GLOBAL:local.local { color: #01B } + :global .GLOBAL :local .local { color: #01C } + :global { .GLOBAL { :local { .local { color: #01D } } } } ` css_suite.expectBundled(t, bundled{ @@ -375,6 +379,47 @@ func TestImportCSSFromJSLocalVsGlobal(t *testing.T) { }) } +func TestImportCSSFromJSLowerBareLocalAndGlobal(t *testing.T) { + css := ` + .before { color: #000 } + :local { .button { color: #000 } } + .after { color: #000 } + + .before { color: #001 } + :global { .button { color: #001 } } + .after { color: #001 } + + div { :local { .button { color: #002 } } } + div { :global { .button { color: #003 } } } + + :local(:global) { color: #004 } + :global(:local) { color: #005 } + + :local(:global) { .button { color: #006 } } + :global(:local) { .button { color: #007 } } + ` + + css_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/entry.js": ` + import styles from "./styles.css" + console.log(styles) + `, + "/styles.css": css, + }, + entryPaths: []string{"/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputDir: "/out", + ExtensionToLoader: map[string]config.Loader{ + ".js": config.LoaderJS, + ".css": config.LoaderLocalCSS, + }, + UnsupportedCSSFeatures: compat.Nesting, + }, + }) +} + func TestImportCSSFromJSWriteToStdout(t *testing.T) { css_suite.expectBundled(t, bundled{ files: map[string]string{ diff --git a/internal/bundler_tests/snapshots/snapshots_css.txt b/internal/bundler_tests/snapshots/snapshots_css.txt index 87b672d437b..997f8d42113 100644 --- a/internal/bundler_tests/snapshots/snapshots_css.txt +++ b/internal/bundler_tests/snapshots/snapshots_css.txt @@ -735,6 +735,21 @@ div:global(+ .GLOBAL_A):hover { div:local(+ .local_a):hover { color: #01A; } +:global.GLOBAL:local.local { + color: #01B; +} +:global .GLOBAL :local .local { + color: #01C; +} +:global { + .GLOBAL { + :local { + .local { + color: #01D; + } + } + } +} /* LOCAL.global-css */ .top_level { @@ -820,6 +835,21 @@ div + .GLOBAL_A:hover { div + .LOCAL_local_a:hover { color: #01A; } +.GLOBAL.LOCAL_local { + color: #01B; +} +.GLOBAL .LOCAL_local { + color: #01C; +} +& { + .GLOBAL { + & { + .LOCAL_local { + color: #01D; + } + } + } +} /* LOCAL.local-css */ .LOCAL_top_level { @@ -905,6 +935,73 @@ div + .GLOBAL_A:hover { div + .LOCAL_local_a2:hover { color: #01A; } +.GLOBAL.LOCAL_local2 { + color: #01B; +} +.GLOBAL .LOCAL_local2 { + color: #01C; +} +& { + .GLOBAL { + & { + .LOCAL_local2 { + color: #01D; + } + } + } +} + +================================================================================ +TestImportCSSFromJSLowerBareLocalAndGlobal +---------- /out/entry.js ---------- +// styles.css +var styles_default = { + before: "styles_before", + button: "styles_button", + after: "styles_after" +}; + +// entry.js +console.log(styles_default); + +---------- /out/entry.css ---------- +/* styles.css */ +.styles_before { + color: #000; +} +:scope .styles_button { + color: #000; +} +.styles_after { + color: #000; +} +.styles_before { + color: #001; +} +:scope .button { + color: #001; +} +.styles_after { + color: #001; +} +div .styles_button { + color: #002; +} +div .button { + color: #003; +} +:scope { + color: #004; +} +:scope { + color: #005; +} +:scope .styles_button { + color: #006; +} +:scope .styles_button { + color: #007; +} ================================================================================ TestImportGlobalCSSFromJS diff --git a/internal/css_ast/css_ast.go b/internal/css_ast/css_ast.go index bea3372928a..2aaae2d14fd 100644 --- a/internal/css_ast/css_ast.go +++ b/internal/css_ast/css_ast.go @@ -745,6 +745,23 @@ func (sel CompoundSelector) IsSingleAmpersand() bool { return sel.HasNestingSelector() && sel.Combinator.Byte == 0 && sel.TypeSelector == nil && len(sel.SubclassSelectors) == 0 } +func (sel CompoundSelector) IsInvalidBecauseEmpty() bool { + return !sel.HasNestingSelector() && sel.TypeSelector == nil && len(sel.SubclassSelectors) == 0 +} + +func (sel CompoundSelector) FirstLoc() logger.Loc { + var firstLoc ast.Index32 + if sel.TypeSelector != nil { + firstLoc = ast.MakeIndex32(uint32(sel.TypeSelector.FirstLoc().Start)) + } else if len(sel.SubclassSelectors) > 0 { + firstLoc = ast.MakeIndex32(uint32(sel.SubclassSelectors[0].Loc.Start)) + } + if firstLoc.IsValid() && (!sel.NestingSelectorLoc.IsValid() || firstLoc.GetIndex() < sel.NestingSelectorLoc.GetIndex()) { + return logger.Loc{Start: int32(firstLoc.GetIndex())} + } + return logger.Loc{Start: int32(sel.NestingSelectorLoc.GetIndex())} +} + func (sel CompoundSelector) Clone() CompoundSelector { clone := sel diff --git a/internal/css_parser/css_parser.go b/internal/css_parser/css_parser.go index e9e688a50b0..a8230f22f2b 100644 --- a/internal/css_parser/css_parser.go +++ b/internal/css_parser/css_parser.go @@ -1835,6 +1835,11 @@ func mangleNumber(t string) (string, bool) { } func (p *parser) parseSelectorRuleFrom(preludeStart int, isTopLevel bool, opts parseSelectorOpts) css_ast.Rule { + // Save and restore the local symbol state in case there are any bare + // ":global" or ":local" annotations. The effect of these should be scoped + // to within the selector rule. + local := p.makeLocalSymbols + // Try parsing the prelude as a selector list if list, ok := p.parseSelectorList(opts); ok { canInlineNoOpNesting := true @@ -1863,9 +1868,11 @@ func (p *parser) parseSelectorRuleFrom(preludeStart int, isTopLevel bool, opts p if p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) { selector.CloseBraceLoc = closeBraceLoc } + p.makeLocalSymbols = local return css_ast.Rule{Loc: p.tokens[preludeStart].Range.Loc, Data: &selector} } } + p.makeLocalSymbols = local // Otherwise, parse a generic qualified rule return p.parseQualifiedRuleFrom(preludeStart, parseQualifiedRuleOpts{ diff --git a/internal/css_parser/css_parser_selector.go b/internal/css_parser/css_parser_selector.go index b8aa6b4ce53..0807f65a201 100644 --- a/internal/css_parser/css_parser_selector.go +++ b/internal/css_parser/css_parser_selector.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/evanw/esbuild/internal/ast" + "github.com/evanw/esbuild/internal/compat" "github.com/evanw/esbuild/internal/css_ast" "github.com/evanw/esbuild/internal/css_lexer" "github.com/evanw/esbuild/internal/logger" @@ -124,58 +125,98 @@ func mergeCompoundSelectors(target *css_ast.CompoundSelector, source css_ast.Com target.SubclassSelectors = append(target.SubclassSelectors, source.SubclassSelectors...) } +func containsLocalOrGlobalSelector(sel css_ast.ComplexSelector) bool { + for _, s := range sel.Selectors { + for _, ss := range s.SubclassSelectors { + switch pseudo := ss.Data.(type) { + case *css_ast.SSPseudoClass: + if pseudo.Name == "global" || pseudo.Name == "local" { + return true + } + + case *css_ast.SSPseudoClassWithSelectorList: + if pseudo.Kind == css_ast.PseudoClassGlobal || pseudo.Kind == css_ast.PseudoClassLocal { + return true + } + } + } + } + return false +} + // This handles the ":local()" and ":global()" annotations from CSS modules func (p *parser) flattenLocalAndGlobalSelectors(list []css_ast.ComplexSelector, sel css_ast.ComplexSelector) []css_ast.ComplexSelector { - if p.options.symbolMode == symbolModeDisabled { - return append(list, sel) - } + // Only do the work to flatten the whole list if there's a ":local" or a ":global" + if p.options.symbolMode != symbolModeDisabled && containsLocalOrGlobalSelector(sel) { + var selectors []css_ast.CompoundSelector + + for _, s := range sel.Selectors { + oldSubclassSelectors := s.SubclassSelectors + s.SubclassSelectors = make([]css_ast.SubclassSelector, 0, len(oldSubclassSelectors)) + + for _, ss := range oldSubclassSelectors { + switch pseudo := ss.Data.(type) { + case *css_ast.SSPseudoClass: + if pseudo.Name == "global" || pseudo.Name == "local" { + // Remove bare ":global" and ":local" pseudo-classes + continue + } - // Rewrite all ":local" and ":global" annotations within this compound selector - for _, s := range sel.Selectors { - for _, ss := range s.SubclassSelectors { - if pseudo, ok := ss.Data.(*css_ast.SSPseudoClassWithSelectorList); ok && (pseudo.Kind == css_ast.PseudoClassGlobal || pseudo.Kind == css_ast.PseudoClassLocal) { - // Only do the work to flatten the whole list if there's a ":local" or a ":global" - var selectors []css_ast.CompoundSelector - for _, s := range sel.Selectors { - oldSubclassSelectors := s.SubclassSelectors - s.SubclassSelectors = make([]css_ast.SubclassSelector, 0, len(oldSubclassSelectors)) - - for _, ss := range oldSubclassSelectors { - if pseudo, ok := ss.Data.(*css_ast.SSPseudoClassWithSelectorList); ok && (pseudo.Kind == css_ast.PseudoClassGlobal || pseudo.Kind == css_ast.PseudoClassLocal) { - inner := pseudo.Selectors[0].Selectors - - // Replace this pseudo-class with all inner compound selectors. - // The first inner compound selector is merged with the compound - // selector before it and the last inner compound selector is - // merged with the compound selector after it: - // - // "div:local(.a .b):hover" => "div.a b:hover" - // - // This behavior is really strange since this is not how anything - // involving pseudo-classes in real CSS works at all. However, all - // other implementations (Lightning CSS, PostCSS, and Webpack) are - // consistent with this strange behavior, so we do it too. - if inner[0].Combinator.Byte == 0 { - mergeCompoundSelectors(&s, inner[0]) - inner = inner[1:] - } else { - // "div:local(+ .foo):hover" => "div + .foo:hover" - } - if n := len(inner); n > 0 { - selectors = append(append(selectors, s), inner[:n-1]...) - s = inner[n-1] - } + case *css_ast.SSPseudoClassWithSelectorList: + if pseudo.Kind == css_ast.PseudoClassGlobal || pseudo.Kind == css_ast.PseudoClassLocal { + inner := pseudo.Selectors[0].Selectors + + // Replace this pseudo-class with all inner compound selectors. + // The first inner compound selector is merged with the compound + // selector before it and the last inner compound selector is + // merged with the compound selector after it: + // + // "div:local(.a .b):hover" => "div.a b:hover" + // + // This behavior is really strange since this is not how anything + // involving pseudo-classes in real CSS works at all. However, all + // other implementations (Lightning CSS, PostCSS, and Webpack) are + // consistent with this strange behavior, so we do it too. + if inner[0].Combinator.Byte == 0 { + mergeCompoundSelectors(&s, inner[0]) + inner = inner[1:] } else { - s.SubclassSelectors = append(s.SubclassSelectors, ss) + // "div:local(+ .foo):hover" => "div + .foo:hover" } + if n := len(inner); n > 0 { + if !s.IsInvalidBecauseEmpty() { + // Don't add this selector if it consisted only of a bare ":global" or ":local" + selectors = append(selectors, s) + } + selectors = append(selectors, inner[:n-1]...) + s = inner[n-1] + } + continue } - - selectors = append(selectors, s) } - sel.Selectors = selectors - return append(list, sel) + + s.SubclassSelectors = append(s.SubclassSelectors, ss) + } + + if !s.IsInvalidBecauseEmpty() { + // Don't add this selector if it consisted only of a bare ":global" or ":local" + selectors = append(selectors, s) + } + } + + if len(selectors) == 0 { + // Treat a bare ":global" or ":local" as a bare "&" nesting selector + selectors = append(selectors, css_ast.CompoundSelector{ + NestingSelectorLoc: ast.MakeIndex32(uint32(sel.Selectors[0].FirstLoc().Start)), + }) + + // Make sure we report that nesting is present so that it can be lowered + if p.options.unsupportedCSSFeatures.Has(compat.Nesting) { + p.shouldLowerNesting = true } } + + sel.Selectors = selectors } return append(list, sel) @@ -408,7 +449,7 @@ subclassSelectors: } // The compound selector must be non-empty - if !sel.HasNestingSelector() && sel.TypeSelector == nil && len(sel.SubclassSelectors) == 0 { + if sel.IsInvalidBecauseEmpty() { p.unexpected() return } @@ -615,6 +656,17 @@ func (p *parser) parsePseudoClassSelector(isElement bool) css_ast.SS { sel := css_ast.SSPseudoClass{IsElement: isElement} if p.expect(css_lexer.TIdent) { sel.Name = name + + // ":local .local_name :global .global_name {}" + // ":local { .local_name { :global { .global_name {} } }" + if p.options.symbolMode != symbolModeDisabled { + switch name { + case "local": + p.makeLocalSymbols = true + case "global": + p.makeLocalSymbols = false + } + } } return &sel }