diff --git a/.changeset/styled-components-css-namespace.md b/.changeset/styled-components-css-namespace.md new file mode 100644 index 000000000..06de1723e --- /dev/null +++ b/.changeset/styled-components-css-namespace.md @@ -0,0 +1,5 @@ +--- +"@swc/plugin-styled-components": minor +--- + +Add `cssNamespace` to scope styled-components template CSS under a parent selector. diff --git a/packages/styled-components/README.md b/packages/styled-components/README.md index 8c502bce0..6dabffead 100644 --- a/packages/styled-components/README.md +++ b/packages/styled-components/README.md @@ -26,6 +26,18 @@ Then update your `.swcrc` file like below: } ``` +#### Options + +`cssNamespace` prefixes generated component CSS with a parent selector. It is independent from `namespace`, which only prefixes the generated `componentId`. + +```json +{ + "cssNamespace": "myapp" +} +``` + +With `cssNamespace: "myapp"`, a styled component template is emitted under `.myapp & { ... }`. You can also pass an explicit selector such as `.shell .app`, or a self-reference selector such as `&&` to increase specificity. + # @swc/plugin-styled-components ## 12.7.0 diff --git a/packages/styled-components/README.tmpl.md b/packages/styled-components/README.tmpl.md index ca7a03cec..e6a18358e 100644 --- a/packages/styled-components/README.tmpl.md +++ b/packages/styled-components/README.tmpl.md @@ -28,4 +28,16 @@ Then update your `.swcrc` file like below: } ``` +#### Options + +`cssNamespace` prefixes generated component CSS with a parent selector. It is independent from `namespace`, which only prefixes the generated `componentId`. + +```json +{ + "cssNamespace": "myapp" +} +``` + +With `cssNamespace: "myapp"`, a styled component template is emitted under `.myapp & { ... }`. You can also pass an explicit selector such as `.shell .app`, or a self-reference selector such as `&&` to increase specificity. + ${CHANGELOG} diff --git a/packages/styled-components/__tests__/__snapshots__/wasm.test.ts.snap b/packages/styled-components/__tests__/__snapshots__/wasm.test.ts.snap index 7acd1850e..c916b6654 100644 --- a/packages/styled-components/__tests__/__snapshots__/wasm.test.ts.snap +++ b/packages/styled-components/__tests__/__snapshots__/wasm.test.ts.snap @@ -399,6 +399,52 @@ var _StyledDiv = _styled("div").withConfig({ " `; +exports[`Should load styled-components wasm plugin correctly > Should transform css-namespace correctly 1`] = ` +"import styled, { css, createGlobalStyle } from "styled-components"; +const Button = styled.button([ + \`.myapp &{color:red;&:hover{color:blue;}}\` +]); +const Wrapped = styled(Link)([ + \`.myapp &{color:green;}\` +]); +const ObjectStyle = styled.div({ + color: "black" +}); +const helper = css([ + \`color:hotpink;\` +]); +const GlobalStyle = createGlobalStyle([ + \`body{margin:0;}\` +]); +" +`; + +exports[`Should load styled-components wasm plugin correctly > Should transform css-namespace-selector correctly 1`] = ` +"import styled from "styled-components"; +const Panel = styled.section([ + \`.shell .app &{padding:8px;}\` +]); +" +`; + +exports[`Should load styled-components wasm plugin correctly > Should transform css-namespace-self-reference correctly 1`] = ` +"import styled from "styled-components"; +const Panel = styled.section([ + \`&&{padding:8px;}\` +]); +" +`; + +exports[`Should load styled-components wasm plugin correctly > Should transform css-namespace-with-component-namespace correctly 1`] = ` +"import styled from "styled-components"; +const Button = styled.button.withConfig({ + componentId: "test-namespace__sc-460d3d61-0" +})([ + \`.myapp &{color:red;}\` +]); +" +`; + exports[`Should load styled-components wasm plugin correctly > Should transform does-not-desugar-styled-assignment correctly 1`] = ` "const domElements = [ "div" diff --git a/packages/styled-components/transform/src/lib.rs b/packages/styled-components/transform/src/lib.rs index 2298f2216..7c416f83b 100644 --- a/packages/styled-components/transform/src/lib.rs +++ b/packages/styled-components/transform/src/lib.rs @@ -8,9 +8,9 @@ use swc_ecma_ast::{fn_pass, Pass}; pub use crate::{ utils::{analyze, analyzer, State}, visitors::{ - display_name_and_id::display_name_and_id, minify::visitor::minify, - pure_annotation::pure_annotation, template_literals::template_literals, - transpile_css_prop::transpile::transpile_css_prop, + css_namespace::css_namespace, display_name_and_id::display_name_and_id, + minify::visitor::minify, pure_annotation::pure_annotation, + template_literals::template_literals, transpile_css_prop::transpile::transpile_css_prop, }, }; @@ -36,6 +36,9 @@ pub struct Config { #[serde(default)] pub namespace: String, + #[serde(default)] + pub css_namespace: Option, + #[serde(default)] pub top_level_import_paths: Vec, @@ -67,6 +70,32 @@ impl Config { } format!("{}__", self.namespace) } + + pub(crate) fn css_namespace_selector(&self) -> Option { + let css_namespace = self.css_namespace.as_deref()?.trim(); + + if css_namespace.is_empty() { + return None; + } + + if css_namespace.starts_with('&') { + return Some(css_namespace.to_string()); + } + + let selector = if is_bare_class_name(css_namespace) { + format!(".{css_namespace}") + } else { + css_namespace.to_string() + }; + + Some(format!("{selector} &")) + } +} + +fn is_bare_class_name(value: &str) -> bool { + value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) } pub fn styled_components<'a, C>( @@ -91,6 +120,10 @@ where return; } + if let Some(selector) = config.css_namespace_selector() { + program.mutate(css_namespace(&state, selector)); + } + program.mutate(( Optional { enabled: config.minify, diff --git a/packages/styled-components/transform/src/visitors/css_namespace.rs b/packages/styled-components/transform/src/visitors/css_namespace.rs new file mode 100644 index 000000000..e1b9958ea --- /dev/null +++ b/packages/styled-components/transform/src/visitors/css_namespace.rs @@ -0,0 +1,48 @@ +//! Adds a parent selector wrapper to styled-components template literals. + +use swc_ecma_ast::*; +use swc_ecma_visit::{noop_visit_mut_type, visit_mut_pass, VisitMut, VisitMutWith}; + +use crate::utils::State; + +pub fn css_namespace(state: &State, selector: String) -> impl '_ + Pass { + visit_mut_pass(CssNamespace { state, selector }) +} + +#[derive(Debug)] +struct CssNamespace<'a> { + state: &'a State, + selector: String, +} + +impl VisitMut for CssNamespace<'_> { + noop_visit_mut_type!(fail); + + fn visit_mut_expr(&mut self, expr: &mut Expr) { + expr.visit_mut_children_with(self); + + let Expr::TaggedTpl(tagged) = expr else { + return; + }; + + if !self.state.is_styled(&tagged.tag) { + return; + } + + wrap_template(&mut tagged.tpl, &self.selector); + } +} + +fn wrap_template(tpl: &mut Tpl, selector: &str) { + if let Some(first) = tpl.quasis.first_mut() { + let raw = first.raw.to_string(); + first.raw = format!("{selector} {{{raw}").into(); + first.cooked = None; + } + + if let Some(last) = tpl.quasis.last_mut() { + let raw = last.raw.to_string(); + last.raw = format!("{raw}}}").into(); + last.cooked = None; + } +} diff --git a/packages/styled-components/transform/src/visitors/mod.rs b/packages/styled-components/transform/src/visitors/mod.rs index bb8ecab81..d4748d7df 100644 --- a/packages/styled-components/transform/src/visitors/mod.rs +++ b/packages/styled-components/transform/src/visitors/mod.rs @@ -1,3 +1,4 @@ +pub mod css_namespace; pub mod display_name_and_id; pub mod minify; pub mod pure_annotation; diff --git a/packages/styled-components/transform/tests/fixtures/css-namespace-selector/code.js b/packages/styled-components/transform/tests/fixtures/css-namespace-selector/code.js new file mode 100644 index 000000000..393229269 --- /dev/null +++ b/packages/styled-components/transform/tests/fixtures/css-namespace-selector/code.js @@ -0,0 +1,5 @@ +import styled from "styled-components"; + +const Panel = styled.section` + padding: 8px; +`; diff --git a/packages/styled-components/transform/tests/fixtures/css-namespace-selector/config.json b/packages/styled-components/transform/tests/fixtures/css-namespace-selector/config.json new file mode 100644 index 000000000..d11b505a3 --- /dev/null +++ b/packages/styled-components/transform/tests/fixtures/css-namespace-selector/config.json @@ -0,0 +1,5 @@ +{ + "cssNamespace": ".shell .app", + "ssr": false, + "displayName": false +} diff --git a/packages/styled-components/transform/tests/fixtures/css-namespace-selector/output.js b/packages/styled-components/transform/tests/fixtures/css-namespace-selector/output.js new file mode 100644 index 000000000..b1068803c --- /dev/null +++ b/packages/styled-components/transform/tests/fixtures/css-namespace-selector/output.js @@ -0,0 +1,4 @@ +import styled from "styled-components"; +const Panel = styled.section([ + `.shell .app &{padding:8px;}` +]); diff --git a/packages/styled-components/transform/tests/fixtures/css-namespace-self-reference/code.js b/packages/styled-components/transform/tests/fixtures/css-namespace-self-reference/code.js new file mode 100644 index 000000000..393229269 --- /dev/null +++ b/packages/styled-components/transform/tests/fixtures/css-namespace-self-reference/code.js @@ -0,0 +1,5 @@ +import styled from "styled-components"; + +const Panel = styled.section` + padding: 8px; +`; diff --git a/packages/styled-components/transform/tests/fixtures/css-namespace-self-reference/config.json b/packages/styled-components/transform/tests/fixtures/css-namespace-self-reference/config.json new file mode 100644 index 000000000..356cecb03 --- /dev/null +++ b/packages/styled-components/transform/tests/fixtures/css-namespace-self-reference/config.json @@ -0,0 +1,5 @@ +{ + "cssNamespace": "&&", + "ssr": false, + "displayName": false +} diff --git a/packages/styled-components/transform/tests/fixtures/css-namespace-self-reference/output.js b/packages/styled-components/transform/tests/fixtures/css-namespace-self-reference/output.js new file mode 100644 index 000000000..027234365 --- /dev/null +++ b/packages/styled-components/transform/tests/fixtures/css-namespace-self-reference/output.js @@ -0,0 +1,4 @@ +import styled from "styled-components"; +const Panel = styled.section([ + `&&{padding:8px;}` +]); diff --git a/packages/styled-components/transform/tests/fixtures/css-namespace-with-component-namespace/code.js b/packages/styled-components/transform/tests/fixtures/css-namespace-with-component-namespace/code.js new file mode 100644 index 000000000..03421519e --- /dev/null +++ b/packages/styled-components/transform/tests/fixtures/css-namespace-with-component-namespace/code.js @@ -0,0 +1,5 @@ +import styled from "styled-components"; + +const Button = styled.button` + color: red; +`; diff --git a/packages/styled-components/transform/tests/fixtures/css-namespace-with-component-namespace/config.json b/packages/styled-components/transform/tests/fixtures/css-namespace-with-component-namespace/config.json new file mode 100644 index 000000000..bfea55bcf --- /dev/null +++ b/packages/styled-components/transform/tests/fixtures/css-namespace-with-component-namespace/config.json @@ -0,0 +1,5 @@ +{ + "cssNamespace": "myapp", + "namespace": "test-namespace", + "displayName": false +} diff --git a/packages/styled-components/transform/tests/fixtures/css-namespace-with-component-namespace/output.js b/packages/styled-components/transform/tests/fixtures/css-namespace-with-component-namespace/output.js new file mode 100644 index 000000000..cb410f33d --- /dev/null +++ b/packages/styled-components/transform/tests/fixtures/css-namespace-with-component-namespace/output.js @@ -0,0 +1,6 @@ +import styled from "styled-components"; +const Button = styled.button.withConfig({ + componentId: "test-namespace__sc-460d3d61-0" +})([ + `.myapp &{color:red;}` +]); diff --git a/packages/styled-components/transform/tests/fixtures/css-namespace/code.js b/packages/styled-components/transform/tests/fixtures/css-namespace/code.js new file mode 100644 index 000000000..935f946fa --- /dev/null +++ b/packages/styled-components/transform/tests/fixtures/css-namespace/code.js @@ -0,0 +1,27 @@ +import styled, { css, createGlobalStyle } from "styled-components"; + +const Button = styled.button` + color: red; + + &:hover { + color: blue; + } +`; + +const Wrapped = styled(Link)` + color: green; +`; + +const ObjectStyle = styled.div({ + color: "black", +}); + +const helper = css` + color: hotpink; +`; + +const GlobalStyle = createGlobalStyle` + body { + margin: 0; + } +`; diff --git a/packages/styled-components/transform/tests/fixtures/css-namespace/config.json b/packages/styled-components/transform/tests/fixtures/css-namespace/config.json new file mode 100644 index 000000000..9830ca37e --- /dev/null +++ b/packages/styled-components/transform/tests/fixtures/css-namespace/config.json @@ -0,0 +1,5 @@ +{ + "cssNamespace": "myapp", + "ssr": false, + "displayName": false +} diff --git a/packages/styled-components/transform/tests/fixtures/css-namespace/output.js b/packages/styled-components/transform/tests/fixtures/css-namespace/output.js new file mode 100644 index 000000000..e14069e44 --- /dev/null +++ b/packages/styled-components/transform/tests/fixtures/css-namespace/output.js @@ -0,0 +1,16 @@ +import styled, { css, createGlobalStyle } from "styled-components"; +const Button = styled.button([ + `.myapp &{color:red;&:hover{color:blue;}}` +]); +const Wrapped = styled(Link)([ + `.myapp &{color:green;}` +]); +const ObjectStyle = styled.div({ + color: "black" +}); +const helper = css([ + `color:hotpink;` +]); +const GlobalStyle = createGlobalStyle([ + `body{margin:0;}` +]);