📝 feat: Add Markdown Rendering Support for Artifacts (#10049)

* Add Markdown rendering support for artifacts

* Add tests

* Remove custom code for mermaid

* Remove unnecessary dark mode hook

* refactor: optimize mermaid dependencies

- Added support for additional MIME types in artifact templates.
- Updated mermaidDependencies to include new packages: class-variance-authority, clsx, tailwind-merge, and @radix-ui/react-slot.
- Refactored zoom and refresh icons in MermaidDiagram component for improved clarity and maintainability.

* fix: add Markdown support for artifacts rendering

* feat: support 'text/md' as an additional MIME type for Markdown artifacts

* refactor: simplify markdownDependencies structure in artifacts utility

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Sebastien Bruel 2025-10-11 23:37:35 +09:00 committed by GitHub
parent e9a85d5c65
commit 7c9a868d34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 760 additions and 88 deletions

View file

@ -3,6 +3,7 @@ const { EModelEndpoint, ArtifactModes } = require('librechat-data-provider');
const { generateShadcnPrompt } = require('~/app/clients/prompts/shadcn-docs/generate');
const { components } = require('~/app/clients/prompts/shadcn-docs/components');
/** @deprecated */
// eslint-disable-next-line no-unused-vars
const artifactsPromptV1 = dedent`The assistant can create and reference artifacts during conversations.
@ -115,6 +116,7 @@ Here are some examples of correct usage of artifacts:
</assistant_response>
</example>
</examples>`;
const artifactsPrompt = dedent`The assistant can create and reference artifacts during conversations.
Artifacts are for substantial, self-contained content that users might modify or reuse, displayed in a separate UI window for clarity.
@ -165,6 +167,10 @@ Artifacts are for substantial, self-contained content that users might modify or
- SVG: "image/svg+xml"
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
- The assistant should specify the viewbox of the SVG rather than defining a width/height
- Markdown: "text/markdown" or "text/md"
- The user interface will render Markdown content placed within the artifact tags.
- Supports standard Markdown syntax including headers, lists, links, images, code blocks, tables, and more.
- Both "text/markdown" and "text/md" are accepted as valid MIME types for Markdown content.
- Mermaid Diagrams: "application/vnd.mermaid"
- The user interface will render Mermaid diagrams placed within the artifact tags.
- React Components: "application/vnd.react"
@ -366,6 +372,10 @@ Artifacts are for substantial, self-contained content that users might modify or
- SVG: "image/svg+xml"
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
- The assistant should specify the viewbox of the SVG rather than defining a width/height
- Markdown: "text/markdown" or "text/md"
- The user interface will render Markdown content placed within the artifact tags.
- Supports standard Markdown syntax including headers, lists, links, images, code blocks, tables, and more.
- Both "text/markdown" and "text/md" are accepted as valid MIME types for Markdown content.
- Mermaid Diagrams: "application/vnd.mermaid"
- The user interface will render Mermaid diagrams placed within the artifact tags.
- React Components: "application/vnd.react"

View file

@ -144,9 +144,10 @@ export const ArtifactCodeEditor = function ({
}
return {
...sharedOptions,
activeFile: '/' + fileKey,
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
};
}, [config, template]);
}, [config, template, fileKey]);
const [readOnly, setReadOnly] = useState(isSubmitting ?? false);
useEffect(() => {
setReadOnly(isSubmitting ?? false);

View file

@ -13,7 +13,6 @@ export const ArtifactPreview = memo(function ({
files,
fileKey,
template,
isMermaid,
sharedProps,
previewRef,
currentCode,
@ -21,7 +20,6 @@ export const ArtifactPreview = memo(function ({
}: {
files: ArtifactFiles;
fileKey: string;
isMermaid: boolean;
template: SandpackProviderProps['template'];
sharedProps: Partial<SandpackProviderProps>;
previewRef: React.MutableRefObject<SandpackPreviewRef>;
@ -56,15 +54,6 @@ export const ArtifactPreview = memo(function ({
return _options;
}, [startupConfig, template]);
const style: PreviewProps['style'] | undefined = useMemo(() => {
if (isMermaid) {
return {
backgroundColor: '#282C34',
};
}
return;
}, [isMermaid]);
if (Object.keys(artifactFiles).length === 0) {
return null;
}
@ -84,7 +73,6 @@ export const ArtifactPreview = memo(function ({
showRefreshButton={false}
tabIndex={0}
ref={previewRef}
style={style}
/>
</SandpackProvider>
);

View file

@ -8,17 +8,14 @@ import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll';
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
import { useGetStartupConfig } from '~/data-provider';
import { ArtifactPreview } from './ArtifactPreview';
import { MermaidMarkdown } from './MermaidMarkdown';
import { cn } from '~/utils';
export default function ArtifactTabs({
artifact,
isMermaid,
editorRef,
previewRef,
}: {
artifact: Artifact;
isMermaid: boolean;
editorRef: React.MutableRefObject<CodeEditorRef>;
previewRef: React.MutableRefObject<SandpackPreviewRef>;
}) {
@ -46,25 +43,20 @@ export default function ArtifactTabs({
className={cn('flex-grow overflow-auto')}
tabIndex={-1}
>
{isMermaid ? (
<MermaidMarkdown content={content} isSubmitting={isSubmitting} />
) : (
<ArtifactCodeEditor
files={files}
fileKey={fileKey}
template={template}
artifact={artifact}
editorRef={editorRef}
sharedProps={sharedProps}
/>
)}
<ArtifactCodeEditor
files={files}
fileKey={fileKey}
template={template}
artifact={artifact}
editorRef={editorRef}
sharedProps={sharedProps}
/>
</Tabs.Content>
<Tabs.Content value="preview" className="flex-grow overflow-auto" tabIndex={-1}>
<ArtifactPreview
files={files}
fileKey={fileKey}
template={template}
isMermaid={isMermaid}
previewRef={previewRef}
sharedProps={sharedProps}
currentCode={currentCode}

View file

@ -27,7 +27,6 @@ export default function Artifacts() {
const {
activeTab,
isMermaid,
setActiveTab,
currentIndex,
cycleArtifact,
@ -116,7 +115,6 @@ export default function Artifacts() {
</div>
{/* Content */}
<ArtifactTabs
isMermaid={isMermaid}
artifact={currentArtifact}
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}

View file

@ -1,11 +0,0 @@
import { CodeMarkdown } from './Code';
export function MermaidMarkdown({
content,
isSubmitting,
}: {
content: string;
isSubmitting: boolean;
}) {
return <CodeMarkdown content={`\`\`\`mermaid\n${content}\`\`\``} isSubmitting={isSubmitting} />;
}

