mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 00:40:14 +01:00
📝 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:
parent
e9a85d5c65
commit
7c9a868d34
13 changed files with 760 additions and 88 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,9 +43,6 @@ export default function ArtifactTabs({
|
|||
className={cn('flex-grow overflow-auto')}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{isMermaid ? (
|
||||
<MermaidMarkdown content={content} isSubmitting={isSubmitting} />
|
||||
) : (
|
||||
<ArtifactCodeEditor
|
||||
files={files}
|
||||
fileKey={fileKey}
|
||||
|
|
@ -57,14 +51,12 @@ export default function ArtifactTabs({
|
|||
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}
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
219
client/src/hooks/Artifacts/__tests__/useArtifactProps.test.ts
Normal file
219
client/src/hooks/Artifacts/__tests__/useArtifactProps.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
185
client/src/utils/__tests__/markdown.test.ts
Normal file
185
client/src/utils/__tests__/markdown.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
{
|
||||
const mermaidDependencies = {
|
||||
mermaid: '^11.4.1',
|
||||
'react-zoom-pan-pinch': '^3.6.1',
|
||||
},
|
||||
standardDependencies,
|
||||
);
|
||||
'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,
|
||||
};
|
||||
|
||||
|
|
|
|||
256
client/src/utils/markdown.ts
Normal file
256
client/src/utils/markdown.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue