@@ -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
0 commit comments