diff --git a/packages/x-markdown/src/XMarkdown/__test__/Renderer.test.ts b/packages/x-markdown/src/XMarkdown/__test__/Renderer.test.ts
index 127b86e87..9e4adab6a 100644
--- a/packages/x-markdown/src/XMarkdown/__test__/Renderer.test.ts
+++ b/packages/x-markdown/src/XMarkdown/__test__/Renderer.test.ts
@@ -1133,4 +1133,22 @@ describe('Renderer', () => {
createElementSpy.mockClear();
});
});
+
+ describe('Renderer when window is undefined', () => {
+ it('use safeSanitize when window is undefined', () => {
+ const originalWindow = global.window;
+
+ // @ts-ignore
+ delete global.window;
+
+ const renderer = new Renderer({});
+
+ const testHtml = '
content
';
+ const result = (renderer as any).safeSanitize(testHtml, {});
+
+ expect(result).toBe(testHtml);
+
+ global.window = originalWindow;
+ });
+ });
});
diff --git a/packages/x-markdown/src/XMarkdown/__test__/index.test.tsx b/packages/x-markdown/src/XMarkdown/__test__/index.test.tsx
index 461807130..4d99c74ca 100644
--- a/packages/x-markdown/src/XMarkdown/__test__/index.test.tsx
+++ b/packages/x-markdown/src/XMarkdown/__test__/index.test.tsx
@@ -492,3 +492,33 @@ console.log(1);
expect(code).not.toHaveAttribute('data-state');
});
});
+
+describe('streaming', () => {
+ it('should append footer tag when hasNextChunk is true', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.innerHTML).toContain('');
+ });
+
+ it('should not append footer tag when hasNextChunk is false', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.innerHTML).not.toContain('');
+ });
+
+ it('should remove footer tag when hasNextChunk changes from true to false', () => {
+ const { rerender, container } = render(
+ ,
+ );
+
+ expect(container.innerHTML).toContain('');
+
+ rerender();
+
+ expect(container.innerHTML).not.toContain('');
+ });
+});
diff --git a/packages/x-markdown/src/XMarkdown/core/Parser.ts b/packages/x-markdown/src/XMarkdown/core/Parser.ts
index ceca03eac..cd5311228 100644
--- a/packages/x-markdown/src/XMarkdown/core/Parser.ts
+++ b/packages/x-markdown/src/XMarkdown/core/Parser.ts
@@ -1,10 +1,11 @@
-import { Marked, Renderer, Tokens } from 'marked';
+import { Marked, Renderer, RendererObject, Tokens } from 'marked';
import { XMarkdownProps } from '../interface';
type ParserOptions = {
markedConfig?: XMarkdownProps['config'];
paragraphTag?: string;
openLinksInNewTab?: boolean;
+ configureRenderCleaner?: (content: string, type: keyof RendererObject) => string;
};
export const other = {
@@ -50,8 +51,8 @@ class Parser {
this.markdownInstance = new Marked();
this.configureLinkRenderer();
- this.configureParagraphRenderer();
- this.configureCodeRenderer();
+ this.configureParagraphRenderer(options.configureRenderCleaner);
+ this.configureCodeRenderer(options.configureRenderCleaner);
// user config at last
this.markdownInstance.use(markedConfig);
}
@@ -69,19 +70,24 @@ class Parser {
this.markdownInstance.use({ renderer });
}
- public configureParagraphRenderer() {
+ public configureParagraphRenderer(
+ configureRenderCleaner: ParserOptions['configureRenderCleaner'] = (s) => s,
+ ) {
const { paragraphTag } = this.options;
if (!paragraphTag) return;
const renderer = {
paragraph(this: Renderer, { tokens }: Tokens.Paragraph) {
- return `<${paragraphTag}>${this.parser.parseInline(tokens)}${paragraphTag}>\n`;
+ const code = `<${paragraphTag}>${this.parser.parseInline(tokens)}${paragraphTag}>\n`;
+ return configureRenderCleaner(code, 'paragraph');
},
};
this.markdownInstance.use({ renderer });
}
- public configureCodeRenderer() {
+ public configureCodeRenderer(
+ configureRenderCleaner: ParserOptions['configureRenderCleaner'] = (s) => s,
+ ) {
const renderer = {
code({ text, raw, lang, escaped, codeBlockStyle }: Tokens.Code): string {
const langString = (lang || '').match(other.notSpaceStart)?.[0];
@@ -90,7 +96,9 @@ class Parser {
// if code is indented, it's done because it has no end tag
const streamStatus =
isIndentedCode || other.completeFencedCode.test(raw) ? 'done' : 'loading';
- const escapedCode = escaped ? code : escapeHtml(code, true);
+
+ const cleanedCode = configureRenderCleaner(code, 'code');
+ const escapedCode = escaped ? cleanedCode : escapeHtml(cleanedCode, true);
const classAttr = langString ? ` class="language-${escapeHtml(langString)}"` : '';
diff --git a/packages/x-markdown/src/XMarkdown/core/Renderer.ts b/packages/x-markdown/src/XMarkdown/core/Renderer.ts
index 7f1ca8440..3b6db29f8 100644
--- a/packages/x-markdown/src/XMarkdown/core/Renderer.ts
+++ b/packages/x-markdown/src/XMarkdown/core/Renderer.ts
@@ -20,6 +20,10 @@ class Renderer {
this.options = options;
}
+ private static isBrowserEnvironment(): boolean {
+ return typeof window !== 'undefined' && typeof document !== 'undefined';
+ }
+
/**
* Detect unclosed tags using regular expressions
*/
@@ -137,13 +141,21 @@ class Renderer {
});
}
+ private safeSanitize(htmlString: string, config: DOMPurifyConfig): string {
+ if (!Renderer.isBrowserEnvironment()) {
+ return htmlString;
+ }
+
+ return DOMPurify.sanitize(htmlString, config);
+ }
+
public processHtml(htmlString: string): React.ReactNode {
const unclosedTags = this.detectUnclosedTags(htmlString);
const cidRef = { current: 0 };
// Use DOMPurify to clean HTML while preserving custom components and target attributes
const purifyConfig = this.configureDOMPurify();
- const cleanHtml = DOMPurify.sanitize(htmlString, purifyConfig);
+ const cleanHtml = this.safeSanitize(htmlString, purifyConfig);
return parseHtml(cleanHtml, {
replace: this.createReplaceElement(unclosedTags, cidRef),
diff --git a/packages/x-markdown/src/XMarkdown/index.tsx b/packages/x-markdown/src/XMarkdown/index.tsx
index bc5e43c74..2b1d4b0f8 100644
--- a/packages/x-markdown/src/XMarkdown/index.tsx
+++ b/packages/x-markdown/src/XMarkdown/index.tsx
@@ -10,7 +10,6 @@ const XMarkdown: React.FC = React.memo((props) => {
const {
streaming,
config,
- components,
paragraphTag,
content,
children,
@@ -20,8 +19,18 @@ const XMarkdown: React.FC = React.memo((props) => {
style,
openLinksInNewTab,
dompurifyConfig,
+ footer,
} = props;
+ const components = useMemo(() => {
+ return Object.assign(
+ {
+ 'xmd-footer': footer,
+ },
+ props?.components ?? {},
+ );
+ }, [footer, props?.components]);
+
// ============================ style ============================
const { direction: contextDirection, getPrefixCls } = useXProviderContext();
@@ -38,18 +47,29 @@ const XMarkdown: React.FC = React.memo((props) => {
);
// ============================ Streaming ============================
- const displayContent = useStreaming(content || children || '', streaming);
+ const output = useStreaming(content || children || '', streaming);
+
+ const displayContent = useMemo(() => {
+ if (streaming?.hasNextChunk) {
+ return output + '';
+ }
+
+ return !footer ? output : output.replace(/<\/xmd-footer>/g, '') || '';
+ }, [streaming?.hasNextChunk, output, footer]);
// ============================ Render ============================
- const parser = useMemo(
- () =>
- new Parser({
- markedConfig: config,
- paragraphTag,
- openLinksInNewTab,
- }),
- [config, paragraphTag, openLinksInNewTab],
- );
+ const parser = new Parser({
+ markedConfig: config,
+ paragraphTag,
+ openLinksInNewTab,
+ configureRenderCleaner: (code: string, type) => {
+ if (type === 'code') {
+ return !footer ? code : code.replace(/<\/xmd-footer>/g, '') || '';
+ }
+
+ return code;
+ },
+ });
const renderer = useMemo(
() =>
@@ -62,11 +82,16 @@ const XMarkdown: React.FC = React.memo((props) => {
);
const htmlString = useMemo(() => {
- if (!displayContent) return '';
+ if (!displayContent) {
+ return '';
+ }
+
return parser.parse(displayContent);
}, [displayContent, parser]);
- if (!displayContent) return null;
+ if (!displayContent) {
+ return null;
+ }
return (
diff --git a/packages/x-markdown/src/XMarkdown/interface.ts b/packages/x-markdown/src/XMarkdown/interface.ts
index dc73e88a6..f08ab2345 100644
--- a/packages/x-markdown/src/XMarkdown/interface.ts
+++ b/packages/x-markdown/src/XMarkdown/interface.ts
@@ -142,6 +142,11 @@ interface XMarkdownProps {
* @description DOMPurify configuration for HTML sanitization and XSS protection
*/
dompurifyConfig?: DOMPurifyConfig;
+ /**
+ * @description 用于在流式渲染过程中自定义渲染尾部内容的 React 组件
+ * @description Custom footer content
+ */
+ footer?: React.ComponentType
| keyof JSX.IntrinsicElements;
}
export type { XMarkdownProps, Token, Tokens, StreamStatus, ComponentProps, StreamingOption };
diff --git a/packages/x/docs/x-markdown/demo/codeDemo/md-footer.tsx b/packages/x/docs/x-markdown/demo/codeDemo/md-footer.tsx
new file mode 100644
index 000000000..30317a552
--- /dev/null
+++ b/packages/x/docs/x-markdown/demo/codeDemo/md-footer.tsx
@@ -0,0 +1,87 @@
+import { Bubble } from '@ant-design/x';
+import XMarkdown from '@ant-design/x-markdown';
+import { Button, Flex, Slider, Spin } from 'antd';
+import React from 'react';
+import { useIntl } from 'react-intl';
+import { useMarkdownTheme } from '../_utils';
+import { Adx_Markdown_En, Adx_Markdown_Zh } from '../_utils/adx-markdown';
+import '@ant-design/x-markdown/themes/light.css';
+import '@ant-design/x-markdown/themes/dark.css';
+
+const Footer = React.memo(() => {
+ return ;
+});
+
+const App = () => {
+ const [index, setIndex] = React.useState(0);
+ const [speed, setSpeed] = React.useState(10);
+ const [hasNextChunk, setHasNextChunk] = React.useState(false);
+ const timer = React.useRef | null>(null);
+ const [className] = useMarkdownTheme();
+ const { locale } = useIntl();
+ const content = locale === 'zh-CN' ? Adx_Markdown_Zh : Adx_Markdown_En;
+
+ const renderStream = React.useCallback(() => {
+ if (index >= content.length) {
+ if (timer.current) clearTimeout(timer.current);
+
+ setHasNextChunk(false);
+ return;
+ }
+
+ timer.current = setTimeout(() => {
+ setIndex((prev) => prev + 10);
+ renderStream();
+ }, 20 * speed);
+ }, [index, content.length, speed]);
+
+ const onChange = React.useCallback((newValue: number) => {
+ setSpeed(newValue);
+ }, []);
+
+ React.useEffect(() => {
+ if (index === content.length) return;
+
+ if (!hasNextChunk) {
+ setHasNextChunk(true);
+ }
+
+ renderStream();
+ return () => {
+ if (timer.current) clearTimeout(timer.current);
+ };
+ }, [index, hasNextChunk, content.length, renderStream]);
+
+ return (
+
+
+ Render Speed
+ `${20 * speed} ms`,
+ }}
+ onChange={onChange}
+ value={speed}
+ style={{ width: 150 }}
+ />
+
+
+
+ (
+
+ {content}
+
+ )}
+ variant="outlined"
+ />
+
+ );
+};
+
+export default App;
diff --git a/packages/x/docs/x-markdown/examples.en-US.md b/packages/x/docs/x-markdown/examples.en-US.md
index 66873aa60..c3816f248 100644
--- a/packages/x/docs/x-markdown/examples.en-US.md
+++ b/packages/x/docs/x-markdown/examples.en-US.md
@@ -21,6 +21,7 @@ Used for rendering streaming Markdown format returned by LLM.
+
## API
@@ -30,6 +31,7 @@ Used for rendering streaming Markdown format returned by LLM.
| content | Markdown content to render | `string` | - |
| children | Markdown content, alias for `content` prop | `string` | - |
| components | Custom React components to replace HTML elements | `Record \| keyof JSX.IntrinsicElements>`, see [details](/x-markdowns/components) | - |
+| footer | React component for customizing the rendering of footer content during streaming rendering | `React.ComponentType \| keyof JSX.IntrinsicElements` | - |
| paragraphTag | Custom HTML tag for paragraph elements to prevent validation errors when custom components contain block-level elements | `keyof JSX.IntrinsicElements` | `'p'` |
| streaming | Configuration for streaming rendering behavior | `StreamingOption`, see [syntax processing](/x-markdowns/streaming-syntax) and [animation effects](/x-markdowns/streaming-animation) | - |
| config | Marked.js configuration for Markdown parsing and extensions | [`MarkedExtension`](https://marked.js.org/using_advanced#options) | `{ gfm: true }` |
diff --git a/packages/x/docs/x-markdown/examples.zh-CN.md b/packages/x/docs/x-markdown/examples.zh-CN.md
index 0f849a935..576410ff0 100644
--- a/packages/x/docs/x-markdown/examples.zh-CN.md
+++ b/packages/x/docs/x-markdown/examples.zh-CN.md
@@ -21,6 +21,7 @@ order: 2
+
## API
@@ -30,6 +31,7 @@ order: 2
| content | 需要渲染的 Markdown 内容 | `string` | - |
| children | Markdown 内容,作为 `content` 属性的别名 | `string` | - |
| components | 用于替换 HTML 元素的自定义 React 组件 | `Record \| keyof JSX.IntrinsicElements>`,查看[详情](/x-markdowns/components-cn) | - |
+| footer | 用于在流式渲染过程中自定义渲染尾部内容的 React 组件 | `React.ComponentType \| keyof JSX.IntrinsicElements` | - |
| paragraphTag | 段落元素的自定义 HTML 标签,防止自定义组件包含块级元素时的验证错误 | `keyof JSX.IntrinsicElements` | `'p'` |
| streaming | 流式渲染行为的配置 | `StreamingOption`,查看[语法处理](/x-markdowns/streaming-syntax)和[动画效果](/x-markdowns/streaming-animation) | - |
| config | Markdown 解析和扩展的 Marked.js 配置 | [`MarkedExtension`](https://marked.js.org/using_advanced#options) | `{ gfm: true }` |