Skip to content

Commit 7295c1a

Browse files
committed
css: also parse media queries in @import rules
1 parent e3991dd commit 7295c1a

7 files changed

Lines changed: 181 additions & 45 deletions

File tree

internal/css_ast/css_ast.go

Lines changed: 117 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,24 @@ func CloneTokensWithImportRecords(
430430
return tokensOut, importRecordsOut
431431
}
432432

433+
func CloneMediaQueriesWithImportRecords(
434+
queriesIn []MediaQuery, importRecordsIn []ast.ImportRecord,
435+
queriesOut []MediaQuery, importRecordsOut []ast.ImportRecord,
436+
) ([]MediaQuery, []ast.ImportRecord) {
437+
// Preallocate the output array if we can
438+
if queriesOut == nil {
439+
queriesOut = make([]MediaQuery, 0, len(queriesIn))
440+
}
441+
442+
// Recursively clone each query
443+
for _, query := range queriesIn {
444+
query.Data, importRecordsOut = query.Data.CloneWithImportRecords(importRecordsIn, importRecordsOut)
445+
queriesOut = append(queriesOut, query)
446+
}
447+
448+
return queriesOut, importRecordsOut
449+
}
450+
433451
type Rule struct {
434452
Data R
435453
Loc logger.Loc
@@ -495,7 +513,7 @@ type ImportConditions struct {
495513
// @import url(...) list-of-media-queries;
496514
//
497515
// From: https://developer.mozilla.org/en-US/docs/Web/CSS/@import#syntax
498-
Media []Token
516+
Queries []MediaQuery
499517

500518
// These two fields will only ever have zero or one tokens. However, they are
501519
// implemented as arrays for convenience because most of esbuild's helper
@@ -508,7 +526,7 @@ func (c *ImportConditions) CloneWithImportRecords(importRecordsIn []ast.ImportRe
508526
result := ImportConditions{}
509527
result.Layers, importRecordsOut = CloneTokensWithImportRecords(c.Layers, importRecordsIn, nil, importRecordsOut)
510528
result.Supports, importRecordsOut = CloneTokensWithImportRecords(c.Supports, importRecordsIn, nil, importRecordsOut)
511-
result.Media, importRecordsOut = CloneTokensWithImportRecords(c.Media, importRecordsIn, nil, importRecordsOut)
529+
result.Queries, importRecordsOut = CloneMediaQueriesWithImportRecords(c.Queries, importRecordsIn, nil, importRecordsOut)
512530
return result, importRecordsOut
513531
}
514532

@@ -754,7 +772,6 @@ func (r *RAtLayer) Hash() (uint32, bool) {
754772
}
755773

756774
type RAtMedia struct {
757-
AtToken string
758775
Queries []MediaQuery
759776
Rules []Rule
760777
CloseBraceLoc logger.Loc
@@ -779,7 +796,9 @@ type MediaQuery struct {
779796

780797
type MQ interface {
781798
Equal(query MQ, check *CrossFileEqualityCheck) bool
799+
EqualIgnoringWhitespace(query MQ) bool
782800
Hash() uint32
801+
CloneWithImportRecords(importRecordsIn []ast.ImportRecord, importRecordsOut []ast.ImportRecord) (MQ, []ast.ImportRecord)
783802
}
784803

785804
func MediaQueriesEqual(a []MediaQuery, b []MediaQuery, check *CrossFileEqualityCheck) bool {
@@ -794,6 +813,18 @@ func MediaQueriesEqual(a []MediaQuery, b []MediaQuery, check *CrossFileEqualityC
794813
return true
795814
}
796815

816+
func MediaQueriesEqualIgnoringWhitespace(a []MediaQuery, b []MediaQuery) bool {
817+
if len(a) != len(b) {
818+
return false
819+
}
820+
for i, ai := range a {
821+
if !ai.Data.EqualIgnoringWhitespace(b[i].Data) {
822+
return false
823+
}
824+
}
825+
return true
826+
}
827+
797828
func HashMediaQueries(hash uint32, queries []MediaQuery) uint32 {
798829
hash = helpers.HashCombine(hash, uint32(len(queries)))
799830
for _, q := range queries {
@@ -821,13 +852,26 @@ func (q *MQType) Equal(query MQ, check *CrossFileEqualityCheck) bool {
821852
return ok && q.Op == p.Op && q.Type == p.Type
822853
}
823854

855+
func (q *MQType) EqualIgnoringWhitespace(query MQ) bool {
856+
p, ok := query.(*MQType)
857+
return ok && q.Op == p.Op && q.Type == p.Type
858+
}
859+
824860
func (q *MQType) Hash() uint32 {
825861
hash := uint32(0)
826862
hash = helpers.HashCombine(hash, uint32(q.Op))
827863
hash = helpers.HashCombineString(hash, q.Type)
828864
return hash
829865
}
830866

867+
func (q *MQType) CloneWithImportRecords(importRecordsIn []ast.ImportRecord, importRecordsOut []ast.ImportRecord) (MQ, []ast.ImportRecord) {
868+
var andOrNull MQ
869+
if q.AndOrNull.Data != nil {
870+
andOrNull, importRecordsOut = q.AndOrNull.Data.CloneWithImportRecords(importRecordsIn, importRecordsOut)
871+
}
872+
return &MQType{Op: q.Op, Type: q.Type, AndOrNull: MediaQuery{Data: andOrNull}}, importRecordsOut
873+
}
874+
831875
type MQNot struct {
832876
Inner MediaQuery
833877
}
@@ -837,12 +881,22 @@ func (q *MQNot) Equal(query MQ, check *CrossFileEqualityCheck) bool {
837881
return ok && q.Inner.Data.Equal(p.Inner.Data, check)
838882
}
839883

884+
func (q *MQNot) EqualIgnoringWhitespace(query MQ) bool {
885+
p, ok := query.(*MQNot)
886+
return ok && q.Inner.Data.EqualIgnoringWhitespace(p.Inner.Data)
887+
}
888+
840889
func (q *MQNot) Hash() uint32 {
841890
hash := uint32(1)
842891
hash = helpers.HashCombine(hash, q.Inner.Data.Hash())
843892
return hash
844893
}
845894

895+
func (q *MQNot) CloneWithImportRecords(importRecordsIn []ast.ImportRecord, importRecordsOut []ast.ImportRecord) (MQ, []ast.ImportRecord) {
896+
inner, importRecordsOut := q.Inner.Data.CloneWithImportRecords(importRecordsIn, importRecordsOut)
897+
return &MQNot{Inner: MediaQuery{Data: inner}}, importRecordsOut
898+
}
899+
846900
type MQBinaryOp uint8
847901

848902
const (
@@ -860,28 +914,53 @@ func (q *MQBinary) Equal(query MQ, check *CrossFileEqualityCheck) bool {
860914
return ok && q.Op == p.Op && MediaQueriesEqual(q.Terms, p.Terms, check)
861915
}
862916

917+
func (q *MQBinary) EqualIgnoringWhitespace(query MQ) bool {
918+
p, ok := query.(*MQBinary)
919+
return ok && q.Op == p.Op && MediaQueriesEqualIgnoringWhitespace(q.Terms, p.Terms)
920+
}
921+
863922
func (q *MQBinary) Hash() uint32 {
864923
hash := uint32(2)
865924
hash = helpers.HashCombine(hash, uint32(q.Op))
866925
hash = HashMediaQueries(hash, q.Terms)
867926
return hash
868927
}
869928

870-
type MQGeneralEnclosed struct {
929+
func (q *MQBinary) CloneWithImportRecords(importRecordsIn []ast.ImportRecord, importRecordsOut []ast.ImportRecord) (MQ, []ast.ImportRecord) {
930+
terms := make([]MediaQuery, 0, len(q.Terms))
931+
for _, term := range q.Terms {
932+
var clone MQ
933+
clone, importRecordsOut = term.Data.CloneWithImportRecords(importRecordsIn, importRecordsOut)
934+
terms = append(terms, MediaQuery{Data: clone})
935+
}
936+
return &MQBinary{Op: q.Op, Terms: terms}, importRecordsOut
937+
}
938+
939+
type MQArbitraryTokens struct {
871940
Tokens []Token
872941
}
873942

874-
func (q *MQGeneralEnclosed) Equal(query MQ, check *CrossFileEqualityCheck) bool {
875-
p, ok := query.(*MQGeneralEnclosed)
943+
func (q *MQArbitraryTokens) Equal(query MQ, check *CrossFileEqualityCheck) bool {
944+
p, ok := query.(*MQArbitraryTokens)
876945
return ok && TokensEqual(q.Tokens, p.Tokens, check)
877946
}
878947

879-
func (q *MQGeneralEnclosed) Hash() uint32 {
948+
func (q *MQArbitraryTokens) EqualIgnoringWhitespace(query MQ) bool {
949+
p, ok := query.(*MQArbitraryTokens)
950+
return ok && TokensEqualIgnoringWhitespace(q.Tokens, p.Tokens)
951+
}
952+
953+
func (q *MQArbitraryTokens) Hash() uint32 {
880954
hash := uint32(3)
881955
hash = HashTokens(hash, q.Tokens)
882956
return hash
883957
}
884958

959+
func (q *MQArbitraryTokens) CloneWithImportRecords(importRecordsIn []ast.ImportRecord, importRecordsOut []ast.ImportRecord) (MQ, []ast.ImportRecord) {
960+
tokens, importRecordsOut := CloneTokensWithImportRecords(q.Tokens, importRecordsIn, nil, importRecordsOut)
961+
return &MQArbitraryTokens{Tokens: tokens}, importRecordsOut
962+
}
963+
885964
type MQPlainOrBoolean struct {
886965
Name string
887966
ValueOrNil []Token
@@ -892,13 +971,26 @@ func (q *MQPlainOrBoolean) Equal(query MQ, check *CrossFileEqualityCheck) bool {
892971
return ok && q.Name == p.Name && TokensEqual(q.ValueOrNil, p.ValueOrNil, check)
893972
}
894973

974+
func (q *MQPlainOrBoolean) EqualIgnoringWhitespace(query MQ) bool {
975+
p, ok := query.(*MQPlainOrBoolean)
976+
return ok && q.Name == p.Name && TokensEqualIgnoringWhitespace(q.ValueOrNil, p.ValueOrNil)
977+
}
978+
895979
func (q *MQPlainOrBoolean) Hash() uint32 {
896980
hash := uint32(4)
897981
hash = helpers.HashCombineString(hash, q.Name)
898982
hash = HashTokens(hash, q.ValueOrNil)
899983
return hash
900984
}
901985

986+
func (q *MQPlainOrBoolean) CloneWithImportRecords(importRecordsIn []ast.ImportRecord, importRecordsOut []ast.ImportRecord) (MQ, []ast.ImportRecord) {
987+
var valueOrNil []Token
988+
if q.ValueOrNil != nil {
989+
valueOrNil, importRecordsOut = CloneTokensWithImportRecords(q.ValueOrNil, importRecordsIn, nil, importRecordsOut)
990+
}
991+
return &MQPlainOrBoolean{Name: q.Name, ValueOrNil: valueOrNil}, importRecordsOut
992+
}
993+
902994
type MQRange struct {
903995
Before []Token
904996
Name string
@@ -914,6 +1006,12 @@ func (q *MQRange) Equal(query MQ, check *CrossFileEqualityCheck) bool {
9141006
TokensEqual(q.Before, p.Before, check) && TokensEqual(q.After, p.After, check)
9151007
}
9161008

1009+
func (q *MQRange) EqualIgnoringWhitespace(query MQ) bool {
1010+
p, ok := query.(*MQRange)
1011+
return ok && q.BeforeCmp == p.BeforeCmp && q.AfterCmp == p.AfterCmp && q.Name == p.Name &&
1012+
TokensEqualIgnoringWhitespace(q.Before, p.Before) && TokensEqualIgnoringWhitespace(q.After, p.After)
1013+
}
1014+
9171015
func (q *MQRange) Hash() uint32 {
9181016
hash := uint32(5)
9191017
hash = HashTokens(hash, q.Before)
@@ -924,6 +1022,18 @@ func (q *MQRange) Hash() uint32 {
9241022
return hash
9251023
}
9261024

1025+
func (q *MQRange) CloneWithImportRecords(importRecordsIn []ast.ImportRecord, importRecordsOut []ast.ImportRecord) (MQ, []ast.ImportRecord) {
1026+
before, importRecordsOut := CloneTokensWithImportRecords(q.Before, importRecordsIn, nil, importRecordsOut)
1027+
after, importRecordsOut := CloneTokensWithImportRecords(q.After, importRecordsIn, nil, importRecordsOut)
1028+
return &MQRange{
1029+
Before: before,
1030+
BeforeCmp: q.BeforeCmp,
1031+
Name: q.Name,
1032+
AfterCmp: q.AfterCmp,
1033+
After: after,
1034+
}, importRecordsOut
1035+
}
1036+
9271037
type MQCmp uint8
9281038

9291039
const (

internal/css_parser/css_parser.go

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1246,25 +1246,17 @@ abortRuleParser:
12461246
}
12471247

12481248
// Parse the optional media query list
1249-
for {
1250-
if kind := p.current().Kind; kind == css_lexer.TSemicolon || kind == css_lexer.TOpenBrace ||
1251-
kind == css_lexer.TCloseBrace || kind == css_lexer.TEndOfFile {
1252-
break
1253-
}
1254-
p.parseComponentValue()
1255-
}
1249+
conditions.Queries = p.parseMediaQueryListUntil(func(kind css_lexer.T) bool {
1250+
return kind == css_lexer.TSemicolon || kind == css_lexer.TOpenBrace ||
1251+
kind == css_lexer.TCloseBrace || kind == css_lexer.TEndOfFile
1252+
})
12561253
if p.peek(css_lexer.TOpenBrace) {
12571254
break // Avoid parsing an invalid "@import" rule
12581255
}
1259-
conditions.Media = p.convertTokens(p.tokens[importConditionsStart:p.index])
1260-
if n := len(conditions.Media); n > 0 {
1261-
conditions.Media[0].Whitespace &= ^css_ast.WhitespaceBefore
1262-
conditions.Media[n-1].Whitespace &= ^css_ast.WhitespaceAfter
1263-
}
12641256

12651257
// Check whether any import conditions are present
12661258
var importConditions *css_ast.ImportConditions
1267-
if len(conditions.Layers) > 0 || len(conditions.Supports) > 0 || len(conditions.Media) > 0 {
1259+
if len(conditions.Layers) > 0 || len(conditions.Supports) > 0 || len(conditions.Queries) > 0 {
12681260
importConditions = &conditions
12691261
}
12701262

@@ -1533,10 +1525,9 @@ abortRuleParser:
15331525
}
15341526

15351527
case "media":
1536-
queries := p.parseMediaQueryList()
1537-
if queries == nil {
1538-
break abortRuleParser
1539-
}
1528+
queries := p.parseMediaQueryListUntil(func(kind css_lexer.T) bool {
1529+
return kind == css_lexer.TOpenBrace
1530+
})
15401531

15411532
// Expect a block after the query
15421533
matchingLoc := p.current().Range.Loc
@@ -1567,7 +1558,7 @@ abortRuleParser:
15671558
closeBraceLoc = logger.Loc{}
15681559
}
15691560

1570-
return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RAtMedia{AtToken: atToken, Queries: queries, Rules: rules, CloseBraceLoc: closeBraceLoc}}
1561+
return css_ast.Rule{Loc: atRange.Loc, Data: &css_ast.RAtMedia{Queries: queries, Rules: rules, CloseBraceLoc: closeBraceLoc}}
15711562

15721563
default:
15731564
if kind == atRuleUnknown && lowerAtToken == "namespace" {

internal/css_parser/css_parser_media.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,21 @@ import (
1010
)
1111

1212
// Reference: https://drafts.csswg.org/mediaqueries-4/
13-
func (p *parser) parseMediaQueryList() []css_ast.MediaQuery {
13+
func (p *parser) parseMediaQueryListUntil(stop func(css_lexer.T) bool) []css_ast.MediaQuery {
1414
var queries []css_ast.MediaQuery
1515
p.eat(css_lexer.TWhitespace)
16-
for !p.peek(css_lexer.TEndOfFile) && !p.peek(css_lexer.TOpenBrace) {
16+
for !p.peek(css_lexer.TEndOfFile) && !stop(p.current().Kind) {
17+
start := p.index
1718
query, ok := p.parseMediaQuery()
1819
if !ok {
19-
return nil
20+
// If parsing failed, parse an arbitrary sequence of tokens instead
21+
p.index = start
22+
loc := p.current().Range.Loc
23+
for !p.peek(css_lexer.TEndOfFile) && !stop(p.current().Kind) && !p.peek(css_lexer.TComma) {
24+
p.parseComponentValue()
25+
}
26+
tokens := p.convertTokens(p.tokens[start:p.index])
27+
query = css_ast.MediaQuery{Loc: loc, Data: &css_ast.MQArbitraryTokens{Tokens: tokens}}
2028
}
2129
queries = append(queries, query)
2230
p.eat(css_lexer.TWhitespace)
@@ -219,7 +227,7 @@ func (p *parser) parseMediaInParens() (css_ast.MediaQuery, bool) {
219227
}
220228
}
221229
}
222-
return css_ast.MediaQuery{Loc: loc, Data: &css_ast.MQGeneralEnclosed{Tokens: tokens}}, true
230+
return css_ast.MediaQuery{Loc: loc, Data: &css_ast.MQArbitraryTokens{Tokens: tokens}}, true
223231
}
224232

225233
func lowerMediaRange(loc logger.Loc, name string, cmp css_ast.MQCmp, value []css_ast.Token) css_ast.MediaQuery {

internal/css_parser/css_parser_test.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1618,7 +1618,7 @@ func TestAtImport(t *testing.T) {
16181618
expectPrinted(t, "@import url(\"foo.css\") ;", "@import \"foo.css\";\n", "")
16191619
expectPrinted(t, "@import url( \"foo.css\" );", "@import \"foo.css\";\n", "")
16201620
expectPrinted(t, "@import url(\"foo.css\") print;", "@import \"foo.css\" print;\n", "")
1621-
expectPrinted(t, "@import url(\"foo.css\") screen and (orientation:landscape);", "@import \"foo.css\" screen and (orientation:landscape);\n", "")
1621+
expectPrinted(t, "@import url(\"foo.css\") screen and (orientation:landscape);", "@import \"foo.css\" screen and (orientation: landscape);\n", "")
16221622

16231623
expectPrinted(t, "@import;", "@import;\n", "<stdin>: WARNING: Expected URL token but found \";\"\n")
16241624
expectPrinted(t, "@import ;", "@import;\n", "<stdin>: WARNING: Expected URL token but found \";\"\n")
@@ -1638,6 +1638,11 @@ func TestAtImport(t *testing.T) {
16381638
expectPrinted(t, "a { @import \"foo.css\" }", "a {\n @import \"foo.css\";\n}\n", "<stdin>: WARNING: \"@import\" is only valid at the top level\n<stdin>: WARNING: Expected \";\"\n")
16391639
}
16401640

1641+
func TestLowerAtImportMediaRange(t *testing.T) {
1642+
expectPrinted(t, "@import \"foo.css\" (1px<=width<=2px);", "@import \"foo.css\" (1px <= width <= 2px);\n", "")
1643+
expectPrintedLower(t, "@import \"foo.css\" (1px<=width<=2px);", "@import \"foo.css\" (min-width: 1px) and (max-width: 2px);\n", "")
1644+
}
1645+
16411646
func TestLegalComment(t *testing.T) {
16421647
expectPrinted(t, "/*!*/@import \"x\";", "/*!*/\n@import \"x\";\n", "")
16431648
expectPrinted(t, "/*!*/@charset \"UTF-8\";", "/*!*/\n@charset \"UTF-8\";\n", "")
@@ -2542,6 +2547,9 @@ func TestAtMedia(t *testing.T) {
25422547
expectPrinted(t, "@media (1px<=width>=2px) {}", "@media (1px<=width>=2px) {\n}\n", "")
25432548
expectPrinted(t, "@media (1px>=width<=2px) {}", "@media (1px>=width<=2px) {\n}\n", "")
25442549

2550+
// Preserve invalid syntax and valid syntax in the same rule
2551+
expectPrinted(t, "@media junk(a<b),(a<b),junk(a<b) {}", "@media junk(a<b), (a < b), junk(a<b) {\n}\n", "")
2552+
25452553
// Whitespace is required between a "not", "and", or "or" keyword and the following "(" character, because without it that would instead parse as a <function-token>.
25462554
expectPrinted(t, "@media not(color) {}", "@media not(color) {\n}\n", "")
25472555
expectPrinted(t, "@media( color )or( opacity ){}", "@media (color)or(opacity) {\n}\n", "<stdin>: WARNING: Expected \"{\" but found \"or(\"\n")

internal/css_printer/css_printer.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,11 +186,20 @@ func (p *printer) printRule(rule css_ast.Rule, indent int32, omitTrailingSemicol
186186
p.printTokens(conditions.Supports, printTokensOpts{})
187187
space = true
188188
}
189-
if len(conditions.Media) > 0 {
189+
if len(conditions.Queries) > 0 {
190190
if space {
191191
p.print(" ")
192192
}
193-
p.printTokens(conditions.Media, printTokensOpts{})
193+
for i, query := range conditions.Queries {
194+
if i > 0 {
195+
if p.options.MinifyWhitespace {
196+
p.print(",")
197+
} else {
198+
p.print(", ")
199+
}
200+
}
201+
p.printMediaQuery(query, 0)
202+
}
194203
}
195204
}
196205
p.print(";")
@@ -385,8 +394,8 @@ const (
385394
)
386395

387396
func (p *printer) printMediaQuery(query css_ast.MediaQuery, flags mqFlags) {
388-
if q, ok := query.Data.(*css_ast.MQGeneralEnclosed); ok {
389-
if (flags&mqAfterIdentifier) != 0 && q.Tokens[0].Kind == css_lexer.TFunction {
397+
if q, ok := query.Data.(*css_ast.MQArbitraryTokens); ok {
398+
if (flags & mqAfterIdentifier) != 0 {
390399
p.print(" ")
391400
}
392401
p.printTokens(q.Tokens, printTokensOpts{})

0 commit comments

Comments
 (0)