diff --git a/.changeset/small-kids-switch.md b/.changeset/small-kids-switch.md new file mode 100644 index 000000000000..85460c87a99d --- /dev/null +++ b/.changeset/small-kids-switch.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +feat: CSS nesting support \ No newline at end of file diff --git a/packages/svelte/src/compiler/css/Selector.js b/packages/svelte/src/compiler/css/Selector.js index dc5360c893eb..b741b93c89da 100644 --- a/packages/svelte/src/compiler/css/Selector.js +++ b/packages/svelte/src/compiler/css/Selector.js @@ -1,7 +1,7 @@ import { get_possible_values } from './utils.js'; import { regex_starts_with_whitespace, regex_ends_with_whitespace } from '../phases/patterns.js'; import { error } from '../errors.js'; -import { Stylesheet } from './Stylesheet.js'; +import { Stylesheet, Rule } from './Stylesheet.js'; const NO_MATCH = 'NO_MATCH'; const POSSIBLE_MATCH = 'POSSIBLE_MATCH'; @@ -12,7 +12,10 @@ const NodeExist = /** @type {const} */ ({ Definitely: 1 }); -/** @typedef {typeof NodeExist[keyof typeof NodeExist]} NodeExistsValue */ +/** + * @typedef {typeof NodeExist[keyof typeof NodeExist]} NodeExistsValue + * @typedef {import("#compiler").Css.SimpleSelector & { use_wrapper: { used: boolean }, visible: boolean }} SimpleSelectorWithData + * */ const whitelist_attribute_selector = new Map([ ['details', new Set(['open'])], @@ -23,45 +26,49 @@ export class ComplexSelector { /** @type {import('#compiler').Css.ComplexSelector} */ node; - /** @type {import('./Stylesheet.js').Stylesheet} */ + /** @type {Stylesheet} */ stylesheet; - /** @type {RelativeSelector[]} */ - relative_selectors; + /** @type {RelativeSelector[][]} */ + selector_list; - /** - * The `relative_selectors`, minus any trailing global selectors - * (which includes `:root` and `:host`) since we ignore these - * when determining if a selector is used. - * @type {RelativeSelector[]} - */ - local_relative_selectors; + /** @type {RelativeSelector[][]} */ + local_selector_list; used = false; /** * @param {import('#compiler').Css.ComplexSelector} node - * @param {import('./Stylesheet.js').Stylesheet} stylesheet + * @param {Stylesheet} stylesheet + * @param {Rule} rule */ - constructor(node, stylesheet) { + constructor(node, stylesheet, rule) { this.node = node; this.stylesheet = stylesheet; - this.relative_selectors = group_selectors(node); + this.selector_list = group_selectors(node, rule); - // take trailing :global(...) selectors out of consideration - const i = this.relative_selectors.findLastIndex((s) => !s.can_ignore()); - this.local_relative_selectors = this.relative_selectors.slice(0, i + 1); + this.local_selector_list = this.selector_list.map((complex_selector) => { + const i = complex_selector.findLastIndex((block) => !block.can_ignore()); + return complex_selector.slice(0, i + 1); + }); // if we have a `:root {...}` or `:global(...) {...}` selector, we need to mark // this selector as `used` even if the component doesn't contain any nodes - this.used = this.local_relative_selectors.length === 0; + this.used = this.local_selector_list.some((blocks) => blocks.length === 0); } - /** @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} node */ + /** + * Determines whether the given selector potentially applies to `node` — + * if so, marks both the selector and the node as encapsulated + * @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} node - The node to apply the selector to. + * @returns {void} + */ apply(node) { - if (apply_selector(this.local_relative_selectors.slice(), node, this.stylesheet)) { - this.used = true; + for (const complex_selector of this.local_selector_list) { + if (apply_selector(complex_selector.slice(), node, this.stylesheet)) { + this.used = true; + } } } @@ -82,18 +89,26 @@ export class ComplexSelector { * @param {string} modifier */ function encapsulate_block(relative_selector, modifier) { - for (const selector of relative_selector.selectors) { + for (const selector of relative_selector.compound.selectors) { if (selector.type === 'PseudoClassSelector' && selector.name === 'global') { remove_global_pseudo_class(selector); } } - let i = relative_selector.selectors.length; + let i = relative_selector.compound.selectors.length; + + let first_selector = relative_selector.compound.selectors[0]; + if (first_selector.type === 'TypeSelector' && !first_selector.visible) return; + while (i--) { - const selector = relative_selector.selectors[i]; + const selector = relative_selector.compound.selectors[i]; + + if (selector.use_wrapper.used) break; + + selector.use_wrapper.used = true; if (selector.type === 'PseudoElementSelector' || selector.type === 'PseudoClassSelector') { - if (selector.name !== 'root' && selector.name !== 'host') { + if (!relative_selector.root && !relative_selector.host) { if (i === 0) code.prependRight(selector.start, modifier); } continue; @@ -109,18 +124,26 @@ export class ComplexSelector { } } - let first = true; - for (const relative_selector of this.relative_selectors) { - if (relative_selector.is_global) { - remove_global_pseudo_class(relative_selector.selectors[0]); - } + for (const complex_selector of this.selector_list) { + // We must wrap modifier with :where if the first selector is invisible (part of a nested rule) + let is_first_invisible_selector = complex_selector[0].contains_invisible_selectors; + let contains_any_invisible_selectors = complex_selector.some( + (relative_selector) => relative_selector.contains_invisible_selectors + ); + let first = contains_any_invisible_selectors ? is_first_invisible_selector : true; + for (const relative_selector of complex_selector) { + if (relative_selector.global) { + // Remove the global pseudo class from the selector + remove_global_pseudo_class(relative_selector.compound.selectors[0]); + } - if (relative_selector.should_encapsulate) { - // for the first occurrence, we use a classname selector, so that every - // encapsulated selector gets a +0-1-0 specificity bump. thereafter, - // we use a `:where` selector, which does not affect specificity - encapsulate_block(relative_selector, first ? modifier : `:where(${modifier})`); - first = false; + if (relative_selector.should_encapsulate) { + // for the first occurrence, we use a classname selector, so that every + // encapsulated selector gets a +0-1-0 specificity bump. thereafter, + // we use a `:where` selector, which does not affect specificity + encapsulate_block(relative_selector, first ? modifier : `:where(${modifier})`); + first = false; + } } } } @@ -134,35 +157,39 @@ export class ComplexSelector { } validate_global_placement() { - let start = 0; - let end = this.relative_selectors.length; - for (; start < end; start += 1) { - if (!this.relative_selectors[start].is_global) break; - } - for (; end > start; end -= 1) { - if (!this.relative_selectors[end - 1].is_global) break; - } - for (let i = start; i < end; i += 1) { - if (this.relative_selectors[i].is_global) { - error(this.relative_selectors[i].selectors[0], 'invalid-css-global-placement'); + for (let complex_selector of this.selector_list) { + let start = 0; + let end = complex_selector.length; + for (; start < end; start += 1) { + if (!complex_selector[start].global) break; + } + for (; end > start; end -= 1) { + if (!complex_selector[end - 1].global) break; + } + for (let i = start; i < end; i += 1) { + if (complex_selector[i].global) { + error(complex_selector[i].compound.selectors[0], 'invalid-css-global-placement'); + } } } } validate_global_with_multiple_selectors() { - if (this.relative_selectors.length === 1 && this.relative_selectors[0].selectors.length === 1) { - // standalone :global() with multiple selectors is OK - return; - } - for (const relative_selector of this.relative_selectors) { - for (const selector of relative_selector.selectors) { - if ( - selector.type === 'PseudoClassSelector' && - selector.name === 'global' && - selector.args !== null && - selector.args.children.length > 1 - ) { - error(selector, 'invalid-css-global-selector'); + for (const complex_selector of this.selector_list) { + if (complex_selector.length === 1 && complex_selector[0].compound.selectors.length === 1) { + // standalone :global() with multiple selectors is OK + return; + } + for (const relative_selector of complex_selector) { + for (const selector of relative_selector.compound.selectors) { + if ( + selector.type === 'PseudoClassSelector' && + selector.name === 'global' && + selector.args !== null && + selector.args.children.length > 1 + ) { + error(selector, 'invalid-css-global-selector'); + } } } } @@ -170,34 +197,37 @@ export class ComplexSelector { /** @param {import('../phases/types.js').ComponentAnalysis} analysis */ validate_invalid_combinator_without_selector(analysis) { - for (let i = 0; i < this.relative_selectors.length; i++) { - const relative_selector = this.relative_selectors[i]; - if (relative_selector.selectors.length === 0) { - error(this.node, 'invalid-css-selector'); + for (const complex_selector of this.selector_list) { + for (const relative_selector of complex_selector) { + if (relative_selector.compound.selectors.length === 0) { + error(this.node, 'invalid-css-selector'); + } } } } validate_global_compound_selector() { - for (const relative_selector of this.relative_selectors) { - if (relative_selector.selectors.length === 1) continue; - - for (let i = 0; i < relative_selector.selectors.length; i++) { - const selector = relative_selector.selectors[i]; - - if (selector.type === 'PseudoClassSelector' && selector.name === 'global') { - const child = selector.args?.children[0].children[0]; - if ( - child?.type === 'TypeSelector' && - !/[.:#]/.test(child.name[0]) && - (i !== 0 || - relative_selector.selectors - .slice(1) - .some( - (s) => s.type !== 'PseudoElementSelector' && s.type !== 'PseudoClassSelector' - )) - ) { - error(selector, 'invalid-css-global-selector-list'); + for (const group of this.selector_list) { + for (const relative_selector of group) { + if (relative_selector.compound.selectors.length === 1) continue; + + for (let i = 0; i < relative_selector.compound.selectors.length; i++) { + const selector = relative_selector.compound.selectors[i]; + + if (selector.type === 'PseudoClassSelector' && selector.name === 'global') { + const child = selector.args?.children[0].children[0]; + if ( + child?.type === 'TypeSelector' && + !/[.:#]/.test(child.name[0]) && + (i !== 0 || + relative_selector.compound.selectors + .slice(1) + .some( + (s) => s.type !== 'PseudoElementSelector' && s.type !== 'PseudoClassSelector' + )) + ) { + error(selector, 'invalid-css-global-selector-list'); + } } } } @@ -216,9 +246,9 @@ function apply_selector(relative_selectors, node, stylesheet) { if (!relative_selector) return false; if (!node) { return ( - (relative_selector.is_global && - relative_selectors.every((relative_selector) => relative_selector.is_global)) || - (relative_selector.is_host && relative_selectors.length === 0) + (relative_selector.global && + relative_selectors.every((relative_selector) => relative_selector.global)) || + (relative_selector.host && relative_selectors.length === 0) ); } const applies = block_might_apply_to_node(relative_selector, node); @@ -249,12 +279,14 @@ function apply_selector(relative_selectors, node, stylesheet) { relative_selector.combinator.name === ' ' ) { for (const ancestor_block of relative_selectors) { - if (ancestor_block.is_global) { + if (ancestor_block.global) { continue; } - if (ancestor_block.is_host) { + + if (ancestor_block.host) { return mark(relative_selector, node); } + /** @type {import('#compiler').RegularElement | import('#compiler').SvelteElement | null} */ let parent = node; let matched = false; @@ -268,13 +300,15 @@ function apply_selector(relative_selectors, node, stylesheet) { return mark(relative_selector, node); } } - if (relative_selectors.every((relative_selector) => relative_selector.is_global)) { + + if (relative_selectors.every((relative_selector) => relative_selector.global)) { return mark(relative_selector, node); } + return false; } else if (relative_selector.combinator.name === '>') { const has_global_parent = relative_selectors.every( - (relative_selector) => relative_selector.is_global + (relative_selector) => relative_selector.global ); if ( has_global_parent || @@ -282,6 +316,7 @@ function apply_selector(relative_selectors, node, stylesheet) { ) { return mark(relative_selector, node); } + return false; } else if ( relative_selector.combinator.name === '+' || @@ -292,24 +327,25 @@ function apply_selector(relative_selectors, node, stylesheet) { relative_selector.combinator.name === '+' ); let has_match = false; + // NOTE: if we have :global(), we couldn't figure out what is selected within `:global` due to the // css-tree limitation that does not parse the inner selector of :global // so unless we are sure there will be no sibling to match, we will consider it as matched - const has_global = relative_selectors.some( - (relative_selector) => relative_selector.is_global - ); + const has_global = relative_selectors.some((relative_selector) => relative_selector.global); if (has_global) { if (siblings.size === 0 && get_element_parent(node) !== null) { return false; } return mark(relative_selector, node); } + for (const possible_sibling of siblings.keys()) { if (apply_selector(relative_selectors.slice(), possible_sibling, stylesheet)) { mark(relative_selector, node); has_match = true; } } + return has_match; } @@ -328,11 +364,11 @@ const regex_backslash_and_following_character = /\\(.)/g; * @returns {NO_MATCH | POSSIBLE_MATCH | UNKNOWN_SELECTOR} */ function block_might_apply_to_node(relative_selector, node) { - if (relative_selector.is_host || relative_selector.is_root) return NO_MATCH; + if (relative_selector.host || relative_selector.root) return NO_MATCH; - let i = relative_selector.selectors.length; + let i = relative_selector.compound.selectors.length; while (i--) { - const selector = relative_selector.selectors[i]; + const selector = relative_selector.compound.selectors[i]; if (selector.type === 'Percentage' || selector.type === 'Nth') continue; @@ -342,7 +378,7 @@ function block_might_apply_to_node(relative_selector, node) { return NO_MATCH; } if ( - relative_selector.selectors.length === 1 && + relative_selector.compound.selectors.length === 1 && selector.type === 'PseudoClassSelector' && name === 'global' ) { @@ -804,51 +840,102 @@ function loop_child(children, adjacent_only) { } /** - * Represents a compound selector (aka an array of simple selectors) plus - * a preceding combinator (if not the first in the list). Given this... - * - * ```css - * .a + .b.c {...} - * ``` - * - * ...both `.a` and `+ .b.c` are relative selectors. - * Combined, they are a complex selector. + * Not shared between different Selector instances */ class RelativeSelector { /** @type {import('#compiler').Css.Combinator | null} */ combinator; - /** @type {import('#compiler').Css.SimpleSelector[]} */ - selectors = []; + /** @type {CompoundSelector} */ + compound; - is_host = false; - is_root = false; - should_encapsulate = false; - start = -1; - end = -1; + /** @type {boolean} */ + should_encapsulate; - /** @param {import('#compiler').Css.Combinator | null} combinator */ - constructor(combinator) { + /** + * @param {import('#compiler').Css.Combinator | null} combinator + * @param {CompoundSelector} compound + * */ + constructor(combinator, compound) { this.combinator = combinator; + this.compound = compound; + this.should_encapsulate = false; + } + + /** @param {SimpleSelectorWithData} selector */ + add(selector) { + this.compound.add(selector); + } + + can_ignore() { + return this.compound.global || this.compound.host || this.compound.root; } - /** @param {import('#compiler').Css.SimpleSelector} selector */ + get global() { + return this.compound.global; + } + + get host() { + return this.compound.host; + } + + get root() { + return this.compound.root; + } + + get contains_invisible_selectors() { + return this.compound.selectors.some((selector) => !selector.visible); + } +} + +/** @type {import('#compiler').Css.Combinator} */ +const FakeCombinator = { + type: 'Combinator', + name: ' ', + start: -1, + end: -1 +}; + +/** + * Shared between different Selector instances, so they are + * not encapsulated multiple times + **/ +class CompoundSelector { + /** @type {Array} */ + selectors; + + /** @type {number} */ + start; + + /** @type {number} */ + end; + + /** @type {boolean} */ + host; + + /** @type {boolean} */ + root; + + constructor() { + this.selectors = []; + this.start = -1; + this.end = -1; + this.host = false; + this.root = false; + } + + /** @param {SimpleSelectorWithData} selector */ add(selector) { if (this.selectors.length === 0) { this.start = selector.start; - this.is_host = selector.type === 'PseudoClassSelector' && selector.name === 'host'; + this.host = selector.type === 'PseudoClassSelector' && selector.name === 'host'; } - this.is_root = - this.is_root || (selector.type === 'PseudoClassSelector' && selector.name === 'root'); + this.root = this.root || (selector.type === 'PseudoClassSelector' && selector.name === 'root'); this.selectors.push(selector); this.end = selector.end; } - can_ignore() { - return this.is_global || this.is_host || this.is_root; - } - - get is_global() { + get global() { return ( this.selectors.length >= 1 && this.selectors[0].type === 'PseudoClassSelector' && @@ -861,18 +948,132 @@ class RelativeSelector { } } -/** @param {import('#compiler').Css.ComplexSelector} selector */ -function group_selectors(selector) { - let relative_selector = new RelativeSelector(null); - const relative_selectors = [relative_selector]; +/** + * Groups selectors and inserts parent blocks into nested rules. + * + * @param {import('#compiler').Css.ComplexSelector} selector - The selector to group and analyze. + * @param {Rule} rule + * @returns {RelativeSelector[][]} - The grouped selectors with parent's blocks inserted if nested. + */ +function group_selectors(selector, rule) { + // TODO this logic isn't quite right, as it doesn't properly account for atrules + if (rule.parent instanceof Rule) { + const parent_selector_list = rule.parent.selectors + .map((selector) => selector.selector_list) + .flat(); + + return parent_selector_list.map((parent_complex_selector) => { + return selector_to_blocks( + [...selector.children], + [...parent_complex_selector] // Clone the parent's blocks to avoid modifying the original array + ); + }); + } + + return [selector_to_blocks([...selector.children], null)]; +} + +/** + * @param {import('#compiler').Css.ComplexSelector["children"]} children + * @param {RelativeSelector[] | null} parent_complex_selector - The parent rule's selectors to insert/swap into the nesting selector positions. + */ +function selector_to_blocks(children, parent_complex_selector) { + let block = new RelativeSelector(null, new CompoundSelector()); + const blocks = [block]; + + // If this is a nested rule + if (parent_complex_selector) nest_fake_parents(children, parent_complex_selector); - selector.children.forEach((child) => { + for (const child of children) { if (child.type === 'Combinator') { - relative_selector = new RelativeSelector(child); - relative_selectors.push(relative_selector); + block = new RelativeSelector(child, new CompoundSelector()); + blocks.push(block); + } else if (child.type === 'NestingSelector') { + if (!parent_complex_selector) { + error(child, 'nesting-selector-not-allowed'); + } else { + // We shoudld've already handled these above (except for multiple nesting selectors, which is supposed to work?) + throw new Error('Unexpected nesting selector'); + } } else { - relative_selector.add(child); + // shared reference bween all children + child.use_wrapper = child.use_wrapper ?? { used: false }; + + // Shallow copy the child to avoid modifying the original's visibility + block.add( + /** @type {SimpleSelectorWithData} */ ({ + ...child, + visible: child.visible === undefined ? true : child.visible + }) + ); } - }); - return relative_selectors; + } + + return blocks; +} + +/** + * @param {RelativeSelector[]} parent_complex_selector - The parent blocks to insert into the nesting selector positions. + * @returns {import('#compiler').Css.ComplexSelector["children"]} - The parent selectors to insert into the nesting selector positions. + */ +function get_parent_selectors(parent_complex_selector) { + const parent_selectors = []; + for (const relative_selector of parent_complex_selector) { + if (relative_selector.combinator) { + parent_selectors.push(relative_selector.combinator); + } + parent_selectors.push( + ...relative_selector.compound.selectors.map((selector) => ({ + ...selector, + visible: false + })) + ); + } + return parent_selectors; +} + +/** + * Nest the parent selectors into the children array so we can easily + * check for usage and scoping. + * + * Some cases: + * b { c { color: red }} -> need to insert ' ' before c, so output needs to look like [b, " ", c] + * b { & c { color: red }} -> already has a child combinator before c, so output needs to look like [b, " ", c] + * b { & > c { color: red }} -> next combinator is '>' so output needs to look like [b, >, c] + * b { c & { color: red }} -> so we need to insert ' ' after c so children needs to look like [c, " ",b] + * .x { & { color: red }} -> no combinator, so children needs to look like .x.x + * + * @param {import('#compiler').Css.ComplexSelector["children"]} children + * @param {RelativeSelector[]} parent_complex_selector - The parent blocks to insert into the nesting selector positions. + */ +function nest_fake_parents(children, parent_complex_selector) { + const nested_selector_indexes = children.reduce((indexes, child, index) => { + if (child.type === 'NestingSelector') { + indexes.push(index); + } + return indexes; + }, /** @type {number[]} */ ([])); + + let used_ampersand = nested_selector_indexes.length !== 0; + + if (!used_ampersand) { + // insert the parent selectors at the beginning of the children array + nested_selector_indexes.push(0); + // If there are no nesting selectors and the next item is not a combinator + // we need to insert a fake combinator because: + // a { b { color: red }} is equivalent to a { & b { color: red }} + // however a { + b { color: red }} is not equivalent to a [ "&", " ", "+", "b" ] { color: red }} + if (children[0].type !== 'Combinator') { + children.unshift(FakeCombinator); + } + children.unshift({ type: 'NestingSelector', name: '&', start: -1, end: -1 }); + } + + /** @type typeof children */ + const parent_selectors = get_parent_selectors(parent_complex_selector); + + // Insert the parent selectors into the children array in reverse order (so we don't mess up the indexes) + for (const nested_selector_index of nested_selector_indexes.reverse()) { + children.splice(nested_selector_index, 1, ...parent_selectors); + } } diff --git a/packages/svelte/src/compiler/css/Stylesheet.js b/packages/svelte/src/compiler/css/Stylesheet.js index e632a3692e7c..6fb98d2c3668 100644 --- a/packages/svelte/src/compiler/css/Stylesheet.js +++ b/packages/svelte/src/compiler/css/Stylesheet.js @@ -47,43 +47,50 @@ function escape_comment_close(node, code) { } } -class Rule { +export class Rule { /** @type {ComplexSelector[]} */ selectors; /** @type {import('#compiler').Css.Rule} */ node; - /** @type {Stylesheet | Atrule} */ + /** @type {Stylesheet | Atrule | Rule} */ parent; /** @type {Declaration[]} */ - declarations; + declarations = []; + + /** @type {Array} */ + children = []; /** * @param {import('#compiler').Css.Rule} node - * @param {any} stylesheet - * @param {Stylesheet | Atrule} parent + * @param {Stylesheet} stylesheet + * @param {Stylesheet | Atrule | Rule} parent */ constructor(node, stylesheet, parent) { this.node = node; this.parent = parent; - this.selectors = node.prelude.children.map((node) => new ComplexSelector(node, stylesheet)); - this.declarations = /** @type {import('#compiler').Css.Declaration[]} */ ( - node.block.children - ).map((node) => new Declaration(node)); + this.selectors = node.prelude.children.map( + (node) => new ComplexSelector(node, stylesheet, this) + ); } /** @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} node */ apply(node) { this.selectors.forEach((selector) => selector.apply(node)); // TODO move the logic in here? + this.children.forEach((rule) => rule.apply(node)); } /** @returns {boolean} */ is_empty() { if (this.declarations.length > 0) return false; + for (const rule of this.children) { + if (rule.is_used() && !rule.is_empty()) return false; + } + return true; } @@ -97,6 +104,10 @@ class Rule { if (selector.used) return true; } + for (const rule of this.children) { + if (rule.is_used()) return true; + } + return false; } @@ -113,6 +124,7 @@ class Rule { const modifier = `.${id}`; this.selectors.forEach((selector) => selector.transform(code, modifier)); this.declarations.forEach((declaration) => declaration.transform(code, keyframes)); + this.children.forEach((rule) => rule.transform(code, id, keyframes)); } /** @param {import('../phases/types.js').ComponentAnalysis} analysis */ @@ -120,6 +132,9 @@ class Rule { this.selectors.forEach((selector) => { selector.validate(analysis); }); + this.children.forEach((rule) => { + rule.validate(analysis); + }); } /** @param {(selector: ComplexSelector) => void} handler */ @@ -127,6 +142,9 @@ class Rule { this.selectors.forEach((selector) => { if (!selector.used) handler(selector); }); + this.children.forEach((rule) => { + rule.warn_on_unused_selector(handler); + }); } /** @@ -188,6 +206,10 @@ class Rule { code.appendLeft(last, '*/'); } } + + for (const rule of this.children) { + rule.prune(code, dev); + } } } @@ -379,6 +401,7 @@ export class Stylesheet { css: ast.content.styles, hash }); + this.has_styles = true; const state = { @@ -396,7 +419,6 @@ export class Stylesheet { } } - // @ts-expect-error temporary, until nesting is implemented context.state.current.children.push(atrule); context.next({ current: atrule }); }, @@ -405,10 +427,8 @@ export class Stylesheet { /** @type {Atrule | Rule} */ (context.state.current).declarations.push(declaration); }, Rule: (node, context) => { - // @ts-expect-error temporary, until nesting is implemented const rule = new Rule(node, this, context.state.current); - // @ts-expect-error temporary, until nesting is implemented context.state.current.children.push(rule); context.next({ current: rule }); } diff --git a/packages/svelte/src/compiler/css/types.d.ts b/packages/svelte/src/compiler/css/types.d.ts index f275e114ba96..22d1e2c13559 100644 --- a/packages/svelte/src/compiler/css/types.d.ts +++ b/packages/svelte/src/compiler/css/types.d.ts @@ -72,7 +72,12 @@ export interface Nth extends BaseNode { value: string; } -export type SimpleSelector = +export interface NestingSelector extends BaseNode { + type: 'NestingSelector'; + name: '&'; +} + +export type SimpleSelector = ( | TypeSelector | IdSelector | ClassSelector @@ -80,7 +85,14 @@ export type SimpleSelector = | PseudoElementSelector | PseudoClassSelector | Percentage - | Nth; + | Nth + | NestingSelector +) & { + // Deeply nested because we want to track whether the selector is used + // between clones of the AST + use_wrapper?: { used: boolean }; + visible?: boolean; +}; export interface Combinator extends BaseNode { type: 'Combinator'; diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js index 891b0956d4c8..317516f10679 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -93,7 +93,8 @@ const parse = { 'invalid-render-arguments': () => 'expected at most one argument', 'invalid-render-spread-argument': () => 'cannot use spread arguments in {@render ...} tags', 'invalid-snippet-rest-parameter': () => - 'snippets do not support rest parameters; use an array instead' + 'snippets do not support rest parameters; use an array instead', + 'expected-declaration-or-nested-rule': () => 'Expected a CSS declaration or nested rule' }; /** @satisfies {Errors} */ @@ -107,7 +108,8 @@ const css = { 'invalid-css-global-selector-list': () => `:global(...) must not contain type or universal selectors when used in a compound selector`, 'invalid-css-selector': () => `Invalid selector`, - 'invalid-css-identifier': () => 'Expected a valid CSS identifier' + 'invalid-css-identifier': () => 'Expected a valid CSS identifier', + 'nesting-selector-not-allowed': () => 'Nesting selector is not allowed in top level rules' }; /** @satisfies {Errors} */ diff --git a/packages/svelte/src/compiler/phases/1-parse/read/style.js b/packages/svelte/src/compiler/phases/1-parse/read/style.js index 8f30b5516241..bccb209f43fc 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/style.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/style.js @@ -83,37 +83,11 @@ function read_at_rule(parser) { /** @type {import('#compiler').Css.Block | null} */ let block = null; + // eg: `@media (max-width: 600px) { ... }` if (parser.match('{')) { - // if the parser could easily distinguish between rules and declarations, this wouldn't be necessary. - // but this approach is much simpler. in future, when we support CSS nesting, the parser _will_ need - // to be able to distinguish between them, but since we'll also need other changes to support that - // this remains a TODO - const contains_declarations = [ - 'color-profile', - 'counter-style', - 'font-face', - 'font-palette-values', - 'page', - 'property' - ].includes(name); - - if (contains_declarations) { - block = read_block(parser); - } else { - const start = parser.index; - - parser.eat('{', true); - const children = read_body(parser, '}'); - parser.eat('}', true); - - block = { - type: 'Block', - start, - end: parser.index, - children - }; - } + block = read_block(parser); } else { + // eg: `@import 'foo';` parser.eat(';', true); } @@ -193,14 +167,20 @@ function read_selector(parser, inside_pseudo_class = false) { while (parser.index < parser.template.length) { const start = parser.index; - if (parser.eat('*')) { + if (parser.eat('&')) { + children.push({ + type: 'NestingSelector', + name: '&', + start, + end: parser.index + }); + } else if (parser.eat('*')) { let name = '*'; if (parser.match('|')) { // * is the namespace (which we ignore) parser.index++; name = read_identifier(parser); } - children.push({ type: 'TypeSelector', name, @@ -362,7 +342,7 @@ function read_block(parser) { parser.eat('{', true); - /** @type {Array} */ + /** @type {Array} */ const children = []; while (parser.index < parser.template.length) { @@ -371,7 +351,7 @@ function read_block(parser) { if (parser.match('}')) { break; } else { - children.push(read_declaration(parser)); + children.push(read_block_item(parser)); } } @@ -414,6 +394,27 @@ function read_declaration(parser) { }; } +/** + * Reads a declaration, rule or at-rule + * + * @param {import('../index.js').Parser} parser + * @returns {import('#compiler').Css.Declaration | import('#compiler').Css.Rule | import('#compiler').Css.Atrule} + */ +function read_block_item(parser) { + if (parser.match('@')) { + return read_at_rule(parser); + } + + // read ahead to understand whether we're dealing with a declaration or a nested rule. + // this involves some duplicated work, but avoids a try-catch that would disguise errors + const start = parser.index; + read_value(parser); + const char = parser.template[parser.index]; + parser.index = start; + + return char === '{' ? read_rule(parser) : read_declaration(parser); +} + /** * @param {import('../index.js').Parser} parser * @returns {string} diff --git a/packages/svelte/tests/css/samples/nested-css/_config.js b/packages/svelte/tests/css/samples/nested-css/_config.js new file mode 100644 index 000000000000..53ebc0cee861 --- /dev/null +++ b/packages/svelte/tests/css/samples/nested-css/_config.js @@ -0,0 +1,7 @@ +import { test } from '../../test'; + +export default test({ + compileOptions: { + dev: false + } +}); diff --git a/packages/svelte/tests/css/samples/nested-css/expected.css b/packages/svelte/tests/css/samples/nested-css/expected.css new file mode 100644 index 000000000000..3a2c455fe99c --- /dev/null +++ b/packages/svelte/tests/css/samples/nested-css/expected.css @@ -0,0 +1,59 @@ + + .a.svelte-xyz { + color: green; + + /* implicit & */ + .b:where(.svelte-xyz) /* (unused) .unused*/ { + color: green; + + .c:where(.svelte-xyz) { + color: green; + } + + /* (unused) .unused { + color: red; + + .c:where(.svelte-xyz) { + color: red; + } + }*/ + } + + /* (empty) .d:where(.svelte-xyz) { + .unused { + color: red; + } + }*/ + + /* explicit & */ + & .b:where(.svelte-xyz) { + color: green; + + /* (empty) .c:where(.svelte-xyz) { + & & { + color: red; + } + }*/ + } + + & & { + color: green; + } + + /* silly but valid */ + && { + color: rebeccapurple; + } + + .container:where(.svelte-xyz) & { + color: green; + } + + /* (unused) &.b { + color: red; + }*/ + + /* (unused) .unused { + color: red; + }*/ + } diff --git a/packages/svelte/tests/css/samples/nested-css/input.svelte b/packages/svelte/tests/css/samples/nested-css/input.svelte new file mode 100644 index 000000000000..b14b68463e23 --- /dev/null +++ b/packages/svelte/tests/css/samples/nested-css/input.svelte @@ -0,0 +1,74 @@ +
+
+ +
+
+
+ +
+
+ +
+
+
+ + diff --git a/packages/svelte/tests/css/samples/weird-syntactic-escaping/expected.css b/packages/svelte/tests/css/samples/weird-syntactic-escaping/expected.css new file mode 100644 index 000000000000..6d3eaf6c3d46 --- /dev/null +++ b/packages/svelte/tests/css/samples/weird-syntactic-escaping/expected.css @@ -0,0 +1,13 @@ +[title='{;}'].svelte-xyz { + content: "{};[]"; + /* {} */ + color: red; + foo:where(.svelte-xyz) { + color: red; + /* [] ; { } */ + color: green; + /* [] { } ; */ + color: black; + } + color: red; +} \ No newline at end of file diff --git a/packages/svelte/tests/css/samples/weird-syntactic-escaping/input.svelte b/packages/svelte/tests/css/samples/weird-syntactic-escaping/input.svelte new file mode 100644 index 000000000000..a94156eb068c --- /dev/null +++ b/packages/svelte/tests/css/samples/weird-syntactic-escaping/input.svelte @@ -0,0 +1,19 @@ +
+ +
+ +