diff --git a/packages/less/lib/less/tree/nested-at-rule.js b/packages/less/lib/less/tree/nested-at-rule.js index 4d3860839..6eab3339d 100644 --- a/packages/less/lib/less/tree/nested-at-rule.js +++ b/packages/less/lib/less/tree/nested-at-rule.js @@ -37,6 +37,40 @@ import Node from './node.js'; * }} NestableAtRuleThis */ +// A media query may only carry a media type at its front, before any +// conditions (https://drafts.csswg.org/mediaqueries-5/#typedef-media-query-list). +// Operators that join media-query parts, so a fragment leading with one is not +// a media type. Any other bare identifier is treated as a type, since unknown +// media types are valid non-matches in CSS, not syntax errors. +const MEDIA_QUERY_OPERATORS = ['and', 'or']; + +/** + * Whether a flattened media-query fragment leads with a media type, e.g. + * `screen`, `only screen`, `print and (color)` or an unknown type like `foo`. + * @param {Node & { value?: * }} fragment + */ +function startsWithMediaType(fragment) { + let head; + if (fragment.type === 'Keyword' || fragment.type === 'Anonymous') { + head = fragment.value; + } else if (fragment.type === 'Expression' && Array.isArray(fragment.value)) { + const parts = fragment.value.filter(p => p && p.value !== undefined); + let idx = 0; + const first = parts[idx] && String(parts[idx].value).toLowerCase(); + if (first === 'not' || first === 'only') { idx++; } + head = parts[idx] && parts[idx].value; + } + if (typeof head !== 'string' || head === '') { + return false; + } + // Inspect only the first token. A media type is a bare identifier; a feature + // condition begins with '(' - e.g. an escaped ~"(max-width: 1px)" is an + // Anonymous whose whole value is "(max-width: 1px)", which is not a media type. + const firstToken = head.trim().split(/[\s(]/)[0]; + return firstToken !== '' + && MEDIA_QUERY_OPERATORS.indexOf(firstToken.toLowerCase()) < 0; +} + const NestableAtRulePrototype = { isRulesetLike() { @@ -149,6 +183,13 @@ const NestableAtRulePrototype = { /** @param {Node & { toCSS?: Function }} fragment */ fragment => fragment.toCSS ? fragment : new Anonymous(/** @type {string} */ (/** @type {unknown} */ (fragment)))); + // A media type nested inside conditions must move ahead of them + // so the flattened query stays valid (issue #3694, #3764). + const types = /** @type {Node[]} */ (path).filter(startsWithMediaType); + if (types.length && types.length < /** @type {Node[]} */ (path).length) { + path = types.concat(/** @type {Node[]} */ (path).filter(f => !startsWithMediaType(f))); + } + for (i = /** @type {Node[]} */ (path).length - 1; i > 0; i--) { /** @type {Node[]} */ (path).splice(i, 0, new Anonymous('and')); } diff --git a/packages/test-data/tests-unit/media-nested-type/media-nested-type.css b/packages/test-data/tests-unit/media-nested-type/media-nested-type.css new file mode 100644 index 000000000..adec56722 --- /dev/null +++ b/packages/test-data/tests-unit/media-nested-type/media-nested-type.css @@ -0,0 +1,40 @@ +@media screen and (max-width: 500px) { + .a { + color: red; + } +} +@media only screen and (min-width: 100px) { + .b { + color: blue; + } +} +@media all and (max-width: 9px) { + .c { + color: green; + } +} +@media print and (color) and (min-width: 1px) { + .d { + color: black; + } +} +@media screen and (max-width: 500px) { + .e { + color: red; + } +} +@media (max-width: 500px) and (min-width: 100px) { + .f { + color: red; + } +} +@media tester and (max-width: 500px) { + .g { + color: red; + } +} +@media screen and (max-width: 500px) { + .h { + color: red; + } +} diff --git a/packages/test-data/tests-unit/media-nested-type/media-nested-type.less b/packages/test-data/tests-unit/media-nested-type/media-nested-type.less new file mode 100644 index 000000000..df9b62a35 --- /dev/null +++ b/packages/test-data/tests-unit/media-nested-type/media-nested-type.less @@ -0,0 +1,51 @@ +// A media type nested inside conditions must lead the flattened query, +// otherwise the result is invalid CSS (issue #3694, #3764). +@media (max-width: 500px) { + @media screen { + .a { color: red; } + } +} +@media (min-width: 100px) { + @media only screen { + .b { color: blue; } + } +} +@media (max-width: 9px) { + @media all { + .c { color: green; } + } +} +@media (min-width: 1px) { + @media print and (color) { + .d { color: black; } + } +} + +// Already-correct ordering is preserved. +@media screen { + @media (max-width: 500px) { + .e { color: red; } + } +} + +// Queries without a media type are left untouched. +@media (max-width: 500px) { + @media (min-width: 100px) { + .f { color: red; } + } +} + +// Unknown media types are valid CSS non-matches, so they reorder too. +@media (max-width: 500px) { + @media tester { + .g { color: red; } + } +} + +// An escaped-string feature condition (Anonymous) must not be mistaken for a media type. +@fc: ~"(max-width: 500px)"; +@media @fc { + @media screen { + .h { color: red; } + } +}