Skip to content

feat: nested CSS support #10491

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Feb 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/beige-mirrors-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

fix: correctly scope CSS selectors with descendant combinators
5 changes: 5 additions & 0 deletions .changeset/fluffy-dolls-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

feat: implement nested CSS support
3 changes: 2 additions & 1 deletion packages/svelte/src/compiler/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,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',
'invalid-nesting-selector': () => `Nesting selectors can only be used inside a rule`
};

/** @satisfies {Errors} */
Expand Down
73 changes: 40 additions & 33 deletions packages/svelte/src/compiler/phases/1-parse/read/style.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,36 +83,10 @@ function read_at_rule(parser) {
let block = null;

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
};
}
// e.g. `@media (...) {...}`
block = read_block(parser);
} else {
// e.g. `@import '...'`
parser.eat(';', true);
}

Expand All @@ -138,7 +112,11 @@ function read_rule(parser) {
prelude: read_selector_list(parser),
block: read_block(parser),
start,
end: parser.index
end: parser.index,
metadata: {
parent_rule: null,
has_local_selectors: false
}
};
}

Expand Down Expand Up @@ -216,7 +194,14 @@ function read_selector(parser, inside_pseudo_class = false) {
while (parser.index < parser.template.length) {
let start = parser.index;

if (parser.eat('*')) {
if (parser.eat('&')) {
relative_selector.selectors.push({
type: 'NestingSelector',
name: '&',
start,
end: parser.index
});
} else if (parser.eat('*')) {
let name = '*';

if (parser.eat('|')) {
Expand Down Expand Up @@ -356,6 +341,7 @@ function read_selector(parser, inside_pseudo_class = false) {
end: index,
children,
metadata: {
rule: null,
used: false
}
};
Expand Down Expand Up @@ -432,7 +418,7 @@ function read_block(parser) {

parser.eat('{', true);

/** @type {Array<import('#compiler').Css.Declaration | import('#compiler').Css.Rule>} */
/** @type {Array<import('#compiler').Css.Declaration | import('#compiler').Css.Rule | import('#compiler').Css.Atrule>} */
const children = [];

while (parser.index < parser.template.length) {
Expand All @@ -441,7 +427,7 @@ function read_block(parser) {
if (parser.match('}')) {
break;
} else {
children.push(read_declaration(parser));
children.push(read_block_item(parser));
}
}

Expand All @@ -455,6 +441,27 @@ function read_block(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 {import('#compiler').Css.Declaration}
Expand Down
42 changes: 38 additions & 4 deletions packages/svelte/src/compiler/phases/2-analyze/css/css-analyze.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { walk } from 'zimmerframe';
import { error } from '../../../errors.js';
import { is_keyframes_node } from '../../css.js';
import { merge } from '../../visitors.js';

/**
* @typedef {import('zimmerframe').Visitors<
* import('#compiler').Css.Node,
* NonNullable<import('../../types.js').ComponentAnalysis['css']>
* {
* keyframes: string[];
* rule: import('#compiler').Css.Rule | null;
* }
* >} Visitors
*/

Expand All @@ -24,7 +28,7 @@ function is_global(relative_selector) {
}

/** @type {Visitors} */
const analysis = {
const analysis_visitors = {
Atrule(node, context) {
if (is_keyframes_node(node)) {
if (!node.prelude.startsWith('-global-')) {
Expand All @@ -35,6 +39,8 @@ const analysis = {
ComplexSelector(node, context) {
context.next(); // analyse relevant selectors first

node.metadata.rule = context.state.rule;

node.metadata.used = node.children.every(
({ metadata }) => metadata.is_global || metadata.is_host || metadata.is_root
);
Expand All @@ -59,11 +65,25 @@ const analysis = {
);

context.next();
},
Rule(node, context) {
node.metadata.parent_rule = context.state.rule;

context.next({
...context.state,
rule: node
});

node.metadata.has_local_selectors = node.prelude.children.some((selector) => {
return selector.children.some(
({ metadata }) => !metadata.is_global && !metadata.is_host && !metadata.is_root
);
});
}
};

/** @type {Visitors} */
const validation = {
const validation_visitors = {
ComplexSelector(node, context) {
// ensure `:global(...)` is not used in the middle of a selector
{
Expand Down Expand Up @@ -118,7 +138,21 @@ const validation = {
}
}
}
},
NestingSelector(node, context) {
const rule = /** @type {import('#compiler').Css.Rule} */ (context.state.rule);
if (!rule.metadata.parent_rule) {
error(node, 'invalid-nesting-selector');
}
}
};

export const css_visitors = merge(analysis, validation);
const css_visitors = merge(analysis_visitors, validation_visitors);

/**
* @param {import('#compiler').Css.StyleSheet} stylesheet
* @param {import('../../types.js').ComponentAnalysis} analysis
*/
export function analyze_css(stylesheet, analysis) {
walk(stylesheet, { keyframes: analysis.css.keyframes, rule: null }, css_visitors);
}
Loading