diff --git a/.changeset/fair-ligers-smell.md b/.changeset/fair-ligers-smell.md
new file mode 100644
index 000000000000..f758b69867bb
--- /dev/null
+++ b/.changeset/fair-ligers-smell.md
@@ -0,0 +1,5 @@
+---
+"@biomejs/biome": patch
+---
+
+Fixed [#8710](https://github.com/biomejs/biome/issues/8710): Biome now parses Vue dynamic slot shorthand arguments that use template literals in `[]`.
diff --git a/crates/biome_html_parser/src/lexer/mod.rs b/crates/biome_html_parser/src/lexer/mod.rs
index 1543690b2146..8c6dca5fd6e3 100644
--- a/crates/biome_html_parser/src/lexer/mod.rs
+++ b/crates/biome_html_parser/src/lexer/mod.rs
@@ -155,6 +155,42 @@ impl<'src> HtmlLexer<'src> {
}
}
+ /// Consume a token in the [HtmlLexContext::VueDirectiveArgument] context.
+ fn consume_token_vue_directive_argument(&mut self) -> HtmlSyntaxKind {
+ let start = self.text_position();
+ let mut brackets_stack = 0;
+ let mut quotes_seen = QuotesSeen::new();
+
+ while let Some(byte) = self.current_byte() {
+ quotes_seen.check_byte(byte);
+ let char = biome_unicode_table::lookup_byte(byte);
+ use biome_unicode_table::Dispatch::*;
+
+ if quotes_seen.is_empty() {
+ match char {
+ BTO => {
+ brackets_stack += 1;
+ }
+ BTC => {
+ if brackets_stack == 0 {
+ break;
+ }
+ brackets_stack -= 1;
+ }
+ _ => {}
+ }
+ }
+
+ self.advance_byte_or_char(byte);
+ }
+
+ if self.text_position() != start {
+ HTML_LITERAL
+ } else {
+ ERROR_TOKEN
+ }
+ }
+
/// Consume a token in the [HtmlLexContext::Regular] context.
fn consume_token(&mut self, current: u8) -> HtmlSyntaxKind {
match current {
@@ -1078,6 +1114,9 @@ impl<'src> Lexer<'src> for HtmlLexer<'src> {
HtmlLexContext::Regular => self.consume_token(current),
HtmlLexContext::InsideTag => self.consume_token_inside_tag(current),
HtmlLexContext::InsideTagVue => self.consume_token_inside_tag_vue(current),
+ HtmlLexContext::VueDirectiveArgument => {
+ self.consume_token_vue_directive_argument()
+ }
HtmlLexContext::AttributeValue => self.consume_token_attribute_value(current),
HtmlLexContext::Doctype => self.consume_token_doctype(current),
HtmlLexContext::EmbeddedLanguage(lang) => {
diff --git a/crates/biome_html_parser/src/syntax/vue.rs b/crates/biome_html_parser/src/syntax/vue.rs
index 5aa44a5af5d3..19447e049b29 100644
--- a/crates/biome_html_parser/src/syntax/vue.rs
+++ b/crates/biome_html_parser/src/syntax/vue.rs
@@ -161,7 +161,7 @@ fn parse_vue_dynamic_argument(p: &mut HtmlParser) -> ParsedSyntax {
let m = p.start();
- p.bump_with_context(T!['['], HtmlLexContext::InsideTagVue);
+ p.bump_with_context(T!['['], HtmlLexContext::VueDirectiveArgument);
p.expect_with_context(HTML_LITERAL, HtmlLexContext::InsideTagVue);
p.expect_with_context(T![']'], HtmlLexContext::InsideTagVue);
diff --git a/crates/biome_html_parser/src/token_source.rs b/crates/biome_html_parser/src/token_source.rs
index 049ac3391fb3..e3badb2ea307 100644
--- a/crates/biome_html_parser/src/token_source.rs
+++ b/crates/biome_html_parser/src/token_source.rs
@@ -29,6 +29,8 @@ pub(crate) enum HtmlLexContext {
InsideTag,
/// Like [InsideTag], but with Vue-specific tokens enabled.
InsideTagVue,
+ /// Lexes Vue directive arguments inside `[]`.
+ VueDirectiveArgument,
/// When the parser encounters a `=` token (the beginning of the attribute initializer clause), it switches to this context.
///
/// This is because attribute values can start and end with a `"` or `'` character, or be unquoted, and the lexer needs to know to start lexing a string literal.
diff --git a/crates/biome_html_parser/tests/html_specs/ok/vue/dynamic-slot-arg.vue b/crates/biome_html_parser/tests/html_specs/ok/vue/dynamic-slot-arg.vue
new file mode 100644
index 000000000000..b28d15bfbe74
--- /dev/null
+++ b/crates/biome_html_parser/tests/html_specs/ok/vue/dynamic-slot-arg.vue
@@ -0,0 +1,23 @@
+
+ ...
+
+
+
+ ...
+
+
+
+ ...
+
diff --git a/crates/biome_html_parser/tests/html_specs/ok/vue/dynamic-slot-arg.vue.snap b/crates/biome_html_parser/tests/html_specs/ok/vue/dynamic-slot-arg.vue.snap
new file mode 100644
index 000000000000..df94f15ba314
--- /dev/null
+++ b/crates/biome_html_parser/tests/html_specs/ok/vue/dynamic-slot-arg.vue.snap
@@ -0,0 +1,393 @@
+---
+source: crates/biome_html_parser/tests/spec_test.rs
+expression: snapshot
+---
+## Input
+
+```vue
+
+ ...
+
+
+
+ ...
+
+
+
+ ...
+
+
+```
+
+
+## AST
+
+```
+HtmlRoot {
+ bom_token: missing (optional),
+ frontmatter: missing (optional),
+ directive: missing (optional),
+ html: HtmlElementList [
+ HtmlElement {
+ opening_element: HtmlOpeningElement {
+ l_angle_token: L_ANGLE@0..1 "<" [] [],
+ name: HtmlTagName {
+ value_token: HTML_LITERAL@1..9 "template" [] [],
+ },
+ attributes: HtmlAttributeList [
+ VueDirective {
+ name_token: IDENT@9..17 "v-for" [Newline("\n"), Whitespace(" ")] [],
+ arg: missing (optional),
+ modifiers: VueModifierList [],
+ initializer: HtmlAttributeInitializerClause {
+ eq_token: EQ@17..18 "=" [] [],
+ value: HtmlString {
+ value_token: HTML_STRING_LITERAL@18..39 "\"episode in episodes\"" [] [],
+ },
+ },
+ },
+ VueVSlotShorthandDirective {
+ hash_token: HASH@39..43 "#" [Newline("\n"), Whitespace(" ")] [],
+ arg: VueDynamicArgument {
+ l_brack_token: L_BRACKET@43..44 "[" [] [],
+ name_token: HTML_LITERAL@44..64 "`${episode.id}-cell`" [] [],
+ r_brack_token: R_BRACKET@64..65 "]" [] [],
+ },
+ modifiers: VueModifierList [],
+ initializer: HtmlAttributeInitializerClause {
+ eq_token: EQ@65..66 "=" [] [],
+ value: HtmlString {
+ value_token: HTML_STRING_LITERAL@66..75 "\"{ row }\"" [] [],
+ },
+ },
+ },
+ VueVBindShorthandDirective {
+ arg: VueDirectiveArgument {
+ colon_token: COLON@75..79 ":" [Newline("\n"), Whitespace(" ")] [],
+ arg: VueStaticArgument {
+ name_token: HTML_LITERAL@79..82 "key" [] [],
+ },
+ },
+ modifiers: VueModifierList [],
+ initializer: HtmlAttributeInitializerClause {
+ eq_token: EQ@82..83 "=" [] [],
+ value: HtmlString {
+ value_token: HTML_STRING_LITERAL@83..95 "\"episode.id\"" [] [],
+ },
+ },
+ },
+ ],
+ r_angle_token: R_ANGLE@95..97 ">" [Newline("\n")] [],
+ },
+ children: HtmlElementList [
+ HtmlContent {
+ value_token: HTML_LITERAL@97..103 "..." [Newline("\n"), Whitespace(" ")] [],
+ },
+ ],
+ closing_element: HtmlClosingElement {
+ l_angle_token: L_ANGLE@103..105 "<" [Newline("\n")] [],
+ slash_token: SLASH@105..106 "/" [] [],
+ name: HtmlTagName {
+ value_token: HTML_LITERAL@106..114 "template" [] [],
+ },
+ r_angle_token: R_ANGLE@114..115 ">" [] [],
+ },
+ },
+ HtmlElement {
+ opening_element: HtmlOpeningElement {
+ l_angle_token: L_ANGLE@115..118 "<" [Newline("\n"), Newline("\n")] [],
+ name: HtmlTagName {
+ value_token: HTML_LITERAL@118..126 "template" [] [],
+ },
+ attributes: HtmlAttributeList [
+ VueDirective {
+ name_token: IDENT@126..134 "v-for" [Newline("\n"), Whitespace(" ")] [],
+ arg: missing (optional),
+ modifiers: VueModifierList [],
+ initializer: HtmlAttributeInitializerClause {
+ eq_token: EQ@134..135 "=" [] [],
+ value: HtmlString {
+ value_token: HTML_STRING_LITERAL@135..156 "\"episode in episodes\"" [] [],
+ },
+ },
+ },
+ VueDirective {
+ name_token: IDENT@156..165 "v-slot" [Newline("\n"), Whitespace(" ")] [],
+ arg: VueDirectiveArgument {
+ colon_token: COLON@165..166 ":" [] [],
+ arg: VueDynamicArgument {
+ l_brack_token: L_BRACKET@166..167 "[" [] [],
+ name_token: HTML_LITERAL@167..187 "`${episode.id}-cell`" [] [],
+ r_brack_token: R_BRACKET@187..188 "]" [] [],
+ },
+ },
+ modifiers: VueModifierList [],
+ initializer: HtmlAttributeInitializerClause {
+ eq_token: EQ@188..189 "=" [] [],
+ value: HtmlString {
+ value_token: HTML_STRING_LITERAL@189..198 "\"{ row }\"" [] [],
+ },
+ },
+ },
+ VueVBindShorthandDirective {
+ arg: VueDirectiveArgument {
+ colon_token: COLON@198..202 ":" [Newline("\n"), Whitespace(" ")] [],
+ arg: VueStaticArgument {
+ name_token: HTML_LITERAL@202..205 "key" [] [],
+ },
+ },
+ modifiers: VueModifierList [],
+ initializer: HtmlAttributeInitializerClause {
+ eq_token: EQ@205..206 "=" [] [],
+ value: HtmlString {
+ value_token: HTML_STRING_LITERAL@206..218 "\"episode.id\"" [] [],
+ },
+ },
+ },
+ ],
+ r_angle_token: R_ANGLE@218..220 ">" [Newline("\n")] [],
+ },
+ children: HtmlElementList [
+ HtmlContent {
+ value_token: HTML_LITERAL@220..226 "..." [Newline("\n"), Whitespace(" ")] [],
+ },
+ ],
+ closing_element: HtmlClosingElement {
+ l_angle_token: L_ANGLE@226..228 "<" [Newline("\n")] [],
+ slash_token: SLASH@228..229 "/" [] [],
+ name: HtmlTagName {
+ value_token: HTML_LITERAL@229..237 "template" [] [],
+ },
+ r_angle_token: R_ANGLE@237..238 ">" [] [],
+ },
+ },
+ HtmlElement {
+ opening_element: HtmlOpeningElement {
+ l_angle_token: L_ANGLE@238..241 "<" [Newline("\n"), Newline("\n")] [],
+ name: HtmlTagName {
+ value_token: HTML_LITERAL@241..249 "template" [] [],
+ },
+ attributes: HtmlAttributeList [
+ VueDirective {
+ name_token: IDENT@249..257 "v-for" [Newline("\n"), Whitespace(" ")] [],
+ arg: missing (optional),
+ modifiers: VueModifierList [],
+ initializer: HtmlAttributeInitializerClause {
+ eq_token: EQ@257..258 "=" [] [],
+ value: HtmlString {
+ value_token: HTML_STRING_LITERAL@258..279 "\"episode in episodes\"" [] [],
+ },
+ },
+ },
+ VueVSlotShorthandDirective {
+ hash_token: HASH@279..283 "#" [Newline("\n"), Whitespace(" ")] [],
+ arg: VueDynamicArgument {
+ l_brack_token: L_BRACKET@283..284 "[" [] [],
+ name_token: HTML_LITERAL@284..294 "episode.id" [] [],
+ r_brack_token: R_BRACKET@294..295 "]" [] [],
+ },
+ modifiers: VueModifierList [],
+ initializer: HtmlAttributeInitializerClause {
+ eq_token: EQ@295..296 "=" [] [],
+ value: HtmlString {
+ value_token: HTML_STRING_LITERAL@296..305 "\"{ row }\"" [] [],
+ },
+ },
+ },
+ VueVBindShorthandDirective {
+ arg: VueDirectiveArgument {
+ colon_token: COLON@305..309 ":" [Newline("\n"), Whitespace(" ")] [],
+ arg: VueStaticArgument {
+ name_token: HTML_LITERAL@309..312 "key" [] [],
+ },
+ },
+ modifiers: VueModifierList [],
+ initializer: HtmlAttributeInitializerClause {
+ eq_token: EQ@312..313 "=" [] [],
+ value: HtmlString {
+ value_token: HTML_STRING_LITERAL@313..325 "\"episode.id\"" [] [],
+ },
+ },
+ },
+ ],
+ r_angle_token: R_ANGLE@325..327 ">" [Newline("\n")] [],
+ },
+ children: HtmlElementList [
+ HtmlContent {
+ value_token: HTML_LITERAL@327..333 "..." [Newline("\n"), Whitespace(" ")] [],
+ },
+ ],
+ closing_element: HtmlClosingElement {
+ l_angle_token: L_ANGLE@333..335 "<" [Newline("\n")] [],
+ slash_token: SLASH@335..336 "/" [] [],
+ name: HtmlTagName {
+ value_token: HTML_LITERAL@336..344 "template" [] [],
+ },
+ r_angle_token: R_ANGLE@344..345 ">" [] [],
+ },
+ },
+ ],
+ eof_token: EOF@345..346 "" [Newline("\n")] [],
+}
+```
+
+## CST
+
+```
+0: HTML_ROOT@0..346
+ 0: (empty)
+ 1: (empty)
+ 2: (empty)
+ 3: HTML_ELEMENT_LIST@0..345
+ 0: HTML_ELEMENT@0..115
+ 0: HTML_OPENING_ELEMENT@0..97
+ 0: L_ANGLE@0..1 "<" [] []
+ 1: HTML_TAG_NAME@1..9
+ 0: HTML_LITERAL@1..9 "template" [] []
+ 2: HTML_ATTRIBUTE_LIST@9..95
+ 0: VUE_DIRECTIVE@9..39
+ 0: IDENT@9..17 "v-for" [Newline("\n"), Whitespace(" ")] []
+ 1: (empty)
+ 2: VUE_MODIFIER_LIST@17..17
+ 3: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@17..39
+ 0: EQ@17..18 "=" [] []
+ 1: HTML_STRING@18..39
+ 0: HTML_STRING_LITERAL@18..39 "\"episode in episodes\"" [] []
+ 1: VUE_V_SLOT_SHORTHAND_DIRECTIVE@39..75
+ 0: HASH@39..43 "#" [Newline("\n"), Whitespace(" ")] []
+ 1: VUE_DYNAMIC_ARGUMENT@43..65
+ 0: L_BRACKET@43..44 "[" [] []
+ 1: HTML_LITERAL@44..64 "`${episode.id}-cell`" [] []
+ 2: R_BRACKET@64..65 "]" [] []
+ 2: VUE_MODIFIER_LIST@65..65
+ 3: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@65..75
+ 0: EQ@65..66 "=" [] []
+ 1: HTML_STRING@66..75
+ 0: HTML_STRING_LITERAL@66..75 "\"{ row }\"" [] []
+ 2: VUE_V_BIND_SHORTHAND_DIRECTIVE@75..95
+ 0: VUE_DIRECTIVE_ARGUMENT@75..82
+ 0: COLON@75..79 ":" [Newline("\n"), Whitespace(" ")] []
+ 1: VUE_STATIC_ARGUMENT@79..82
+ 0: HTML_LITERAL@79..82 "key" [] []
+ 1: VUE_MODIFIER_LIST@82..82
+ 2: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@82..95
+ 0: EQ@82..83 "=" [] []
+ 1: HTML_STRING@83..95
+ 0: HTML_STRING_LITERAL@83..95 "\"episode.id\"" [] []
+ 3: R_ANGLE@95..97 ">" [Newline("\n")] []
+ 1: HTML_ELEMENT_LIST@97..103
+ 0: HTML_CONTENT@97..103
+ 0: HTML_LITERAL@97..103 "..." [Newline("\n"), Whitespace(" ")] []
+ 2: HTML_CLOSING_ELEMENT@103..115
+ 0: L_ANGLE@103..105 "<" [Newline("\n")] []
+ 1: SLASH@105..106 "/" [] []
+ 2: HTML_TAG_NAME@106..114
+ 0: HTML_LITERAL@106..114 "template" [] []
+ 3: R_ANGLE@114..115 ">" [] []
+ 1: HTML_ELEMENT@115..238
+ 0: HTML_OPENING_ELEMENT@115..220
+ 0: L_ANGLE@115..118 "<" [Newline("\n"), Newline("\n")] []
+ 1: HTML_TAG_NAME@118..126
+ 0: HTML_LITERAL@118..126 "template" [] []
+ 2: HTML_ATTRIBUTE_LIST@126..218
+ 0: VUE_DIRECTIVE@126..156
+ 0: IDENT@126..134 "v-for" [Newline("\n"), Whitespace(" ")] []
+ 1: (empty)
+ 2: VUE_MODIFIER_LIST@134..134
+ 3: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@134..156
+ 0: EQ@134..135 "=" [] []
+ 1: HTML_STRING@135..156
+ 0: HTML_STRING_LITERAL@135..156 "\"episode in episodes\"" [] []
+ 1: VUE_DIRECTIVE@156..198
+ 0: IDENT@156..165 "v-slot" [Newline("\n"), Whitespace(" ")] []
+ 1: VUE_DIRECTIVE_ARGUMENT@165..188
+ 0: COLON@165..166 ":" [] []
+ 1: VUE_DYNAMIC_ARGUMENT@166..188
+ 0: L_BRACKET@166..167 "[" [] []
+ 1: HTML_LITERAL@167..187 "`${episode.id}-cell`" [] []
+ 2: R_BRACKET@187..188 "]" [] []
+ 2: VUE_MODIFIER_LIST@188..188
+ 3: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@188..198
+ 0: EQ@188..189 "=" [] []
+ 1: HTML_STRING@189..198
+ 0: HTML_STRING_LITERAL@189..198 "\"{ row }\"" [] []
+ 2: VUE_V_BIND_SHORTHAND_DIRECTIVE@198..218
+ 0: VUE_DIRECTIVE_ARGUMENT@198..205
+ 0: COLON@198..202 ":" [Newline("\n"), Whitespace(" ")] []
+ 1: VUE_STATIC_ARGUMENT@202..205
+ 0: HTML_LITERAL@202..205 "key" [] []
+ 1: VUE_MODIFIER_LIST@205..205
+ 2: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@205..218
+ 0: EQ@205..206 "=" [] []
+ 1: HTML_STRING@206..218
+ 0: HTML_STRING_LITERAL@206..218 "\"episode.id\"" [] []
+ 3: R_ANGLE@218..220 ">" [Newline("\n")] []
+ 1: HTML_ELEMENT_LIST@220..226
+ 0: HTML_CONTENT@220..226
+ 0: HTML_LITERAL@220..226 "..." [Newline("\n"), Whitespace(" ")] []
+ 2: HTML_CLOSING_ELEMENT@226..238
+ 0: L_ANGLE@226..228 "<" [Newline("\n")] []
+ 1: SLASH@228..229 "/" [] []
+ 2: HTML_TAG_NAME@229..237
+ 0: HTML_LITERAL@229..237 "template" [] []
+ 3: R_ANGLE@237..238 ">" [] []
+ 2: HTML_ELEMENT@238..345
+ 0: HTML_OPENING_ELEMENT@238..327
+ 0: L_ANGLE@238..241 "<" [Newline("\n"), Newline("\n")] []
+ 1: HTML_TAG_NAME@241..249
+ 0: HTML_LITERAL@241..249 "template" [] []
+ 2: HTML_ATTRIBUTE_LIST@249..325
+ 0: VUE_DIRECTIVE@249..279
+ 0: IDENT@249..257 "v-for" [Newline("\n"), Whitespace(" ")] []
+ 1: (empty)
+ 2: VUE_MODIFIER_LIST@257..257
+ 3: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@257..279
+ 0: EQ@257..258 "=" [] []
+ 1: HTML_STRING@258..279
+ 0: HTML_STRING_LITERAL@258..279 "\"episode in episodes\"" [] []
+ 1: VUE_V_SLOT_SHORTHAND_DIRECTIVE@279..305
+ 0: HASH@279..283 "#" [Newline("\n"), Whitespace(" ")] []
+ 1: VUE_DYNAMIC_ARGUMENT@283..295
+ 0: L_BRACKET@283..284 "[" [] []
+ 1: HTML_LITERAL@284..294 "episode.id" [] []
+ 2: R_BRACKET@294..295 "]" [] []
+ 2: VUE_MODIFIER_LIST@295..295
+ 3: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@295..305
+ 0: EQ@295..296 "=" [] []
+ 1: HTML_STRING@296..305
+ 0: HTML_STRING_LITERAL@296..305 "\"{ row }\"" [] []
+ 2: VUE_V_BIND_SHORTHAND_DIRECTIVE@305..325
+ 0: VUE_DIRECTIVE_ARGUMENT@305..312
+ 0: COLON@305..309 ":" [Newline("\n"), Whitespace(" ")] []
+ 1: VUE_STATIC_ARGUMENT@309..312
+ 0: HTML_LITERAL@309..312 "key" [] []
+ 1: VUE_MODIFIER_LIST@312..312
+ 2: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@312..325
+ 0: EQ@312..313 "=" [] []
+ 1: HTML_STRING@313..325
+ 0: HTML_STRING_LITERAL@313..325 "\"episode.id\"" [] []
+ 3: R_ANGLE@325..327 ">" [Newline("\n")] []
+ 1: HTML_ELEMENT_LIST@327..333
+ 0: HTML_CONTENT@327..333
+ 0: HTML_LITERAL@327..333 "..." [Newline("\n"), Whitespace(" ")] []
+ 2: HTML_CLOSING_ELEMENT@333..345
+ 0: L_ANGLE@333..335 "<" [Newline("\n")] []
+ 1: SLASH@335..336 "/" [] []
+ 2: HTML_TAG_NAME@336..344
+ 0: HTML_LITERAL@336..344 "template" [] []
+ 3: R_ANGLE@344..345 ">" [] []
+ 4: EOF@345..346 "" [Newline("\n")] []
+
+```