package css_parser import ( "fmt" "strings" "github.com/evanw/esbuild/internal/ast" "github.com/evanw/esbuild/internal/compat" "github.com/evanw/esbuild/internal/config" "github.com/evanw/esbuild/internal/css_ast" "github.com/evanw/esbuild/internal/css_lexer" "github.com/evanw/esbuild/internal/logger" ) // This is mostly a normal CSS parser with one exception: the addition of // support for parsing https://drafts.csswg.org/css-nesting-1/. type parser struct { log logger.Log source logger.Source tokens []css_lexer.Token allComments []logger.Range legalComments []css_lexer.Comment stack []css_lexer.T importRecords []ast.ImportRecord symbols []ast.Symbol composes map[ast.Ref]*css_ast.Composes localSymbols []ast.LocRef localScope map[string]ast.LocRef globalScope map[string]ast.LocRef nestingWarnings map[logger.Loc]struct{} tracker logger.LineColumnTracker enclosingAtMedia [][]css_ast.MediaQuery layersPreImport [][]string layersPostImport [][]string enclosingLayer []string anonLayerCount int index int legalCommentIndex int inSelectorSubtree int prevError logger.Loc options Options nestingIsPresent bool makeLocalSymbols bool hasSeenAtImport bool } type Options struct { cssPrefixData map[css_ast.D]compat.CSSPrefix // This is an embedded struct. Always access these directly instead of off // the name "optionsThatSupportStructuralEquality". This is only grouped like // this to make the equality comparison easier and safer (and hopefully faster). optionsThatSupportStructuralEquality } type symbolMode uint8 const ( symbolModeDisabled symbolMode = iota symbolModeGlobal symbolModeLocal ) type optionsThatSupportStructuralEquality struct { originalTargetEnv string unsupportedCSSFeatures compat.CSSFeature minifySyntax bool minifyWhitespace bool minifyIdentifiers bool symbolMode symbolMode } func OptionsFromConfig(loader config.Loader, options *config.Options) Options { var symbolMode symbolMode switch loader { case config.LoaderGlobalCSS: symbolMode = symbolModeGlobal case config.LoaderLocalCSS: symbolMode = symbolModeLocal } return Options{ cssPrefixData: options.CSSPrefixData, optionsThatSupportStructuralEquality: optionsThatSupportStructuralEquality{ minifySyntax: options.MinifySyntax, minifyWhitespace: options.MinifyWhitespace, minifyIdentifiers: options.MinifyIdentifiers, unsupportedCSSFeatures: options.UnsupportedCSSFeatures, originalTargetEnv: options.OriginalTargetEnv, symbolMode: symbolMode, }, } } func (a *Options) Equal(b *Options) bool { // Compare "optionsThatSupportStructuralEquality" if a.optionsThatSupportStructuralEquality != b.optionsThatSupportStructuralEquality { return false } // Compare "cssPrefixData" if len(a.cssPrefixData) != len(b.cssPrefixData) { return false } for k, va := range a.cssPrefixData { vb, ok := b.cssPrefixData[k] if !ok || va != vb { return false } } for k := range b.cssPrefixData { if _, ok := b.cssPrefixData[k]; !ok { return false } } return true } func Parse(log logger.Log, source logger.Source, options Options) css_ast.AST { result := css_lexer.Tokenize(log, source, css_lexer.Options{ RecordAllComments: options.minifyIdentifiers, }) p := parser{ log: log, source: source, tracker: logger.MakeLineColumnTracker(&source), options: options, tokens: result.Tokens, allComments: result.AllComments, legalComments: result.LegalComments, prevError: logger.Loc{Start: -1}, composes: make(map[ast.Ref]*css_ast.Composes), localScope: make(map[string]ast.LocRef), globalScope: make(map[string]ast.LocRef), makeLocalSymbols: options.symbolMode == symbolModeLocal, } rules := p.parseListOfRules(ruleContext{ isTopLevel: true, parseSelectors: true, }) p.expect(css_lexer.TEndOfFile) return css_ast.AST{ Rules: rules, CharFreq: p.computeCharacterFrequency(), Symbols: p.symbols, ImportRecords: p.importRecords, ApproximateLineCount: result.ApproximateLineCount, SourceMapComment: result.SourceMapComment, LocalSymbols: p.localSymbols, LocalScope: p.localScope, GlobalScope: p.globalScope, Composes: p.composes, LayersPreImport: p.layersPreImport, LayersPostImport: p.layersPostImport, } } // Compute a character frequency histogram for everything that's not a bound // symbol. This is used to modify how minified names are generated for slightly // better gzip compression. Even though it's a very small win, we still do it // because it's simple to do and very cheap to compute. func (p *parser) computeCharacterFrequency() *ast.CharFreq { if !p.options.minifyIdentifiers { return nil } // Add everything in the file to the histogram charFreq := &ast.CharFreq{} charFreq.Scan(p.source.Contents, 1) // Subtract out all comments for _, commentRange := range p.allComments { charFreq.Scan(p.source.TextForRange(commentRange), -1) } // Subtract out all import paths for _, record := range p.importRecords { if !record.SourceIndex.IsValid() { charFreq.Scan(record.Path.Text, -1) } } // Subtract out all symbols that will be minified for _, symbol := range p.symbols { if symbol.Kind == ast.SymbolLocalCSS { charFreq.Scan(symbol.OriginalName, -int32(symbol.UseCountEstimate)) } } return charFreq } func (p *parser) advance() { if p.index < len(p.tokens) { p.index++ } } func (p *parser) at(index int) css_lexer.Token { if index < len(p.tokens) { return p.tokens[index] } return css_lexer.Token{ Kind: css_lexer.TEndOfFile, Range: logger.Range{Loc: logger.Loc{Start: int32(len(p.source.Contents))}}, } } func (p *parser) current() css_lexer.Token { return p.at(p.index) } func (p *parser) next() css_lexer.Token { return p.at(p.index + 1) } func (p *parser) raw() string { t := p.current() return p.source.Contents[t.Range.Loc.Start:t.Range.End()] } func (p *parser) decoded() string { return p.current().DecodedText(p.source.Contents) } func (p *parser) peek(kind css_lexer.T) bool { return kind == p.current().Kind } func (p *parser) eat(kind css_lexer.T) bool { if p.peek(kind) { p.advance() return true } return false } func (p *parser) expect(kind css_lexer.T) bool { return p.expectWithMatchingLoc(kind, logger.Loc{Start: -1}) } func (p *parser) expectWithMatchingLoc(kind css_lexer.T, matchingLoc logger.Loc) bool { if p.eat(kind) { return true } t := p.current() if (t.Flags & css_lexer.DidWarnAboutSingleLineComment) != 0 { return false } var text string var suggestion string var notes []logger.MsgData expected := kind.String() if strings.HasPrefix(expected, "\"") && strings.HasSuffix(expected, "\"") { suggestion = expected[1 : len(expected)-1] } if (kind == css_lexer.TSemicolon || kind == css_lexer.TColon) && p.index > 0 && p.at(p.index-1).Kind == css_lexer.TWhitespace { // Have a nice error message for forgetting a trailing semicolon or colon text = fmt.Sprintf("Expected %s", expected) t = p.at(p.index - 1) } else if (kind == css_lexer.TCloseBrace || kind == css_lexer.TCloseBracket || kind == css_lexer.TCloseParen) && matchingLoc.Start != -1 && int(matchingLoc.Start)+1 <= len(p.source.Contents) { // Have a nice error message for forgetting a closing brace/bracket/parenthesis c := p.source.Contents[matchingLoc.Start : matchingLoc.Start+1] text = fmt.Sprintf("Expected %s to go with %q", expected, c) notes = append(notes, p.tracker.MsgData(logger.Range{Loc: matchingLoc, Len: 1}, fmt.Sprintf("The unbalanced %q is here:", c))) } else { switch t.Kind { case css_lexer.TEndOfFile, css_lexer.TWhitespace: text = fmt.Sprintf("Expected %s but found %s", expected, t.Kind.String()) t.Range.Len = 0 case css_lexer.TBadURL, css_lexer.TUnterminatedString: text = fmt.Sprintf("Expected %s but found %s", expected, t.Kind.String()) default: text = fmt.Sprintf("Expected %s but found %q", expected, p.raw()) } } if t.Range.Loc.Start > p.prevError.Start { data := p.tracker.MsgData(t.Range, text) data.Location.Suggestion = suggestion p.log.AddMsgID(logger.MsgID_CSS_CSSSyntaxError, logger.Msg{Kind: logger.Warning, Data: data, Notes: notes}) p.prevError = t.Range.Loc } return false } func (p *parser) unexpected() { if t := p.current(); t.Range.Loc.Start > p.prevError.Start && (t.Flags&css_lexer.DidWarnAboutSingleLineComment) == 0 { var text string switch t.Kind { case css_lexer.TEndOfFile, css_lexer.TWhitespace: text = fmt.Sprintf("Unexpected %s", t.Kind.String()) t.Range.Len = 0 case css_lexer.TBadURL, css_lexer.TUnterminatedString: text = fmt.Sprintf("Unexpected %s", t.Kind.String()) default: text = fmt.Sprintf("Unexpected %q", p.raw()) } p.log.AddID(logger.MsgID_CSS_CSSSyntaxError, logger.Warning, &p.tracker, t.Range, text) p.prevError = t.Range.Loc } } func (p *parser) symbolForName(loc logger.Loc, name string) ast.LocRef { var kind ast.SymbolKind var scope map[string]ast.LocRef if p.makeLocalSymbols { kind = ast.SymbolLocalCSS scope = p.localScope } else { kind = ast.SymbolGlobalCSS scope = p.globalScope } entry, ok := scope[name] if !ok { entry = ast.LocRef{ Loc: loc, Ref: ast.Ref{ SourceIndex: p.source.Index, InnerIndex: uint32(len(p.symbols)), }, } p.symbols = append(p.symbols, ast.Symbol{ Kind: kind, OriginalName: name, Link: ast.InvalidRef, }) scope[name] = entry if kind == ast.SymbolLocalCSS { p.localSymbols = append(p.localSymbols, entry) } } p.symbols[entry.Ref.InnerIndex].UseCountEstimate++ return entry } func (p *parser) recordAtLayerRule(layers [][]string) { if p.anonLayerCount > 0 { return } for _, layer := range layers { if len(p.enclosingLayer) > 0 { clone := make([]string, 0, len(p.enclosingLayer)+len(layer)) layer = append(append(clone, p.enclosingLayer...), layer...) } p.layersPostImport = append(p.layersPostImport, layer) } } type ruleContext struct { isTopLevel bool parseSelectors bool } func (p *parser) parseListOfRules(context ruleContext) []css_ast.Rule { atRuleContext := atRuleContext{} if context.isTopLevel { atRuleContext.charsetValidity = atRuleValid atRuleContext.importValidity = atRuleValid atRuleContext.isTopLevel = true } rules := []css_ast.Rule{} didFindAtImport := false loop: for { if context.isTopLevel { p.nestingIsPresent = 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) { comment := p.legalComments[p.legalCommentIndex] if comment.TokenIndexAfter > uint32(p.index) { break } if comment.TokenIndexAfter == uint32(p.index) { rules = append(rules, css_ast.Rule{Loc: comment.Loc, Data: &css_ast.RComment{Text: comment.Text}}) } p.legalCommentIndex++ } switch p.current().Kind { case css_lexer.TEndOfFile: break loop case css_lexer.TCloseBrace: if !context.isTopLevel { break loop } case css_lexer.TWhitespace: p.advance() continue case css_lexer.TAtKeyword: rule := p.parseAtRule(atRuleContext) // Disallow "@charset" and "@import" after other rules if context.isTopLevel { switch r := rule.Data.(type) { case *css_ast.RAtCharset: // This doesn't invalidate anything because it always comes first case *css_ast.RAtImport: didFindAtImport = true if atRuleContext.charsetValidity == atRuleValid { atRuleContext.afterLoc = rule.Loc atRuleContext.charsetValidity = atRuleInvalidAfter } case *css_ast.RAtLayer: if atRuleContext.charsetValidity == atRuleValid { atRuleContext.afterLoc = rule.Loc atRuleContext.charsetValidity = atRuleInvalidAfter } // From the specification: "Note: No @layer rules are allowed between // @import and @namespace rules. Any @layer rule that comes after an // @import or @namespace rule will cause any subsequent @import or // @namespace rules to be ignored." if atRuleContext.importValidity == atRuleValid && (r.Rules != nil || didFindAtImport) { atRuleContext.afterLoc = rule.Loc atRuleContext.charsetValidity = atRuleInvalidAfter atRuleContext.importValidity = atRuleInvalidAfter } default: if atRuleContext.importValidity == atRuleValid { atRuleContext.afterLoc = rule.Loc atRuleContext.charsetValidity = atRuleInvalidAfter atRuleContext.importValidity = atRuleInvalidAfter } } } // Lower CSS nesting if it's not supported (but only at the top level) if p.nestingIsPresent && p.options.unsupportedCSSFeatures.Has(compat.Nesting) && context.isTopLevel { rules = p.lowerNestingInRule(rule, rules) } else { rules = append(rules, rule) } continue case css_lexer.TCDO, css_lexer.TCDC: if context.isTopLevel { p.advance() continue } } if atRuleContext.importValidity == atRuleValid { atRuleContext.afterLoc = p.current().Range.Loc atRuleContext.charsetValidity = atRuleInvalidAfter atRuleContext.importValidity = atRuleInvalidAfter } // Note: CSS recently changed to parse and discard declarations // here instead of treating them as the start of a qualified rule. // See also: https://github.com/w3c/csswg-drafts/issues/8834 if !context.isTopLevel { if scan, index := p.scanForEndOfRule(); scan == endOfRuleSemicolon { tokens := p.convertTokens(p.tokens[p.index:index]) rules = append(rules, css_ast.Rule{Loc: p.current().Range.Loc, Data: &css_ast.RBadDeclaration{Tokens: tokens}}) p.index = index + 1 continue } } var rule css_ast.Rule if context.parseSelectors { rule = p.parseSelectorRule(context.isTopLevel, parseSelectorOpts{}) } else { rule = p.parseQualifiedRule(parseQualifiedRuleOpts{isTopLevel: context.isTopLevel}) } // Lower CSS nesting if it's not supported (but only at the top level) if p.nestingIsPresent && p.options.unsupportedCSSFeatures.Has(compat.Nesting) && context.isTopLevel { rules = p.lowerNestingInRule(rule, rules) } else { rules = append(rules, rule) } } if p.options.minifySyntax { rules = p.mangleRules(rules, context.isTopLevel) } return rules } type listOfDeclarationsOpts struct { composesContext *composesContext canInlineNoOpNesting bool } func (p *parser) parseListOfDeclarations(opts listOfDeclarationsOpts) (list []css_ast.Rule) { list = []css_ast.Rule{} foundNesting := false for { switch p.current().Kind { case css_lexer.TWhitespace, css_lexer.TSemicolon: p.advance() case css_lexer.TEndOfFile, css_lexer.TCloseBrace: list = p.processDeclarations(list, opts.composesContext) if p.options.minifySyntax { list = p.mangleRules(list, false /* isTopLevel */) // Pull out all unnecessarily-nested declarations and stick them at the end if opts.canInlineNoOpNesting { // "a { & { x: y } }" => "a { x: y }" // "a { & { b: c } d: e }" => "a { d: e; b: c }" if foundNesting { var inlineDecls []css_ast.Rule n := 0 for _, rule := range list { if rule, ok := rule.Data.(*css_ast.RSelector); ok && len(rule.Selectors) == 1 { if sel := rule.Selectors[0]; len(sel.Selectors) == 1 && sel.Selectors[0].IsSingleAmpersand() { inlineDecls = append(inlineDecls, rule.Rules...) continue } } list[n] = rule n++ } list = append(list[:n], inlineDecls...) } } else { // "a, b::before { & { x: y } }" => "a, b::before { & { x: y } }" } } return case css_lexer.TAtKeyword: if p.inSelectorSubtree > 0 { p.nestingIsPresent = true } list = append(list, p.parseAtRule(atRuleContext{ isDeclarationList: true, canInlineNoOpNesting: opts.canInlineNoOpNesting, })) // Reference: https://drafts.csswg.org/css-nesting-1/ default: if scan, _ := p.scanForEndOfRule(); scan == endOfRuleOpenBrace { p.nestingIsPresent = true foundNesting = true rule := p.parseSelectorRule(false, parseSelectorOpts{ isDeclarationContext: true, composesContext: opts.composesContext, }) // If this rule was a single ":global" or ":local", inline it here. This // is handled differently than a bare "&" with normal CSS nesting because // that would be inlined at the end of the parent rule's body instead, // which is probably unexpected (e.g. it would trip people up when trying // to write rules in a specific order). if sel, ok := rule.Data.(*css_ast.RSelector); ok && len(sel.Selectors) == 1 { if first := sel.Selectors[0]; len(first.Selectors) == 1 { if first := first.Selectors[0]; first.WasEmptyFromLocalOrGlobal && first.IsSingleAmpersand() { list = append(list, sel.Rules...) continue } } } list = append(list, rule) } else { list = append(list, p.parseDeclaration()) } } } } func (p *parser) mangleRules(rules []css_ast.Rule, isTopLevel bool) []css_ast.Rule { // Remove empty rules mangledRules := make([]css_ast.Rule, 0, len(rules)) var prevNonComment css_ast.R next: for _, rule := range rules { nextNonComment := rule.Data switch r := rule.Data.(type) { case *css_ast.RAtKeyframes: // Do not remove empty "@keyframe foo {}" rules. Even empty rules still // dispatch JavaScript animation events, so removing them changes // behavior: https://bugzilla.mozilla.org/show_bug.cgi?id=1004377. case *css_ast.RAtLayer: if len(r.Rules) == 0 && len(r.Names) > 0 { // Do not remove empty "@layer foo {}" rules. The specification says: // "Cascade layers are sorted by the order in which they first are // declared, with nested layers grouped within their parent layers // before any unlayered rules." So removing empty rules could change // the order in which they are first declared, and is therefore invalid. // // We can turn "@layer foo {}" into "@layer foo;" to be shorter. But // don't collapse anonymous "@layer {}" into "@layer;" because that is // a syntax error. r.Rules = nil } else if len(r.Rules) == 1 && len(r.Names) == 1 { // Only collapse layers if each layer has exactly one name if r2, ok := r.Rules[0].Data.(*css_ast.RAtLayer); ok && len(r2.Names) == 1 { // "@layer a { @layer b {} }" => "@layer a.b;" // "@layer a { @layer b { c {} } }" => "@layer a.b { c {} }" r.Names[0] = append(r.Names[0], r2.Names[0]...) r.Rules = r2.Rules } } case *css_ast.RKnownAt: if len(r.Rules) == 0 && atKnownRuleCanBeRemovedIfEmpty[r.AtToken] { continue } case *css_ast.RAtMedia: if len(r.Rules) == 0 { continue } // Unwrap "@media" rules that duplicate conditions from a parent "@media" // rule. This is unlikely to be authored manually but can be automatically // generated when using a CSS framework such as Tailwind. // // @media (min-width: 1024px) { // .md\:class { // color: red; // } // @media (min-width: 1024px) { // .md\:class { // color: red; // } // } // } // // This converts that code into the following: // // @media (min-width: 1024px) { // .md\:class { // color: red; // } // .md\:class { // color: red; // } // } // // Which can then be mangled further. for _, queries := range p.enclosingAtMedia { if css_ast.MediaQueriesEqual(r.Queries, queries, nil) { mangledRules = append(mangledRules, r.Rules...) continue next } } case *css_ast.RSelector: if len(r.Rules) == 0 { continue } // Merge adjacent selectors with the same content // "a { color: red; } b { color: red; }" => "a, b { color: red; }" if prevNonComment != nil { if r, ok := rule.Data.(*css_ast.RSelector); ok { if prev, ok := prevNonComment.(*css_ast.RSelector); ok && css_ast.RulesEqual(r.Rules, prev.Rules, nil) && isSafeSelectors(r.Selectors) && isSafeSelectors(prev.Selectors) { nextSelector: for _, sel := range r.Selectors { for _, prevSel := range prev.Selectors { if sel.Equal(prevSel, nil) { // Don't add duplicate selectors more than once continue nextSelector } } prev.Selectors = append(prev.Selectors, sel) } continue } } } case *css_ast.RComment: nextNonComment = nil } if nextNonComment != nil { prevNonComment = nextNonComment } mangledRules = append(mangledRules, rule) } // Mangle non-top-level rules using a back-to-front pass. Top-level rules // will be mangled by the linker instead for cross-file rule mangling. if !isTopLevel { remover := MakeDeadRuleMangler(ast.SymbolMap{}) mangledRules = remover.RemoveDeadRulesInPlace(p.source.Index, mangledRules, p.importRecords) } return mangledRules } type ruleEntry struct { data css_ast.R callCounter uint32 } type hashEntry struct { rules []ruleEntry } type callEntry struct { importRecords []ast.ImportRecord sourceIndex uint32 } type DeadRuleRemover struct { entries map[uint32]hashEntry calls []callEntry check css_ast.CrossFileEqualityCheck } func MakeDeadRuleMangler(symbols ast.SymbolMap) DeadRuleRemover { return DeadRuleRemover{ entries: make(map[uint32]hashEntry), check: css_ast.CrossFileEqualityCheck{Symbols: symbols}, } } func (remover *DeadRuleRemover) RemoveDeadRulesInPlace(sourceIndex uint32, rules []css_ast.Rule, importRecords []ast.ImportRecord) []css_ast.Rule { // The caller may call this function multiple times, each with a different // set of import records. Remember each set of import records for equality // checks later. callCounter := uint32(len(remover.calls)) remover.calls = append(remover.calls, callEntry{importRecords, sourceIndex}) // Remove duplicate rules, scanning from the back so we keep the last // duplicate. Note that the linker calls this, so we do not want to do // anything that modifies the rules themselves. One reason is that ASTs // are immutable at the linking stage. Another reason is that merging // CSS ASTs from separate files will mess up source maps because a single // AST cannot simultaneously represent offsets from multiple files. n := len(rules) start := n skipRule: for i := n - 1; i >= 0; i-- { rule := rules[i] // Remove rules with selectors that don't apply to anything (e.g. ":is()") if r, ok := rule.Data.(*css_ast.RSelector); ok && allSelectorsAreDead(r.Selectors) { continue skipRule } // For duplicate rules, omit all but the last copy if hash, ok := rule.Data.Hash(); ok { entry := remover.entries[hash] for _, current := range entry.rules { var check *css_ast.CrossFileEqualityCheck // If this rule was from another file, then pass along both arrays // of import records so that the equality check for "url()" tokens // can use them to check for equality. if current.callCounter != callCounter { // Reuse the same memory allocation check = &remover.check call := remover.calls[current.callCounter] check.ImportRecordsA = importRecords check.ImportRecordsB = call.importRecords check.SourceIndexA = sourceIndex check.SourceIndexB = call.sourceIndex } if rule.Data.Equal(current.data, check) { continue skipRule } } entry.rules = append(entry.rules, ruleEntry{ data: rule.Data, callCounter: callCounter, }) remover.entries[hash] = entry } start-- rules[start] = rule } return rules[start:] } func containsDeadSelectors(selectors []css_ast.CompoundSelector) bool { for _, sel := range selectors { for _, ss := range sel.SubclassSelectors { if pseudo, ok := ss.Data.(*css_ast.SSPseudoClassWithSelectorList); ok && len(pseudo.Selectors) == 0 && (pseudo.Kind == css_ast.PseudoClassIs || pseudo.Kind == css_ast.PseudoClassWhere) { // ":is()" and ":where()" never match anything when empty return true } } } return false } func allSelectorsAreDead(selectors []css_ast.ComplexSelector) bool { for _, sel := range selectors { if !containsDeadSelectors(sel.Selectors) { return false } } return true } // Reference: https://developer.mozilla.org/en-US/docs/Web/HTML/Element var nonDeprecatedElementsSupportedByIE7 = map[string]bool{ "a": true, "abbr": true, "address": true, "area": true, "b": true, "base": true, "blockquote": true, "body": true, "br": true, "button": true, "caption": true, "cite": true, "code": true, "col": true, "colgroup": true, "dd": true, "del": true, "dfn": true, "div": true, "dl": true, "dt": true, "em": true, "embed": true, "fieldset": true, "form": true, "h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true, "head": true, "hr": true, "html": true, "i": true, "iframe": true, "img": true, "input": true, "ins": true, "kbd": true, "label": true, "legend": true, "li": true, "link": true, "map": true, "menu": true, "meta": true, "noscript": true, "object": true, "ol": true, "optgroup": true, "option": true, "p": true, "param": true, "pre": true, "q": true, "ruby": true, "s": true, "samp": true, "script": true, "select": true, "small": true, "span": true, "strong": true, "style": true, "sub": true, "sup": true, "table": true, "tbody": true, "td": true, "textarea": true, "tfoot": true, "th": true, "thead": true, "title": true, "tr": true, "u": true, "ul": true, "var": true, } // This only returns true if all of these selectors are considered "safe" which // means that they are very likely to work in any browser a user might reasonably // be using. We do NOT want to merge adjacent qualified rules with the same body // if any of the selectors are unsafe, since then browsers which don't support // that particular feature would ignore the entire merged qualified rule: // // Input: // a { color: red } // b { color: red } // input::-moz-placeholder { color: red } // // Valid output: // a, b { color: red } // input::-moz-placeholder { color: red } // // Invalid output: // a, b, input::-moz-placeholder { color: red } // // This considers IE 7 and above to be a browser that a user could possibly use. // Versions of IE less than 6 are not considered. func isSafeSelectors(complexSelectors []css_ast.ComplexSelector) bool { for _, complex := range complexSelectors { for _, compound := range complex.Selectors { if len(compound.NestingSelectorLocs) > 0 { // Bail because this is an extension: https://drafts.csswg.org/css-nesting-1/ return false } if compound.Combinator.Byte != 0 { // "Before Internet Explorer 10, the combinator only works in standards mode" // Reference: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors return false } if compound.TypeSelector != nil { if compound.TypeSelector.NamespacePrefix != nil { // Bail if we hit a namespace, which doesn't work in IE before version 9 // Reference: https://developer.mozilla.org/en-US/docs/Web/CSS/Type_selectors return false } if compound.TypeSelector.Name.Kind == css_lexer.TIdent && !nonDeprecatedElementsSupportedByIE7[compound.TypeSelector.Name.Text] { // Bail if this element is either deprecated or not supported in IE 7 return false } } for _, ss := range compound.SubclassSelectors { switch s := ss.Data.(type) { case *css_ast.SSAttribute: if s.MatcherModifier != 0 { // Bail if we hit a case modifier, which doesn't work in IE at all // Reference: https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors return false } case *css_ast.SSPseudoClass: // Bail if this pseudo class doesn't match a hard-coded list that's // known to work everywhere. For example, ":focus" doesn't work in IE 7. // Reference: https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-classes if s.Args == nil && !s.IsElement { switch s.Name { case "active", "first-child", "hover", "link", "visited": continue } } return false case *css_ast.SSPseudoClassWithSelectorList: // These definitely don't work in IE 7 return false } } } } return true } func (p *parser) parseURLOrString() (string, logger.Range, bool) { t := p.current() switch t.Kind { case css_lexer.TString: text := p.decoded() p.advance() return text, t.Range, true case css_lexer.TURL: text := p.decoded() p.advance() return text, t.Range, true case css_lexer.TFunction: if strings.EqualFold(p.decoded(), "url") { matchingLoc := logger.Loc{Start: p.current().Range.End() - 1} i := p.index + 1 // Skip over whitespace for p.at(i).Kind == css_lexer.TWhitespace { i++ } // Consume a string if p.at(i).Kind == css_lexer.TString { stringIndex := i i++ // Skip over whitespace for p.at(i).Kind == css_lexer.TWhitespace { i++ } // Consume a closing parenthesis if close := p.at(i).Kind; close == css_lexer.TCloseParen || close == css_lexer.TEndOfFile { t := p.at(stringIndex) text := t.DecodedText(p.source.Contents) p.index = i p.expectWithMatchingLoc(css_lexer.TCloseParen, matchingLoc) return text, t.Range, true } } } } return "", logger.Range{}, false } func (p *parser) expectURLOrString() (url string, r logger.Range, ok bool) { url, r, ok = p.parseURLOrString() if !ok { p.expect(css_lexer.TURL) } return } type atRuleKind uint8 const ( atRuleUnknown atRuleKind = iota atRuleDeclarations atRuleInheritContext atRuleQualifiedOrEmpty atRuleEmpty ) var specialAtRules = map[string]atRuleKind{ "media": atRuleInheritContext, "supports": atRuleInheritContext, "font-face": atRuleDeclarations, "page": atRuleDeclarations, // These go inside "@page": https://www.w3.org/TR/css-page-3/#syntax-page-selector "bottom-center": atRuleDeclarations, "bottom-left-corner": atRuleDeclarations, "bottom-left": atRuleDeclarations, "bottom-right-corner": atRuleDeclarations, "bottom-right": atRuleDeclarations, "left-bottom": atRuleDeclarations, "left-middle": atRuleDeclarations, "left-top": atRuleDeclarations, "right-bottom": atRuleDeclarations, "right-middle": atRuleDeclarations, "right-top": atRuleDeclarations, "top-center": atRuleDeclarations, "top-left-corner": atRuleDeclarations, "top-left": atRuleDeclarations, "top-right-corner": atRuleDeclarations, "top-right": atRuleDeclarations, // These properties are very deprecated and appear to only be useful for // mobile versions of internet explorer (which may no longer exist?), but // they are used by the https://ant.design/ design system so we recognize // them to avoid the warning. // // Documentation: https://developer.mozilla.org/en-US/docs/Web/CSS/@viewport // Discussion: https://github.com/w3c/csswg-drafts/issues/4766 // "viewport": atRuleDeclarations, "-ms-viewport": atRuleDeclarations, // This feature has been removed from the web because it's actively harmful. // However, there is one exception where "@-moz-document url-prefix() {" is // accepted by Firefox to basically be an "if Firefox" conditional rule. // // Documentation: https://developer.mozilla.org/en-US/docs/Web/CSS/@document // Discussion: https://bugzilla.mozilla.org/show_bug.cgi?id=1035091 // "document": atRuleInheritContext, "-moz-document": atRuleInheritContext, // This is a new feature that changes how the CSS rule cascade works. It can // end in either a "{}" block or a ";" rule terminator so we need this special // case to support both. // // Documentation: https://developer.mozilla.org/en-US/docs/Web/CSS/@layer // Motivation: https://developer.chrome.com/blog/cascade-layers/ // "layer": atRuleQualifiedOrEmpty, // Reference: https://drafts.csswg.org/css-cascade-6/#scoped-styles "scope": atRuleInheritContext, // Reference: https://drafts.csswg.org/css-fonts-4/#font-palette-values "font-palette-values": atRuleDeclarations, // Documentation: https://developer.mozilla.org/en-US/docs/Web/CSS/@counter-style // Reference: https://drafts.csswg.org/css-counter-styles/#the-counter-style-rule "counter-style": atRuleDeclarations, // Documentation: https://developer.mozilla.org/en-US/docs/Web/CSS/@font-feature-values // Reference: https://drafts.csswg.org/css-fonts/#font-feature-values "font-feature-values": atRuleDeclarations, "annotation": atRuleDeclarations, "character-variant": atRuleDeclarations, "historical-forms": atRuleDeclarations, "ornaments": atRuleDeclarations, "styleset": atRuleDeclarations, "stylistic": atRuleDeclarations, "swash": atRuleDeclarations, // Container Queries // Reference: https://drafts.csswg.org/css-contain-3/#container-rule "container": atRuleInheritContext, // Defining before-change style: the @starting-style rule // Reference: https://drafts.csswg.org/css-transitions-2/#defining-before-change-style-the-starting-style-rule "starting-style": atRuleInheritContext, // Anchor Positioning // Reference: https://drafts.csswg.org/css-anchor-position-1/#at-ruledef-position-try "position-try": atRuleDeclarations, // @view-transition // Reference: https://drafts.csswg.org/css-view-transitions-2/#view-transition-rule "view-transition": atRuleDeclarations, } var atKnownRuleCanBeRemovedIfEmpty = map[string]bool{ "media": true, "supports": true, "font-face": true, "page": true, // https://www.w3.org/TR/css-page-3/#syntax-page-selector "bottom-center": true, "bottom-left-corner": true, "bottom-left": true, "bottom-right-corner": true, "bottom-right": true, "left-bottom": true, "left-middle": true, "left-top": true, "right-bottom": true, "right-middle": true, "right-top": true, "top-center": true, "top-left-corner": true, "top-left": true, "top-right-corner": true, "top-right": true, // https://drafts.csswg.org/css-cascade-6/#scoped-styles "scope": true, // https://drafts.csswg.org/css-fonts-4/#font-palette-values "font-palette-values": true, // https://drafts.csswg.org/css-contain-3/#container-rule "container": true, } type atRuleValidity uint8 const ( atRuleInvalid atRuleValidity = iota atRuleValid atRuleInvalidAfter ) type atRuleContext struct { afterLoc logger.Loc charsetValidity atRuleValidity importValidity atRuleValidity canInlineNoOpNesting bool isDeclarationList bool isTopLevel bool } func (p *parser) parseAtRule(context atRuleContext) css_ast.Rule { // Parse the name atToken := p.decoded() atRange := p.current().Range lowerAtToken := strings.ToLower(atToken) kind := specialAtRules[lowerAtToken] p.advance() // Parse the prelude preludeStart := p.index abortRuleParser: switch lowerAtToken { case "charset": switch context.charsetValidity { case atRuleInvalid: p.log.AddID(logger.MsgID_CSS_InvalidAtCharset, logger.Warning, &p.tracker, atRange, "\"@charset\" must be the first rule in the file") case atRuleInvalidAfter: p.log.AddIDWithNotes(logger.MsgID_CSS_InvalidAtCharset, logger.Warning, &p.tracker, atRange, "\"@charset\" must be the first rule in the file", []logger.MsgData{p.tracker.MsgData(logger.Range{Loc: context.afterLoc}, "This rule cannot come before a \"@charset\" rule")}) case atRuleValid: kind = atRuleEmpty p.expect(css_lexer.TWhitespace) if p.peek(css_lexer.TString) { encoding := p.decoded() if !strings.EqualFold(encoding, "UTF-8") { p.log.AddID(logger.MsgID_CSS_UnsupportedAtCharset, logger.Warning, &p.tracker, p.current().Range, fmt.Sprintf("\"UTF-8\" will be used instead of unsupported charset %q", encoding)) } p.advance() p.expect(css_lexer.TSemicolon) return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RAtCharset{Encoding: encoding}} } p.expect(css_lexer.TString) } case "import": switch context.importValidity { case atRuleInvalid: p.log.AddID(logger.MsgID_CSS_InvalidAtImport, logger.Warning, &p.tracker, atRange, "\"@import\" is only valid at the top level") case atRuleInvalidAfter: p.log.AddIDWithNotes(logger.MsgID_CSS_InvalidAtImport, logger.Warning, &p.tracker, atRange, "All \"@import\" rules must come first", []logger.MsgData{p.tracker.MsgData(logger.Range{Loc: context.afterLoc}, "This rule cannot come before an \"@import\" rule")}) case atRuleValid: kind = atRuleEmpty p.eat(css_lexer.TWhitespace) if path, r, ok := p.expectURLOrString(); ok { var conditions css_ast.ImportConditions importConditionsStart := p.index // Parse the optional "layer()" p.eat(css_lexer.TWhitespace) if (p.peek(css_lexer.TIdent) || p.peek(css_lexer.TFunction)) && strings.EqualFold(p.decoded(), "layer") { p.parseComponentValue() conditions.Layers = p.convertTokens(p.tokens[importConditionsStart:p.index]) importConditionsStart = p.index // Remove leading and trailing whitespace if len(conditions.Layers) > 0 { conditions.Layers[0].Whitespace &= ^(css_ast.WhitespaceBefore | css_ast.WhitespaceAfter) } } // Parse the optional "supports()" p.eat(css_lexer.TWhitespace) if p.peek(css_lexer.TFunction) && strings.EqualFold(p.decoded(), "supports") { p.parseComponentValue() conditions.Supports = p.convertTokens(p.tokens[importConditionsStart:p.index]) importConditionsStart = p.index // Remove leading and trailing whitespace if len(conditions.Supports) > 0 { conditions.Supports[0].Whitespace &= ^(css_ast.WhitespaceBefore | css_ast.WhitespaceAfter) } } // Parse the optional media query list conditions.Queries = p.parseMediaQueryListUntil(func(kind css_lexer.T) bool { return kind == css_lexer.TSemicolon || kind == css_lexer.TOpenBrace || kind == css_lexer.TCloseBrace || kind == css_lexer.TEndOfFile }) if p.peek(css_lexer.TOpenBrace) { break // Avoid parsing an invalid "@import" rule } // Check whether any import conditions are present var importConditions *css_ast.ImportConditions if len(conditions.Layers) > 0 || len(conditions.Supports) > 0 || len(conditions.Queries) > 0 { importConditions = &conditions } p.expect(css_lexer.TSemicolon) importRecordIndex := uint32(len(p.importRecords)) p.importRecords = append(p.importRecords, ast.ImportRecord{ Kind: ast.ImportAt, Path: logger.Path{Text: path}, Range: r, }) // Fill in the pre-import layers once we see the first "@import" if !p.hasSeenAtImport { p.hasSeenAtImport = true p.layersPreImport = p.layersPostImport p.layersPostImport = nil } return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RAtImport{ ImportRecordIndex: importRecordIndex, ImportConditions: importConditions, }} } } case "keyframes", "-webkit-keyframes", "-moz-keyframes", "-ms-keyframes", "-o-keyframes": p.eat(css_lexer.TWhitespace) nameLoc := p.current().Range.Loc var name string if p.peek(css_lexer.TIdent) { name = p.decoded() if isInvalidAnimationName(name) { msg := logger.Msg{ ID: logger.MsgID_CSS_CSSSyntaxError, Kind: logger.Warning, Data: p.tracker.MsgData(p.current().Range, fmt.Sprintf("Cannot use %q as a name for \"@keyframes\" without quotes", name)), Notes: []logger.MsgData{{Text: fmt.Sprintf("You can put %q in quotes to prevent it from becoming a CSS keyword.", name)}}, } msg.Data.Location.Suggestion = fmt.Sprintf("%q", name) p.log.AddMsg(msg) break } p.advance() } else if p.peek(css_lexer.TString) { // Note: Strings as names is allowed in the CSS specification and works in // Firefox and Safari but Chrome has strangely decided to deliberately not // support this. We always turn all string names into identifiers to avoid // them silently breaking in Chrome. name = p.decoded() p.advance() if !p.makeLocalSymbols && isInvalidAnimationName(name) { break } } else if !p.expect(css_lexer.TIdent) { break } p.eat(css_lexer.TWhitespace) blockStart := p.index matchingLoc := p.current().Range.Loc if p.expect(css_lexer.TOpenBrace) { var blocks []css_ast.KeyframeBlock badSyntax: for { switch p.current().Kind { case css_lexer.TWhitespace: p.advance() continue case css_lexer.TCloseBrace: closeBraceLoc := p.current().Range.Loc p.advance() return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RAtKeyframes{ AtToken: atToken, Name: p.symbolForName(nameLoc, name), Blocks: blocks, CloseBraceLoc: closeBraceLoc, }} case css_lexer.TEndOfFile: break badSyntax case css_lexer.TOpenBrace: p.expect(css_lexer.TPercentage) break badSyntax default: var selectors []string var firstSelectorLoc logger.Loc selectors: for { t := p.current() switch t.Kind { case css_lexer.TWhitespace: p.advance() continue case css_lexer.TOpenBrace: blockMatchingLoc := p.current().Range.Loc p.advance() rules := p.parseListOfDeclarations(listOfDeclarationsOpts{}) closeBraceLoc := p.current().Range.Loc if !p.expectWithMatchingLoc(css_lexer.TCloseBrace, blockMatchingLoc) { closeBraceLoc = logger.Loc{} } // "@keyframes { from {} to { color: red } }" => "@keyframes { to { color: red } }" if !p.options.minifySyntax || len(rules) > 0 { blocks = append(blocks, css_ast.KeyframeBlock{ Selectors: selectors, Rules: rules, Loc: firstSelectorLoc, CloseBraceLoc: closeBraceLoc, }) } break selectors case css_lexer.TCloseBrace, css_lexer.TEndOfFile: p.expect(css_lexer.TOpenBrace) break badSyntax case css_lexer.TIdent, css_lexer.TPercentage: if firstSelectorLoc.Start == 0 { firstSelectorLoc = p.current().Range.Loc } text := p.decoded() if t.Kind == css_lexer.TIdent { if strings.EqualFold(text, "from") { if p.options.minifySyntax { text = "0%" // "0%" is equivalent to but shorter than "from" } } else if !strings.EqualFold(text, "to") { p.expect(css_lexer.TPercentage) } } else if p.options.minifySyntax && text == "100%" { text = "to" // "to" is equivalent to but shorter than "100%" } selectors = append(selectors, text) p.advance() // Keyframe selectors are comma-separated p.eat(css_lexer.TWhitespace) if p.eat(css_lexer.TComma) { p.eat(css_lexer.TWhitespace) if k := p.current().Kind; k != css_lexer.TIdent && k != css_lexer.TPercentage { p.expect(css_lexer.TPercentage) break badSyntax } } else if k := p.current().Kind; k != css_lexer.TOpenBrace && k != css_lexer.TCloseBrace && k != css_lexer.TEndOfFile { p.expect(css_lexer.TComma) break badSyntax } default: p.expect(css_lexer.TPercentage) break badSyntax } } } } // Otherwise, finish parsing the body and return an unknown rule for !p.peek(css_lexer.TCloseBrace) && !p.peek(css_lexer.TEndOfFile) { p.parseComponentValue() } p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) prelude := p.convertTokens(p.tokens[preludeStart:blockStart]) block, _ := p.convertTokensHelper(p.tokens[blockStart:p.index], css_lexer.TEndOfFile, convertTokensOpts{allowImports: true}) return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RUnknownAt{AtToken: atToken, Prelude: prelude, Block: block}} } case "layer": // Reference: https://developer.mozilla.org/en-US/docs/Web/CSS/@layer // Read the layer name list var names [][]string p.eat(css_lexer.TWhitespace) if p.peek(css_lexer.TIdent) { for { ident, ok := p.expectValidLayerNameIdent() if !ok { break abortRuleParser } name := []string{ident} for { p.eat(css_lexer.TWhitespace) if !p.eat(css_lexer.TDelimDot) { break } p.eat(css_lexer.TWhitespace) ident, ok := p.expectValidLayerNameIdent() if !ok { break abortRuleParser } name = append(name, ident) } names = append(names, name) p.eat(css_lexer.TWhitespace) if !p.eat(css_lexer.TComma) { break } p.eat(css_lexer.TWhitespace) } } // Read the optional block matchingLoc := p.current().Range.Loc if len(names) <= 1 && p.eat(css_lexer.TOpenBrace) { p.recordAtLayerRule(names) oldEnclosingLayer := p.enclosingLayer if len(names) == 1 { p.enclosingLayer = append(p.enclosingLayer, names[0]...) } else { p.anonLayerCount++ } // Parse the block for this rule var rules []css_ast.Rule if context.isDeclarationList { rules = p.parseListOfDeclarations(listOfDeclarationsOpts{ canInlineNoOpNesting: context.canInlineNoOpNesting, }) } else { rules = p.parseListOfRules(ruleContext{ parseSelectors: true, }) } if len(names) != 1 { p.anonLayerCount-- } p.enclosingLayer = oldEnclosingLayer closeBraceLoc := p.current().Range.Loc if !p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) { closeBraceLoc = logger.Loc{} } return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RAtLayer{Names: names, Rules: rules, CloseBraceLoc: closeBraceLoc}} } // Handle lack of a block if len(names) >= 1 && p.eat(css_lexer.TSemicolon) { p.recordAtLayerRule(names) return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RAtLayer{Names: names}} } // Otherwise there's some kind of syntax error switch p.current().Kind { case css_lexer.TEndOfFile: p.expect(css_lexer.TSemicolon) p.recordAtLayerRule(names) return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RAtLayer{Names: names}} case css_lexer.TCloseBrace: p.expect(css_lexer.TSemicolon) if !context.isTopLevel { p.recordAtLayerRule(names) return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RAtLayer{Names: names}} } case css_lexer.TOpenBrace: p.expect(css_lexer.TSemicolon) default: p.unexpected() } case "media": queries := p.parseMediaQueryListUntil(func(kind css_lexer.T) bool { return kind == css_lexer.TOpenBrace }) // Expect a block after the query matchingLoc := p.current().Range.Loc if !p.expect(css_lexer.TOpenBrace) { break } // Push the "@media" conditions p.enclosingAtMedia = append(p.enclosingAtMedia, queries) // Parse the block for this rule var rules []css_ast.Rule if context.isDeclarationList { rules = p.parseListOfDeclarations(listOfDeclarationsOpts{ canInlineNoOpNesting: context.canInlineNoOpNesting, }) } else { rules = p.parseListOfRules(ruleContext{ parseSelectors: true, }) } // Pop the "@media" conditions p.enclosingAtMedia = p.enclosingAtMedia[:len(p.enclosingAtMedia)-1] closeBraceLoc := p.current().Range.Loc if !p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) { closeBraceLoc = logger.Loc{} } return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RAtMedia{Queries: queries, Rules: rules, CloseBraceLoc: closeBraceLoc}} case "scope": var ok bool // Parse the start limit var start []css_ast.ComplexSelector p.eat(css_lexer.TWhitespace) if p.eat(css_lexer.TOpenParen) { if start, ok = p.parseSelectorList(parseSelectorOpts{stopOnCloseParen: true}); !ok || !p.expect(css_lexer.TCloseParen) { break } p.eat(css_lexer.TWhitespace) } // Parse the end limit var end []css_ast.ComplexSelector if strings.EqualFold(p.decoded(), "to") && p.eat(css_lexer.TIdent) { p.eat(css_lexer.TWhitespace) if !p.expect(css_lexer.TOpenParen) { break } if end, ok = p.parseSelectorList(parseSelectorOpts{stopOnCloseParen: true}); !ok || !p.expect(css_lexer.TCloseParen) { break } } p.eat(css_lexer.TWhitespace) // Expect a block after the query matchingLoc := p.current().Range.Loc if !p.expect(css_lexer.TOpenBrace) { break } // Parse the block for this rule var rules []css_ast.Rule if context.isDeclarationList { rules = p.parseListOfDeclarations(listOfDeclarationsOpts{ canInlineNoOpNesting: context.canInlineNoOpNesting, }) } else { rules = p.parseListOfRules(ruleContext{ parseSelectors: true, }) } closeBraceLoc := p.current().Range.Loc if !p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) { closeBraceLoc = logger.Loc{} } return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RAtScope{Start: start, End: end, Rules: rules, CloseBraceLoc: closeBraceLoc}} default: 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 // also complicated to support in a bundler because CSS namespaces are // file-scoped, which means: // // * Default namespaces can be different in different files, in which // case some default namespaces would have to be converted to prefixed // namespaces to avoid collisions. // // * Prefixed namespaces from different files can use the same name, in // which case some prefixed namespaces would need to be renamed to // avoid collisions. // // Instead of implementing all of that for an extremely obscure feature, // CSS namespaces are just explicitly not supported. p.log.AddID(logger.MsgID_CSS_UnsupportedAtNamespace, logger.Warning, &p.tracker, atRange, "\"@namespace\" rules are not supported") } } // Parse an unknown prelude p.index = preludeStart prelude: for { switch p.current().Kind { case css_lexer.TOpenBrace, css_lexer.TEndOfFile: break prelude case css_lexer.TSemicolon, css_lexer.TCloseBrace: prelude := p.convertTokens(p.tokens[preludeStart:p.index]) switch kind { case atRuleQualifiedOrEmpty: // Parse a known at rule below break prelude case atRuleEmpty, atRuleUnknown: // Parse an unknown at rule p.expect(css_lexer.TSemicolon) return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RUnknownAt{AtToken: atToken, Prelude: prelude}} default: // Report an error for rules that should have blocks p.expect(css_lexer.TOpenBrace) p.eat(css_lexer.TSemicolon) return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RUnknownAt{AtToken: atToken, Prelude: prelude}} } default: p.parseComponentValue() } } prelude := p.convertTokens(p.tokens[preludeStart:p.index]) blockStart := p.index switch kind { case atRuleEmpty: // Report an error for rules that shouldn't have blocks p.expect(css_lexer.TSemicolon) p.parseBlock(css_lexer.TOpenBrace, css_lexer.TCloseBrace) block := p.convertTokens(p.tokens[blockStart:p.index]) return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RUnknownAt{AtToken: atToken, Prelude: prelude, Block: block}} case atRuleDeclarations: // Parse known rules whose blocks always consist of declarations matchingLoc := p.current().Range.Loc p.expect(css_lexer.TOpenBrace) rules := p.parseListOfDeclarations(listOfDeclarationsOpts{}) closeBraceLoc := p.current().Range.Loc if !p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) { closeBraceLoc = logger.Loc{} } // Handle local names for "@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 } } return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RKnownAt{AtToken: atToken, Prelude: prelude, Rules: rules, CloseBraceLoc: closeBraceLoc}} case atRuleInheritContext: // Parse known rules whose blocks consist of whatever the current context is matchingLoc := p.current().Range.Loc p.expect(css_lexer.TOpenBrace) // Parse the block for this rule var rules []css_ast.Rule if context.isDeclarationList { rules = p.parseListOfDeclarations(listOfDeclarationsOpts{ canInlineNoOpNesting: context.canInlineNoOpNesting, }) } else { rules = p.parseListOfRules(ruleContext{ parseSelectors: true, }) } closeBraceLoc := p.current().Range.Loc if !p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) { closeBraceLoc = logger.Loc{} } // Handle local names for "@container" if len(prelude) >= 1 && lowerAtToken == "container" { if t := &prelude[0]; t.Kind == css_lexer.TIdent && !strings.EqualFold(t.Text, "not") { t.Kind = css_lexer.TSymbol t.PayloadIndex = p.symbolForName(t.Loc, t.Text).Ref.InnerIndex } } return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RKnownAt{AtToken: atToken, Prelude: prelude, Rules: rules, CloseBraceLoc: closeBraceLoc}} case atRuleQualifiedOrEmpty: matchingLoc := p.current().Range.Loc if p.eat(css_lexer.TOpenBrace) { rules := p.parseListOfRules(ruleContext{ parseSelectors: true, }) closeBraceLoc := p.current().Range.Loc if !p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) { closeBraceLoc = logger.Loc{} } return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RKnownAt{AtToken: atToken, Prelude: prelude, Rules: rules, CloseBraceLoc: closeBraceLoc}} } p.expect(css_lexer.TSemicolon) return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RKnownAt{AtToken: atToken, Prelude: prelude}} default: // Otherwise, parse an unknown rule p.parseBlock(css_lexer.TOpenBrace, css_lexer.TCloseBrace) block, _ := p.convertTokensHelper(p.tokens[blockStart:p.index], css_lexer.TEndOfFile, convertTokensOpts{allowImports: true}) return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RUnknownAt{AtToken: atToken, Prelude: prelude, Block: block}} } } func (p *parser) expectValidLayerNameIdent() (string, bool) { r := p.current().Range text := p.decoded() if !p.expect(css_lexer.TIdent) { return "", false } switch text { case "initial", "inherit", "unset": p.log.AddID(logger.MsgID_CSS_InvalidAtLayer, logger.Warning, &p.tracker, r, fmt.Sprintf("%q cannot be used as a layer name", text)) p.prevError = r.Loc return "", false } return text, true } func (p *parser) convertTokens(tokens []css_lexer.Token) []css_ast.Token { result, _ := p.convertTokensHelper(tokens, css_lexer.TEndOfFile, convertTokensOpts{}) return result } type convertTokensOpts struct { allowImports bool verbatimWhitespace bool isInsideCalcFunction bool } func (p *parser) convertTokensHelper(tokens []css_lexer.Token, close css_lexer.T, opts convertTokensOpts) ([]css_ast.Token, []css_lexer.Token) { result := []css_ast.Token{} var nextWhitespace css_ast.WhitespaceFlags // Enable verbatim whitespace mode when the first two non-whitespace tokens // are a CSS variable name followed by a colon. This is because it could be // a form of CSS variable usage, and removing whitespace could potentially // break this usage. For example, the following CSS is ignored by Chrome if // the whitespace isn't preserved: // // @supports (--foo: ) { // html { background: green; } // } // // Strangely whitespace removal doesn't cause the declaration to be ignored // in Firefox or Safari, so there's definitely a browser bug somewhere. if !opts.verbatimWhitespace { for i, t := range tokens { if t.Kind == css_lexer.TWhitespace { continue } if t.Kind == css_lexer.TIdent && strings.HasPrefix(t.DecodedText(p.source.Contents), "--") { for _, t := range tokens[i+1:] { if t.Kind == css_lexer.TWhitespace { continue } if t.Kind == css_lexer.TColon { opts.verbatimWhitespace = true } break } } break } } loop: for len(tokens) > 0 { t := tokens[0] tokens = tokens[1:] if t.Kind == close { break loop } token := css_ast.Token{ Loc: t.Range.Loc, Kind: t.Kind, Text: t.DecodedText(p.source.Contents), Whitespace: nextWhitespace, } nextWhitespace = 0 // Warn about invalid "+" and "-" operators that break the containing "calc()" if opts.isInsideCalcFunction && t.Kind.IsNumeric() && len(result) > 0 && result[len(result)-1].Kind.IsNumeric() && (strings.HasPrefix(token.Text, "+") || strings.HasPrefix(token.Text, "-")) { // "calc(1+2)" and "calc(1-2)" are invalid p.log.AddID(logger.MsgID_CSS_InvalidCalc, logger.Warning, &p.tracker, logger.Range{Loc: t.Range.Loc, Len: 1}, fmt.Sprintf("The %q operator only works if there is whitespace on both sides", token.Text[:1])) } switch t.Kind { case css_lexer.TWhitespace: if last := len(result) - 1; last >= 0 { result[last].Whitespace |= css_ast.WhitespaceAfter } nextWhitespace = css_ast.WhitespaceBefore continue case css_lexer.TDelimPlus, css_lexer.TDelimMinus: // Warn about invalid "+" and "-" operators that break the containing "calc()" if opts.isInsideCalcFunction && len(tokens) > 0 { if len(result) == 0 || result[len(result)-1].Kind == css_lexer.TComma { // "calc(-(1 + 2))" is invalid p.log.AddID(logger.MsgID_CSS_InvalidCalc, logger.Warning, &p.tracker, t.Range, fmt.Sprintf("%q can only be used as an infix operator, not a prefix operator", token.Text)) } else if token.Whitespace != css_ast.WhitespaceBefore || tokens[0].Kind != css_lexer.TWhitespace { // "calc(1- 2)" and "calc(1 -(2))" are invalid p.log.AddID(logger.MsgID_CSS_InvalidCalc, logger.Warning, &p.tracker, t.Range, fmt.Sprintf("The %q operator only works if there is whitespace on both sides", token.Text)) } } case css_lexer.TNumber: if p.options.minifySyntax { if text, ok := mangleNumber(token.Text); ok { token.Text = text } } case css_lexer.TPercentage: if p.options.minifySyntax { if text, ok := mangleNumber(token.PercentageValue()); ok { token.Text = text + "%" } } case css_lexer.TDimension: token.UnitOffset = t.UnitOffset if p.options.minifySyntax { if text, ok := mangleNumber(token.DimensionValue()); ok { token.Text = text + token.DimensionUnit() token.UnitOffset = uint16(len(text)) } if value, unit, ok := mangleDimension(token.DimensionValue(), token.DimensionUnit()); ok { token.Text = value + unit token.UnitOffset = uint16(len(value)) } } case css_lexer.TURL: token.PayloadIndex = uint32(len(p.importRecords)) var flags ast.ImportRecordFlags if !opts.allowImports { flags |= ast.IsUnused } p.importRecords = append(p.importRecords, ast.ImportRecord{ Kind: ast.ImportURL, Path: logger.Path{Text: token.Text}, Range: t.Range, Flags: flags, }) token.Text = "" case css_lexer.TFunction: var nested []css_ast.Token original := tokens nestedOpts := opts if strings.EqualFold(token.Text, "var") { // CSS variables require verbatim whitespace for correctness nestedOpts.verbatimWhitespace = true } 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 && strings.EqualFold(token.Text, "calc") { token = p.tryToReduceCalcExpression(token) } // Treat a URL function call with a string just like a URL token 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 token.PayloadIndex = uint32(len(p.importRecords)) var flags ast.ImportRecordFlags if !opts.allowImports { flags |= ast.IsUnused } p.importRecords = append(p.importRecords, ast.ImportRecord{ Kind: ast.ImportURL, Path: logger.Path{Text: nested[0].Text}, Range: original[0].Range, Flags: flags, }) } case css_lexer.TOpenParen: var nested []css_ast.Token nested, tokens = p.convertTokensHelper(tokens, css_lexer.TCloseParen, opts) token.Children = &nested case css_lexer.TOpenBrace: var nested []css_ast.Token nested, tokens = p.convertTokensHelper(tokens, css_lexer.TCloseBrace, opts) // Pretty-printing: insert leading and trailing whitespace when not minifying if !opts.verbatimWhitespace && !p.options.minifyWhitespace && len(nested) > 0 { nested[0].Whitespace |= css_ast.WhitespaceBefore nested[len(nested)-1].Whitespace |= css_ast.WhitespaceAfter } token.Children = &nested case css_lexer.TOpenBracket: var nested []css_ast.Token nested, tokens = p.convertTokensHelper(tokens, css_lexer.TCloseBracket, opts) token.Children = &nested } result = append(result, token) } if !opts.verbatimWhitespace { for i := range result { token := &result[i] // Always remove leading and trailing whitespace if i == 0 { token.Whitespace &= ^css_ast.WhitespaceBefore } if i+1 == len(result) { token.Whitespace &= ^css_ast.WhitespaceAfter } switch token.Kind { case css_lexer.TComma: // Assume that whitespace can always be removed before a comma token.Whitespace &= ^css_ast.WhitespaceBefore if i > 0 { result[i-1].Whitespace &= ^css_ast.WhitespaceAfter } // Assume whitespace can always be added after a comma if p.options.minifyWhitespace { token.Whitespace &= ^css_ast.WhitespaceAfter if i+1 < len(result) { result[i+1].Whitespace &= ^css_ast.WhitespaceBefore } } else { token.Whitespace |= css_ast.WhitespaceAfter if i+1 < len(result) { result[i+1].Whitespace |= css_ast.WhitespaceBefore } } } } } // Insert an explicit whitespace token if we're in verbatim mode and all // tokens were whitespace. In this case there is no token to attach the // whitespace before/after flags so this is the only way to represent this. // This is the only case where this function generates an explicit whitespace // token. It represents whitespace as flags in all other cases. if opts.verbatimWhitespace && len(result) == 0 && nextWhitespace == css_ast.WhitespaceBefore { result = append(result, css_ast.Token{ Kind: css_lexer.TWhitespace, }) } return result, tokens } func shiftDot(text string, dotOffset int) (string, bool) { // This doesn't handle numbers with exponents if strings.ContainsAny(text, "eE") { return "", false } // Handle a leading sign sign := "" if len(text) > 0 && (text[0] == '-' || text[0] == '+') { sign = text[:1] text = text[1:] } // Remove the dot dot := strings.IndexByte(text, '.') if dot == -1 { dot = len(text) } else { text = text[:dot] + text[dot+1:] } // Move the dot dot += dotOffset // Remove any leading zeros before the dot for len(text) > 0 && dot > 0 && text[0] == '0' { text = text[1:] dot-- } // Remove any trailing zeros after the dot for len(text) > 0 && len(text) > dot && text[len(text)-1] == '0' { text = text[:len(text)-1] } // Does this number have no fractional component? if dot >= len(text) { trailingZeros := strings.Repeat("0", dot-len(text)) return fmt.Sprintf("%s%s%s", sign, text, trailingZeros), true } // Potentially add leading zeros if dot < 0 { text = strings.Repeat("0", -dot) + text dot = 0 } // Insert the dot again return fmt.Sprintf("%s%s.%s", sign, text[:dot], text[dot:]), true } func mangleDimension(value string, unit string) (string, string, bool) { const msLen = 2 const sLen = 1 // Mangle times: https://developer.mozilla.org/en-US/docs/Web/CSS/time if strings.EqualFold(unit, "ms") { if shifted, ok := shiftDot(value, -3); ok && len(shifted)+sLen < len(value)+msLen { // Convert "ms" to "s" if shorter return shifted, "s", true } } if strings.EqualFold(unit, "s") { if shifted, ok := shiftDot(value, 3); ok && len(shifted)+msLen < len(value)+sLen { // Convert "s" to "ms" if shorter return shifted, "ms", true } } return "", "", false } func mangleNumber(t string) (string, bool) { original := t if dot := strings.IndexByte(t, '.'); dot != -1 { // Remove trailing zeros for len(t) > 0 && t[len(t)-1] == '0' { t = t[:len(t)-1] } // Remove the decimal point if it's unnecessary if dot+1 == len(t) { t = t[:dot] if t == "" || t == "+" || t == "-" { t += "0" } } else { // Remove a leading zero if len(t) >= 3 && t[0] == '0' && t[1] == '.' && t[2] >= '0' && t[2] <= '9' { t = t[1:] } else if len(t) >= 4 && (t[0] == '+' || t[0] == '-') && t[1] == '0' && t[2] == '.' && t[3] >= '0' && t[3] <= '9' { t = t[0:1] + t[2:] } } } return t, t != original } func (p *parser) parseSelectorRule(isTopLevel bool, opts parseSelectorOpts) css_ast.Rule { // Save and restore the local symbol state in case there are any bare // ":global" or ":local" annotations. The effect of these should be scoped // to within the selector rule. local := p.makeLocalSymbols preludeStart := p.index // Try parsing the prelude as a selector list if list, ok := p.parseSelectorList(opts); ok { canInlineNoOpNesting := true for _, sel := range list { // We cannot transform the CSS "a, b::before { & { color: red } }" into // "a, b::before { color: red }" because it's basically equivalent to // ":is(a, b::before) { color: red }" which only applies to "a", not to // "b::before" 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. if sel.UsesPseudoElement() { canInlineNoOpNesting = false break } } selector := css_ast.RSelector{Selectors: list} matchingLoc := p.current().Range.Loc if p.expect(css_lexer.TOpenBrace) { p.inSelectorSubtree++ declOpts := listOfDeclarationsOpts{ canInlineNoOpNesting: canInlineNoOpNesting, } // Prepare for "composes" declarations if opts.composesContext != nil && len(list) == 1 && len(list[0].Selectors) == 1 && list[0].Selectors[0].IsSingleAmpersand() { // Support code like this: // // .foo { // :local { composes: bar } // :global { composes: baz } // } // declOpts.composesContext = opts.composesContext } else { composesContext := composesContext{parentRange: list[0].Selectors[0].Range()} if opts.composesContext != nil { composesContext.problemRange = opts.composesContext.parentRange } for _, sel := range list { first := sel.Selectors[0] if first.Combinator.Byte != 0 { composesContext.problemRange = logger.Range{Loc: first.Combinator.Loc, Len: 1} } else if first.TypeSelector != nil { composesContext.problemRange = first.TypeSelector.Range() } else if len(first.NestingSelectorLocs) > 0 { composesContext.problemRange = logger.Range{Loc: first.NestingSelectorLocs[0], Len: 1} } else { for i, ss := range first.SubclassSelectors { class, ok := ss.Data.(*css_ast.SSClass) if i > 0 || !ok { composesContext.problemRange = ss.Range } else { composesContext.parentRefs = append(composesContext.parentRefs, class.Name.Ref) } } } if composesContext.problemRange.Len > 0 { break } if len(sel.Selectors) > 1 { composesContext.problemRange = sel.Selectors[1].Range() break } } declOpts.composesContext = &composesContext } selector.Rules = p.parseListOfDeclarations(declOpts) p.inSelectorSubtree-- closeBraceLoc := p.current().Range.Loc if p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) { selector.CloseBraceLoc = closeBraceLoc } p.makeLocalSymbols = local return css_ast.Rule{Loc: p.tokens[preludeStart].Range.Loc, Data: &selector} } } p.makeLocalSymbols = local p.index = preludeStart // Otherwise, parse a generic qualified rule return p.parseQualifiedRule(parseQualifiedRuleOpts{ isAlreadyInvalid: true, isTopLevel: isTopLevel, isDeclarationContext: opts.isDeclarationContext, }) } type parseQualifiedRuleOpts struct { isAlreadyInvalid bool isTopLevel bool isDeclarationContext bool } func (p *parser) parseQualifiedRule(opts parseQualifiedRuleOpts) css_ast.Rule { preludeStart := p.index preludeLoc := p.current().Range.Loc loop: for { switch p.current().Kind { case css_lexer.TOpenBrace, css_lexer.TEndOfFile: break loop case css_lexer.TCloseBrace: if !opts.isTopLevel { break loop } case css_lexer.TSemicolon: if opts.isDeclarationContext { return css_ast.Rule{Loc: preludeLoc, Data: &css_ast.RBadDeclaration{ Tokens: p.convertTokens(p.tokens[preludeStart:p.index]), }} } } p.parseComponentValue() } qualified := css_ast.RQualified{ Prelude: p.convertTokens(p.tokens[preludeStart:p.index]), } matchingLoc := p.current().Range.Loc if p.eat(css_lexer.TOpenBrace) { qualified.Rules = p.parseListOfDeclarations(listOfDeclarationsOpts{}) closeBraceLoc := p.current().Range.Loc if p.expectWithMatchingLoc(css_lexer.TCloseBrace, matchingLoc) { qualified.CloseBraceLoc = closeBraceLoc } } else if !opts.isAlreadyInvalid { p.expect(css_lexer.TOpenBrace) } return css_ast.Rule{Loc: preludeLoc, Data: &qualified} } type endOfRuleScan uint8 const ( endOfRuleUnknown endOfRuleScan = iota endOfRuleSemicolon endOfRuleOpenBrace ) // Note: This was a late change to the CSS nesting syntax. // See also: https://github.com/w3c/csswg-drafts/issues/7961 func (p *parser) scanForEndOfRule() (endOfRuleScan, int) { var initialStack [4]css_lexer.T stack := initialStack[:0] for i, t := range p.tokens[p.index:] { switch t.Kind { case css_lexer.TSemicolon: if len(stack) == 0 { return endOfRuleSemicolon, p.index + i } case css_lexer.TFunction, css_lexer.TOpenParen: stack = append(stack, css_lexer.TCloseParen) case css_lexer.TOpenBracket: stack = append(stack, css_lexer.TCloseBracket) case css_lexer.TOpenBrace: if len(stack) == 0 { return endOfRuleOpenBrace, p.index + i } stack = append(stack, css_lexer.TCloseBrace) case css_lexer.TCloseParen, css_lexer.TCloseBracket: if n := len(stack); n > 0 && t.Kind == stack[n-1] { stack = stack[:n-1] } case css_lexer.TCloseBrace: if n := len(stack); n > 0 && t.Kind == stack[n-1] { stack = stack[:n-1] } else { return endOfRuleUnknown, -1 } } } return endOfRuleUnknown, -1 } func (p *parser) parseDeclaration() css_ast.Rule { // Parse the key keyStart := p.index keyRange := p.tokens[keyStart].Range keyIsIdent := p.expect(css_lexer.TIdent) ok := false if keyIsIdent { p.eat(css_lexer.TWhitespace) ok = p.eat(css_lexer.TColon) } // Parse the value valueStart := p.index stop: for { switch p.current().Kind { case css_lexer.TEndOfFile, css_lexer.TSemicolon, css_lexer.TCloseBrace: break stop default: p.parseComponentValue() } } // Stop now if this is not a valid declaration if !ok { if keyIsIdent { 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]), }} } keyToken := p.tokens[keyStart] keyText := keyToken.DecodedText(p.source.Contents) value := p.tokens[valueStart:p.index] verbatimWhitespace := strings.HasPrefix(keyText, "--") // Remove trailing "!important" important := false i := len(value) - 1 if i >= 0 && value[i].Kind == css_lexer.TWhitespace { i-- } if i >= 0 && value[i].Kind == css_lexer.TIdent && strings.EqualFold(value[i].DecodedText(p.source.Contents), "important") { i-- if i >= 0 && value[i].Kind == css_lexer.TWhitespace { i-- } if i >= 0 && value[i].Kind == css_lexer.TDelimExclamation { value = value[:i] important = true } } result, _ := p.convertTokensHelper(value, css_lexer.TEndOfFile, convertTokensOpts{ allowImports: true, // CSS variables require verbatim whitespace for correctness verbatimWhitespace: verbatimWhitespace, }) // Insert or remove whitespace before the first token if !verbatimWhitespace && len(result) > 0 { if p.options.minifyWhitespace { result[0].Whitespace &= ^css_ast.WhitespaceBefore } else { result[0].Whitespace |= css_ast.WhitespaceBefore } } 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(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, Notes: []logger.MsgData{{Text: fmt.Sprintf("Did you mean %q instead?", corrected)}}}) } } return css_ast.Rule{Loc: keyRange.Loc, Data: &css_ast.RDeclaration{ Key: key, KeyText: keyText, KeyRange: keyToken.Range, Value: result, Important: important, }} } func (p *parser) parseComponentValue() { switch p.current().Kind { case css_lexer.TFunction: p.parseBlock(css_lexer.TFunction, css_lexer.TCloseParen) case css_lexer.TOpenParen: p.parseBlock(css_lexer.TOpenParen, css_lexer.TCloseParen) case css_lexer.TOpenBrace: p.parseBlock(css_lexer.TOpenBrace, css_lexer.TCloseBrace) case css_lexer.TOpenBracket: p.parseBlock(css_lexer.TOpenBracket, css_lexer.TCloseBracket) case css_lexer.TEndOfFile: p.unexpected() default: p.advance() } } func (p *parser) parseBlock(open css_lexer.T, close css_lexer.T) { current := p.current() matchingStart := current.Range.End() - 1 if p.expect(open) { for !p.eat(close) { if p.peek(css_lexer.TEndOfFile) { p.expectWithMatchingLoc(close, logger.Loc{Start: matchingStart}) return } p.parseComponentValue() } } }