From 16e09883f42f14bdd991f5a6f763ce9f9521c91c Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Mon, 20 Feb 2023 00:44:52 -0500 Subject: [PATCH] update css nesting stuff to match the latest spec --- CHANGELOG.md | 28 ++++ internal/bundler_tests/bundler_css_test.go | 66 +++++++- .../bundler_tests/snapshots/snapshots_css.txt | 144 +++++++++++++++++- internal/css_ast/css_ast.go | 21 +-- internal/css_parser/css_parser.go | 77 +++++----- internal/css_parser/css_parser_selector.go | 73 +++------ internal/css_parser/css_parser_test.go | 96 +++++++----- internal/css_printer/css_printer.go | 25 +-- internal/logger/msg_ids.go | 10 +- 9 files changed, 359 insertions(+), 181 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e906666460..efa90083a6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ ## Unreleased +* Update esbuild's handling of CSS nesting to match the latest specification changes ([#1945](https://github.com/evanw/esbuild/issues/1945)) + + The syntax for the upcoming CSS nesting feature has [recently changed](https://webkit.org/blog/13813/try-css-nesting-today-in-safari-technology-preview/). The `@nest` prefix that was previously required in some cases is now gone, and nested rules no longer have to start with `&` (as long as they don't start with an identifier or function token). + + This release updates esbuild's pass-through handling of CSS nesting syntax to match the latest specification changes. So you can now use esbuild to bundle CSS containing nested rules and try them out in a browser that supports CSS nesting (which includes nightly builds of both Chrome and Safari). + + However, I'm not implementing lowering of nested CSS to non-nested CSS for older browsers yet. While the syntax has been decided, the semantics are still in flux. In particular, there is still some debate about changing the fundamental way that CSS nesting works. For example, you might think that the following CSS is equivalent to a `.outer .inner button { ... }` rule: + + ```css + .inner button { + .outer & { + color: red; + } + } + ``` + + But instead it's actually equivalent to a `.outer :is(.inner button) { ... }` rule which unintuitively also matches the following DOM structure: + + ```html +
+
+ +
+
+ ``` + + The `:is()` behavior is preferred by browser implementers because it's more memory-efficient, but the straightforward translation into a `.outer .inner button { ... }` rule is preferred by developers used to the existing CSS preprocessing ecosystem (e.g. SASS). It seems premature to commit esbuild to specific semantics for this syntax at this time given the ongoing debate. + * Fix cross-file CSS rule deduplication involving `url()` tokens ([#2936](https://github.com/evanw/esbuild/issues/2936)) Previously cross-file CSS rule deduplication didn't handle `url()` tokens correctly. These tokens contain references to import paths which may be internal (i.e. in the bundle) or external (i.e. not in the bundle). When comparing two `url()` tokens for equality, the underlying import paths should be compared instead of their references. This release of esbuild fixes `url()` token comparisons. One side effect is that `@font-face` rules should now be deduplicated correctly across files: diff --git a/internal/bundler_tests/bundler_css_test.go b/internal/bundler_tests/bundler_css_test.go index 47269a0acc7..c5e9c9db4cc 100644 --- a/internal/bundler_tests/bundler_css_test.go +++ b/internal/bundler_tests/bundler_css_test.go @@ -700,18 +700,71 @@ func TestCSSExternalQueryAndHashMatchIssue1822(t *testing.T) { func TestCSSNestingOldBrowser(t *testing.T) { css_suite.expectBundled(t, bundled{ files: map[string]string{ - "/entry.css": ` - a { &:hover { color: red; } } - `, + "/nested-@layer.css": `a { @layer base { color: red; } }`, + "/nested-@media.css": `a { @media screen { color: red; } }`, + "/nested-ampersand.css": `a { &, & { color: red; } }`, + "/nested-attribute.css": `a { [href] { color: red; } }`, + "/nested-colon.css": `a { :hover { color: red; } }`, + "/nested-dot.css": `a { .cls { color: red; } }`, + "/nested-greaterthan.css": `a { > b { color: red; } }`, + "/nested-hash.css": `a { #id { color: red; } }`, + "/nested-plus.css": `a { + b { color: red; } }`, + "/nested-tilde.css": `a { ~ b { color: red; } }`, + + "/toplevel-ampersand.css": `a { &, & { color: red; } }`, + "/toplevel-attribute.css": `a { [href] { color: red; } }`, + "/toplevel-colon.css": `a { :hover { color: red; } }`, + "/toplevel-dot.css": `a { .cls { color: red; } }`, + "/toplevel-greaterthan.css": `a { > b { color: red; } }`, + "/toplevel-hash.css": `a { #id { color: red; } }`, + "/toplevel-plus.css": `a { + b { color: red; } }`, + "/toplevel-tilde.css": `a { ~ b { color: red; } }`, + }, + entryPaths: []string{ + "/nested-@layer.css", + "/nested-@media.css", + "/nested-ampersand.css", + "/nested-attribute.css", + "/nested-colon.css", + "/nested-dot.css", + "/nested-greaterthan.css", + "/nested-hash.css", + "/nested-plus.css", + "/nested-tilde.css", + + "/toplevel-ampersand.css", + "/toplevel-attribute.css", + "/toplevel-colon.css", + "/toplevel-dot.css", + "/toplevel-greaterthan.css", + "/toplevel-hash.css", + "/toplevel-plus.css", + "/toplevel-tilde.css", }, - entryPaths: []string{"/entry.css"}, options: config.Options{ Mode: config.ModeBundle, - AbsOutputFile: "/out.css", + AbsOutputDir: "/out", UnsupportedCSSFeatures: compat.Nesting, OriginalTargetEnv: "chrome10", }, - expectedScanLog: `entry.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10) + expectedScanLog: `nested-@layer.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10) +nested-@media.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10) +nested-ampersand.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10) +nested-attribute.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10) +nested-colon.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10) +nested-dot.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10) +nested-greaterthan.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10) +nested-hash.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10) +nested-plus.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10) +nested-tilde.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10) +toplevel-ampersand.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10) +toplevel-attribute.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10) +toplevel-colon.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10) +toplevel-dot.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10) +toplevel-greaterthan.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10) +toplevel-hash.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10) +toplevel-plus.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10) +toplevel-tilde.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10) `, }) } @@ -802,6 +855,5 @@ func TestDeduplicateRules(t *testing.T) { AbsOutputDir: "/out", MinifySyntax: true, }, - expectedScanLog: "no0.css: WARNING: CSS nesting syntax cannot be used outside of a style rule\n", }) } diff --git a/internal/bundler_tests/snapshots/snapshots_css.txt b/internal/bundler_tests/snapshots/snapshots_css.txt index ff71d3e5c86..2d5f17ec543 100644 --- a/internal/bundler_tests/snapshots/snapshots_css.txt +++ b/internal/bundler_tests/snapshots/snapshots_css.txt @@ -180,10 +180,148 @@ console.log(void 0); ================================================================================ TestCSSNestingOldBrowser ----------- /out.css ---------- -/* entry.css */ +---------- /out/nested-@layer.css ---------- +/* nested-@layer.css */ +a { + @layer base { + color: red; + } +} + +---------- /out/nested-@media.css ---------- +/* nested-@media.css */ +a { + @media screen { + color: red; + } +} + +---------- /out/nested-ampersand.css ---------- +/* nested-ampersand.css */ +a { + &, + & { + color: red; + } +} + +---------- /out/nested-attribute.css ---------- +/* nested-attribute.css */ +a { + [href] { + color: red; + } +} + +---------- /out/nested-colon.css ---------- +/* nested-colon.css */ +a { + :hover { + color: red; + } +} + +---------- /out/nested-dot.css ---------- +/* nested-dot.css */ +a { + .cls { + color: red; + } +} + +---------- /out/nested-greaterthan.css ---------- +/* nested-greaterthan.css */ +a { + > b { + color: red; + } +} + +---------- /out/nested-hash.css ---------- +/* nested-hash.css */ +a { + #id { + color: red; + } +} + +---------- /out/nested-plus.css ---------- +/* nested-plus.css */ +a { + + b { + color: red; + } +} + +---------- /out/nested-tilde.css ---------- +/* nested-tilde.css */ +a { + ~ b { + color: red; + } +} + +---------- /out/toplevel-ampersand.css ---------- +/* toplevel-ampersand.css */ +a { + &, + & { + color: red; + } +} + +---------- /out/toplevel-attribute.css ---------- +/* toplevel-attribute.css */ +a { + [href] { + color: red; + } +} + +---------- /out/toplevel-colon.css ---------- +/* toplevel-colon.css */ +a { + :hover { + color: red; + } +} + +---------- /out/toplevel-dot.css ---------- +/* toplevel-dot.css */ +a { + .cls { + color: red; + } +} + +---------- /out/toplevel-greaterthan.css ---------- +/* toplevel-greaterthan.css */ +a { + > b { + color: red; + } +} + +---------- /out/toplevel-hash.css ---------- +/* toplevel-hash.css */ +a { + #id { + color: red; + } +} + +---------- /out/toplevel-plus.css ---------- +/* toplevel-plus.css */ +a { + + b { + color: red; + } +} + +---------- /out/toplevel-tilde.css ---------- +/* toplevel-tilde.css */ a { - &:hover { + ~ b { color: red; } } diff --git a/internal/css_ast/css_ast.go b/internal/css_ast/css_ast.go index 138bd62b857..ae94ecff2fd 100644 --- a/internal/css_ast/css_ast.go +++ b/internal/css_ast/css_ast.go @@ -437,12 +437,11 @@ func (r *RUnknownAt) Hash() (uint32, bool) { type RSelector struct { Selectors []ComplexSelector Rules []Rule - HasAtNest bool } func (a *RSelector) Equal(rule R, check *CrossFileEqualityCheck) bool { b, ok := rule.(*RSelector) - if ok && len(a.Selectors) == len(b.Selectors) && a.HasAtNest == b.HasAtNest { + if ok && len(a.Selectors) == len(b.Selectors) { for i, ai := range a.Selectors { if !ai.Equal(b.Selectors[i], check) { return false @@ -606,7 +605,7 @@ func (a ComplexSelector) Equal(b ComplexSelector, check *CrossFileEqualityCheck) for i, ai := range a.Selectors { bi := b.Selectors[i] - if ai.NestingSelector != bi.NestingSelector || ai.Combinator != bi.Combinator { + if ai.HasNestingSelector != bi.HasNestingSelector || ai.Combinator != bi.Combinator { return false } @@ -629,19 +628,11 @@ func (a ComplexSelector) Equal(b ComplexSelector, check *CrossFileEqualityCheck) return true } -type NestingSelector uint8 - -const ( - NestingSelectorNone NestingSelector = iota - NestingSelectorPrefix // "&a {}" - NestingSelectorPresentButNotPrefix // "a& {}" -) - type CompoundSelector struct { - Combinator string // Optional, may be "" - TypeSelector *NamespacedName - SubclassSelectors []SS - NestingSelector NestingSelector // "&" + Combinator string // Optional, may be "" + TypeSelector *NamespacedName + SubclassSelectors []SS + HasNestingSelector bool // "&" } type NameToken struct { diff --git a/internal/css_parser/css_parser.go b/internal/css_parser/css_parser.go index 70eacbd0fcd..1fd63d80982 100644 --- a/internal/css_parser/css_parser.go +++ b/internal/css_parser/css_parser.go @@ -310,14 +310,24 @@ func (p *parser) parseListOfDeclarations() (list []css_ast.Rule) { return case css_lexer.TAtKeyword: + p.maybeWarnAboutNesting(p.current().Range) list = append(list, p.parseAtRule(atRuleContext{ isDeclarationList: true, - allowNesting: true, })) - case css_lexer.TDelimAmpersand: - // Reference: https://drafts.csswg.org/css-nesting-1/ - list = append(list, p.parseSelectorRuleFrom(p.index, parseSelectorOpts{allowNesting: true})) + // Reference: https://drafts.csswg.org/css-nesting-1/ + case css_lexer.TDelimAmpersand, + css_lexer.TDelimDot, + css_lexer.THash, + css_lexer.TColon, + css_lexer.TOpenBracket, + css_lexer.TDelimAsterisk, + css_lexer.TDelimBar, + css_lexer.TDelimPlus, + css_lexer.TDelimGreaterThan, + css_lexer.TDelimTilde: + p.maybeWarnAboutNesting(p.current().Range) + list = append(list, p.parseSelectorRuleFrom(p.index, parseSelectorOpts{})) default: list = append(list, p.parseDeclaration()) @@ -596,7 +606,7 @@ var nonDeprecatedElementsSupportedByIE7 = map[string]bool{ func isSafeSelectors(complexSelectors []css_ast.ComplexSelector) bool { for _, complex := range complexSelectors { for _, compound := range complex.Selectors { - if compound.NestingSelector != css_ast.NestingSelectorNone { + if compound.HasNestingSelector { // Bail because this is an extension: https://drafts.csswg.org/css-nesting-1/ return false } @@ -749,9 +759,6 @@ var specialAtRules = map[string]atRuleKind{ "scope": atRuleInheritContext, "supports": atRuleInheritContext, - // Reference: https://drafts.csswg.org/css-nesting-1/ - "nest": atRuleDeclarations, - // Reference: https://drafts.csswg.org/css-fonts-4/#font-palette-values "font-palette-values": atRuleDeclarations, @@ -788,7 +795,6 @@ type atRuleContext struct { charsetValidity atRuleValidity importValidity atRuleValidity isDeclarationList bool - allowNesting bool isTopLevel bool } @@ -1006,18 +1012,6 @@ abortRuleParser: return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RUnknownAt{AtToken: atToken, Prelude: prelude, Block: block}} } - case "nest": - // Reference: https://drafts.csswg.org/css-nesting-1/ - p.eat(css_lexer.TWhitespace) - if kind := p.current().Kind; kind != css_lexer.TSemicolon && kind != css_lexer.TOpenBrace && - kind != css_lexer.TCloseBrace && kind != css_lexer.TEndOfFile { - return p.parseSelectorRuleFrom(preludeStart-1, parseSelectorOpts{ - atNestRange: atRange, - allowNesting: context.allowNesting, - isTopLevel: context.isTopLevel, - }) - } - case "layer": // Reference: https://developer.mozilla.org/en-US/docs/Web/CSS/@layer @@ -1055,9 +1049,14 @@ abortRuleParser: // Read the optional block matchingLoc := p.current().Range.Loc if len(names) <= 1 && p.eat(css_lexer.TOpenBrace) { - rules := p.parseListOfRules(ruleContext{ - parseSelectors: true, - }) + var rules []css_ast.Rule + if context.isDeclarationList { + rules = p.parseListOfDeclarations() + } else { + rules = p.parseListOfRules(ruleContext{ + parseSelectors: true, + }) + } p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RAtLayer{Names: names, Rules: rules}} } @@ -1196,6 +1195,16 @@ func (p *parser) expectValidLayerNameIdent() (string, bool) { return text, true } +func (p *parser) maybeWarnAboutNesting(r logger.Range) { + if p.options.UnsupportedCSSFeatures.Has(compat.Nesting) { + text := "CSS nesting syntax is not supported in the configured target environment" + if p.options.OriginalTargetEnv != "" { + text = fmt.Sprintf("%s (%s)", text, p.options.OriginalTargetEnv) + } + p.log.AddID(logger.MsgID_CSS_UnsupportedCSSNesting, logger.Warning, &p.tracker, r, text) + } +} + func (p *parser) convertTokens(tokens []css_lexer.Token) []css_ast.Token { result, _ := p.convertTokensHelper(tokens, css_lexer.TEndOfFile, convertTokensOpts{}) return result @@ -1546,29 +1555,11 @@ func mangleNumber(t string) (string, bool) { func (p *parser) parseSelectorRuleFrom(preludeStart int, opts parseSelectorOpts) css_ast.Rule { // Try parsing the prelude as a selector list if list, ok := p.parseSelectorList(opts); ok { - selector := css_ast.RSelector{ - Selectors: list, - HasAtNest: opts.atNestRange.Len != 0, - } + selector := css_ast.RSelector{Selectors: list} matchingLoc := p.current().Range.Loc if p.expect(css_lexer.TOpenBrace) { selector.Rules = p.parseListOfDeclarations() p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) - - // Minify "@nest" when possible - if p.options.MinifySyntax && selector.HasAtNest { - allHaveNestPrefix := true - for _, complex := range selector.Selectors { - if len(complex.Selectors) == 0 || complex.Selectors[0].NestingSelector != css_ast.NestingSelectorPrefix { - allHaveNestPrefix = false - break - } - } - if allHaveNestPrefix { - selector.HasAtNest = false - } - } - return css_ast.Rule{Loc: p.tokens[preludeStart].Range.Loc, Data: &selector} } } diff --git a/internal/css_parser/css_parser_selector.go b/internal/css_parser/css_parser_selector.go index 40dfa4b8946..60894e26e0b 100644 --- a/internal/css_parser/css_parser_selector.go +++ b/internal/css_parser/css_parser_selector.go @@ -1,9 +1,6 @@ package css_parser import ( - "fmt" - - "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" @@ -11,8 +8,7 @@ import ( func (p *parser) parseSelectorList(opts parseSelectorOpts) (list []css_ast.ComplexSelector, ok bool) { // Parse the first selector - firstRange := p.current().Range - sel, good, firstHasNestPrefix := p.parseComplexSelector(opts) + sel, good := p.parseComplexSelector(opts) if !good { return } @@ -25,23 +21,11 @@ func (p *parser) parseSelectorList(opts parseSelectorOpts) (list []css_ast.Compl break } p.eat(css_lexer.TWhitespace) - loc := p.current().Range.Loc - sel, good, hasNestPrefix := p.parseComplexSelector(opts) + sel, good := p.parseComplexSelector(opts) if !good { return } list = append(list, sel) - - // Validate nest prefix consistency - if firstHasNestPrefix && !hasNestPrefix && opts.atNestRange.Len == 0 { - data := p.tracker.MsgData(logger.Range{Loc: loc}, "Every selector in a nested style rule must start with \"&\"") - data.Location.Suggestion = "&" - p.log.AddMsgID(logger.MsgID_CSS_InvalidAtNest, logger.Msg{ - Kind: logger.Warning, - Data: data, - Notes: []logger.MsgData{p.tracker.MsgData(firstRange, "This is a nested style rule because of the \"&\" here:")}, - }) - } } ok = true @@ -49,20 +33,22 @@ func (p *parser) parseSelectorList(opts parseSelectorOpts) (list []css_ast.Compl } type parseSelectorOpts struct { - atNestRange logger.Range - allowNesting bool - isTopLevel bool + isTopLevel bool } -func (p *parser) parseComplexSelector(opts parseSelectorOpts) (result css_ast.ComplexSelector, ok bool, hasNestPrefix bool) { +func (p *parser) parseComplexSelector(opts parseSelectorOpts) (result css_ast.ComplexSelector, ok bool) { + // This is an extension: https://drafts.csswg.org/css-nesting-1/ + combinator := p.parseCombinator() + if combinator != "" { + p.eat(css_lexer.TWhitespace) + } + // Parent - loc := p.current().Range.Loc sel, good := p.parseCompoundSelector(opts) if !good { return } - hasNestPrefix = sel.NestingSelector == css_ast.NestingSelectorPrefix - isNestContaining := sel.NestingSelector != css_ast.NestingSelectorNone + sel.Combinator = combinator result.Selectors = append(result.Selectors, sel) for { @@ -84,16 +70,6 @@ func (p *parser) parseComplexSelector(opts parseSelectorOpts) (result css_ast.Co } sel.Combinator = combinator result.Selectors = append(result.Selectors, sel) - if sel.NestingSelector != css_ast.NestingSelectorNone { - isNestContaining = true - } - } - - // Validate nest selector consistency - if opts.atNestRange.Len != 0 && !isNestContaining { - p.log.AddIDWithNotes(logger.MsgID_CSS_InvalidAtNest, logger.Warning, &p.tracker, logger.Range{Loc: loc}, - "Every selector in a nested style rule must contain \"&\"", - []logger.MsgData{p.tracker.MsgData(opts.atNestRange, "This is a nested style rule because of the \"@nest\" here:")}) } ok = true @@ -107,24 +83,10 @@ func (p *parser) nameToken() css_ast.NameToken { } } -func (p *parser) maybeWarnAboutNesting(r logger.Range, opts parseSelectorOpts) { - if !opts.allowNesting { - p.log.AddID(logger.MsgID_CSS_InvalidAtNest, logger.Warning, &p.tracker, r, "CSS nesting syntax cannot be used outside of a style rule") - } else if p.options.UnsupportedCSSFeatures.Has(compat.Nesting) { - text := "CSS nesting syntax is not supported in the configured target environment" - if p.options.OriginalTargetEnv != "" { - text = fmt.Sprintf("%s (%s)", text, p.options.OriginalTargetEnv) - } - p.log.AddID(logger.MsgID_CSS_InvalidAtNest, logger.Warning, &p.tracker, r, text) - } -} - func (p *parser) parseCompoundSelector(opts parseSelectorOpts) (sel css_ast.CompoundSelector, ok bool) { // This is an extension: https://drafts.csswg.org/css-nesting-1/ - r := p.current().Range if p.eat(css_lexer.TDelimAmpersand) { - sel.NestingSelector = css_ast.NestingSelectorPrefix - p.maybeWarnAboutNesting(r, opts) + sel.HasNestingSelector = true } // Parse the type selector @@ -208,12 +170,11 @@ subclassSelectors: case css_lexer.TDelimAmpersand: // This is an extension: https://drafts.csswg.org/css-nesting-1/ - r := p.current().Range - p.advance() - if sel.NestingSelector == css_ast.NestingSelectorNone { - sel.NestingSelector = css_ast.NestingSelectorPresentButNotPrefix - p.maybeWarnAboutNesting(r, opts) + if !sel.HasNestingSelector { + p.maybeWarnAboutNesting(p.current().Range) + sel.HasNestingSelector = true } + p.advance() default: break subclassSelectors @@ -221,7 +182,7 @@ subclassSelectors: } // The compound selector must be non-empty - if sel.NestingSelector == css_ast.NestingSelectorNone && sel.TypeSelector == nil && len(sel.SubclassSelectors) == 0 { + if !sel.HasNestingSelector && sel.TypeSelector == nil && len(sel.SubclassSelectors) == 0 { p.unexpected() return } diff --git a/internal/css_parser/css_parser_test.go b/internal/css_parser/css_parser_test.go index 04d9c602c4b..bb582cfd9d6 100644 --- a/internal/css_parser/css_parser_test.go +++ b/internal/css_parser/css_parser_test.go @@ -589,7 +589,7 @@ func TestDeclaration(t *testing.T) { // See http://browserhacks.com/ expectPrinted(t, ".selector { (;property: value;); }", ".selector {\n (;property: value;);\n}\n") - expectPrinted(t, ".selector { [;property: value;]; }", ".selector {\n [;property: value;];\n}\n") + expectPrinted(t, ".selector { [;property: value;]; }", ".selector {\n [;property: value;]; {\n }\n}\n") // Note: This now overlaps with CSS nesting syntax expectPrinted(t, ".selector, {}", ".selector, {\n}\n") expectPrinted(t, ".selector\\ {}", ".selector\\ {\n}\n") expectPrinted(t, ".selector { property: value\\9; }", ".selector {\n property: value\\\t;\n}\n") @@ -730,42 +730,68 @@ func TestNestedSelector(t *testing.T) { expectPrinted(t, "a { &a|b {} }", "a {\n &a|b {\n }\n}\n") expectPrinted(t, "a { &[b] {} }", "a {\n &[b] {\n }\n}\n") - expectParseError(t, "a { & b, c {} }", - ": WARNING: Every selector in a nested style rule must start with \"&\"\n"+ - ": NOTE: This is a nested style rule because of the \"&\" here:\n") + expectPrinted(t, "a { && {} }", "a {\n & {\n }\n}\n") + expectPrinted(t, "a { & + & {} }", "a {\n & + & {\n }\n}\n") + expectPrinted(t, "a { & > & {} }", "a {\n & > & {\n }\n}\n") + expectPrinted(t, "a { & ~ & {} }", "a {\n & ~ & {\n }\n}\n") + expectPrinted(t, "a { & + c& {} }", "a {\n & + &c {\n }\n}\n") + expectPrinted(t, "a { .b& + & {} }", "a {\n &.b + & {\n }\n}\n") + expectPrinted(t, "a { .b& + c& {} }", "a {\n &.b + &c {\n }\n}\n") + expectPrinted(t, "a { & + & > & ~ & {} }", "a {\n & + & > & ~ & {\n }\n}\n") + + // CSS nesting works for all tokens except identifiers and functions + expectParseError(t, "a { .b {} }", "") + expectParseError(t, "a { #b {} }", "") + expectParseError(t, "a { :b {} }", "") + expectParseError(t, "a { [b] {} }", "") + expectParseError(t, "a { * {} }", "") + expectParseError(t, "a { |b {} }", "") + expectParseError(t, "a { >b {} }", "") + expectParseError(t, "a { +b {} }", "") + expectParseError(t, "a { ~b {} }", "") + expectParseError(t, "a { b {} }", ": WARNING: Expected \":\"\n") + expectParseError(t, "a { b() {} }", ": WARNING: Expected identifier but found \"b(\"\n") + expectPrinted(t, "a { .b {} }", "a {\n .b {\n }\n}\n") + expectPrinted(t, "a { #b {} }", "a {\n #b {\n }\n}\n") + expectPrinted(t, "a { :b {} }", "a {\n :b {\n }\n}\n") + expectPrinted(t, "a { [b] {} }", "a {\n [b] {\n }\n}\n") + expectPrinted(t, "a { * {} }", "a {\n * {\n }\n}\n") + expectPrinted(t, "a { |b {} }", "a {\n |b {\n }\n}\n") + expectPrinted(t, "a { >b {} }", "a {\n > b {\n }\n}\n") + expectPrinted(t, "a { +b {} }", "a {\n + b {\n }\n}\n") + expectPrinted(t, "a { ~b {} }", "a {\n ~ b {\n }\n}\n") + expectPrinted(t, "a { b {} }", "a {\n b {};\n}\n") + expectPrinted(t, "a { b() {} }", "a {\n b() {};\n}\n") + + // Note: CSS nesting no longer requires each complex selector to contain "&" + expectParseError(t, "a { & b, c {} }", "") expectParseError(t, "a { & b, & c {} }", "") - expectParseError(t, "a { b & {} }", ": WARNING: Expected \":\"\n") - expectParseError(t, "a { @nest b & {} }", "") - expectParseError(t, "a { @nest & b, c {} }", - ": WARNING: Every selector in a nested style rule must contain \"&\"\n"+ - ": NOTE: This is a nested style rule because of the \"@nest\" here:\n") - expectParseError(t, "a { @nest b &, c {} }", - ": WARNING: Every selector in a nested style rule must contain \"&\"\n"+ - ": NOTE: This is a nested style rule because of the \"@nest\" here:\n") - expectPrinted(t, "a { @nest b & { color: red } }", "a {\n @nest b & {\n color: red;\n }\n}\n") - expectPrinted(t, "a { @nest b& { color: red } }", "a {\n @nest b& {\n color: red;\n }\n}\n") - expectPrinted(t, "a { @nest b&[c] { color: red } }", "a {\n @nest b[c]& {\n color: red;\n }\n}\n") - expectPrinted(t, "a { @nest &[c] { color: red } }", "a {\n @nest &[c] {\n color: red;\n }\n}\n") - expectPrinted(t, "a { @nest [c]& { color: red } }", "a {\n @nest [c]& {\n color: red;\n }\n}\n") - expectPrintedMinify(t, "a { @nest b & { color: red } }", "a{@nest b &{color:red}}") - expectPrintedMinify(t, "a { @nest b& { color: red } }", "a{@nest b&{color:red}}") - - // Don't drop "@nest" for invalid rules - expectParseError(t, "a { @nest @invalid { color: red } }", ": WARNING: Unexpected \"@invalid\"\n") - expectPrinted(t, "a { @nest @invalid { color: red } }", "a {\n @nest @invalid {\n color: red;\n }\n}\n") - - // Check removal of "@nest" when minifying - expectPrinted(t, "a { @nest & b, & c { color: red } }", "a {\n @nest & b,\n & c {\n color: red;\n }\n}\n") - expectPrintedMangle(t, "a { @nest & b, & c { color: red } }", "a {\n & b,\n & c {\n color: red;\n }\n}\n") - expectPrintedMangle(t, "a { @nest b &, & c { color: red } }", "a {\n @nest b &,\n & c {\n color: red;\n }\n}\n") - expectPrintedMangle(t, "a { @nest & b, c & { color: red } }", "a {\n @nest & b,\n c & {\n color: red;\n }\n}\n") - - outside := ": WARNING: CSS nesting syntax cannot be used outside of a style rule\n" - expectParseError(t, "& a {}", outside) - expectParseError(t, "@nest a & {}", outside) - expectParseError(t, "@media screen { & a {} }", outside) - expectParseError(t, "@media screen { @nest a & {} }", outside) + // Note: CSS nesting no longer requires the rule to be nested inside a parent + // (instead un-nested CSS nesting refers to ":scope" or to ":root") + expectParseError(t, "& b, c {}", "") + expectParseError(t, "& b, & c {}", "") + expectParseError(t, "b & {}", "") + expectParseError(t, "b &, c {}", "") + + expectPrinted(t, "a { .b & { color: red } }", "a {\n .b & {\n color: red;\n }\n}\n") + expectPrinted(t, "a { .b& { color: red } }", "a {\n &.b {\n color: red;\n }\n}\n") + expectPrinted(t, "a { .b&[c] { color: red } }", "a {\n &.b[c] {\n color: red;\n }\n}\n") + expectPrinted(t, "a { &[c] { color: red } }", "a {\n &[c] {\n color: red;\n }\n}\n") + expectPrinted(t, "a { [c]& { color: red } }", "a {\n &[c] {\n color: red;\n }\n}\n") + expectPrintedMinify(t, "a { .b & { color: red } }", "a{.b &{color:red}}") + expectPrintedMinify(t, "a { .b& { color: red } }", "a{&.b{color:red}}") + + // Nested at-rules + expectPrinted(t, "a { @media screen { color: red } }", "a {\n @media screen {\n color: red;\n }\n}\n") + expectPrinted(t, "a { @media screen { .b { color: green } color: red } }", + "a {\n @media screen {\n .b {\n color: green;\n }\n color: red;\n }\n}\n") + expectPrinted(t, "a { @media screen { color: red; .b { color: green } } }", + "a {\n @media screen {\n color: red;\n .b {\n color: green;\n }\n }\n}\n") + expectPrinted(t, "html { @layer base { block-size: 100%; @layer support { & body { min-block-size: 100%; } } } }", + "html {\n @layer base {\n block-size: 100%;\n @layer support {\n & body {\n min-block-size: 100%;\n }\n }\n }\n}\n") + expectPrinted(t, ".card { aspect-ratio: 3/4; @scope (&) { :scope { border: 1px solid white } } }", + ".card {\n aspect-ratio: 3/4;\n @scope (&) {\n :scope {\n border: 1px solid white;\n }\n }\n}\n") } func TestBadQualifiedRules(t *testing.T) { diff --git a/internal/css_printer/css_printer.go b/internal/css_printer/css_printer.go index 8cc8259e863..0075ddab0b5 100644 --- a/internal/css_printer/css_printer.go +++ b/internal/css_printer/css_printer.go @@ -225,10 +225,7 @@ func (p *printer) printRule(rule css_ast.Rule, indent int32, omitTrailingSemicol } case *css_ast.RSelector: - if r.HasAtNest { - p.print("@nest") - } - p.printComplexSelectors(r.Selectors, indent, r.HasAtNest) + p.printComplexSelectors(r.Selectors, indent) if !p.options.MinifyWhitespace { p.print(" ") } @@ -336,7 +333,7 @@ func (p *printer) printRuleBlock(rules []css_ast.Rule, indent int32) { p.print("}") } -func (p *printer) printComplexSelectors(selectors []css_ast.ComplexSelector, indent int32, hasAtNest bool) { +func (p *printer) printComplexSelectors(selectors []css_ast.ComplexSelector, indent int32) { for i, complex := range selectors { if i > 0 { if p.options.MinifyWhitespace { @@ -348,7 +345,7 @@ func (p *printer) printComplexSelectors(selectors []css_ast.ComplexSelector, ind } for j, compound := range complex.Selectors { - p.printCompoundSelector(compound, (!hasAtNest || i != 0) && j == 0, j+1 == len(complex.Selectors)) + p.printCompoundSelector(compound, j == 0, j+1 == len(complex.Selectors)) } } } @@ -361,12 +358,8 @@ func (p *printer) printCompoundSelector(sel css_ast.CompoundSelector, isFirst bo p.print(" ") } - if sel.NestingSelector == css_ast.NestingSelectorPrefix { - p.print("&") - } - if sel.Combinator != "" { - if !p.options.MinifyWhitespace { + if !isFirst && !p.options.MinifyWhitespace { p.print(" ") } p.print(sel.Combinator) @@ -375,6 +368,10 @@ func (p *printer) printCompoundSelector(sel css_ast.CompoundSelector, isFirst bo } } + if sel.HasNestingSelector { + p.print("&") + } + if sel.TypeSelector != nil { whitespace := mayNeedWhitespaceAfter if len(sel.SubclassSelectors) > 0 { @@ -439,12 +436,6 @@ func (p *printer) printCompoundSelector(sel css_ast.CompoundSelector, isFirst bo p.printPseudoClassSelector(*s, whitespace) } } - - // It doesn't matter where the "&" goes since all non-prefix cases are - // treated the same. This just always puts it as a suffix for simplicity. - if sel.NestingSelector == css_ast.NestingSelectorPresentButNotPrefix { - p.print("&") - } } func (p *printer) printNamespacedName(nsName css_ast.NamespacedName, whitespace trailingWhitespace) { diff --git a/internal/logger/msg_ids.go b/internal/logger/msg_ids.go index 94c232eb749..7b2f5b7c251 100644 --- a/internal/logger/msg_ids.go +++ b/internal/logger/msg_ids.go @@ -43,12 +43,12 @@ const ( MsgID_CSS_InvalidAtCharset MsgID_CSS_InvalidAtImport MsgID_CSS_InvalidAtLayer - MsgID_CSS_InvalidAtNest MsgID_CSS_InvalidCalc MsgID_CSS_JSCommentInCSS MsgID_CSS_UnsupportedAtCharset MsgID_CSS_UnsupportedAtNamespace MsgID_CSS_UnsupportedCSSProperty + MsgID_CSS_UnsupportedCSSNesting // Bundler MsgID_Bundler_AmbiguousReexport @@ -147,8 +147,6 @@ func StringToMsgIDs(str string, logLevel LogLevel, overrides map[MsgID]LogLevel) overrides[MsgID_CSS_InvalidAtCharset] = logLevel case "invalid-@import": overrides[MsgID_CSS_InvalidAtImport] = logLevel - case "invalid-@nest": - overrides[MsgID_CSS_InvalidAtNest] = logLevel case "invalid-@layer": overrides[MsgID_CSS_InvalidAtLayer] = logLevel case "invalid-calc": @@ -161,6 +159,8 @@ func StringToMsgIDs(str string, logLevel LogLevel, overrides map[MsgID]LogLevel) overrides[MsgID_CSS_UnsupportedAtNamespace] = logLevel case "unsupported-css-property": overrides[MsgID_CSS_UnsupportedCSSProperty] = logLevel + case "unsupported-css-nesting": + overrides[MsgID_CSS_UnsupportedCSSNesting] = logLevel // Bundler case "ambiguous-reexport": @@ -263,8 +263,6 @@ func MsgIDToString(id MsgID) string { return "invalid-@charset" case MsgID_CSS_InvalidAtImport: return "invalid-@import" - case MsgID_CSS_InvalidAtNest: - return "invalid-@nest" case MsgID_CSS_InvalidAtLayer: return "invalid-@layer" case MsgID_CSS_InvalidCalc: @@ -277,6 +275,8 @@ func MsgIDToString(id MsgID) string { return "unsupported-@namespace" case MsgID_CSS_UnsupportedCSSProperty: return "unsupported-css-property" + case MsgID_CSS_UnsupportedCSSNesting: + return "unsupported-css-nesting" // Bundler case MsgID_Bundler_AmbiguousReexport: