diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d71c1373..69bfb27b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ See https://sass-lang.com/d/bogus-combinators for more details. +* Add partial support for new media query syntax from Media Queries Level 4. The + only exception are logical operations nested within parentheses, as these were + previously interpreted differently as SassScript expressions. + + A parenthesized media condition that begins with `not` or an opening + parenthesis now produces a deprecation warning. In a future release, these + will be interpreted as plain CSS instead. + ### JS API * Add a `charset` option that controls whether or not Sass emits a diff --git a/lib/sass.dart b/lib/sass.dart index 0f77eaea0..e6d428646 100644 --- a/lib/sass.dart +++ b/lib/sass.dart @@ -26,7 +26,8 @@ export 'src/exception.dart' show SassException; export 'src/importer.dart'; export 'src/logger.dart'; export 'src/syntax.dart'; -export 'src/value.dart' hide ColorFormat, SassApiColor, SassApiValue, SpanColorFormat; +export 'src/value.dart' + hide ColorFormat, SassApiColor, SassApiValue, SpanColorFormat; export 'src/visitor/serialize.dart' show OutputStyle; export 'src/evaluation_context.dart' show warn; diff --git a/lib/src/ast/css/media_query.dart b/lib/src/ast/css/media_query.dart index 54d5d81c2..26d07b0f5 100644 --- a/lib/src/ast/css/media_query.dart +++ b/lib/src/ast/css/media_query.dart @@ -15,14 +15,24 @@ class CssMediaQuery { /// The media type, for example "screen" or "print". /// - /// This may be `null`. If so, [features] will not be empty. + /// This may be `null`. If so, [conditions] will not be empty. final String? type; - /// Feature queries, including parentheses. - final List features; + /// Whether [conditions] is a conjunction or a disjunction. + /// + /// In other words, if this is `true this query matches when _all_ + /// [conditions] are met, and if it's `false` this query matches when _any_ + /// condition in [conditions] is met. + /// + /// If this is [false], [modifier] and [type] will both be `null`. + final bool conjunction; - /// Whether this media query only specifies features. - bool get isCondition => modifier == null && type == null; + /// Media conditions, including parentheses. + /// + /// This is anything that can appear in the [``] production. + /// + /// [``]: https://drafts.csswg.org/mediaqueries-4/#typedef-media-in-parens + final List conditions; /// Whether this media query matches all media types. bool get matchesAllTypes => type == null || equalsIgnoreCase(type, 'all'); @@ -36,19 +46,38 @@ class CssMediaQuery { {Object? url, Logger? logger}) => MediaQueryParser(contents, url: url, logger: logger).parse(); - /// Creates a media query specifies a type and, optionally, features. - CssMediaQuery(this.type, {this.modifier, Iterable? features}) - : features = features == null ? const [] : List.unmodifiable(features); - - /// Creates a media query that only specifies features. - CssMediaQuery.condition(Iterable features) + /// Creates a media query specifies a type and, optionally, conditions. + /// + /// This always sets [conjunction] to `true`. + CssMediaQuery.type(this.type, {this.modifier, Iterable? conditions}) + : conjunction = true, + conditions = + conditions == null ? const [] : List.unmodifiable(conditions); + + /// Creates a media query that matches [conditions] according to + /// [conjunction]. + /// + /// The [conjunction] argument may not be null if [conditions] is longer than + /// a single element. + CssMediaQuery.condition(Iterable conditions, {bool? conjunction}) : modifier = null, type = null, - features = List.unmodifiable(features); + conjunction = conjunction ?? true, + conditions = List.unmodifiable(conditions) { + if (this.conditions.length > 1 && conjunction == null) { + throw ArgumentError( + "If conditions is longer than one element, conjunction may not be " + "null."); + } + } /// Merges this with [other] to return a query that matches the intersection /// of both inputs. MediaQueryMergeResult merge(CssMediaQuery other) { + if (!conjunction || !other.conjunction) { + return MediaQueryMergeResult.unrepresentable; + } + var ourModifier = this.modifier?.toLowerCase(); var ourType = this.type?.toLowerCase(); var theirModifier = other.modifier?.toLowerCase(); @@ -56,27 +85,27 @@ class CssMediaQuery { if (ourType == null && theirType == null) { return MediaQuerySuccessfulMergeResult._( - CssMediaQuery.condition([...this.features, ...other.features])); + CssMediaQuery.condition([...this.conditions, ...other.conditions], conjunction: true)); } String? modifier; String? type; - List features; + List conditions; if ((ourModifier == 'not') != (theirModifier == 'not')) { if (ourType == theirType) { - var negativeFeatures = - ourModifier == 'not' ? this.features : other.features; - var positiveFeatures = - ourModifier == 'not' ? other.features : this.features; + var negativeConditions = + ourModifier == 'not' ? this.conditions : other.conditions; + var positiveConditions = + ourModifier == 'not' ? other.conditions : this.conditions; - // If the negative features are a subset of the positive features, the + // If the negative conditions are a subset of the positive conditions, the // query is empty. For example, `not screen and (color)` has no // intersection with `screen and (color) and (grid)`. // // However, `not screen and (color)` *does* intersect with `screen and // (grid)`, because it means `not (screen and (color))` and so it allows // a screen with no color but with a grid. - if (negativeFeatures.every(positiveFeatures.contains)) { + if (negativeConditions.every(positiveConditions.contains)) { return MediaQueryMergeResult.empty; } else { return MediaQueryMergeResult.unrepresentable; @@ -88,30 +117,30 @@ class CssMediaQuery { if (ourModifier == 'not') { modifier = theirModifier; type = theirType; - features = other.features; + conditions = other.conditions; } else { modifier = ourModifier; type = ourType; - features = this.features; + conditions = this.conditions; } } else if (ourModifier == 'not') { assert(theirModifier == 'not'); // CSS has no way of representing "neither screen nor print". if (ourType != theirType) return MediaQueryMergeResult.unrepresentable; - var moreFeatures = this.features.length > other.features.length - ? this.features - : other.features; - var fewerFeatures = this.features.length > other.features.length - ? other.features - : this.features; + var moreConditions = this.conditions.length > other.conditions.length + ? this.conditions + : other.conditions; + var fewerConditions = this.conditions.length > other.conditions.length + ? other.conditions + : this.conditions; - // If one set of features is a superset of the other, use those features + // If one set of conditions is a superset of the other, use those conditions // because they're strictly narrower. - if (fewerFeatures.every(moreFeatures.contains)) { + if (fewerConditions.every(moreConditions.contains)) { modifier = ourModifier; // "not" type = ourType; - features = moreFeatures; + conditions = moreConditions; } else { // Otherwise, there's no way to represent the intersection. return MediaQueryMergeResult.unrepresentable; @@ -121,41 +150,41 @@ class CssMediaQuery { // Omit the type if either input query did, since that indicates that they // aren't targeting a browser that requires "all and". type = (other.matchesAllTypes && ourType == null) ? null : theirType; - features = [...this.features, ...other.features]; + conditions = [...this.conditions, ...other.conditions]; } else if (other.matchesAllTypes) { modifier = ourModifier; type = ourType; - features = [...this.features, ...other.features]; + conditions = [...this.conditions, ...other.conditions]; } else if (ourType != theirType) { return MediaQueryMergeResult.empty; } else { modifier = ourModifier ?? theirModifier; type = ourType; - features = [...this.features, ...other.features]; + conditions = [...this.conditions, ...other.conditions]; } - return MediaQuerySuccessfulMergeResult._(CssMediaQuery( + return MediaQuerySuccessfulMergeResult._(CssMediaQuery.type( type == ourType ? this.type : other.type, modifier: modifier == ourModifier ? this.modifier : other.modifier, - features: features)); + conditions: conditions)); } bool operator ==(Object other) => other is CssMediaQuery && other.modifier == modifier && other.type == type && - listEquals(other.features, features); + listEquals(other.conditions, conditions); - int get hashCode => modifier.hashCode ^ type.hashCode ^ listHash(features); + int get hashCode => modifier.hashCode ^ type.hashCode ^ listHash(conditions); String toString() { var buffer = StringBuffer(); if (modifier != null) buffer.write("$modifier "); if (type != null) { buffer.write(type); - if (features.isNotEmpty) buffer.write(" and "); + if (conditions.isNotEmpty) buffer.write(" and "); } - buffer.write(features.join(" and ")); + buffer.write(conditions.join(conjunction ? " and " : " or ")); return buffer.toString(); } } diff --git a/lib/src/parse/media_query.dart b/lib/src/parse/media_query.dart index d9f5cbbb3..d38a472f5 100644 --- a/lib/src/parse/media_query.dart +++ b/lib/src/parse/media_query.dart @@ -20,6 +20,7 @@ class MediaQueryParser extends Parser { do { whitespace(); queries.add(_mediaQuery()); + whitespace(); } while (scanner.scanChar($comma)); scanner.expectDone(); return queries; @@ -29,52 +30,93 @@ class MediaQueryParser extends Parser { /// Consumes a single media query. CssMediaQuery _mediaQuery() { // This is somewhat duplicated in StylesheetParser._mediaQuery. + if (scanner.peekChar() == $lparen) { + var conditions = [_mediaInParens()]; + whitespace(); + + var conjunction = true; + if (scanIdentifier("and")) { + expectWhitespace(); + conditions.addAll(_mediaLogicSequence("and")); + } else if (scanIdentifier("or")) { + expectWhitespace(); + conjunction = false; + conditions.addAll(_mediaLogicSequence("or")); + } + + return CssMediaQuery.condition(conditions, conjunction: conjunction); + } + String? modifier; String? type; - if (scanner.peekChar() != $lparen) { - var identifier1 = identifier(); - whitespace(); + var identifier1 = identifier(); + if (equalsIgnoreCase(identifier1, "not")) { + expectWhitespace(); if (!lookingAtIdentifier()) { - // For example, "@media screen {" - return CssMediaQuery(identifier1); + // For example, "@media not (...) {" + return CssMediaQuery.condition(["(not ${_mediaInParens()})"]); } + } - var identifier2 = identifier(); - whitespace(); + whitespace(); + if (!lookingAtIdentifier()) { + // For example, "@media screen {" + return CssMediaQuery.type(identifier1); + } - if (equalsIgnoreCase(identifier2, "and")) { - // For example, "@media screen and ..." - type = identifier1; + var identifier2 = identifier(); + + if (equalsIgnoreCase(identifier2, "and")) { + expectWhitespace(); + // For example, "@media screen and ..." + type = identifier1; + } else { + whitespace(); + modifier = identifier1; + type = identifier2; + if (scanIdentifier("and")) { + // For example, "@media only screen and ..." + expectWhitespace(); } else { - modifier = identifier1; - type = identifier2; - if (scanIdentifier("and")) { - // For example, "@media only screen and ..." - whitespace(); - } else { - // For example, "@media only screen {" - return CssMediaQuery(type, modifier: modifier); - } + // For example, "@media only screen {" + return CssMediaQuery.type(type, modifier: modifier); } } - // We've consumed either `IDENTIFIER "and"`, `IDENTIFIER IDENTIFIER "and"`, - // or no text. + // We've consumed either `IDENTIFIER "and"` or + // `IDENTIFIER IDENTIFIER "and"`. - var features = []; - do { - whitespace(); - scanner.expectChar($lparen); - features.add("(${declarationValue()})"); - scanner.expectChar($rparen); + if (scanIdentifier("not")) { + // For example, "@media screen and not (...) {" + expectWhitespace(); + return CssMediaQuery.type(type, + modifier: modifier, conditions: ["(not ${_mediaInParens()})"]); + } + + return CssMediaQuery.type(type, + modifier: modifier, conditions: _mediaLogicSequence("and")); + } + + /// Consumes one or more `` expressions separated by + /// [operator] and returns them. + List _mediaLogicSequence(String operator) { + var result = []; + while (true) { + result.add(_mediaInParens()); whitespace(); - } while (scanIdentifier("and")); - if (type == null) { - return CssMediaQuery.condition(features); - } else { - return CssMediaQuery(type, modifier: modifier, features: features); + if (!scanIdentifier(operator)) return result; + expectWhitespace(); } } + + /// Consumes a `` expression and returns it, parentheses + /// included. + String _mediaInParens() { + scanner.expectChar($lparen, name: "media condition in parentheses"); + var result = "(${declarationValue()})"; + scanner.expectChar($rparen); + return result; + } } diff --git a/lib/src/parse/parser.dart b/lib/src/parse/parser.dart index f60a6cdbc..1f7bd20cd 100644 --- a/lib/src/parse/parser.dart +++ b/lib/src/parse/parser.dart @@ -113,6 +113,16 @@ class Parser { } } + /// Like [whitespace], but throws an error if no whitespace is consumed. + @protected + void expectWhitespace() { + if (scanner.isDone || !(isWhitespace(scanner.peekChar()) || scanComment())) { + scanner.error("Expected whitespace."); + } + + whitespace(); + } + /// Consumes and ignores a silent (Sass-style) comment. @protected void silentComment() { @@ -591,15 +601,39 @@ class Parser { if (!lookingAtIdentifier()) return false; var start = scanner.state; - for (var letter in text.codeUnits) { - if (scanIdentChar(letter, caseSensitive: caseSensitive)) continue; + if (_consumeIdentifier(text, caseSensitive) && !lookingAtIdentifierBody()) { + return true; + } else { scanner.state = start; return false; } + } - if (!lookingAtIdentifierBody()) return true; + /// Returns whether an identifier whose name exactly matches [text] is at the + /// current scanner position. + /// + /// This doesn't move the scan pointer forward + @protected + bool matchesIdentifier(String text, {bool caseSensitive = false}) { + if (!lookingAtIdentifier()) return false; + + var start = scanner.state; + var result = + _consumeIdentifier(text, caseSensitive) && !lookingAtIdentifierBody(); scanner.state = start; - return false; + return result; + } + + /// Consumes [text] as an identifer, but doesn't verify whether there's + /// additional identifier text afterwards. + /// + /// Returns `true` if the full [text] is consumed and `false` otherwise, but + /// doesn't reset the scan pointer. + bool _consumeIdentifier(String text, bool caseSensitive) { + for (var letter in text.codeUnits) { + if (!scanIdentChar(letter, caseSensitive: caseSensitive)) return false; + } + return true; } /// Consumes an identifier and asserts that its name exactly matches [text]. diff --git a/lib/src/parse/stylesheet.dart b/lib/src/parse/stylesheet.dart index d08b704dc..e5aa1263c 100644 --- a/lib/src/parse/stylesheet.dart +++ b/lib/src/parse/stylesheet.dart @@ -3449,6 +3449,7 @@ abstract class StylesheetParser extends Parser { while (true) { whitespace(); _mediaQuery(buffer); + whitespace(); if (!scanner.scanChar($comma)) break; buffer.writeCharCode($comma); buffer.writeCharCode($space); @@ -3459,61 +3460,127 @@ abstract class StylesheetParser extends Parser { /// Consumes a single media query. void _mediaQuery(InterpolationBuffer buffer) { // This is somewhat duplicated in MediaQueryParser._mediaQuery. - if (scanner.peekChar() != $lparen) { - buffer.addInterpolation(interpolatedIdentifier()); + if (scanner.peekChar() == $lparen) { + _mediaInParens(buffer); whitespace(); + if (scanIdentifier("and")) { + buffer.write(" and "); + expectWhitespace(); + _mediaLogicSequence(buffer, "and"); + } else if (scanIdentifier("or")) { + buffer.write(" or "); + expectWhitespace(); + _mediaLogicSequence(buffer, "or"); + } + + return; + } + + var identifier1 = interpolatedIdentifier(); + if (equalsIgnoreCase(identifier1.asPlain, "not")) { + // For example, "@media not (...) {" + expectWhitespace(); if (!_lookingAtInterpolatedIdentifier()) { - // For example, "@media screen {". + buffer.write("not "); + _mediaOrInterp(buffer); return; } + } - buffer.writeCharCode($space); - var identifier = interpolatedIdentifier(); - whitespace(); + whitespace(); + buffer.addInterpolation(identifier1); + if (!_lookingAtInterpolatedIdentifier()) { + // For example, "@media screen {". + return; + } + + buffer.writeCharCode($space); + var identifier2 = interpolatedIdentifier(); - if (equalsIgnoreCase(identifier.asPlain, "and")) { - // For example, "@media screen and ..." + if (equalsIgnoreCase(identifier2.asPlain, "and")) { + expectWhitespace(); + // For example, "@media screen and ..." + buffer.write(" and "); + } else { + whitespace(); + buffer.addInterpolation(identifier2); + if (scanIdentifier("and")) { + // For example, "@media only screen and ..." + expectWhitespace(); buffer.write(" and "); } else { - buffer.addInterpolation(identifier); - if (scanIdentifier("and")) { - // For example, "@media only screen and ..." - whitespace(); - buffer.write(" and "); - } else { - // For example, "@media only screen {" - return; - } + // For example, "@media only screen {" + return; } } // We've consumed either `IDENTIFIER "and"` or // `IDENTIFIER IDENTIFIER "and"`. + if (scanIdentifier("not")) { + // For example, "@media screen and not (...) {" + expectWhitespace(); + buffer.write("not "); + _mediaOrInterp(buffer); + return; + } + + _mediaLogicSequence(buffer, "and"); + return; + } + + /// Consumes one or more `MediaOrInterp` expressions separated by [operator] + /// and writes them to [buffer]. + void _mediaLogicSequence(InterpolationBuffer buffer, String operator) { while (true) { + _mediaOrInterp(buffer); whitespace(); - buffer.addInterpolation(_mediaFeature()); - whitespace(); - if (!scanIdentifier("and")) break; - buffer.write(" and "); + + if (!scanIdentifier(operator)) return; + expectWhitespace(); + + buffer.writeCharCode($space); + buffer.write(operator); + buffer.writeCharCode($space); } } - /// Consumes a media query feature. - Interpolation _mediaFeature() { + /// Consumes a `MediaOrInterp` expression and writes it to [buffer]. + void _mediaOrInterp(InterpolationBuffer buffer) { if (scanner.peekChar() == $hash) { var interpolation = singleInterpolation(); - return Interpolation([interpolation], interpolation.span); + buffer + .addInterpolation(Interpolation([interpolation], interpolation.span)); + } else { + _mediaInParens(buffer); } + } - var start = scanner.state; - var buffer = InterpolationBuffer(); - scanner.expectChar($lparen); + /// Consumes a `MediaInParens` expression and writes it to [buffer]. + void _mediaInParens(InterpolationBuffer buffer) { + scanner.expectChar($lparen, name: "media condition in parentheses"); buffer.writeCharCode($lparen); whitespace(); - buffer.add(_expressionUntilComparison()); + var needsParenDeprecation = scanner.peekChar() == $lparen; + var needsNotDeprecation = matchesIdentifier("not"); + + var expression = _expressionUntilComparison(); + if (needsParenDeprecation || needsNotDeprecation) { + logger.warn( + 'Starting a @media query with "${needsParenDeprecation ? '(' : 'not'}" ' + "is deprecated because it conflicts with official CSS syntax.\n" + "\n" + "To preserve existing behavior: #{$expression}\n" + 'To migrate to new behavior: #{"$expression"}\n' + "\n" + "For details, see https://sass-lang.com/d/media-logic", + span: expression.span, + deprecation: true); + } + + buffer.add(expression); if (scanner.scanChar($colon)) { whitespace(); buffer.writeCharCode($colon); @@ -3549,8 +3616,6 @@ abstract class StylesheetParser extends Parser { scanner.expectChar($rparen); whitespace(); buffer.writeCharCode($rparen); - - return buffer.interpolation(scanner.spanFrom(start)); } /// Consumes an expression until it reaches a top-level `<`, `>`, or a `=` diff --git a/lib/src/visitor/serialize.dart b/lib/src/visitor/serialize.dart index d098923af..705a7854e 100644 --- a/lib/src/visitor/serialize.dart +++ b/lib/src/visitor/serialize.dart @@ -221,7 +221,10 @@ class _SerializeVisitor _for(node, () { _buffer.write("@media"); - if (!_isCompressed || !node.queries.first.isCondition) { + var firstQuery = node.queries.first; + if (!_isCompressed || + firstQuery.modifier != null || + firstQuery.type != null) { _buffer.writeCharCode($space); } @@ -287,13 +290,21 @@ class _SerializeVisitor if (query.type != null) { _buffer.write(query.type); - if (query.features.isNotEmpty) { + if (query.conditions.isNotEmpty) { _buffer.write(" and "); } } - _writeBetween( - query.features, _isCompressed ? "and " : " and ", _buffer.write); + if (query.conditions.length == 1 && + query.conditions.first.startsWith("(not ")) { + _buffer.write("not "); + var condition = query.conditions.first; + _buffer.write(condition.substring("(not ".length, condition.length - 1)); + } else { + var operator = query.conjunction ? "and" : "or"; + _writeBetween(query.conditions, + _isCompressed ? "$operator " : " $operator ", _buffer.write); + } } void visitCssStyleRule(CssStyleRule node) { diff --git a/pkg/sass_api/CHANGELOG.md b/pkg/sass_api/CHANGELOG.md index dab80c12e..f7badf1e8 100644 --- a/pkg/sass_api/CHANGELOG.md +++ b/pkg/sass_api/CHANGELOG.md @@ -1,3 +1,20 @@ +## 2.0.0 + +* Refactor the `CssMediaQuery` API to support new logical operators: + + * Rename the `features` field to `conditions`, to reflect the fact that it can + contain more than just the `` production. + + * Add a `conjunction` field to track whether `conditions` are matched + conjunctively or disjunctively. + + * Rename the default constructor to `CssMediaQuery.type()` to reflect the fact + that it's no longer by far the most commonly used form of media query. + + * Add a required `conjunction` argument to `CssMediaQuery.condition()`. + + * Delete the `isCondition` getter. + ## 1.1.0 * Provide access to Sass's selector AST, including the following classes: diff --git a/test/compressed_test.dart b/test/compressed_test.dart index ddeaca4de..22fda58d1 100644 --- a/test/compressed_test.dart +++ b/test/compressed_test.dart @@ -209,15 +209,36 @@ void main() { // Removing whitespace after "and", "or", or "not" is forbidden because it // would cause it to parse as a function token. - test('removes whitespace before "and" when possible', () { - expect( - _compile(""" - @media screen and (min-width: 900px) and (max-width: 100px) { - a {b: c} - } - """), - equals("@media screen and (min-width: 900px)and (max-width: 100px)" - "{a{b:c}}")); + group('preserves whitespace when necessary', () { + test('around "and"', () { + expect( + _compile(""" + @media screen and (min-width: 900px) and (max-width: 100px) { + a {b: c} + } + """), + equals("@media screen and (min-width: 900px)and (max-width: 100px)" + "{a{b:c}}")); + }); + + test('around "or"', () { + expect( + _compile(""" + @media (min-width: 900px) or (max-width: 100px) or (print) { + a {b: c} + } + """), + equals("@media(min-width: 900px)or (max-width: 100px)or (print)" + "{a{b:c}}")); + }); + + test('after "not"', () { + expect(_compile(""" + @media not (min-width: 900px) { + a {b: c} + } + """), equals("@media not (min-width: 900px){a{b:c}}")); + }); }); test("preserves whitespace around the modifier", () {