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);
}
}