Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions packages/x-markdown/src/XMarkdown/__tests__/Parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,4 +232,148 @@ describe('Parser', () => {
expect(escapeHtml('test<script>', undefined)).toBe('test&lt;script&gt;');
});
});

// Regression for CJK-friendly strong emphasis (#2038). marked's CommonMark
// flanking rule keeps `**` literal when next to punctuation, which breaks
// common Chinese/Japanese output like 写作**"加粗"**表示.
describe('CJK-friendly strong emphasis', () => {
it('renders bold when ** is immediately followed by ASCII double quotes', () => {
const parser = new Parser();
const result = parser.parse('写作**"加粗"**表示');
expect(result).toContain('<strong>"加粗"</strong>');
expect(result).not.toContain('**');
});

it('renders bold when ** is immediately followed by fullwidth double quotes', () => {
const parser = new Parser();
const result = parser.parse('写作**“加粗”**表示');
expect(result).toContain('<strong>“加粗”</strong>');
expect(result).not.toContain('**');
});

it('renders bold when ** is immediately followed by fullwidth single quotes', () => {
const parser = new Parser();
const result = parser.parse('写作**‘加粗’**表示');
expect(result).toContain('<strong>‘加粗’</strong>');
});

it('renders bold when ** wraps a fullwidth-parenthesized term', () => {
const parser = new Parser();
const result = parser.parse('写作**(加粗)**表示');
expect(result).toContain('<strong>(加粗)</strong>');
});

it('renders bold when ** wraps an ASCII-parenthesized term', () => {
const parser = new Parser();
const result = parser.parse('写作**(加粗)**表示');
expect(result).toContain('<strong>(加粗)</strong>');
});

it('renders bold for Japanese and Korean adjacent to quotes', () => {
const parser = new Parser();
expect(parser.parse('これは**“太字”**です')).toContain('<strong>“太字”</strong>');
expect(parser.parse('이것은**“굵게”**입니다')).toContain('<strong>“굵게”</strong>');
});

it('keeps plain CJK bold working (no regression)', () => {
const parser = new Parser();
expect(parser.parse('这是**加粗**测试')).toContain('<strong>加粗</strong>');
});

it('keeps plain ASCII bold and intraword bold working (no regression)', () => {
const parser = new Parser();
expect(parser.parse('plain **bold** text')).toContain('<strong>bold</strong>');
expect(parser.parse('foo**bar**baz')).toContain('<strong>bar</strong>');
});

it('keeps nested em inside strong working (no regression)', () => {
const parser = new Parser();
expect(parser.parse('**bold *italic* inside**')).toContain('<strong>bold <em>italic</em> inside</strong>');
});

it('does not leak the boundary sentinel into the output', () => {
const parser = new Parser();
const result = parser.parse('写作**“加粗”**表示');
// PUA sentinel used internally must be stripped before returning.
expect(result).not.toMatch(/\uE000|\uE001|\uE002/);
});

it('keeps ** literal inside fenced code (no regression)', () => {
const parser = new Parser();
const result = parser.parse('```\n**not bold**\n```');
expect(result).not.toContain('<strong>');
expect(result).toContain('**not bold**');
});

it('keeps ** literal inside inline code (no regression)', () => {
const parser = new Parser();
const result = parser.parse('`src/**/*.ts`');
expect(result).not.toContain('<strong>');
expect(result).toContain('src/**/*.ts');
});

it('produces balanced <strong> across all streaming prefixes', () => {
const parser = new Parser();
const sentence = '写作**“加粗”**表示。';
for (let i = 1; i <= sentence.length; i++) {
const result = parser.parse(sentence.slice(0, i));
const open = (result.match(/<strong>/g) || []).length;
const close = (result.match(/<\/strong>/g) || []).length;
expect(open).toBe(close);
}
});

// Regression: user-supplied U+E002 in input must survive parsing — only
// sentinels inserted by relaxEmphasis should be stripped.
it('preserves user-supplied U+E002 characters in the output', () => {
const parser = new Parser();
const input = '普通文字\uE002中间有哨兵';
const result = parser.parse(input);
expect(result).toContain('\uE002');
// Content other than the sentinel must not be lost either.
expect(result).toContain('普通文字');
expect(result).toContain('中间有哨兵');
});

// Regression: a pre-existing placeholder-shaped sequence in the source must
// not collide with generated placeholders (which start at counter 0).
it('does not collide with a pre-existing placeholder-shaped sequence', () => {
const parser = new Parser();
// \uE000X_MD_EB_0\uE001 is the exact shape of the first generated key.
const input = '\uE000X_MD_EB_0\uE001\uE002';
const result = parser.parse(input);
expect(result).toContain('\uE000X_MD_EB_0\uE001');
expect(result).toContain('\uE002');
});

it('does not collide when multiple U+E002 are protected', () => {
const parser = new Parser();
const input = 'a\uE000X_MD_EB_0\uE001b\uE002c\uE002d';
const result = parser.parse(input);
expect(result).toContain('a\uE000X_MD_EB_0\uE001b');
expect(result).toContain('c\uE002d');
});

// Regression: triple-emphasis delimiters (*** / ___) must not be split —
// relaxEmphasis should not insert a sentinel inside them.
it('does not insert boundary inside triple *** delimiter', () => {
const parser = new Parser();
const result = parser.parse('***"加粗"***');
// marked should produce bold+italic, same as without preprocessing.
expect(result).toContain('<strong>');
expect(result).toContain('<em>');
});

it('does not insert boundary inside triple ___ delimiter', () => {
const parser = new Parser();
const result = parser.parse('___"加粗"___');
expect(result).toContain('<strong>');
expect(result).toContain('<em>');
});

it('keeps plain triple emphasis working (no regression)', () => {
const parser = new Parser();
expect(parser.parse('***bold***')).toContain('<em><strong>bold</strong></em>');
});
});
});
101 changes: 95 additions & 6 deletions packages/x-markdown/src/XMarkdown/core/Parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ const PLACEHOLDER_PREFIX = '\uE000X_MD_NL_';
const PLACEHOLDER_SUFFIX = '\uE001';
const PLACEHOLDER_REGEX = /\uE000X_MD_NL_\d+\uE001/g;