View file

@ -0,0 +1,219 @@
import { renderHook } from '@testing-library/react';
import useArtifactProps from '../useArtifactProps';
import type { Artifact } from '~/common';
describe('useArtifactProps', () => {
const createArtifact = (partial: Partial<Artifact>): Artifact => ({
id: 'test-id',
lastUpdateTime: Date.now(),
...partial,
});
describe('markdown artifacts', () => {
it('should handle text/markdown type with content.md as fileKey', () => {
const artifact = createArtifact({
type: 'text/markdown',
content: '# Hello World\n\nThis is markdown.',
});
const { result } = renderHook(() => useArtifactProps({ artifact }));
expect(result.current.fileKey).toBe('content.md');
expect(result.current.template).toBe('react-ts');
});
it('should handle text/plain type with content.md as fileKey', () => {
const artifact = createArtifact({
type: 'text/plain',
content: '# Plain text as markdown',
});
const { result } = renderHook(() => useArtifactProps({ artifact }));
expect(result.current.fileKey).toBe('content.md');
expect(result.current.template).toBe('react-ts');
});
it('should include content.md in files with original markdown', () => {
const markdownContent = '# Test\n\n- Item 1\n- Item 2';
const artifact = createArtifact({
type: 'text/markdown',
content: markdownContent,
});
const { result } = renderHook(() => useArtifactProps({ artifact }));
expect(result.current.files['content.md']).toBe(markdownContent);
});
it('should include App.tsx with wrapped markdown renderer', () => {
const artifact = createArtifact({
type: 'text/markdown',
content: '# Test',
});
const { result } = renderHook(() => useArtifactProps({ artifact }));
expect(result.current.files['App.tsx']).toContain('MarkdownRenderer');
expect(result.current.files['App.tsx']).toContain('import React from');
});
it('should include all required markdown files', () => {
const artifact = createArtifact({
type: 'text/markdown',
content: '# Test',
});
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();
});
it('should escape special characters in markdown content', () => {
const artifact = createArtifact({
type: 'text/markdown',
content: 'Code: `const x = 1;`\nPath: C:\\Users',
});
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('\\\\');
});
it('should handle empty markdown content', () => {
const artifact = createArtifact({
type: 'text/markdown',
content: '',
});
const { result } = renderHook(() => useArtifactProps({ artifact }));
expect(result.current.files['content.md']).toBe('# No content provided');
});
it('should handle undefined markdown content', () => {
const artifact = createArtifact({
type: 'text/markdown',
});
const { result } = renderHook(() => useArtifactProps({ artifact }));
expect(result.current.files['content.md']).toBe('# No content provided');
});
it('should provide marked-react dependency', () => {
const artifact = createArtifact({
type: 'text/markdown',
content: '# Test',
});
const { result } = renderHook(() => useArtifactProps({ artifact }));
expect(result.current.sharedProps.customSetup?.dependencies).toHaveProperty('marked-react');
});
it('should update files when content changes', () => {
const artifact = createArtifact({
type: 'text/markdown',
content: '# Original',
});
const { result, rerender } = renderHook(({ artifact }) => useArtifactProps({ artifact }), {
initialProps: { artifact },
});
expect(result.current.files['content.md']).toBe('# Original');
// Update the artifact content
const updatedArtifact = createArtifact({
...artifact,
content: '# Updated',
});
rerender({ artifact: updatedArtifact });
expect(result.current.files['content.md']).toBe('# Updated');
});
});
describe('mermaid artifacts', () => {
it('should handle mermaid type with content.md as fileKey', () => {
const artifact = createArtifact({
type: 'application/vnd.mermaid',
content: 'graph TD\n A-->B',
});
const { result } = renderHook(() => useArtifactProps({ artifact }));
expect(result.current.fileKey).toBe('diagram.mmd');
expect(result.current.template).toBe('react-ts');
});
});
describe('react artifacts', () => {
it('should handle react type with App.tsx as fileKey', () => {
const artifact = createArtifact({
type: 'application/vnd.react',
content: 'export default () => <div>Test</div>',
});
const { result } = renderHook(() => useArtifactProps({ artifact }));
expect(result.current.fileKey).toBe('App.tsx');
expect(result.current.template).toBe('react-ts');
});
});
describe('html artifacts', () => {
it('should handle html type with index.html as fileKey', () => {
const artifact = createArtifact({
type: 'text/html',
content: '<html><body>Test</body></html>',
});
const { result } = renderHook(() => useArtifactProps({ artifact }));
expect(result.current.fileKey).toBe('index.html');
expect(result.current.template).toBe('static');
});
});
describe('edge cases', () => {
it('should handle artifact with language parameter', () => {
const artifact = createArtifact({
type: 'text/markdown',
language: 'en',
content: '# Test',
});
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');
});
it('should handle artifact with undefined type', () => {
const artifact = createArtifact({
content: '# Test',
});
const { result } = renderHook(() => useArtifactProps({ artifact }));
// Should use default behavior
expect(result.current.template).toBe('static');
});
});
});

