Skip to content

Commit 0be2013

Browse files
authored
feat: add button to view rendered markdown blocks (#40)
* feat: add button to view rendered markdown blocks * feat: support rendering html blocks * fix: run html in iframe to prevent xss * refactor: move language checking to function
1 parent 59ef067 commit 0be2013

8 files changed

+405
-19
lines changed

src/components/ChatMessage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export const ChatMessage: FC<Props> = ({
3030
// Initialize the renderer and parser once the contentRef is available
3131
useEffect(() => {
3232
if (!contentRef.current) return;
33-
const renderer = customRenderer(contentRef.current);
33+
const renderer = customRenderer(contentRef.current, false, true);
3434
renderer$.set(ObservableHint.opaque(renderer));
3535
const parser = smd.parser(renderer);
3636
parser$.set(ObservableHint.opaque(parser));

src/components/CodeDisplay.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export function CodeDisplay({
2525
if (!code) return;
2626

2727
// Use our shared utility
28-
setHighlightedCode(highlightCode(code, language, true, 1000));
28+
setHighlightedCode(highlightCode(code, language, true, 1000).code);
2929
}, [code, language]);
3030

3131
if (!code) return null;

src/components/TabbedCodeBlock.tsx

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { useState, useRef, useEffect } from 'react';
2+
import { getCodeBlockEmoji } from '@/utils/markdownUtils';
3+
import * as smd from '@/utils/smd';
4+
import { customRenderer } from '@/utils/markdownRenderer';
5+
6+
/**
7+
* Props for the TabbedCodeBlock component
8+
*/
9+
interface TabbedCodeBlockProps {
10+
/** The language or file extension of the code */
11+
language?: string;
12+
/** The raw code content to display */
13+
codeText: string;
14+
/** The code element to display */
15+
code: HTMLElement;
16+
}
17+
18+
/**
19+
* TabbedCodeBlock renders a code block with tabs for switching between
20+
* code view and preview. Preview is only available for markdown and HTML content.
21+
*
22+
* For markdown content, it uses the streaming markdown parser to render the preview.
23+
* For HTML content, it uses a sandboxed iframe to prevent XSS attacks.
24+
*/
25+
export const TabbedCodeBlock: React.FC<TabbedCodeBlockProps> = ({ language, codeText, code }) => {
26+
const [activeTab, setActiveTab] = useState<'code' | 'preview'>('code');
27+
const previewRef = useRef<HTMLDivElement>(null);
28+
const [renderError, setRenderError] = useState<string | null>(null);
29+
30+
const emoji = getCodeBlockEmoji(language || '');
31+
32+
// Check if this is a markdown code block
33+
const isMarkdown = language?.toLowerCase() === 'md' || language?.toLowerCase() === 'markdown';
34+
35+
// Determine if preview should be available - only for markdown or HTML
36+
const hasPreview = isMarkdown || language?.toLowerCase() === 'html';
37+
38+
// Handle markdown rendering when the preview tab is selected
39+
useEffect(() => {
40+
if (previewRef.current && codeText && isMarkdown) {
41+
try {
42+
// Clear previous content and error state
43+
previewRef.current.innerHTML = '';
44+
setRenderError(null);
45+
46+
// Use streaming markdown parser for markdown content
47+
const renderer = customRenderer(previewRef.current);
48+
const parser = smd.parser(renderer);
49+
smd.parser_write(parser, codeText);
50+
smd.parser_end(parser);
51+
} catch (error) {
52+
console.error('Error rendering preview:', error);
53+
setRenderError('Failed to render preview');
54+
}
55+
}
56+
}, [codeText, language, isMarkdown]);
57+
58+
return (
59+
<div>
60+
<div className="flex bg-gray-50 !p-0 dark:bg-gray-900">
61+
<button
62+
className={`px-4 py-2 text-xs font-normal transition-colors ${
63+
activeTab === 'code'
64+
? 'border-b-2 border-blue-500 bg-white dark:bg-gray-800'
65+
: 'hover:bg-gray-100 dark:hover:bg-gray-800'
66+
}`}
67+
onClick={() => setActiveTab('code')}
68+
>
69+
{emoji} Code
70+
</button>
71+
{hasPreview && (
72+
<button
73+
className={`px-4 py-2 text-xs font-normal transition-colors ${
74+
activeTab === 'preview'
75+
? 'border-b-2 border-blue-500 bg-white dark:bg-gray-800'
76+
: 'hover:bg-gray-100 dark:hover:bg-gray-800'
77+
}`}
78+
onClick={() => setActiveTab('preview')}
79+
>
80+
👁️ Preview
81+
</button>
82+
)}
83+
</div>
84+
85+
<div className="overflow-auto">
86+
<pre
87+
className={`m-0 overflow-auto ${activeTab === 'preview' ? '!hidden' : ''}`}
88+
dangerouslySetInnerHTML={{ __html: code.outerHTML }}
89+
/>
90+
{renderError && activeTab === 'preview' && (
91+
<div className="rounded-md bg-red-50 p-4 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400">
92+
{renderError}
93+
</div>
94+
)}
95+
<div
96+
ref={previewRef}
97+
className={`preview-content prose prose-sm dark:prose-invert mx-4 !mt-[-10px] mb-4 max-w-none ${
98+
activeTab === 'code' || language === 'html' || renderError ? '!hidden' : ''
99+
}`}
100+
></div>
101+
{language === 'html' && (
102+
<iframe
103+
sandbox="allow-scripts"
104+
srcDoc={codeText}
105+
className={`min-h-[450px] w-full border-0 ${activeTab === 'code' || renderError ? '!hidden' : ''}`}
106+
title="HTML Preview"
107+
></iframe>
108+
)}
109+
</div>
110+
</div>
111+
);
112+
};

src/index.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@
140140
}
141141

142142
.chat-message details {
143-
@apply my-2 overflow-hidden rounded-md border border-border text-sm dark:border-stone-950;
143+
@apply my-2 overflow-hidden rounded-md border border-border bg-white font-sans text-sm dark:border-stone-950 dark:bg-slate-900;
144144
}
145145

146146
.chat-message details summary {
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import '@testing-library/jest-dom';
2+
import { customRenderer } from '../markdownRenderer';
3+
import * as smd from '@/utils/smd';
4+
5+
function parse(markdown: string, streaming: boolean = false, log: boolean = false) {
6+
const div = document.createElement('div');
7+
const renderer = customRenderer(div, log);
8+
const parser = smd.parser(renderer);
9+
10+
if (streaming) {
11+
for (const char of markdown) {
12+
smd.parser_write(parser, char);
13+
}
14+
} else {
15+
smd.parser_write(parser, markdown);
16+
}
17+
18+
smd.parser_end(parser);
19+
return div;
20+
}
21+
22+
describe('simple text rendering', () => {
23+
const markdown = 'This is a test';
24+
it('all at once, should render standard text', () => {
25+
const div = parse(markdown);
26+
27+
// Output should be:
28+
// <div>
29+
// <p>
30+
// This is a test
31+
// </p>
32+
// </div>
33+
expect(div.innerHTML).toBe('<p>This is a test</p>');
34+
});
35+
36+
it('should render standard text, one character at a time', () => {
37+
const div = parse(markdown, true);
38+
39+
// Output should be:
40+
// <div>
41+
// <p>
42+
// This is a test
43+
// </p>
44+
// </div>
45+
expect(div.innerHTML).toBe('<p>This is a test</p>');
46+
});
47+
});
48+
49+
describe('renderThinkingBlocks', () => {
50+
it('should handle one thinking block at start of text', () => {
51+
const markdown = `<thinking>This is a thinking block</thinking> some other text`;
52+
53+
// Output should be:
54+
// <div>
55+
// <p>
56+
// <details type="thinking" open="true">
57+
// <summary>💭 Thinking</summary>
58+
// <div style="white-space: pre-wrap; padding-top: 0px; padding-bottom: 0.5rem;">This is a thinking block</div>
59+
// </details>
60+
// some other text
61+
// </p>
62+
// </div>
63+
const expected =
64+
'<p><details type="thinking" open="true"><summary>💭 Thinking</summary><div style="white-space: pre-wrap; padding-top: 0px; padding-bottom: 0.5rem;">This is a thinking block</div></details> some other text</p>';
65+
66+
let div = parse(markdown);
67+
expect(div.innerHTML).toBe(expected);
68+
69+
div = parse(markdown, true);
70+
expect(div.innerHTML).toBe(expected);
71+
});
72+
73+
it('should handle one thinking block at end of text', () => {
74+
const markdown = `some other text <thinking>This is a thinking block</thinking>`;
75+
76+
// Output should be:
77+
// <div>
78+
// <p>
79+
// some other text
80+
// <details type="thinking" open="true">
81+
// <summary>💭 Thinking</summary>
82+
// <div style="white-space: pre-wrap; padding-top: 0px; padding-bottom: 0.5rem;">This is a thinking block</div>
83+
// </details>
84+
// </p>
85+
// </div>
86+
const expected =
87+
'<p>some other text <details type="thinking" open="true"><summary>💭 Thinking</summary><div style="white-space: pre-wrap; padding-top: 0px; padding-bottom: 0.5rem;">This is a thinking block</div></details></p>';
88+
89+
let div = parse(markdown);
90+
expect(div.innerHTML).toBe(expected);
91+
92+
div = parse(markdown, true);
93+
expect(div.innerHTML).toBe(expected);
94+
});
95+
96+
it('should handle multiple thinking blocks', () => {
97+
const markdown = `some other text <thinking>This is a thinking block</thinking> some other text <thinking>This is another thinking block</thinking>`;
98+
99+
// Output should be:
100+
// <div>
101+
// <p>
102+
// some other text
103+
// <details type="thinking" open="true">
104+
// <summary>💭 Thinking</summary>
105+
// <div style="white-space: pre-wrap; padding-top: 0px; padding-bottom: 0.5rem;">This is a thinking block</div>
106+
// </details>
107+
// some other text
108+
// <details type="thinking" open="true">
109+
// <summary>💭 Thinking</summary>
110+
// <div style="white-space: pre-wrap; padding-top: 0px; padding-bottom: 0.5rem;">This is another thinking block</div>
111+
// </details>
112+
// </p>
113+
// </div>
114+
const expected =
115+
'<p>some other text <details type="thinking" open="true"><summary>💭 Thinking</summary><div style="white-space: pre-wrap; padding-top: 0px; padding-bottom: 0.5rem;">This is a thinking block</div></details> some other text <details type="thinking" open="true"><summary>💭 Thinking</summary><div style="white-space: pre-wrap; padding-top: 0px; padding-bottom: 0.5rem;">This is another thinking block</div></details></p>';
116+
117+
const div = parse(markdown);
118+
expect(div.innerHTML).toBe(expected);
119+
120+
const div2 = parse(markdown, true);
121+
expect(div2.innerHTML).toBe(expected);
122+
});
123+
});
124+
125+
describe('renderCodeBlocks', () => {
126+
it('should handle one python code block at start of text', () => {
127+
const markdown = `\`\`\`python\nThis is a code block\n\`\`\` some other text`;
128+
129+
const expected =
130+
'<details open="true"><summary>💻 python</summary><pre><code class="hljs language-python">This <span class="hljs-keyword">is</span> a code block</code></pre></details><p>some other text</p>';
131+
132+
let div = parse(markdown);
133+
expect(div.innerHTML).toBe(expected);
134+
135+
div = parse(markdown, true);
136+
expect(div.innerHTML).toBe(expected);
137+
});
138+
139+
it('should handle one python code block at end of text', () => {
140+
const markdown = `some other text\n\`\`\`python\nThis is a code block\n\`\`\``;
141+
142+
const expected =
143+
'<p>some other text<details open="true"><summary>💻 python</summary><pre><code class="hljs language-python">This <span class="hljs-keyword">is</span> a code block</code></pre></details></p>';
144+
145+
let div = parse(markdown);
146+
expect(div.innerHTML).toBe(expected);
147+
148+
div = parse(markdown, true);
149+
expect(div.innerHTML).toBe(expected);
150+
});
151+
});
152+
153+
describe('renderMarkdownBlocks', () => {
154+
it('should handle one markdown block at start of text', () => {
155+
const markdown = `\`\`\`markdown\nThis is a markdown block\n\`\`\`\nsome other text`;
156+
157+
const expected =
158+
'<details open="true"><summary>💻 markdown</summary><pre><code class="hljs language-markdown">This is a markdown block</code></pre></details><p>some other text</p>';
159+
160+
let div = parse(markdown, false, true);
161+
console.log(div.innerHTML);
162+
expect(div.innerHTML).toBe(expected);
163+
164+
div = parse(markdown, true, false);
165+
expect(div.innerHTML).toBe(expected);
166+
});
167+
});

src/utils/highlightUtils.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,28 @@
11
import hljs from 'highlight.js';
22
//import 'highlight.js/styles/github-dark.css';
33

4+
export interface HighlightResult {
5+
// The highlighted code
6+
code: string;
7+
// The detected language of the code
8+
language?: string;
9+
}
10+
411
/**
512
* Highlight code with syntax highlighting
613
* @param code The code to highlight
714
* @param language Optional language specification
815
* @param autoDetect Whether to attempt language auto-detection if language not specified
916
* @param maxDetectionLength Maximum length for auto-detection (for performance)
10-
* @returns HTML string with highlighted code
17+
* @returns HighlightResult object with highlighted code and language
1118
*/
1219
export function highlightCode(
1320
code: string,
1421
language?: string,
1522
autoDetect = true,
16-
maxDetectionLength = 1000
17-
): string {
18-
if (!code) return '';
23+
maxDetectionLength = 10000
24+
): HighlightResult {
25+
if (!code) return { code: '' };
1926

2027
try {
2128
// Normalize language name
@@ -26,21 +33,22 @@ export function highlightCode(
2633

2734
// Check if language is supported
2835
if (hljs.getLanguage(language)) {
29-
return hljs.highlight(code, { language }).value;
36+
return { code: hljs.highlight(code, { language }).value, language };
3037
}
3138
}
3239

3340
// Auto-detect language for shorter code blocks if requested
3441
if (autoDetect && code.length < maxDetectionLength) {
35-
return hljs.highlightAuto(code).value;
42+
const result = hljs.highlightAuto(code);
43+
return { code: result.value, language: result.language };
3644
}
3745

3846
// Return escaped plain text for larger blocks or when auto-detect is disabled
39-
return code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
47+
return { code: code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') };
4048
} catch (error) {
4149
console.warn('Syntax highlighting error:', error);
4250
// Return escaped text on error
43-
return code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
51+
return { code: code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') };
4452
}
4553
}
4654

0 commit comments

Comments
 (0)