⛓️💥 fix: Replace React Markdown Artifact Renderer with Static HTML (#12337)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions

The react-markdown dependency chain uses Node.js subpath imports
(vfile/lib/#minpath) that Sandpack's bundler cannot resolve, breaking
markdown artifact preview. Switch to a self-contained static HTML page
using marked.js from CDN, eliminating the React bootstrap overhead and
the problematic dependency resolution.
This commit is contained in:
Danny Avila 2026-03-20 13:31:08 -04:00 committed by GitHub
parent 59873e74fc
commit b66f7914a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 224 additions and 198 deletions

View file

@ -19,7 +19,7 @@ describe('useArtifactProps', () => {
const { result } = renderHook(() => useArtifactProps({ artifact }));
expect(result.current.fileKey).toBe('content.md');
expect(result.current.template).toBe('react-ts');
expect(result.current.template).toBe('static');
});
it('should handle text/plain type with content.md as fileKey', () => {
@ -31,7 +31,7 @@ describe('useArtifactProps', () => {
const { result } = renderHook(() => useArtifactProps({ artifact }));
expect(result.current.fileKey).toBe('content.md');
expect(result.current.template).toBe('react-ts');
expect(result.current.template).toBe('static');
});
it('should include content.md in files with original markdown', () => {
@ -46,7 +46,7 @@ describe('useArtifactProps', () => {
expect(result.current.files['content.md']).toBe(markdownContent);
});
it('should include App.tsx with wrapped markdown renderer', () => {
it('should include index.html with static markdown rendering', () => {
const artifact = createArtifact({
type: 'text/markdown',
content: '# Test',
@ -54,8 +54,8 @@ describe('useArtifactProps', () => {
const { result } = renderHook(() => useArtifactProps({ artifact }));
expect(result.current.files['App.tsx']).toContain('MarkdownRenderer');
expect(result.current.files['App.tsx']).toContain('import React from');
expect(result.current.files['index.html']).toContain('<!DOCTYPE html>');
expect(result.current.files['index.html']).toContain('marked.parse');
});
it('should include all required markdown files', () => {
@ -66,12 +66,8 @@ describe('useArtifactProps', () => {
const { result } = renderHook(() => useArtifactProps({ artifact }));
// Check all required files are present
expect(result.current.files['content.md']).toBeDefined();
expect(result.current.files['App.tsx']).toBeDefined();
expect(result.current.files['index.tsx']).toBeDefined();
expect(result.current.files['/components/ui/MarkdownRenderer.tsx']).toBeDefined();
expect(result.current.files['markdown.css']).toBeDefined();
expect(result.current.files['index.html']).toBeDefined();
});
it('should escape special characters in markdown content', () => {
@ -82,13 +78,11 @@ describe('useArtifactProps', () => {
const { result } = renderHook(() => useArtifactProps({ artifact }));
// Original content should be preserved in content.md
expect(result.current.files['content.md']).toContain('`const x = 1;`');
expect(result.current.files['content.md']).toContain('C:\\Users');
// App.tsx should have escaped content
expect(result.current.files['App.tsx']).toContain('\\`');
expect(result.current.files['App.tsx']).toContain('\\\\');
expect(result.current.files['index.html']).toContain('\\`');
expect(result.current.files['index.html']).toContain('\\\\');
});
it('should handle empty markdown content', () => {
@ -112,7 +106,7 @@ describe('useArtifactProps', () => {
expect(result.current.files['content.md']).toBe('# No content provided');
});
it('should provide react-markdown dependency', () => {
it('should have no custom dependencies for markdown (uses CDN)', () => {
const artifact = createArtifact({
type: 'text/markdown',
content: '# Test',
@ -120,9 +114,8 @@ describe('useArtifactProps', () => {
const { result } = renderHook(() => useArtifactProps({ artifact }));
expect(result.current.sharedProps.customSetup?.dependencies).toHaveProperty('react-markdown');
expect(result.current.sharedProps.customSetup?.dependencies).toHaveProperty('remark-gfm');
expect(result.current.sharedProps.customSetup?.dependencies).toHaveProperty('remark-breaks');
const deps = result.current.sharedProps.customSetup?.dependencies ?? {};
expect(deps).toEqual({});
});
it('should update files when content changes', () => {
@ -137,7 +130,6 @@ describe('useArtifactProps', () => {
expect(result.current.files['content.md']).toBe('# Original');
// Update the artifact content
const updatedArtifact = createArtifact({
...artifact,
content: '# Updated',
@ -201,8 +193,6 @@ describe('useArtifactProps', () => {
const { result } = renderHook(() => useArtifactProps({ artifact }));
// Language parameter should not affect markdown handling
// It checks the type directly, not the key
expect(result.current.fileKey).toBe('content.md');
expect(result.current.files['content.md']).toBe('# Test');
});
@ -214,7 +204,6 @@ describe('useArtifactProps', () => {
const { result } = renderHook(() => useArtifactProps({ artifact }));
// Should use default behavior
expect(result.current.template).toBe('static');
});
});

View file

@ -1,4 +1,4 @@
import { isSafeUrl, getMarkdownFiles } from '../markdown';
import { isSafeUrl, getMarkdownFiles, EMBEDDED_IS_SAFE_URL } from '../markdown';
describe('isSafeUrl', () => {
it('allows https URLs', () => {
@ -68,6 +68,37 @@ describe('isSafeUrl', () => {
});
});
describe('isSafeUrl sync verification', () => {
const embeddedFn = new Function('url', EMBEDDED_IS_SAFE_URL + '\nreturn isSafeUrl(url);') as (
url: string,
) => boolean;
const cases: [string, boolean][] = [
['https://example.com', true],
['http://example.com', true],
['mailto:a@b.com', true],
['tel:+1234567890', true],
['/relative', true],
['./relative', true],
['../up', true],
['#anchor', true],
['javascript:alert(1)', false],
[' javascript:void(0)', false],
['data:text/html,<b>x</b>', false],
['blob:http://x.com/uuid', false],
['vbscript:run', false],
['file:///etc/passwd', false],
['custom:payload', false],
['', false],
[' ', false],
];
it.each(cases)('embedded copy matches exported isSafeUrl for %j → %s', (url, expected) => {
expect(embeddedFn(url)).toBe(expected);
expect(isSafeUrl(url)).toBe(expected);
});
});
describe('markdown artifacts', () => {
describe('getMarkdownFiles', () => {
it('should return content.md with the original markdown content', () => {
@ -83,46 +114,26 @@ describe('markdown artifacts', () => {
expect(files['content.md']).toBe('# No content provided');
});
it('should include App.tsx with MarkdownRenderer component', () => {
it('should include index.html with static markdown rendering', () => {
const markdown = '# Test';
const files = getMarkdownFiles(markdown);
expect(files['App.tsx']).toContain('import React from');
expect(files['App.tsx']).toContain(
"import MarkdownRenderer from '/components/ui/MarkdownRenderer'",
);
expect(files['App.tsx']).toContain('<MarkdownRenderer content={');
expect(files['App.tsx']).toContain('export default App');
expect(files['index.html']).toContain('<!DOCTYPE html>');
expect(files['index.html']).toContain('marked.min.js');
expect(files['index.html']).toContain('marked.parse');
expect(files['index.html']).toContain('# Test');
});
it('should include index.tsx entry point', () => {
const markdown = '# Test';
const files = getMarkdownFiles(markdown);
expect(files['index.tsx']).toContain('import App from "./App"');
expect(files['index.tsx']).toContain('import "./styles.css"');
expect(files['index.tsx']).toContain('import "./markdown.css"');
expect(files['index.tsx']).toContain('createRoot');
it('should only produce content.md and index.html', () => {
const files = getMarkdownFiles('# Test');
expect(Object.keys(files).sort()).toEqual(['content.md', 'index.html']);
});
it('should include MarkdownRenderer component file', () => {
const markdown = '# Test';
const files = getMarkdownFiles(markdown);
expect(files['/components/ui/MarkdownRenderer.tsx']).toContain('import ReactMarkdown from');
expect(files['/components/ui/MarkdownRenderer.tsx']).toContain('MarkdownRendererProps');
expect(files['/components/ui/MarkdownRenderer.tsx']).toContain(
'export default MarkdownRenderer',
);
});
it('should include markdown.css with styling', () => {
const markdown = '# Test';
const files = getMarkdownFiles(markdown);
expect(files['markdown.css']).toContain('.markdown-body');
expect(files['markdown.css']).toContain('list-style-type: disc');
expect(files['markdown.css']).toContain('prefers-color-scheme: dark');
it('should include markdown CSS in index.html', () => {
const files = getMarkdownFiles('# Test');
expect(files['index.html']).toContain('.markdown-body');
expect(files['index.html']).toContain('list-style-type: disc');
expect(files['index.html']).toContain('prefers-color-scheme: dark');
});
describe('content escaping', () => {
@ -130,29 +141,36 @@ describe('markdown artifacts', () => {
const markdown = 'Here is some `inline code`';
const files = getMarkdownFiles(markdown);
expect(files['App.tsx']).toContain('\\`');
expect(files['index.html']).toContain('\\`');
});
it('should escape backslashes in markdown content', () => {
const markdown = 'Path: C:\\Users\\Test';
const files = getMarkdownFiles(markdown);
expect(files['App.tsx']).toContain('\\\\');
expect(files['index.html']).toContain('\\\\');
});
it('should escape dollar signs in markdown content', () => {
const markdown = 'Price: $100';
const files = getMarkdownFiles(markdown);
expect(files['App.tsx']).toContain('\\$');
expect(files['index.html']).toContain('\\$');
});
it('should handle code blocks with backticks', () => {
const markdown = '```js\nconsole.log("test");\n```';
const files = getMarkdownFiles(markdown);
// Should be escaped
expect(files['App.tsx']).toContain('\\`\\`\\`');
expect(files['index.html']).toContain('\\`\\`\\`');
});
it('should prevent </script> in content from breaking out of the script block', () => {
const markdown = 'Some content with </script><script>alert(1)</script>';
const files = getMarkdownFiles(markdown);
expect(files['index.html']).not.toContain('</script><script>alert(1)');
expect(files['index.html']).toContain('<\\/script');
});
});
@ -161,32 +179,27 @@ describe('markdown artifacts', () => {
const markdown = '- Item 1\n - Subitem 1\n - Subitem 2';
const files = getMarkdownFiles(markdown);
// The indentation normalization happens in wrapMarkdownRenderer
// It converts 2 spaces before list markers to 4 spaces
// Check that content.md preserves the original, but App.tsx has normalized content
expect(files['content.md']).toBe(markdown);
expect(files['App.tsx']).toContain('- Item 1');
expect(files['App.tsx']).toContain('Subitem 1');
expect(files['index.html']).toContain('- Item 1');
expect(files['index.html']).toContain('Subitem 1');
});
it('should handle numbered lists with 2-space indents', () => {
const markdown = '1. First\n 2. Second nested';
const files = getMarkdownFiles(markdown);
// Verify normalization occurred
expect(files['content.md']).toBe(markdown);
expect(files['App.tsx']).toContain('1. First');
expect(files['App.tsx']).toContain('2. Second nested');
expect(files['index.html']).toContain('1. First');
expect(files['index.html']).toContain('2. Second nested');
});
it('should not affect already 4-space indented lists', () => {
const markdown = '- Item 1\n - Subitem 1';
const files = getMarkdownFiles(markdown);
// Already normalized, should be preserved
expect(files['content.md']).toBe(markdown);
expect(files['App.tsx']).toContain('- Item 1');
expect(files['App.tsx']).toContain('Subitem 1');
expect(files['index.html']).toContain('- Item 1');
expect(files['index.html']).toContain('Subitem 1');
});
});
@ -196,7 +209,7 @@ describe('markdown artifacts', () => {
const files = getMarkdownFiles(longMarkdown);
expect(files['content.md']).toBe(longMarkdown);
expect(files['App.tsx']).toContain('Lorem ipsum');
expect(files['index.html']).toContain('Lorem ipsum');
});
it('should handle markdown with special characters', () => {
@ -229,41 +242,79 @@ describe('markdown artifacts', () => {
});
});
describe('markdown component structure', () => {
it('should generate a MarkdownRenderer component with safe markdown rendering', () => {
describe('static HTML structure', () => {
it('should generate a complete HTML document with marked.js', () => {
const files = getMarkdownFiles('# Test');
const rendererCode = files['/components/ui/MarkdownRenderer.tsx'];
const html = files['index.html'];
expect(rendererCode).toContain("import ReactMarkdown from 'react-markdown'");
expect(rendererCode).toContain("import remarkBreaks from 'remark-breaks'");
expect(rendererCode).toContain('skipHtml={true}');
expect(rendererCode).toContain('SAFE_PROTOCOLS');
expect(rendererCode).toContain('isSafeUrl');
expect(rendererCode).toContain('urlTransform={urlTransform}');
expect(rendererCode).toContain('remarkPlugins={remarkPlugins}');
expect(rendererCode).toContain('isSafeUrl(url) ? url : null');
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('<html lang="en">');
expect(html).toContain('<title>Markdown Preview</title>');
expect(html).toContain('marked.min.js');
expect(html).toContain('marked.use(');
expect(html).toContain('marked.parse(');
});
it('should embed isSafeUrl logic matching the exported version', () => {
it('should pin the CDN script to an exact version with SRI', () => {
const files = getMarkdownFiles('# Test');
const rendererCode = files['/components/ui/MarkdownRenderer.tsx'];
const html = files['index.html'];
expect(rendererCode).toContain("new Set(['http:', 'https:', 'mailto:', 'tel:'])");
expect(rendererCode).toContain('new URL(trimmed).protocol');
expect(rendererCode).toContain("trimmed.startsWith('/')");
expect(rendererCode).toContain("trimmed.startsWith('#')");
expect(rendererCode).toContain("trimmed.startsWith('.')");
expect(html).toMatch(/marked@\d+\.\d+\.\d+/);
expect(html).toContain('integrity="sha384-');
expect(html).toContain('crossorigin="anonymous"');
});
it('should pass markdown content to the Markdown component', () => {
it('should show an error message when marked fails to load', () => {
const files = getMarkdownFiles('# Test');
const html = files['index.html'];
expect(html).toContain("typeof marked === 'undefined'");
expect(html).toContain('failed to load');
expect(html).toContain('style="color:#e53e3e;padding:1rem"');
});
it('should strip raw HTML blocks via renderer override', () => {
const files = getMarkdownFiles('# Test');
const html = files['index.html'];
expect(html).toContain("html() { return ''; }");
});
it('should embed isSafeUrl logic in the HTML for link sanitization', () => {
const files = getMarkdownFiles('# Test');
const html = files['index.html'];
expect(html).toContain("new Set(['http:', 'https:', 'mailto:', 'tel:'])");
expect(html).toContain('isSafeUrl');
expect(html).toContain("trimmed.startsWith('/')");
expect(html).toContain("trimmed.startsWith('#')");
expect(html).toContain("trimmed.startsWith('.')");
});
it('should configure marked with GFM and line-break support', () => {
const files = getMarkdownFiles('# Test');
const html = files['index.html'];
expect(html).toContain('gfm: true');
expect(html).toContain('breaks: true');
});
it('should configure a custom renderer for link/image sanitization', () => {
const files = getMarkdownFiles('# Test');
const html = files['index.html'];
expect(html).toContain('renderer:');
expect(html).toContain('link(token)');
expect(html).toContain('image(token)');
});
it('should embed the markdown content in the HTML', () => {
const testContent = '# Heading\n- List item';
const files = getMarkdownFiles(testContent);
const appCode = files['App.tsx'];
const html = files['index.html'];
// The App.tsx should pass the content to MarkdownRenderer
expect(appCode).toContain('<MarkdownRenderer content={');
expect(appCode).toContain('# Heading');
expect(appCode).toContain('- List item');
expect(html).toContain('# Heading');
expect(html).toContain('- List item');
});
});
});

View file

@ -32,9 +32,9 @@ const artifactTemplate: Record<
'application/vnd.ant.react': 'react-ts',
'application/vnd.mermaid': 'react-ts',
'application/vnd.code-html': 'static',
'text/markdown': 'react-ts',
'text/md': 'react-ts',
'text/plain': 'react-ts',
'text/markdown': 'static',
'text/md': 'static',
'text/plain': 'static',
default: 'static',
// 'css': 'css',
// 'javascript': 'js',
@ -107,12 +107,6 @@ const mermaidDependencies = {
'@radix-ui/react-slot': '^1.1.0',
};
const markdownDependencies = {
'remark-gfm': '^4.0.0',
'remark-breaks': '^4.0.0',
'react-markdown': '^9.0.1',
};
const dependenciesMap: Record<
| keyof typeof artifactFilename
| 'application/vnd.mermaid'
@ -126,9 +120,9 @@ const dependenciesMap: Record<
'application/vnd.ant.react': standardDependencies,
'text/html': standardDependencies,
'application/vnd.code-html': standardDependencies,
'text/markdown': markdownDependencies,
'text/md': markdownDependencies,
'text/plain': markdownDependencies,
'text/markdown': {},
'text/md': {},
'text/plain': {},
default: standardDependencies,
};

View file

@ -1,11 +1,11 @@
import dedent from 'dedent';
const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
/**
* Allowlist-based URL validator for markdown artifact rendering.
* Mirrored verbatim in the markdownRenderer template string below
* any logic change MUST be applied to both copies.
* The logic body is duplicated verbatim into the generated static HTML
* template (`EMBEDDED_IS_SAFE_URL` constant below). Any behavioral change
* here MUST be applied to both copies. A sync-verification test in
* `markdown.test.ts` enforces this.
*/
export const isSafeUrl = (url: string): boolean => {
const trimmed = url.trim();
@ -22,76 +22,6 @@ export const isSafeUrl = (url: string): boolean => {
}
};
const markdownRenderer = dedent(`import React from 'react';
import remarkGfm from 'remark-gfm';
import remarkBreaks from 'remark-breaks';
import ReactMarkdown from 'react-markdown';
interface MarkdownRendererProps {
content: string;
}
/** Mirror of the exported isSafeUrl in markdown.ts — keep in sync. */
const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
const isSafeUrl = (url: string): boolean => {
const trimmed = url.trim();
if (!trimmed) return false;
if (trimmed.startsWith('/') || trimmed.startsWith('#') || trimmed.startsWith('.')) return true;
try {
return SAFE_PROTOCOLS.has(new URL(trimmed).protocol);
} catch {
return false;
}
};
const remarkPlugins = [remarkGfm, remarkBreaks];
const urlTransform = (url: string) => (isSafeUrl(url) ? url : null);
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content }) => {
return (
<div
className="markdown-body"
style={{
padding: '2rem',
margin: '1rem',
minHeight: '100vh'
}}
>
<ReactMarkdown
remarkPlugins={remarkPlugins}
skipHtml={true}
urlTransform={urlTransform}
>
{content}
</ReactMarkdown>
</div>
);
};
export default MarkdownRenderer;`);
const wrapMarkdownRenderer = (content: string) => {
// Normalize indentation: convert 2-space indents to 4-space for proper nesting
const normalizedContent = content.replace(/^( {2})(-|\d+\.)/gm, ' $2');
// Escape backticks, backslashes, and dollar signs in the content
const escapedContent = normalizedContent
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$/g, '\\$');
return dedent(`import React from 'react';
import MarkdownRenderer from '/components/ui/MarkdownRenderer';
const App = () => {
return <MarkdownRenderer content={\`${escapedContent}\`} />;
};
export default App;
`);
};
const markdownCSS = `
/* GitHub Markdown CSS - Light theme base */
.markdown-body {
@ -283,21 +213,83 @@ const markdownCSS = `
}
`;
export const getMarkdownFiles = (content: string) => {
/**
* Escapes content for safe embedding inside a JS template literal that
* lives within an HTML `<script>` block. Prevents the content from
* breaking out of the template literal or prematurely closing the
* surrounding `<script>` tag (which would allow arbitrary HTML injection).
*/
function escapeForTemplateLiteral(content: string): string {
return content
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$/g, '\\$')
.replace(/<\/script/gi, '<\\/script');
}
const MARKED_CDN = 'https://cdn.jsdelivr.net/npm/marked@15.0.12/marked.min.js';
const MARKED_SRI = 'sha384-948ahk4ZmxYVYOc+rxN1H2gM1EJ2Duhp7uHtZ4WSLkV4Vtx5MUqnV+l7u9B+jFv+';
/**
* Embedded JS copy of `isSafeUrl`. Keep in sync with the exported
* TypeScript version above `markdown.test.ts` has a sync-verification
* test that will break if the two copies diverge.
*/
export const EMBEDDED_IS_SAFE_URL = `const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
const isSafeUrl = (url) => {
const trimmed = url.trim();
if (!trimmed) return false;
if (trimmed.startsWith('/') || trimmed.startsWith('#') || trimmed.startsWith('.')) return true;
try { return SAFE_PROTOCOLS.has(new URL(trimmed).protocol); } catch(e) { return false; }
};`;
function generateMarkdownHtml(content: string): string {
const normalizedContent = content.replace(/^( {2})(-|\d+\.)/gm, ' $2');
const escapedContent = escapeForTemplateLiteral(normalizedContent);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Markdown Preview</title>
<style>${markdownCSS}</style>
</head>
<body>
<div class="markdown-body" id="content" style="padding:2rem;margin:1rem;min-height:100vh"></div>
<script src="${MARKED_CDN}" integrity="${MARKED_SRI}" crossorigin="anonymous"></script>
<script>
if (typeof marked === 'undefined') {
document.getElementById('content').innerHTML =
'<p style="color:#e53e3e;padding:1rem">Markdown renderer failed to load. Check network connectivity.</p>';
} else {
${EMBEDDED_IS_SAFE_URL}
marked.use({
gfm: true,
breaks: true,
renderer: {
html() { return ''; },
link(token) {
if (!isSafeUrl(token.href || '')) return '';
return false; // fall through to marked's default link renderer
},
image(token) {
if (!isSafeUrl(token.href || '')) return '';
return false; // fall through to marked's default image renderer
}
}
});
document.getElementById('content').innerHTML = marked.parse(\`${escapedContent}\`);
}
</script>
</body>
</html>`;
}
export const getMarkdownFiles = (content: string): Record<string, string> => {
const md = content || '# No content provided';
return {
'content.md': content || '# No content provided',
'App.tsx': wrapMarkdownRenderer(content),
'index.tsx': dedent(`import React, { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./styles.css";
import "./markdown.css";
import App from "./App";
const root = createRoot(document.getElementById("root"));
root.render(<App />);
;`),
'/components/ui/MarkdownRenderer.tsx': markdownRenderer,
'markdown.css': markdownCSS,
'content.md': md,
'index.html': generateMarkdownHtml(md),
};
};