Skip to content
Closed
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,4 @@ packages/x-markdown/src/XMarkdown/__benchmark__/blob-report/
packages/x-markdown/src/XMarkdown/__benchmark__/playwright/.cache/
packages/x-markdown/src/XMarkdown/__benchmark__/playwright/.auth/
packages/x-markdown/src/XMarkdown/__benchmark__/test-results
.codefuse/codefuse.json
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"ignoreUnknown": true,
"includes": [
"**",
"!**/.dumi/**/*",
"!**/.dumi/tmp*",
"!**/dist/**/*",
"!**/es/**/*",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "x-mono",
"version": "2.7.0",
"version": "2.8.0",
"private": true,
"scripts": {
"presite": "npm run prestart --workspaces",
Expand Down
2 changes: 1 addition & 1 deletion packages/x-card/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ant-design/x-card",
"version": "2.7.0",
"version": "2.8.0",
"description": "React card loader for dynamic content loading and management",
"keywords": [
"A2UI",
Expand Down
2 changes: 1 addition & 1 deletion packages/x-card/src/A2UI/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function validateComponentAgainstCatalog(
const errors: string[] = [];

// If no catalog, pass by default
if (!catalog || !catalog.components) {
if (!catalog?.components) {
return { valid: true, errors: [] };
}

Expand Down
2 changes: 1 addition & 1 deletion packages/x-card/src/version.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
// This file is auto generated by npm run version
export default '2.7.0';
export default '2.8.0';
1 change: 1 addition & 0 deletions packages/x-markdown/.jest.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const compileModules = [
'khroma',
'd3',
'd3-',
'marked',
];

const resolve = (p) => require.resolve(`@ant-design/tools/lib/jest/${p}`);
Expand Down
2 changes: 1 addition & 1 deletion packages/x-markdown/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ant-design/x-markdown",
"version": "2.7.0",
"version": "2.8.0",
"scripts": {
"compile": "father build",
"tsc": "tsc --noEmit",
Expand Down
44 changes: 44 additions & 0 deletions packages/x-markdown/src/XMarkdown/__tests__/Parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,50 @@ describe('Parser', () => {
const result = parser.parse(content);
expect(result).toContain('<CustomComponent>Single line content</CustomComponent>');
});

it('should keep block markdown parsing behavior when only protectCustomTagNewlines is enabled', () => {
const parser = new Parser({
protectCustomTagNewlines: true,
components: { think: 'div' },
});
const content =
'<think>The user is asking what I can do.\n\nKey capabilities:\n1. one\n2. two\n</think>正文内容开始';
const result = parser.parse(content);

expect(result).toContain('<ol>');
expect(result).toContain('<li>one</li>');
expect(result).toContain('正文内容开始');
});

it('should keep ordered list markup inside custom tags intact when disableCustomTagBlockMarkdown is enabled', () => {
const parser = new Parser({
disableCustomTagBlockMarkdown: true,
components: { think: 'div' },
});
const content =
'<think>The user is asking what I can do.\n\nKey capabilities:\n1. one\n2. two\n</think>正文内容开始';
const result = parser.parse(content);

expect(result).toContain(
'<think>The user is asking what I can do.\n\nKey capabilities:\n1. one\n2. two\n</think>',
);
expect(result).toContain('正文内容开始');
expect(result).not.toContain('<ol>');
expect(result).not.toContain('<li>');
});

it('should still parse inline markdown when disableCustomTagBlockMarkdown is enabled', () => {
const parser = new Parser({
disableCustomTagBlockMarkdown: true,
components: { think: 'div' },
});
const content = '<think>line a\n**bold**\nline c</think>tail';
const result = parser.parse(content);

expect(result).toContain('<think>line a\n<strong>bold</strong>\nline c</think>');
expect(result).not.toContain('<ol>');
expect(result).not.toMatch(/X_MD_NL_/);
});
});

describe('escapeHtml', () => {
Expand Down
172 changes: 158 additions & 14 deletions packages/x-markdown/src/XMarkdown/__tests__/Renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1032,20 +1032,28 @@ describe('Renderer', () => {

const renderer = new Renderer({ components });

// Spy on DOMPurify.sanitize
const sanitizeSpy = jest.spyOn(DOMPurify, 'sanitize');
const createElementSpy = jest.spyOn(React, 'createElement');

// Verify that custom tags are preserved and dangerous tags (script) are removed
const html = '<custom-tag>content</custom-tag><script>alert("xss")</script>';
renderer.processHtml(html);

expect(sanitizeSpy).toHaveBeenCalledWith(
html,
// The custom tag should be rendered (via React.createElement)
expect(createElementSpy).toHaveBeenCalledWith(
MockComponent,
expect.objectContaining({
ADD_TAGS: expect.arrayContaining(['custom-tag']),
streamStatus: 'done',
}),
);

sanitizeSpy.mockRestore();
// The script tag should be stripped by DOMPurify — no createElement
// call should have 'alert' or 'script' in its arguments
const scriptCalls = createElementSpy.mock.calls.filter(
(call) => typeof call[0] === 'string' && (call[0] === 'script' || call[0] === 'Script'),
);
expect(scriptCalls).toHaveLength(0);

createElementSpy.mockRestore();
});

it('should respect user dompurifyConfig', () => {
Expand All @@ -1063,22 +1071,30 @@ describe('Renderer', () => {
dompurifyConfig: userConfig,
});

// Spy on DOMPurify.sanitize
const sanitizeSpy = jest.spyOn(DOMPurify, 'sanitize');
const createElementSpy = jest.spyOn(React, 'createElement');

// With ALLOWED_TAGS and ALLOWED_ATTR, only the custom-tag with class
// attribute should be preserved; id attribute should be stripped
const html = '<custom-tag class="test" id="test-id">content</custom-tag>';
renderer.processHtml(html);

expect(sanitizeSpy).toHaveBeenCalledWith(
html,
// The custom tag should be rendered with the class attribute
expect(createElementSpy).toHaveBeenCalledWith(
MockComponent,
expect.objectContaining({
ALLOWED_TAGS: expect.arrayContaining(['custom-tag']),
ALLOWED_ATTR: expect.arrayContaining(['class']),
ADD_TAGS: expect.arrayContaining(['custom-tag']),
class: 'test',
}),
);

sanitizeSpy.mockRestore();
// Verify id attribute is stripped (not in ALLOWED_ATTR)
expect(createElementSpy).not.toHaveBeenCalledWith(
MockComponent,
expect.objectContaining({
id: 'test-id',
}),
);

createElementSpy.mockRestore();
});
});

Expand Down Expand Up @@ -1390,4 +1406,132 @@ describe('Renderer', () => {
createElementSpy.mockClear();
});
});

describe('createPatchedDOMPurify', () => {
it('should handle nodeName patch fallback for various node types', () => {
// Test that Renderer works correctly with patched DOMPurify
const components = {
'test-component': MockComponent,
};

const renderer = new Renderer({ components });
const createElementSpy = jest.spyOn(React, 'createElement');

const html = '<test-component>content</test-component>';
renderer.processHtml(html);

// Verify the component was rendered
expect(createElementSpy).toHaveBeenCalledWith(
MockComponent,
expect.objectContaining({
streamStatus: 'done',
}),
);

createElementSpy.mockRestore();
});

it('should handle DOMPurify with custom config', () => {
const components = {
'custom-element': MockComponent,
};

const renderer = new Renderer({
components,
dompurifyConfig: {
ALLOWED_TAGS: ['custom-element'],
ALLOWED_ATTR: ['data-custom'],
},
});

const createElementSpy = jest.spyOn(React, 'createElement');

const html = '<custom-element data-custom="value">content</custom-element>';
renderer.processHtml(html);

expect(createElementSpy).toHaveBeenCalledWith(
MockComponent,
expect.objectContaining({
'data-custom': 'value',
streamStatus: 'done',
}),
);

createElementSpy.mockRestore();
});

it('should process HTML with text nodes', () => {
const components = {};
const renderer = new Renderer({ components });

// Simple HTML with just text
const html = '<p>Plain text content</p>';
const result = renderer.processHtml(html);

expect(result).toBeDefined();
});

it('should handle empty or invalid HTML gracefully', () => {
const components = {};
const renderer = new Renderer({ components });

// Empty string - should not throw
expect(() => renderer.render('')).not.toThrow();

// Whitespace only - should not throw
expect(() => renderer.render(' ')).not.toThrow();
});

it('should handle HTML with comments', () => {
const components = {};
const renderer = new Renderer({ components });

const html = '<!-- comment --><p>content</p>';
const result = renderer.processHtml(html);

expect(result).toBeDefined();
});

it('should handle streaming mode with animation', () => {
const createElementSpy = jest.spyOn(React, 'createElement');

// Use a custom component to avoid AnimationText being skipped due to parent custom component check
const CustomComponent: React.FC<any> = (props) => React.createElement('div', props);
const rendererWithComponent = new Renderer({
components: { 'custom-wrapper': CustomComponent },
streaming: {
enableAnimation: true,
animationConfig: {
fadeDuration: 100,
easing: 'ease-in',
},
},
});

const html = '<custom-wrapper>Animated text content</custom-wrapper>';
rendererWithComponent.processHtml(html);

// Verify that createElement was called (meaning the renderer processed the HTML)
expect(createElementSpy).toHaveBeenCalled();

createElementSpy.mockRestore();
});
});

describe('SSR / no DOM safety', () => {
it('returns null from processHtml when DOMPurify.sanitize is unavailable', () => {
const renderer = new Renderer({ components: { 'custom-tag': MockComponent } });
const originalSanitize = DOMPurify.sanitize;
// Simulate a server environment without a DOM, where DOMPurify's default
// export has no `sanitize` method.
(DOMPurify as any).sanitize = undefined;
try {
expect(renderer.processHtml('<custom-tag>content</custom-tag>')).toBeNull();
// render() delegates to processHtml and must not throw either.
expect(renderer.render('<p>hello</p>')).toBeNull();
} finally {
(DOMPurify as any).sanitize = originalSanitize;
}
});
});
});
55 changes: 55 additions & 0 deletions packages/x-markdown/src/XMarkdown/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,61 @@ describe('XMarkdown', () => {
expect(wrapper.innerHTML).toBe('<div>This is a paragraph.</div>\n');
});

describe('disableDefaultStyles', () => {
it('should not add any disable class by default', () => {
const { container } = render(<XMarkdown content="- Item" />);
const wrapper = container.firstChild as HTMLElement;

expect(wrapper).toHaveClass('x-markdown');
expect(wrapper.className).not.toMatch(/x-md-disable/);
});

it('should add x-md-disable-all when disableDefaultStyles is true', () => {
const { container } = render(<XMarkdown content="- Item" disableDefaultStyles />);
const wrapper = container.firstChild as HTMLElement;

expect(wrapper).toHaveClass('x-markdown');
expect(wrapper).toHaveClass('x-md-disable-all');
});

it('should add per-tag disable classes when an array is provided', () => {
const { container } = render(
<XMarkdown content="- Item" disableDefaultStyles={['ul', 'ol', 'li']} />,
);
const wrapper = container.firstChild as HTMLElement;

expect(wrapper).toHaveClass('x-md-disable-ul');
expect(wrapper).toHaveClass('x-md-disable-ol');
expect(wrapper).toHaveClass('x-md-disable-li');
expect(wrapper).not.toHaveClass('x-md-disable-all');
expect(wrapper).not.toHaveClass('x-md-disable-code');
});

it('should not add any disable class when an empty array is provided', () => {
const { container } = render(<XMarkdown content="- Item" disableDefaultStyles={[]} />);
const wrapper = container.firstChild as HTMLElement;

expect(wrapper.className).not.toMatch(/x-md-disable/);
});

it('should keep working alongside rootClassName and className', () => {
const { container } = render(
<XMarkdown
content="- Item"
disableDefaultStyles={['ul']}
rootClassName="root-cls"
className="custom-cls"
/>,
);
const wrapper = container.firstChild as HTMLElement;

expect(wrapper).toHaveClass('x-markdown');
expect(wrapper).toHaveClass('x-md-disable-ul');
expect(wrapper).toHaveClass('root-cls');
expect(wrapper).toHaveClass('custom-cls');
});
});

it('support checkbox is checked', () => {
const { container } = render(<XMarkdown content="- [x] checkbox" />);
expect(container).toMatchSnapshot();
Expand Down
Loading
Loading