Skip to content

Commit

Permalink
fix #3316: make the css parser more case-agnostic
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Sep 13, 2023
1 parent 8d52aaf commit e48baa3
Show file tree
Hide file tree
Showing 11 changed files with 132 additions and 34 deletions.
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions internal/bundler_tests/bundler_css_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
})
}
28 changes: 28 additions & 0 deletions internal/bundler_tests/snapshots/snapshots_css.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------
Expand Down
11 changes: 6 additions & 5 deletions internal/css_ast/css_ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package css_ast

import (
"strconv"
"strings"

"github.com/evanw/esbuild/internal/ast"
"github.com/evanw/esbuild/internal/css_lexer"
Expand Down Expand Up @@ -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":
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
8 changes: 5 additions & 3 deletions internal/css_parser/css_decls.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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"
}
}
Expand Down
4 changes: 3 additions & 1 deletion internal/css_parser/css_decls_box.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion internal/css_parser/css_decls_box_shadow.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package css_parser

import (
"strings"

"github.com/evanw/esbuild/internal/css_ast"
"github.com/evanw/esbuild/internal/css_lexer"
)
Expand Down Expand Up @@ -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"
Expand Down
5 changes: 3 additions & 2 deletions internal/css_parser/css_decls_composes.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package css_parser

import (
"fmt"
"strings"

"github.com/evanw/esbuild/internal/ast"
"github.com/evanw/esbuild/internal/css_ast"
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
38 changes: 20 additions & 18 deletions internal/css_parser/css_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:]
}
Expand Down Expand Up @@ -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%" {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit e48baa3

Please sign in to comment.