From f1cd335befa62cbbe67a4861d7d97954d2fa35d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=81=87=E8=A7=81=E5=90=8C=E5=AD=A6?= <1875694521@qq.com> Date: Tue, 19 May 2026 11:49:35 +0800 Subject: [PATCH] feat(parser): enhance handling of custom tags and markdown syntax --- .../src/XMarkdown/__tests__/Parser.test.ts | 33 +++- .../src/XMarkdown/__tests__/index.test.tsx | 44 +++++ .../x-markdown/src/XMarkdown/core/Parser.ts | 178 +++++++++++++++--- 3 files changed, 230 insertions(+), 25 deletions(-) diff --git a/packages/x-markdown/src/XMarkdown/__tests__/Parser.test.ts b/packages/x-markdown/src/XMarkdown/__tests__/Parser.test.ts index 06fb1a2b9..b296c3d80 100644 --- a/packages/x-markdown/src/XMarkdown/__tests__/Parser.test.ts +++ b/packages/x-markdown/src/XMarkdown/__tests__/Parser.test.ts @@ -81,6 +81,34 @@ describe('Parser', () => { }); describe('protectCustomTagNewlines', () => { + it('should keep markdown syntax inside custom tag children as plain text', () => { + const parser = new Parser({ + components: { custom: 'div' }, + }); + const content = '{"sales":[{"name":"电子产品[202](红)","value":52000}]}'; + const result = parser.parse(content); + expect(result).toContain('电子产品[202](红)'); + expect(result).not.toContain(' { + const parser = new Parser({ + components: { custom: 'div' }, + }); + const content = '{"sales":[{"name":"电子产品[202](红)","value":52000'; + const result = parser.parse(content); + expect(result).toContain('电子产品[202](红)'); + expect(result).not.toContain(' { + const parser = new Parser({ + components: { p: 'p' }, + }); + const result = parser.parse('

[Google](https://google.com)

'); + expect(result).toContain('

[Google](https://google.com)

'); + }); + it('should protect newlines inside custom tags when protectCustomTagNewlines is true', () => { const parser = new Parser({ protectCustomTagNewlines: true, @@ -92,14 +120,15 @@ describe('Parser', () => { expect(result).not.toMatch(/First line<\/p>\s*

Second line/); }); - it('should not protect newlines when protectCustomTagNewlines is false', () => { + it('should protect custom tag content when protectCustomTagNewlines is false', () => { const parser = new Parser({ protectCustomTagNewlines: false, components: { CustomComponent: 'div' }, }); const content = 'First line\n\nSecond line'; const result = parser.parse(content); - expect(result).toContain('

'); + expect(result).toContain('First line\n\nSecond line'); + expect(result).not.toMatch(/First line<\/p>\s*

Second line/); }); it('should work normally when protectCustomTagNewlines is true but no custom components', () => { diff --git a/packages/x-markdown/src/XMarkdown/__tests__/index.test.tsx b/packages/x-markdown/src/XMarkdown/__tests__/index.test.tsx index 6ee85e0dc..f758afc0b 100644 --- a/packages/x-markdown/src/XMarkdown/__tests__/index.test.tsx +++ b/packages/x-markdown/src/XMarkdown/__tests__/index.test.tsx @@ -176,6 +176,50 @@ describe('XMarkdown', () => { expect((container.firstChild as HTMLElement)?.innerHTML).toBe(html); }); + it('passes custom component children as plain text without parsing markdown links', () => { + let receivedChildren: React.ReactNode; + const markdown = + '{"sales":[{"name":"电子产品[202](红)","value":52000}],"majorGroupName":"华南师范大学[202](汕尾校区)"}'; + + const { container } = render( + { + receivedChildren = props.children; + return {props.children}; + }, + }} + />, + ); + + expect(receivedChildren).toBe( + '{"sales":[{"name":"电子产品[202](红)","value":52000}],"majorGroupName":"华南师范大学[202](汕尾校区)"}', + ); + expect(container.querySelector('a')).not.toBeInTheDocument(); + }); + + it('passes streaming custom component children as plain text before the tag is closed', () => { + let receivedChildren: React.ReactNode; + const markdown = '{"sales":[{"name":"电子产品[202](红)","value":52000'; + + const { container } = render( + { + receivedChildren = props.children; + return {props.children}; + }, + }} + />, + ); + + expect(receivedChildren).toBe('{"sales":[{"name":"电子产品[202](红)","value":52000'); + expect(container.querySelector('a')).not.toBeInTheDocument(); + }); + it('walkToken', () => { const walkTokens = (token: Token) => { if (token.type === 'heading') { diff --git a/packages/x-markdown/src/XMarkdown/core/Parser.ts b/packages/x-markdown/src/XMarkdown/core/Parser.ts index 17aeb32cc..bfabd0988 100644 --- a/packages/x-markdown/src/XMarkdown/core/Parser.ts +++ b/packages/x-markdown/src/XMarkdown/core/Parser.ts @@ -34,6 +34,121 @@ const escapeReplacements: Record = { }; const getEscapeReplacement = (ch: string) => escapeReplacements[ch]; +const NATIVE_HTML_TAGS = new Set([ + 'a', + 'abbr', + 'address', + 'area', + 'article', + 'aside', + 'audio', + 'b', + 'base', + 'bdi', + 'bdo', + 'blockquote', + 'body', + 'br', + 'button', + 'canvas', + 'caption', + 'cite', + 'code', + 'col', + 'colgroup', + 'data', + 'datalist', + 'dd', + 'del', + 'details', + 'dfn', + 'dialog', + 'div', + 'dl', + 'dt', + 'em', + 'embed', + 'fieldset', + 'figcaption', + 'figure', + 'footer', + 'form', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'head', + 'header', + 'hgroup', + 'hr', + 'html', + 'i', + 'iframe', + 'img', + 'input', + 'ins', + 'kbd', + 'label', + 'legend', + 'li', + 'link', + 'main', + 'map', + 'mark', + 'menu', + 'meta', + 'meter', + 'nav', + 'noscript', + 'object', + 'ol', + 'optgroup', + 'option', + 'output', + 'p', + 'picture', + 'pre', + 'progress', + 'q', + 'rp', + 'rt', + 'ruby', + 's', + 'samp', + 'script', + 'search', + 'section', + 'select', + 'slot', + 'small', + 'source', + 'span', + 'strong', + 'style', + 'sub', + 'summary', + 'sup', + 'table', + 'tbody', + 'td', + 'template', + 'textarea', + 'tfoot', + 'th', + 'thead', + 'time', + 'title', + 'tr', + 'track', + 'u', + 'ul', + 'var', + 'video', + 'wbr', +]); + export function escapeHtml(html: string, encode?: boolean) { if (encode) { if (other.escapeTest.test(html)) { @@ -54,6 +169,10 @@ const TAIL_MARKER = Symbol('tailMarker'); // Type for tokens that can be marked for tail injection type MarkableToken = Token & { [TAIL_MARKER]?: boolean }; +type CustomTagPlaceholder = { + protected: string; +}; + class Parser { options: ParserOptions; markdownInstance: Marked; @@ -173,7 +292,9 @@ class Parser { placeholders: Map; } { const placeholders = new Map(); - const customTagNames = Object.keys(this.options.components || {}); + const customTagNames = Object.keys(this.options.components || {}).filter( + (name) => !NATIVE_HTML_TAGS.has(name.toLowerCase()), + ); if (customTagNames.length === 0) { return { protected: content, placeholders }; @@ -223,6 +344,12 @@ class Parser { const result: string[] = []; let lastIndex = 0; + const createPlaceholder = ({ protected: protectedContent }: CustomTagPlaceholder) => { + const ph = `\u0000XMDPLACEHOLDER${placeholderIndex++}\u0000`; + placeholders.set(ph, protectedContent); + return ph; + }; + for (const pos of positions) { if (pos.type === 'open') { // Self-closing tags don't have inner content @@ -246,22 +373,31 @@ class Parser { result.push(content.slice(lastIndex, startPos)); } - if (innerContent.includes('\n\n')) { - const protectedInner = innerContent.replace(/\n\n/g, () => { - const ph = `__X_MD_PLACEHOLDER_${placeholderIndex++}__`; - placeholders.set(ph, '\n\n'); - return ph; - }); - result.push(openTag + protectedInner + closeTag); - } else { - result.push(openTag + innerContent + closeTag); - } + result.push( + createPlaceholder({ + protected: openTag + innerContent + closeTag, + }), + ); lastIndex = endPos; } } } + if (stack.length > 0) { + const open = stack[0]; + if (lastIndex < open.start) { + result.push(content.slice(lastIndex, open.start)); + } + const unclosedContent = content.slice(open.start); + result.push( + createPlaceholder({ + protected: unclosedContent, + }), + ); + return { protected: result.join(''), placeholders }; + } + if (lastIndex < content.length) { result.push(content.slice(lastIndex)); } @@ -273,10 +409,11 @@ class Parser { if (placeholders.size === 0) { return content; } - return content.replace( - /__X_MD_PLACEHOLDER_\d+__/g, - (match) => placeholders.get(match) ?? match, - ); + let restored = content; + placeholders.forEach((value, placeholder) => { + restored = restored.split(placeholder).join(value); + }); + return restored; } /** @@ -337,14 +474,9 @@ class Parser { // Set tail injection flag this.injectTail = parseOptions?.injectTail ?? false; - // Protect custom tags if needed - if (this.options.protectCustomTagNewlines) { - const { protected: protectedContent, placeholders } = this.protectCustomTags(content); - const parsed = this.markdownInstance.parse(protectedContent) as string; - return this.restorePlaceholders(parsed, placeholders); - } - - return this.markdownInstance.parse(content) as string; + const { protected: protectedContent, placeholders } = this.protectCustomTags(content); + const parsed = this.markdownInstance.parse(protectedContent) as string; + return this.restorePlaceholders(parsed, placeholders); } }