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:
98
src/lib/markdown.ts
Normal file
98
src/lib/markdown.ts
Normal 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> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
}
|
||||
return text.replace(/[&<>"']/g, (m) => map[m])
|
||||
}
|
||||
49
src/lib/theme.tsx
Normal file
49
src/lib/theme.tsx
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user