Skip to content

Commit dbca5fa

Browse files
fix(mdxish): Unclosed curly brace and callout fail to render on MDXish (#1333)
[![PR App][icn]][demo] | Fix RM-XYZ :-------------------:|:----------: ## 🧰 Changes The `escapeUnbalancedBraces()` function in `preprocess-jsx-expressions.ts` had an edge case where it can't handle multi-byte characters such as emoji This change converts the content to an array of unicode code points once at the start to ensure multi-byte characters are handled properly Describe your changes in detail. ## 🧬 QA & Testing - [Broken on production][prod]. - [Working in this PR app][demo]. [demo]: https://markdown-pr-PR_NUMBER.herokuapp.com [prod]: https://SUBDOMAIN.readme.io [icn]: https://user-images.githubusercontent.com/886627/160426047-1bee9488-305a-4145-bb2b-09d8b757d38a.svg --------- Co-authored-by: Dimas Putra Anugerah <63914983+eaglethrost@users.noreply.github.com>
1 parent 2a37f3a commit dbca5fa

3 files changed

Lines changed: 112 additions & 4 deletions

File tree

__tests__/lib/mdxish/mdxish.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,24 @@ describe('mdxish should render', () => {
3535
const md2 = 'This is an api: /param1/{param2 that has a unclosed curly brace';
3636
expect(() => mdxish(md2)).not.toThrow();
3737
});
38+
39+
it('should render unclosed curly braces in content with emojis', () => {
40+
// Regression test for bug where emojis prevented brace escaping
41+
const md = `> 📘 Note
42+
>
43+
> Content with unclosed brace: {`;
44+
expect(() => mdxish(md)).not.toThrow();
45+
46+
// Also test the exact bug report case
47+
const bugReportCase = `test
48+
49+
> 📘 Enter an optional title
50+
>
51+
> test
52+
>
53+
> test {`;
54+
expect(() => mdxish(bugReportCase)).not.toThrow();
55+
});
3856
});
3957

4058
it('should render content in new lines', () => {

__tests__/transformers/preprocess-jsx-expressions.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,93 @@ describe('preprocessJSXExpressions', () => {
187187
});
188188
});
189189

190+
it('should escape unclosed braces in content with emojis', () => {
191+
// Regression test: emojis are multi-byte Unicode that used to break position tracking
192+
const content = '📘 test {';
193+
const result = preprocessJSXExpressions(content);
194+
expect(result).toBe('📘 test \\{');
195+
});
196+
197+
it('should escape unclosed braces in blockquote with emoji', () => {
198+
const content = '> 📘 test {';
199+
const result = preprocessJSXExpressions(content);
200+
expect(result).toBe('> 📘 test \\{');
201+
});
202+
203+
it('should escape unclosed braces in multi-line callout with emoji', () => {
204+
const content = `> 📘 Title
205+
>
206+
> test {`;
207+
const result = preprocessJSXExpressions(content);
208+
expect(result).toContain('\\{');
209+
expect(() => mdxish(result)).not.toThrow();
210+
});
211+
212+
it('should escape unclosed braces in content with Mandarin characters', () => {
213+
// Regression test: multi-byte Unicode characters (Chinese) should not break position tracking
214+
const content = '汉字 test {';
215+
const result = preprocessJSXExpressions(content);
216+
expect(result).toBe('汉字 test \\{');
217+
});
218+
219+
it('should escape unclosed braces in blockquote with Mandarin characters', () => {
220+
const content = '> 吉 test {';
221+
const result = preprocessJSXExpressions(content);
222+
expect(result).toBe('> 吉 test \\{');
223+
});
224+
225+
it('should escape unclosed braces in content with math notation symbols', () => {
226+
// Regression test: math Unicode symbols should not break position tracking
227+
const content = '∑∫∞ test {';
228+
const result = preprocessJSXExpressions(content);
229+
expect(result).toBe('∑∫∞ test \\{');
230+
});
231+
232+
it('should escape unclosed braces in content with square root and math symbols', () => {
233+
const content = '√2 + ∆x {';
234+
const result = preprocessJSXExpressions(content);
235+
expect(result).toBe('√2 + ∆x \\{');
236+
});
237+
238+
it('should escape unclosed braces in blockquote with math notation', () => {
239+
const content = '> π ≈ 3.14 {';
240+
const result = preprocessJSXExpressions(content);
241+
expect(result).toBe('> π ≈ 3.14 \\{');
242+
});
243+
244+
it('should escape unclosed braces with mixed Unicode characters', () => {
245+
const content = '汉字 📘 ∑ test {';
246+
const result = preprocessJSXExpressions(content);
247+
expect(result).toBe('汉字 📘 ∑ test \\{');
248+
});
249+
250+
it('should escape unmatched closing brace with Mandarin characters', () => {
251+
const content = '汉字 test }';
252+
const result = preprocessJSXExpressions(content);
253+
expect(result).toBe('汉字 test \\}');
254+
});
255+
256+
it('should escape unmatched closing brace with math symbols', () => {
257+
const content = '∞ test }';
258+
const result = preprocessJSXExpressions(content);
259+
expect(result).toBe('∞ test \\}');
260+
});
261+
262+
it('should handle balanced braces with mixed Unicode characters', () => {
263+
const content = '汉字 {name} 📘 {amount} ∑';
264+
const result = preprocessJSXExpressions(content);
265+
expect(result).toBe(content);
266+
});
267+
268+
it('should escape unclosed braces in multi-line callout with Mandarin and math symbols', () => {
269+
const content = `> 汉字 Title
270+
>
271+
> ∑∫ test {`;
272+
const result = preprocessJSXExpressions(content);
273+
expect(result).toContain('\\{');
274+
expect(() => mdxish(result)).not.toThrow();
275+
});
276+
190277
it('should handle string literals inside expressions', () => {
191278
// The closing brace inside the string should not close the expression
192279
const content = '{"}"}';

processor/transform/mdxish/preprocess-jsx-expressions.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,11 @@ function escapeUnbalancedBraces(content: string): string {
140140
let strDelim: string | null = null;
141141
let strEscaped = false;
142142

143-
for (let i = 0; i < content.length; i += 1) {
144-
const ch = content[i];
143+
// Convert to array of Unicode code points to handle emojis and multi-byte characters correctly
144+
const chars = Array.from(content);
145+
146+
for (let i = 0; i < chars.length; i += 1) {
147+
const ch = chars[i];
145148

146149
// Track strings inside expressions to ignore braces within them
147150
if (opens.length > 0) {
@@ -162,7 +165,7 @@ function escapeUnbalancedBraces(content: string): string {
162165
// Skip already-escaped braces (count preceding backslashes)
163166
if (ch === '{' || ch === '}') {
164167
let bs = 0;
165-
for (let j = i - 1; j >= 0 && content[j] === '\\'; j -= 1) bs += 1;
168+
for (let j = i - 1; j >= 0 && chars[j] === '\\'; j -= 1) bs += 1;
166169
if (bs % 2 === 1) {
167170
// eslint-disable-next-line no-continue
168171
continue;
@@ -179,7 +182,7 @@ function escapeUnbalancedBraces(content: string): string {
179182
opens.forEach(pos => unbalanced.add(pos));
180183
if (unbalanced.size === 0) return content;
181184

182-
return Array.from(content)
185+
return chars
183186
.map((ch, i) => (unbalanced.has(i) ? `\\${ch}` : ch))
184187
.join('');
185188
}

0 commit comments

Comments
 (0)