2487 lines
74 KiB
Go
2487 lines
74 KiB
Go
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()
|
|
}
|
|
}
|
|
}
|