diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b72d9364fa..8db38ee146e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ ## Unreleased +* Support `An+B` syntax and `:nth-*()` pseudo-classes in CSS + + This adds support for the `:nth-child()`, `:nth-last-child()`, `:nth-of-type()`, and `:nth-last-of-type()` pseudo-classes to esbuild, which has the following consequences: + + * The [`An+B` syntax](https://drafts.csswg.org/css-syntax-3/#anb-microsyntax) is now parsed, so parse errors are now reported + * `An+B` values inside these pseudo-classes are now pretty-printed (e.g. a leading `+` will be stripped because it's not in the AST) + * When minification is enabled, `An+B` values are reduced to equivalent but shorter forms (e.g. `2n+0` => `2n`, `2n+1` => `odd`) + * Local CSS names in an `of` clause are now detected (e.g. in `:nth-child(2n of :local(.foo))` the name `foo` is now renamed) + + ```css + /* Original code */ + .foo:nth-child(+2n+1 of :local(.bar)) { + color: red; + } + + /* Old output (with --loader=local-css) */ + .stdin_foo:nth-child(+2n + 1 of :local(.bar)) { + color: red; + } + + /* New output (with --loader=local-css) */ + .stdin_foo:nth-child(2n+1 of .stdin_bar) { + color: red; + } + ``` + * Adjust CSS nesting parser for IE7 hacks ([#3272](https://github.com/evanw/esbuild/issues/3272)) This fixes a regression with esbuild's treatment of IE7 hacks in CSS. CSS nesting allows selectors to be used where declarations are expected. There's an IE7 hack where prefixing a declaration with a `*` causes that declaration to only be applied in IE7 due to a bug in IE7's CSS parser. However, it's valid for nested CSS selectors to start with `*`. So esbuild was incorrectly parsing these declarations and anything following it up until the next `{` as a selector for a nested CSS rule. This release changes esbuild's parser to terminate the parsing of selectors for nested CSS rules when a `;` is encountered to fix this edge case: diff --git a/internal/bundler_tests/bundler_css_test.go b/internal/bundler_tests/bundler_css_test.go index c5a28209292..07ab77e0df9 100644 --- a/internal/bundler_tests/bundler_css_test.go +++ b/internal/bundler_tests/bundler_css_test.go @@ -382,32 +382,62 @@ func TestImportCSSFromJSLocalVsGlobal(t *testing.T) { } func TestImportCSSFromJSLowerBareLocalAndGlobal(t *testing.T) { - css := ` - .before { color: #000 } - :local { .button { color: #000 } } - .after { color: #000 } + css_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/entry.js": ` + import styles from "./styles.css" + console.log(styles) + `, + "/styles.css": ` + .before { color: #000 } + :local { .button { color: #000 } } + .after { color: #000 } - .before { color: #001 } - :global { .button { color: #001 } } - .after { color: #001 } + .before { color: #001 } + :global { .button { color: #001 } } + .after { color: #001 } - div { :local { .button { color: #002 } } } - div { :global { .button { color: #003 } } } + div { :local { .button { color: #002 } } } + div { :global { .button { color: #003 } } } - :local(:global) { color: #004 } - :global(:local) { color: #005 } + :local(:global) { color: #004 } + :global(:local) { color: #005 } - :local(:global) { .button { color: #006 } } - :global(:local) { .button { color: #007 } } - ` + :local(:global) { .button { color: #006 } } + :global(:local) { .button { color: #007 } } + `, + }, + entryPaths: []string{"/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputDir: "/out", + ExtensionToLoader: map[string]config.Loader{ + ".js": config.LoaderJS, + ".css": config.LoaderLocalCSS, + }, + UnsupportedCSSFeatures: compat.Nesting, + }, + }) +} +func TestImportCSSFromJSNthIndexLocal(t *testing.T) { css_suite.expectBundled(t, bundled{ files: map[string]string{ "/entry.js": ` import styles from "./styles.css" console.log(styles) `, - "/styles.css": css, + "/styles.css": ` + :nth-child(2n of .local) { color: #000 } + :nth-child(2n of :local(#local), :global(.GLOBAL)) { color: #001 } + :nth-child(2n of .local1 :global .GLOBAL1, .GLOBAL2 :local .local2) { color: #002 } + .local1, :nth-child(2n of :global .GLOBAL), .local2 { color: #003 } + + :nth-last-child(2n of .local) { color: #000 } + :nth-last-child(2n of :local(#local), :global(.GLOBAL)) { color: #001 } + :nth-last-child(2n of .local1 :global .GLOBAL1, .GLOBAL2 :local .local2) { color: #002 } + .local1, :nth-last-child(2n of :global .GLOBAL), .local2 { color: #003 } + `, }, entryPaths: []string{"/entry.js"}, options: config.Options{ diff --git a/internal/bundler_tests/snapshots/snapshots_css.txt b/internal/bundler_tests/snapshots/snapshots_css.txt index 341bca53183..0db52dc266e 100644 --- a/internal/bundler_tests/snapshots/snapshots_css.txt +++ b/internal/bundler_tests/snapshots/snapshots_css.txt @@ -1029,6 +1029,50 @@ div .button { color: #007; } +================================================================================ +TestImportCSSFromJSNthIndexLocal +---------- /out/entry.js ---------- +// styles.css +var styles_default = { + local: "styles_local", + local1: "styles_local1", + local2: "styles_local2" +}; + +// entry.js +console.log(styles_default); + +---------- /out/entry.css ---------- +/* styles.css */ +:nth-child(2n of .styles_local) { + color: #000; +} +:nth-child(2n of #styles_local, .GLOBAL) { + color: #001; +} +:nth-child(2n of .styles_local1 .GLOBAL1, .GLOBAL2 .styles_local2) { + color: #002; +} +.styles_local1, +:nth-child(2n of .GLOBAL), +.styles_local2 { + color: #003; +} +:nth-last-child(2n of .styles_local) { + color: #000; +} +:nth-last-child(2n of #styles_local, .GLOBAL) { + color: #001; +} +:nth-last-child(2n of .styles_local1 .GLOBAL1, .GLOBAL2 .styles_local2) { + color: #002; +} +.styles_local1, +:nth-last-child(2n of .GLOBAL), +.styles_local2 { + color: #003; +} + ================================================================================ TestImportGlobalCSSFromJS ---------- /out/entry.js ---------- diff --git a/internal/css_ast/css_ast.go b/internal/css_ast/css_ast.go index 01b3cd51a55..8d7bf02d838 100644 --- a/internal/css_ast/css_ast.go +++ b/internal/css_ast/css_ast.go @@ -936,9 +936,17 @@ const ( PseudoClassIs PseudoClassLocal PseudoClassNot + PseudoClassNthChild + PseudoClassNthLastChild + PseudoClassNthLastOfType + PseudoClassNthOfType PseudoClassWhere ) +func (kind PseudoClassKind) HasNthIndex() bool { + return kind >= PseudoClassNthChild && kind <= PseudoClassNthOfType +} + func (kind PseudoClassKind) String() string { switch kind { case PseudoClassGlobal: @@ -951,6 +959,14 @@ func (kind PseudoClassKind) String() string { return "local" case PseudoClassNot: return "not" + case PseudoClassNthChild: + return "nth-child" + case PseudoClassNthLastChild: + return "nth-last-child" + case PseudoClassNthLastOfType: + return "nth-last-of-type" + case PseudoClassNthOfType: + return "nth-of-type" case PseudoClassWhere: return "where" default: @@ -958,20 +974,60 @@ func (kind PseudoClassKind) String() string { } } +// This is the "An+B" syntax +type NthIndex struct { + A string + B string // May be "even" or "odd" +} + +func (index *NthIndex) Minify() { + // "even" => "2n" + if index.B == "even" { + index.A = "2" + index.B = "" + return + } + + // "2n+1" => "odd" + if index.A == "2" && index.B == "1" { + index.A = "" + index.B = "odd" + return + } + + // "0n+1" => "1" + if index.A == "0" { + index.A = "" + if index.B == "" { + // "0n" => "0" + index.B = "0" + } + return + } + + // "1n+0" => "1n" + if index.B == "0" && index.A != "" { + index.B = "" + } +} + // See https://drafts.csswg.org/selectors/#grouping type SSPseudoClassWithSelectorList struct { - Kind PseudoClassKind Selectors []ComplexSelector + Index NthIndex + Kind PseudoClassKind } func (a *SSPseudoClassWithSelectorList) Equal(ss SS, check *CrossFileEqualityCheck) bool { b, ok := ss.(*SSPseudoClassWithSelectorList) - return ok && a.Kind == b.Kind && ComplexSelectorsEqual(a.Selectors, b.Selectors, check) + return ok && a.Kind == b.Kind && a.Index == b.Index && ComplexSelectorsEqual(a.Selectors, b.Selectors, check) } func (ss *SSPseudoClassWithSelectorList) Hash() uint32 { hash := uint32(5) hash = helpers.HashCombine(hash, uint32(ss.Kind)) + hash = helpers.HashCombineString(hash, ss.Index.A) + hash = helpers.HashCombineString(hash, ss.Index.B) hash = HashComplexSelectors(hash, ss.Selectors) return hash } diff --git a/internal/css_parser/css_parser_selector.go b/internal/css_parser/css_parser_selector.go index ad1140080d8..e1a6b0fedf8 100644 --- a/internal/css_parser/css_parser_selector.go +++ b/internal/css_parser/css_parser_selector.go @@ -2,6 +2,7 @@ package css_parser import ( "fmt" + "strings" "github.com/evanw/esbuild/internal/ast" "github.com/evanw/esbuild/internal/css_ast" @@ -14,6 +15,7 @@ type parseSelectorOpts struct { isDeclarationContext bool stopOnCloseParen bool onlyOneComplexSelector bool + noLeadingCombinator bool } func (p *parser) parseSelectorList(opts parseSelectorOpts) (list []css_ast.ComplexSelector, ok bool) { @@ -258,10 +260,13 @@ type parseComplexSelectorOpts struct { func (p *parser) parseComplexSelector(opts parseComplexSelectorOpts) (result css_ast.ComplexSelector, ok bool) { // This is an extension: https://drafts.csswg.org/css-nesting-1/ - combinator := p.parseCombinator() - if combinator.Byte != 0 { - p.shouldLowerNesting = true - p.eat(css_lexer.TWhitespace) + var combinator css_ast.Combinator + if !opts.noLeadingCombinator { + combinator = p.parseCombinator() + if combinator.Byte != 0 { + p.shouldLowerNesting = true + p.eat(css_lexer.TWhitespace) + } } // Parent @@ -618,6 +623,14 @@ func (p *parser) parsePseudoClassSelector(isElement bool) css_ast.SS { } case "not": kind = css_ast.PseudoClassNot + case "nth-child": + kind = css_ast.PseudoClassNthChild + case "nth-last-child": + kind = css_ast.PseudoClassNthLastChild + case "nth-of-type": + kind = css_ast.PseudoClassNthOfType + case "nth-last-of-type": + kind = css_ast.PseudoClassNthLastOfType case "where": kind = css_ast.PseudoClassWhere default: @@ -625,25 +638,60 @@ func (p *parser) parsePseudoClassSelector(isElement bool) css_ast.SS { } if ok { old := p.index - p.eat(css_lexer.TWhitespace) - - // ":local" forces local names and ":global" forces global names - oldLocal := p.makeLocalSymbols - p.makeLocalSymbols = local - selectors, ok := p.parseSelectorList(parseSelectorOpts{ - pseudoClassKind: kind, - stopOnCloseParen: true, - onlyOneComplexSelector: kind == css_ast.PseudoClassGlobal || kind == css_ast.PseudoClassLocal, - }) - p.makeLocalSymbols = oldLocal - - if ok && p.expectWithMatchingLoc(css_lexer.TCloseParen, matchingLoc) { - return &css_ast.SSPseudoClassWithSelectorList{Kind: kind, Selectors: selectors} - } + if kind.HasNthIndex() { + p.eat(css_lexer.TWhitespace) + + // Parse the "An+B" syntax + if index, ok := p.parseNthIndex(); ok { + var selectors []css_ast.ComplexSelector + + // Parse the optional "of" clause + if (kind == css_ast.PseudoClassNthChild || kind == css_ast.PseudoClassNthLastChild) && + p.peek(css_lexer.TIdent) && p.decoded() == "of" { + p.advance() + p.eat(css_lexer.TWhitespace) + + // Contain the effects of ":local" and ":global" + oldLocal := p.makeLocalSymbols + selectors, ok = p.parseSelectorList(parseSelectorOpts{ + stopOnCloseParen: true, + noLeadingCombinator: true, + }) + p.makeLocalSymbols = oldLocal + } + // "2n+0" => "2n" + if p.options.minifySyntax { + index.Minify() + } + + // Match the closing ")" + if ok && p.expectWithMatchingLoc(css_lexer.TCloseParen, matchingLoc) { + return &css_ast.SSPseudoClassWithSelectorList{Kind: kind, Selectors: selectors, Index: index} + } + } + } else { + p.eat(css_lexer.TWhitespace) + + // ":local" forces local names and ":global" forces global names + oldLocal := p.makeLocalSymbols + p.makeLocalSymbols = local + selectors, ok := p.parseSelectorList(parseSelectorOpts{ + pseudoClassKind: kind, + stopOnCloseParen: true, + onlyOneComplexSelector: kind == css_ast.PseudoClassGlobal || kind == css_ast.PseudoClassLocal, + }) + p.makeLocalSymbols = oldLocal + + // Match the closing ")" + if ok && p.expectWithMatchingLoc(css_lexer.TCloseParen, matchingLoc) { + return &css_ast.SSPseudoClassWithSelectorList{Kind: kind, Selectors: selectors} + } + } p.index = old } } + args := p.convertTokens(p.parseAnyValue()) p.expectWithMatchingLoc(css_lexer.TCloseParen, matchingLoc) return &css_ast.SSPseudoClass{IsElement: isElement, Name: text, Args: args} @@ -732,3 +780,176 @@ func (p *parser) parseCombinator() css_ast.Combinator { return css_ast.Combinator{} } } + +func parseInteger(text string) (string, bool) { + n := len(text) + if n == 0 { + return "", false + } + + // Trim leading zeros + start := 0 + for start < n && text[start] == '0' { + start++ + } + + // Make sure remaining characters are digits + if start == n { + return "0", true + } + for i := start; i < n; i++ { + if c := text[i]; c < '0' || c > '9' { + return "", false + } + } + return text[start:], true +} + +func (p *parser) parseNthIndex() (css_ast.NthIndex, bool) { + type sign uint8 + const ( + none sign = iota + negative + positive + ) + + // Reference: https://drafts.csswg.org/css-syntax-3/#anb-microsyntax + t0 := p.current() + text0 := p.decoded() + + // Handle "even" and "odd" + if t0.Kind == css_lexer.TIdent && (text0 == "even" || text0 == "odd") { + p.advance() + p.eat(css_lexer.TWhitespace) + return css_ast.NthIndex{B: text0}, true + } + + // Handle a single number + if t0.Kind == css_lexer.TNumber { + bNeg := false + if strings.HasPrefix(text0, "-") { + bNeg = true + text0 = text0[1:] + } else if strings.HasPrefix(text0, "+") { + text0 = text0[1:] + } + if b, ok := parseInteger(text0); ok { + if bNeg { + b = "-" + b + } + p.advance() + p.eat(css_lexer.TWhitespace) + return css_ast.NthIndex{B: b}, true + } + p.unexpected() + return css_ast.NthIndex{}, false + } + + aSign := none + if p.eat(css_lexer.TDelimPlus) { + aSign = positive + t0 = p.current() + text0 = p.decoded() + } + + // Everything from here must be able to contain an "n" + if t0.Kind != css_lexer.TIdent && t0.Kind != css_lexer.TDimension { + p.unexpected() + return css_ast.NthIndex{}, false + } + + // Check for a leading sign + if aSign == none { + if strings.HasPrefix(text0, "-") { + aSign = negative + text0 = text0[1:] + } else if strings.HasPrefix(text0, "+") { + text0 = text0[1:] + } + } + + // The string must contain an "n" + n := strings.IndexByte(text0, 'n') + if n < 0 { + p.unexpected() + return css_ast.NthIndex{}, false + } + + // Parse the number before the "n" + var a string + if n == 0 { + if aSign == negative { + a = "-1" + } else { + a = "1" + } + } else if aInt, ok := parseInteger(text0[:n]); ok { + if aSign == negative { + aInt = "-" + aInt + } + a = aInt + } else { + p.unexpected() + return css_ast.NthIndex{}, false + } + text0 = text0[n+1:] + + // Parse the stuff after the "n" + bSign := none + if strings.HasPrefix(text0, "-") { + text0 = text0[1:] + if b, ok := parseInteger(text0); ok { + p.advance() + p.eat(css_lexer.TWhitespace) + return css_ast.NthIndex{A: a, B: "-" + b}, true + } + bSign = negative + } + if text0 != "" { + p.unexpected() + return css_ast.NthIndex{}, false + } + p.advance() + p.eat(css_lexer.TWhitespace) + + // Parse an optional sign delimiter + if bSign == none { + if p.eat(css_lexer.TDelimMinus) { + bSign = negative + p.eat(css_lexer.TWhitespace) + } else if p.eat(css_lexer.TDelimPlus) { + bSign = positive + p.eat(css_lexer.TWhitespace) + } + } + + // Parse an optional trailing number + t1 := p.current() + text1 := p.decoded() + if t1.Kind == css_lexer.TNumber { + if bSign == none { + if strings.HasPrefix(text1, "-") { + bSign = negative + text1 = text1[1:] + } else if strings.HasPrefix(text1, "+") { + text1 = text1[1:] + } + } + if b, ok := parseInteger(text1); ok { + if bSign == negative { + b = "-" + b + } + p.advance() + p.eat(css_lexer.TWhitespace) + return css_ast.NthIndex{A: a, B: b}, true + } + } + + // If there is a trailing sign, then there must also be a trailing number + if bSign != none { + p.expect(css_lexer.TNumber) + return css_ast.NthIndex{}, false + } + + return css_ast.NthIndex{A: a}, true +} diff --git a/internal/css_parser/css_parser_test.go b/internal/css_parser/css_parser_test.go index 7d610590dd5..d00d4fe989c 100644 --- a/internal/css_parser/css_parser_test.go +++ b/internal/css_parser/css_parser_test.go @@ -2295,3 +2295,102 @@ func TestPrefixInsertion(t *testing.T) { "a { before: value; -ms-text-size-adjust: 2; text-size-adjust: 3; after: value }", "a {\n before: value;\n -ms-text-size-adjust: 2;\n -webkit-text-size-adjust: 3;\n text-size-adjust: 3;\n after: value;\n}\n", "") } + +func TestNthChild(t *testing.T) { + for _, nth := range []string{"nth-child", "nth-last-child"} { + expectPrinted(t, ":"+nth+"(x) {}", ":"+nth+"(x) {\n}\n", ": WARNING: Unexpected \"x\"\n") + expectPrinted(t, ":"+nth+"(1e2) {}", ":"+nth+"(1e2) {\n}\n", ": WARNING: Unexpected \"1e2\"\n") + expectPrinted(t, ":"+nth+"(-n-) {}", ":"+nth+"(-n-) {\n}\n", ": WARNING: Expected number but found \")\"\n") + expectPrinted(t, ":"+nth+"(-nn) {}", ":"+nth+"(-nn) {\n}\n", ": WARNING: Unexpected \"-nn\"\n") + expectPrinted(t, ":"+nth+"(-n-n) {}", ":"+nth+"(-n-n) {\n}\n", ": WARNING: Unexpected \"-n-n\"\n") + expectPrinted(t, ":"+nth+"(-2n-) {}", ":"+nth+"(-2n-) {\n}\n", ": WARNING: Expected number but found \")\"\n") + expectPrinted(t, ":"+nth+"(-2n-2n) {}", ":"+nth+"(-2n-2n) {\n}\n", ": WARNING: Unexpected \"-2n-2n\"\n") + expectPrinted(t, ":"+nth+"(+) {}", ":"+nth+"(+) {\n}\n", ": WARNING: Unexpected \")\"\n") + expectPrinted(t, ":"+nth+"(-) {}", ":"+nth+"(-) {\n}\n", ": WARNING: Unexpected \"-\"\n") + expectPrinted(t, ":"+nth+"(+ 2) {}", ":"+nth+"(+ 2) {\n}\n", ": WARNING: Unexpected whitespace\n") + expectPrinted(t, ":"+nth+"(- 2) {}", ":"+nth+"(- 2) {\n}\n", ": WARNING: Unexpected \"-\"\n") + + expectPrinted(t, ":"+nth+"(0) {}", ":"+nth+"(0) {\n}\n", "") + expectPrinted(t, ":"+nth+"(0 ) {}", ":"+nth+"(0) {\n}\n", "") + expectPrinted(t, ":"+nth+"( 0) {}", ":"+nth+"(0) {\n}\n", "") + expectPrinted(t, ":"+nth+"(00) {}", ":"+nth+"(0) {\n}\n", "") + expectPrinted(t, ":"+nth+"(01) {}", ":"+nth+"(1) {\n}\n", "") + expectPrinted(t, ":"+nth+"(0n) {}", ":"+nth+"(0n) {\n}\n", "") + expectPrinted(t, ":"+nth+"(n) {}", ":"+nth+"(n) {\n}\n", "") + expectPrinted(t, ":"+nth+"(-n) {}", ":"+nth+"(-n) {\n}\n", "") + expectPrinted(t, ":"+nth+"(1n) {}", ":"+nth+"(n) {\n}\n", "") + expectPrinted(t, ":"+nth+"(-1n) {}", ":"+nth+"(-n) {\n}\n", "") + expectPrinted(t, ":"+nth+"(2n) {}", ":"+nth+"(2n) {\n}\n", "") + expectPrinted(t, ":"+nth+"(-2n) {}", ":"+nth+"(-2n) {\n}\n", "") + + expectPrinted(t, ":"+nth+"(odd) {}", ":"+nth+"(odd) {\n}\n", "") + expectPrinted(t, ":"+nth+"(odd ) {}", ":"+nth+"(odd) {\n}\n", "") + expectPrinted(t, ":"+nth+"( odd) {}", ":"+nth+"(odd) {\n}\n", "") + expectPrinted(t, ":"+nth+"(even) {}", ":"+nth+"(even) {\n}\n", "") + expectPrinted(t, ":"+nth+"(even ) {}", ":"+nth+"(even) {\n}\n", "") + expectPrinted(t, ":"+nth+"( even) {}", ":"+nth+"(even) {\n}\n", "") + + expectPrinted(t, ":"+nth+"(n+3) {}", ":"+nth+"(n+3) {\n}\n", "") + expectPrinted(t, ":"+nth+"(n-3) {}", ":"+nth+"(n-3) {\n}\n", "") + expectPrinted(t, ":"+nth+"(n +3) {}", ":"+nth+"(n+3) {\n}\n", "") + expectPrinted(t, ":"+nth+"(n -3) {}", ":"+nth+"(n-3) {\n}\n", "") + expectPrinted(t, ":"+nth+"(n+ 3) {}", ":"+nth+"(n+3) {\n}\n", "") + expectPrinted(t, ":"+nth+"(n- 3) {}", ":"+nth+"(n-3) {\n}\n", "") + expectPrinted(t, ":"+nth+"( n + 3 ) {}", ":"+nth+"(n+3) {\n}\n", "") + expectPrinted(t, ":"+nth+"( n - 3 ) {}", ":"+nth+"(n-3) {\n}\n", "") + expectPrinted(t, ":"+nth+"(+n+3) {}", ":"+nth+"(n+3) {\n}\n", "") + expectPrinted(t, ":"+nth+"(+n-3) {}", ":"+nth+"(n-3) {\n}\n", "") + expectPrinted(t, ":"+nth+"(-n+3) {}", ":"+nth+"(-n+3) {\n}\n", "") + expectPrinted(t, ":"+nth+"(-n-3) {}", ":"+nth+"(-n-3) {\n}\n", "") + expectPrinted(t, ":"+nth+"( +n + 3 ) {}", ":"+nth+"(n+3) {\n}\n", "") + expectPrinted(t, ":"+nth+"( +n - 3 ) {}", ":"+nth+"(n-3) {\n}\n", "") + expectPrinted(t, ":"+nth+"( -n + 3 ) {}", ":"+nth+"(-n+3) {\n}\n", "") + expectPrinted(t, ":"+nth+"( -n - 3 ) {}", ":"+nth+"(-n-3) {\n}\n", "") + + expectPrinted(t, ":"+nth+"(2n+3) {}", ":"+nth+"(2n+3) {\n}\n", "") + expectPrinted(t, ":"+nth+"(2n-3) {}", ":"+nth+"(2n-3) {\n}\n", "") + expectPrinted(t, ":"+nth+"(2n +3) {}", ":"+nth+"(2n+3) {\n}\n", "") + expectPrinted(t, ":"+nth+"(2n -3) {}", ":"+nth+"(2n-3) {\n}\n", "") + expectPrinted(t, ":"+nth+"(2n+ 3) {}", ":"+nth+"(2n+3) {\n}\n", "") + expectPrinted(t, ":"+nth+"(2n- 3) {}", ":"+nth+"(2n-3) {\n}\n", "") + expectPrinted(t, ":"+nth+"( 2n + 3 ) {}", ":"+nth+"(2n+3) {\n}\n", "") + expectPrinted(t, ":"+nth+"( 2n - 3 ) {}", ":"+nth+"(2n-3) {\n}\n", "") + expectPrinted(t, ":"+nth+"(+2n+3) {}", ":"+nth+"(2n+3) {\n}\n", "") + expectPrinted(t, ":"+nth+"(+2n-3) {}", ":"+nth+"(2n-3) {\n}\n", "") + expectPrinted(t, ":"+nth+"(-2n+3) {}", ":"+nth+"(-2n+3) {\n}\n", "") + expectPrinted(t, ":"+nth+"(-2n-3) {}", ":"+nth+"(-2n-3) {\n}\n", "") + expectPrinted(t, ":"+nth+"( +2n + 3 ) {}", ":"+nth+"(2n+3) {\n}\n", "") + expectPrinted(t, ":"+nth+"( +2n - 3 ) {}", ":"+nth+"(2n-3) {\n}\n", "") + expectPrinted(t, ":"+nth+"( -2n + 3 ) {}", ":"+nth+"(-2n+3) {\n}\n", "") + expectPrinted(t, ":"+nth+"( -2n - 3 ) {}", ":"+nth+"(-2n-3) {\n}\n", "") + + expectPrinted(t, ":"+nth+"(2n of + .foo) {}", ":"+nth+"(2n of + .foo) {\n}\n", ": WARNING: Unexpected \"+\"\n") + expectPrinted(t, ":"+nth+"(2n of .foo, ~.bar) {}", ":"+nth+"(2n of .foo, ~.bar) {\n}\n", ": WARNING: Unexpected \"~\"\n") + expectPrinted(t, ":"+nth+"(2n of .foo) {}", ":"+nth+"(2n of .foo) {\n}\n", "") + expectPrinted(t, ":"+nth+"(2n of.foo+.bar) {}", ":"+nth+"(2n of .foo + .bar) {\n}\n", "") + expectPrinted(t, ":"+nth+"(2n of[href]) {}", ":"+nth+"(2n of [href]) {\n}\n", "") + expectPrinted(t, ":"+nth+"(2n of.foo,.bar) {}", ":"+nth+"(2n of .foo, .bar) {\n}\n", "") + expectPrinted(t, ":"+nth+"(2n of .foo, .bar) {}", ":"+nth+"(2n of .foo, .bar) {\n}\n", "") + expectPrinted(t, ":"+nth+"(2n of .foo , .bar ) {}", ":"+nth+"(2n of .foo, .bar) {\n}\n", "") + + expectPrintedMinify(t, ":"+nth+"(2n of [foo] , [bar] ) {}", ":"+nth+"(2n of[foo],[bar]){}", "") + expectPrintedMinify(t, ":"+nth+"(2n of .foo , .bar ) {}", ":"+nth+"(2n of.foo,.bar){}", "") + expectPrintedMinify(t, ":"+nth+"(2n of #foo , #bar ) {}", ":"+nth+"(2n of#foo,#bar){}", "") + expectPrintedMinify(t, ":"+nth+"(2n of :foo , :bar ) {}", ":"+nth+"(2n of:foo,:bar){}", "") + expectPrintedMinify(t, ":"+nth+"(2n of div , span ) {}", ":"+nth+"(2n of div,span){}", "") + + expectPrintedMangle(t, ":"+nth+"(even) { color: red }", ":"+nth+"(2n) {\n color: red;\n}\n", "") + expectPrintedMangle(t, ":"+nth+"(2n+1) { color: red }", ":"+nth+"(odd) {\n color: red;\n}\n", "") + expectPrintedMangle(t, ":"+nth+"(0n) { color: red }", ":"+nth+"(0) {\n color: red;\n}\n", "") + expectPrintedMangle(t, ":"+nth+"(0n+0) { color: red }", ":"+nth+"(0) {\n color: red;\n}\n", "") + expectPrintedMangle(t, ":"+nth+"(1n+0) { color: red }", ":"+nth+"(n) {\n color: red;\n}\n", "") + expectPrintedMangle(t, ":"+nth+"(0n-2) { color: red }", ":"+nth+"(-2) {\n color: red;\n}\n", "") + expectPrintedMangle(t, ":"+nth+"(0n+2) { color: red }", ":"+nth+"(2) {\n color: red;\n}\n", "") + } + + for _, nth := range []string{"nth-of-type", "nth-last-of-type"} { + expectPrinted(t, ":"+nth+"(2n of .foo) {}", ":"+nth+"(2n of .foo) {\n}\n", + ": WARNING: Expected \")\" to go with \"(\"\n: NOTE: The unbalanced \"(\" is here:\n") + expectPrinted(t, ":"+nth+"(+2n + 1) {}", ":"+nth+"(2n+1) {\n}\n", "") + } +} diff --git a/internal/css_printer/css_printer.go b/internal/css_printer/css_printer.go index e9dea0df36d..87491e60f5e 100644 --- a/internal/css_printer/css_printer.go +++ b/internal/css_printer/css_printer.go @@ -500,6 +500,16 @@ func (p *printer) printCompoundSelector(sel css_ast.CompoundSelector, isFirst bo p.print(":") p.print(s.Kind.String()) p.print("(") + if s.Index.A != "" || s.Index.B != "" { + p.printNthIndex(s.Index) + if len(s.Selectors) > 0 { + if p.options.MinifyWhitespace && s.Selectors[0].Selectors[0].TypeSelector == nil { + p.print(" of") + } else { + p.print(" of ") + } + } + } p.printComplexSelectors(s.Selectors, indent, layoutSingleLine) p.print(")") @@ -509,6 +519,25 @@ func (p *printer) printCompoundSelector(sel css_ast.CompoundSelector, isFirst bo } } +func (p *printer) printNthIndex(index css_ast.NthIndex) { + if index.A != "" { + if index.A == "-1" { + p.print("-") + } else if index.A != "1" { + p.print(index.A) + } + p.print("n") + if index.B != "" { + if !strings.HasPrefix(index.B, "-") { + p.print("+") + } + p.print(index.B) + } + } else if index.B != "" { + p.print(index.B) + } +} + func (p *printer) printNamespacedName(nsName css_ast.NamespacedName, whitespace trailingWhitespace) { if prefix := nsName.NamespacePrefix; prefix != nil { if p.options.AddSourceMappings {