Skip to content

Commit a3a27a7

Browse files
feat(analyze/html/vue): add useVueVapor rule (#8644)
1 parent 9a8c98d commit a3a27a7

File tree

13 files changed

+359
-2
lines changed

13 files changed

+359
-2
lines changed

.changeset/v-vapor-rule.md

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 `useVueVapor` to enforce `<script setup vapor>` in Vue SFCs. For example `<script setup>` is invalid.

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

Lines changed: 22 additions & 1 deletion
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: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_html_analyze/src/lint/nursery.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,5 @@ pub mod use_vue_valid_v_on;
2222
pub mod use_vue_valid_v_once;
2323
pub mod use_vue_valid_v_pre;
2424
pub mod use_vue_valid_v_text;
25-
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_ambiguous_anchor_text :: NoAmbiguousAnchorText , self :: no_script_url :: NoScriptUrl , self :: no_sync_scripts :: NoSyncScripts , self :: no_vue_v_if_with_v_for :: NoVueVIfWithVFor , self :: use_vue_consistent_v_bind_style :: UseVueConsistentVBindStyle , self :: use_vue_consistent_v_on_style :: UseVueConsistentVOnStyle , self :: use_vue_hyphenated_attributes :: UseVueHyphenatedAttributes , self :: use_vue_v_for_key :: UseVueVForKey , self :: use_vue_valid_template_root :: UseVueValidTemplateRoot , self :: use_vue_valid_v_bind :: UseVueValidVBind , self :: use_vue_valid_v_cloak :: UseVueValidVCloak , self :: use_vue_valid_v_else :: UseVueValidVElse , self :: use_vue_valid_v_else_if :: UseVueValidVElseIf , self :: use_vue_valid_v_html :: UseVueValidVHtml , self :: use_vue_valid_v_if :: UseVueValidVIf , self :: use_vue_valid_v_on :: UseVueValidVOn , self :: use_vue_valid_v_once :: UseVueValidVOnce , self :: use_vue_valid_v_pre :: UseVueValidVPre , self :: use_vue_valid_v_text :: UseVueValidVText ,] } }
25+
pub mod use_vue_vapor;
26+
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_ambiguous_anchor_text :: NoAmbiguousAnchorText , self :: no_script_url :: NoScriptUrl , self :: no_sync_scripts :: NoSyncScripts , self :: no_vue_v_if_with_v_for :: NoVueVIfWithVFor , self :: use_vue_consistent_v_bind_style :: UseVueConsistentVBindStyle , self :: use_vue_consistent_v_on_style :: UseVueConsistentVOnStyle , self :: use_vue_hyphenated_attributes :: UseVueHyphenatedAttributes , self :: use_vue_v_for_key :: UseVueVForKey , self :: use_vue_valid_template_root :: UseVueValidTemplateRoot , self :: use_vue_valid_v_bind :: UseVueValidVBind , self :: use_vue_valid_v_cloak :: UseVueValidVCloak , self :: use_vue_valid_v_else :: UseVueValidVElse , self :: use_vue_valid_v_else_if :: UseVueValidVElseIf , self :: use_vue_valid_v_html :: UseVueValidVHtml , self :: use_vue_valid_v_if :: UseVueValidVIf , self :: use_vue_valid_v_on :: UseVueValidVOn , self :: use_vue_valid_v_once :: UseVueValidVOnce , self :: use_vue_valid_v_pre :: UseVueValidVPre , self :: use_vue_valid_v_text :: UseVueValidVText , self :: use_vue_vapor :: UseVueVapor ,] } }
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
use biome_analyze::{
2+
Ast, FixKind, Rule, RuleDiagnostic, RuleDomain, context::RuleContext, declare_lint_rule,
3+
};
4+
use biome_console::markup;
5+
use biome_html_factory::make;
6+
use biome_html_syntax::{
7+
AnyHtmlAttribute, HtmlAttributeList, HtmlOpeningElement, HtmlSyntaxKind, HtmlSyntaxToken,
8+
};
9+
use biome_rowan::{AstNode, AstNodeList, BatchMutationExt, TriviaPiece};
10+
use biome_rule_options::use_vue_vapor::UseVueVaporOptions;
11+
12+
declare_lint_rule! {
13+
/// Enforce opting in to Vue Vapor mode in `<script setup>` blocks.
14+
///
15+
/// Vue 3.6 introduces an opt-in “Vapor mode” for SFC `<script setup>` blocks:
16+
/// `<script setup vapor>`.
17+
///
18+
/// Vapor mode only works for Vue Single File Components (SFCs) using `<script setup>`.
19+
///
20+
/// This rule reports `<script setup>` opening tags that are missing the `vapor` attribute.
21+
///
22+
/// ## Examples
23+
///
24+
/// ### Invalid
25+
///
26+
/// ```vue,expect_diagnostic
27+
/// <script setup>
28+
/// </script>
29+
/// ```
30+
///
31+
/// ### Valid
32+
///
33+
/// ```vue
34+
/// <script setup vapor>
35+
/// </script>
36+
/// ```
37+
///
38+
pub UseVueVapor {
39+
version: "next",
40+
name: "useVueVapor",
41+
language: "html",
42+
recommended: false,
43+
domains: &[RuleDomain::Vue],
44+
sources: &[],
45+
fix_kind: FixKind::Unsafe,
46+
}
47+
}
48+
49+
impl Rule for UseVueVapor {
50+
type Query = Ast<HtmlOpeningElement>;
51+
type State = ();
52+
type Signals = Option<Self::State>;
53+
type Options = UseVueVaporOptions;
54+
55+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
56+
let opening = ctx.query();
57+
58+
let name = opening.name().ok()?;
59+
let name_token = name.value_token().ok()?;
60+
if !name_token.text_trimmed().eq_ignore_ascii_case("script") {
61+
return None;
62+
}
63+
64+
let attributes = opening.attributes();
65+
attributes.find_by_name("setup")?;
66+
67+
if attributes.find_by_name("vapor").is_some() {
68+
return None;
69+
}
70+
71+
Some(())
72+
}
73+
74+
fn diagnostic(ctx: &RuleContext<Self>, _state: &Self::State) -> Option<RuleDiagnostic> {
75+
Some(
76+
RuleDiagnostic::new(
77+
rule_category!(),
78+
ctx.query().range(),
79+
markup! {
80+
"This "<Emphasis>"<script setup>"</Emphasis>" is missing the "<Emphasis>"vapor"</Emphasis>" attribute."
81+
},
82+
)
83+
.note(markup! {
84+
"Add "<Emphasis>"vapor"</Emphasis>" to opt in to Vue Vapor mode: "<Emphasis>"<script setup vapor>"</Emphasis>"."
85+
}),
86+
)
87+
}
88+
89+
fn action(ctx: &RuleContext<Self>, _state: &Self::State) -> Option<crate::HtmlRuleAction> {
90+
let opening = ctx.query();
91+
let old_attributes = opening.attributes();
92+
93+
// Only apply the fix for <script setup> that doesn't already have vapor.
94+
if old_attributes.find_by_name("setup").is_none()
95+
|| old_attributes.find_by_name("vapor").is_some()
96+
{
97+
return None;
98+
}
99+
100+
let new_attributes = insert_after_setup(old_attributes)?;
101+
102+
let mut mutation = BatchMutationExt::begin(ctx.root());
103+
mutation.replace_node(opening.attributes(), new_attributes);
104+
105+
Some(biome_analyze::RuleAction::new(
106+
ctx.metadata().action_category(ctx.category(), ctx.group()),
107+
ctx.metadata().applicability(),
108+
markup! { "Add the "<Emphasis>"vapor"</Emphasis>" attribute." }.to_owned(),
109+
mutation,
110+
))
111+
}
112+
}
113+
114+
#[derive(Clone, Copy, Debug)]
115+
enum VaporAttributeSpacing {
116+
/// Add a leading space before `vapor` (used when `setup` is the last attribute).
117+
Leading,
118+
/// Add a trailing space after `vapor` (used when there are more attributes after `setup`).
119+
Trailing,
120+
}
121+
122+
fn make_vapor_attribute(spacing: VaporAttributeSpacing) -> AnyHtmlAttribute {
123+
let vapor_token = match spacing {
124+
VaporAttributeSpacing::Leading => HtmlSyntaxToken::new_detached(
125+
HtmlSyntaxKind::IDENT,
126+
" vapor",
127+
[TriviaPiece::whitespace(1)],
128+
[],
129+
),
130+
VaporAttributeSpacing::Trailing => HtmlSyntaxToken::new_detached(
131+
HtmlSyntaxKind::IDENT,
132+
"vapor ",
133+
[],
134+
[TriviaPiece::whitespace(1)],
135+
),
136+
};
137+
138+
AnyHtmlAttribute::HtmlAttribute(
139+
make::html_attribute(make::html_attribute_name(vapor_token)).build(),
140+
)
141+
}
142+
143+
fn insert_after_setup(old_attributes: HtmlAttributeList) -> Option<HtmlAttributeList> {
144+
let mut items: Vec<AnyHtmlAttribute> = old_attributes.iter().collect();
145+
146+
let setup_index = items.iter().position(is_setup_attribute)?;
147+
148+
let spacing = if setup_index + 1 == items.len() {
149+
VaporAttributeSpacing::Leading
150+
} else {
151+
VaporAttributeSpacing::Trailing
152+
};
153+
154+
items.insert(setup_index + 1, make_vapor_attribute(spacing));
155+
156+
Some(make::html_attribute_list(items))
157+
}
158+
159+
fn is_setup_attribute(attribute: &AnyHtmlAttribute) -> bool {
160+
match attribute {
161+
AnyHtmlAttribute::HtmlAttribute(attr) => attr
162+
.name()
163+
.ok()
164+
.and_then(|name| name.value_token().ok())
165+
.is_some_and(|tok| tok.text_trimmed() == "setup"),
166+
_ => false,
167+
}
168+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<!-- should generate diagnostics -->
2+
3+
<script setup>
4+
const a = 1;
5+
</script>
6+
7+
<script setup lang="ts">
8+
const b: number = 1;
9+
</script>
10+
11+
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
source: crates/biome_html_analyze/tests/spec_tests.rs
3+
expression: invalid.vue
4+
---
5+
# Input
6+
```html
7+
<!-- should generate diagnostics -->
8+
9+
<script setup>
10+
const a = 1;
11+
</script>
12+
13+
<script setup lang="ts">
14+
const b: number = 1;
15+
</script>
16+
17+
18+
19+
```
20+
21+
# Diagnostics
22+
```
23+
invalid.vue:3:1 lint/nursery/useVueVapor FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
24+
25+
i This <script setup> is missing the vapor attribute.
26+
27+
1 │ <!-- should generate diagnostics -->
28+
2 │
29+
> 3 │ <script setup>
30+
│ ^^^^^^^^^^^^^^
31+
4 │ const a = 1;
32+
5 │ </script>
33+
34+
i Add vapor to opt in to Vue Vapor mode: <script setup vapor>.
35+
36+
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.
37+
38+
i Unsafe fix: Add the vapor attribute.
39+
40+
3 │ <script·setup·vapor>
41+
│ ++++++
42+
43+
```
44+
45+
```
46+
invalid.vue:7:1 lint/nursery/useVueVapor FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
47+
48+
i This <script setup> is missing the vapor attribute.
49+
50+
5 │ </script>
51+
6 │
52+
> 7 │ <script setup lang="ts">
53+
│ ^^^^^^^^^^^^^^^^^^^^^^^^
54+
8 │ const b: number = 1;
55+
9 │ </script>
56+
57+
i Add vapor to opt in to Vue Vapor mode: <script setup vapor>.
58+
59+
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.
60+
61+
i Unsafe fix: Add the vapor attribute.
62+
63+
7 │ <script·setup·vapor·lang="ts">
64+
│ ++++++
65+
66+
```
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<!-- should not generate diagnostics -->
2+
3+
<script setup vapor>
4+
const a = 1;
5+
</script>
6+
7+
<script setup vapor lang="ts">
8+
const b: number = 1;
9+
</script>
10+
11+
<script>
12+
export default {};
13+
</script>
14+
15+
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
source: crates/biome_html_analyze/tests/spec_tests.rs
3+
expression: valid.vue
4+
---
5+
# Input
6+
```html
7+
<!-- should not generate diagnostics -->
8+
9+
<script setup vapor>
10+
const a = 1;
11+
</script>
12+
13+
<script setup vapor lang="ts">
14+
const b: number = 1;
15+
</script>
16+
17+
<script>
18+
export default {};
19+
</script>
20+
21+
22+
23+
```

crates/biome_rule_options/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,5 +416,6 @@ pub mod use_vue_valid_v_on;
416416
pub mod use_vue_valid_v_once;
417417
pub mod use_vue_valid_v_pre;
418418
pub mod use_vue_valid_v_text;
419+
pub mod use_vue_vapor;
419420
pub mod use_while;
420421
pub mod use_yield;

0 commit comments

Comments
 (0)