Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
bddde57
fix: fix ci
Div627 Mar 12, 2026
2b53e6a
Merge branch 'main' of github.com:ant-design/x
Div627 Mar 13, 2026
27e5170
Merge branch 'main' of github.com:ant-design/x
Div627 May 27, 2026
f45f8b8
feat(x-markdown): add disableDefaultStyles to opt out of default tag …
Div627 Jun 2, 2026
73a28e3
fix(x): coerce string|number design tokens before arithmetic
Div627 Jun 2, 2026
4cf51ec
fix(x-markdown): gate list markers by list container disable class
Div627 Jun 2, 2026
6626481
fix: fix ci
Div627 Jun 2, 2026
555cda0
fix: fix ci
Div627 Jun 2, 2026
a363296
Merge remote-tracking branch 'origin/feature' into feat/x-markdown-di…
Div627 Jun 3, 2026
8f00872
Merge remote-tracking branch 'origin/feature' into feat/x-markdown-di…
Div627 Jun 3, 2026
291f283
Merge pull request #1909 from ant-design/feat/x-markdown-disable-defa…
Div627 Jun 3, 2026
2794936
fix: avoid jank when initial streaming output is non-streaming
waiter Jun 3, 2026
999270d
Refactor streaming output handling in useStreaming hook
waiter Jun 3, 2026
0d8d2a0
chore: update test snapshot
Div627 Jun 4, 2026
983dcc1
test(x-markdown): fix nodeName getter for happy-dom regression
Div627 Jun 4, 2026
4aea3d1
fix(x-markdown): guard DOMPurify against SSR (no DOM)
Div627 Jun 4, 2026
5958114
test(x-markdown): cover SSR no-DOM guard in Renderer.processHtml
Div627 Jun 4, 2026
180ddf3
Merge pull request #1917 from waiter/fix_streaming
Div627 Jun 5, 2026
7ed41ac
docs: fix outdated type name in Prompts API table (#1915)
18062706139fcz Jun 5, 2026
669226e
fix(x): refresh FileCard.List scroll ping state on items change (#1914)
18062706139fcz Jun 5, 2026
cfcdcfd
fix(x-markdown): protect all newlines inside custom tags (#1896)
18062706139fcz Jun 7, 2026
fe6272b
fix(x-markdown): split full-newline protection into a separate flag
18062706139fcz Jun 8, 2026
03b000c
ci: pinned dumi version to 2.4.28 (#1922)
meet-student Jun 8, 2026
247052a
refactor(x-markdown): clarify custom tag block markdown option
18062706139fcz Jun 8, 2026
1e89c70
refactor(x-markdown): align internal flag name with public option
18062706139fcz Jun 8, 2026
2e426e4
docs: cache chat provider with useRef in x-conversations demos (#1925)
18062706139fcz Jun 8, 2026
d212e12
Merge pull request #1920 from 18062706139fcz/fix/x-markdown-custom-ta…
Div627 Jun 8, 2026
a270320
chore: main merge feature (#1929)
kimteayon Jun 8, 2026
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