feat: `container` query support via `css-tree` extension (#8275)

Closes #6969

As discussed there, container query support is quite useful to add to Svelte as it is now broadly available with Firefox releasing support imminently w/ FF v110 this upcoming week (~Feb 14th). Chrome has had support since ~Aug '22. The central issue is that css-tree which is a dependency for CSS AST parsing is significantly lagging behind on adding more recent features such as container query support. Ample time has been given to the maintainer to update css-tree and I do have every confidence that in time css-tree will receive a new major version with all sorts of modern CSS syntax supported including container queries. This PR provides an interim solution for what Svelte needs to support container queries now.
pull/8425/head
Michael Leahy 1 year ago committed by GitHub
parent d49b568019
commit 91e8dfcd6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -173,7 +173,7 @@ class Atrule {
}
apply(node: Element) {
if (this.node.name === 'media' || this.node.name === 'supports' || this.node.name === 'layer') {
if (this.node.name === 'container' || this.node.name === 'media' || this.node.name === 'supports' || this.node.name === 'layer') {
this.children.forEach(child => {
child.apply(node);
});

@ -0,0 +1,43 @@
// @ts-nocheck
// Note: Must import from the `css-tree` browser bundled distribution due to `createRequire` usage if importing from
// `css-tree` Node module directly. This allows the production build of Svelte to work correctly.
import { fork } from '../../../../../node_modules/css-tree/dist/csstree.esm.js';
import * as Comparison from './node/comparison';
import * as ContainerFeature from './node/container_feature';
import * as ContainerFeatureRange from './node/container_feature_range';
import * as ContainerFeatureStyle from './node/container_feature_style';
import * as ContainerQuery from './node/container_query';
import * as QueryCSSFunction from './node/query_css_function';
/**
* Extends `css-tree` for container query support by forking and adding new nodes and at-rule support for `@container`.
*
* The new nodes are located in `./node`.
*/
const cqSyntax = fork({
atrule: { // extend or override at-rule dictionary
container: {
parse: {
prelude() {
return this.createSingleNodeList(
this.ContainerQuery()
);
},
block(isStyleBlock = false) {
return this.Block(isStyleBlock);
}
}
}
},
node: { // extend node types
Comparison,
ContainerFeature,
ContainerFeatureRange,
ContainerFeatureStyle,
ContainerQuery,
QueryCSSFunction
}
});
export const parse = cqSyntax.parse;

@ -0,0 +1,48 @@
// @ts-nocheck
import { Delim } from 'css-tree/tokenizer';
export const name = 'Comparison';
export const structure = {
value: String
};
export function parse() {
const start = this.tokenStart;
const char1 = this.consume(Delim);
// The first character in the comparison operator must match '<', '=', or '>'.
if (char1 !== '<' && char1 !== '>' && char1 !== '=') {
this.error('Malformed comparison operator');
}
let char2;
if (this.tokenType === Delim) {
char2 = this.consume(Delim);
// The second character in the comparison operator must match '='.
if (char2 !== '=') {
this.error('Malformed comparison operator');
}
}
// If the next token is also 'Delim' then it is malformed.
if (this.tokenType === Delim) {
this.error('Malformed comparison operator');
}
const value = char2 ? `${char1}${char2}` : char1;
return {
type: 'Comparison',
loc: this.getLocation(start, this.tokenStart),
value
};
}
export function generate(node) {
for (let index = 0; index < node.value.length; index++) {
this.token(Delim, node.value.charAt(index));
}
}

@ -0,0 +1,82 @@
// @ts-nocheck
import {
Ident,
Number,
Dimension,
Function,
LeftParenthesis,
RightParenthesis,
Colon,
Delim
} from 'css-tree/tokenizer';
export const name = 'ContainerFeature';
export const structure = {
name: String,
value: ['Identifier', 'Number', 'Dimension', 'QueryCSSFunction', 'Ratio', null]
};
export function parse() {
const start = this.tokenStart;
let value = null;
this.eat(LeftParenthesis);
this.skipSC();
const name = this.consume(Ident);
this.skipSC();
if (this.tokenType !== RightParenthesis) {
this.eat(Colon);
this.skipSC();
switch (this.tokenType) {
case Number:
if (this.lookupNonWSType(1) === Delim) {
value = this.Ratio();
} else {
value = this.Number();
}
break;
case Dimension:
value = this.Dimension();
break;
case Function:
value = this.QueryCSSFunction();
break;
case Ident:
value = this.Identifier();
break;
default:
this.error('Number, dimension, ratio, function, or identifier is expected');
break;
}
this.skipSC();
}
this.eat(RightParenthesis);
return {
type: 'ContainerFeature',
loc: this.getLocation(start, this.tokenStart),
name,
value
};
}
export function generate(node) {
this.token(LeftParenthesis, '(');
this.token(Ident, node.name);
if (node.value !== null) {
this.token(Colon, ':');
this.node(node.value);
}
this.token(RightParenthesis, ')');
}

@ -0,0 +1,86 @@
// @ts-nocheck
import {
Ident,
Number,
Delim,
Dimension,
Function,
LeftParenthesis,
RightParenthesis,
WhiteSpace
} from 'css-tree/tokenizer';
export const name = 'ContainerFeatureRange';
export const structure = {
name: String,
value: ['Identifier', 'Number', 'Comparison', 'Dimension', 'QueryCSSFunction', 'Ratio', null]
};
function lookup_non_WS_type_and_value(offset, type, referenceStr) {
let current_type;
do {
current_type = this.lookupType(offset++);
if (current_type !== WhiteSpace) {
break;
}
} while (current_type !== 0); // NULL -> 0
return current_type === type ? this.lookupValue(offset - 1, referenceStr) : false;
}
export function parse() {
const children = this.createList();
let child = null;
this.eat(LeftParenthesis);
this.skipSC();
while (!this.eof && this.tokenType !== RightParenthesis) {
switch (this.tokenType) {
case Number:
if (lookup_non_WS_type_and_value.call(this, 1, Delim, '/')) {
child = this.Ratio();
} else {
child = this.Number();
}
break;
case Delim:
child = this.Comparison();
break;
case Dimension:
child = this.Dimension();
break;
case Function:
child = this.QueryCSSFunction();
break;
case Ident:
child = this.Identifier();
break;
default:
this.error('Number, dimension, comparison, ratio, function, or identifier is expected');
break;
}
children.push(child);
this.skipSC();
}
this.eat(RightParenthesis);
return {
type: 'ContainerFeatureRange',
loc: this.getLocationFromList(children),
children
};
}
export function generate(node) {
this.children(node);
}

@ -0,0 +1,85 @@
// @ts-nocheck
import {
Function,
Ident,
Number,
Dimension,
RightParenthesis,
Colon,
Delim
} from 'css-tree/tokenizer';
export const name = 'ContainerFeatureStyle';
export const structure = {
name: String,
value: ['Function', 'Identifier', 'Number', 'Dimension', 'QueryCSSFunction', 'Ratio', null]
};
export function parse() {
const start = this.tokenStart;
let value = null;
const function_name = this.consumeFunctionName();
if (function_name !== 'style') {
this.error('Unknown container style query identifier; "style" is expected');
}
this.skipSC();
const name = this.consume(Ident);
this.skipSC();
if (this.tokenType !== RightParenthesis) {
this.eat(Colon);
this.skipSC();
switch (this.tokenType) {
case Number:
if (this.lookupNonWSType(1) === Delim) {
value = this.Ratio();
} else {
value = this.Number();
}
break;
case Dimension:
value = this.Dimension();
break;
case Function:
value = this.QueryCSSFunction();
break;
case Ident:
value = this.Identifier();
break;
default:
this.error('Number, dimension, ratio, function or identifier is expected');
break;
}
this.skipSC();
}
this.eat(RightParenthesis);
return {
type: 'ContainerFeatureStyle',
loc: this.getLocation(start, this.tokenStart),
name,
value
};
}
export function generate(node) {
this.token(Function, 'style(');
this.token(Ident, node.name);
if (node.value !== null) {
this.token(Colon, ':');
this.node(node.value);
}
this.token(RightParenthesis, ')');
}

@ -0,0 +1,130 @@
// @ts-nocheck
import {
EOF,
WhiteSpace,
Comment,
Delim,
Function,
Ident,
LeftParenthesis,
RightParenthesis,
LeftCurlyBracket,
Colon
} from 'css-tree/tokenizer';
const CONTAINER_QUERY_KEYWORDS = new Set(['none', 'and', 'not', 'or']);
export const name = 'ContainerQuery';
export const structure = {
name: 'Identifier',
children: [[
'Identifier',
'ContainerFeature',
'ContainerFeatureRange',
'ContainerFeatureStyle',
'WhiteSpace'
]]
};
/**
* Looks ahead to determine if query feature is a range query. This involves locating at least one delimiter and no
* colon tokens.
*
* @returns {boolean} Is potential range query.
*/
function lookahead_is_range() {
let type;
let offset = 0;
let count = 0;
let delim_found = false;
let no_colon = true;
// A range query has maximum 5 tokens when formatted as 'mf-range' /
// '<mf-value> <mf-lt> <mf-name> <mf-lt> <mf-value>'. So only look ahead maximum of 6 non-whitespace tokens.
do {
type = this.lookupNonWSType(offset++);
if (type !== WhiteSpace) {
count++;
}
if (type === Delim) {
delim_found = true;
}
if (type === Colon) {
no_colon = false;
}
if (type === LeftCurlyBracket || type === RightParenthesis) {
break;
}
} while (type !== EOF && count <= 6);
return delim_found && no_colon;
}
export function parse() {
const start = this.tokenStart;
const children = this.createList();
let child = null;
let name = null;
// Parse potential container name.
if (this.tokenType === Ident) {
const container_name = this.substring(this.tokenStart, this.tokenEnd);
// Container name doesn't match a query keyword, so assign it as container name.
if (!CONTAINER_QUERY_KEYWORDS.has(container_name.toLowerCase())) {
name = container_name;
this.eatIdent(container_name);
}
}
this.skipSC();
scan:
while (!this.eof) {
switch (this.tokenType) {
case Comment:
case WhiteSpace:
this.next();
continue;
case Ident:
child = this.Identifier();
break;
case Function:
child = this.ContainerFeatureStyle();
break;
case LeftParenthesis:
// Lookahead to determine if range feature.
child = lookahead_is_range.call(this) ? this.ContainerFeatureRange() : this.ContainerFeature();
break;
default:
break scan;
}
children.push(child);
}
if (child === null) {
this.error('Identifier or parenthesis is expected');
}
return {
type: 'ContainerQuery',
loc: this.getLocation(start, this.tokenStart - 1),
name,
children
};
}
export function generate(node) {
if (typeof node.name === 'string') {
this.token(Ident, node.name);
}
this.children(node);
}

@ -0,0 +1,41 @@
// @ts-nocheck
import {
RightParenthesis
} from 'css-tree/tokenizer';
const QUERY_CSS_FUNCTIONS = new Set(['calc', 'clamp', 'min', 'max']);
export const name = 'QueryCSSFunction';
export const structure = {
name: String,
expression: String
};
export function parse() {
const start = this.tokenStart;
const name = this.consumeFunctionName();
if (!QUERY_CSS_FUNCTIONS.has(name)) {
this.error('Unknown query single value function; expected: "calc", "clamp", "max", min"');
}
const body = this.Raw(this.tokenIndex, null, false);
this.eat(RightParenthesis);
return {
type: 'QueryCSSFunction',
loc: this.getLocation(start, this.tokenStart),
name,
expression: body.value
};
}
export function generate(node) {
this.token(Function, `${node.name}(`);
this.node(node.expression);
this.token(RightParenthesis, ')');
}

@ -1,5 +1,6 @@
// @ts-ignore
import parse from 'css-tree/parser';
// import parse from 'css-tree/parser'; // When css-tree supports container queries uncomment.
import { parse } from './css-tree-cq/css_tree_parse'; // Use extended css-tree for container query support.
import { walk } from 'estree-walker';
import { Parser } from '../index';
import { Node } from 'estree';
@ -78,7 +79,7 @@ export default function read_style(parser: Parser, start: number, attributes: No
});
parser.read(regex_starts_with_closing_style_tag);
const end = parser.index;
return {

@ -0,0 +1 @@
div.svelte-xyz{container:test-container / inline-size}@container (min-width: 400px){div.svelte-xyz{color:red}}@container test-container (min-width: 410px){div.svelte-xyz{color:green}}@container test-container (width < 400px){div.svelte-xyz{color:blue}}@container test-container (0 <= width < 300px){div.svelte-xyz{color:purple}}@container not (width < 400px){div.svelte-xyz{color:pink}}@container (width > 400px) and (height > 400px){div.svelte-xyz{color:lightgreen}}@container (width > 400px) or (height > 400px){div.svelte-xyz{color:lightblue}}@container (width > 400px) and (width > 800px) or (orientation: portrait){div.svelte-xyz{color:salmon}}@container style(color: blue){div.svelte-xyz{color:tan}}@container test-container (min-width: calc(400px + 1px)){div.svelte-xyz{color:green}}@container test-container (width < clamp(200px, 40%, 400px)){div.svelte-xyz{color:blue}}@container test-container (calc(400px + 1px) <= width < calc(500px + 1px)){div.svelte-xyz{color:purple}}@container style(--var: calc(400px + 1px)){div.svelte-xyz{color:sandybrown}}

@ -0,0 +1,87 @@
<div>container query</div>
<style>
div {
container: test-container / inline-size;
}
/* Most common container query statements. */
@container (min-width: 400px) {
div {
color: red;
}
}
@container test-container (min-width: 410px) {
div {
color: green;
}
}
@container test-container (width < 400px) {
div {
color: blue;
}
}
@container test-container (0 <= width < 300px) {
div {
color: purple;
}
}
@container not (width < 400px) {
div {
color: pink;
}
}
@container (width > 400px) and (height > 400px) {
div {
color: lightgreen;
}
}
@container (width > 400px) or (height > 400px) {
div {
color: lightblue;
}
}
@container (width > 400px) and (width > 800px) or (orientation: portrait) {
div {
color: salmon;
}
}
@container style(color: blue) {
div {
color: tan;
}
}
@container test-container (min-width: calc(400px + 1px)) {
div {
color: green;
}
}
@container test-container (width < clamp(200px, 40%, 400px)) {
div {
color: blue;
}
}
@container test-container (calc(400px + 1px) <= width < calc(500px + 1px)) {
div {
color: purple;
}
}
@container style(--var: calc(400px + 1px)) {
div {
color: sandybrown;
}
}
</style>
Loading…
Cancel
Save