diff --git a/CHANGELOG.md b/CHANGELOG.md index ffd24429edf..1b330cee4a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,24 @@ Note that this bug only affected code using the `local-css` loader. It did not affect code using the `css` loader. +* Ignore case in CSS in more places ([#3316](https://github.com/evanw/esbuild/issues/3316)) + + This release makes esbuild's CSS support more case-agnostic, which better matches how browsers work. For example: + + ```css + /* Original code */ + @KeyFrames Foo { From { OpaCity: 0 } To { OpaCity: 1 } } + body { CoLoR: YeLLoW } + + /* Old output (with --minify) */ + @KeyFrames Foo{From {OpaCity: 0} To {OpaCity: 1}}body{CoLoR:YeLLoW} + + /* New output (with --minify) */ + @KeyFrames Foo{0%{OpaCity:0}To{OpaCity:1}}body{CoLoR:#ff0} + ``` + + Please never actually write code like this. + ## 0.19.2 * Update how CSS nesting is parsed again diff --git a/internal/bundler_tests/bundler_css_test.go b/internal/bundler_tests/bundler_css_test.go index 8c1f94fb472..1df42cd29ec 100644 --- a/internal/bundler_tests/bundler_css_test.go +++ b/internal/bundler_tests/bundler_css_test.go @@ -2575,3 +2575,45 @@ func TestCSSAtLayerMergingWithImportConditions(t *testing.T) { }, }) } + +func TestCSSCaseInsensitivity(t *testing.T) { + css_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/entry.css": ` + /* "@IMPORT" should be recognized as an import */ + /* "LAYER(...)" should wrap with "@layer" */ + /* "SUPPORTS(...)" should wrap with "@supports" */ + @IMPORT Url("nested.css") LAYER(layer-name) SUPPORTS(supports-condition) list-of-media-queries; + `, + "/nested.css": ` + /* "from" should be recognized and optimized to "0%" */ + @KeyFrames Foo { + froM { OPAcity: 0 } + tO { opaCITY: 1 } + } + + body { + /* "#FF0000" should be optimized to "red" because "BACKGROUND-color" should be recognized */ + BACKGROUND-color: #FF0000; + + /* This should be optimized to 50px */ + width: CaLc(20Px + 30pX); + + /* This URL token should be recognized and bundled */ + background-IMAGE: Url(image.png); + } + `, + "/image.png": `...`, + }, + entryPaths: []string{"/entry.css"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputFile: "/out.css", + MinifySyntax: true, + ExtensionToLoader: map[string]config.Loader{ + ".css": config.LoaderCSS, + ".png": config.LoaderCopy, + }, + }, + }) +} diff --git a/internal/bundler_tests/snapshots/snapshots_css.txt b/internal/bundler_tests/snapshots/snapshots_css.txt index 467438c8ebc..e7b7d5eb347 100644 --- a/internal/bundler_tests/snapshots/snapshots_css.txt +++ b/internal/bundler_tests/snapshots/snapshots_css.txt @@ -1639,6 +1639,34 @@ TestCSSAtLayerMergingWithImportConditions /* entry.css */ +================================================================================ +TestCSSCaseInsensitivity +---------- /image-AKINYSFH.png ---------- +... +---------- /out.css ---------- +/* nested.css */ +@media list-of-media-queries { + @supports (supports-condition) { + @layer layer-name { + @KeyFrames Foo { + 0% { + OPAcity: 0; + } + tO { + opaCITY: 1; + } + } + body { + BACKGROUND-color: red; + width: 50Px; + background-IMAGE: url(./image-AKINYSFH.png); + } + } + } +} + +/* entry.css */ + ================================================================================ TestCSSEntryPoint ---------- /out.css ---------- diff --git a/internal/css_ast/css_ast.go b/internal/css_ast/css_ast.go index 84e06fd47cb..b938ce06f49 100644 --- a/internal/css_ast/css_ast.go +++ b/internal/css_ast/css_ast.go @@ -2,6 +2,7 @@ package css_ast import ( "strconv" + "strings" "github.com/evanw/esbuild/internal/ast" "github.com/evanw/esbuild/internal/css_lexer" @@ -329,7 +330,7 @@ func (t Token) DimensionUnit() string { } func (t Token) DimensionUnitIsSafeLength() bool { - switch t.DimensionUnit() { + switch strings.ToLower(t.DimensionUnit()) { // These units can be reasonably expected to be supported everywhere. // Information used: https://developer.mozilla.org/en-US/docs/Web/CSS/length case "cm", "em", "in", "mm", "pc", "pt", "px": @@ -348,7 +349,7 @@ func (t Token) IsOne() bool { func (t Token) IsAngle() bool { if t.Kind == css_lexer.TDimension { - unit := t.DimensionUnit() + unit := strings.ToLower(t.DimensionUnit()) return unit == "deg" || unit == "grad" || unit == "rad" || unit == "turn" } return false @@ -508,7 +509,7 @@ type KeyframeBlock struct { } func (a *RAtKeyframes) Equal(rule R, check *CrossFileEqualityCheck) bool { - if b, ok := rule.(*RAtKeyframes); ok && a.AtToken == b.AtToken && check.RefsAreEquivalent(a.Name.Ref, b.Name.Ref) && len(a.Blocks) == len(b.Blocks) { + if b, ok := rule.(*RAtKeyframes); ok && strings.EqualFold(a.AtToken, b.AtToken) && check.RefsAreEquivalent(a.Name.Ref, b.Name.Ref) && len(a.Blocks) == len(b.Blocks) { for i, ai := range a.Blocks { bi := b.Blocks[i] if len(ai.Selectors) != len(bi.Selectors) { @@ -551,7 +552,7 @@ type RKnownAt struct { func (a *RKnownAt) Equal(rule R, check *CrossFileEqualityCheck) bool { b, ok := rule.(*RKnownAt) - return ok && a.AtToken == b.AtToken && TokensEqual(a.Prelude, b.Prelude, check) && RulesEqual(a.Rules, b.Rules, check) + return ok && strings.EqualFold(a.AtToken, b.AtToken) && TokensEqual(a.Prelude, b.Prelude, check) && RulesEqual(a.Rules, b.Rules, check) } func (r *RKnownAt) Hash() (uint32, bool) { @@ -570,7 +571,7 @@ type RUnknownAt struct { func (a *RUnknownAt) Equal(rule R, check *CrossFileEqualityCheck) bool { b, ok := rule.(*RUnknownAt) - return ok && a.AtToken == b.AtToken && TokensEqual(a.Prelude, b.Prelude, check) && TokensEqual(a.Block, b.Block, check) + return ok && strings.EqualFold(a.AtToken, b.AtToken) && TokensEqual(a.Prelude, b.Prelude, check) && TokensEqual(a.Block, b.Block, check) } func (r *RUnknownAt) Hash() (uint32, bool) { diff --git a/internal/css_parser/css_decls.go b/internal/css_parser/css_decls.go index a18f86653db..8193dd02d3c 100644 --- a/internal/css_parser/css_decls.go +++ b/internal/css_parser/css_decls.go @@ -1,6 +1,8 @@ package css_parser import ( + "strings" + "github.com/evanw/esbuild/internal/compat" "github.com/evanw/esbuild/internal/css_ast" "github.com/evanw/esbuild/internal/css_lexer" @@ -385,13 +387,13 @@ func (p *parser) insertPrefixedDeclaration(rules []css_ast.Rule, prefix string, switch decl.Key { case css_ast.DBackgroundClip: // The prefix is only needed for "background-clip: text" - if len(decl.Value) != 1 || decl.Value[0].Kind != css_lexer.TIdent || decl.Value[0].Text != "text" { + if len(decl.Value) != 1 || decl.Value[0].Kind != css_lexer.TIdent || !strings.EqualFold(decl.Value[0].Text, "text") { return rules } case css_ast.DPosition: // The prefix is only needed for "position: sticky" - if len(decl.Value) != 1 || decl.Value[0].Kind != css_lexer.TIdent || decl.Value[0].Text != "sticky" { + if len(decl.Value) != 1 || decl.Value[0].Kind != css_lexer.TIdent || !strings.EqualFold(decl.Value[0].Text, "sticky") { return rules } } @@ -407,7 +409,7 @@ func (p *parser) insertPrefixedDeclaration(rules []css_ast.Rule, prefix string, case css_ast.DUserSelect: // The prefix applies to the value as well as the property - if prefix == "-moz-" && len(value) == 1 && value[0].Kind == css_lexer.TIdent && value[0].Text == "none" { + if prefix == "-moz-" && len(value) == 1 && value[0].Kind == css_lexer.TIdent && strings.EqualFold(value[0].Text, "none") { value[0].Text = "-moz-none" } } diff --git a/internal/css_parser/css_decls_box.go b/internal/css_parser/css_decls_box.go index bc6a4a1546c..9f7d7ec3ab2 100644 --- a/internal/css_parser/css_decls_box.go +++ b/internal/css_parser/css_decls_box.go @@ -1,6 +1,8 @@ package css_parser import ( + "strings" + "github.com/evanw/esbuild/internal/css_ast" "github.com/evanw/esbuild/internal/css_lexer" "github.com/evanw/esbuild/internal/logger" @@ -134,7 +136,7 @@ func (box *boxTracker) mangleSide(rules []css_ast.Rule, decl *css_ast.RDeclarati } if tokens := decl.Value; len(tokens) == 1 { - if t := tokens[0]; t.Kind.IsNumeric() || (t.Kind == css_lexer.TIdent && box.allowAuto && t.Text == "auto") { + if t := tokens[0]; t.Kind.IsNumeric() || (t.Kind == css_lexer.TIdent && box.allowAuto && strings.EqualFold(t.Text, "auto")) { unitSafety := unitSafetyTracker{} if !box.allowAuto || t.Kind.IsNumeric() { unitSafety.includeUnitOf(t) diff --git a/internal/css_parser/css_decls_box_shadow.go b/internal/css_parser/css_decls_box_shadow.go index a8385f20f58..47da50b8d93 100644 --- a/internal/css_parser/css_decls_box_shadow.go +++ b/internal/css_parser/css_decls_box_shadow.go @@ -1,6 +1,8 @@ package css_parser import ( + "strings" + "github.com/evanw/esbuild/internal/css_ast" "github.com/evanw/esbuild/internal/css_lexer" ) @@ -36,7 +38,7 @@ func (p *parser) mangleBoxShadow(tokens []css_ast.Token) []css_ast.Token { if hex, ok := parseColor(t); ok { colorCount++ tokens[i] = p.mangleColor(t, hex) - } else if t.Kind == css_lexer.TIdent && t.Text == "inset" { + } else if t.Kind == css_lexer.TIdent && strings.EqualFold(t.Text, "inset") { insetCount++ } else { // Track if we found a token other than a number, a color, or "inset" diff --git a/internal/css_parser/css_decls_composes.go b/internal/css_parser/css_decls_composes.go index 5890d191942..2b8aa0aa55e 100644 --- a/internal/css_parser/css_decls_composes.go +++ b/internal/css_parser/css_decls_composes.go @@ -2,6 +2,7 @@ package css_parser import ( "fmt" + "strings" "github.com/evanw/esbuild/internal/ast" "github.com/evanw/esbuild/internal/css_ast" @@ -26,7 +27,7 @@ func (p *parser) handleComposesPragma(context composesContext, tokens []css_ast. for i, t := range tokens { if t.Kind == css_lexer.TIdent { // Check for a "from" clause at the end - if t.Text == "from" && i+2 == len(tokens) { + if strings.EqualFold(t.Text, "from") && i+2 == len(tokens) { last := tokens[i+1] // A string or a URL is an external file @@ -58,7 +59,7 @@ func (p *parser) handleComposesPragma(context composesContext, tokens []css_ast. // An identifier must be "global" if last.Kind == css_lexer.TIdent { - if last.Text == "global" { + if strings.EqualFold(last.Text, "global") { fromGlobal = true break } diff --git a/internal/css_parser/css_parser.go b/internal/css_parser/css_parser.go index ebbd88726cb..ba84c8f177e 100644 --- a/internal/css_parser/css_parser.go +++ b/internal/css_parser/css_parser.go @@ -657,7 +657,7 @@ next: // } // // Which can then be mangled further. - if r.AtToken == "media" { + if strings.EqualFold(r.AtToken, "media") { for _, prelude := range p.enclosingAtMedia { if css_ast.TokensEqualIgnoringWhitespace(r.Prelude, prelude) { mangledRules = append(mangledRules, r.Rules...) @@ -972,7 +972,7 @@ func (p *parser) parseURLOrString() (string, logger.Range, bool) { return text, t.Range, true case css_lexer.TFunction: - if p.decoded() == "url" { + if strings.EqualFold(p.decoded(), "url") { matchingLoc := logger.Loc{Start: p.current().Range.End() - 1} p.advance() t = p.current() @@ -1145,13 +1145,14 @@ func (p *parser) parseAtRule(context atRuleContext) css_ast.Rule { // Parse the name atToken := p.decoded() atRange := p.current().Range - kind := specialAtRules[atToken] + lowerAtToken := strings.ToLower(atToken) + kind := specialAtRules[lowerAtToken] p.advance() // Parse the prelude preludeStart := p.index abortRuleParser: - switch atToken { + switch lowerAtToken { case "charset": switch context.charsetValidity { case atRuleInvalid: @@ -1214,14 +1215,14 @@ abortRuleParser: importConditions = &conditions // Handle "layer()" - if t := conditions.Media[0]; (t.Kind == css_lexer.TIdent || t.Kind == css_lexer.TFunction) && t.Text == "layer" { + if t := conditions.Media[0]; (t.Kind == css_lexer.TIdent || t.Kind == css_lexer.TFunction) && strings.EqualFold(t.Text, "layer") { conditions.Layers = conditions.Media[:1] conditions.Media = conditions.Media[1:] } // Handle "supports()" if len(conditions.Media) > 0 { - if t := conditions.Media[0]; t.Kind == css_lexer.TFunction && t.Text == "supports" { + if t := conditions.Media[0]; t.Kind == css_lexer.TFunction && strings.EqualFold(t.Text, "supports") { conditions.Supports = conditions.Media[:1] conditions.Media = conditions.Media[1:] } @@ -1368,11 +1369,11 @@ abortRuleParser: } text := p.decoded() if t.Kind == css_lexer.TIdent { - if text == "from" { + if strings.EqualFold(text, "from") { if p.options.minifySyntax { text = "0%" // "0%" is equivalent to but shorter than "from" } - } else if text != "to" { + } else if !strings.EqualFold(text, "to") { p.expect(css_lexer.TPercentage) } } else if p.options.minifySyntax && text == "100%" { @@ -1505,7 +1506,7 @@ abortRuleParser: } default: - if kind == atRuleUnknown && atToken == "namespace" { + if kind == atRuleUnknown && lowerAtToken == "namespace" { // CSS namespaces are a weird feature that appears to only really be // useful for styling XML. And the world has moved on from XHTML to // HTML5 so pretty much no one uses CSS namespaces anymore. They are @@ -1579,7 +1580,7 @@ prelude: } // Handle local names for "@counter-style" - if len(prelude) == 1 && atToken == "counter-style" { + if len(prelude) == 1 && lowerAtToken == "counter-style" { if t := &prelude[0]; t.Kind == css_lexer.TIdent { t.Kind = css_lexer.TSymbol t.PayloadIndex = p.symbolForName(t.Loc, t.Text).Ref.InnerIndex @@ -1595,7 +1596,7 @@ prelude: var rules []css_ast.Rule // Push the "@media" conditions - isAtMedia := atToken == "media" + isAtMedia := lowerAtToken == "media" if isAtMedia { p.enclosingAtMedia = append(p.enclosingAtMedia, prelude) } @@ -1622,7 +1623,7 @@ prelude: } // Handle local names for "@container" - if len(prelude) >= 1 && atToken == "container" { + if len(prelude) >= 1 && lowerAtToken == "container" { if t := &prelude[0]; t.Kind == css_lexer.TIdent && strings.ToLower(t.Text) != "not" { t.Kind = css_lexer.TSymbol t.PayloadIndex = p.symbolForName(t.Loc, t.Text).Ref.InnerIndex @@ -1808,23 +1809,23 @@ loop: var nested []css_ast.Token original := tokens nestedOpts := opts - if token.Text == "var" { + if strings.EqualFold(token.Text, "var") { // CSS variables require verbatim whitespace for correctness nestedOpts.verbatimWhitespace = true } - if token.Text == "calc" { + if strings.EqualFold(token.Text, "calc") { nestedOpts.isInsideCalcFunction = true } nested, tokens = p.convertTokensHelper(tokens, css_lexer.TCloseParen, nestedOpts) token.Children = &nested // Apply "calc" simplification rules when minifying - if p.options.minifySyntax && token.Text == "calc" { + if p.options.minifySyntax && strings.EqualFold(token.Text, "calc") { token = p.tryToReduceCalcExpression(token) } // Treat a URL function call with a string just like a URL token - if token.Text == "url" && len(nested) == 1 && nested[0].Kind == css_lexer.TString { + if strings.EqualFold(token.Text, "url") && len(nested) == 1 && nested[0].Kind == css_lexer.TString { token.Kind = css_lexer.TURL token.Text = "" token.Children = nil @@ -2295,11 +2296,12 @@ stop: } } - key := css_ast.KnownDeclarations[keyText] + lowerKeyText := strings.ToLower(keyText) + key := css_ast.KnownDeclarations[lowerKeyText] // Attempt to point out trivial typos if key == css_ast.DUnknown { - if corrected, ok := css_ast.MaybeCorrectDeclarationTypo(keyText); ok { + if corrected, ok := css_ast.MaybeCorrectDeclarationTypo(lowerKeyText); ok { data := p.tracker.MsgData(keyToken.Range, fmt.Sprintf("%q is not a known CSS property", keyText)) data.Location.Suggestion = corrected p.log.AddMsgID(logger.MsgID_CSS_UnsupportedCSSProperty, logger.Msg{Kind: logger.Warning, Data: data, diff --git a/internal/css_parser/css_parser_selector.go b/internal/css_parser/css_parser_selector.go index e2a39a32c93..342e872bfdc 100644 --- a/internal/css_parser/css_parser_selector.go +++ b/internal/css_parser/css_parser_selector.go @@ -654,7 +654,7 @@ func (p *parser) parsePseudoClassSelector(loc logger.Loc, isElement bool) (css_a // Parse the optional "of" clause if (kind == css_ast.PseudoClassNthChild || kind == css_ast.PseudoClassNthLastChild) && - p.peek(css_lexer.TIdent) && p.decoded() == "of" { + p.peek(css_lexer.TIdent) && strings.EqualFold(p.decoded(), "of") { p.advance() p.eat(css_lexer.TWhitespace) diff --git a/internal/css_parser/css_reduce_calc.go b/internal/css_parser/css_reduce_calc.go index 4b134aecc1e..5af3902a8b0 100644 --- a/internal/css_parser/css_reduce_calc.go +++ b/internal/css_parser/css_reduce_calc.go @@ -317,7 +317,7 @@ func (c *calcSum) partiallySimplify() calcTerm { end := i + 1 for j := end; j < len(terms); j++ { term2 := terms[j] - if numeric2, ok := term2.data.(*calcNumeric); ok && numeric2.unit == numeric.unit { + if numeric2, ok := term2.data.(*calcNumeric); ok && strings.EqualFold(numeric2.unit, numeric.unit) { numeric.number += numeric2.number } else { terms[end] = term2 @@ -471,10 +471,10 @@ func tryToParseCalcTerm(tokens []css_ast.Token) calcTerm { for i, token := range tokens { var term calcTerm - if token.Kind == css_lexer.TFunction && token.Text == "var" { + if token.Kind == css_lexer.TFunction && strings.EqualFold(token.Text, "var") { // Using "var()" should bail because it can expand to any number of tokens return nil - } else if token.Kind == css_lexer.TOpenParen || (token.Kind == css_lexer.TFunction && token.Text == "calc") { + } else if token.Kind == css_lexer.TOpenParen || (token.Kind == css_lexer.TFunction && strings.EqualFold(token.Text, "calc")) { term = tryToParseCalcTerm(*token.Children) if term == nil { return nil