Skip to content

Commit 2aadfc7

Browse files
committed
fix(auto-close): improve performance & partial issues
1 parent 8664088 commit 2aadfc7

2 files changed

Lines changed: 135 additions & 30 deletions

File tree

src/utils/auto-close.ts

Lines changed: 108 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,19 @@
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
/**

test/auto-close.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,21 +127,48 @@ describe('autoCloseMarkdown - MDC Components', () => {
127127
const expected = '*italic*'
128128
expect(autoCloseMarkdown(input)).toBe(expected)
129129
})
130+
130131
it('should ignore trailing space in bold', () => {
131132
const input = '**bold '
132133
const expected = '**bold**'
133134
expect(autoCloseMarkdown(input)).toBe(expected)
134135
})
136+
135137
it('should ignore trailing space in strikethrough', () => {
136138
const input = '~~strikethrough '
137139
const expected = '~~strikethrough~~'
138140
expect(autoCloseMarkdown(input)).toBe(expected)
139141
})
142+
140143
it('should ignore trailing space in code', () => {
141144
const input = '`code '
142145
const expected = '`code `'
143146
expect(autoCloseMarkdown(input)).toBe(expected)
144147
})
148+
149+
it('***italic and bold', () => {
150+
const input = '***italic and bold'
151+
const expected = '***italic and bold***'
152+
expect(autoCloseMarkdown(input)).toBe(expected)
153+
})
154+
155+
it('***italic and bold partial', () => {
156+
const input = '***italic and bold*'
157+
const expected = '***italic and bold***'
158+
expect(autoCloseMarkdown(input)).toBe(expected)
159+
})
160+
161+
it('***italic and bold partial 2', () => {
162+
const input = '***italic and bold**'
163+
const expected = '***italic and bold***'
164+
expect(autoCloseMarkdown(input)).toBe(expected)
165+
})
166+
167+
it('**bold partial', () => {
168+
const input = '**bold*'
169+
const expected = '**bold**'
170+
expect(autoCloseMarkdown(input)).toBe(expected)
171+
})
145172
})
146173

147174
describe('autoCloseMarkdown - Combined Scenarios', () => {

0 commit comments

Comments
 (0)