Skip to content

Commit e43e730

Browse files
feat(lint/html): add useVueScopedStyles (#9185)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent f239e20 commit e43e730

15 files changed

Lines changed: 434 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Added the nursery rule `useVueScopedStyles` for Vue SFCs. This rule enforces that `<style>` blocks have the `scoped` attribute (or `module` for CSS Modules), preventing style leakage and conflicts between components.

crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_configuration/src/analyzer/linter/rules.rs

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_diagnostics_categories/src/categories.rs

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
use biome_analyze::{
2+
Ast, FixKind, Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext,
3+
declare_lint_rule,
4+
};
5+
use biome_console::markup;
6+
use biome_html_factory::make;
7+
use biome_html_syntax::{
8+
AnyHtmlAttribute, AstroIsDirective, HtmlFileSource, HtmlOpeningElement, HtmlSyntaxKind,
9+
HtmlSyntaxToken,
10+
};
11+
use biome_rowan::{AstNode, AstNodeList, BatchMutationExt, SyntaxNodeCast};
12+
use biome_rule_options::use_scoped_styles::UseScopedStylesOptions;
13+
14+
declare_lint_rule! {
15+
/// Enforce that `<style>` blocks in Vue SFCs have the `scoped` attribute and that `<style>` blocks in Astro components do not have the `is:global` directive.
16+
///
17+
/// Vue's `scoped` attribute automatically scopes CSS to the component,
18+
/// preventing style leakage and conflicts. Astro's `is:global` attribute
19+
/// allows for global styles, but without it, styles are scoped to the component by default.
20+
///
21+
/// Style blocks with the `module` attribute are exempt, as CSS Modules
22+
/// is an alternative scoping mechanism.
23+
///
24+
/// ## Examples
25+
///
26+
/// ### Invalid
27+
///
28+
/// ```vue,expect_diagnostic
29+
/// <style>
30+
/// .foo { color: red; }
31+
/// </style>
32+
/// ```
33+
///
34+
/// ```astro,expect_diagnostic
35+
/// <style is:global>
36+
/// .foo { color: red; }
37+
/// </style>
38+
/// ```
39+
///
40+
/// ### Valid
41+
///
42+
/// ```vue
43+
/// <style scoped>
44+
/// .foo { color: red; }
45+
/// </style>
46+
/// ```
47+
///
48+
/// ```vue
49+
/// <style module>
50+
/// .foo { color: red; }
51+
/// </style>
52+
/// ```
53+
///
54+
/// ## References:
55+
///
56+
/// - [Vue Documentation](https://vuejs.org/api/sfc-css-features.html#scoped-css)
57+
/// - [Astro Documentation](https://docs.astro.build/en/guides/styling/#global-styles)
58+
pub UseScopedStyles {
59+
version: "next",
60+
name: "useScopedStyles",
61+
language: "html",
62+
recommended: true,
63+
domains: &[RuleDomain::Vue],
64+
sources: &[RuleSource::EslintVueJs("enforce-style-attribute").inspired()],
65+
fix_kind: FixKind::Unsafe,
66+
}
67+
}
68+
69+
pub enum GlobalStylesKind {
70+
Vue,
71+
Astro { directive: AstroIsDirective },
72+
}
73+
74+
impl Rule for UseScopedStyles {
75+
type Query = Ast<HtmlOpeningElement>;
76+
type State = GlobalStylesKind;
77+
type Signals = Option<Self::State>;
78+
type Options = UseScopedStylesOptions;
79+
80+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
81+
if !ctx.source_type::<HtmlFileSource>().is_vue()
82+
&& !ctx.source_type::<HtmlFileSource>().is_astro()
83+
{
84+
return None;
85+
}
86+
87+
let opening = ctx.query();
88+
89+
let name = opening.name().ok()?;
90+
let name_text = name.token_text_trimmed()?;
91+
if !name_text.eq_ignore_ascii_case("style") {
92+
return None;
93+
}
94+
95+
let attributes = opening.attributes();
96+
if ctx.source_type::<HtmlFileSource>().is_vue() {
97+
let has_scoped = attributes.find_by_name("scoped").is_some();
98+
let has_module = attributes.find_by_name("module").is_some();
99+
100+
if has_scoped || has_module {
101+
return None;
102+
} else {
103+
return Some(GlobalStylesKind::Vue);
104+
}
105+
} else if ctx.source_type::<HtmlFileSource>().is_astro() {
106+
let is_directives = attributes
107+
.iter()
108+
.filter_map(|attr| attr.syntax().clone().cast::<AstroIsDirective>());
109+
for directive in is_directives {
110+
let name = directive.value().ok()?.name().ok()?;
111+
let name_text = name.token_text_trimmed()?;
112+
if name_text.eq_ignore_ascii_case("global") {
113+
return Some(GlobalStylesKind::Astro { directive });
114+
}
115+
}
116+
return None;
117+
}
118+
119+
None
120+
}
121+
122+
fn diagnostic(ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
123+
match state {
124+
GlobalStylesKind::Vue => {
125+
Some(
126+
RuleDiagnostic::new(
127+
rule_category!(),
128+
ctx.query().range(),
129+
markup! {
130+
"This "<Emphasis>"<style>"</Emphasis>" block is missing the "<Emphasis>"scoped"</Emphasis>" attribute."
131+
},
132+
)
133+
.note(markup! {
134+
"In Vue, unscoped styles become global across the entire project. This can lead to unintended side effects and maintenance challenges. Adding the "<Emphasis>"scoped"</Emphasis>" attribute ensures that styles are scoped to this component, preventing style leakage and conflicts."
135+
}),
136+
)
137+
},
138+
GlobalStylesKind::Astro { directive } => {
139+
Some(
140+
RuleDiagnostic::new(
141+
rule_category!(),
142+
directive.range(),
143+
markup! {
144+
"This "<Emphasis>"is:global"</Emphasis>" directive is making the styles in this block global."
145+
},
146+
)
147+
.note(markup! {
148+
"In Astro, styles are scoped to the component by default. The "<Emphasis>"is:global"</Emphasis>" directive allows for global styles, but it can lead to unintended side effects and maintenance challenges."
149+
}),
150+
)
151+
}
152+
}
153+
}
154+
155+
fn action(ctx: &RuleContext<Self>, state: &Self::State) -> Option<crate::HtmlRuleAction> {
156+
match state {
157+
GlobalStylesKind::Vue => {
158+
let opening = ctx.query();
159+
let old_attributes = opening.attributes();
160+
161+
let token =
162+
HtmlSyntaxToken::new_detached(HtmlSyntaxKind::HTML_LITERAL, " scoped", [], []);
163+
164+
let attr = AnyHtmlAttribute::HtmlAttribute(
165+
make::html_attribute(make::html_attribute_name(token)).build(),
166+
);
167+
let mut items: Vec<AnyHtmlAttribute> = old_attributes.iter().collect();
168+
items.push(attr);
169+
let new_attributes = make::html_attribute_list(items);
170+
171+
let mut mutation = BatchMutationExt::begin(ctx.root());
172+
mutation.replace_node(old_attributes, new_attributes);
173+
174+
Some(biome_analyze::RuleAction::new(
175+
ctx.metadata().action_category(ctx.category(), ctx.group()),
176+
ctx.metadata().applicability(),
177+
markup! { "Add the "<Emphasis>"scoped"</Emphasis>" attribute so the styles will only apply to this component." }.to_owned(),
178+
mutation,
179+
))
180+
}
181+
GlobalStylesKind::Astro { directive } => {
182+
let mut mutation = BatchMutationExt::begin(ctx.root());
183+
mutation.remove_node(directive.clone());
184+
185+
Some(biome_analyze::RuleAction::new(
186+
ctx.metadata().action_category(ctx.category(), ctx.group()),
187+
ctx.metadata().applicability(),
188+
markup! { "Remove the "<Emphasis>"is:global"</Emphasis>" directive so the styles in this block will be scoped to this component." }.to_owned(),
189+
mutation,
190+
))
191+
}
192+
}
193+
}
194+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<!-- should generate diagnostics -->
2+
3+
<style is:global>
4+
.foo {
5+
color: red;
6+
}
7+
</style>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
source: crates/biome_html_analyze/tests/spec_tests.rs
3+
expression: invalid.astro
4+
---
5+
# Input
6+
```html
7+
<!-- should generate diagnostics -->
8+
9+
<style is:global>
10+
.foo {
11+
color: red;
12+
}
13+
</style>
14+
15+
```
16+
17+
# Diagnostics
18+
```
19+
invalid.astro:3:8 lint/nursery/useScopedStyles FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
20+
21+
i This is:global directive is making the styles in this block global.
22+
23+
1 <!-- should generate diagnostics -->
24+
2
25+
> 3 │ <style is:global>
26+
^^^^^^^^^
27+
4 .foo {
28+
5color: red;
29+
30+
i In Astro, styles are scoped to the component by default. The is:global directive allows for global styles, but it can lead to unintended side effects and maintenance challenges.
31+
32+
i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
33+
34+
i Unsafe fix: Remove the is:global directive so the styles in this block will be scoped to this component.
35+
36+
3<style·is:global>
37+
---------
38+
39+
```
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<!-- should generate diagnostics -->
2+
3+
<style>
4+
.foo { color: red; }
5+
</style>
6+
7+
<style lang="scss">
8+
.foo { color: blue; }
9+
</style>

0 commit comments

Comments
 (0)