From 72c837935d74ab1f421c0d96e9d1ce1052438737 Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Sat, 25 Mar 2023 20:22:23 -0400 Subject: [PATCH] fix #1945: initial lowering code for css nesting --- CHANGELOG.md | 60 ++++ internal/bundler_tests/bundler_css_test.go | 2 +- .../bundler_tests/snapshots/snapshots_css.txt | 64 ++-- internal/compat/css_table.go | 24 +- internal/css_ast/css_ast.go | 156 +++++++++ internal/css_parser/css_nesting.go | 330 ++++++++++++++++++ internal/css_parser/css_parser.go | 115 ++++-- internal/css_parser/css_parser_selector.go | 14 +- internal/css_parser/css_parser_test.go | 94 ++++- internal/css_printer/css_printer.go | 9 +- internal/css_printer/css_printer_test.go | 10 + 11 files changed, 780 insertions(+), 98 deletions(-) create mode 100644 internal/css_parser/css_nesting.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 41ebfcab407..26006381da6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,66 @@ ## Unreleased +* Implement preliminary lowering for CSS nesting ([#1945](https://github.com/evanw/esbuild/issues/1945)) + + Chrome has [implemented the new CSS nesting specification](https://developer.chrome.com/articles/css-nesting/) in version 112, which is currently in beta but will become stable very soon. So CSS nesting is now a part of the web platform! + + This release of esbuild can now transform nested CSS syntax into non-nested CSS syntax for older browsers. The transformation relies on the `:is()` pseudo-class in many cases, so the transformation is only guaranteed to work when targeting browsers that support `:is()` (e.g. Chrome 88+). You'll need to set esbuild's [`target`](https://esbuild.github.io/api/#target) to the browsers you intend to support to tell esbuild to do this transformation. You will get a warning if you use CSS nesting syntax with a `target` which includes older browsers that don't support `:is()`. + + The lowering transformation looks like this: + + ```css + /* Original input */ + a.btn { + color: #333; + &:hover { color: #444 } + &:active { color: #555 } + } + + /* New output (with --target=chrome88) */ + a.btn { + color: #333; + } + a.btn:hover { + color: #444; + } + a.btn:active { + color: #555; + } + ``` + + More complex cases may generate the `:is()` pseudo-class: + + ```css + /* Original input */ + div, p { + .warning, .error { + padding: 20px; + } + } + + /* New output (with --target=chrome88) */ + :is(div, p) :is(.warning, .error) { + padding: 20px; + } + ``` + + In addition, esbuild now has a special warning message for nested style rules that start with an identifier. This isn't allowed in CSS because the syntax would be ambiguous with the existing declaration syntax. The new warning message looks like this: + + ``` + ▲ [WARNING] A nested style rule cannot start with "p" because it looks like the start of a declaration [css-syntax-error] + + :1:7: + 1 │ main { p { margin: auto } } + │ ^ + ╵ :is(p) + + To start a nested style rule with an identifier, you need to wrap the identifier in ":is(...)" to + prevent the rule from being parsed as a declaration. + ``` + + Keep in mind that the transformation in this release is a preliminary implementation. CSS has many features that interact in complex ways, and there may be some edge cases that don't work correctly yet. + * Minification now removes unnecessary `&` CSS nesting selectors This release introduces the following CSS minification optimizations: diff --git a/internal/bundler_tests/bundler_css_test.go b/internal/bundler_tests/bundler_css_test.go index 01138f36487..269c2a54e5b 100644 --- a/internal/bundler_tests/bundler_css_test.go +++ b/internal/bundler_tests/bundler_css_test.go @@ -772,7 +772,7 @@ func TestCSSNestingOldBrowser(t *testing.T) { options: config.Options{ Mode: config.ModeBundle, AbsOutputDir: "/out", - UnsupportedCSSFeatures: compat.Nesting, + UnsupportedCSSFeatures: compat.Nesting | compat.IsPseudoClass, OriginalTargetEnv: "chrome10", }, expectedScanLog: `media-ampersand-first.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10) diff --git a/internal/bundler_tests/snapshots/snapshots_css.txt b/internal/bundler_tests/snapshots/snapshots_css.txt index 66ae7de068a..ce7e0d498ba 100644 --- a/internal/bundler_tests/snapshots/snapshots_css.txt +++ b/internal/bundler_tests/snapshots/snapshots_css.txt @@ -182,92 +182,74 @@ console.log(void 0); TestCSSNestingOldBrowser ---------- /out/nested-@layer.css ---------- /* nested-@layer.css */ -a { - @layer base { +@layer base { + a { color: red; } } ---------- /out/nested-@media.css ---------- /* nested-@media.css */ -a { - @media screen { +@media screen { + a { color: red; } } ---------- /out/nested-ampersand-twice.css ---------- /* nested-ampersand-twice.css */ +a, a { - &, - & { - color: red; - } + color: red; } ---------- /out/nested-ampersand-first.css ---------- /* nested-ampersand-first.css */ -a { - &, - b { - color: red; - } +a, +a b { + color: red; } ---------- /out/nested-attribute.css ---------- /* nested-attribute.css */ -a { - [href] { - color: red; - } +a [href] { + color: red; } ---------- /out/nested-colon.css ---------- /* nested-colon.css */ -a { - :hover { - color: red; - } +a :hover { + color: red; } ---------- /out/nested-dot.css ---------- /* nested-dot.css */ -a { - .cls { - color: red; - } +a .cls { + color: red; } ---------- /out/nested-greaterthan.css ---------- /* nested-greaterthan.css */ -a { - > b { - color: red; - } +a > b { + color: red; } ---------- /out/nested-hash.css ---------- /* nested-hash.css */ -a { - #id { - color: red; - } +a #id { + color: red; } ---------- /out/nested-plus.css ---------- /* nested-plus.css */ -a { - + b { - color: red; - } +a + b { + color: red; } ---------- /out/nested-tilde.css ---------- /* nested-tilde.css */ -a { - ~ b { - color: red; - } +a ~ b { + color: red; } ---------- /out/toplevel-ampersand-twice.css ---------- diff --git a/internal/compat/css_table.go b/internal/compat/css_table.go index 5d920b2bb28..f3a1fe1987e 100644 --- a/internal/compat/css_table.go +++ b/internal/compat/css_table.go @@ -16,15 +16,17 @@ const ( InsetProperty Nesting + IsPseudoClass ) var StringToCSSFeature = map[string]CSSFeature{ - "hex-rgba": HexRGBA, - "inline-style": InlineStyle, - "rebecca-purple": RebeccaPurple, - "modern-rgb-hsl": Modern_RGB_HSL, - "inset-property": InsetProperty, - "nesting": Nesting, + "hex-rgba": HexRGBA, + "inline-style": InlineStyle, + "rebecca-purple": RebeccaPurple, + "modern-rgb-hsl": Modern_RGB_HSL, + "inset-property": InsetProperty, + "nesting": Nesting, + "is-pseudo-class": IsPseudoClass, } func (features CSSFeature) Has(feature CSSFeature) bool { @@ -77,6 +79,16 @@ var cssTable = map[CSSFeature]map[Engine][]versionRange{ Nesting: { Chrome: {{start: v{112, 0, 0}}}, }, + + // Data from: https://caniuse.com/css-matches-pseudo + IsPseudoClass: { + Chrome: {{start: v{88, 0, 0}}}, + Edge: {{start: v{88, 0, 0}}}, + Firefox: {{start: v{78, 0, 0}}}, + IOS: {{start: v{14, 0, 0}}}, + Opera: {{start: v{75, 0, 0}}}, + Safari: {{start: v{14, 0, 0}}}, + }, } // Return all features that are not available in at least one environment diff --git a/internal/css_ast/css_ast.go b/internal/css_ast/css_ast.go index 31270742239..8d73a5124f3 100644 --- a/internal/css_ast/css_ast.go +++ b/internal/css_ast/css_ast.go @@ -598,6 +598,44 @@ type ComplexSelector struct { Selectors []CompoundSelector } +func (s ComplexSelector) AppendToTokens(tokens []Token) []Token { + for i, sel := range s.Selectors { + if n := len(tokens); i > 0 && n > 0 { + tokens[n-1].Whitespace |= WhitespaceAfter + } + tokens = sel.AppendToTokens(tokens) + } + return tokens +} + +func (sel ComplexSelector) IsRelative() bool { + if sel.Selectors[0].Combinator == 0 { + for _, inner := range sel.Selectors { + if inner.HasNestingSelector { + return false + } + for _, ss := range inner.SubclassSelectors { + if class, ok := ss.(*SSPseudoClass); ok && tokensContainAmpersandRecursive(class.Args) { + return false + } + } + } + } + return true +} + +func tokensContainAmpersandRecursive(tokens []Token) bool { + for _, t := range tokens { + if t.Kind == css_lexer.TDelimAmpersand { + return true + } + if children := t.Children; children != nil && tokensContainAmpersandRecursive(*children) { + return true + } + } + return false +} + func (sel ComplexSelector) UsesPseudoElement() bool { for _, sel := range sel.Selectors { for _, sub := range sel.SubclassSelectors { @@ -662,6 +700,35 @@ func (sel CompoundSelector) IsSingleAmpersand() bool { return sel.HasNestingSelector && sel.Combinator == 0 && sel.TypeSelector == nil && len(sel.SubclassSelectors) == 0 } +func (sel CompoundSelector) AppendToTokens(tokens []Token) []Token { + if sel.Combinator != 0 { + switch sel.Combinator { + case '>': + tokens = append(tokens, Token{Kind: css_lexer.TDelimGreaterThan, Text: ">", Whitespace: WhitespaceAfter}) + case '+': + tokens = append(tokens, Token{Kind: css_lexer.TDelimPlus, Text: "+", Whitespace: WhitespaceAfter}) + case '~': + tokens = append(tokens, Token{Kind: css_lexer.TDelimTilde, Text: "~", Whitespace: WhitespaceAfter}) + default: + panic("Internal error") + } + } + + if sel.HasNestingSelector { + tokens = append(tokens, Token{Kind: css_lexer.TDelimAmpersand, Text: "&"}) + } + + if sel.TypeSelector != nil { + tokens = sel.TypeSelector.AppendToTokens(tokens) + } + + for _, ss := range sel.SubclassSelectors { + tokens = ss.AppendToTokens(tokens) + } + + return tokens +} + type NameToken struct { Text string Kind css_lexer.T @@ -675,6 +742,16 @@ type NamespacedName struct { Name NameToken } +func (n NamespacedName) AppendToTokens(tokens []Token) []Token { + if n.NamespacePrefix != nil { + tokens = append(tokens, + Token{Kind: n.NamespacePrefix.Kind, Text: n.NamespacePrefix.Text}, + Token{Kind: css_lexer.TDelimBar, Text: "|"}, + ) + } + return append(tokens, Token{Kind: n.Name.Kind, Text: n.Name.Text}) +} + func (a NamespacedName) Equal(b NamespacedName) bool { return a.Name == b.Name && (a.NamespacePrefix == nil) == (b.NamespacePrefix == nil) && (a.NamespacePrefix == nil || b.NamespacePrefix == nil || *a.NamespacePrefix == *b.NamespacePrefix) @@ -683,6 +760,7 @@ func (a NamespacedName) Equal(b NamespacedName) bool { type SS interface { Equal(ss SS, check *CrossFileEqualityCheck) bool Hash() uint32 + AppendToTokens(tokens []Token) []Token } type SSHash struct { @@ -700,6 +778,10 @@ func (ss *SSHash) Hash() uint32 { return hash } +func (ss *SSHash) AppendToTokens(tokens []Token) []Token { + return append(tokens, Token{Kind: css_lexer.THash, Text: ss.Name}) +} + type SSClass struct { Name string } @@ -715,6 +797,13 @@ func (ss *SSClass) Hash() uint32 { return hash } +func (ss *SSClass) AppendToTokens(tokens []Token) []Token { + return append(tokens, + Token{Kind: css_lexer.TDelimDot, Text: "."}, + Token{Kind: css_lexer.TIdent, Text: ss.Name}, + ) +} + type SSAttribute struct { MatcherOp string // Either "" or one of: "=" "~=" "|=" "^=" "$=" "*=" MatcherValue string @@ -736,6 +825,55 @@ func (ss *SSAttribute) Hash() uint32 { return hash } +func (ss *SSAttribute) AppendToTokens(tokens []Token) []Token { + var children []Token + children = ss.NamespacedName.AppendToTokens(children) + + if ss.MatcherOp != "" { + switch ss.MatcherOp { + case "=": + children = append(children, Token{Kind: css_lexer.TDelimEquals, Text: "="}) + case "~=": + children = append(children, Token{Kind: css_lexer.TDelimTilde, Text: "~"}, Token{Kind: css_lexer.TDelimEquals, Text: "="}) + case "|=": + children = append(children, Token{Kind: css_lexer.TDelimBar, Text: "|"}, Token{Kind: css_lexer.TDelimEquals, Text: "="}) + case "^=": + children = append(children, Token{Kind: css_lexer.TDelimCaret, Text: "^"}, Token{Kind: css_lexer.TDelimEquals, Text: "="}) + case "$=": + children = append(children, Token{Kind: css_lexer.TDelimDollar, Text: "$"}, Token{Kind: css_lexer.TDelimEquals, Text: "="}) + case "*=": + children = append(children, Token{Kind: css_lexer.TDelimAsterisk, Text: "*"}, Token{Kind: css_lexer.TDelimEquals, Text: "="}) + default: + panic("Internal error") + } + printAsIdent := false + + // Print the value as an identifier if it's possible + if css_lexer.WouldStartIdentifierWithoutEscapes(ss.MatcherValue) { + printAsIdent = true + for _, c := range ss.MatcherValue { + if !css_lexer.IsNameContinue(c) { + printAsIdent = false + break + } + } + } + + if printAsIdent { + children = append(children, Token{Kind: css_lexer.TIdent, Text: ss.MatcherValue}) + } else { + children = append(children, Token{Kind: css_lexer.TString, Text: ss.MatcherValue}) + } + } + + if ss.MatcherModifier != 0 { + children = append(children, Token{Kind: css_lexer.TIdent, Text: string(rune(ss.MatcherModifier)), Whitespace: WhitespaceBefore}) + } + + tokens = append(tokens, Token{Kind: css_lexer.TOpenBracket, Text: "[", Children: &children}) + return tokens +} + type SSPseudoClass struct { Name string Args []Token @@ -753,3 +891,21 @@ func (ss *SSPseudoClass) Hash() uint32 { hash = HashTokens(hash, ss.Args) return hash } + +func (ss *SSPseudoClass) AppendToTokens(tokens []Token) []Token { + if ss.IsElement { + tokens = append(tokens, Token{Kind: css_lexer.TColon, Text: ":"}) + } + + if ss.Args != nil { + return append(tokens, + Token{Kind: css_lexer.TColon, Text: ":"}, + Token{Kind: css_lexer.TFunction, Text: ss.Name, Children: &ss.Args}, + ) + } + + return append(tokens, + Token{Kind: css_lexer.TColon, Text: ":"}, + Token{Kind: css_lexer.TIdent, Text: ss.Name}, + ) +} diff --git a/internal/css_parser/css_nesting.go b/internal/css_parser/css_nesting.go new file mode 100644 index 00000000000..db9e0f266c0 --- /dev/null +++ b/internal/css_parser/css_nesting.go @@ -0,0 +1,330 @@ +package css_parser + +import ( + "github.com/evanw/esbuild/internal/css_ast" + "github.com/evanw/esbuild/internal/css_lexer" +) + +func lowerNestingInRule(rule css_ast.Rule, results []css_ast.Rule) []css_ast.Rule { + switch r := rule.Data.(type) { + case *css_ast.RSelector: + // Filter out pseudo elements because they are ignored by nested style + // rules. This is because pseudo-elements are not valid within :is(): + // https://www.w3.org/TR/selectors-4/#matches-pseudo. This restriction + // may be relaxed in the future, but this restriction hash shipped so + // we're stuck with it: https://github.com/w3c/csswg-drafts/issues/7433. + selectors := r.Selectors + n := 0 + for _, sel := range selectors { + if !sel.UsesPseudoElement() { + selectors[n] = sel + n++ + } + } + selectors = selectors[:n] + + // Emit this selector before its nested children + start := len(results) + results = append(results, rule) + + // Lower all children and filter out ones that become empty + context := lowerNestingContext{ + parentSelectors: selectors, + loweredRules: results, + } + r.Rules = lowerNestingInRulesAndReturnRemaining(r.Rules, &context) + + // Omit this selector entirely if it's now empty + if len(r.Rules) == 0 { + copy(context.loweredRules[start:], context.loweredRules[start+1:]) + context.loweredRules = context.loweredRules[:len(context.loweredRules)-1] + } + return context.loweredRules + + case *css_ast.RKnownAt: + var rules []css_ast.Rule + for _, child := range r.Rules { + rules = lowerNestingInRule(child, rules) + } + r.Rules = rules + + case *css_ast.RAtLayer: + var rules []css_ast.Rule + for _, child := range r.Rules { + rules = lowerNestingInRule(child, rules) + } + r.Rules = rules + } + + return append(results, rule) +} + +// Lower all children and filter out ones that become empty +func lowerNestingInRulesAndReturnRemaining(rules []css_ast.Rule, context *lowerNestingContext) []css_ast.Rule { + n := 0 + for _, child := range rules { + child = lowerNestingInRuleWithContext(child, context) + if child.Data != nil { + rules[n] = child + n++ + } + } + return rules[:n] +} + +type lowerNestingContext struct { + parentSelectors []css_ast.ComplexSelector + loweredRules []css_ast.Rule +} + +func lowerNestingInRuleWithContext(rule css_ast.Rule, context *lowerNestingContext) css_ast.Rule { + switch r := rule.Data.(type) { + case *css_ast.RSelector: + // "a { & b {} }" => "a b {}" + // "a { &b {} }" => "a:is(b) {}" + // "a { &:hover {} }" => "a:hover {}" + // ".x { &b {} }" => "b.x {}" + // "a, b { .c, d {} }" => ":is(a, b) :is(.c, d) {}" + // "a, b { &.c, & d, e & {} }" => ":is(a, b).c, :is(a, b) d, e :is(a, b) {}" + + // Pass 1: Canonicalize and analyze our selectors + canUseGroupDescendantCombinator := true // Can we do "parent «space» :is(...selectors)"? + canUseGroupSubSelector := true // Can we do "parent«nospace»:is(...selectors)"? + for i := range r.Selectors { + sel := &r.Selectors[i] + + // Inject the implicit "&" now for simplicity later on + if sel.IsRelative() { + sel.Selectors = append([]css_ast.CompoundSelector{{HasNestingSelector: true}}, sel.Selectors...) + } + + // Are all children of the form "& «something»"? + if len(sel.Selectors) < 2 || !sel.Selectors[0].IsSingleAmpersand() { + canUseGroupDescendantCombinator = false + } + + // Are all children of the form "&«something»"? + if first := sel.Selectors[0]; !first.HasNestingSelector || first.IsSingleAmpersand() { + canUseGroupSubSelector = false + } + } + + // Try to apply simplifications for shorter output + if canUseGroupDescendantCombinator { + // "& a, & b {}" => "& :is(a, b) {}" + for i := range r.Selectors { + sel := &r.Selectors[i] + sel.Selectors = sel.Selectors[1:] + } + merged := multipleComplexSelectorsToSingleComplexSelector(r.Selectors) + merged.Selectors = append([]css_ast.CompoundSelector{{HasNestingSelector: true}}, merged.Selectors...) + r.Selectors = []css_ast.ComplexSelector{merged} + } else if canUseGroupSubSelector { + // "&a, &b {}" => "&:is(a, b) {}" + for i := range r.Selectors { + sel := &r.Selectors[i] + sel.Selectors[0].HasNestingSelector = false + } + merged := multipleComplexSelectorsToSingleComplexSelector(r.Selectors) + merged.Selectors[0].HasNestingSelector = true + r.Selectors = []css_ast.ComplexSelector{merged} + } + + // Pass 2: Substitue "&" for the parent selector + for i := range r.Selectors { + complex := &r.Selectors[i] + results := make([]css_ast.CompoundSelector, 0, len(complex.Selectors)) + parent := multipleComplexSelectorsToSingleComplexSelector(context.parentSelectors) + for _, compound := range complex.Selectors { + results = substituteAmpersandsInCompoundSelector(compound, parent, results) + } + complex.Selectors = results + } + + // Lower all child rules using our newly substituted selector + context.loweredRules = lowerNestingInRule(rule, context.loweredRules) + return css_ast.Rule{} + + case *css_ast.RKnownAt: + childContext := lowerNestingContext{parentSelectors: context.parentSelectors} + r.Rules = lowerNestingInRulesAndReturnRemaining(r.Rules, &childContext) + + // "div { @media screen { color: red } }" "@media screen { div { color: red } }" + if len(r.Rules) > 0 { + childContext.loweredRules = append([]css_ast.Rule{{Loc: rule.Loc, Data: &css_ast.RSelector{ + Selectors: context.parentSelectors, + Rules: r.Rules, + }}}, childContext.loweredRules...) + } + + // "div { @media screen { &:hover { color: red } } }" "@media screen { div:hover { color: red } }" + if len(childContext.loweredRules) > 0 { + r.Rules = childContext.loweredRules + context.loweredRules = append(context.loweredRules, rule) + } + + return css_ast.Rule{} + + case *css_ast.RAtLayer: + // Lower all children and filter out ones that become empty + childContext := lowerNestingContext{parentSelectors: context.parentSelectors} + r.Rules = lowerNestingInRulesAndReturnRemaining(r.Rules, &childContext) + + // "div { @layer foo { color: red } }" "@layer foo { div { color: red } }" + if len(r.Rules) > 0 { + childContext.loweredRules = append([]css_ast.Rule{{Loc: rule.Loc, Data: &css_ast.RSelector{ + Selectors: context.parentSelectors, + Rules: r.Rules, + }}}, childContext.loweredRules...) + } + + // "div { @layer foo { &:hover { color: red } } }" "@layer foo { div:hover { color: red } }" + // "div { @layer foo {} }" => "@layer foo {}" (layers have side effects, so don't remove empty ones) + r.Rules = childContext.loweredRules + context.loweredRules = append(context.loweredRules, rule) + return css_ast.Rule{} + } + + return rule +} + +func substituteAmpersandsInCompoundSelector(sel css_ast.CompoundSelector, replacement css_ast.ComplexSelector, results []css_ast.CompoundSelector) []css_ast.CompoundSelector { + if sel.HasNestingSelector { + sel.HasNestingSelector = false + + // Convert the replacement to a single compound selector + var single css_ast.CompoundSelector + if len(replacement.Selectors) == 1 || len(results) == 0 { + // ".foo { :hover & {} }" => ":hover .foo {}" + // ".foo .bar { &:hover {} }" => ".foo .bar:hover {}" + last := len(replacement.Selectors) - 1 + results = append(results, replacement.Selectors[:last]...) + single = replacement.Selectors[last] + } else { + // ".foo .bar { :hover & {} }" => ":hover :is(.foo .bar) {}" + single = css_ast.CompoundSelector{ + SubclassSelectors: []css_ast.SS{&css_ast.SSPseudoClass{ + Name: "is", + Args: replacement.AppendToTokens(nil), + }}, + } + } + + var subclassSelectorPrefix []css_ast.SS + + // Insert the type selector + if single.TypeSelector != nil { + if sel.TypeSelector != nil { + subclassSelectorPrefix = append(subclassSelectorPrefix, &css_ast.SSPseudoClass{ + Name: "is", + Args: sel.TypeSelector.AppendToTokens(nil), + }) + } + sel.TypeSelector = single.TypeSelector + } + + // Insert the subclass selectors + subclassSelectorPrefix = append(subclassSelectorPrefix, single.SubclassSelectors...) + + // Write the changes back + if len(subclassSelectorPrefix) > 0 { + sel.SubclassSelectors = append(subclassSelectorPrefix, sel.SubclassSelectors...) + } + } + + // "div { :is(&.foo) {} }" => ":is(div.foo) {}" + for _, ss := range sel.SubclassSelectors { + if class, ok := ss.(*css_ast.SSPseudoClass); ok { + class.Args = substituteAmpersandsInTokens(class.Args, replacement) + } + } + + return append(results, sel) +} + +func substituteAmpersandsInTokens(tokens []css_ast.Token, replacement css_ast.ComplexSelector) []css_ast.Token { + foundAmpersand := false + + for _, t := range tokens { + if t.Children != nil { + *t.Children = substituteAmpersandsInTokens(*t.Children, replacement) + } + if t.Kind == css_lexer.TDelimAmpersand { + foundAmpersand = true + } + } + + // We only need to allocate if we find an ampersand + if !foundAmpersand { + return tokens + } + + var results []css_ast.Token + replacementTokens := replacement.AppendToTokens(nil) + for _, t := range tokens { + if t.Kind != css_lexer.TDelimAmpersand { + results = append(results, t) + continue + } + + insert := replacementTokens + + // Try to avoid generating a bad substitution when "&" is in a weird place. + // This is necessary because we're operating on a list of tokens instead of + // a fully-parsed AST. Here's an example: + // + // "div { :is(.foo&) {} }" => ":is(.foo:is(div))" + // "div { :is(#foo&) {} }" => ":is(#foo:is(div))" + // "div { :is([foo]&) {} }" => ":is([foo]:is(div))" + // "div { :is(:hover&) {} }" => ":is(:hover:is(div))" + // "div { :is(:is(.foo)&) {} }" => ":is(:is(.foo):is(div))" + // + // There are likely a lot of edge cases with this that aren't covered. But + // this is probably fine because a) who would write code like this and b) + // PostCSS's nesting transform doesn't handle these edge cases at all. + if len(results) > 0 && len(insert) > 0 { + if first := insert[0]; first.Kind == css_lexer.TIdent && (first.Whitespace&css_ast.WhitespaceBefore) == 0 { + if last := results[len(results)-1]; (last.Whitespace & css_ast.WhitespaceAfter) == 0 { + switch last.Kind { + case css_lexer.TIdent, css_lexer.TOpenBracket, css_lexer.TFunction: + insert = []css_ast.Token{ + {Kind: css_lexer.TColon, Text: ":"}, + {Kind: css_lexer.TFunction, Text: "is", Children: &replacementTokens}, + } + } + } + } + } + + results = append(results, insert...) + } + return results +} + +// Turn the list of selectors into a single selector by wrapping lists +// without a single element with ":is(...)". Note that this may result +// in an empty ":is()" selector (which matches nothing). +func multipleComplexSelectorsToSingleComplexSelector(selectors []css_ast.ComplexSelector) css_ast.ComplexSelector { + if len(selectors) == 1 { + return selectors[0] + } + + // This must be non-nil so we print ":is()" instead of ":is" + tokens := []css_ast.Token{} + + for i, sel := range selectors { + if i > 0 { + tokens = append(tokens, css_ast.Token{Kind: css_lexer.TComma, Text: ",", Whitespace: css_ast.WhitespaceAfter}) + } + tokens = sel.AppendToTokens(tokens) + } + + return css_ast.ComplexSelector{ + Selectors: []css_ast.CompoundSelector{{ + SubclassSelectors: []css_ast.SS{&css_ast.SSPseudoClass{ + Name: "is", + Args: tokens, + }}, + }}, + } +} diff --git a/internal/css_parser/css_parser.go b/internal/css_parser/css_parser.go index 5e44c967c7d..1907ff4e5df 100644 --- a/internal/css_parser/css_parser.go +++ b/internal/css_parser/css_parser.go @@ -15,18 +15,19 @@ import ( // support for parsing https://drafts.csswg.org/css-nesting-1/. type parser struct { - log logger.Log - source logger.Source - tokens []css_lexer.Token - legalComments []css_lexer.Comment - stack []css_lexer.T - importRecords []ast.ImportRecord - tracker logger.LineColumnTracker - index int - end int - legalCommentIndex int - prevError logger.Loc - options Options + log logger.Log + source logger.Source + tokens []css_lexer.Token + legalComments []css_lexer.Comment + stack []css_lexer.T + importRecords []ast.ImportRecord + tracker logger.LineColumnTracker + index int + end int + legalCommentIndex int + prevError logger.Loc + options Options + shouldLowerNesting bool } type Options struct { @@ -199,6 +200,10 @@ func (p *parser) parseListOfRules(context ruleContext) []css_ast.Rule { loop: for { + if context.isTopLevel { + p.shouldLowerNesting = false + } + // If there are any legal comments immediately before the current token, // turn them all into comment rules and append them to the current rule list for p.legalCommentIndex < len(p.legalComments) { @@ -266,7 +271,12 @@ loop: } } - rules = append(rules, rule) + // Lower CSS nesting if it's not supported (but only at the top level) + if context.isTopLevel && p.shouldLowerNesting { + rules = lowerNestingInRule(rule, rules) + } else { + rules = append(rules, rule) + } continue case css_lexer.TCDO, css_lexer.TCDC: @@ -282,10 +292,18 @@ loop: atRuleContext.importValidity = atRuleInvalidAfter } + var rule css_ast.Rule if context.parseSelectors { - rules = append(rules, p.parseSelectorRuleFrom(p.index, context.isTopLevel, parseSelectorOpts{})) + rule = p.parseSelectorRuleFrom(p.index, context.isTopLevel, parseSelectorOpts{}) } else { - rules = append(rules, p.parseQualifiedRuleFrom(p.index, parseQualifiedRuleOpts{isTopLevel: context.isTopLevel})) + rule = p.parseQualifiedRuleFrom(p.index, parseQualifiedRuleOpts{isTopLevel: context.isTopLevel}) + } + + // Lower CSS nesting if it's not supported (but only at the top level) + if context.isTopLevel && p.shouldLowerNesting { + rules = lowerNestingInRule(rule, rules) + } else { + rules = append(rules, rule) } } @@ -339,7 +357,7 @@ func (p *parser) parseListOfDeclarations(opts listOfDeclarationsOpts) (list []cs return case css_lexer.TAtKeyword: - p.maybeWarnAboutNesting(p.current().Range) + p.reportUseOfNesting(p.current().Range, false) list = append(list, p.parseAtRule(atRuleContext{ isDeclarationList: true, canInlineNoOpNesting: opts.canInlineNoOpNesting, @@ -356,7 +374,7 @@ func (p *parser) parseListOfDeclarations(opts listOfDeclarationsOpts) (list []cs css_lexer.TDelimPlus, css_lexer.TDelimGreaterThan, css_lexer.TDelimTilde: - p.maybeWarnAboutNesting(p.current().Range) + p.reportUseOfNesting(p.current().Range, false) list = append(list, p.parseSelectorRuleFrom(p.index, false, parseSelectorOpts{isDeclarationContext: true})) foundNesting = true @@ -1279,13 +1297,16 @@ func (p *parser) expectValidLayerNameIdent() (string, bool) { return text, true } -func (p *parser) maybeWarnAboutNesting(r logger.Range) { +func (p *parser) reportUseOfNesting(r logger.Range, didWarnAlready bool) { 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.shouldLowerNesting = true + if p.options.UnsupportedCSSFeatures.Has(compat.IsPseudoClass) && !didWarnAlready { + 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) } - p.log.AddID(logger.MsgID_CSS_UnsupportedCSSNesting, logger.Warning, &p.tracker, r, text) } } @@ -1301,7 +1322,7 @@ type convertTokensOpts struct { } func (p *parser) convertTokensHelper(tokens []css_lexer.Token, close css_lexer.T, opts convertTokensOpts) ([]css_ast.Token, []css_lexer.Token) { - var result []css_ast.Token + result := []css_ast.Token{} var nextWhitespace css_ast.WhitespaceFlags // Enable verbatim whitespace mode when the first two non-whitespace tokens @@ -1714,23 +1735,27 @@ loop: func (p *parser) parseDeclaration() css_ast.Rule { // Parse the key keyStart := p.index - keyLoc := p.tokens[keyStart].Range.Loc + keyRange := p.tokens[keyStart].Range + keyIsIdent := p.expect(css_lexer.TIdent) ok := false - if p.expect(css_lexer.TIdent) { + if keyIsIdent { p.eat(css_lexer.TWhitespace) - if p.expect(css_lexer.TColon) { - ok = true - } + ok = p.eat(css_lexer.TColon) } // Parse the value valueStart := p.index + foundOpenBrace := false stop: for { switch p.current().Kind { case css_lexer.TEndOfFile, css_lexer.TSemicolon, css_lexer.TCloseBrace: break stop + case css_lexer.TOpenBrace: + foundOpenBrace = true + p.parseComponentValue() + default: p.parseComponentValue() } @@ -1738,7 +1763,37 @@ stop: // Stop now if this is not a valid declaration if !ok { - return css_ast.Rule{Loc: keyLoc, Data: &css_ast.RBadDeclaration{ + if keyIsIdent { + if foundOpenBrace { + // If we encountered a "{", assume this is someone trying to make a nested style rule + if keyRange.Loc.Start > p.prevError.Start { + p.prevError.Start = keyRange.Loc.Start + key := p.tokens[keyStart].DecodedText(p.source.Contents) + data := p.tracker.MsgData(keyRange, fmt.Sprintf("A nested style rule cannot start with %q because it looks like the start of a declaration", key)) + data.Location.Suggestion = fmt.Sprintf(":is(%s)", p.source.TextForRange(keyRange)) + p.log.AddMsgID(logger.MsgID_CSS_CSSSyntaxError, logger.Msg{ + Kind: logger.Warning, + Data: data, + Notes: []logger.MsgData{{ + Text: "To start a nested style rule with an identifier, you need to wrap the " + + "identifier in \":is(...)\" to prevent the rule from being parsed as a declaration."}}, + }) + } + } else { + // Otherwise, show a generic error about a missing ":" + if end := keyRange.End(); end > p.prevError.Start { + p.prevError.Start = end + data := p.tracker.MsgData(logger.Range{Loc: logger.Loc{Start: end}}, "Expected \":\"") + data.Location.Suggestion = ":" + p.log.AddMsgID(logger.MsgID_CSS_CSSSyntaxError, logger.Msg{ + Kind: logger.Warning, + Data: data, + }) + } + } + } + + return css_ast.Rule{Loc: keyRange.Loc, Data: &css_ast.RBadDeclaration{ Tokens: p.convertTokens(p.tokens[keyStart:p.index]), }} } @@ -1793,7 +1848,7 @@ stop: } } - return css_ast.Rule{Loc: keyLoc, Data: &css_ast.RDeclaration{ + return css_ast.Rule{Loc: keyRange.Loc, Data: &css_ast.RDeclaration{ Key: key, KeyText: keyText, KeyRange: keyToken.Range, diff --git a/internal/css_parser/css_parser_selector.go b/internal/css_parser/css_parser_selector.go index 6804dc1fb0e..6bf179404ef 100644 --- a/internal/css_parser/css_parser_selector.go +++ b/internal/css_parser/css_parser_selector.go @@ -106,9 +106,7 @@ func (p *parser) parseComplexSelector(opts parseSelectorOpts) (result css_ast.Co r := p.current().Range combinator := p.parseCombinator() if combinator != 0 { - if !opts.isDeclarationContext { - p.maybeWarnAboutNesting(r) - } + p.reportUseOfNesting(r, opts.isDeclarationContext) p.eat(css_lexer.TWhitespace) } @@ -155,9 +153,7 @@ func (p *parser) nameToken() css_ast.NameToken { func (p *parser) parseCompoundSelector(opts parseSelectorOpts) (sel css_ast.CompoundSelector, ok bool) { // This is an extension: https://drafts.csswg.org/css-nesting-1/ if p.peek(css_lexer.TDelimAmpersand) { - if !opts.isDeclarationContext { - p.maybeWarnAboutNesting(p.current().Range) - } + p.reportUseOfNesting(p.current().Range, opts.isDeclarationContext) sel.HasNestingSelector = true p.advance() } @@ -243,10 +239,8 @@ subclassSelectors: case css_lexer.TDelimAmpersand: // This is an extension: https://drafts.csswg.org/css-nesting-1/ - if !sel.HasNestingSelector { - p.maybeWarnAboutNesting(p.current().Range) - sel.HasNestingSelector = true - } + p.reportUseOfNesting(p.current().Range, sel.HasNestingSelector) + sel.HasNestingSelector = true p.advance() default: diff --git a/internal/css_parser/css_parser_test.go b/internal/css_parser/css_parser_test.go index 6fbbada4248..5a61ef7a008 100644 --- a/internal/css_parser/css_parser_test.go +++ b/internal/css_parser/css_parser_test.go @@ -50,14 +50,14 @@ func expectParseError(t *testing.T, contents string, expected string, expectedLo func expectParseErrorMinify(t *testing.T, contents string, expected string, expectedLog string) { t.Helper() - expectPrintedCommon(t, contents, contents, expected, &expectedLog, config.Options{ + expectPrintedCommon(t, contents+" [minify]", contents, expected, &expectedLog, config.Options{ MinifyWhitespace: true, }) } func expectParseErrorMangle(t *testing.T, contents string, expected string, expectedLog string) { t.Helper() - expectPrintedCommon(t, contents, contents, expected, &expectedLog, config.Options{ + expectPrintedCommon(t, contents+" [mangle]", contents, expected, &expectedLog, config.Options{ MinifySyntax: true, }) } @@ -69,7 +69,7 @@ func expectPrinted(t *testing.T, contents string, expected string) { func expectPrintedLower(t *testing.T, contents string, expected string) { t.Helper() - expectPrintedCommon(t, contents+" [mangle]", contents, expected, nil, config.Options{ + expectPrintedCommon(t, contents+" [lower]", contents, expected, nil, config.Options{ UnsupportedCSSFeatures: ^compat.CSSFeature(0), }) } @@ -592,7 +592,9 @@ func TestDeclaration(t *testing.T) { expectPrinted(t, ".decl { a: b; }", ".decl {\n a: b;\n}\n") expectPrinted(t, ".decl { a: b; c: d }", ".decl {\n a: b;\n c: d;\n}\n") expectPrinted(t, ".decl { a: b; c: d; }", ".decl {\n a: b;\n c: d;\n}\n") - expectParseError(t, ".decl { a { b: c; } }", ".decl {\n a { b: c; };\n}\n", ": WARNING: Expected \":\"\n") + expectParseError(t, ".decl { a { b: c; } }", ".decl {\n a { b: c; };\n}\n", + ": WARNING: A nested style rule cannot start with \"a\" because it looks like the start of a declaration\n"+ + "NOTE: To start a nested style rule with an identifier, you need to wrap the identifier in \":is(...)\" to prevent the rule from being parsed as a declaration.\n") expectPrinted(t, ".decl { & a { b: c; } }", ".decl {\n & a {\n b: c;\n }\n}\n") // See http://browserhacks.com/ @@ -757,7 +759,9 @@ func TestNestedSelector(t *testing.T) { expectParseError(t, "a { >b {} }", "a {\n > b {\n }\n}\n", "") expectParseError(t, "a { +b {} }", "a {\n + b {\n }\n}\n", "") expectParseError(t, "a { ~b {} }", "a {\n ~ b {\n }\n}\n", "") - expectParseError(t, "a { b {} }", "a {\n b {};\n}\n", ": WARNING: Expected \":\"\n") + expectParseError(t, "a { b {} }", "a {\n b {};\n}\n", + ": WARNING: A nested style rule cannot start with \"b\" because it looks like the start of a declaration\n"+ + "NOTE: To start a nested style rule with an identifier, you need to wrap the identifier in \":is(...)\" to prevent the rule from being parsed as a declaration.\n") expectParseError(t, "a { b() {} }", "a {\n b() {};\n}\n", ": WARNING: Expected identifier but found \"b(\"\n") // Note: CSS nesting no longer requires each complex selector to contain "&" @@ -854,12 +858,90 @@ func TestNestedSelector(t *testing.T) { expectPrintedMangle(t, "div, span:hover { @media screen { & { color: red } } }", "div,\nspan:hover {\n @media screen {\n color: red;\n }\n}\n") expectPrintedMangle(t, "div, span::pseudo { @layer foo { & { color: red } } }", "div,\nspan::pseudo {\n @layer foo {\n & {\n color: red;\n }\n }\n}\n") expectPrintedMangle(t, "div, span::pseudo { @media screen { & { color: red } } }", "div,\nspan::pseudo {\n @media screen {\n & {\n color: red;\n }\n }\n}\n") + + // Lowering tests for nesting + expectPrintedLower(t, ".foo { .bar { color: red } }", ".foo .bar {\n color: red;\n}\n") + expectPrintedLower(t, ".foo { &.bar { color: red } }", ".foo.bar {\n color: red;\n}\n") + expectPrintedLower(t, ".foo { & .bar { color: red } }", ".foo .bar {\n color: red;\n}\n") + expectPrintedLower(t, ".foo .bar { .baz { color: red } }", ".foo .bar .baz {\n color: red;\n}\n") + expectPrintedLower(t, ".foo .bar { &.baz { color: red } }", ".foo .bar.baz {\n color: red;\n}\n") + expectPrintedLower(t, ".foo .bar { & .baz { color: red } }", ".foo .bar .baz {\n color: red;\n}\n") + expectPrintedLower(t, ".foo .bar { & > .baz { color: red } }", ".foo .bar > .baz {\n color: red;\n}\n") + expectPrintedLower(t, ".foo .bar { .baz & { color: red } }", ".baz :is(.foo .bar) {\n color: red;\n}\n") // NOT the same as ".baz .foo .bar" + expectPrintedLower(t, ".foo .bar { & .baz & { color: red } }", ".foo .bar .baz :is(.foo .bar) {\n color: red;\n}\n") + expectPrintedLower(t, ".foo, .bar { .baz & { color: red } }", ".baz :is(.foo, .bar) {\n color: red;\n}\n") + expectPrintedLower(t, ".foo, [bar~='abc'] { .baz { color: red } }", ":is(.foo, [bar~=abc]) .baz {\n color: red;\n}\n") + expectPrintedLower(t, ".foo, [bar~='a b c'] { .baz { color: red } }", ":is(.foo, [bar~=\"a b c\"]) .baz {\n color: red;\n}\n") + expectPrintedLower(t, ".baz { .foo, .bar { color: red } }", ".baz :is(.foo, .bar) {\n color: red;\n}\n") + expectPrintedLower(t, ".baz { .foo, & .bar { color: red } }", ".baz :is(.foo, .bar) {\n color: red;\n}\n") + expectPrintedLower(t, ".baz { & .foo, .bar { color: red } }", ".baz :is(.foo, .bar) {\n color: red;\n}\n") + expectPrintedLower(t, ".baz { & .foo, & .bar { color: red } }", ".baz :is(.foo, .bar) {\n color: red;\n}\n") + expectPrintedLower(t, ".baz { .foo, &.bar { color: red } }", ".baz .foo,\n.baz.bar {\n color: red;\n}\n") + expectPrintedLower(t, ".baz { &.foo, .bar { color: red } }", ".baz.foo,\n.baz .bar {\n color: red;\n}\n") + expectPrintedLower(t, ".baz { &.foo, &.bar { color: red } }", ".baz:is(.foo, .bar) {\n color: red;\n}\n") + expectPrintedLower(t, ".foo { color: blue; & .bar { color: red } }", ".foo {\n color: blue;\n}\n.foo .bar {\n color: red;\n}\n") + expectPrintedLower(t, ".foo { & .bar { color: red } color: blue }", ".foo {\n color: blue;\n}\n.foo .bar {\n color: red;\n}\n") + expectPrintedLower(t, ".foo { color: blue; & .bar { color: red } zoom: 2 }", ".foo {\n color: blue;\n zoom: 2;\n}\n.foo .bar {\n color: red;\n}\n") + expectPrintedLower(t, ".a, .b { .c, .d { color: red } }", ":is(.a, .b) :is(.c, .d) {\n color: red;\n}\n") + expectPrintedLower(t, ".foo, .bar, .foo:before, .bar:after { &:hover { color: red } }", ":is(.foo, .bar):hover {\n color: red;\n}\n") + expectPrintedLower(t, ".foo, .bar:before { &:hover { color: red } }", ".foo:hover {\n color: red;\n}\n") + expectPrintedLower(t, ".foo, .bar:before { :hover & { color: red } }", ":hover .foo {\n color: red;\n}\n") + expectPrintedLower(t, ".bar:before { &:hover { color: red } }", ":is():hover {\n color: red;\n}\n") + expectPrintedLower(t, ".bar:before { :hover & { color: red } }", ":hover :is() {\n color: red;\n}\n") + expectPrintedLower(t, ".xy { :where(&.foo) { color: red } }", ":where(.xy.foo) {\n color: red;\n}\n") + expectPrintedLower(t, "div { :where(&.foo) { color: red } }", ":where(div.foo) {\n color: red;\n}\n") + expectPrintedLower(t, ".xy { :where(.foo&) { color: red } }", ":where(.foo.xy) {\n color: red;\n}\n") + expectPrintedLower(t, "div { :where(.foo&) { color: red } }", ":where(.foo:is(div)) {\n color: red;\n}\n") + expectPrintedLower(t, ".xy { :where([href]&) { color: red } }", ":where([href].xy) {\n color: red;\n}\n") + expectPrintedLower(t, "div { :where([href]&) { color: red } }", ":where([href]:is(div)) {\n color: red;\n}\n") + expectPrintedLower(t, ".xy { :where(:hover&) { color: red } }", ":where(:hover.xy) {\n color: red;\n}\n") + expectPrintedLower(t, "div { :where(:hover&) { color: red } }", ":where(:hover:is(div)) {\n color: red;\n}\n") + expectPrintedLower(t, ".xy { :where(:is(.foo)&) { color: red } }", ":where(:is(.foo).xy) {\n color: red;\n}\n") + expectPrintedLower(t, "div { :where(:is(.foo)&) { color: red } }", ":where(:is(.foo):is(div)) {\n color: red;\n}\n") + expectPrintedLower(t, ".xy { :where(.foo + &) { color: red } }", ":where(.foo + .xy) {\n color: red;\n}\n") + expectPrintedLower(t, "div { :where(.foo + &) { color: red } }", ":where(.foo + div) {\n color: red;\n}\n") + expectPrintedLower(t, ".xy { :where(&, span:is(.foo &)) { color: red } }", ":where(.xy, span:is(.foo .xy)) {\n color: red;\n}\n") + expectPrintedLower(t, "div { :where(&, span:is(.foo &)) { color: red } }", ":where(div, span:is(.foo div)) {\n color: red;\n}\n") + expectPrintedLower(t, ".foo { @media screen {} }", "") + expectPrintedLower(t, ".foo { @media screen { color: red } }", "@media screen {\n .foo {\n color: red;\n }\n}\n") + expectPrintedLower(t, ".foo { @media screen { &:hover { color: red } } }", "@media screen {\n .foo:hover {\n color: red;\n }\n}\n") + expectPrintedLower(t, ".foo { @media screen { :hover { color: red } } }", "@media screen {\n .foo :hover {\n color: red;\n }\n}\n") + expectPrintedLower(t, ".foo, .bar { @media screen { color: red } }", "@media screen {\n .foo,\n .bar {\n color: red;\n }\n}\n") + expectPrintedLower(t, ".foo, .bar { @media screen { &:hover { color: red } } }", "@media screen {\n :is(.foo, .bar):hover {\n color: red;\n }\n}\n") + expectPrintedLower(t, ".foo, .bar { @media screen { :hover { color: red } } }", "@media screen {\n :is(.foo, .bar) :hover {\n color: red;\n }\n}\n") + expectPrintedLower(t, ".foo { @layer xyz {} }", "@layer xyz;\n") + expectPrintedLower(t, ".foo { @layer xyz { color: red } }", "@layer xyz {\n .foo {\n color: red;\n }\n}\n") + expectPrintedLower(t, ".foo { @layer xyz { &:hover { color: red } } }", "@layer xyz {\n .foo:hover {\n color: red;\n }\n}\n") + expectPrintedLower(t, ".foo { @layer xyz { :hover { color: red } } }", "@layer xyz {\n .foo :hover {\n color: red;\n }\n}\n") + expectPrintedLower(t, ".foo, .bar { @layer xyz { color: red } }", "@layer xyz {\n .foo,\n .bar {\n color: red;\n }\n}\n") + expectPrintedLower(t, ".foo, .bar { @layer xyz { &:hover { color: red } } }", "@layer xyz {\n :is(.foo, .bar):hover {\n color: red;\n }\n}\n") + expectPrintedLower(t, ".foo, .bar { @layer xyz { :hover { color: red } } }", "@layer xyz {\n :is(.foo, .bar) :hover {\n color: red;\n }\n}\n") + expectPrintedLower(t, "@media screen { @media (min-width: 900px) { a, b { &:hover { color: red } } } }", + "@media screen {\n @media (min-width: 900px) {\n :is(a, b):hover {\n color: red;\n }\n }\n}\n") + expectPrintedLower(t, "@supports (display: flex) { @supports selector(h2 > p) { a, b { &:hover { color: red } } } }", + "@supports (display: flex) {\n @supports selector(h2 > p) {\n :is(a, b):hover {\n color: red;\n }\n }\n}\n") + expectPrintedLower(t, "@layer foo { @layer bar { a, b { &:hover { color: red } } } }", + "@layer foo {\n @layer bar {\n :is(a, b):hover {\n color: red;\n }\n }\n}\n") + expectPrintedLower(t, ".demo { .lg { &.triangle, &.circle { color: red } } }", + ".demo .lg:is(.triangle, .circle) {\n color: red;\n}\n") + expectPrintedLower(t, ".demo { .lg { .triangle, .circle { color: red } } }", + ".demo .lg :is(.triangle, .circle) {\n color: red;\n}\n") + expectPrintedLower(t, ".card { .featured & & & { color: red } }", + ".featured .card .card .card {\n color: red;\n}\n") + expectPrintedLower(t, ".card { &--header { color: red } }", + "--header.card {\n color: red;\n}\n") + expectPrintedLower(t, ".card { @supports (selector(&)) { &:hover { color: red } } }", + "@supports (selector(&)) {\n .card:hover {\n color: red;\n }\n}\n") + expectPrintedLower(t, "html { @layer base { color: blue; @layer support { & body { color: red } } } }", + "@layer base {\n html {\n color: blue;\n }\n @layer support {\n html body {\n color: red;\n }\n }\n}\n") } func TestBadQualifiedRules(t *testing.T) { expectParseError(t, "$bad: rule;", "$bad: rule; {\n}\n", ": WARNING: Unexpected \"$\"\n") expectParseError(t, "$bad { color: red }", "$bad {\n color: red;\n}\n", ": WARNING: Unexpected \"$\"\n") - expectParseError(t, "a { div.major { color: blue } color: red }", "a {\n div.major { color: blue } color: red;\n}\n", ": WARNING: Expected \":\" but found \".\"\n") + expectParseError(t, "a { div.major { color: blue } color: red }", "a {\n div.major { color: blue } color: red;\n}\n", + ": WARNING: A nested style rule cannot start with \"div\" because it looks like the start of a declaration\n"+ + "NOTE: To start a nested style rule with an identifier, you need to wrap the identifier in \":is(...)\" to prevent the rule from being parsed as a declaration.\n") expectParseError(t, "a { div:hover { color: blue } color: red }", "a {\n div: hover { color: blue } color: red;\n}\n", "") expectParseError(t, "a { div:hover { color: blue }; color: red }", "a {\n div: hover { color: blue };\n color: red;\n}\n", "") expectParseError(t, "a { div:hover { color: blue } ; color: red }", "a {\n div: hover { color: blue };\n color: red;\n}\n", "") diff --git a/internal/css_printer/css_printer.go b/internal/css_printer/css_printer.go index 7ff2a34f834..295c66ffc8c 100644 --- a/internal/css_printer/css_printer.go +++ b/internal/css_printer/css_printer.go @@ -211,14 +211,14 @@ func (p *printer) printRule(rule css_ast.Rule, indent int32, omitTrailingSemicol whitespace = canDiscardWhitespaceAfter } p.printIdent(r.AtToken, identNormal, whitespace) - if (!p.options.MinifyWhitespace && r.Block != nil) || len(r.Prelude) > 0 { + if (!p.options.MinifyWhitespace && len(r.Block) != 0) || len(r.Prelude) > 0 { p.print(" ") } p.printTokens(r.Prelude, printTokensOpts{}) - if !p.options.MinifyWhitespace && r.Block != nil && len(r.Prelude) > 0 { + if !p.options.MinifyWhitespace && len(r.Block) != 0 && len(r.Prelude) > 0 { p.print(" ") } - if r.Block == nil { + if len(r.Block) == 0 { p.print(";") } else { p.printTokens(r.Block, printTokensOpts{}) @@ -471,7 +471,8 @@ func (p *printer) printPseudoClassSelector(pseudo css_ast.SSPseudoClass, whitesp p.print(":") } - if len(pseudo.Args) > 0 { + // This checks for "nil" so we can distinguish ":is()" from ":is" + if pseudo.Args != nil { p.printIdent(pseudo.Name, identNormal, canDiscardWhitespaceAfter) p.print("(") p.printTokens(pseudo.Args, printTokensOpts{}) diff --git a/internal/css_printer/css_printer_test.go b/internal/css_printer/css_printer_test.go index 5a3d88750f6..82a1ab7a7f4 100644 --- a/internal/css_printer/css_printer_test.go +++ b/internal/css_printer/css_printer_test.go @@ -128,6 +128,16 @@ func TestSelector(t *testing.T) { expectPrintedMinify(t, ":unknown( x ( a + b ), 'c' ) {}", ":unknown(x (a + b),\"c\"){}") expectPrintedMinify(t, ":unknown( x ( a - b ), 'c' ) {}", ":unknown(x (a - b),\"c\"){}") expectPrintedMinify(t, ":unknown( x ( a , b ), 'c' ) {}", ":unknown(x (a,b),\"c\"){}") + + // ":foo()" is a parse error, but should ideally still be preserved so they don't accidentally become valid + expectPrinted(t, ":is {}", ":is {\n}\n") + expectPrinted(t, ":is() {}", ":is() {\n}\n") + expectPrinted(t, ":hover {}", ":hover {\n}\n") + expectPrinted(t, ":hover() {}", ":hover() {\n}\n") + expectPrintedMinify(t, ":is {}", ":is{}") + expectPrintedMinify(t, ":is() {}", ":is(){}") + expectPrintedMinify(t, ":hover {}", ":hover{}") + expectPrintedMinify(t, ":hover() {}", ":hover(){}") } func TestNestedSelector(t *testing.T) {