Skip to content
Open
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
18 changes: 18 additions & 0 deletions packages/x-markdown/src/XMarkdown/__test__/Renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<div><script>alert("xss")</script>content</div>';
const result = (renderer as any).safeSanitize(testHtml, {});

expect(result).toBe(testHtml);

global.window = originalWindow;
});
});
});
30 changes: 30 additions & 0 deletions packages/x-markdown/src/XMarkdown/__test__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<XMarkdown content="# Title\n\nContent" streaming={{ hasNextChunk: true }} />,
);

expect(container.innerHTML).toContain('<xmd-footer></xmd-footer>');
});

it('should not append footer tag when hasNextChunk is false', () => {
const { container } = render(
<XMarkdown content="# Title\n\nContent" streaming={{ hasNextChunk: false }} />,
);

expect(container.innerHTML).not.toContain('<xmd-footer></xmd-footer>');
});

it('should remove footer tag when hasNextChunk changes from true to false', () => {
const { rerender, container } = render(
<XMarkdown content="# Title\n\nContent" streaming={{ hasNextChunk: true }} />,
);

expect(container.innerHTML).toContain('<xmd-footer></xmd-footer>');

rerender(<XMarkdown content="# Title\n\nContent" streaming={{ hasNextChunk: false }} />);

expect(container.innerHTML).not.toContain('<xmd-footer></xmd-footer>');
});
});
22 changes: 15 additions & 7 deletions packages/x-markdown/src/XMarkdown/core/Parser.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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);
}
Expand All @@ -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];
Expand All @@ -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)}"` : '';

Expand Down
14 changes: 13 additions & 1 deletion packages/x-markdown/src/XMarkdown/core/Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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),
Expand Down
51 changes: 38 additions & 13 deletions packages/x-markdown/src/XMarkdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ const XMarkdown: React.FC<XMarkdownProps> = React.memo((props) => {
const {
streaming,
config,
components,
paragraphTag,
content,
children,
Expand All @@ -20,8 +19,18 @@ const XMarkdown: React.FC<XMarkdownProps> = 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();

Expand All @@ -38,18 +47,29 @@ const XMarkdown: React.FC<XMarkdownProps> = React.memo((props) => {
);

// ============================ Streaming ============================
const displayContent = useStreaming(content || children || '', streaming);
const output = useStreaming(content || children || '', streaming);

const displayContent = useMemo(() => {
if (streaming?.hasNextChunk) {
return output + '<xmd-footer></xmd-footer>';
}

return !footer ? output : output.replace(/<xmd-footer><\/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><\/xmd-footer>/g, '') || '';
}

return code;
},
});

const renderer = useMemo(
() =>
Expand All @@ -62,11 +82,16 @@ const XMarkdown: React.FC<XMarkdownProps> = 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 (
<div className={mergedCls} style={mergedStyle}>
Expand Down
5 changes: 5 additions & 0 deletions packages/x-markdown/src/XMarkdown/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComponentProps> | keyof JSX.IntrinsicElements;
}

export type { XMarkdownProps, Token, Tokens, StreamStatus, ComponentProps, StreamingOption };
87 changes: 87 additions & 0 deletions packages/x/docs/x-markdown/demo/codeDemo/md-footer.tsx
Original file line number Diff line number Diff line change
@@ -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 <Spin />;
});

const App = () => {
const [index, setIndex] = React.useState(0);
const [speed, setSpeed] = React.useState(10);
const [hasNextChunk, setHasNextChunk] = React.useState(false);
const timer = React.useRef<ReturnType<typeof setTimeout> | 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 (
<Flex vertical gap="small">
<Flex justify="flex-end" align="center" gap="small">
<div>Render Speed</div>
<Slider
min={1}
max={20}
step={1}
tooltip={{
open: true,
formatter: () => `${20 * speed} ms`,
}}
onChange={onChange}
value={speed}
style={{ width: 150 }}
/>
<Button onClick={() => setIndex(0)}>Re-Render</Button>
</Flex>

<Bubble
content={content.slice(0, index)}
contentRender={(content) => (
<XMarkdown streaming={{ hasNextChunk }} footer={Footer} className={className}>
{content}
</XMarkdown>
)}
variant="outlined"
/>
</Flex>
);
};

export default App;
2 changes: 2 additions & 0 deletions packages/x/docs/x-markdown/examples.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Used for rendering streaming Markdown format returned by LLM.
<code src="./demo/codeDemo/link.tsx" title="Chinese Link Handling"></code>
<code src="./demo/codeDemo/xss.tsx" title="XSS Defense"></code>
<code src="./demo/codeDemo/open-links-in-new-tab.tsx" description="Open links in new tab." title="Open Links in New Tab"></code>
<code src="./demo/codeDemo/md-footer.tsx" title="Streaming Footer"></code>

## API

Expand All @@ -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<string, React.ComponentType<ComponentProps> \| keyof JSX.IntrinsicElements>`, see [details](/x-markdowns/components) | - |
| footer | React component for customizing the rendering of footer content during streaming rendering | `React.ComponentType<ComponentProps> \| 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 }` |
Expand Down
2 changes: 2 additions & 0 deletions packages/x/docs/x-markdown/examples.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ order: 2
<code src="./demo/codeDemo/link.tsx" title="中文链接处理"></code>
<code src="./demo/codeDemo/xss.tsx" title="XSS 防御"></code>
<code src="./demo/codeDemo/open-links-in-new-tab.tsx" description="链接在新标签页打开。" title="新标签页打开链接"></code>
<code src="./demo/codeDemo/md-footer.tsx" title="流式渲染 Footer"></code>

## API

Expand All @@ -30,6 +31,7 @@ order: 2
| content | 需要渲染的 Markdown 内容 | `string` | - |
| children | Markdown 内容,作为 `content` 属性的别名 | `string` | - |
| components | 用于替换 HTML 元素的自定义 React 组件 | `Record<string, React.ComponentType<ComponentProps> \| keyof JSX.IntrinsicElements>`,查看[详情](/x-markdowns/components-cn) | - |
| footer | 用于在流式渲染过程中自定义渲染尾部内容的 React 组件 | `React.ComponentType<ComponentProps> \| 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 }` |
Expand Down
Loading