Skip to content

Commit cd62fa1

Browse files
committed
minify: remove unnecessary & selectors
1 parent 0546cf7 commit cd62fa1

File tree

7 files changed

+283
-10
lines changed

7 files changed

+283
-10
lines changed

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,29 @@
22

33
## Unreleased
44

5+
* Minification now removes unnecessary `&` CSS nesting selectors
6+
7+
This release introduces the following CSS minification optimizations:
8+
9+
```css
10+
/* Original input */
11+
a {
12+
font-weight: bold;
13+
& {
14+
color: blue;
15+
}
16+
& :hover {
17+
text-decoration: underline;
18+
}
19+
}
20+
21+
/* Old output (with --minify) */
22+
a{font-weight:700;&{color:#00f}& :hover{text-decoration:underline}}
23+
24+
/* New output (with --minify) */
25+
a{font-weight:700;:hover{text-decoration:underline}color:#00f}
26+
```
27+
528
* Minification now removes duplicates from CSS selector lists
629

730
This release introduces the following CSS minification optimization:

internal/bundler_tests/bundler_css_test.go

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,17 @@ func TestCSSNestingOldBrowser(t *testing.T) {
722722
"/toplevel-hash.css": `#id { color: red; }`,
723723
"/toplevel-plus.css": `+ b { color: red; }`,
724724
"/toplevel-tilde.css": `~ b { color: red; }`,
725+
726+
"/media-ampersand-twice.css": `@media screen { &, & { color: red; } }`,
727+
"/media-ampersand-first.css": `@media screen { &, a { color: red; } }`,
728+
"/media-ampersand-second.css": `@media screen { a, & { color: red; } }`,
729+
"/media-attribute.css": `@media screen { [href] { color: red; } }`,
730+
"/media-colon.css": `@media screen { :hover { color: red; } }`,
731+
"/media-dot.css": `@media screen { .cls { color: red; } }`,
732+
"/media-greaterthan.css": `@media screen { > b { color: red; } }`,
733+
"/media-hash.css": `@media screen { #id { color: red; } }`,
734+
"/media-plus.css": `@media screen { + b { color: red; } }`,
735+
"/media-tilde.css": `@media screen { ~ b { color: red; } }`,
725736
},
726737
entryPaths: []string{
727738
@@ -746,14 +757,32 @@ func TestCSSNestingOldBrowser(t *testing.T) {
746757
"/toplevel-hash.css",
747758
"/toplevel-plus.css",
748759
"/toplevel-tilde.css",
760+
761+
"/media-ampersand-twice.css",
762+
"/media-ampersand-first.css",
763+
"/media-ampersand-second.css",
764+
"/media-attribute.css",
765+
"/media-colon.css",
766+
"/media-dot.css",
767+
"/media-greaterthan.css",
768+
"/media-hash.css",
769+
"/media-plus.css",
770+
"/media-tilde.css",
749771
},
750772
options: config.Options{
751773
Mode: config.ModeBundle,
752774
AbsOutputDir: "/out",
753775
UnsupportedCSSFeatures: compat.Nesting,
754776
OriginalTargetEnv: "chrome10",
755777
},
756-
expectedScanLog: `[email protected]: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
778+
expectedScanLog: `media-ampersand-first.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
779+
media-ampersand-second.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
780+
media-ampersand-twice.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
781+
media-ampersand-twice.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
782+
media-greaterthan.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
783+
media-plus.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
784+
media-tilde.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
785+
[email protected]: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
757786
[email protected]: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
758787
nested-ampersand-first.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
759788
nested-ampersand-twice.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
@@ -821,8 +850,9 @@ func TestDeduplicateRules(t *testing.T) {
821850
"/yes0.css": "a { color: red; color: green; color: red }",
822851
"/yes1.css": "a { color: red } a { color: green } a { color: red }",
823852
"/yes2.css": "@media screen { a { color: red } } @media screen { a { color: red } }",
853+
"/yes3.css": "@media screen { a { color: red } } @media screen { & a { color: red } }",
824854

825-
"/no0.css": "@media screen { a { color: red } } @media screen { & a { color: red } }",
855+
"/no0.css": "@media screen { a { color: red } } @media screen { &a { color: red } }",
826856
"/no1.css": "@media screen { a { color: red } } @media screen { a[x] { color: red } }",
827857
"/no2.css": "@media screen { a { color: red } } @media screen { a.x { color: red } }",
828858
"/no3.css": "@media screen { a { color: red } } @media screen { a#x { color: red } }",
@@ -844,6 +874,7 @@ func TestDeduplicateRules(t *testing.T) {
844874
"/yes0.css",
845875
"/yes1.css",
846876
"/yes2.css",
877+
"/yes3.css",
847878

848879
"/no0.css",
849880
"/no1.css",

internal/bundler_tests/snapshots/snapshots_css.txt

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,89 @@ a,
333333
color: red;
334334
}
335335

336+
---------- /out/media-ampersand-twice.css ----------
337+
/* media-ampersand-twice.css */
338+
@media screen {
339+
&,
340+
& {
341+
color: red;
342+
}
343+
}
344+
345+
---------- /out/media-ampersand-first.css ----------
346+
/* media-ampersand-first.css */
347+
@media screen {
348+
&,
349+
a {
350+
color: red;
351+
}
352+
}
353+
354+
---------- /out/media-ampersand-second.css ----------
355+
/* media-ampersand-second.css */
356+
@media screen {
357+
a,
358+
& {
359+
color: red;
360+
}
361+
}
362+
363+
---------- /out/media-attribute.css ----------
364+
/* media-attribute.css */
365+
@media screen {
366+
[href] {
367+
color: red;
368+
}
369+
}
370+
371+
---------- /out/media-colon.css ----------
372+
/* media-colon.css */
373+
@media screen {
374+
:hover {
375+
color: red;
376+
}
377+
}
378+
379+
---------- /out/media-dot.css ----------
380+
/* media-dot.css */
381+
@media screen {
382+
.cls {
383+
color: red;
384+
}
385+
}
386+
387+
---------- /out/media-greaterthan.css ----------
388+
/* media-greaterthan.css */
389+
@media screen {
390+
> b {
391+
color: red;
392+
}
393+
}
394+
395+
---------- /out/media-hash.css ----------
396+
/* media-hash.css */
397+
@media screen {
398+
#id {
399+
color: red;
400+
}
401+
}
402+
403+
---------- /out/media-plus.css ----------
404+
/* media-plus.css */
405+
@media screen {
406+
+ b {
407+
color: red;
408+
}
409+
}
410+
411+
---------- /out/media-tilde.css ----------
412+
/* media-tilde.css */
413+
@media screen {
414+
~ b {
415+
color: red;
416+
}
417+
}
418+
336419
================================================================================
337420
TestDataURLImportURLInCSS
338421
---------- /out/entry.css ----------
@@ -367,6 +450,14 @@ a {
367450
}
368451
}
369452

