Add markdown rendering with syntax highlighting and copy buttons

- Implement markdown parser using marked with Shiki syntax highlighting
- Add CodeBlockInline component for tool call outputs with syntax highlighting
- Add Markdown component for assistant message text with code blocks
- Add ThemeProvider for light/dark mode support
- Add copy buttons to all code blocks (markdown and tool calls)
- Support 20+ languages: TypeScript, JavaScript, Python, Bash, JSON, HTML, CSS, C++, Java, C, C#, Rust, Go, PHP, Ruby, Swift, Kotlin, and more
- Auto-detect language from file extensions in tool call outputs
- Apply consistent styling for code blocks across the application
- Fix whitespace handling in markdown-rendered text
- Add language labels to all code blocks
This commit is contained in:
Shantur Rathore
2025-10-23 10:07:17 +01:00
parent 7cf0f9a179
commit b836086978
9 changed files with 1130 additions and 35 deletions

98
src/lib/markdown.ts Normal file
View File

@@ -0,0 +1,98 @@
import { marked } from "marked"
import { getHighlighter, type Highlighter } from "shiki"
let highlighter: Highlighter | null = null
let currentTheme: "light" | "dark" = "light"
async function getOrCreateHighlighter() {
if (!highlighter) {
highlighter = await getHighlighter({
themes: ["github-light", "github-dark"],
langs: [
"typescript",
"javascript",
"python",
"bash",
"json",
"html",
"css",
"markdown",
"yaml",
"sql",
"rust",
"go",
"cpp",
"c",
"java",
"csharp",
"php",
"ruby",
"swift",
"kotlin",
"diff",
"shell",
],
})
}
return highlighter
}
export async function initMarkdown(isDark: boolean) {
const hl = await getOrCreateHighlighter()
currentTheme = isDark ? "dark" : "light"
marked.setOptions({
breaks: true,
gfm: true,
})
const renderer = new marked.Renderer()
renderer.code = (code: string, lang: string | undefined) => {
const encodedCode = encodeURIComponent(code)
const escapedLang = lang ? escapeHtml(lang) : ""
if (!lang) {
return `<div class="markdown-code-block" data-language="" data-code="${encodedCode}"><pre><code>${escapeHtml(code)}</code></pre></div>`
}
try {
const html = hl.codeToHtml(code, {
lang,
theme: isDark ? "github-dark" : "github-light",
})
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${html}</div>`
} catch {
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}"><pre><code class="language-${escapedLang}">${escapeHtml(code)}</code></pre></div>`
}
}
renderer.link = (href: string, title: string | null | undefined, text: string) => {
const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""
return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
}
renderer.codespan = (code: string) => {
return `<code class="inline-code">${escapeHtml(code)}</code>`
}
marked.use({ renderer })
}
export async function renderMarkdown(content: string): Promise<string> {
if (!highlighter) {
await initMarkdown(currentTheme === "dark")
}
return marked.parse(content) as Promise<string>
}
function escapeHtml(text: string): string {
const map: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
}
return text.replace(/[&<>"']/g, (m) => map[m])
}

49
src/lib/theme.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { createContext, createSignal, useContext, onMount, type JSX } from "solid-js"
interface ThemeContextValue {
isDark: () => boolean
toggleTheme: () => void
setTheme: (dark: boolean) => void
}
const ThemeContext = createContext<ThemeContextValue>()
export function ThemeProvider(props: { children: JSX.Element }) {
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches
const savedTheme = localStorage.getItem("theme")
const initialDark = savedTheme ? savedTheme === "dark" : prefersDark
const [isDark, setIsDarkSignal] = createSignal(initialDark)
onMount(() => {
if (isDark()) {
document.documentElement.setAttribute("data-theme", "dark")
} else {
document.documentElement.removeAttribute("data-theme")
}
})
const setTheme = (dark: boolean) => {
setIsDarkSignal(dark)
localStorage.setItem("theme", dark ? "dark" : "light")
if (dark) {
document.documentElement.setAttribute("data-theme", "dark")
} else {
document.documentElement.removeAttribute("data-theme")
}
}
const toggleTheme = () => {
setTheme(!isDark())
}
return <ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>{props.children}</ThemeContext.Provider>
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error("useTheme must be used within ThemeProvider")
}
return context
}