@@ -76,14 +76,16 @@ fn scan_astro_frontmatter(source_text: &str) -> Option<AstroFrontmatterInfo> {
7676 Some ( AstroFrontmatterInfo { content_start, content_end, frontmatter_end, body_start } )
7777}
7878
79- /// Find the position of the closing `---` fence, skipping over strings, template literals, and comments.
79+ /// Find the position of the closing `---` fence, skipping over strings, template literals,
80+ /// comments, and regex literals.
8081///
8182/// This uses a simple state machine to track whether we're inside:
8283/// - Single-quoted strings ('...')
8384/// - Double-quoted strings ("...")
8485/// - Template literals (`...`)
8586/// - Line comments (// ...)
8687/// - Block comments (/* ... */)
88+ /// - Regex literals (/.../)
8789///
8890/// Returns the byte offset of the closing `---` in the search area, or None if not found.
8991fn find_closing_fence ( search_area : & str ) -> Option < usize > {
@@ -95,13 +97,23 @@ fn find_closing_fence(search_area: &str) -> Option<usize> {
9597 TemplateLiteral ,
9698 LineComment ,
9799 BlockComment ,
100+ Regex ,
101+ /// Inside a character class `[...]` within a regex literal.
102+ RegexCharClass ,
98103 }
99104
100105 let bytes = search_area. as_bytes ( ) ;
101106 let len = bytes. len ( ) ;
102107 let mut state = State :: Normal ;
103108 let mut i = 0 ;
104109
110+ // Track whether a `/` should be interpreted as starting a regex literal vs division.
111+ // After these characters, `/` is the start of a regex literal:
112+ // punctuation/operators that cannot end an expression value.
113+ // After identifiers, numbers, `)`, `]`, `++`, `--`, `/` is division.
114+ // We track this with a simple flag: `true` means the next `/` could be a regex start.
115+ let mut slash_is_regex = true ;
116+
105117 while i < len {
106118 let b = bytes[ i] ;
107119
@@ -112,25 +124,50 @@ fn find_closing_fence(search_area: &str) -> Option<usize> {
112124 return Some ( i) ;
113125 }
114126
115- // Check for string/comment starts
127+ // Check for string/comment/regex starts
116128 match b {
117- b'\'' => state = State :: SingleQuote ,
118- b'"' => state = State :: DoubleQuote ,
119- b'`' => state = State :: TemplateLiteral ,
120- b'/' if i + 1 < len => {
121- match bytes[ i + 1 ] {
122- b'/' => {
123- state = State :: LineComment ;
124- i += 1 ; // Skip the second '/'
125- }
126- b'*' => {
127- state = State :: BlockComment ;
128- i += 1 ; // Skip the '*'
129- }
130- _ => { }
131- }
129+ b'\'' => {
130+ state = State :: SingleQuote ;
131+ slash_is_regex = true ;
132+ }
133+ b'"' => {
134+ state = State :: DoubleQuote ;
135+ slash_is_regex = true ;
136+ }
137+ b'`' => {
138+ state = State :: TemplateLiteral ;
139+ slash_is_regex = true ;
140+ }
141+ b'/' if i + 1 < len && bytes[ i + 1 ] == b'/' => {
142+ state = State :: LineComment ;
143+ i += 1 ; // Skip the second '/'
144+ }
145+ b'/' if i + 1 < len && bytes[ i + 1 ] == b'*' => {
146+ state = State :: BlockComment ;
147+ i += 1 ; // Skip the '*'
148+ }
149+ b'/' if slash_is_regex => {
150+ state = State :: Regex ;
151+ }
152+ // Characters that indicate the next `/` is a regex (not division):
153+ // operators, punctuation, keywords that precede expressions
154+ b'=' | b'(' | b'[' | b'{' | b'}' | b';' | b',' | b'!' | b'&' | b'|' | b'^'
155+ | b'~' | b'?' | b':' | b'<' | b'>' | b'+' | b'-' | b'*' | b'%' | b'\n'
156+ | b'\r' => {
157+ slash_is_regex = true ;
158+ }
159+ // After identifiers, numbers, `)`, `]`, `/` is division
160+ b')' | b']' => {
161+ slash_is_regex = false ;
162+ }
163+ b if b. is_ascii_alphanumeric ( ) || b == b'_' || b == b'$' => {
164+ slash_is_regex = false ;
165+ }
166+ // Whitespace doesn't change the slash_is_regex flag
167+ b' ' | b'\t' => { }
168+ _ => {
169+ slash_is_regex = true ;
132170 }
133- _ => { }
134171 }
135172 }
136173
@@ -140,8 +177,12 @@ fn find_closing_fence(search_area: &str) -> Option<usize> {
140177 i += 1 ;
141178 } else if b == b'\'' {
142179 state = State :: Normal ;
180+ slash_is_regex = false ;
181+ } else if b == b'\n' || b == b'\r' {
182+ // Newline ends single-quote string (syntax error in JS, but we recover)
183+ state = State :: Normal ;
184+ slash_is_regex = true ;
143185 }
144- // Note: newline ends single-quote string (syntax error in JS, but we continue)
145186 }
146187
147188 State :: DoubleQuote => {
@@ -150,8 +191,12 @@ fn find_closing_fence(search_area: &str) -> Option<usize> {
150191 i += 1 ;
151192 } else if b == b'"' {
152193 state = State :: Normal ;
194+ slash_is_regex = false ;
195+ } else if b == b'\n' || b == b'\r' {
196+ // Newline ends double-quote string (syntax error in JS, but we recover)
197+ state = State :: Normal ;
198+ slash_is_regex = true ;
153199 }
154- // Note: newline ends double-quote string (syntax error in JS, but we continue)
155200 }
156201
157202 State :: TemplateLiteral => {
@@ -160,6 +205,7 @@ fn find_closing_fence(search_area: &str) -> Option<usize> {
160205 i += 1 ;
161206 } else if b == b'`' {
162207 state = State :: Normal ;
208+ slash_is_regex = false ;
163209 }
164210 // Note: template literals CAN span multiple lines, so no newline handling
165211 // We also don't track ${...} interpolations - `---` inside interpolation
@@ -170,6 +216,7 @@ fn find_closing_fence(search_area: &str) -> Option<usize> {
170216 // Line comment ends at newline
171217 if b == b'\n' {
172218 state = State :: Normal ;
219+ slash_is_regex = true ;
173220 }
174221 }
175222
@@ -178,6 +225,42 @@ fn find_closing_fence(search_area: &str) -> Option<usize> {
178225 if b == b'*' && i + 1 < len && bytes[ i + 1 ] == b'/' {
179226 state = State :: Normal ;
180227 i += 1 ; // Skip the '/'
228+ // Don't change slash_is_regex - preserve the context from before the comment
229+ }
230+ }
231+
232+ State :: Regex => {
233+ if b == b'\\' && i + 1 < len {
234+ // Skip escaped character in regex
235+ i += 1 ;
236+ } else if b == b'[' {
237+ // Enter character class - `/` inside `[...]` doesn't end the regex
238+ state = State :: RegexCharClass ;
239+ } else if b == b'/' {
240+ // End of regex literal - skip optional flags (g, i, m, s, u, v, y, d)
241+ state = State :: Normal ;
242+ slash_is_regex = false ;
243+ while i + 1 < len && bytes[ i + 1 ] . is_ascii_alphabetic ( ) {
244+ i += 1 ;
245+ }
246+ } else if b == b'\n' || b == b'\r' {
247+ // Regex can't span lines - this was actually a division, recover
248+ state = State :: Normal ;
249+ slash_is_regex = true ;
250+ }
251+ }
252+
253+ State :: RegexCharClass => {
254+ if b == b'\\' && i + 1 < len {
255+ // Skip escaped character in character class
256+ i += 1 ;
257+ } else if b == b']' {
258+ // End of character class, back to regex body
259+ state = State :: Regex ;
260+ } else if b == b'\n' || b == b'\r' {
261+ // Regex can't span lines - recover
262+ state = State :: Normal ;
263+ slash_is_regex = true ;
181264 }
182265 }
183266 }
0 commit comments