View file

@ -3,11 +3,19 @@ import { removeNullishValues } from 'librechat-data-provider';
import type { Artifact } from '~/common';
import { getKey, getProps, getTemplate, getArtifactFilename } from '~/utils/artifacts';
import { getMermaidFiles } from '~/utils/mermaid';
import { getMarkdownFiles } from '~/utils/markdown';
export default function useArtifactProps({ artifact }: { artifact: Artifact }) {
const [fileKey, files] = useMemo(() => {
if (getKey(artifact.type ?? '', artifact.language).includes('mermaid')) {
return ['App.tsx', getMermaidFiles(artifact.content ?? '')];
const key = getKey(artifact.type ?? '', artifact.language);
const type = artifact.type ?? '';
if (key.includes('mermaid')) {
return ['diagram.mmd', getMermaidFiles(artifact.content ?? '')];
}
if (type === 'text/markdown' || type === 'text/md' || type === 'text/plain') {
return ['content.md', getMarkdownFiles(artifact.content ?? '')];
}
const fileKey = getArtifactFilename(artifact.type ?? '', artifact.language);

View file

@ -122,17 +122,8 @@ export default function useArtifacts() {
setCurrentArtifactId(orderedArtifactIds[newIndex]);
};
const isMermaid = useMemo(() => {
if (currentArtifact?.type == null) {
return false;
}
const key = getKey(currentArtifact.type, currentArtifact.language);
return key.includes('mermaid');
}, [currentArtifact?.type, currentArtifact?.language]);
return {
activeTab,
isMermaid,
setActiveTab,
currentIndex,
cycleArtifact,

View file

@ -0,0 +1,185 @@
import { getMarkdownFiles } from '../markdown';
describe('markdown artifacts', () => {
describe('getMarkdownFiles', () => {
it('should return content.md with the original markdown content', () => {
const markdown = '# Hello World\n\nThis is a test.';
const files = getMarkdownFiles(markdown);
expect(files['content.md']).toBe(markdown);
});
it('should return default content when markdown is empty', () => {
const files = getMarkdownFiles('');
expect(files['content.md']).toBe('# No content provided');
});
it('should include App.tsx with MarkdownRenderer component', () => {
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');
});
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 include MarkdownRenderer component file', () => {
const markdown = '# Test';
const files = getMarkdownFiles(markdown);
expect(files['/components/ui/MarkdownRenderer.tsx']).toContain('import Markdown 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');
});
describe('content escaping', () => {
it('should escape backticks in markdown content', () => {
const markdown = 'Here is some `inline code`';
const files = getMarkdownFiles(markdown);
expect(files['App.tsx']).toContain('\\`');
});
it('should escape backslashes in markdown content', () => {
const markdown = 'Path: C:\\Users\\Test';
const files = getMarkdownFiles(markdown);
expect(files['App.tsx']).toContain('\\\\');
});
it('should escape dollar signs in markdown content', () => {
const markdown = 'Price: $100';
const files = getMarkdownFiles(markdown);
expect(files['App.tsx']).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('\\`\\`\\`');
});
});
describe('list indentation normalization', () => {
it('should normalize 2-space indented lists to 4-space', () => {
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');
});
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');
});
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');
});
});
describe('edge cases', () => {
it('should handle very long markdown content', () => {
const longMarkdown = '# Test\n\n' + 'Lorem ipsum '.repeat(1000);
const files = getMarkdownFiles(longMarkdown);
expect(files['content.md']).toBe(longMarkdown);
expect(files['App.tsx']).toContain('Lorem ipsum');
});
it('should handle markdown with special characters', () => {
const markdown = '# Test & < > " \'';
const files = getMarkdownFiles(markdown);
expect(files['content.md']).toBe(markdown);
});
it('should handle markdown with unicode characters', () => {
const markdown = '# 你好 世界 🌍';
const files = getMarkdownFiles(markdown);
expect(files['content.md']).toBe(markdown);
});
it('should handle markdown with only whitespace', () => {
const markdown = ' \n\n ';
const files = getMarkdownFiles(markdown);
expect(files['content.md']).toBe(markdown);
});
it('should handle markdown with mixed line endings', () => {
const markdown = '# Line 1\r\n## Line 2\n### Line 3';
const files = getMarkdownFiles(markdown);
expect(files['content.md']).toBe(markdown);
});
});
});
describe('markdown component structure', () => {
it('should generate a MarkdownRenderer component that uses marked-react', () => {
const files = getMarkdownFiles('# Test');
const rendererCode = files['/components/ui/MarkdownRenderer.tsx'];
// Verify the component imports and uses Markdown from marked-react
expect(rendererCode).toContain("import Markdown from 'marked-react'");
expect(rendererCode).toContain('<Markdown gfm={true} breaks={true}>{content}</Markdown>');
});
it('should pass markdown content to the Markdown component', () => {
const testContent = '# Heading\n- List item';
const files = getMarkdownFiles(testContent);
const appCode = files['App.tsx'];
// The App.tsx should pass the content to MarkdownRenderer
expect(appCode).toContain('<MarkdownRenderer content={');
expect(appCode).toContain('# Heading');
expect(appCode).toContain('- List item');
});
});
});

