mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-21 23:26:34 +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
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue