Skip to content
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/add-use-vue-scoped-styles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@biomejs/biome": patch
---

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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Changeset description is out of date with the implementation.

It still says useVueScopedStyles and Vue-only scope, while the shipped rule is useScopedStyles and also handles Astro is:global.

♻️ Suggested patch
-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.
+Added the nursery rule `useScopedStyles` for Vue and Astro components. It enforces component-scoped styles by requiring `<style scoped>` (or `module`) in Vue SFCs and disallowing `<style is:global>` in Astro, helping prevent style leakage across components.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.changeset/add-use-vue-scoped-styles.md at line 5, Update the changeset text
to match the implemented rule name and behavior: replace references to
`useVueScopedStyles` and "Vue-only" with `useScopedStyles` and mention that it
also enforces Astro `is:global` handling (or that it accepts `module` for CSS
Modules where applicable); ensure the description explains that the rule applies
to Vue SFCs and Astro files and enforces scoped/module or Astro `is:global`
usage to prevent style leakage.

16 changes: 16 additions & 0 deletions crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions crates/biome_configuration/src/analyzer/linter/rules.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions crates/biome_diagnostics_categories/src/categories.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

194 changes: 194 additions & 0 deletions crates/biome_html_analyze/src/lint/nursery/use_scoped_styles.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
use biome_analyze::{
Ast, FixKind, Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext,
declare_lint_rule,
};
use biome_console::markup;
use biome_html_factory::make;
use biome_html_syntax::{
AnyHtmlAttribute, AstroIsDirective, HtmlFileSource, HtmlOpeningElement, HtmlSyntaxKind,
HtmlSyntaxToken,
};
use biome_rowan::{AstNode, AstNodeList, BatchMutationExt, SyntaxNodeCast};
use biome_rule_options::use_scoped_styles::UseScopedStylesOptions;

declare_lint_rule! {
/// 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.
///
/// Vue's `scoped` attribute automatically scopes CSS to the component,
/// preventing style leakage and conflicts. Astro's `is:global` attribute
/// allows for global styles, but without it, styles are scoped to the component by default.
///
/// Style blocks with the `module` attribute are exempt, as CSS Modules
/// is an alternative scoping mechanism.
///
/// ## Examples
///
/// ### Invalid
///
/// ```vue,expect_diagnostic
/// <style>
/// .foo { color: red; }
/// </style>
/// ```
///
/// ```astro,expect_diagnostic
/// <style is:global>
/// .foo { color: red; }
/// </style>
/// ```
///
/// ### Valid
///
/// ```vue
/// <style scoped>
/// .foo { color: red; }
/// </style>
/// ```
///
/// ```vue
/// <style module>
/// .foo { color: red; }
/// </style>
/// ```
///
/// ## References:
///
/// - [Vue Documentation](https://vuejs.org/api/sfc-css-features.html#scoped-css)
/// - [Astro Documentation](https://docs.astro.build/en/guides/styling/#global-styles)
pub UseScopedStyles {
version: "next",
name: "useScopedStyles",
language: "html",
recommended: true,
domains: &[RuleDomain::Vue],
sources: &[RuleSource::EslintVueJs("enforce-style-attribute").inspired()],
fix_kind: FixKind::Unsafe,
}
}

pub enum GlobalStylesKind {
Vue,
Astro { directive: AstroIsDirective },
}

impl Rule for UseScopedStyles {
type Query = Ast<HtmlOpeningElement>;
type State = GlobalStylesKind;
type Signals = Option<Self::State>;
type Options = UseScopedStylesOptions;

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
if !ctx.source_type::<HtmlFileSource>().is_vue()
&& !ctx.source_type::<HtmlFileSource>().is_astro()
{
return None;
}

let opening = ctx.query();

let name = opening.name().ok()?;
let name_text = name.token_text_trimmed()?;
if !name_text.eq_ignore_ascii_case("style") {
return None;
}

let attributes = opening.attributes();
if ctx.source_type::<HtmlFileSource>().is_vue() {
let has_scoped = attributes.find_by_name("scoped").is_some();
let has_module = attributes.find_by_name("module").is_some();

if has_scoped || has_module {
return None;
} else {
return Some(GlobalStylesKind::Vue);
}
} else if ctx.source_type::<HtmlFileSource>().is_astro() {
let is_directives = attributes
.iter()
.filter_map(|attr| attr.syntax().clone().cast::<AstroIsDirective>());
for directive in is_directives {
let name = directive.value().ok()?.name().ok()?;
let name_text = name.token_text_trimmed()?;
if name_text.eq_ignore_ascii_case("global") {
return Some(GlobalStylesKind::Astro { directive });
Comment on lines +109 to +113
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid bailing out the Astro scan on one malformed directive.

Using ? here can return early and skip checking later directives on the same <style> tag.

♻️ Suggested patch
             for directive in is_directives {
-                let name = directive.value().ok()?.name().ok()?;
-                let name_text = name.token_text_trimmed()?;
-                if name_text.eq_ignore_ascii_case("global") {
+                let is_global = directive
+                    .value()
+                    .ok()
+                    .and_then(|value| value.name().ok())
+                    .and_then(|name| name.token_text_trimmed())
+                    .is_some_and(|name| name.eq_ignore_ascii_case("global"));
+                if is_global {
                     return Some(GlobalStylesKind::Astro { directive });
                 }
             }

Based on learnings: Applies to crates/biome_analyze/**/*.rs : Use functional methods like map, filter, and and_then on Result and Option types instead of nested if let statements to avoid excessive indentation.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for directive in is_directives {
let name = directive.value().ok()?.name().ok()?;
let name_text = name.token_text_trimmed()?;
if name_text.eq_ignore_ascii_case("global") {
return Some(GlobalStylesKind::Astro { directive });
for directive in is_directives {
let is_global = directive
.value()
.ok()
.and_then(|value| value.name().ok())
.and_then(|name| name.token_text_trimmed())
.is_some_and(|name| name.eq_ignore_ascii_case("global"));
if is_global {
return Some(GlobalStylesKind::Astro { directive });
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/biome_html_analyze/src/lint/nursery/use_scoped_styles.rs` around lines
109 - 113, The loop over is_directives currently uses the try operator (?),
which can early-return from the whole function on a malformed directive and skip
remaining directives; change the per-directive extraction to handle failures
locally (e.g., use directive.value().and_then(|v| v.name().ok()).and_then(|n|
n.token_text_trimmed()).map(|name_text| (name_text, directive.clone())) or use
an if-let chain) so that if any step yields None you continue to the next
directive instead of returning; if name_text.eq_ignore_ascii_case("global") then
return Some(GlobalStylesKind::Astro { directive }) as before.

}
}
return None;
}

None
}

fn diagnostic(ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
match state {
GlobalStylesKind::Vue => {
Some(
RuleDiagnostic::new(
rule_category!(),
ctx.query().range(),
markup! {
"This "<Emphasis>"<style>"</Emphasis>" block is missing the "<Emphasis>"scoped"</Emphasis>" attribute."
},
)
.note(markup! {
"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."
}),
)
},
GlobalStylesKind::Astro { directive } => {
Some(
RuleDiagnostic::new(
rule_category!(),
directive.range(),
markup! {
"This "<Emphasis>"is:global"</Emphasis>" directive is making the styles in this block global."
},
)
.note(markup! {
"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."
}),
)
}
}
}

fn action(ctx: &RuleContext<Self>, state: &Self::State) -> Option<crate::HtmlRuleAction> {
match state {
GlobalStylesKind::Vue => {
let opening = ctx.query();
let old_attributes = opening.attributes();

let token =
HtmlSyntaxToken::new_detached(HtmlSyntaxKind::HTML_LITERAL, " scoped", [], []);

let attr = AnyHtmlAttribute::HtmlAttribute(
make::html_attribute(make::html_attribute_name(token)).build(),
);
let mut items: Vec<AnyHtmlAttribute> = old_attributes.iter().collect();
items.push(attr);
let new_attributes = make::html_attribute_list(items);

let mut mutation = BatchMutationExt::begin(ctx.root());
mutation.replace_node(old_attributes, new_attributes);

Some(biome_analyze::RuleAction::new(
ctx.metadata().action_category(ctx.category(), ctx.group()),
ctx.metadata().applicability(),
markup! { "Add the "<Emphasis>"scoped"</Emphasis>" attribute so the styles will only apply to this component." }.to_owned(),
mutation,
))
}
GlobalStylesKind::Astro { directive } => {
let mut mutation = BatchMutationExt::begin(ctx.root());
mutation.remove_node(directive.clone());

Some(biome_analyze::RuleAction::new(
ctx.metadata().action_category(ctx.category(), ctx.group()),
ctx.metadata().applicability(),
markup! { "Remove the "<Emphasis>"is:global"</Emphasis>" directive so the styles in this block will be scoped to this component." }.to_owned(),
mutation,
))
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<!-- should generate diagnostics -->

<style is:global>
.foo {
color: red;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
source: crates/biome_html_analyze/tests/spec_tests.rs
expression: invalid.astro
---
# Input
```html
<!-- should generate diagnostics -->

<style is:global>
.foo {
color: red;
}
</style>

```

# Diagnostics
```
invalid.astro:3:8 lint/nursery/useScopedStyles FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

i This is:global directive is making the styles in this block global.

1 │ <!-- should generate diagnostics -->
2 │
> 3 │ <style is:global>
│ ^^^^^^^^^
4 │ .foo {
5 │ color: red;

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.

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.

i Unsafe fix: Remove the is:global directive so the styles in this block will be scoped to this component.

3 │ <style·is:global>
│ ---------

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!-- should generate diagnostics -->

<style>
.foo { color: red; }
</style>

<style lang="scss">
.foo { color: blue; }
</style>
Loading