@@ -738,6 +738,16 @@ class _Tokenizer {
738738
739739 private _consumeEntity ( textTokenType : TokenType ) : void {
740740 this . _beginToken ( TokenType . ENCODED_ENTITY ) ;
741+ const start = this . _cursor . clone ( ) ;
742+ const parts = this . _readEntity ( ) ;
743+ if ( parts . length === 1 ) {
744+ this . _beginToken ( textTokenType , start ) ;
745+ }
746+ this . _endToken ( parts ) ;
747+ }
748+
749+ /** Consume an HTML entity without emitting a token. */
750+ private _readEntity ( ) : [ string ] | [ string , string ] {
741751 const start = this . _cursor . clone ( ) ;
742752 this . _cursor . advance ( ) ;
743753 if ( this . _attemptCharCode ( chars . $HASH ) ) {
@@ -758,7 +768,7 @@ class _Tokenizer {
758768 this . _cursor . advance ( ) ;
759769 try {
760770 const charCode = parseInt ( strNum , isHex ? 16 : 10 ) ;
761- this . _endToken ( [ String . fromCodePoint ( charCode ) , this . _cursor . getChars ( start ) ] ) ;
771+ return [ String . fromCodePoint ( charCode ) , this . _cursor . getChars ( start ) ] ;
762772 } catch {
763773 throw this . _createError (
764774 _unknownEntityErrorMsg ( this . _cursor . getChars ( start ) ) ,
@@ -769,25 +779,39 @@ class _Tokenizer {
769779 const nameStart = this . _cursor . clone ( ) ;
770780 this . _attemptCharCodeUntilFn ( isNamedEntityEnd ) ;
771781 if ( this . _cursor . peek ( ) != chars . $SEMICOLON ) {
772- // No semicolon was found so abort the encoded entity token that was in progress, and treat
773- // this as a text token
774- this . _beginToken ( textTokenType , start ) ;
782+ // Without a semicolon, only the ampersand is consumed as plain text.
775783 this . _cursor = nameStart ;
776- this . _endToken ( [ '&' ] ) ;
784+ return [ '&' ] ;
777785 } else {
778786 const name = this . _cursor . getChars ( nameStart ) ;
779787 this . _cursor . advance ( ) ;
780788 const char = Object . hasOwn ( NAMED_ENTITIES , name ) && NAMED_ENTITIES [ name ] ;
781789 if ( ! char ) {
782790 throw this . _createError ( _unknownEntityErrorMsg ( name ) , this . _cursor . getSpan ( start ) ) ;
783791 }
784- this . _endToken ( [ char , `&${ name } ;` ] ) ;
792+ return [ char , `&${ name } ;` ] ;
785793 }
786794 }
787795 }
788796
789797 private _consumeRawText ( consumeEntities : boolean , endMarkerPredicate : ( ) => boolean ) : void {
790- this . _beginToken ( consumeEntities ? TokenType . ESCAPABLE_RAW_TEXT : TokenType . RAW_TEXT ) ;
798+ if ( consumeEntities ) {
799+ const isRawTextEnd = ( ) => {
800+ const start = this . _cursor . clone ( ) ;
801+ const foundEndMarker = endMarkerPredicate ( ) ;
802+ this . _cursor = start ;
803+ return foundEndMarker ;
804+ } ;
805+ this . _consumeWithInterpolation (
806+ TokenType . ESCAPABLE_RAW_TEXT ,
807+ TokenType . INTERPOLATION ,
808+ isRawTextEnd ,
809+ isRawTextEnd ,
810+ ) ;
811+ return ;
812+ }
813+
814+ this . _beginToken ( TokenType . RAW_TEXT ) ;
791815 const parts : string [ ] = [ ] ;
792816 while ( true ) {
793817 const tagCloseStart = this . _cursor . clone ( ) ;
@@ -796,14 +820,7 @@ class _Tokenizer {
796820 if ( foundEndMarker ) {
797821 break ;
798822 }
799- if ( consumeEntities && this . _cursor . peek ( ) === chars . $AMPERSAND ) {
800- this . _endToken ( [ this . _processCarriageReturns ( parts . join ( '' ) ) ] ) ;
801- parts . length = 0 ;
802- this . _consumeEntity ( TokenType . ESCAPABLE_RAW_TEXT ) ;
803- this . _beginToken ( TokenType . ESCAPABLE_RAW_TEXT ) ;
804- } else {
805- parts . push ( this . _readChar ( ) ) ;
806- }
823+ parts . push ( this . _readChar ( ) ) ;
807824 }
808825 this . _endToken ( [ this . _processCarriageReturns ( parts . join ( '' ) ) ] ) ;
809826 }
@@ -1324,7 +1341,12 @@ class _Tokenizer {
13241341 if ( this . _attemptStr ( INTERPOLATION . start ) ) {
13251342 this . _endToken ( [ this . _processCarriageReturns ( parts . join ( '' ) ) ] , current ) ;
13261343 parts . length = 0 ;
1327- this . _consumeInterpolation ( interpolationTokenType , current , endInterpolation ) ;
1344+ this . _consumeInterpolation (
1345+ interpolationTokenType ,
1346+ current ,
1347+ endInterpolation ,
1348+ textTokenType ,
1349+ ) ;
13281350 this . _beginToken ( textTokenType ) ;
13291351 } else if ( this . _cursor . peek ( ) === chars . $AMPERSAND ) {
13301352 this . _endToken ( [ this . _processCarriageReturns ( parts . join ( '' ) ) ] ) ;
@@ -1352,11 +1374,13 @@ class _Tokenizer {
13521374 * @param interpolationStart a cursor that points to the start of this interpolation.
13531375 * @param prematureEndPredicate a function that should return true if the next characters indicate
13541376 * an end to the interpolation before its normal closing marker.
1377+ * @param textTokenType the surrounding text type, which determines whether markup is literal.
13551378 */
13561379 private _consumeInterpolation (
13571380 interpolationTokenType : TokenType ,
13581381 interpolationStart : CharacterCursor ,
13591382 prematureEndPredicate : ( ( ) => boolean ) | null ,
1383+ textTokenType : TokenType ,
13601384 ) : void {
13611385 const parts : string [ ] = [ ] ;
13621386 this . _beginToken ( interpolationTokenType , interpolationStart ) ;
@@ -1372,7 +1396,7 @@ class _Tokenizer {
13721396 ) {
13731397 const current = this . _cursor . clone ( ) ;
13741398
1375- if ( this . _isTagStart ( ) ) {
1399+ if ( textTokenType !== TokenType . ESCAPABLE_RAW_TEXT && this . _isTagStart ( ) ) {
13761400 // We are starting what looks like an HTML element in the middle of this interpolation.
13771401 // Reset the cursor to before the `<` character and end the interpolation token.
13781402 // (This is actually wrong but here for backward compatibility).
@@ -1396,8 +1420,22 @@ class _Tokenizer {
13961420 }
13971421
13981422 const char = this . _cursor . peek ( ) ;
1423+ if ( textTokenType === TokenType . ESCAPABLE_RAW_TEXT && char === chars . $AMPERSAND ) {
1424+ this . _readEntity ( ) ;
1425+ continue ;
1426+ }
13991427 this . _cursor . advance ( ) ;
14001428 if ( char === chars . $BACKSLASH ) {
1429+ if ( textTokenType === TokenType . ESCAPABLE_RAW_TEXT ) {
1430+ // String escapes cannot hide a closing tag or bypass HTML entity validation in RCDATA.
1431+ if ( prematureEndPredicate ?.( ) ) {
1432+ continue ;
1433+ }
1434+ if ( this . _cursor . peek ( ) === chars . $AMPERSAND ) {
1435+ this . _readEntity ( ) ;
1436+ continue ;
1437+ }
1438+ }
14011439 // Skip the next character because it was escaped.
14021440 this . _cursor . advance ( ) ;
14031441 } else if ( char === inQuote ) {
0 commit comments