diff --git a/src/compiler/compile/css/Stylesheet.ts b/src/compiler/compile/css/Stylesheet.ts index 8f54830e6a..7cb1af3635 100644 --- a/src/compiler/compile/css/Stylesheet.ts +++ b/src/compiler/compile/css/Stylesheet.ts @@ -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); }); diff --git a/src/compiler/parse/read/css-tree-cq/css_tree_parse.ts b/src/compiler/parse/read/css-tree-cq/css_tree_parse.ts new file mode 100644 index 0000000000..d56e6d72ed --- /dev/null +++ b/src/compiler/parse/read/css-tree-cq/css_tree_parse.ts @@ -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; diff --git a/src/compiler/parse/read/css-tree-cq/node/comparison.ts b/src/compiler/parse/read/css-tree-cq/node/comparison.ts new file mode 100644 index 0000000000..4f40f59c24 --- /dev/null +++ b/src/compiler/parse/read/css-tree-cq/node/comparison.ts @@ -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)); + } +} diff --git a/src/compiler/parse/read/css-tree-cq/node/container_feature.ts b/src/compiler/parse/read/css-tree-cq/node/container_feature.ts new file mode 100644 index 0000000000..d84d5f5afd --- /dev/null +++ b/src/compiler/parse/read/css-tree-cq/node/container_feature.ts @@ -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, ')'); +} diff --git a/src/compiler/parse/read/css-tree-cq/node/container_feature_range.ts b/src/compiler/parse/read/css-tree-cq/node/container_feature_range.ts new file mode 100644 index 0000000000..747fbe8da0 --- /dev/null +++ b/src/compiler/parse/read/css-tree-cq/node/container_feature_range.ts @@ -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); +} diff --git a/src/compiler/parse/read/css-tree-cq/node/container_feature_style.ts b/src/compiler/parse/read/css-tree-cq/node/container_feature_style.ts new file mode 100644 index 0000000000..343b5d47ca --- /dev/null +++ b/src/compiler/parse/read/css-tree-cq/node/container_feature_style.ts @@ -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, ')'); +} diff --git a/src/compiler/parse/read/css-tree-cq/node/container_query.ts b/src/compiler/parse/read/css-tree-cq/node/container_query.ts new file mode 100644 index 0000000000..c88b91f12d --- /dev/null +++ b/src/compiler/parse/read/css-tree-cq/node/container_query.ts @@ -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' / + // ' '. 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); +} + diff --git a/src/compiler/parse/read/css-tree-cq/node/query_css_function.ts b/src/compiler/parse/read/css-tree-cq/node/query_css_function.ts new file mode 100644 index 0000000000..792aeabd35 --- /dev/null +++ b/src/compiler/parse/read/css-tree-cq/node/query_css_function.ts @@ -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, ')'); +} diff --git a/src/compiler/parse/read/style.ts b/src/compiler/parse/read/style.ts index 3ed510c3d0..eba86d22de 100644 --- a/src/compiler/parse/read/style.ts +++ b/src/compiler/parse/read/style.ts @@ -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 { diff --git a/test/css/samples/container-query/expected.css b/test/css/samples/container-query/expected.css new file mode 100644 index 0000000000..5002ed9c83 --- /dev/null +++ b/test/css/samples/container-query/expected.css @@ -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}} \ No newline at end of file diff --git a/test/css/samples/container-query/input.svelte b/test/css/samples/container-query/input.svelte new file mode 100644 index 0000000000..bb6de73618 --- /dev/null +++ b/test/css/samples/container-query/input.svelte @@ -0,0 +1,87 @@ +
container query
+ +