mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-28 02:26:33 +01:00
⛓️💥 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
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:
parent
59873e74fc
commit
b66f7914a5
4 changed files with 224 additions and 198 deletions
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue