Add partial support for Media Queries Level 4 (#1749)

See sass/sass#2538
See #1728
This commit is contained in:
Natalie Weizenbaum 2022-07-22 13:34:07 -07:00 committed by GitHub
parent 0d4c0d0365
commit eeedebcee5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 359 additions and 129 deletions

View File

@ -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.
* Deprecate passing non-`deg` units to `color.hwb()`'s `$hue` argument.
### Dart API

View File

@ -28,7 +28,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;

View File

@ -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<String> 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 [`<media-in-parens>`] production.
///
/// [`<media-in-parens>`]: https://drafts.csswg.org/mediaqueries-4/#typedef-media-in-parens
final List<String> conditions;
/// Whether this media query matches all media types.
bool get matchesAllTypes => type == null || equalsIgnoreCase(type, 'all');
@ -36,47 +46,67 @@ 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<String>? features})
: features = features == null ? const [] : List.unmodifiable(features);
/// Creates a media query specifies a type and, optionally, conditions.
///
/// This always sets [conjunction] to `true`.
CssMediaQuery.type(this.type, {this.modifier, Iterable<String>? conditions})
: conjunction = true,
conditions =
conditions == null ? const [] : List.unmodifiable(conditions);
/// Creates a media query that only specifies features.
CssMediaQuery.condition(Iterable<String> features)
/// 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<String> 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();
var theirType = other.type?.toLowerCase();
if (ourType == null && theirType == null) {
return MediaQuerySuccessfulMergeResult._(
CssMediaQuery.condition([...this.features, ...other.features]));
return MediaQuerySuccessfulMergeResult._(CssMediaQuery.condition(
[...this.conditions, ...other.conditions],
conjunction: true));
}
String? modifier;
String? type;
List<String> features;
List<String> 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 +118,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 +151,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();
}
}

View File

@ -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);
}
var identifier2 = identifier();
whitespace();
if (equalsIgnoreCase(identifier2, "and")) {
// For example, "@media screen and ..."
type = identifier1;
} 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 not (...) {"
return CssMediaQuery.condition(["(not ${_mediaInParens()})"]);
}
}
// We've consumed either `IDENTIFIER "and"`, `IDENTIFIER IDENTIFIER "and"`,
// or no text.
whitespace();
if (!lookingAtIdentifier()) {
// For example, "@media screen {"
return CssMediaQuery.type(identifier1);
}
var features = <String>[];
do {
whitespace();
scanner.expectChar($lparen);
features.add("(${declarationValue()})");
scanner.expectChar($rparen);
whitespace();
} while (scanIdentifier("and"));
var identifier2 = identifier();
if (type == null) {
return CssMediaQuery.condition(features);
if (equalsIgnoreCase(identifier2, "and")) {
expectWhitespace();
// For example, "@media screen and ..."
type = identifier1;
} else {
return CssMediaQuery(type, modifier: modifier, features: features);
whitespace();
modifier = identifier1;
type = identifier2;
if (scanIdentifier("and")) {
// For example, "@media only screen and ..."
expectWhitespace();
} else {
// For example, "@media only screen {"
return CssMediaQuery.type(type, modifier: modifier);
}
}
// We've consumed either `IDENTIFIER "and"` or
// `IDENTIFIER IDENTIFIER "and"`.
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 `<media-in-parens>` expressions separated by
/// [operator] and returns them.
List<String> _mediaLogicSequence(String operator) {
var result = <String>[];
while (true) {
result.add(_mediaInParens());
whitespace();
if (!scanIdentifier(operator)) return result;
expectWhitespace();
}
}
/// Consumes a `<media-in-parens>` expression and returns it, parentheses
/// included.
String _mediaInParens() {
scanner.expectChar($lparen, name: "media condition in parentheses");
var result = "(${declarationValue()})";
scanner.expectChar($rparen);
return result;
}
}

View File

@ -113,6 +113,17 @@ 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 +602,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].

View File

@ -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 (!_lookingAtInterpolatedIdentifier()) {
// For example, "@media screen {".
return;
if (scanIdentifier("and")) {
buffer.write(" and ");
expectWhitespace();
_mediaLogicSequence(buffer, "and");
} else if (scanIdentifier("or")) {
buffer.write(" or ");
expectWhitespace();
_mediaLogicSequence(buffer, "or");
}
buffer.writeCharCode($space);
var identifier = interpolatedIdentifier();
whitespace();
return;
}
if (equalsIgnoreCase(identifier.asPlain, "and")) {
// For example, "@media screen and ..."
var identifier1 = interpolatedIdentifier();
if (equalsIgnoreCase(identifier1.asPlain, "not")) {
// For example, "@media not (...) {"
expectWhitespace();
if (!_lookingAtInterpolatedIdentifier()) {
buffer.write("not ");
_mediaOrInterp(buffer);
return;
}
}
whitespace();
buffer.addInterpolation(identifier1);
if (!_lookingAtInterpolatedIdentifier()) {
// For example, "@media screen {".
return;
}
buffer.writeCharCode($space);
var identifier2 = interpolatedIdentifier();
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 `=`

View File

@ -221,7 +221,12 @@ 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 ||
(firstQuery.conditions.length == 1 &&
firstQuery.conditions.first.startsWith("(not "))) {
_buffer.writeCharCode($space);
}
@ -287,13 +292,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) {

View File

@ -1,4 +1,19 @@
## 1.1.0
## 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 `<media-feature>` 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.
* Provide access to Sass's selector AST, including the following classes:
`Selector`, `ListSelector`, `ComplexSelector`, `ComplexSelectorComponent`,

View File

@ -2,7 +2,7 @@ name: sass_api
# Note: Every time we add a new Sass AST node, we need to bump the *major*
# version because it's a breaking change for anyone who's implementing the
# visitor interface(s).
version: 1.1.0-dev
version: 2.0.0-dev
description: Additional APIs for Dart Sass.
homepage: https://github.com/sass/dart-sass

View File

@ -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", () {