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)}\n`; + const code = `<${paragraphTag}>${this.parser.parseInline(tokens)}\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 }` |