33 * Useful for streaming/incremental parsing where content may be partial
44 */
55
6+ // Pre-compiled regex patterns for better performance
7+ const PATTERNS = {
8+ trailingWhitespace : / \s + $ / ,
9+ trailingAsterisks : / \* + $ / ,
10+ asteriskGlobal : / \* / g,
11+ doubleQuoteGlobal : / " / g,
12+ singleQuoteGlobal : / ' / g,
13+ tildeGlobal : / ~ ~ / g,
14+ } as const
15+
16+ // Pre-generated colon closers for common nesting depths (avoids repeat() calls)
17+ const COLON_CLOSERS = [ '' , ':' , '::' , ':::' , '::::' , ':::::' , '::::::' , ':::::::' ] as const
18+
619/**
720 * Detects and auto-closes unclosed markdown inline syntax and MDC components
821 *
@@ -21,13 +34,19 @@ export function autoCloseMarkdown(markdown: string): string {
2134 return markdown
2235 }
2336
24- let result = markdown
37+ // Split once and share between functions to avoid redundant splitting
38+ const lines = markdown . split ( '\n' )
39+ const lastLine = lines [ lines . length - 1 ]
2540
2641 // Step 1: Auto-close inline markdown syntax
27- result = autoCloseInlineSyntax ( result )
42+ let result = autoCloseInlineSyntax ( markdown , lastLine )
2843
29- // Step 2: Auto-close MDC block components
30- result = autoCloseMDCComponents ( result )
44+ // Step 2: Auto-close MDC block components (only if content changed or has components)
45+ if ( result . includes ( '::' ) ) {
46+ // Re-split only if markdown was modified by inline closing
47+ const updatedLines = result === markdown ? lines : result . split ( '\n' )
48+ result = autoCloseMDCComponents ( result , updatedLines )
49+ }
3150
3251 return result
3352}
@@ -36,13 +55,10 @@ export function autoCloseMarkdown(markdown: string): string {
3655 * Auto-closes unclosed inline markdown syntax (bold, italic, code, strikethrough)
3756 * Only closes markers that appear to be incomplete at the end of content
3857 */
39- function autoCloseInlineSyntax ( markdown : string ) : string {
58+ function autoCloseInlineSyntax ( markdown : string , lastLine : string ) : string {
4059 // Track what needs closing by scanning from the end
4160 // This prevents closing markers that are intentionally left open in the middle
4261
43- const lines = markdown . split ( '\n' )
44- const lastLine = lines [ lines . length - 1 ]
45-
4662 // Define markers in order (bold+italic, then bold, then italic to avoid conflicts)
4763 const markers = [
4864 { marker : '***' , pattern : / \* \* \* (?: [ ^ * ] | \* (? ! \* \* ) | \* \* (? ! \* ) ) * $ / } , // bold+italic (strong emphasis)
@@ -55,23 +71,82 @@ function autoCloseInlineSyntax(markdown: string): string {
5571 let closingSuffix = ''
5672 let trimTrailing = false
5773
74+ // Pre-compute values used multiple times
75+ const hasTrailingWhitespace = PATTERNS . trailingWhitespace . test ( lastLine )
76+ const trimmedLastLine = hasTrailingWhitespace ? lastLine . trimEnd ( ) : lastLine
77+
5878 // Check each marker
5979 for ( const { marker, pattern } of markers ) {
6080 if ( pattern . test ( lastLine ) ) {
61- // Count occurrences in the last line
62- const escapedMarker = escapeRegex ( marker )
63- const markerRegex = new RegExp ( escapedMarker , 'g' )
64- const count = ( lastLine . match ( markerRegex ) || [ ] ) . length
65-
66- // If odd number of markers, we have an unclosed one
67- if ( count % 2 === 1 ) {
68- // Check if content ends with whitespace before we close
69- // But preserve whitespace for inline code (spaces are significant in code)
70- if ( marker !== '`' && / \s + $ / . test ( lastLine ) ) {
71- trimTrailing = true
81+ const markerLen = marker . length
82+ const markerChar = marker [ 0 ]
83+
84+ // For asterisk-based markers (*, **, ***), handle partial closings
85+ if ( markerChar === '*' ) {
86+ // Check if line ends with asterisks (indicates partial closing)
87+ const endsWithAsterisks = PATTERNS . trailingAsterisks . test ( trimmedLastLine )
88+
89+ // Count total asterisks in the line (use pre-compiled regex)
90+ const asteriskCount = ( lastLine . match ( PATTERNS . asteriskGlobal ) || [ ] ) . length
91+
92+ // Calculate remainder when dividing by (markerLen * 2)
93+ // markerLen * 2 represents one complete open + close pair
94+ const remainder = asteriskCount % ( markerLen * 2 )
95+
96+ // If remainder is 0, this marker is properly closed, skip it
97+ if ( remainder === 0 ) {
98+ continue
99+ }
100+
101+ // If remainder equals markerLen, we have exactly one opening marker
102+ if ( remainder === markerLen ) {
103+ // Check if line starts with more asterisks than this marker (e.g., *** when checking **)
104+ // This prevents "***text***" from being seen as unclosed **
105+ const startsWithMoreAsterisks = new RegExp ( '^\\*{' + ( markerLen + 1 ) + ',}' ) . test ( lastLine )
106+ if ( startsWithMoreAsterisks ) {
107+ continue // This line uses a different (longer) marker
108+ }
109+
110+ // Additional check: ensure line doesn't already have complete pairs and end with non-asterisk
111+ // This prevents false positives like "**bold** and *italic*" being seen as unclosed **
112+ const completePairPattern = new RegExp ( escapeRegex ( marker ) + '[^*]+' + escapeRegex ( marker ) )
113+ const hasCompletePair = completePairPattern . test ( lastLine )
114+
115+ if ( hasCompletePair && ! endsWithAsterisks ) {
116+ continue // Skip, this is a false match
117+ }
118+
119+ if ( hasTrailingWhitespace ) {
120+ trimTrailing = true
121+ }
122+ closingSuffix = marker
123+ break
124+ }
125+ // If line ends with asterisks AND remainder shows partial closing
126+ else if ( endsWithAsterisks && remainder > markerLen && remainder < markerLen * 2 ) {
127+ const needed = ( markerLen * 2 ) - remainder
128+ if ( hasTrailingWhitespace ) {
129+ trimTrailing = true
130+ }
131+ closingSuffix = '*' . repeat ( needed )
132+ break
133+ }
134+ }
135+ // For non-asterisk markers, use the original logic
136+ else {
137+ // Use pre-compiled regex for strikethrough
138+ const count = marker === '~~'
139+ ? ( lastLine . match ( PATTERNS . tildeGlobal ) || [ ] ) . length
140+ : ( lastLine . match ( new RegExp ( escapeRegex ( marker ) , 'g' ) ) || [ ] ) . length
141+
142+ if ( count % 2 === 1 ) {
143+ // Preserve whitespace for inline code (spaces are significant in code)
144+ if ( marker !== '`' && hasTrailingWhitespace ) {
145+ trimTrailing = true
146+ }
147+ closingSuffix = marker
148+ break
72149 }
73- closingSuffix += marker
74- break // Only close the first unclosed marker
75150 }
76151 }
77152 }
@@ -89,9 +164,8 @@ function autoCloseInlineSyntax(markdown: string): string {
89164 * Handles nested components by tracking the marker depth (::, :::, ::::, etc.)
90165 * Also closes incomplete props {...}
91166 */
92- function autoCloseMDCComponents ( markdown : string ) : string {
167+ function autoCloseMDCComponents ( markdown : string , lines : string [ ] ) : string {
93168 let result = markdown
94- const lines = result . split ( '\n' )
95169
96170 // Check for incomplete props on the last line
97171 const lastLine = lines [ lines . length - 1 ]
@@ -101,9 +175,9 @@ function autoCloseMDCComponents(markdown: string): string {
101175 if ( openBraceMatch ) {
102176 const propsContent = openBraceMatch [ 0 ] . substring ( 1 ) // Remove the opening {
103177
104- // Check if there's an unclosed quote within the props
105- const doubleQuotes = ( propsContent . match ( / " / g ) || [ ] ) . length
106- const singleQuotes = ( propsContent . match ( / ' / g ) || [ ] ) . length
178+ // Single-pass quote counting using pre-compiled patterns
179+ const doubleQuotes = ( propsContent . match ( PATTERNS . doubleQuoteGlobal ) || [ ] ) . length
180+ const singleQuotes = ( propsContent . match ( PATTERNS . singleQuoteGlobal ) || [ ] ) . length
107181
108182 let closing = ''
109183
@@ -170,14 +244,18 @@ function autoCloseMDCComponents(markdown: string): string {
170244 }
171245
172246 // Add closing markers for any unclosed components (in reverse order)
173- let closingSuffix = ''
247+ // Use array join pattern for better performance with multiple closers
248+ const closers : string [ ] = [ ]
174249 while ( unclosedStack . length > 0 ) {
175250 const component = unclosedStack . pop ( ) !
176- const closer = ':' . repeat ( component . markerCount )
177- closingSuffix += `\n${ closer } `
251+ // Use pre-generated closers for common depths, fallback to repeat() for deep nesting
252+ const closer = component . markerCount < COLON_CLOSERS . length
253+ ? COLON_CLOSERS [ component . markerCount ]
254+ : ':' . repeat ( component . markerCount )
255+ closers . push ( closer )
178256 }
179257
180- return result + closingSuffix
258+ return closers . length > 0 ? result + '\n' + closers . join ( '\n' ) : result
181259}
182260
183261/**
0 commit comments