// PUA sentinel between `**`/`__` and adjacent punctuation — relaxes marked's
// strict CommonMark flanking rule for CJK bold. Stripped from the final HTML.
const EMPH_BOUNDARY = '\uE002';
const EMPH_BOUNDARY_REGEX = /\uE002/g;
// Placeholder to protect user-supplied U+E002 before preprocessing so that
// stripEmphasisBoundary only removes sentinels we inserted, never user content.
const EMPH_USER_PLACEHOLDER_PREFIX = '\uE000X_MD_EB_';
const EMPH_USER_PLACEHOLDER_SUFFIX = '\uE001';
const EMPH_USER_PLACEHOLDER_REGEX = /\uE000X_MD_EB_\d+\uE001/g;

// Type for tokens that can be marked for tail injection
type MarkableToken = Token & { [TAIL_MARKER]?: boolean };

Expand Down Expand Up @@ -299,6 +309,36 @@ class Parser {
return content.replace(PLACEHOLDER_REGEX, (match) => placeholders.get(match) ?? match);
}

/**
* Relax CommonMark's strong-emphasis flanking rule around punctuation so that
* CJK bold patterns like `写作**"加粗"**表示` render as `<strong>` instead of
* staying literal (mirrors what `remark-cjk-friendly` does for remark).
*
* Scope: fully fixes `**`. Fixes `__` when not directly inside a word run
* (marked's intraword rule for `_` cannot be bypassed by boundary insertion,
* so `写作__"加粗"__表示` stays literal — same behavior as before the fix,
* no regression; prefer `**` for CJK bold).
*/
private relaxEmphasis(content: string): string {
// Match marked's own `((?!\*)punct)` exclusion — don't split `***` / `___`.
// Negative lookbehind/lookahead prevent matching `**` inside `***` or `__`
// inside `___`, which would corrupt triple-emphasis delimiters.
const notDelim = '(?<![*_])';
const notDelimAfter = '(?![*_])';
const punct = '(?:(?![*_])[\\p{P}\\p{S}])';
const delim = `(\\*\\*|__)`;

let out = content.replace(
new RegExp(`${notDelim}${delim}(?=${punct})`, 'gu'),
`$1${EMPH_BOUNDARY}`,
);
out = out.replace(
new RegExp(`(${punct})${delim}${notDelimAfter}`, 'gu'),
`$1${EMPH_BOUNDARY}$2`,
);
return out;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
* Find the last non-empty token in the token tree (reverse search)
*/
Expand Down Expand Up @@ -354,20 +394,69 @@ class Parser {
}

public parse(content: string, parseOptions?: ParseOptions) {
// Set tail injection flag
this.injectTail = parseOptions?.injectTail ?? false;

// Protect custom tags if needed
// Protect any user-supplied U+E002 before inserting our own sentinels,
// so stripEmphasisBoundary never removes user content.
const { protected: protectedInput, placeholders } = this.protectEmphasisBoundary(content);
// Relax strong-emphasis flanking around punctuation (CJK-friendly bold).
// Runs before custom-tag protection so inline markdown inside custom tags
// also benefits; sentinel is stripped after parse.
const relaxed = this.relaxEmphasis(protectedInput);

if (this.options.protectCustomTagNewlines || this.options.disableCustomTagBlockMarkdown) {
const { protected: protectedContent, placeholders } = this.protectCustomTags(
content,
const { protected: protectedContent, placeholders: tagPlaceholders } = this.protectCustomTags(
relaxed,
!!this.options.disableCustomTagBlockMarkdown,
);
const parsed = this.markdownInstance.parse(protectedContent) as string;
return this.restorePlaceholders(parsed, placeholders);
return this.restoreEmphasisBoundary(
this.stripEmphasisBoundary(this.restorePlaceholders(parsed, tagPlaceholders)),
placeholders,
);
}

return this.restoreEmphasisBoundary(
this.stripEmphasisBoundary(this.markdownInstance.parse(relaxed) as string),
placeholders,
);
}

private protectEmphasisBoundary(content: string): { protected: string; placeholders: Map<string, string> } {
if (!content.includes(EMPH_BOUNDARY)) {
return { protected: content, placeholders: new Map() };
}
const placeholders = new Map<string, string>();
let counter = 0;
const nextPlaceholder = () => {
// Skip keys that already exist in the source, otherwise a pre-existing
// placeholder-shaped sequence would collide and get rewritten on restore.
let placeholder = `${EMPH_USER_PLACEHOLDER_PREFIX}${counter++}${EMPH_USER_PLACEHOLDER_SUFFIX}`;
while (content.includes(placeholder)) {
placeholder = `${EMPH_USER_PLACEHOLDER_PREFIX}${counter++}${EMPH_USER_PLACEHOLDER_SUFFIX}`;
}
return placeholder;
};
const protectedContent = content.replace(EMPH_BOUNDARY_REGEX, (match) => {
const placeholder = nextPlaceholder();
placeholders.set(placeholder, match);
return placeholder;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
return { protected: protectedContent, placeholders };
}

private restoreEmphasisBoundary(html: string, placeholders: Map<string, string>): string {
if (placeholders.size === 0) {
return html;
}
return html.replace(EMPH_USER_PLACEHOLDER_REGEX, (match) => placeholders.get(match) ?? match);
}

return this.markdownInstance.parse(content) as string;
private stripEmphasisBoundary(html: string): string {
if (!html.includes(EMPH_BOUNDARY)) {
return html;
}
return html.replace(EMPH_BOUNDARY_REGEX, '');
}
}

Expand Down
Loading