diff --git a/.changeset/fix-astro-frontmatter-fence-detection.md b/.changeset/fix-astro-frontmatter-fence-detection.md new file mode 100644 index 000000000000..e6e32c4eecb8 --- /dev/null +++ b/.changeset/fix-astro-frontmatter-fence-detection.md @@ -0,0 +1,5 @@ +--- +"@biomejs/biome": patch +--- + +Fixed [#8882](https://github.com/biomejs/biome/issues/8882) and [#9108](https://github.com/biomejs/biome/issues/9108): The Astro frontmatter lexer now correctly identifies the closing `---` fence when the frontmatter contains multi-line block comments with quote characters, strings that mix quote types (e.g. `"it's"`), or escaped quote characters (e.g. `"\"`). diff --git a/crates/biome_html_parser/src/lexer/mod.rs b/crates/biome_html_parser/src/lexer/mod.rs index 60c409f80498..fa2706f5a82e 100644 --- a/crates/biome_html_parser/src/lexer/mod.rs +++ b/crates/biome_html_parser/src/lexer/mod.rs @@ -15,7 +15,7 @@ use biome_parser::diagnostic::ParseDiagnostic; use biome_parser::lexer::{Lexer, LexerCheckpoint, LexerWithCheckpoint, ReLexer, TokenFlags}; use biome_rowan::SyntaxKind; use biome_unicode_table::{Dispatch::*, lookup_byte}; -use std::ops::{Add, AddAssign}; +use std::ops::Add; pub(crate) struct HtmlLexer<'src> { /// Source text @@ -1366,97 +1366,147 @@ impl<'src> LexerWithCheckpoint<'src> for HtmlLexer<'src> { } } +/// Tracks whether the lexer is currently inside an open string literal while +/// scanning Astro frontmatter. Used to determine whether a `---` sequence is +/// a genuine closing fence or merely three dashes that appear inside a string. +/// +/// ## Design +/// +/// The tracker maintains: +/// - The **currently open quote character** (`current_quote`): `None` when not +/// inside any string, or `Some(b'"')` / `Some(b'\'')` / `Some(b'`')` when +/// inside a string. A quote opens a string only when no other string is +/// already open; it closes the string only when it **matches** the opening +/// quote. For example, a `'` inside a `"…"` string is treated as a literal +/// character, not as a new string opener. +/// - The **comment state** (`comment`): distinguishes single-line (`//`) from +/// multi-line (`/* … */`) comments, so that quote characters inside comments +/// are not counted as string delimiters. +/// - The **escape flag** (`escaped`): set when the previous byte was an +/// unescaped `\`, so that the immediately following quote character is not +/// treated as a string delimiter. A double backslash resets the flag because +/// the backslash itself is escaped. struct QuotesSeen { - single: u16, - double: u16, - template: u16, - inside_comment: bool, + /// The quote character that opened the current string, if any. + current_quote: Option, + /// Current comment state. + comment: QuotesSeenComment, + /// Whether the previous byte was an unescaped backslash. + escaped: bool, + /// The previous byte, needed to detect `//` and `/* */` comment markers + /// and the `*/` block-comment terminator. prev_byte: Option, } +/// Distinguishes the kind of comment the lexer is currently inside. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum QuotesSeenComment { + /// Not inside any comment. + None, + /// Inside a `//` single-line comment; exits on `\n`. + SingleLine, + /// Inside a `/* … */` multi-line comment; exits on `*/`. + MultiLine, +} + impl QuotesSeen { fn new() -> Self { Self { - single: 0, - double: 0, - template: 0, - inside_comment: false, + current_quote: None, + comment: QuotesSeenComment::None, + escaped: false, prev_byte: None, } } - /// It checks the given byte. If it's a quote, it's tracked + /// Processes one byte of frontmatter source and updates the tracking state. fn check_byte(&mut self, byte: u8) { - // Check for comment exit first - if self.inside_comment { - if byte == b'\n' { - // Exit single-line comment - self.inside_comment = false; - } else if self.prev_byte == Some(b'*') && byte == b'/' { - // Exit multi-line comment - self.inside_comment = false; + match self.comment { + QuotesSeenComment::SingleLine => { + // Single-line comment ends at the newline. + if byte == b'\n' { + self.comment = QuotesSeenComment::None; + } + self.prev_byte = Some(byte); + // Quotes inside comments are ignored. + return; } + QuotesSeenComment::MultiLine => { + // Multi-line comment ends at `*/`. + if self.prev_byte == Some(b'*') && byte == b'/' { + self.comment = QuotesSeenComment::None; + } + self.prev_byte = Some(byte); + // Quotes inside comments are ignored. + return; + } + QuotesSeenComment::None => {} + } + + // Handle escape sequences: a `\` that is not itself escaped toggles the + // escape flag for the next character. + if byte == b'\\' { + self.escaped = !self.escaped; self.prev_byte = Some(byte); - return; // Don't track quotes inside comments + return; } - // Check for comment entry - but only if we're not inside quotes - if self.prev_byte == Some(b'/') - && (byte == b'/' || byte == b'*') - && self.single == 0 - && self.double == 0 - && self.template == 0 - { - self.inside_comment = true; + // If the current byte is escaped, it cannot act as a string delimiter + // or comment opener. + let was_escaped = self.escaped; + self.escaped = false; + + if was_escaped { self.prev_byte = Some(byte); return; } - // Normal quote tracking + // Detect comment openers — only valid outside of open strings. + if self.current_quote.is_none() && self.prev_byte == Some(b'/') { + match byte { + b'/' => { + self.comment = QuotesSeenComment::SingleLine; + self.prev_byte = Some(byte); + return; + } + b'*' => { + self.comment = QuotesSeenComment::MultiLine; + self.prev_byte = Some(byte); + return; + } + _ => {} + } + } + + // Track string delimiters. match byte { - b'"' => self.track_double(), - b'\'' => self.track_single(), - b'`' => self.track_template(), + b'"' | b'\'' | b'`' => { + match self.current_quote { + None => { + // No string is open; this quote opens one. + self.current_quote = Some(byte); + } + Some(open) if open == byte => { + // The same quote that opened this string closes it. + self.current_quote = None; + } + Some(_) => { + // A different quote character inside an open string is + // just a literal character — ignore it. + } + } + } _ => {} } self.prev_byte = Some(byte); } - /// It adds a single quote if single quotes are zero and the others are greater than zero. It removes it otherwise - fn track_single(&mut self) { - if (self.single == 0 && (self.double > 0 || self.template > 0)) - || (self.single == 0 && self.double == 0 && self.template == 0) - { - self.single.add_assign(1); - } else { - self.single = self.single.saturating_sub(1); - } - } - /// It adds a double quote if double quotes are zero and the others are greater than zero. It removes it otherwise - fn track_double(&mut self) { - if (self.double == 0 && (self.single > 0 || self.template > 0)) - || (self.double == 0 && self.single == 0 && self.template == 0) - { - self.double.add_assign(1); - } else { - self.double = self.double.saturating_sub(1); - } - } - - /// It adds a template quote if template quotes are zero and the others are greater than zero. It removes it otherwise - fn track_template(&mut self) { - if (self.template == 0 && (self.single > 0 || self.double > 0)) - || (self.template == 0 && self.single == 0 && self.double == 0) - { - self.template.add_assign(1); - } else { - self.template = self.template.saturating_sub(1); - } - } - + /// Returns `true` when the tracker is not currently inside an open string literal + /// or a comment. Both states must be absent for a `---` fence to be a valid + /// frontmatter closing delimiter. fn is_empty(&self) -> bool { - self.single == 0 && self.double == 0 && self.template == 0 + self.current_quote.is_none() && self.comment == QuotesSeenComment::None } } @@ -1488,19 +1538,17 @@ mod quotes_seen { let mut quotes_seen = QuotesSeen::new(); track(source, &mut quotes_seen); assert!(quotes_seen.is_empty()); - - let source = r#"// Don't want to use any of this? Delete everything in this file, the `assets`, `components`, and `layouts` directories, and start fresh."#; - let mut quotes_seen = QuotesSeen::new(); - track(source, &mut quotes_seen); - assert!(quotes_seen.is_empty()); } + /// A single-line comment that has not yet been terminated (no trailing newline) + /// leaves the tracker inside the comment, so `is_empty()` returns `false`. + /// A `---` encountered in this state must not be treated as a closing fence. #[test] - fn empty_inside_comments() { + fn not_empty_inside_unterminated_single_line_comment() { let source = r#"// Don't want to use any of this? Delete everything in this file, the `assets`, `components`, and `layouts` directories, and start fresh."#; let mut quotes_seen = QuotesSeen::new(); track(source, &mut quotes_seen); - assert!(quotes_seen.is_empty()); + assert!(!quotes_seen.is_empty()); } #[test] @@ -1531,4 +1579,188 @@ const f = "something" "#; track(source, &mut quotes_seen); assert!(quotes_seen.is_empty()); } + + // --- Tests for issue #9108: mixed quote types inside a string --- + + /// A double-quoted string containing a single quote should be considered closed. + /// The apostrophe is a literal character inside the double-quoted string and + /// must not be treated as an opening single-quote delimiter. + #[test] + fn issue_9108_double_quoted_with_apostrophe() { + // "test'" — double-quoted string that contains an apostrophe + let source = r#""test'""#; + let mut quotes_seen = QuotesSeen::new(); + track(source, &mut quotes_seen); + assert!( + quotes_seen.is_empty(), + "double-quoted string containing apostrophe must be treated as closed" + ); + } + + /// A single-quoted string containing a double quote should be considered closed. + #[test] + fn issue_9108_single_quoted_with_double_quote() { + // 'it"s' + let source = r#"'it"s'"#; + let mut quotes_seen = QuotesSeen::new(); + track(source, &mut quotes_seen); + assert!( + quotes_seen.is_empty(), + "single-quoted string containing double quote must be treated as closed" + ); + } + + /// A template literal containing both single and double quotes should close cleanly. + #[test] + fn issue_9108_template_with_mixed_quotes() { + // `it's a "test"` + let source = "`it's a \"test\"`"; + let mut quotes_seen = QuotesSeen::new(); + track(source, &mut quotes_seen); + assert!( + quotes_seen.is_empty(), + "template literal containing single and double quotes must be treated as closed" + ); + } + + /// Multiple complete strings with different quote types on the same line. + #[test] + fn issue_9108_multiple_strings_with_mixed_quotes() { + // const a = "it's"; const b = 'say "hi"'; + let source = r#"const a = "it's"; const b = 'say "hi"';"#; + let mut quotes_seen = QuotesSeen::new(); + track(source, &mut quotes_seen); + assert!( + quotes_seen.is_empty(), + "multiple complete string literals with mixed quote types must all be closed" + ); + } + + // --- Tests for issue #8882: multi-line block comments with quotes --- + + /// A multi-line block comment containing an apostrophe must not leave the tracker + /// in a non-empty state. Quote characters inside block comments are not string + /// delimiters and must be ignored across all lines of the comment. + #[test] + fn issue_8882_multiline_block_comment_with_apostrophe() { + let source = "/*\n * Doesn't that stink?\n */"; + let mut quotes_seen = QuotesSeen::new(); + track(source, &mut quotes_seen); + assert!( + quotes_seen.is_empty(), + "multi-line block comment with apostrophe must not leave an open quote" + ); + } + + /// JSDoc-style comment followed by real code — mirrors the exact pattern from #8882. + #[test] + fn issue_8882_jsdoc_comment_then_code() { + let source = "/**\n * In this comment, if you add any string opening or closing, such as an apostrophe, the file will show \n * a bunch of errors. Doesn't (remove the apostrophe in the previous word to fix) that stink? \n */\nimport type { HTMLAttributes } from \"astro/types\";\nconst { class: className } = Astro.props;"; + let mut quotes_seen = QuotesSeen::new(); + track(source, &mut quotes_seen); + assert!( + quotes_seen.is_empty(), + "JSDoc comment with apostrophes followed by real code must leave tracker empty" + ); + } + + /// Multi-line block comment with quotes on every line. + #[test] + fn issue_8882_multiline_block_comment_quotes_every_line() { + let source = "/* line one 'unclosed\n line two `unclosed\n line three \"unclosed */\nconst x = 1;"; + let mut quotes_seen = QuotesSeen::new(); + track(source, &mut quotes_seen); + assert!( + quotes_seen.is_empty(), + "block comment with unclosed quote chars on every line must not affect tracker" + ); + } + + // --- Tests for fence-inside-comment cases --- + + /// A `---` sequence that appears after `//` on the same line must not close + /// the frontmatter, because it is inside a single-line comment. + #[test] + fn fence_inside_single_line_comment_is_not_empty() { + // "// ---" — the dashes are inside a line comment, not a real fence + let source = "// ---"; + let mut quotes_seen = QuotesSeen::new(); + track(source, &mut quotes_seen); + assert!( + !quotes_seen.is_empty(), + "tracker must be non-empty while inside a single-line comment" + ); + } + + /// A `---` sequence that appears inside a `/* */` block comment must not + /// close the frontmatter. + #[test] + fn fence_inside_block_comment_is_not_empty() { + // "/*\n---\n" — the dashes are inside a block comment that has not yet closed + let source = "/*\n---\n"; + let mut quotes_seen = QuotesSeen::new(); + track(source, &mut quotes_seen); + assert!( + !quotes_seen.is_empty(), + "tracker must be non-empty while inside a multi-line block comment" + ); + } + + // --- Tests for escape sequence handling --- + + /// An escaped quote inside a double-quoted string must not close the string early. + /// In `"\""`, the inner `\"` is escaped, so only the final `"` closes the string. + #[test] + fn escaped_double_quote_inside_double_string() { + // "\"" — the backslash escapes the inner double quote + let source = "\"\\\"\""; + let mut quotes_seen = QuotesSeen::new(); + track(source, &mut quotes_seen); + assert!( + quotes_seen.is_empty(), + "escaped double quote inside double-quoted string must not close it prematurely" + ); + } + + /// An escaped single quote inside a single-quoted string must not close it early. + /// In `'\''`, the inner `\'` is escaped, so only the final `'` closes the string. + #[test] + fn escaped_single_quote_inside_single_string() { + // '\'' — open single quote, escaped single quote, closing single quote + let source = r"'\''"; + let mut quotes_seen = QuotesSeen::new(); + track(source, &mut quotes_seen); + assert!( + quotes_seen.is_empty(), + "escaped single quote inside single-quoted string must not close it prematurely" + ); + } + + /// An escaped backtick inside a template literal must not close it early. + #[test] + fn escaped_backtick_inside_template() { + // `\`` + let source = "`\\``"; + let mut quotes_seen = QuotesSeen::new(); + track(source, &mut quotes_seen); + assert!( + quotes_seen.is_empty(), + "escaped backtick inside template literal must not close it prematurely" + ); + } + + /// A double backslash before a closing quote means the backslash is itself escaped, + /// so the quote is a genuine string delimiter. `"\\"` opens with the first `"`, + /// contains an escaped backslash `\\`, and closes with the final `"`. + #[test] + fn double_backslash_then_quote() { + // "\\" — opening quote, escaped backslash, closing quote + let source = "\"\\\\\""; + let mut quotes_seen = QuotesSeen::new(); + track(source, &mut quotes_seen); + assert!( + quotes_seen.is_empty(), + "double backslash followed by closing quote must close the string" + ); + } } diff --git a/crates/biome_html_parser/tests/html_specs/ok/astro/fence_inside_block_comment.astro b/crates/biome_html_parser/tests/html_specs/ok/astro/fence_inside_block_comment.astro new file mode 100644 index 000000000000..8a1f8e811b45 --- /dev/null +++ b/crates/biome_html_parser/tests/html_specs/ok/astro/fence_inside_block_comment.astro @@ -0,0 +1,8 @@ +--- +/* +--- +*/ +const x = 1; +--- + +

Hi

diff --git a/crates/biome_html_parser/tests/html_specs/ok/astro/fence_inside_block_comment.astro.snap b/crates/biome_html_parser/tests/html_specs/ok/astro/fence_inside_block_comment.astro.snap new file mode 100644 index 000000000000..c830276d5514 --- /dev/null +++ b/crates/biome_html_parser/tests/html_specs/ok/astro/fence_inside_block_comment.astro.snap @@ -0,0 +1,93 @@ +--- +source: crates/biome_html_parser/tests/spec_test.rs +expression: snapshot +--- + +## Input + +```astro +--- +/* +--- +*/ +const x = 1; +--- + +

Hi

+ +``` + + +## AST + +``` +HtmlRoot { + bom_token: missing (optional), + frontmatter: AstroFrontmatterElement { + l_fence_token: FENCE@0..3 "---" [] [], + content: AstroEmbeddedContent { + content_token: HTML_LITERAL@3..27 "/*\n---\n*/\nconst x = 1;\n" [Newline("\n")] [], + }, + r_fence_token: FENCE@27..30 "---" [] [], + }, + directive: missing (optional), + html: HtmlElementList [ + HtmlElement { + opening_element: HtmlOpeningElement { + l_angle_token: L_ANGLE@30..33 "<" [Newline("\n"), Newline("\n")] [], + name: HtmlTagName { + value_token: HTML_LITERAL@33..35 "h1" [] [], + }, + attributes: HtmlAttributeList [], + r_angle_token: R_ANGLE@35..36 ">" [] [], + }, + children: HtmlElementList [ + HtmlContent { + value_token: HTML_LITERAL@36..38 "Hi" [] [], + }, + ], + closing_element: HtmlClosingElement { + l_angle_token: L_ANGLE@38..39 "<" [] [], + slash_token: SLASH@39..40 "/" [] [], + name: HtmlTagName { + value_token: HTML_LITERAL@40..42 "h1" [] [], + }, + r_angle_token: R_ANGLE@42..43 ">" [] [], + }, + }, + ], + eof_token: EOF@43..44 "" [Newline("\n")] [], +} +``` + +## CST + +``` +0: HTML_ROOT@0..44 + 0: (empty) + 1: ASTRO_FRONTMATTER_ELEMENT@0..30 + 0: FENCE@0..3 "---" [] [] + 1: ASTRO_EMBEDDED_CONTENT@3..27 + 0: HTML_LITERAL@3..27 "/*\n---\n*/\nconst x = 1;\n" [Newline("\n")] [] + 2: FENCE@27..30 "---" [] [] + 2: (empty) + 3: HTML_ELEMENT_LIST@30..43 + 0: HTML_ELEMENT@30..43 + 0: HTML_OPENING_ELEMENT@30..36 + 0: L_ANGLE@30..33 "<" [Newline("\n"), Newline("\n")] [] + 1: HTML_TAG_NAME@33..35 + 0: HTML_LITERAL@33..35 "h1" [] [] + 2: HTML_ATTRIBUTE_LIST@35..35 + 3: R_ANGLE@35..36 ">" [] [] + 1: HTML_ELEMENT_LIST@36..38 + 0: HTML_CONTENT@36..38 + 0: HTML_LITERAL@36..38 "Hi" [] [] + 2: HTML_CLOSING_ELEMENT@38..43 + 0: L_ANGLE@38..39 "<" [] [] + 1: SLASH@39..40 "/" [] [] + 2: HTML_TAG_NAME@40..42 + 0: HTML_LITERAL@40..42 "h1" [] [] + 3: R_ANGLE@42..43 ">" [] [] + 4: EOF@43..44 "" [Newline("\n")] [] + +``` diff --git a/crates/biome_html_parser/tests/html_specs/ok/astro/fence_inside_single_line_comment.astro b/crates/biome_html_parser/tests/html_specs/ok/astro/fence_inside_single_line_comment.astro new file mode 100644 index 000000000000..f75a3285967c --- /dev/null +++ b/crates/biome_html_parser/tests/html_specs/ok/astro/fence_inside_single_line_comment.astro @@ -0,0 +1,6 @@ +--- +// --- +const x = 1; +--- + +

Hi

diff --git a/crates/biome_html_parser/tests/html_specs/ok/astro/fence_inside_single_line_comment.astro.snap b/crates/biome_html_parser/tests/html_specs/ok/astro/fence_inside_single_line_comment.astro.snap new file mode 100644 index 000000000000..78d08c0f5fa9 --- /dev/null +++ b/crates/biome_html_parser/tests/html_specs/ok/astro/fence_inside_single_line_comment.astro.snap @@ -0,0 +1,91 @@ +--- +source: crates/biome_html_parser/tests/spec_test.rs +expression: snapshot +--- + +## Input + +```astro +--- +// --- +const x = 1; +--- + +

Hi

+ +``` + + +## AST + +``` +HtmlRoot { + bom_token: missing (optional), + frontmatter: AstroFrontmatterElement { + l_fence_token: FENCE@0..3 "---" [] [], + content: AstroEmbeddedContent { + content_token: HTML_LITERAL@3..24 "// ---\nconst x = 1;\n" [Newline("\n")] [], + }, + r_fence_token: FENCE@24..27 "---" [] [], + }, + directive: missing (optional), + html: HtmlElementList [ + HtmlElement { + opening_element: HtmlOpeningElement { + l_angle_token: L_ANGLE@27..30 "<" [Newline("\n"), Newline("\n")] [], + name: HtmlTagName { + value_token: HTML_LITERAL@30..32 "h1" [] [], + }, + attributes: HtmlAttributeList [], + r_angle_token: R_ANGLE@32..33 ">" [] [], + }, + children: HtmlElementList [ + HtmlContent { + value_token: HTML_LITERAL@33..35 "Hi" [] [], + }, + ], + closing_element: HtmlClosingElement { + l_angle_token: L_ANGLE@35..36 "<" [] [], + slash_token: SLASH@36..37 "/" [] [], + name: HtmlTagName { + value_token: HTML_LITERAL@37..39 "h1" [] [], + }, + r_angle_token: R_ANGLE@39..40 ">" [] [], + }, + }, + ], + eof_token: EOF@40..41 "" [Newline("\n")] [], +} +``` + +## CST + +``` +0: HTML_ROOT@0..41 + 0: (empty) + 1: ASTRO_FRONTMATTER_ELEMENT@0..27 + 0: FENCE@0..3 "---" [] [] + 1: ASTRO_EMBEDDED_CONTENT@3..24 + 0: HTML_LITERAL@3..24 "// ---\nconst x = 1;\n" [Newline("\n")] [] + 2: FENCE@24..27 "---" [] [] + 2: (empty) + 3: HTML_ELEMENT_LIST@27..40 + 0: HTML_ELEMENT@27..40 + 0: HTML_OPENING_ELEMENT@27..33 + 0: L_ANGLE@27..30 "<" [Newline("\n"), Newline("\n")] [] + 1: HTML_TAG_NAME@30..32 + 0: HTML_LITERAL@30..32 "h1" [] [] + 2: HTML_ATTRIBUTE_LIST@32..32 + 3: R_ANGLE@32..33 ">" [] [] + 1: HTML_ELEMENT_LIST@33..35 + 0: HTML_CONTENT@33..35 + 0: HTML_LITERAL@33..35 "Hi" [] [] + 2: HTML_CLOSING_ELEMENT@35..40 + 0: L_ANGLE@35..36 "<" [] [] + 1: SLASH@36..37 "/" [] [] + 2: HTML_TAG_NAME@37..39 + 0: HTML_LITERAL@37..39 "h1" [] [] + 3: R_ANGLE@39..40 ">" [] [] + 4: EOF@40..41 "" [Newline("\n")] [] + +``` diff --git a/crates/biome_html_parser/tests/html_specs/ok/astro/issue_8882.astro b/crates/biome_html_parser/tests/html_specs/ok/astro/issue_8882.astro new file mode 100644 index 000000000000..004a6e7923dd --- /dev/null +++ b/crates/biome_html_parser/tests/html_specs/ok/astro/issue_8882.astro @@ -0,0 +1,21 @@ +--- +/** + * In this comment, if you add any string opening or closing, such as an apostrophe, the file will show + * a bunch of errors. Doesn't (remove the apostrophe in the previous word to fix) that stink? + */ +import type { HTMLAttributes } from "astro/types"; +type Props = HTMLAttributes<"div">; +const { class: className, ...rest } = Astro.props; +--- + +
+
+ +
+
diff --git a/crates/biome_html_parser/tests/html_specs/ok/astro/issue_8882.astro.snap b/crates/biome_html_parser/tests/html_specs/ok/astro/issue_8882.astro.snap new file mode 100644 index 000000000000..8bc2204eede6 --- /dev/null +++ b/crates/biome_html_parser/tests/html_specs/ok/astro/issue_8882.astro.snap @@ -0,0 +1,257 @@ +--- +source: crates/biome_html_parser/tests/spec_test.rs +expression: snapshot +--- + +## Input + +```astro +--- +/** + * In this comment, if you add any string opening or closing, such as an apostrophe, the file will show + * a bunch of errors. Doesn't (remove the apostrophe in the previous word to fix) that stink? + */ +import type { HTMLAttributes } from "astro/types"; +type Props = HTMLAttributes<"div">; +const { class: className, ...rest } = Astro.props; +--- + +
+
+ +
+
+ +``` + + +## AST + +``` +HtmlRoot { + bom_token: missing (optional), + frontmatter: AstroFrontmatterElement { + l_fence_token: FENCE@0..3 "---" [] [], + content: AstroEmbeddedContent { + content_token: HTML_LITERAL@3..348 "/**\n * In this comment, if you add any string opening or closing, such as an apostrophe, the file will show\n * a bunch of errors. Doesn't (remove the apostrophe in the previous word to fix) that stink?\n */\nimport type { HTMLAttributes } from \"astro/types\";\ntype Props = HTMLAttributes<\"div\">;\nconst { class: className, ...rest } = Astro.props;\n" [Newline("\n")] [], + }, + r_fence_token: FENCE@348..351 "---" [] [], + }, + directive: missing (optional), + html: HtmlElementList [ + HtmlElement { + opening_element: HtmlOpeningElement { + l_angle_token: L_ANGLE@351..354 "<" [Newline("\n"), Newline("\n")] [], + name: HtmlTagName { + value_token: HTML_LITERAL@354..357 "div" [] [], + }, + attributes: HtmlAttributeList [ + HtmlAttribute { + name: HtmlAttributeName { + value_token: HTML_LITERAL@357..365 "class" [Newline("\n"), Whitespace(" ")] [], + }, + initializer: HtmlAttributeInitializerClause { + eq_token: EQ@365..366 "=" [] [], + value: HtmlString { + value_token: HTML_STRING_LITERAL@366..380 "\"some-classes\"" [] [], + }, + }, + }, + HtmlAttribute { + name: HtmlAttributeName { + value_token: HTML_LITERAL@380..393 "data-state" [Newline("\n"), Whitespace(" ")] [], + }, + initializer: HtmlAttributeInitializerClause { + eq_token: EQ@393..394 "=" [] [], + value: HtmlString { + value_token: HTML_STRING_LITERAL@394..402 "\"closed\"" [] [], + }, + }, + }, + HtmlAttribute { + name: HtmlAttributeName { + value_token: HTML_LITERAL@402..410 "style" [Newline("\n"), Whitespace(" ")] [], + }, + initializer: HtmlAttributeInitializerClause { + eq_token: EQ@410..411 "=" [] [], + value: HtmlString { + value_token: HTML_STRING_LITERAL@411..429 "\"animation: none;\"" [] [], + }, + }, + }, + HtmlAttribute { + name: HtmlAttributeName { + value_token: HTML_LITERAL@429..441 "data-slot" [Newline("\n"), Whitespace(" ")] [], + }, + initializer: HtmlAttributeInitializerClause { + eq_token: EQ@441..442 "=" [] [], + value: HtmlString { + value_token: HTML_STRING_LITERAL@442..461 "\"accordion-content\"" [] [], + }, + }, + }, + HtmlSpreadAttribute { + l_curly_token: L_CURLY@461..465 "{" [Newline("\n"), Whitespace(" ")] [], + dotdotdot_token: DOT3@465..468 "..." [] [], + argument: HtmlTextExpression { + html_literal_token: HTML_LITERAL@468..472 "rest" [] [], + }, + r_curly_token: R_CURLY@472..473 "}" [] [], + }, + ], + r_angle_token: R_ANGLE@473..475 ">" [Newline("\n")] [], + }, + children: HtmlElementList [ + HtmlElement { + opening_element: HtmlOpeningElement { + l_angle_token: L_ANGLE@475..479 "<" [Newline("\n"), Whitespace(" ")] [], + name: HtmlTagName { + value_token: HTML_LITERAL@479..483 "div" [] [Whitespace(" ")], + }, + attributes: HtmlAttributeList [ + HtmlAttribute { + name: HtmlAttributeName { + value_token: HTML_LITERAL@483..488 "class" [] [], + }, + initializer: HtmlAttributeInitializerClause { + eq_token: EQ@488..489 "=" [] [], + value: HtmlString { + value_token: HTML_STRING_LITERAL@489..500 "\"pt-0 pb-4\"" [] [], + }, + }, + }, + ], + r_angle_token: R_ANGLE@500..501 ">" [] [], + }, + children: HtmlElementList [ + HtmlSelfClosingElement { + l_angle_token: L_ANGLE@501..507 "<" [Newline("\n"), Whitespace(" ")] [], + name: HtmlTagName { + value_token: HTML_LITERAL@507..512 "slot" [] [Whitespace(" ")], + }, + attributes: HtmlAttributeList [], + slash_token: SLASH@512..513 "/" [] [], + r_angle_token: R_ANGLE@513..514 ">" [] [], + }, + ], + closing_element: HtmlClosingElement { + l_angle_token: L_ANGLE@514..518 "<" [Newline("\n"), Whitespace(" ")] [], + slash_token: SLASH@518..519 "/" [] [], + name: HtmlTagName { + value_token: HTML_LITERAL@519..522 "div" [] [], + }, + r_angle_token: R_ANGLE@522..523 ">" [] [], + }, + }, + ], + closing_element: HtmlClosingElement { + l_angle_token: L_ANGLE@523..525 "<" [Newline("\n")] [], + slash_token: SLASH@525..526 "/" [] [], + name: HtmlTagName { + value_token: HTML_LITERAL@526..529 "div" [] [], + }, + r_angle_token: R_ANGLE@529..530 ">" [] [], + }, + }, + ], + eof_token: EOF@530..531 "" [Newline("\n")] [], +} +``` + +## CST + +``` +0: HTML_ROOT@0..531 + 0: (empty) + 1: ASTRO_FRONTMATTER_ELEMENT@0..351 + 0: FENCE@0..3 "---" [] [] + 1: ASTRO_EMBEDDED_CONTENT@3..348 + 0: HTML_LITERAL@3..348 "/**\n * In this comment, if you add any string opening or closing, such as an apostrophe, the file will show\n * a bunch of errors. Doesn't (remove the apostrophe in the previous word to fix) that stink?\n */\nimport type { HTMLAttributes } from \"astro/types\";\ntype Props = HTMLAttributes<\"div\">;\nconst { class: className, ...rest } = Astro.props;\n" [Newline("\n")] [] + 2: FENCE@348..351 "---" [] [] + 2: (empty) + 3: HTML_ELEMENT_LIST@351..530 + 0: HTML_ELEMENT@351..530 + 0: HTML_OPENING_ELEMENT@351..475 + 0: L_ANGLE@351..354 "<" [Newline("\n"), Newline("\n")] [] + 1: HTML_TAG_NAME@354..357 + 0: HTML_LITERAL@354..357 "div" [] [] + 2: HTML_ATTRIBUTE_LIST@357..473 + 0: HTML_ATTRIBUTE@357..380 + 0: HTML_ATTRIBUTE_NAME@357..365 + 0: HTML_LITERAL@357..365 "class" [Newline("\n"), Whitespace(" ")] [] + 1: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@365..380 + 0: EQ@365..366 "=" [] [] + 1: HTML_STRING@366..380 + 0: HTML_STRING_LITERAL@366..380 "\"some-classes\"" [] [] + 1: HTML_ATTRIBUTE@380..402 + 0: HTML_ATTRIBUTE_NAME@380..393 + 0: HTML_LITERAL@380..393 "data-state" [Newline("\n"), Whitespace(" ")] [] + 1: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@393..402 + 0: EQ@393..394 "=" [] [] + 1: HTML_STRING@394..402 + 0: HTML_STRING_LITERAL@394..402 "\"closed\"" [] [] + 2: HTML_ATTRIBUTE@402..429 + 0: HTML_ATTRIBUTE_NAME@402..410 + 0: HTML_LITERAL@402..410 "style" [Newline("\n"), Whitespace(" ")] [] + 1: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@410..429 + 0: EQ@410..411 "=" [] [] + 1: HTML_STRING@411..429 + 0: HTML_STRING_LITERAL@411..429 "\"animation: none;\"" [] [] + 3: HTML_ATTRIBUTE@429..461 + 0: HTML_ATTRIBUTE_NAME@429..441 + 0: HTML_LITERAL@429..441 "data-slot" [Newline("\n"), Whitespace(" ")] [] + 1: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@441..461 + 0: EQ@441..442 "=" [] [] + 1: HTML_STRING@442..461 + 0: HTML_STRING_LITERAL@442..461 "\"accordion-content\"" [] [] + 4: HTML_SPREAD_ATTRIBUTE@461..473 + 0: L_CURLY@461..465 "{" [Newline("\n"), Whitespace(" ")] [] + 1: DOT3@465..468 "..." [] [] + 2: HTML_TEXT_EXPRESSION@468..472 + 0: HTML_LITERAL@468..472 "rest" [] [] + 3: R_CURLY@472..473 "}" [] [] + 3: R_ANGLE@473..475 ">" [Newline("\n")] [] + 1: HTML_ELEMENT_LIST@475..523 + 0: HTML_ELEMENT@475..523 + 0: HTML_OPENING_ELEMENT@475..501 + 0: L_ANGLE@475..479 "<" [Newline("\n"), Whitespace(" ")] [] + 1: HTML_TAG_NAME@479..483 + 0: HTML_LITERAL@479..483 "div" [] [Whitespace(" ")] + 2: HTML_ATTRIBUTE_LIST@483..500 + 0: HTML_ATTRIBUTE@483..500 + 0: HTML_ATTRIBUTE_NAME@483..488 + 0: HTML_LITERAL@483..488 "class" [] [] + 1: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@488..500 + 0: EQ@488..489 "=" [] [] + 1: HTML_STRING@489..500 + 0: HTML_STRING_LITERAL@489..500 "\"pt-0 pb-4\"" [] [] + 3: R_ANGLE@500..501 ">" [] [] + 1: HTML_ELEMENT_LIST@501..514 + 0: HTML_SELF_CLOSING_ELEMENT@501..514 + 0: L_ANGLE@501..507 "<" [Newline("\n"), Whitespace(" ")] [] + 1: HTML_TAG_NAME@507..512 + 0: HTML_LITERAL@507..512 "slot" [] [Whitespace(" ")] + 2: HTML_ATTRIBUTE_LIST@512..512 + 3: SLASH@512..513 "/" [] [] + 4: R_ANGLE@513..514 ">" [] [] + 2: HTML_CLOSING_ELEMENT@514..523 + 0: L_ANGLE@514..518 "<" [Newline("\n"), Whitespace(" ")] [] + 1: SLASH@518..519 "/" [] [] + 2: HTML_TAG_NAME@519..522 + 0: HTML_LITERAL@519..522 "div" [] [] + 3: R_ANGLE@522..523 ">" [] [] + 2: HTML_CLOSING_ELEMENT@523..530 + 0: L_ANGLE@523..525 "<" [Newline("\n")] [] + 1: SLASH@525..526 "/" [] [] + 2: HTML_TAG_NAME@526..529 + 0: HTML_LITERAL@526..529 "div" [] [] + 3: R_ANGLE@529..530 ">" [] [] + 4: EOF@530..531 "" [Newline("\n")] [] + +``` diff --git a/crates/biome_html_parser/tests/html_specs/ok/astro/issue_9108.astro b/crates/biome_html_parser/tests/html_specs/ok/astro/issue_9108.astro new file mode 100644 index 000000000000..1c90f5941d17 --- /dev/null +++ b/crates/biome_html_parser/tests/html_specs/ok/astro/issue_9108.astro @@ -0,0 +1,7 @@ +--- +const test = "test'" +const other = 'say "hi"' +const template = `it's a "test"` +--- + + diff --git a/crates/biome_html_parser/tests/html_specs/ok/astro/issue_9108.astro.snap b/crates/biome_html_parser/tests/html_specs/ok/astro/issue_9108.astro.snap new file mode 100644 index 000000000000..f6d843aae127 --- /dev/null +++ b/crates/biome_html_parser/tests/html_specs/ok/astro/issue_9108.astro.snap @@ -0,0 +1,105 @@ +--- +source: crates/biome_html_parser/tests/spec_test.rs +expression: snapshot +--- + +## Input + +```astro +--- +const test = "test'" +const other = 'say "hi"' +const template = `it's a "test"` +--- + + + +``` + + +## AST + +``` +HtmlRoot { + bom_token: missing (optional), + frontmatter: AstroFrontmatterElement { + l_fence_token: FENCE@0..3 "---" [] [], + content: AstroEmbeddedContent { + content_token: HTML_LITERAL@3..83 "const test = \"test'\"\nconst other = 'say \"hi\"'\nconst template = `it's a \"test\"`\n" [Newline("\n")] [], + }, + r_fence_token: FENCE@83..86 "---" [] [], + }, + directive: missing (optional), + html: HtmlElementList [ + HtmlElement { + opening_element: HtmlOpeningElement { + l_angle_token: L_ANGLE@86..89 "<" [Newline("\n"), Newline("\n")] [], + name: HtmlTagName { + value_token: HTML_LITERAL@89..94 "html" [] [Whitespace(" ")], + }, + attributes: HtmlAttributeList [ + HtmlAttribute { + name: HtmlAttributeName { + value_token: HTML_LITERAL@94..98 "lang" [] [], + }, + initializer: HtmlAttributeInitializerClause { + eq_token: EQ@98..99 "=" [] [], + value: HtmlString { + value_token: HTML_STRING_LITERAL@99..103 "\"en\"" [] [], + }, + }, + }, + ], + r_angle_token: R_ANGLE@103..104 ">" [] [], + }, + children: HtmlElementList [], + closing_element: HtmlClosingElement { + l_angle_token: L_ANGLE@104..105 "<" [] [], + slash_token: SLASH@105..106 "/" [] [], + name: HtmlTagName { + value_token: HTML_LITERAL@106..110 "html" [] [], + }, + r_angle_token: R_ANGLE@110..111 ">" [] [], + }, + }, + ], + eof_token: EOF@111..112 "" [Newline("\n")] [], +} +``` + +## CST + +``` +0: HTML_ROOT@0..112 + 0: (empty) + 1: ASTRO_FRONTMATTER_ELEMENT@0..86 + 0: FENCE@0..3 "---" [] [] + 1: ASTRO_EMBEDDED_CONTENT@3..83 + 0: HTML_LITERAL@3..83 "const test = \"test'\"\nconst other = 'say \"hi\"'\nconst template = `it's a \"test\"`\n" [Newline("\n")] [] + 2: FENCE@83..86 "---" [] [] + 2: (empty) + 3: HTML_ELEMENT_LIST@86..111 + 0: HTML_ELEMENT@86..111 + 0: HTML_OPENING_ELEMENT@86..104 + 0: L_ANGLE@86..89 "<" [Newline("\n"), Newline("\n")] [] + 1: HTML_TAG_NAME@89..94 + 0: HTML_LITERAL@89..94 "html" [] [Whitespace(" ")] + 2: HTML_ATTRIBUTE_LIST@94..103 + 0: HTML_ATTRIBUTE@94..103 + 0: HTML_ATTRIBUTE_NAME@94..98 + 0: HTML_LITERAL@94..98 "lang" [] [] + 1: HTML_ATTRIBUTE_INITIALIZER_CLAUSE@98..103 + 0: EQ@98..99 "=" [] [] + 1: HTML_STRING@99..103 + 0: HTML_STRING_LITERAL@99..103 "\"en\"" [] [] + 3: R_ANGLE@103..104 ">" [] [] + 1: HTML_ELEMENT_LIST@104..104 + 2: HTML_CLOSING_ELEMENT@104..111 + 0: L_ANGLE@104..105 "<" [] [] + 1: SLASH@105..106 "/" [] [] + 2: HTML_TAG_NAME@106..110 + 0: HTML_LITERAL@106..110 "html" [] [] + 3: R_ANGLE@110..111 ">" [] [] + 4: EOF@111..112 "" [Newline("\n")] [] + +```