View file

@ -6,10 +6,10 @@ import type {
} from '@codesandbox/sandpack-react';
const artifactFilename = {
'application/vnd.mermaid': 'App.tsx',
'application/vnd.react': 'App.tsx',
'text/html': 'index.html',
'application/vnd.code-html': 'index.html',
// mermaid and markdown types are handled separately in useArtifactProps.ts
default: 'index.html',
// 'css': 'css',
// 'javascript': 'js',
@ -19,13 +19,20 @@ const artifactFilename = {
};
const artifactTemplate: Record<
keyof typeof artifactFilename,
| keyof typeof artifactFilename
| 'application/vnd.mermaid'
| 'text/markdown'
| 'text/md'
| 'text/plain',
SandpackPredefinedTemplate | undefined
> = {
'text/html': 'static',
'application/vnd.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',
default: 'static',
// 'css': 'css',
// 'javascript': 'js',
@ -34,27 +41,6 @@ const artifactTemplate: Record<
// 'tsx': 'tsx',
};
export function getFileExtension(language?: string): string {
switch (language) {
case 'application/vnd.react':
return 'tsx';
case 'application/vnd.mermaid':
return 'mermaid';
case 'text/html':
return 'html';
// case 'jsx':
// return 'jsx';
// case 'tsx':
// return 'tsx';
// case 'html':
// return 'html';
// case 'css':
// return 'css';
default:
return 'txt';
}
}
export function getKey(type: string, language?: string): string {
return `${type}${(language?.length ?? 0) > 0 ? `-${language}` : ''}`;
}
@ -109,19 +95,34 @@ const standardDependencies = {
vaul: '^0.9.1',
};
const mermaidDependencies = Object.assign(
{
mermaid: '^11.4.1',
'react-zoom-pan-pinch': '^3.6.1',
},
standardDependencies,
);
const mermaidDependencies = {
mermaid: '^11.4.1',
'react-zoom-pan-pinch': '^3.6.1',
'class-variance-authority': '^0.6.0',
clsx: '^1.2.1',
'tailwind-merge': '^1.9.1',
'@radix-ui/react-slot': '^1.1.0',
};
const dependenciesMap: Record<keyof typeof artifactFilename, object> = {
const markdownDependencies = {
'marked-react': '^2.0.0',
};
const dependenciesMap: Record<
| keyof typeof artifactFilename
| 'application/vnd.mermaid'
| 'text/markdown'
| 'text/md'
| 'text/plain',
Record<string, string>
> = {
'application/vnd.mermaid': mermaidDependencies,
'application/vnd.react': standardDependencies,
'text/html': standardDependencies,
'application/vnd.code-html': standardDependencies,
'text/markdown': markdownDependencies,
'text/md': markdownDependencies,
'text/plain': markdownDependencies,
default: standardDependencies,
};

View file

@ -0,0 +1,256 @@
import dedent from 'dedent';
const markdownRenderer = dedent(`import React, { useEffect, useState } from 'react';
import Markdown from 'marked-react';
interface MarkdownRendererProps {
content: string;
}
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content }) => {
return (
<div
className="markdown-body"
style={{
padding: '2rem',
margin: '1rem',
minHeight: '100vh'
}}
>
<Markdown gfm={true} breaks={true}>{content}</Markdown>
</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 {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
line-height: 1.5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
word-wrap: break-word;
color: #24292f;
background-color: #ffffff;
}
.markdown-body h1, .markdown-body h2 {
border-bottom: 1px solid #d0d7de;
margin: 0.6em 0;
}
.markdown-body h1 { font-size: 2em; margin: 0.67em 0; }
.markdown-body h2 { font-size: 1.5em; }
.markdown-body h3 { font-size: 1.25em; }
.markdown-body h4 { font-size: 1em; }
.markdown-body h5 { font-size: 0.875em; }
.markdown-body h6 { font-size: 0.85em; }
.markdown-body ul, .markdown-body ol {
list-style: revert !important;
padding-left: 2em !important;
margin-top: 0;
margin-bottom: 16px;
}
.markdown-body ul { list-style-type: disc !important; }
.markdown-body ol { list-style-type: decimal !important; }
.markdown-body ul ul { list-style-type: circle !important; }
.markdown-body ul ul ul { list-style-type: square !important; }
.markdown-body li { margin-top: 0.25em; }
.markdown-body li:has(> input[type="checkbox"]) {
list-style-type: none !important;
}
.markdown-body li > input[type="checkbox"] {
margin-right: 0.75em;
margin-left: -1.5em;
vertical-align: middle;
pointer-events: none;
width: 16px;
height: 16px;
}
.markdown-body .task-list-item {
list-style-type: none !important;
}
.markdown-body .task-list-item > input[type="checkbox"] {
margin-right: 0.75em;
margin-left: -1.5em;
vertical-align: middle;
pointer-events: none;
width: 16px;
height: 16px;
}
.markdown-body code {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
border-radius: 6px;
background-color: rgba(175, 184, 193, 0.2);
color: #24292f;
font-family: ui-monospace, monospace;
white-space: pre-wrap;
}
.markdown-body pre {
padding: 16px;
overflow: auto;
font-size: 85%;
line-height: 1.45;
border-radius: 6px;
margin-top: 0;
margin-bottom: 16px;
background-color: #f6f8fa;
color: #24292f;
}
.markdown-body pre code {
display: inline-block;
padding: 0;
margin: 0;
overflow: visible;
line-height: inherit;
word-wrap: normal;
background-color: transparent;
border: 0;
}
.markdown-body a {
text-decoration: none;
color: #0969da;
}
.markdown-body a:hover {
text-decoration: underline;
}
.markdown-body table {
border-spacing: 0;
border-collapse: collapse;
display: block;
width: max-content;
max-width: 100%;
overflow: auto;
}
.markdown-body table thead {
background-color: #f6f8fa;
}
.markdown-body table th, .markdown-body table td {
padding: 6px 13px;
border: 1px solid #d0d7de;
}
.markdown-body blockquote {
padding: 0 1em;
border-left: 0.25em solid #d0d7de;
margin: 0 0 16px 0;
color: #57606a;
}
.markdown-body hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
border: 0;
background-color: #d0d7de;
}
.markdown-body img {
max-width: 100%;
box-sizing: content-box;
}
/* Dark theme */
@media (prefers-color-scheme: dark) {
.markdown-body {
color: #c9d1d9;
background-color: #0d1117;
}
.markdown-body h1, .markdown-body h2 {
border-bottom-color: #21262d;
}
.markdown-body code {
background-color: rgba(110, 118, 129, 0.4);
color: #c9d1d9;
}
.markdown-body pre {
background-color: #161b22;
color: #c9d1d9;
}
.markdown-body a {
color: #58a6ff;
}
.markdown-body table thead {
background-color: #161b22;
}
.markdown-body table th, .markdown-body table td {
border-color: #30363d;
}
.markdown-body blockquote {
border-left-color: #3b434b;
color: #8b949e;
}
.markdown-body hr {
background-color: #21262d;
}
}
`;
export const getMarkdownFiles = (content: string) => {
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,
};
};

View file

@ -7,9 +7,34 @@ import {
ReactZoomPanPinchRef,
} from "react-zoom-pan-pinch";
import mermaid from "mermaid";
import { ZoomIn, ZoomOut, RefreshCw } from "lucide-react";
import { Button } from "/components/ui/button";
const ZoomIn = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<line x1="21" x2="16.65" y1="21" y2="16.65"/>
<line x1="11" x2="11" y1="8" y2="14"/>
<line x1="8" x2="14" y1="11" y2="11"/>
</svg>
);
const ZoomOut = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<line x1="21" x2="16.65" y1="21" y2="16.65"/>
<line x1="8" x2="14" y1="11" y2="11"/>
</svg>
);
const RefreshCw = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M8 16H3v5"/>
</svg>
);
interface MermaidDiagramProps {
content: string;
}
@ -181,21 +206,21 @@ const MermaidDiagram: React.FC<MermaidDiagramProps> = ({ content }) => {
</TransformComponent>
<div className="absolute bottom-2 right-2 flex space-x-2">
<Button onClick={() => zoomIn(0.1)} variant="outline" size="icon">
<ZoomIn className="h-4 w-4" />
<ZoomIn />
</Button>
<Button
onClick={() => zoomOut(0.1)}
variant="outline"
size="icon"
>
<ZoomOut className="h-4 w-4" />
<ZoomOut />
</Button>
<Button
onClick={centerAndFitDiagram}
variant="outline"
size="icon"
>
<RefreshCw className="h-4 w-4" />
<RefreshCw />
</Button>
</div>
</>
@ -217,12 +242,20 @@ export default App = () => (
`);
};
const mermaidCSS = `
body {
background-color: #282C34;
}
`;
export const getMermaidFiles = (content: string) => {
return {
'diagram.mmd': content || '# No mermaid diagram content provided',
'App.tsx': wrapMermaidDiagram(content),
'index.tsx': dedent(`import React, { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./styles.css";
import "./mermaid.css";
import App from "./App";
@ -230,5 +263,6 @@ const root = createRoot(document.getElementById("root"));
root.render(<App />);
;`),
'/components/ui/MermaidDiagram.tsx': mermaid,
'mermaid.css': mermaidCSS,
};
};