From e4c55af7a2c5dc8496d3ef0f4401aab665c90d63 Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Fri, 8 Dec 2023 20:36:15 -0500 Subject: [PATCH] css gradients: lower colors, fix double positions --- compat-table/src/index.ts | 1 + compat-table/src/mdn.ts | 7 + internal/compat/css_table.go | 28 ++- internal/css_parser/css_decls.go | 13 +- internal/css_parser/css_decls_box_shadow.go | 2 +- internal/css_parser/css_decls_color.go | 36 ++++ internal/css_parser/css_decls_gradient.go | 207 ++++++++++++++++++++ internal/css_parser/css_parser_test.go | 55 ++++++ 8 files changed, 338 insertions(+), 11 deletions(-) create mode 100644 internal/css_parser/css_decls_gradient.go diff --git a/compat-table/src/index.ts b/compat-table/src/index.ts index ce18be3900..b0dc08a5f9 100644 --- a/compat-table/src/index.ts +++ b/compat-table/src/index.ts @@ -90,6 +90,7 @@ export const jsFeatures = { export type CSSFeature = keyof typeof cssFeatures export const cssFeatures = { ColorFunctions: true, + GradientDoublePosition: true, HexRGBA: true, HWB: true, InlineStyle: true, diff --git a/compat-table/src/mdn.ts b/compat-table/src/mdn.ts index dec41438f8..e3f29dfe9d 100644 --- a/compat-table/src/mdn.ts +++ b/compat-table/src/mdn.ts @@ -33,6 +33,13 @@ const cssFeatures: Partial> = { 'css.types.color.oklab', 'css.types.color.oklch', ], + GradientDoublePosition: [ + 'css.types.image.gradient.linear-gradient.doubleposition', + 'css.types.image.gradient.radial-gradient.doubleposition', + 'css.types.image.gradient.conic-gradient.doubleposition', + 'css.types.image.gradient.repeating-linear-gradient.doubleposition', + 'css.types.image.gradient.repeating-radial-gradient.doubleposition', + ], HexRGBA: 'css.types.color.rgb_hexadecimal_notation.alpha_hexadecimal_notation', HWB: 'css.types.color.hwb', InsetProperty: 'css.properties.inset', diff --git a/internal/compat/css_table.go b/internal/compat/css_table.go index 2697644158..737e4523f3 100644 --- a/internal/compat/css_table.go +++ b/internal/compat/css_table.go @@ -10,6 +10,7 @@ type CSSFeature uint16 const ( ColorFunctions CSSFeature = 1 << iota + GradientDoublePosition HWB HexRGBA InlineStyle @@ -21,15 +22,16 @@ const ( ) var StringToCSSFeature = map[string]CSSFeature{ - "color-functions": ColorFunctions, - "hwb": HWB, - "hex-rgba": HexRGBA, - "inline-style": InlineStyle, - "inset-property": InsetProperty, - "is-pseudo-class": IsPseudoClass, - "modern-rgb-hsl": Modern_RGB_HSL, - "nesting": Nesting, - "rebecca-purple": RebeccaPurple, + "color-functions": ColorFunctions, + "gradient-double-position": GradientDoublePosition, + "hwb": HWB, + "hex-rgba": HexRGBA, + "inline-style": InlineStyle, + "inset-property": InsetProperty, + "is-pseudo-class": IsPseudoClass, + "modern-rgb-hsl": Modern_RGB_HSL, + "nesting": Nesting, + "rebecca-purple": RebeccaPurple, } func (features CSSFeature) Has(feature CSSFeature) bool { @@ -49,6 +51,14 @@ var cssTable = map[CSSFeature]map[Engine][]versionRange{ Opera: {{start: v{97, 0, 0}}}, Safari: {{start: v{15, 4, 0}}}, }, + GradientDoublePosition: { + Chrome: {{start: v{72, 0, 0}}}, + Edge: {{start: v{79, 0, 0}}}, + Firefox: {{start: v{83, 0, 0}}}, + IOS: {{start: v{12, 2, 0}}}, + Opera: {{start: v{60, 0, 0}}}, + Safari: {{start: v{12, 1, 0}}}, + }, HWB: { Chrome: {{start: v{101, 0, 0}}}, Edge: {{start: v{101, 0, 0}}}, diff --git a/internal/css_parser/css_decls.go b/internal/css_parser/css_decls.go index d5fa04e55a..eaca876e13 100644 --- a/internal/css_parser/css_decls.go +++ b/internal/css_parser/css_decls.go @@ -169,7 +169,18 @@ func (p *parser) processDeclarations(rules []css_ast.Rule, composesContext *comp case css_ast.DBackground: for i, t := range decl.Value { - decl.Value[i] = p.lowerAndMinifyColor(t, wouldClipColor) + t = p.lowerAndMinifyColor(t, wouldClipColor) + t = p.lowerAndMinifyGradient(t, wouldClipColor) + decl.Value[i] = t + } + + case css_ast.DBackgroundImage, + css_ast.DBorderImage, + css_ast.DMaskImage: + + for i, t := range decl.Value { + t = p.lowerAndMinifyGradient(t, wouldClipColor) + decl.Value[i] = t } case css_ast.DBackgroundColor, diff --git a/internal/css_parser/css_decls_box_shadow.go b/internal/css_parser/css_decls_box_shadow.go index 1d77861c39..5bc730f4b8 100644 --- a/internal/css_parser/css_decls_box_shadow.go +++ b/internal/css_parser/css_decls_box_shadow.go @@ -36,7 +36,7 @@ func (p *parser) lowerAndMangleBoxShadow(tokens []css_ast.Token, wouldClipColor numbersDone = true } - if _, ok := parseColor(t); ok { + if looksLikeColor(t) { colorCount++ tokens[i] = p.lowerAndMinifyColor(t, wouldClipColor) } else if t.Kind == css_lexer.TIdent && strings.EqualFold(t.Text, "inset") { diff --git a/internal/css_parser/css_decls_color.go b/internal/css_parser/css_decls_color.go index 46bbc38fa5..d9ffe64564 100644 --- a/internal/css_parser/css_decls_color.go +++ b/internal/css_parser/css_decls_color.go @@ -426,6 +426,42 @@ type parsedColor struct { sRGB bool } +func looksLikeColor(token css_ast.Token) bool { + switch token.Kind { + case css_lexer.TIdent: + if _, ok := colorNameToHex[strings.ToLower(token.Text)]; ok { + return true + } + + case css_lexer.THash: + switch len(token.Text) { + case 3, 4, 6, 8: + if _, ok := parseHex(token.Text); ok { + return true + } + } + + case css_lexer.TFunction: + switch strings.ToLower(token.Text) { + case + "color-mix", + "color", + "hsl", + "hsla", + "hwb", + "lab", + "lch", + "oklab", + "oklch", + "rgb", + "rgba": + return true + } + } + + return false +} + func parseColor(token css_ast.Token) (parsedColor, bool) { text := token.Text diff --git a/internal/css_parser/css_decls_gradient.go b/internal/css_parser/css_decls_gradient.go new file mode 100644 index 0000000000..fef5ef1112 --- /dev/null +++ b/internal/css_parser/css_decls_gradient.go @@ -0,0 +1,207 @@ +package css_parser + +import ( + "strings" + + "github.com/evanw/esbuild/internal/compat" + "github.com/evanw/esbuild/internal/css_ast" + "github.com/evanw/esbuild/internal/css_lexer" +) + +type gradientKind uint8 + +const ( + linearGradient gradientKind = iota + radialGradient + conicGradient +) + +type parsedGradient struct { + initialTokens []css_ast.Token + colorStops []colorStop + kind gradientKind + repeating bool +} + +type colorStop struct { + positions []css_ast.Token + color css_ast.Token +} + +func parseGradient(token css_ast.Token) (gradient parsedGradient, success bool) { + if token.Kind != css_lexer.TFunction { + return + } + + switch strings.ToLower(token.Text) { + case "linear-gradient": + gradient.kind = linearGradient + + case "radial-gradient": + gradient.kind = radialGradient + + case "conic-gradient": + gradient.kind = conicGradient + + case "repeating-linear-gradient": + gradient.kind = linearGradient + gradient.repeating = true + + case "repeating-radial-gradient": + gradient.kind = radialGradient + gradient.repeating = true + + case "repeating-conic-gradient": + gradient.kind = conicGradient + gradient.repeating = true + + default: + return + } + + // Bail if any token is a "var()" since it may introduce commas + tokens := *token.Children + for _, t := range tokens { + if t.Kind == css_lexer.TFunction && strings.EqualFold(t.Text, "var") { + return + } + } + + // Try to strip the initial tokens + if len(tokens) > 0 && !looksLikeColor(tokens[0]) { + i := 0 + for i < len(tokens) && tokens[i].Kind != css_lexer.TComma { + i++ + } + gradient.initialTokens = tokens[:i] + if i < len(tokens) { + tokens = tokens[i+1:] + } else { + tokens = nil + } + } + + // Try to parse the color stops + for len(tokens) > 0 { + // Parse the color + color := tokens[0] + if !looksLikeColor(color) { + return + } + tokens = tokens[1:] + + // Parse up to two positions + var positions []css_ast.Token + for len(positions) < 2 && len(tokens) > 0 { + position := tokens[0] + if position.Kind.IsNumeric() || (position.Kind == css_lexer.TFunction && strings.EqualFold(position.Text, "calc")) { + positions = append(positions, position) + } else { + break + } + tokens = tokens[1:] + } + + // Add the color stop + gradient.colorStops = append(gradient.colorStops, colorStop{ + color: color, + positions: positions, + }) + + // Parse the comma + if len(tokens) > 0 { + if tokens[0].Kind != css_lexer.TComma { + return + } + tokens = tokens[1:] + } + } + + success = true + return +} + +func (p *parser) generateGradient(token css_ast.Token, gradient parsedGradient) css_ast.Token { + var children []css_ast.Token + commaToken := p.commaToken(token.Loc) + + children = append(children, gradient.initialTokens...) + for _, stop := range gradient.colorStops { + if len(children) > 0 { + children = append(children, commaToken) + } + children = append(children, stop.color) + children = append(children, stop.positions...) + } + + token.Children = &children + return token +} + +func (p *parser) lowerAndMinifyGradient(token css_ast.Token, wouldClipColor *bool) css_ast.Token { + gradient, ok := parseGradient(token) + if !ok { + return token + } + + // Lower all colors in the gradient stop + for i, stop := range gradient.colorStops { + gradient.colorStops[i].color = p.lowerAndMinifyColor(stop.color, wouldClipColor) + } + + if p.options.unsupportedCSSFeatures.Has(compat.GradientDoublePosition) { + // Replace double positions with duplicated single positions + for _, stop := range gradient.colorStops { + if len(stop.positions) > 1 { + gradient.colorStops = switchToSinglePositions(gradient.colorStops) + break + } + } + } else if p.options.minifySyntax { + // Replace duplicated single positions with double positions + for i, stop := range gradient.colorStops { + if i > 0 && len(stop.positions) == 1 { + if prev := gradient.colorStops[i-1]; len(prev.positions) == 1 && + css_ast.TokensEqual([]css_ast.Token{prev.color}, []css_ast.Token{stop.color}, nil) { + gradient.colorStops = switchToDoublePositions(gradient.colorStops) + break + } + } + } + } + + return p.generateGradient(token, gradient) +} + +func switchToSinglePositions(double []colorStop) (single []colorStop) { + for _, stop := range double { + for _, position := range stop.positions { + position.Whitespace = css_ast.WhitespaceBefore + stop.positions = []css_ast.Token{position} + single = append(single, stop) + } + if len(stop.positions) == 0 { + single = append(single, stop) + } + } + return +} + +func switchToDoublePositions(single []colorStop) (double []colorStop) { + for i := 0; i < len(single); i++ { + stop := single[i] + if i+1 < len(single) && len(stop.positions) == 1 { + if next := single[i+1]; len(next.positions) == 1 && + css_ast.TokensEqual([]css_ast.Token{stop.color}, []css_ast.Token{next.color}, nil) { + double = append(double, colorStop{ + color: stop.color, + positions: []css_ast.Token{stop.positions[0], next.positions[0]}, + }) + i++ + continue + } + } + double = append(double, stop) + } + return +} diff --git a/internal/css_parser/css_parser_test.go b/internal/css_parser/css_parser_test.go index acf1cc5cd4..ae097e98b1 100644 --- a/internal/css_parser/css_parser_test.go +++ b/internal/css_parser/css_parser_test.go @@ -737,6 +737,61 @@ func TestBackground(t *testing.T) { expectPrintedLower(t, "a { background: border-box #11223344 }", "a {\n background: border-box rgba(17, 34, 51, .267);\n}\n", "") } +func TestGradient(t *testing.T) { + gradientKinds := []string{ + "linear-gradient", + "radial-gradient", + "conic-gradient", + "repeating-linear-gradient", + "repeating-radial-gradient", + "repeating-conic-gradient", + } + + for _, gradient := range gradientKinds { + var code string + + // Different properties + expectPrinted(t, "a { background: "+gradient+"(red, blue) }", "a {\n background: "+gradient+"(red, blue);\n}\n", "") + expectPrinted(t, "a { background-image: "+gradient+"(red, blue) }", "a {\n background-image: "+gradient+"(red, blue);\n}\n", "") + expectPrinted(t, "a { border-image: "+gradient+"(red, blue) }", "a {\n border-image: "+gradient+"(red, blue);\n}\n", "") + expectPrinted(t, "a { mask-image: "+gradient+"(red, blue) }", "a {\n mask-image: "+gradient+"(red, blue);\n}\n", "") + + // Basic + code = "a { background: " + gradient + "(yellow, #11223344) }" + expectPrinted(t, code, "a {\n background: "+gradient+"(yellow, #11223344);\n}\n", "") + expectPrintedMangle(t, code, "a {\n background: "+gradient+"(#ff0, #1234);\n}\n", "") + expectPrintedLower(t, code, "a {\n background: "+gradient+"(yellow, rgba(17, 34, 51, .267));\n}\n", "") + + // Double positions + code = "a { background: " + gradient + "(green, red 10%, red 20%, yellow 70% 80%, black) }" + expectPrinted(t, code, "a {\n background: "+gradient+"(green, red 10%, red 20%, yellow 70% 80%, black);\n}\n", "") + expectPrintedMangle(t, code, "a {\n background: "+gradient+"(green, red 10% 20%, #ff0 70% 80%, #000);\n}\n", "") + expectPrintedLower(t, code, "a {\n background: "+gradient+"(green, red 10%, red 20%, yellow 70%, yellow 80%, black);\n}\n", "") + + // Out-of-gamut colors + code = "a { background: " + gradient + "(yellow, color(display-p3 1 0 0)) }" + expectPrinted(t, code, "a {\n background: "+gradient+"(yellow, color(display-p3 1 0 0));\n}\n", "") + expectPrintedMangle(t, code, "a {\n background: "+gradient+"(#ff0, color(display-p3 1 0 0));\n}\n", "") + expectPrintedLower(t, code, "a {\n background: "+gradient+"(yellow, #ff0f0e);\n "+ + "background: "+gradient+"(yellow, color(display-p3 1 0 0));\n}\n", "") + + // Whitespace + code = "a { background: " + gradient + "(color-mix(in lab,red,green)calc(1px)calc(2px),color-mix(in lab,blue,red)calc(98%)calc(99%)) }" + expectPrinted(t, code, "a {\n background: "+gradient+ + "(color-mix(in lab, red, green)calc(1px)calc(2px), color-mix(in lab, blue, red)calc(98%)calc(99%));\n}\n", "") + expectPrintedMangle(t, code, "a {\n background: "+gradient+ + "(color-mix(in lab, red, green)1px2px, color-mix(in lab, blue, red)98%99%);\n}\n", "") + expectPrintedMinify(t, code, "a{background:"+gradient+ + "(color-mix(in lab,red,green)calc(1px)calc(2px),color-mix(in lab,blue,red)calc(98%)calc(99%))}", "") + expectPrintedLower(t, code, "a {\n background: "+gradient+ + "(color-mix(in lab, red, green) calc(1px), color-mix(in lab, red, green) calc(2px),"+ + " color-mix(in lab, blue, red) calc(98%), color-mix(in lab, blue, red) calc(99%));\n}\n", "") + expectPrintedLowerMangle(t, code, "a {\n background: "+gradient+ + "(color-mix(in lab, red, green) 1px, color-mix(in lab, red, green) 2px,"+ + " color-mix(in lab, blue, red) 98%, color-mix(in lab, blue, red) 99%);\n}\n", "") + } +} + func TestDeclaration(t *testing.T) { expectPrinted(t, ".decl {}", ".decl {\n}\n", "") expectPrinted(t, ".decl { a: b }", ".decl {\n a: b;\n}\n", "")