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" "github.com/evanw/esbuild/internal/logger" ) // Reference: https://drafts.csswg.org/mediaqueries-4/ func (p *parser) parseMediaQueryListUntil(stop func(css_lexer.T) bool) []css_ast.MediaQuery { var queries []css_ast.MediaQuery p.eat(css_lexer.TWhitespace) for !p.peek(css_lexer.TEndOfFile) && !stop(p.current().Kind) { start := p.index query, ok := p.parseMediaQuery() if !ok { // If parsing failed, parse an arbitrary sequence of tokens instead p.index = start loc := p.current().Range.Loc for !p.peek(css_lexer.TEndOfFile) && !stop(p.current().Kind) && !p.peek(css_lexer.TComma) { p.parseComponentValue() } tokens := p.convertTokens(p.tokens[start:p.index]) query = css_ast.MediaQuery{Loc: loc, Data: &css_ast.MQArbitraryTokens{Tokens: tokens}} } queries = append(queries, query) p.eat(css_lexer.TWhitespace) if !p.eat(css_lexer.TComma) { break } p.eat(css_lexer.TWhitespace) } return queries } func (p *parser) parseMediaQuery() (css_ast.MediaQuery, bool) { loc := p.current().Range.Loc // Check for a media condition first if p.looksLikeMediaCondition() { return p.parseMediaCondition(mediaWithOr) } // Parse the media type and potentially the leading "not" or "only" keyword mediaType := p.decoded() if !p.peek(css_lexer.TIdent) { p.expect(css_lexer.TIdent) return css_ast.MediaQuery{}, false } op := css_ast.MQTypeOpNone if strings.EqualFold(mediaType, "not") { op = css_ast.MQTypeOpNot } else if strings.EqualFold(mediaType, "only") { op = css_ast.MQTypeOpOnly } if op != css_ast.MQTypeOpNone { p.advance() p.eat(css_lexer.TWhitespace) mediaType = p.decoded() if !p.peek(css_lexer.TIdent) { p.expect(css_lexer.TIdent) return css_ast.MediaQuery{}, false } } // The production does not include the keywords "only", "not", "and", "or", and "layer". if strings.EqualFold(mediaType, "only") || strings.EqualFold(mediaType, "not") || strings.EqualFold(mediaType, "and") || strings.EqualFold(mediaType, "or") || strings.EqualFold(mediaType, "layer") { p.unexpected() return css_ast.MediaQuery{}, false } p.advance() p.eat(css_lexer.TWhitespace) // Potentially parse a chain of "and" operators var andOrNull css_ast.MediaQuery if p.peek(css_lexer.TIdent) && strings.EqualFold(p.decoded(), "and") { p.advance() p.eat(css_lexer.TWhitespace) var ok bool andOrNull, ok = p.parseMediaCondition(mediaWithoutOr) if !ok { return css_ast.MediaQuery{}, false } } return css_ast.MediaQuery{Loc: loc, Data: &css_ast.MQType{Op: op, Type: mediaType, AndOrNull: andOrNull}}, true } func (p *parser) looksLikeMediaCondition() bool { kind := p.current().Kind return kind == css_lexer.TOpenParen || kind == css_lexer.TFunction || (kind == css_lexer.TIdent && strings.EqualFold(p.decoded(), "not") && p.next().Kind == css_lexer.TWhitespace && p.at(p.index+2).Kind == css_lexer.TOpenParen) } type mediaOr uint8 const ( mediaWithOr mediaOr = iota mediaWithoutOr ) func (p *parser) parseMediaCondition(or mediaOr) (css_ast.MediaQuery, bool) { loc := p.current().Range.Loc // Handle a leading "not" if p.peek(css_lexer.TIdent) && strings.EqualFold(p.decoded(), "not") { p.advance() p.eat(css_lexer.TWhitespace) if inner, ok := p.parseMediaInParens(); !ok { return css_ast.MediaQuery{}, false } else { return p.maybeSimplifyMediaNot(loc, inner), true } } // Parse the first term first, ok := p.parseMediaInParens() if !ok { return css_ast.MediaQuery{}, false } p.eat(css_lexer.TWhitespace) // Potentially parse a chain of "and" or "or" operators if p.peek(css_lexer.TIdent) { if keyword := p.decoded(); strings.EqualFold(keyword, "and") || (or == mediaWithOr && strings.EqualFold(keyword, "or")) { op := css_ast.MQBinaryOpAnd if len(keyword) == 2 { op = css_ast.MQBinaryOpOr } inner := p.appendMediaTerm([]css_ast.MediaQuery{}, first, op) for { p.advance() p.eat(css_lexer.TWhitespace) next, ok := p.parseMediaInParens() if !ok { return css_ast.MediaQuery{}, false } inner = p.appendMediaTerm(inner, next, op) p.eat(css_lexer.TWhitespace) if !p.peek(css_lexer.TIdent) || !strings.EqualFold(p.decoded(), keyword) { break } } return css_ast.MediaQuery{Loc: loc, Data: &css_ast.MQBinary{Op: op, Terms: inner}}, true } } return first, true } func (p *parser) appendMediaTerm(inner []css_ast.MediaQuery, term css_ast.MediaQuery, op css_ast.MQBinaryOp) []css_ast.MediaQuery { // "(a and b) and c" => "a and b and c" // "(a or b) or c" => "a or b or c" if binary, ok := term.Data.(*css_ast.MQBinary); ok && binary.Op == op && p.options.minifySyntax { return append(inner, binary.Terms...) } else { return append(inner, term) } } func (p *parser) parseMediaInParens() (css_ast.MediaQuery, bool) { p.eat(css_lexer.TWhitespace) start := p.index // Consume the opening token isFunction := p.eat(css_lexer.TFunction) if !isFunction && !p.expect(css_lexer.TOpenParen) { return css_ast.MediaQuery{}, false } p.eat(css_lexer.TWhitespace) // Handle a media condition if !isFunction && p.looksLikeMediaCondition() { if inner, ok := p.parseMediaCondition(mediaWithOr); !ok { return css_ast.MediaQuery{}, false } else { p.eat(css_lexer.TWhitespace) if !p.expect(css_lexer.TCloseParen) { return css_ast.MediaQuery{}, false } return inner, ok } } // Scan over the remaining tokens for !p.peek(css_lexer.TCloseParen) && !p.peek(css_lexer.TEndOfFile) { p.parseComponentValue() } end := p.index if !p.expect(css_lexer.TCloseParen) { return css_ast.MediaQuery{}, false } tokens := p.convertTokens(p.tokens[start:end]) loc := tokens[0].Loc // Potentially pattern-match the tokens inside the parentheses if !isFunction && len(tokens) == 1 { if children := tokens[0].Children; children != nil { if term, ok := parsePlainOrBooleanMediaFeature(*children); ok { return css_ast.MediaQuery{Loc: loc, Data: term}, true } if term, ok := parseRangeMediaFeature(*children); ok { if p.options.unsupportedCSSFeatures.Has(compat.MediaRange) { var terms []css_ast.MediaQuery if term.BeforeCmp != css_ast.MQCmpNone { terms = append(terms, lowerMediaRange(term.NameLoc, term.Name, term.BeforeCmp.Reverse(), term.Before)) } if term.AfterCmp != css_ast.MQCmpNone { terms = append(terms, lowerMediaRange(term.NameLoc, term.Name, term.AfterCmp, term.After)) } if len(terms) == 1 { return terms[0], true } else { return css_ast.MediaQuery{Loc: loc, Data: &css_ast.MQBinary{Op: css_ast.MQBinaryOpAnd, Terms: terms}}, true } } return css_ast.MediaQuery{Loc: loc, Data: term}, true } } } return css_ast.MediaQuery{Loc: loc, Data: &css_ast.MQArbitraryTokens{Tokens: tokens}}, true } func lowerMediaRange(loc logger.Loc, name string, cmp css_ast.MQCmp, value []css_ast.Token) css_ast.MediaQuery { switch cmp { case css_ast.MQCmpLe: // "foo <= 123" => "max-foo: 123" return css_ast.MediaQuery{Loc: loc, Data: &css_ast.MQPlainOrBoolean{Name: "max-" + name, ValueOrNil: value}} case css_ast.MQCmpGe: // "foo >= 123" => "min-foo: 123" return css_ast.MediaQuery{Loc: loc, Data: &css_ast.MQPlainOrBoolean{Name: "min-" + name, ValueOrNil: value}} case css_ast.MQCmpLt: // "foo < 123" => "not (min-foo: 123)" return css_ast.MediaQuery{Loc: loc, Data: &css_ast.MQNot{ Inner: css_ast.MediaQuery{Loc: loc, Data: &css_ast.MQPlainOrBoolean{Name: "min-" + name, ValueOrNil: value}}, }} case css_ast.MQCmpGt: // "foo > 123" => "not (max-foo: 123)" return css_ast.MediaQuery{Loc: loc, Data: &css_ast.MQNot{ Inner: css_ast.MediaQuery{Loc: loc, Data: &css_ast.MQPlainOrBoolean{Name: "max-" + name, ValueOrNil: value}}, }} default: // "foo = 123" => "foo: 123" return css_ast.MediaQuery{Loc: loc, Data: &css_ast.MQPlainOrBoolean{Name: name, ValueOrNil: value}} } } func parsePlainOrBooleanMediaFeature(tokens []css_ast.Token) (*css_ast.MQPlainOrBoolean, bool) { if len(tokens) == 1 && tokens[0].Kind == css_lexer.TIdent { return &css_ast.MQPlainOrBoolean{Name: tokens[0].Text}, true } if len(tokens) >= 3 && tokens[0].Kind == css_lexer.TIdent && tokens[1].Kind == css_lexer.TColon { if value, rest := scanMediaValue(tokens[2:]); len(rest) == 0 { return &css_ast.MQPlainOrBoolean{Name: tokens[0].Text, ValueOrNil: value}, true } } return nil, false } func parseRangeMediaFeature(tokens []css_ast.Token) (*css_ast.MQRange, bool) { if first, tokens := scanMediaValue(tokens); len(first) > 0 { if firstCmp, tokens := scanMediaComparison(tokens); firstCmp != css_ast.MQCmpNone { if second, tokens := scanMediaValue(tokens); len(second) > 0 { if len(tokens) == 0 { if name, nameLoc, ok := isSingleIdent(first); ok { return &css_ast.MQRange{ Name: name, NameLoc: nameLoc, AfterCmp: firstCmp, After: second, }, true } else if name, nameLoc, ok := isSingleIdent(second); ok { return &css_ast.MQRange{ Before: first, BeforeCmp: firstCmp, Name: name, NameLoc: nameLoc, }, true } } else if name, nameLoc, ok := isSingleIdent(second); ok { if secondCmp, tokens := scanMediaComparison(tokens); secondCmp != css_ast.MQCmpNone { if f, s := firstCmp.Dir(), secondCmp.Dir(); (f < 0 && s < 0) || (f > 0 && s > 0) { if third, tokens := scanMediaValue(tokens); len(third) > 0 && len(tokens) == 0 { return &css_ast.MQRange{ Before: first, BeforeCmp: firstCmp, Name: name, NameLoc: nameLoc, AfterCmp: secondCmp, After: third, }, true } } } } } } } return nil, false } func (p *parser) maybeSimplifyMediaNot(loc logger.Loc, inner css_ast.MediaQuery) css_ast.MediaQuery { if p.options.minifySyntax { switch data := inner.Data.(type) { case *css_ast.MQNot: // "not (not a)" => "a" // "not (not (not a))" => "not a" return data.Inner case *css_ast.MQBinary: // "not ((not a) and (not b))" => "a or b" // "not ((not a) or (not b))" => "a and b" terms := make([]css_ast.MediaQuery, 0, len(data.Terms)) for _, term := range data.Terms { if not, ok := term.Data.(*css_ast.MQNot); ok { terms = append(terms, not.Inner) } else { break } } if len(terms) == len(data.Terms) { data.Op ^= 1 data.Terms = terms return inner } case *css_ast.MQRange: if (data.BeforeCmp == css_ast.MQCmpNone && data.AfterCmp != css_ast.MQCmpEq) || (data.AfterCmp == css_ast.MQCmpNone && data.BeforeCmp != css_ast.MQCmpEq) { data.BeforeCmp = data.BeforeCmp.Flip() data.AfterCmp = data.AfterCmp.Flip() return inner } } } return css_ast.MediaQuery{Loc: loc, Data: &css_ast.MQNot{Inner: inner}} } func isSingleIdent(tokens []css_ast.Token) (string, logger.Loc, bool) { if len(tokens) == 1 && tokens[0].Kind == css_lexer.TIdent { return tokens[0].Text, tokens[0].Loc, true } else { return "", logger.Loc{}, false } } func scanMediaComparison(tokens []css_ast.Token) (css_ast.MQCmp, []css_ast.Token) { if len(tokens) >= 1 { switch tokens[0].Kind { case css_lexer.TDelimEquals: return css_ast.MQCmpEq, tokens[1:] case css_lexer.TDelimLessThan: // Handle "<=" or "<" if len(tokens) >= 2 && tokens[1].Kind == css_lexer.TDelimEquals && ((tokens[0].Whitespace&css_ast.WhitespaceAfter)|(tokens[1].Whitespace&css_ast.WhitespaceBefore)) == 0 { return css_ast.MQCmpLe, tokens[2:] } return css_ast.MQCmpLt, tokens[1:] case css_lexer.TDelimGreaterThan: // Handle ">=" or ">" if len(tokens) >= 2 && tokens[1].Kind == css_lexer.TDelimEquals && ((tokens[0].Whitespace&css_ast.WhitespaceAfter)|(tokens[1].Whitespace&css_ast.WhitespaceBefore)) == 0 { return css_ast.MQCmpGe, tokens[2:] } return css_ast.MQCmpGt, tokens[1:] } } return css_ast.MQCmpNone, tokens } func scanMediaValue(tokens []css_ast.Token) ([]css_ast.Token, []css_ast.Token) { n := 0 if len(tokens) >= 1 { switch tokens[0].Kind { case css_lexer.TDimension, css_lexer.TIdent: n = 1 case css_lexer.TNumber: // Potentially recognize a ratio which is " / " if len(tokens) >= 3 && tokens[1].Kind == css_lexer.TDelimSlash && tokens[2].Kind == css_lexer.TNumber { n = 3 } else { n = 1 } } } // Trim whitespace at the endpoints if n > 0 { tokens[0].Whitespace &= ^css_ast.WhitespaceBefore tokens[n-1].Whitespace &= ^css_ast.WhitespaceAfter } return tokens[:n], tokens[n:] }