453+
---------- /out/yes3.css ----------
454+
/* yes3.css */
455+
@media screen {
456+
a {
457+
color: red;
458+
}
459+
}
460+
370461
---------- /out/no0.css ----------
371462
/* no0.css */
372463
@media screen {
@@ -375,7 +466,7 @@ a {
375466
}
376467
}
377468
@media screen {
378-
& a {
469+
&a {
379470
color: red;
380471
}
381472
}

internal/css_ast/css_ast.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,10 @@ type CompoundSelector struct {
635635
HasNestingSelector bool // "&"
636636
}
637637

638+
func (sel CompoundSelector) IsSingleAmpersand() bool {
639+
return sel.HasNestingSelector && sel.Combinator == 0 && sel.TypeSelector == nil && len(sel.SubclassSelectors) == 0
640+
}
641+
638642
type NameToken struct {
639643
Text string
640644
Kind css_lexer.T

internal/css_parser/css_parser.go

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ loop:
283283
}
284284

285285
if context.parseSelectors {
286-
rules = append(rules, p.parseSelectorRuleFrom(p.index, parseSelectorOpts{isTopLevel: context.isTopLevel}))
286+
rules = append(rules, p.parseSelectorRuleFrom(p.index, context.isTopLevel, parseSelectorOpts{}))
287287
} else {
288288
rules = append(rules, p.parseQualifiedRuleFrom(p.index, parseQualifiedRuleOpts{isTopLevel: context.isTopLevel}))
289289
}
@@ -297,6 +297,8 @@ loop:
297297

298298
func (p *parser) parseListOfDeclarations() (list []css_ast.Rule) {
299299
list = []css_ast.Rule{}
300+
foundNesting := false
301+
300302
for {
301303
switch p.current().Kind {
302304
case css_lexer.TWhitespace, css_lexer.TSemicolon:
@@ -306,6 +308,24 @@ func (p *parser) parseListOfDeclarations() (list []css_ast.Rule) {
306308
list = p.processDeclarations(list)
307309
if p.options.MinifySyntax {
308310
list = p.mangleRules(list, false /* isTopLevel */)
311+
312+
// Pull out all unnecessarily-nested declarations and stick them at the end
313+
// "a { & { b: c } d: e }" => "a { d: e; b: c; }"
314+
if foundNesting {
315+
var inlineDecls []css_ast.Rule
316+
n := 0
317+
for _, rule := range list {
318+
if rule, ok := rule.Data.(*css_ast.RSelector); ok && len(rule.Selectors) == 1 {
319+
if sel := rule.Selectors[0]; len(sel.Selectors) == 1 && sel.Selectors[0].IsSingleAmpersand() {
320+
inlineDecls = append(inlineDecls, rule.Rules...)
321+
continue
322+
}
323+
}
324+
list[n] = rule
325+
n++
326+
}
327+
list = append(list[:n], inlineDecls...)
328+
}
309329
}
310330
return
311331

@@ -327,7 +347,8 @@ func (p *parser) parseListOfDeclarations() (list []css_ast.Rule) {
327347
css_lexer.TDelimGreaterThan,
328348
css_lexer.TDelimTilde:
329349
p.maybeWarnAboutNesting(p.current().Range)
330-
list = append(list, p.parseSelectorRuleFrom(p.index, parseSelectorOpts{}))
350+
list = append(list, p.parseSelectorRuleFrom(p.index, false, parseSelectorOpts{isDeclarationContext: true}))
351+
foundNesting = true
331352

332353
default:
333354
list = append(list, p.parseDeclaration())
@@ -1600,7 +1621,7 @@ func mangleNumber(t string) (string, bool) {
16001621
return t, t != original
16011622
}
16021623

1603-
func (p *parser) parseSelectorRuleFrom(preludeStart int, opts parseSelectorOpts) css_ast.Rule {
1624+
func (p *parser) parseSelectorRuleFrom(preludeStart int, isTopLevel bool, opts parseSelectorOpts) css_ast.Rule {
16041625
// Try parsing the prelude as a selector list
16051626
if list, ok := p.parseSelectorList(opts); ok {
16061627
selector := css_ast.RSelector{Selectors: list}
@@ -1615,7 +1636,7 @@ func (p *parser) parseSelectorRuleFrom(preludeStart int, opts parseSelectorOpts)
16151636
// Otherwise, parse a generic qualified rule
16161637
return p.parseQualifiedRuleFrom(preludeStart, parseQualifiedRuleOpts{
16171638
isAlreadyInvalid: true,
1618-
isTopLevel: opts.isTopLevel,
1639+
isTopLevel: isTopLevel,
16191640
})
16201641
}
16211642

internal/css_parser/css_parser_selector.go

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,20 +39,74 @@ skip:
3939
list = append(list, sel)
4040
}
4141

42+
if p.options.MinifySyntax {
43+
for i := 1; i < len(list); i++ {
44+
if analyzeLeadingAmpersand(list[i], opts.isDeclarationContext) != cannotRemoveLeadingAmpersand {
45+
list[i].Selectors = list[i].Selectors[1:]
46+
}
47+
}
48+
49+
switch analyzeLeadingAmpersand(list[0], opts.isDeclarationContext) {
50+
case canAlwaysRemoveLeadingAmpersand:
51+
list[0].Selectors = list[0].Selectors[1:]
52+
53+
case canRemoveLeadingAmpersandIfNotFirst:
54+
for i := 1; i < len(list); i++ {
55+
if sel := list[i].Selectors[0]; !sel.HasNestingSelector && (sel.Combinator != 0 || sel.TypeSelector == nil) {
56+
list[0].Selectors = list[0].Selectors[1:]
57+
list[0], list[i] = list[i], list[0]
58+
break
59+
}
60+
}
61+
}
62+
}
63+
4264
ok = true
4365
return
4466
}
4567

68+
type leadingAmpersand uint8
69+
70+
const (
71+
cannotRemoveLeadingAmpersand leadingAmpersand = iota
72+
canAlwaysRemoveLeadingAmpersand
73+
canRemoveLeadingAmpersandIfNotFirst
74+
)
75+
76+
func analyzeLeadingAmpersand(sel css_ast.ComplexSelector, isDeclarationContext bool) leadingAmpersand {
77+
if len(sel.Selectors) > 1 {
78+
if first := sel.Selectors[0]; first.IsSingleAmpersand() {
79+
if second := sel.Selectors[1]; second.Combinator == 0 && second.HasNestingSelector {
80+
// ".foo { & &.bar {} }" => ".foo { & &.bar {} }"
81+
} else if second.Combinator != 0 || second.TypeSelector == nil || !isDeclarationContext {
82+
// "& + div {}" => "+ div {}"
83+
// "& div {}" => "div {}"
84+
// ".foo { & + div {} }" => ".foo { + div {} }"
85+
// ".foo { & + &.bar {} }" => ".foo { + &.bar {} }"
86+
// ".foo { & :hover {} }" => ".foo { :hover {} }"
87+
return canAlwaysRemoveLeadingAmpersand
88+
} else {
89+
// ".foo { & div {} }"
90+
// ".foo { .bar, & div {} }" => ".foo { .bar, div {} }"
91+
return canRemoveLeadingAmpersandIfNotFirst
92+
}
93+
}
94+
} else {
95+
// "& {}" => "& {}"
96+
}
97+
return cannotRemoveLeadingAmpersand
98+
}
99+
46100
type parseSelectorOpts struct {
47-
isTopLevel bool
101+
isDeclarationContext bool
48102
}
49103

50104
func (p *parser) parseComplexSelector(opts parseSelectorOpts) (result css_ast.ComplexSelector, ok bool) {
51105
// This is an extension: https://drafts.csswg.org/css-nesting-1/
52106
r := p.current().Range
53107
combinator := p.parseCombinator()
54108
if combinator != 0 {
55-
if opts.isTopLevel {
109+
if !opts.isDeclarationContext {
56110
p.maybeWarnAboutNesting(r)
57111
}
58112
p.eat(css_lexer.TWhitespace)
@@ -101,7 +155,7 @@ func (p *parser) nameToken() css_ast.NameToken {
101155
func (p *parser) parseCompoundSelector(opts parseSelectorOpts) (sel css_ast.CompoundSelector, ok bool) {
102156
// This is an extension: https://drafts.csswg.org/css-nesting-1/
103157
if p.peek(css_lexer.TDelimAmpersand) {
104-
if opts.isTopLevel {
158+
if !opts.isDeclarationContext {
105159
p.maybeWarnAboutNesting(p.current().Range)
106160
}
107161
sel.HasNestingSelector = true

0 commit comments

Comments
 (0)