Skip to content

Commit c429ef2

Browse files
committed
feat: parse unquoted attribute values using Astro rules
1 parent 53abf55 commit c429ef2

2 files changed

Lines changed: 67 additions & 12 deletions

File tree

crates/oxc_parser/src/astro/jsx.rs

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,9 @@ impl<'a, C: ParserConfig> ParserImpl<'a, C> {
449449
continue;
450450
} else {
451451
// Any other `{expr}` (e.g. `{{ answer: 1 }}`) is shorthand
452-
JSXAttributeItem::Attribute(self.parse_astro_expression_shorthand_attribute())
452+
JSXAttributeItem::Attribute(
453+
self.parse_astro_expression_shorthand_attribute(),
454+
)
453455
}
454456
}
455457
// Quotes can appear in Astro attribute names
@@ -603,18 +605,41 @@ impl<'a, C: ParserConfig> ParserImpl<'a, C> {
603605
let identifier = self.ast.jsx_identifier(name_span, name);
604606
let attr_name = JSXAttributeName::Identifier(self.alloc(identifier));
605607

606-
let value = if self.at(Kind::Eq) {
607-
self.expect_jsx_attribute_value(Kind::Eq);
608-
Some(self.parse_astro_attribute_value())
609-
} else {
610-
None
611-
};
608+
let value = if self.at(Kind::Eq) { Some(self.parse_astro_attribute_value()) } else { None };
612609

613610
self.ast.alloc_jsx_attribute(self.end_span(span), attr_name, value)
614611
}
615612

616-
/// Parse an Astro attribute value, which can include template literals and unquoted values.
613+
/// Parse an Astro attribute value. Called while positioned at `=`.
614+
///
615+
/// The lexing mode is chosen from the raw bytes after `=` *before* the JS
616+
/// lexer runs, so unquoted HTML values reach the Astro reader instead of
617+
/// being rejected as malformed JS tokens (e.g. `color=#18b218`). Quoted
618+
/// strings, `{expr}` and templates still lex as JS/JSX.
617619
fn parse_astro_attribute_value(&mut self) -> JSXAttributeValue<'a> {
620+
let bytes = self.source_text.as_bytes();
621+
let mut value_start = self.cur_token().end() as usize;
622+
while matches!(bytes.get(value_start), Some(b' ' | b'\t' | b'\r' | b'\n')) {
623+
value_start += 1;
624+
}
625+
// A structural terminator (`/`, `>`, `}`, EOF) means the value is missing
626+
// (`attr=` before `/>`); fall through to the JS path to report it there.
627+
let is_unquoted = bytes
628+
.get(value_start)
629+
.is_some_and(|b| !matches!(b, b'"' | b'\'' | b'{' | b'`' | b'<' | b'/' | b'>' | b'}'));
630+
631+
if is_unquoted {
632+
self.prev_token_end = self.cur_token().end(); // consume `=`
633+
self.lexer.set_position_for_astro(value_start as u32);
634+
self.read_astro_unquoted_attribute_value();
635+
let value_span = self.cur_token().span();
636+
let value = Atom::from(value_span.source_text(self.source_text));
637+
self.bump_any();
638+
let str_lit = self.ast.string_literal(value_span, value, None);
639+
return JSXAttributeValue::StringLiteral(self.alloc(str_lit));
640+
}
641+
642+
self.expect_jsx_attribute_value(Kind::Eq);
618643
match self.cur_kind() {
619644
Kind::NoSubstitutionTemplate | Kind::TemplateHead => {
620645
let span = self.start_span();
@@ -634,10 +659,9 @@ impl<'a, C: ParserConfig> ParserImpl<'a, C> {
634659
JSXAttributeValue::ExpressionContainer(expr)
635660
}
636661
Kind::Str | Kind::LAngle => self.parse_jsx_attribute_value(),
637-
// Unquoted HTML value: re-lex from token start under HTML rules so
638-
// the JS lexer doesn't reject `4` or split `hello-world`. Bail out
639-
// when the cur byte is a structural terminator so `attr=` followed
640-
// by `/>` reports the error at the `/` instead of swallowing it.
662+
// Reached only via the JS path for a token that isn't a recognized
663+
// value start (e.g. a comment before the value). Bail on a structural
664+
// terminator, otherwise re-read under HTML rules.
641665
_ => {
642666
let token_start = self.cur_token().span().start as usize;
643667
let starts_with_terminator = self

crates/oxc_parser/src/astro/mod.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1566,6 +1566,37 @@ const value = "test";
15661566
assert_eq!(unquoted_value(1), "#abc123");
15671567
}
15681568

1569+
#[test]
1570+
fn parse_astro_unquoted_attribute_hash_color_with_digit() {
1571+
use oxc_ast::ast::JSXAttributeValue;
1572+
1573+
let allocator = Allocator::default();
1574+
let source_type = SourceType::astro();
1575+
let source = r"<div color=#18b218 background=#686868>x</div>";
1576+
let ret = Parser::new(&allocator, source, source_type).parse_astro();
1577+
assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
1578+
assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
1579+
1580+
let JSXChild::Element(element) = &ret.root.body[0] else {
1581+
panic!("Expected JSXChild::Element");
1582+
};
1583+
let attrs = &element.opening_element.attributes;
1584+
assert_eq!(attrs.len(), 2);
1585+
1586+
let unquoted_value = |idx: usize| -> &str {
1587+
let JSXAttributeItem::Attribute(attr) = &attrs[idx] else {
1588+
panic!("Expected Attribute at {idx}");
1589+
};
1590+
let Some(JSXAttributeValue::StringLiteral(str_lit)) = &attr.value else {
1591+
panic!("Expected StringLiteral value at {idx}, got {:?}", attr.value);
1592+
};
1593+
str_lit.value.as_str()
1594+
};
1595+
1596+
assert_eq!(unquoted_value(0), "#18b218");
1597+
assert_eq!(unquoted_value(1), "#686868");
1598+
}
1599+
15691600
#[test]
15701601
fn parse_astro_unquoted_attribute_then_self_closing() {
15711602
// The unquoted reader must stop at `/` so `<input value=4/>` still self-closes.

0 commit comments

Comments
 (0)