-
-
-
-
- "#;
-
- let sources = parse_astro(source_text);
- assert_eq!(sources.len(), 2);
- assert!(sources[0].source_text.is_empty());
- assert_eq!(sources[0].start, 104);
- assert_eq!(sources[1].source_text.trim(), r#"console.log("Hi");"#);
- assert_eq!(sources[1].start, 122);
- }
-}
diff --git a/crates/oxc_linter/src/loader/partial_loader/mod.rs b/crates/oxc_linter/src/loader/partial_loader/mod.rs
index bb59ffef3f09a..fdbc11a500cb4 100644
--- a/crates/oxc_linter/src/loader/partial_loader/mod.rs
+++ b/crates/oxc_linter/src/loader/partial_loader/mod.rs
@@ -3,10 +3,8 @@ use oxc_span::VALID_EXTENSIONS;
use crate::loader::JavaScriptSource;
-mod astro;
mod svelte;
mod vue;
-pub use astro::AstroPartialLoader;
pub use svelte::SveltePartialLoader;
pub use vue::VuePartialLoader;
@@ -17,7 +15,7 @@ const COMMENT_END: &str = "-->";
/// File extensions that can contain JS/TS code in certain parts, such as in `"#,
None,
None,
Some(PathBuf::from("src/foo/bar.vue")),
),
- (
- r"---
-import Welcome from '../components/Welcome.astro';
-import Layout from '../layouts/Layout.astro';
----
-
-
-",
- None,
- None,
- Some(PathBuf::from("src/foo/bar.astro")),
- ),
(
r"` tags) in the full `.astro` source text.
+/// - `insert_offset` is the byte offset of the `\n` that terminates the `` element with
+ // absolute byte offsets into the full `.astro` source.
+ //
+ // We find the first `>` inside `script.span` — that's the end of the
+ // opening tag. The byte right after it is either `\n` (line-break after
+ // ``, which is `\n`) achieves this.
+ let insert_offset = content_start;
+
+ out.push((content_start, content_end, insert_offset));
+ }
+ JSXChild::Element(el) => {
+ collect_script_spans(source_text, &el.children, out);
+ }
+ JSXChild::Fragment(frag) => {
+ collect_script_spans(source_text, &frag.children, out);
+ }
+ _ => {}
+ }
+ }
+}
+
+/// Given an [`AstroSectionMap`] and a diagnostic byte offset, return the correct
+/// `section_offset` to use for code-action insertion:
+/// - If the offset falls inside a `>>::None,
+ attributes,
+ );
+
+ let closing_name = self
+ .ast
+ .jsx_identifier(oxc_span::Span::new(content_end as u32 + 2, end - 1), "script");
+ let closing_elem_name = JSXElementName::Identifier(self.alloc(closing_name));
+ let closing = self.ast.alloc_jsx_closing_element(
+ oxc_span::Span::new(content_end as u32, end),
+ closing_elem_name,
+ );
+
+ let raw_text = &self.source_text[content_start..content_end];
+ let text_span = oxc_span::Span::new(content_start as u32, content_end as u32);
+ let text = self.ast.alloc_jsx_text(
+ text_span,
+ oxc_span::Atom::from(raw_text),
+ Some(oxc_span::Atom::from(raw_text)),
+ );
+
+ return JSXChild::Element(self.ast.alloc_jsx_element(
+ full_span,
+ opening,
+ self.ast.vec1(JSXChild::Text(text)),
+ Some(closing),
+ ));
+ }
+
+ let script_content_span = oxc_span::Span::new(content_start as u32, content_end as u32);
+ let program = self.ast.program(
+ script_content_span,
+ self.source_type,
+ "",
+ self.ast.vec(),
+ None,
+ self.ast.vec(),
+ self.ast.vec(),
+ );
+ let astro_script =
+ JSXChild::AstroScript(self.ast.alloc_astro_script(full_span, program));
+
+ let opening_name =
+ self.ast.jsx_identifier(oxc_span::Span::new(span + 1, span + 7), "script");
+ let opening_elem_name = JSXElementName::Identifier(self.alloc(opening_name));
+ let opening = self.ast.alloc_jsx_opening_element(
+ oxc_span::Span::new(span, content_start as u32),
+ opening_elem_name,
+ Option::>>::None,
+ attributes,
+ );
+
+ let closing_name = self
+ .ast
+ .jsx_identifier(oxc_span::Span::new(content_end as u32 + 2, end - 1), "script");
+ let closing_elem_name = JSXElementName::Identifier(self.alloc(closing_name));
+ let closing = self.ast.alloc_jsx_closing_element(
+ oxc_span::Span::new(content_end as u32, end),
+ closing_elem_name,
+ );
+
+ return JSXChild::Element(self.ast.alloc_jsx_element(
+ full_span,
+ opening,
+ self.ast.vec1(astro_script),
+ Some(closing),
+ ));
+ }
+
+ // No closing tag found - error recovery
+ let end = self.prev_token_end;
+ let script_span = oxc_span::Span::new(span, end);
+ let program = self.ast.program(
+ oxc_span::Span::empty(end),
+ self.source_type,
+ "",
+ self.ast.vec(),
+ None,
+ self.ast.vec(),
+ self.ast.vec(),
+ );
+ let astro_script = JSXChild::AstroScript(self.ast.alloc_astro_script(script_span, program));
+
+ let name = self.ast.jsx_identifier(oxc_span::Span::new(span + 1, span + 7), "script");
+ let elem_name = JSXElementName::Identifier(self.alloc(name));
+ let opening = self.ast.alloc_jsx_opening_element(
+ script_span,
+ elem_name,
+ Option::>>::None,
+ self.ast.vec(),
+ );
+ JSXChild::Element(self.ast.alloc_jsx_element(
+ script_span,
+ opening,
+ self.ast.vec1(astro_script),
+ Option::>>::None,
+ ))
+ }
+
+ /// Skip the raw text content of a raw text element in Astro.
+ pub(crate) fn skip_astro_raw_text_element_content(
+ &mut self,
+ name: &JSXElementName<'a>,
+ in_jsx_child: bool,
+ ) -> Vec<'a, JSXChild<'a>> {
+ let tag_name = match name {
+ JSXElementName::Identifier(ident) => ident.name.as_str(),
+ JSXElementName::IdentifierReference(ident_ref) => ident_ref.name.as_str(),
+ JSXElementName::MemberExpression(_) => name.span().source_text(self.source_text),
+ JSXElementName::NamespacedName(_) | JSXElementName::ThisExpression(_) => {
+ name.span().source_text(self.source_text)
+ }
+ };
+
+ let closing_tag = format!("{tag_name}");
+
+ let start_pos = self.prev_token_end as usize;
+ if let Some(rest) = self.source_text.get(start_pos..)
+ && let Some(end_pos) = rest.find(&closing_tag)
+ {
+ #[expect(clippy::cast_possible_truncation)]
+ let content_end = (start_pos + end_pos) as u32;
+ let content_start = self.prev_token_end;
+
+ self.lexer.set_position_for_astro(content_end);
+ if in_jsx_child {
+ self.token = self.lexer.next_jsx_child();
+ } else {
+ self.token = self.lexer.next_token();
+ }
+
+ if content_end > content_start {
+ let span = Span::new(content_start, content_end);
+ let raw_text = span.source_text(self.source_text);
+ let jsx_text = self.ast.alloc_jsx_text(span, raw_text, Some(Atom::from(raw_text)));
+ return self.ast.vec1(JSXChild::Text(jsx_text));
+ }
+ }
+
+ self.ast.vec()
+ }
+
+ /// Parse HTML comment in JSX (Astro-specific).
+ #[expect(clippy::cast_possible_truncation)]
+ pub(crate) fn parse_html_comment_in_jsx(&mut self, span: u32) -> Option> {
+ let start_pos = self.prev_token_end as usize;
+
+ if let Some(rest) = self.source_text.get(start_pos..)
+ && rest.starts_with("!--")
+ && let Some(end_offset) = rest.find("-->")
+ {
+ let comment_end = (start_pos + end_offset + 3) as u32;
+ let comment_start = span;
+
+ let content = &rest[3..end_offset];
+ let value = oxc_span::Atom::from(content);
+
+ let comment_span = oxc_span::Span::new(comment_start, comment_end);
+ let comment = self.ast.alloc_astro_comment(comment_span, value);
+
+ self.lexer.set_position_for_astro(comment_end);
+ self.token = self.lexer.next_jsx_child();
+
+ return Some(JSXChild::AstroComment(comment));
+ }
+
+ None
+ }
+
+ /// Parse JSX children in an expression container (Astro-specific).
+ fn parse_astro_jsx_children_in_expression(&mut self, span_start: u32) -> JSXExpression<'a> {
+ let fragment_span_start = span_start + 1;
+ let mut children = self.ast.vec();
+
+ loop {
+ if self.at(Kind::Eof) {
+ break;
+ }
+
+ match self.cur_kind() {
+ Kind::LAngle => {
+ let child_span = self.start_span();
+ self.bump_any();
+
+ let kind = self.cur_kind();
+
+ if kind == Kind::RAngle {
+ let fragment = self.parse_astro_jsx_fragment(child_span, true);
+ children.push(JSXChild::Fragment(fragment));
+ if self.at(Kind::Eof) {
+ self.lexer.errors.pop();
+ self.token = self.lexer.next_token();
+ }
+ } else if kind == Kind::Ident || kind.is_any_keyword() {
+ if self.cur_src() == "script" {
+ children.push(self.parse_astro_script_in_jsx(child_span));
+ } else {
+ let element = self.parse_astro_jsx_element(child_span, true);
+ children.push(JSXChild::Element(element));
+ }
+ if self.at(Kind::Eof) {
+ self.lexer.errors.pop();
+ self.token = self.lexer.next_token();
+ }
+ } else if kind == Kind::Slash {
+ let _: () = self.unexpected();
+ break;
+ } else if kind == Kind::Bang {
+ // `
+//!
Hello {name}!
+//! ```
+
+mod jsx;
+mod parse;
+mod scripts;
+
+pub use parse::AstroParserReturn;
+pub use parse::parse_astro;
+pub use scripts::parse_astro_scripts;
+
+use oxc_allocator::{Box, Vec};
+use oxc_ast::ast::*;
+
+use crate::{ParserImpl, config::ParserConfig, lexer::Kind};
+
+type NoTypeArgs<'a> = Option>>;
+type NoClosingElement<'a> = Option>>;
+
+impl<'a, C: ParserConfig> ParserImpl<'a, C> {
+ /// Parse the HTML body of an Astro file.
+ ///
+ /// The body is essentially JSX children in an implicit fragment.
+ pub(crate) fn parse_astro_body(&mut self) -> Vec<'a, JSXChild<'a>> {
+ let mut children = self.ast.vec();
+
+ // Parse JSX children until EOF
+ while !self.at(Kind::Eof) && self.fatal_error.is_none() {
+ if let Some(child) = self.parse_astro_child() {
+ children.push(child);
+ } else {
+ break;
+ }
+ }
+
+ children
+ }
+
+ /// Parse a single child in the Astro body.
+ fn parse_astro_child(&mut self) -> Option> {
+ match self.cur_kind() {
+ Kind::LAngle => {
+ let span = self.start_span();
+ let checkpoint = self.checkpoint();
+ self.bump_any(); // bump `<`
+
+ let kind = self.cur_kind();
+
+ // `<>` - fragment
+ if kind == Kind::RAngle {
+ return Some(JSXChild::Fragment(self.parse_astro_jsx_fragment(span, true)));
+ }
+
+ // ` tag - we handle it specially in Astro
+ if self.cur_src().starts_with("script") {
+ // Check it's actually "script" and not "script-something"
+ let after_script =
+ self.source_text.get((self.cur_token().span().start as usize) + 6..);
+ if let Some(rest) = after_script {
+ let next_char: Option = rest.chars().next();
+ if matches!(
+ next_char,
+ Some(' ' | '>' | '/' | '\n' | '\r' | '\t') | None
+ ) {
+ return Some(self.parse_astro_script(span));
+ }
+ }
+ }
+ return Some(JSXChild::Element(self.parse_astro_jsx_element(span, true)));
+ }
+
+ // `` - closing tag (end of parent)
+ if kind == Kind::Slash {
+ self.rewind(checkpoint);
+ return None;
+ }
+
+ // ` {
+ // JSX expression container
+ let span_start = self.start_span();
+ self.bump_any(); // bump `{`
+
+ // `{...expr}` - spread
+ if self.eat(Kind::Dot3) {
+ return Some(JSXChild::Spread(self.parse_jsx_spread_child(span_start)));
+ }
+
+ // `{expr}` - expression
+ Some(JSXChild::ExpressionContainer(self.parse_astro_jsx_expression_container(
+ span_start, /* in_jsx_child */ true,
+ )))
+ }
+ Kind::JSXText => Some(JSXChild::Text(self.parse_jsx_text())),
+ Kind::Eof => None,
+ _ => {
+ // In Astro body, we should be getting JSX tokens
+ // If we get something unexpected, try to continue
+ self.bump_any();
+ self.parse_astro_child()
+ }
+ }
+ }
+
+ /// Parse an HTML comment `` or doctype ``.
+ ///
+ /// This handles:
+ /// - HTML comments: `` - added to trivia, returns `None`
+ /// - Doctypes: `` or `` - returns `Some(JSXChild::AstroDoctype)`
+ ///
+ /// `span` is the start position (at `<`).
+ #[expect(clippy::cast_possible_truncation)]
+ fn parse_html_comment_or_doctype(&mut self, span: u32) -> Option> {
+ // We're at `!` after `<`, so start position is after `<`
+ let start_pos = self.prev_token_end as usize;
+
+ // Check if this is a comment (starts with `` to close the comment
+ if let Some(end_offset) = rest.find("-->") {
+ let comment_end = (start_pos + end_offset + 3) as u32;
+ let comment_start = span; // `<` position
+
+ // Extract comment content (between ``)
+ // rest starts with "!--", so content starts at index 3
+ let content = &rest[3..end_offset];
+ let value = oxc_span::Atom::from(content);
+
+ // Create the AstroComment node
+ let comment_span = oxc_span::Span::new(comment_start, comment_end);
+ let comment = self.ast.alloc_astro_comment(comment_span, value);
+
+ // Move lexer position past the comment
+ self.lexer.set_position_for_astro(comment_end);
+ self.token = self.lexer.next_jsx_child();
+
+ return Some(JSXChild::AstroComment(comment));
+ }
+ } else {
+ // This is likely a doctype or other `` construct
+ // Find the closing `>` (not `-->`)
+ if let Some(end_offset) = rest.find('>') {
+ let end_pos = (start_pos + end_offset + 1) as u32;
+
+ // Extract the doctype value (e.g., "html" from "doctype html" or "DOCTYPE html")
+ // rest starts with `!`, so content is after `!`
+ let content = &rest[1..end_offset];
+ // Skip "doctype" or "DOCTYPE" (case-insensitive) and any whitespace
+ let value = content
+ .strip_prefix("doctype")
+ .or_else(|| content.strip_prefix("DOCTYPE"))
+ .or_else(|| content.strip_prefix("Doctype"))
+ .map_or(content.trim(), str::trim);
+ let value = oxc_span::Atom::from(value);
+
+ // Create the doctype node
+ let doctype_span = oxc_span::Span::new(span, end_pos);
+ let doctype = self.ast.alloc_astro_doctype(doctype_span, value);
+
+ // Move lexer position past the doctype/construct
+ self.lexer.set_position_for_astro(end_pos);
+ self.token = self.lexer.next_jsx_child();
+
+ return Some(JSXChild::AstroDoctype(doctype));
+ }
+ }
+ }
+
+ // Fallback: skip tokens until we find `>` or `-->`
+ // This handles malformed comments/doctypes
+ self.bump_any(); // skip `!`
+ while !self.at(Kind::Eof) {
+ if self.at(Kind::RAngle) {
+ self.bump_any();
+ break;
+ } else if self.at(Kind::Minus2) || self.at(Kind::Minus) {
+ self.bump_any();
+ if self.at(Kind::Minus) {
+ self.bump_any();
+ }
+ if self.at(Kind::RAngle) {
+ self.bump_any();
+ break;
+ }
+ } else {
+ self.bump_any();
+ }
+ }
+ None // Fallback case, treat as comment (trivia)
+ }
+
+ /// Parse an Astro ` tag.
+ // Use `prev_token_end` (right after `>`) instead of `cur_token().span().start` so
+ // that leading trivia (e.g. `// eslint-disable-next-line` comments) before the first
+ // real token is included in the content span and picked up by the parser.
+ let content_start = self.prev_token_end as usize;
+ let closing_tag = "` that closes `` by scanning source text directly,
+ // rather than bumping tokens (which would use the wrong lexer mode).
+ let close_end = {
+ let close_source = &self.source_text[content_end..];
+ // close_source starts with ""
+ if let Some(gt) = close_source.find('>') {
+ content_end + gt + 1
+ } else {
+ content_end
+ }
+ };
+
+ #[expect(clippy::cast_possible_truncation)]
+ let end = close_end as u32;
+ // Position the lexer right after `` and re-enter JSX child
+ // mode so the next token is correctly tokenised.
+ self.lexer.set_position_for_astro(end);
+ self.token = self.lexer.next_jsx_child();
+ let full_span = oxc_span::Span::new(span, end);
+
+ if has_attributes {
+ // Script with attributes - return as regular JSX element
+ // The content is raw text, not parsed
+ let opening_name =
+ self.ast.jsx_identifier(oxc_span::Span::new(span + 1, span + 7), "script");
+ let opening_elem_name = JSXElementName::Identifier(self.alloc(opening_name));
+ let no_type_args: NoTypeArgs<'a> = None;
+ let opening = self.ast.alloc_jsx_opening_element(
+ oxc_span::Span::new(span, content_start as u32),
+ opening_elem_name,
+ no_type_args,
+ attributes,
+ );
+
+ let closing_name = self
+ .ast
+ .jsx_identifier(oxc_span::Span::new(content_end as u32 + 2, end - 1), "script");
+ let closing_elem_name = JSXElementName::Identifier(self.alloc(closing_name));
+ let closing = self.ast.alloc_jsx_closing_element(
+ oxc_span::Span::new(content_end as u32, end),
+ closing_elem_name,
+ );
+
+ // Create a text child for the raw content
+ let text_span = oxc_span::Span::new(content_start as u32, content_end as u32);
+ let raw_text = &self.source_text[content_start..content_end];
+ let text_node = self.ast.alloc_jsx_text(
+ text_span,
+ oxc_span::Atom::from(raw_text),
+ Some(oxc_span::Atom::from(raw_text)),
+ );
+
+ return JSXChild::Element(self.ast.alloc_jsx_element(
+ full_span,
+ opening,
+ self.ast.vec1(JSXChild::Text(text_node)),
+ Some(closing),
+ ));
+ }
+
+ // Bare script - wrap AstroScript in closing element
+ let closing_name = self
+ .ast
+ .jsx_identifier(oxc_span::Span::new(content_end as u32 + 2, end - 1), "script");
+ let closing_elem_name = JSXElementName::Identifier(self.alloc(closing_name));
+ let closing = self.ast.alloc_jsx_closing_element(
+ oxc_span::Span::new(content_end as u32, end),
+ closing_elem_name,
+ );
+
+ return JSXChild::Element(self.ast.alloc_jsx_element(
+ full_span,
+ opening,
+ self.ast.vec1(astro_script),
+ Some(closing),
+ ));
+ }
+
+ // Fallback: couldn't find closing tag, parse as regular element
+ JSXChild::Element(self.parse_astro_jsx_element(span, true))
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use oxc_allocator::Allocator;
+ use oxc_ast::ast::{
+ JSXAttributeItem, JSXAttributeName, JSXChild, JSXElementName, JSXExpression, Statement,
+ };
+ use oxc_span::SourceType;
+
+ use crate::Parser;
+
+ #[test]
+ fn parse_astro_smoke_test() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = "";
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ // Frontmatter is always present (synthetic empty one for files without actual frontmatter)
+ assert!(ret.root.frontmatter.is_some());
+ let frontmatter = ret.root.frontmatter.as_ref().unwrap();
+ // Synthetic frontmatter has 0-length span and empty body
+ assert_eq!(frontmatter.span.start, 0);
+ assert_eq!(frontmatter.span.end, 0);
+ assert!(frontmatter.program.body.is_empty());
+ assert!(ret.root.body.is_empty());
+ assert!(ret.errors.is_empty());
+ }
+
+ #[test]
+ fn parse_astro_with_frontmatter() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = r#"---
+const name = "World";
+---
+
Hello
+"#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Check frontmatter - now contains a parsed Program
+ assert!(ret.root.frontmatter.is_some());
+ let frontmatter = ret.root.frontmatter.as_ref().unwrap();
+ // The program should have one statement (const declaration)
+ assert_eq!(frontmatter.program.body.len(), 1);
+ assert!(matches!(frontmatter.program.body[0], Statement::VariableDeclaration(_)));
+
+ // Check body has at least one element
+ assert!(!ret.root.body.is_empty());
+ assert!(matches!(ret.root.body[0], JSXChild::Element(_)));
+ }
+
+ #[test]
+ fn parse_astro_without_frontmatter() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = "
Hello
";
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Frontmatter is always present (synthetic empty one for files without actual frontmatter)
+ assert!(ret.root.frontmatter.is_some());
+ let frontmatter = ret.root.frontmatter.as_ref().unwrap();
+ // Synthetic frontmatter has 0-length span and empty body
+ assert_eq!(frontmatter.span.start, 0);
+ assert_eq!(frontmatter.span.end, 0);
+ assert!(frontmatter.program.body.is_empty());
+
+ // Body should have one element
+ assert_eq!(ret.root.body.len(), 1);
+ assert!(matches!(ret.root.body[0], JSXChild::Element(_)));
+ }
+
+ #[test]
+ fn parse_astro_with_jsx_expression() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ // Simpler test case: just an expression in JSX
+ let source = r#"---
+const name = "World";
+---
+
{name}
+"#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Check frontmatter - now contains a parsed Program
+ assert!(ret.root.frontmatter.is_some());
+ let frontmatter = ret.root.frontmatter.as_ref().unwrap();
+ assert_eq!(frontmatter.program.body.len(), 1);
+
+ // Check body
+ assert!(!ret.root.body.is_empty());
+ // First element should be the
+ assert!(matches!(ret.root.body[0], JSXChild::Element(_)));
+ }
+
+ #[test]
+ fn parse_astro_fragment() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = "<>
1
2
>";
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Body should have one fragment
+ assert_eq!(ret.root.body.len(), 1);
+ assert!(matches!(ret.root.body[0], JSXChild::Fragment(_)));
+ }
+
+ #[test]
+ fn parse_astro_script() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = "
Hello
\n\n
World
";
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Body should have 5 children: div, text(\n), script, text(\n), div
+ assert_eq!(ret.root.body.len(), 5, "Expected 5 children, got {}", ret.root.body.len());
+
+ // First should be an element
+ assert!(matches!(ret.root.body[0], JSXChild::Element(_)));
+
+ // Second should be text (newline)
+ assert!(matches!(ret.root.body[1], JSXChild::Text(_)));
+
+ // Third should be a and
)
+ assert!(matches!(ret.root.body[3], JSXChild::Text(_)));
+
+ // Fifth should be an element
+ assert!(matches!(ret.root.body[4], JSXChild::Element(_)));
+ }
+
+ #[test]
+ fn parse_astro_complex() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = r#"---
+ import CardSkeleton from "$components/card/CardSkeleton.astro"
+ import ReturnHome from "$components/controls/ReturnHome.astro"
+ import Layout from "$layouts/Layout.astro"
+
+ /**
+ * Catalogue page
+ */
+ const pageTitle = "Catalogue"
+ ---
+
+
+
+
+
+
+
+
+
+
{pageTitle}
+
+ Where I keep track of books, movies, songs, video games, and other media I
+ consume. Keep in mind that this is a personal catalogue, incomplete and
+ biased.
+
+
+
+ Images and data are fetched from IGDB for video games, BGG for board games, TMDB for movies and shows, and Spotify for albums.
+ Their licenses apply.
+
+
+
+
+
+
+
+
+"#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Check frontmatter is parsed as TypeScript
+ assert!(ret.root.frontmatter.is_some());
+ let frontmatter = ret.root.frontmatter.as_ref().unwrap();
+ // Should have 4 statements: 3 imports + 1 const declaration
+ assert_eq!(frontmatter.program.body.len(), 4, "Expected 4 statements in frontmatter");
+ // First 3 should be import declarations
+ assert!(matches!(frontmatter.program.body[0], Statement::ImportDeclaration(_)));
+ assert!(matches!(frontmatter.program.body[1], Statement::ImportDeclaration(_)));
+ assert!(matches!(frontmatter.program.body[2], Statement::ImportDeclaration(_)));
+ // Last should be a variable declaration
+ assert!(matches!(frontmatter.program.body[3], Statement::VariableDeclaration(_)));
+
+ // Check body has the Layout element (may have leading whitespace text)
+ assert!(!ret.root.body.is_empty());
+ // Find the first non-text element
+ let first_element =
+ ret.root.body.iter().find(|child| matches!(child, JSXChild::Element(_)));
+ assert!(first_element.is_some(), "Expected at least one JSXChild::Element in body");
+ }
+
+ #[test]
+ fn parse_astro_frontmatter_with_whitespace_before_fence() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ // Whitespace before opening fence is allowed per spec
+ let source = " ---\nconst x = 1;\n---\n
test
";
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Should have frontmatter
+ assert!(ret.root.frontmatter.is_some());
+ let frontmatter = ret.root.frontmatter.as_ref().unwrap();
+ assert_eq!(frontmatter.program.body.len(), 1);
+ assert!(matches!(frontmatter.program.body[0], Statement::VariableDeclaration(_)));
+ }
+
+ #[test]
+ fn parse_astro_frontmatter_content_before_fence() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ // Content before opening fence is allowed (customarily ignored)
+ let source = "ignored content\n---\nconst x = 1;\n---\n
test
";
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Should have frontmatter
+ assert!(ret.root.frontmatter.is_some());
+ let frontmatter = ret.root.frontmatter.as_ref().unwrap();
+ assert_eq!(frontmatter.program.body.len(), 1);
+ assert!(matches!(frontmatter.program.body[0], Statement::VariableDeclaration(_)));
+ }
+
+ #[test]
+ fn parse_astro_frontmatter_code_on_opening_line() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ // Code on same line as opening fence
+ let source = "---const x = 1;\n---\n
test
";
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Should have frontmatter with 1 statement
+ assert!(ret.root.frontmatter.is_some());
+ let frontmatter = ret.root.frontmatter.as_ref().unwrap();
+ assert_eq!(frontmatter.program.body.len(), 1);
+ assert!(matches!(frontmatter.program.body[0], Statement::VariableDeclaration(_)));
+ }
+
+ #[test]
+ fn parse_astro_frontmatter_code_on_closing_line() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ // Code on same line as closing fence
+ let source = "---\nconst x = 1;---\n
test
";
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Should have frontmatter with 1 statement
+ assert!(ret.root.frontmatter.is_some());
+ let frontmatter = ret.root.frontmatter.as_ref().unwrap();
+ assert_eq!(frontmatter.program.body.len(), 1);
+ assert!(matches!(frontmatter.program.body[0], Statement::VariableDeclaration(_)));
+ }
+
+ #[test]
+ fn parse_astro_frontmatter_compact() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ // Both opening and closing on same "line" with code
+ let source = "---const x = 1;---
test
";
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Should have frontmatter with 1 statement
+ assert!(ret.root.frontmatter.is_some());
+ let frontmatter = ret.root.frontmatter.as_ref().unwrap();
+ assert_eq!(frontmatter.program.body.len(), 1);
+ assert!(matches!(frontmatter.program.body[0], Statement::VariableDeclaration(_)));
+
+ // Body should have the div element
+ assert!(!ret.root.body.is_empty());
+ assert!(matches!(ret.root.body[0], JSXChild::Element(_)));
+ }
+
+ #[test]
+ fn parse_astro_attribute_with_at_sign() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = r#""#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Body should have one element
+ assert_eq!(ret.root.body.len(), 1);
+ if let JSXChild::Element(element) = &ret.root.body[0] {
+ // Should have one attribute
+ assert_eq!(element.opening_element.attributes.len(), 1);
+ } else {
+ panic!("Expected JSXChild::Element");
+ }
+ }
+
+ #[test]
+ fn parse_astro_attribute_with_colon_self_closing() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = r#""#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Body should have one element
+ assert_eq!(ret.root.body.len(), 1);
+ if let JSXChild::Element(element) = &ret.root.body[0] {
+ // Should have one attribute
+ assert_eq!(element.opening_element.attributes.len(), 1);
+ } else {
+ panic!("Expected JSXChild::Element");
+ }
+ }
+
+ #[test]
+ fn parse_astro_attribute_with_colon_not_self_closing() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = r#""#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Body should have one element
+ assert_eq!(ret.root.body.len(), 1);
+ if let JSXChild::Element(element) = &ret.root.body[0] {
+ // Should have one attribute
+ assert_eq!(element.opening_element.attributes.len(), 1);
+ } else {
+ panic!("Expected JSXChild::Element");
+ }
+ }
+
+ #[test]
+ fn parse_astro_attribute_colon_with_space_two_attrs() {
+ // In Astro: `
` is two attributes: `:` and `hello`
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = r"";
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Body should have one element
+ assert_eq!(ret.root.body.len(), 1);
+ if let JSXChild::Element(element) = &ret.root.body[0] {
+ // Should have TWO attributes: `:` and `hello`
+ assert_eq!(
+ element.opening_element.attributes.len(),
+ 2,
+ "Expected 2 attributes, got {:?}",
+ element.opening_element.attributes
+ );
+ } else {
+ panic!("Expected JSXChild::Element");
+ }
+ }
+
+ #[test]
+ fn parse_astro_attribute_colon_no_space_one_attr() {
+ // In Astro: `
` is one attribute: `:hello`
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = r"";
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Body should have one element
+ assert_eq!(ret.root.body.len(), 1);
+ if let JSXChild::Element(element) = &ret.root.body[0] {
+ // Should have ONE attribute: `:hello`
+ assert_eq!(
+ element.opening_element.attributes.len(),
+ 1,
+ "Expected 1 attribute, got {:?}",
+ element.opening_element.attributes
+ );
+ } else {
+ panic!("Expected JSXChild::Element");
+ }
+ }
+
+ #[test]
+ fn parse_astro_attribute_with_dot() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = r#""#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Body should have one element
+ assert_eq!(ret.root.body.len(), 1);
+ if let JSXChild::Element(element) = &ret.root.body[0] {
+ assert_eq!(element.opening_element.attributes.len(), 1);
+ } else {
+ panic!("Expected JSXChild::Element");
+ }
+ }
+
+ #[test]
+ fn parse_astro_attribute_shorthand() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = r"";
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Body should have one element
+ assert_eq!(ret.root.body.len(), 1);
+ if let JSXChild::Element(element) = &ret.root.body[0] {
+ // Should have one attribute (the shorthand expanded)
+ assert_eq!(element.opening_element.attributes.len(), 1);
+ } else {
+ panic!("Expected JSXChild::Element");
+ }
+ }
+
+ #[test]
+ fn parse_astro_multiple_special_attributes() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = r#""#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Body should have one element with 3 attributes
+ assert_eq!(ret.root.body.len(), 1);
+ if let JSXChild::Element(element) = &ret.root.body[0] {
+ assert_eq!(element.opening_element.attributes.len(), 3);
+ } else {
+ panic!("Expected JSXChild::Element");
+ }
+ }
+
+ #[test]
+ fn parse_astro_block_comment_between_attributes() {
+ // Block comments between attributes are skipped transparently by the lexer.
+ //
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = "";
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ assert_eq!(ret.root.body.len(), 1);
+ if let JSXChild::Element(element) = &ret.root.body[0] {
+ assert_eq!(
+ element.opening_element.attributes.len(),
+ 1,
+ "expected 1 attribute (class), comment should be skipped"
+ );
+ } else {
+ panic!("Expected JSXChild::Element");
+ }
+ }
+
+ #[test]
+ fn parse_astro_block_comment_between_attributes_multiline() {
+ // Multi-line block comment between attributes.
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = "";
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ assert_eq!(ret.root.body.len(), 1);
+ if let JSXChild::Element(element) = &ret.root.body[0] {
+ assert_eq!(element.opening_element.attributes.len(), 1);
+ } else {
+ panic!("Expected JSXChild::Element");
+ }
+ }
+
+ #[test]
+ fn parse_astro_block_comment_between_multiple_attributes() {
+ // Block comment sandwiched between real attributes — all three must survive.
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = "";
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ assert_eq!(ret.root.body.len(), 1);
+ if let JSXChild::Element(element) = &ret.root.body[0] {
+ assert_eq!(
+ element.opening_element.attributes.len(),
+ 2,
+ "expected 2 attributes (id, class), comment should be skipped"
+ );
+ } else {
+ panic!("Expected JSXChild::Element");
+ }
+ }
+
+ #[test]
+ fn parse_astro_line_comment_on_own_line_between_attributes() {
+ // A `//` line comment on its own line between attributes.
+ // The newline that terminates the comment acts as the separator,
+ // so the next attribute is parsed correctly.
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = "";
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ assert_eq!(ret.root.body.len(), 1);
+ if let JSXChild::Element(element) = &ret.root.body[0] {
+ assert_eq!(
+ element.opening_element.attributes.len(),
+ 1,
+ "expected 1 attribute (class), line comment should be skipped"
+ );
+ } else {
+ panic!("Expected JSXChild::Element");
+ }
+ }
+
+ #[test]
+ fn parse_astro_line_comment_inline_after_attribute() {
+ // A `//` line comment at the end of an attribute line.
+ // Everything from `//` to end-of-line is skipped; the next line's
+ // attribute is still parsed normally.
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = "";
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ assert_eq!(ret.root.body.len(), 1);
+ if let JSXChild::Element(element) = &ret.root.body[0] {
+ assert_eq!(
+ element.opening_element.attributes.len(),
+ 2,
+ "expected 2 attributes (class, id); inline line comment should be skipped"
+ );
+ } else {
+ panic!("Expected JSXChild::Element");
+ }
+ }
+
+ #[test]
+ fn parse_astro_template_literal_attribute() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = "";
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Body should have one element with 1 attribute
+ assert_eq!(ret.root.body.len(), 1);
+ if let JSXChild::Element(element) = &ret.root.body[0] {
+ assert_eq!(element.opening_element.attributes.len(), 1);
+ } else {
+ panic!("Expected JSXChild::Element");
+ }
+ }
+
+ #[test]
+ fn parse_astro_template_literal_attribute_with_expression() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ // Template literal with expression interpolation
+ let source = r#"---
+const value = "test";
+---
+"#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Should have frontmatter
+ assert!(ret.root.frontmatter.is_some());
+
+ // Body should have one element with 1 attribute
+ assert_eq!(ret.root.body.len(), 1);
+ if let JSXChild::Element(element) = &ret.root.body[0] {
+ assert_eq!(element.opening_element.attributes.len(), 1);
+ } else {
+ panic!("Expected JSXChild::Element");
+ }
+ }
+
+ #[test]
+ fn parse_astro_unquoted_numeric_attribute() {
+ // Regression test for withastro/compiler-rs#33: unquoted numeric values
+ // (`minlength=4`) used to tokenize as `Kind::Number` and trip the JSX
+ // attribute-value parser's "Unexpected token" branch.
+ use oxc_ast::ast::JSXAttributeValue;
+
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = r#""#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ assert_eq!(ret.root.body.len(), 1);
+ let JSXChild::Element(element) = &ret.root.body[0] else {
+ panic!("Expected JSXChild::Element");
+ };
+ let attrs = &element.opening_element.attributes;
+ assert_eq!(attrs.len(), 3);
+
+ let unquoted_value = |idx: usize| -> &str {
+ let JSXAttributeItem::Attribute(attr) = &attrs[idx] else {
+ panic!("Expected Attribute at {idx}");
+ };
+ let Some(JSXAttributeValue::StringLiteral(str_lit)) = &attr.value else {
+ panic!("Expected StringLiteral value at {idx}, got {:?}", attr.value);
+ };
+ str_lit.value.as_str()
+ };
+
+ assert_eq!(unquoted_value(1), "4");
+ assert_eq!(unquoted_value(2), "255");
+ }
+
+ #[test]
+ fn parse_astro_unquoted_attribute_with_dashes_and_hash() {
+ // The previous `Kind::Ident` branch only captured the leading identifier,
+ // so `data-id=hello-world` ended up as `"hello"` and `color=#abc123`
+ // failed to parse. The HTML-style unquoted reader handles both.
+ use oxc_ast::ast::JSXAttributeValue;
+
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = r#"
x
"#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ let JSXChild::Element(element) = &ret.root.body[0] else {
+ panic!("Expected JSXChild::Element");
+ };
+ let attrs = &element.opening_element.attributes;
+ assert_eq!(attrs.len(), 2);
+
+ let unquoted_value = |idx: usize| -> &str {
+ let JSXAttributeItem::Attribute(attr) = &attrs[idx] else {
+ panic!("Expected Attribute at {idx}");
+ };
+ let Some(JSXAttributeValue::StringLiteral(str_lit)) = &attr.value else {
+ panic!("Expected StringLiteral value at {idx}, got {:?}", attr.value);
+ };
+ str_lit.value.as_str()
+ };
+
+ assert_eq!(unquoted_value(0), "hello-world");
+ assert_eq!(unquoted_value(1), "#abc123");
+ }
+
+ #[test]
+ fn parse_astro_unquoted_attribute_then_self_closing() {
+ // The unquoted reader must stop at `/` so `` still self-closes.
+ use oxc_ast::ast::JSXAttributeValue;
+
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = r#""#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ let JSXChild::Element(element) = &ret.root.body[0] else {
+ panic!("Expected JSXChild::Element");
+ };
+ assert!(element.closing_element.is_none(), "expected self-closing element");
+ let attrs = &element.opening_element.attributes;
+ assert_eq!(attrs.len(), 1);
+
+ let JSXAttributeItem::Attribute(attr) = &attrs[0] else {
+ panic!("Expected Attribute");
+ };
+ let Some(JSXAttributeValue::StringLiteral(str_lit)) = &attr.value else {
+ panic!("Expected StringLiteral value, got {:?}", attr.value);
+ };
+ assert_eq!(str_lit.value.as_str(), "4");
+ }
+
+ #[test]
+ fn parse_astro_quoted_and_expression_attributes_still_work() {
+ // The new unquoted-value path must not regress string or expression attributes.
+ use oxc_ast::ast::JSXAttributeValue;
+
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = r#""#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ let JSXChild::Element(element) = &ret.root.body[0] else {
+ panic!("Expected JSXChild::Element");
+ };
+ let attrs = &element.opening_element.attributes;
+ assert_eq!(attrs.len(), 3);
+
+ let JSXAttributeItem::Attribute(type_attr) = &attrs[0] else {
+ panic!("Expected Attribute at 0");
+ };
+ assert!(matches!(type_attr.value, Some(JSXAttributeValue::StringLiteral(_))));
+
+ let JSXAttributeItem::Attribute(value_attr) = &attrs[1] else {
+ panic!("Expected Attribute at 1");
+ };
+ assert!(matches!(value_attr.value, Some(JSXAttributeValue::ExpressionContainer(_))));
+
+ let JSXAttributeItem::Attribute(other_attr) = &attrs[2] else {
+ panic!("Expected Attribute at 2");
+ };
+ assert!(matches!(other_attr.value, Some(JSXAttributeValue::StringLiteral(_))));
+ }
+
+ #[test]
+ fn parse_astro_unquoted_attribute_in_middle_of_list() {
+ // Sibling attributes on either side must still parse across the lexer rewind.
+ use oxc_ast::ast::JSXAttributeValue;
+
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = r#""#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ let JSXChild::Element(element) = &ret.root.body[0] else {
+ panic!("Expected JSXChild::Element");
+ };
+ let attrs = &element.opening_element.attributes;
+ assert_eq!(attrs.len(), 4);
+
+ let attr = |idx: usize| -> &oxc_ast::ast::JSXAttribute<'_> {
+ let JSXAttributeItem::Attribute(a) = &attrs[idx] else {
+ panic!("Expected Attribute at {idx}");
+ };
+ a
+ };
+
+ let JSXAttributeName::Identifier(name) = &attr(0).name else {
+ panic!("expected ident name");
+ };
+ assert_eq!(name.name.as_str(), "id");
+ assert!(matches!(attr(0).value, Some(JSXAttributeValue::StringLiteral(_))));
+
+ let JSXAttributeName::Identifier(name) = &attr(1).name else {
+ panic!("expected ident name");
+ };
+ assert_eq!(name.name.as_str(), "class");
+ let Some(JSXAttributeValue::StringLiteral(class_lit)) = &attr(1).value else {
+ panic!("class should be quoted string");
+ };
+ assert_eq!(class_lit.value.as_str(), "bar");
+
+ let JSXAttributeName::Identifier(name) = &attr(2).name else {
+ panic!("expected ident name");
+ };
+ assert_eq!(name.name.as_str(), "data-x");
+ let Some(JSXAttributeValue::StringLiteral(data_lit)) = &attr(2).value else {
+ panic!("data-x should produce a string literal");
+ };
+ assert_eq!(data_lit.value.as_str(), "42");
+
+ let JSXAttributeName::Identifier(name) = &attr(3).name else {
+ panic!("expected ident name");
+ };
+ assert_eq!(name.name.as_str(), "disabled");
+ assert!(attr(3).value.is_none(), "boolean attr should have no value");
+ }
+
+ #[test]
+ fn parse_astro_unquoted_attribute_with_children() {
+ // The unquoted reader must stop at `>` so children parse, not get swallowed.
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = r#"
hello
"#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ let JSXChild::Element(element) = &ret.root.body[0] else {
+ panic!("Expected JSXChild::Element");
+ };
+ assert!(element.closing_element.is_some(), "expected non-self-closing");
+ assert_eq!(element.opening_element.attributes.len(), 1);
+ assert_eq!(element.children.len(), 1);
+ assert!(matches!(element.children[0], JSXChild::Text(_)));
+ }
+
+ #[test]
+ fn parse_astro_unquoted_attribute_value_stops_at_newline() {
+ // An unquoted value must terminate at whitespace, including newlines.
+ // Otherwise multi-line attribute lists would silently swallow the
+ // following attribute names.
+ use oxc_ast::ast::JSXAttributeValue;
+
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = "";
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ let JSXChild::Element(element) = &ret.root.body[0] else {
+ panic!("Expected JSXChild::Element");
+ };
+ let attrs = &element.opening_element.attributes;
+ assert_eq!(attrs.len(), 2);
+
+ let attr_value = |idx: usize| -> &str {
+ let JSXAttributeItem::Attribute(a) = &attrs[idx] else {
+ panic!("Expected Attribute at {idx}");
+ };
+ let Some(JSXAttributeValue::StringLiteral(lit)) = &a.value else {
+ panic!("expected string literal at {idx}");
+ };
+ lit.value.as_str()
+ };
+ assert_eq!(attr_value(0), "42");
+ assert_eq!(attr_value(1), "99");
+ }
+
+ #[test]
+ fn parse_astro_unquoted_value_has_correct_span() {
+ // The lexer rewind in the unquoted-value path must produce a span that
+ // exactly covers the value characters in the source — not the wider
+ // span the JS lexer would have produced for a partial token.
+ use oxc_ast::ast::JSXAttributeValue;
+ use oxc_span::GetSpan;
+
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = r#"
x
"#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ let JSXChild::Element(element) = &ret.root.body[0] else {
+ panic!("Expected JSXChild::Element");
+ };
+ let JSXAttributeItem::Attribute(attr) = &element.opening_element.attributes[0] else {
+ panic!("Expected Attribute");
+ };
+ let Some(JSXAttributeValue::StringLiteral(str_lit)) = &attr.value else {
+ panic!("Expected StringLiteral");
+ };
+
+ let value_start = source.find("hello-world").unwrap() as u32;
+ let value_end = value_start + "hello-world".len() as u32;
+ assert_eq!(str_lit.span().start, value_start);
+ assert_eq!(str_lit.span().end, value_end);
+ }
+
+ #[test]
+ fn parse_astro_empty_value_reports_error_at_terminator() {
+ // `` is malformed: `attr=` with no value. The diagnostic
+ // must point at the `/`, not silently consume it as the value — that
+ // would break self-closing and surface the error far from the cause.
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = r#""#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+
+ assert!(!ret.errors.is_empty(), "expected a diagnostic for empty unquoted value");
+
+ let slash_offset = source.find('/').unwrap() as u32;
+ assert!(
+ ret.errors.iter().any(|e| e
+ .labels
+ .as_ref()
+ .is_some_and(|labels| labels.iter().any(|l| l.offset() as u32 == slash_offset))),
+ "expected diagnostic at offset {slash_offset}, got {:?}",
+ ret.errors
+ );
+ }
+
+ #[test]
+ fn parse_astro_void_element_without_closing() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ // HTML void elements don't need to be self-closed
+ let source = r#" "#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Body should have 3 elements
+ assert_eq!(ret.root.body.len(), 3);
+ assert!(matches!(ret.root.body[0], JSXChild::Element(_)));
+ assert!(matches!(ret.root.body[1], JSXChild::Element(_)));
+ assert!(matches!(ret.root.body[2], JSXChild::Element(_)));
+ }
+
+ #[test]
+ fn parse_astro_void_element_self_closed() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ // Self-closing void elements should also work
+ let source = r#" "#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Body should have 3 elements
+ assert_eq!(ret.root.body.len(), 3);
+ }
+
+ #[test]
+ fn parse_astro_void_elements_mixed_with_content() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ let source = r#"
+
+
+
+
+
"#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Body should have 1 div element
+ assert_eq!(ret.root.body.len(), 1);
+ if let JSXChild::Element(element) = &ret.root.body[0] {
+ // The div should have children (text nodes, input, label, br, img, etc.)
+ assert!(!element.children.is_empty());
+ } else {
+ panic!("Expected JSXChild::Element");
+ }
+ }
+
+ #[test]
+ fn parse_astro_bare_script_parsed_as_typescript() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ // Bare "#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Body should have one "#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Body should have one "#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Body should have one JSX Element with raw text content (not AstroScript)
+ assert_eq!(ret.root.body.len(), 1);
+ match &ret.root.body[0] {
+ JSXChild::Element(element) => {
+ if let JSXElementName::Identifier(ident) = &element.opening_element.name {
+ assert_eq!(ident.name.as_str(), "script");
+ } else {
+ panic!("Expected Identifier for script element");
+ }
+ // Should have text child (raw, unparsed content)
+ assert_eq!(element.children.len(), 1);
+ assert!(matches!(element.children[0], JSXChild::Text(_)));
+ }
+ other => panic!("Expected Element, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn parse_astro_script_defer_is_parsed() {
+ let allocator = Allocator::default();
+ let source_type = SourceType::astro();
+ // "#;
+ let ret = Parser::new(&allocator, source, source_type).parse_astro();
+ assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
+ assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
+
+ // Body should have one
+
+
+
+
+