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:
129
src/components/code-block-inline.tsx
Normal file
129
src/components/code-block-inline.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { createSignal, onMount, Show } from "solid-js"
|
||||||
|
import { getHighlighter, type Highlighter } from "shiki"
|
||||||
|
import { useTheme } from "../lib/theme"
|
||||||
|
|
||||||
|
interface CodeBlockInlineProps {
|
||||||
|
code: string
|
||||||
|
language?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let highlighter: Highlighter | null = null
|
||||||
|
|
||||||
|
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 function CodeBlockInline(props: CodeBlockInlineProps) {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
const [html, setHtml] = createSignal("")
|
||||||
|
const [copied, setCopied] = createSignal(false)
|
||||||
|
const [ready, setReady] = createSignal(false)
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const hl = await getOrCreateHighlighter()
|
||||||
|
setReady(true)
|
||||||
|
updateHighlight(hl)
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateHighlight = async (hl: Highlighter) => {
|
||||||
|
if (!props.language) {
|
||||||
|
setHtml(`<pre><code>${escapeHtml(props.code)}</code></pre>`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const highlighted = hl.codeToHtml(props.code, {
|
||||||
|
lang: props.language,
|
||||||
|
theme: isDark() ? "github-dark" : "github-light",
|
||||||
|
})
|
||||||
|
setHtml(highlighted)
|
||||||
|
} catch {
|
||||||
|
setHtml(`<pre><code>${escapeHtml(props.code)}</code></pre>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyCode = async () => {
|
||||||
|
await navigator.clipboard.writeText(props.code)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show
|
||||||
|
when={ready()}
|
||||||
|
fallback={
|
||||||
|
<pre class="tool-call-content">
|
||||||
|
<code>{props.code}</code>
|
||||||
|
</pre>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="code-block-inline">
|
||||||
|
<div class="code-block-header">
|
||||||
|
<Show when={props.language}>
|
||||||
|
<span class="code-block-language">{props.language}</span>
|
||||||
|
</Show>
|
||||||
|
<button onClick={copyCode} class="code-block-copy">
|
||||||
|
<svg
|
||||||
|
class="copy-icon"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="copy-text">
|
||||||
|
<Show when={copied()} fallback="Copy">
|
||||||
|
Copied!
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div innerHTML={html()} />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
}
|
||||||
|
return text.replace(/[&<>"']/g, (m) => map[m])
|
||||||
|
}
|
||||||
95
src/components/markdown.tsx
Normal file
95
src/components/markdown.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { createEffect, createSignal, onMount, Show } from "solid-js"
|
||||||
|
import { initMarkdown, renderMarkdown } from "../lib/markdown"
|
||||||
|
|
||||||
|
interface MarkdownProps {
|
||||||
|
content: string
|
||||||
|
isDark?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Markdown(props: MarkdownProps) {
|
||||||
|
const [html, setHtml] = createSignal("")
|
||||||
|
const [ready, setReady] = createSignal(false)
|
||||||
|
let containerRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await initMarkdown(props.isDark ?? false)
|
||||||
|
setReady(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(async () => {
|
||||||
|
if (ready()) {
|
||||||
|
const rendered = await renderMarkdown(props.content)
|
||||||
|
setHtml(rendered)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(async () => {
|
||||||
|
if (props.isDark !== undefined) {
|
||||||
|
await initMarkdown(props.isDark)
|
||||||
|
if (ready()) {
|
||||||
|
const rendered = await renderMarkdown(props.content)
|
||||||
|
setHtml(rendered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const currentHtml = html()
|
||||||
|
if (containerRef && currentHtml) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const codeBlocks = containerRef?.querySelectorAll(".markdown-code-block")
|
||||||
|
|
||||||
|
codeBlocks?.forEach((block) => {
|
||||||
|
const existing = block.querySelector(".code-block-header")
|
||||||
|
if (existing) return
|
||||||
|
|
||||||
|
const lang = block.getAttribute("data-language")
|
||||||
|
const encodedCode = block.getAttribute("data-code")
|
||||||
|
|
||||||
|
const header = document.createElement("div")
|
||||||
|
header.className = "code-block-header"
|
||||||
|
|
||||||
|
const languageSpan = lang
|
||||||
|
? `<span class="code-block-language">${lang}</span>`
|
||||||
|
: '<span class="code-block-language"></span>'
|
||||||
|
|
||||||
|
header.innerHTML = `
|
||||||
|
${languageSpan}
|
||||||
|
<button class="code-block-copy" data-code="${encodedCode || ""}">
|
||||||
|
<svg class="copy-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="copy-text">Copy</span>
|
||||||
|
</button>
|
||||||
|
`
|
||||||
|
block.insertBefore(header, block.firstChild)
|
||||||
|
|
||||||
|
const button = header.querySelector(".code-block-copy")
|
||||||
|
if (button) {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
const code = button.getAttribute("data-code")
|
||||||
|
if (code) {
|
||||||
|
const decodedCode = decodeURIComponent(code)
|
||||||
|
await navigator.clipboard.writeText(decodedCode)
|
||||||
|
const copyText = button.querySelector(".copy-text")
|
||||||
|
if (copyText) {
|
||||||
|
copyText.textContent = "Copied!"
|
||||||
|
setTimeout(() => {
|
||||||
|
copyText.textContent = "Copy"
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={ready()} fallback={<div class="text-gray-500">Loading...</div>}>
|
||||||
|
<div ref={containerRef} class="prose prose-sm dark:prose-invert max-w-none" innerHTML={html()} />
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import { Show, Match, Switch } from "solid-js"
|
import { Show, Match, Switch } from "solid-js"
|
||||||
import ToolCall from "./tool-call"
|
import ToolCall from "./tool-call"
|
||||||
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
|
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
|
||||||
|
import { Markdown } from "./markdown"
|
||||||
|
import { useTheme } from "../lib/theme"
|
||||||
|
|
||||||
interface MessagePartProps {
|
interface MessagePartProps {
|
||||||
part: any
|
part: any
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MessagePart(props: MessagePartProps) {
|
export default function MessagePart(props: MessagePartProps) {
|
||||||
|
const { isDark } = useTheme()
|
||||||
const partType = () => props.part?.type || ""
|
const partType = () => props.part?.type || ""
|
||||||
const reasoningId = () => `reasoning-${props.part?.id || ""}`
|
const reasoningId = () => `reasoning-${props.part?.id || ""}`
|
||||||
const isReasoningExpanded = () => isItemExpanded(reasoningId())
|
const isReasoningExpanded = () => isItemExpanded(reasoningId())
|
||||||
@@ -20,7 +23,9 @@ export default function MessagePart(props: MessagePartProps) {
|
|||||||
<Switch>
|
<Switch>
|
||||||
<Match when={partType() === "text"}>
|
<Match when={partType() === "text"}>
|
||||||
<Show when={!props.part.synthetic && props.part.text}>
|
<Show when={!props.part.synthetic && props.part.text}>
|
||||||
<div class="message-text">{props.part.text}</div>
|
<div class="message-text">
|
||||||
|
<Markdown content={props.part.text} isDark={isDark()} />
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
|
|
||||||
@@ -40,7 +45,9 @@ export default function MessagePart(props: MessagePartProps) {
|
|||||||
<span class="reasoning-label">Reasoning</span>
|
<span class="reasoning-label">Reasoning</span>
|
||||||
</div>
|
</div>
|
||||||
<Show when={isReasoningExpanded()}>
|
<Show when={isReasoningExpanded()}>
|
||||||
<div class="message-text mt-2">{props.part.text || ""}</div>
|
<div class="message-text mt-2">
|
||||||
|
<Markdown content={props.part.text || ""} isDark={isDark()} />
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createSignal, Show, For, createEffect } from "solid-js"
|
import { createSignal, Show, For, createEffect } from "solid-js"
|
||||||
import { isToolCallExpanded, toggleToolCallExpanded } from "../stores/tool-call-state"
|
import { isToolCallExpanded, toggleToolCallExpanded } from "../stores/tool-call-state"
|
||||||
|
import { CodeBlockInline } from "./code-block-inline"
|
||||||
|
|
||||||
interface ToolCallProps {
|
interface ToolCallProps {
|
||||||
toolCall: any
|
toolCall: any
|
||||||
@@ -59,6 +60,42 @@ function getRelativePath(path: string): string {
|
|||||||
return parts.slice(-1)[0] || path
|
return parts.slice(-1)[0] || path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLanguageFromPath(path: string): string | undefined {
|
||||||
|
if (!path) return undefined
|
||||||
|
const ext = path.split(".").pop()?.toLowerCase()
|
||||||
|
const langMap: Record<string, string> = {
|
||||||
|
ts: "typescript",
|
||||||
|
tsx: "typescript",
|
||||||
|
js: "javascript",
|
||||||
|
jsx: "javascript",
|
||||||
|
py: "python",
|
||||||
|
sh: "bash",
|
||||||
|
bash: "bash",
|
||||||
|
json: "json",
|
||||||
|
html: "html",
|
||||||
|
css: "css",
|
||||||
|
md: "markdown",
|
||||||
|
yaml: "yaml",
|
||||||
|
yml: "yaml",
|
||||||
|
sql: "sql",
|
||||||
|
rs: "rust",
|
||||||
|
go: "go",
|
||||||
|
cpp: "cpp",
|
||||||
|
cc: "cpp",
|
||||||
|
cxx: "cpp",
|
||||||
|
hpp: "cpp",
|
||||||
|
h: "cpp",
|
||||||
|
c: "c",
|
||||||
|
java: "java",
|
||||||
|
cs: "csharp",
|
||||||
|
php: "php",
|
||||||
|
rb: "ruby",
|
||||||
|
swift: "swift",
|
||||||
|
kt: "kotlin",
|
||||||
|
}
|
||||||
|
return ext ? langMap[ext] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
export default function ToolCall(props: ToolCallProps) {
|
export default function ToolCall(props: ToolCallProps) {
|
||||||
const toolCallId = () => props.toolCallId || props.toolCall?.id || ""
|
const toolCallId = () => props.toolCallId || props.toolCall?.id || ""
|
||||||
const expanded = () => isToolCallExpanded(toolCallId())
|
const expanded = () => isToolCallExpanded(toolCallId())
|
||||||
@@ -263,11 +300,8 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
if (preview && input.filePath) {
|
if (preview && input.filePath) {
|
||||||
const lines = preview.split("\n")
|
const lines = preview.split("\n")
|
||||||
const truncated = lines.slice(0, 6).join("\n")
|
const truncated = lines.slice(0, 6).join("\n")
|
||||||
return (
|
const language = getLanguageFromPath(input.filePath)
|
||||||
<pre class="tool-call-content">
|
return <CodeBlockInline code={truncated} language={language} />
|
||||||
<code>{truncated}</code>
|
|
||||||
</pre>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -281,9 +315,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
if (diff) {
|
if (diff) {
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-diff">
|
<div class="tool-call-diff">
|
||||||
<pre class="tool-call-content">
|
<CodeBlockInline code={diff} language="diff" />
|
||||||
<code>{diff}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -298,11 +330,8 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
if (input.content && input.filePath) {
|
if (input.content && input.filePath) {
|
||||||
const lines = input.content.split("\n")
|
const lines = input.content.split("\n")
|
||||||
const truncated = lines.slice(0, 10).join("\n")
|
const truncated = lines.slice(0, 10).join("\n")
|
||||||
return (
|
const language = getLanguageFromPath(input.filePath)
|
||||||
<pre class="tool-call-content">
|
return <CodeBlockInline code={truncated} language={language} />
|
||||||
<code>{truncated}</code>
|
|
||||||
</pre>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -315,15 +344,10 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
const output = metadata.output
|
const output = metadata.output
|
||||||
|
|
||||||
if (input.command) {
|
if (input.command) {
|
||||||
|
const fullOutput = `$ ${input.command}${output ? "\n" + output : ""}`
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-bash">
|
<div class="tool-call-bash">
|
||||||
<pre class="tool-call-content">
|
<CodeBlockInline code={fullOutput} language="bash" />
|
||||||
<code>
|
|
||||||
$ {input.command}
|
|
||||||
{output && "\n"}
|
|
||||||
{output}
|
|
||||||
</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -338,11 +362,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
if (output) {
|
if (output) {
|
||||||
const lines = output.split("\n")
|
const lines = output.split("\n")
|
||||||
const truncated = lines.slice(0, 10).join("\n")
|
const truncated = lines.slice(0, 10).join("\n")
|
||||||
return (
|
return <CodeBlockInline code={truncated} language="markdown" />
|
||||||
<pre class="tool-call-content">
|
|
||||||
<code>{truncated}</code>
|
|
||||||
</pre>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -428,11 +448,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
if (output) {
|
if (output) {
|
||||||
const lines = output.split("\n")
|
const lines = output.split("\n")
|
||||||
const truncated = lines.slice(0, 10).join("\n")
|
const truncated = lines.slice(0, 10).join("\n")
|
||||||
return (
|
return <CodeBlockInline code={truncated} />
|
||||||
<pre class="tool-call-content">
|
|
||||||
<code>{truncated}</code>
|
|
||||||
</pre>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
|
|||||||
280
src/index.css
280
src/index.css
@@ -195,10 +195,8 @@ body {
|
|||||||
.message-text {
|
.message-text {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
hyphens: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-text pre {
|
.message-text pre {
|
||||||
@@ -753,3 +751,281 @@ body {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prose {
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose code {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
color: #1e293b;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose pre {
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose pre code {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose a {
|
||||||
|
color: #0066ff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose blockquote {
|
||||||
|
border-left: 4px solid #e0e0e0;
|
||||||
|
padding-left: 16px;
|
||||||
|
font-style: italic;
|
||||||
|
color: #666;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose ul,
|
||||||
|
.prose ol {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose li {
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h1 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 16px 0 12px 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h2 {
|
||||||
|
font-size: 1.25em;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 14px 0 10px 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h3 {
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 12px 0 8px 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose th {
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose td {
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose p {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .prose {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .prose code {
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .prose a {
|
||||||
|
color: #0080ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .prose blockquote {
|
||||||
|
border-left-color: #3a3a3a;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .prose th {
|
||||||
|
border-color: #3a3a3a;
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .prose td {
|
||||||
|
border-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .prose hr {
|
||||||
|
border-top-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-code-block {
|
||||||
|
position: relative;
|
||||||
|
margin: 12px 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .markdown-code-block {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .code-block-header {
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
border-bottom-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block-language {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .code-block-language {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block-copy {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .code-block-copy {
|
||||||
|
border-color: #3a3a3a;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block-copy:hover {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
border-color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .code-block-copy:hover {
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
border-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block-copy .copy-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block-copy .copy-text {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-code-block pre {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 12px !important;
|
||||||
|
overflow-x: auto;
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-code-block code {
|
||||||
|
background: transparent !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block-inline {
|
||||||
|
position: relative;
|
||||||
|
margin: 8px 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .code-block-inline {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block-inline .code-block-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .code-block-inline .code-block-header {
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
border-bottom-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block-inline pre {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 10px !important;
|
||||||
|
overflow-x: auto;
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block-inline code {
|
||||||
|
background: transparent !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
10
src/main.tsx
10
src/main.tsx
@@ -1,5 +1,6 @@
|
|||||||
import { render } from "solid-js/web"
|
import { render } from "solid-js/web"
|
||||||
import App from "./App"
|
import App from "./App"
|
||||||
|
import { ThemeProvider } from "./lib/theme"
|
||||||
import "./index.css"
|
import "./index.css"
|
||||||
|
|
||||||
const root = document.getElementById("root")
|
const root = document.getElementById("root")
|
||||||
@@ -8,4 +9,11 @@ if (!root) {
|
|||||||
throw new Error("Root element not found")
|
throw new Error("Root element not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
render(() => <App />, root)
|
render(
|
||||||
|
() => (
|
||||||
|
<ThemeProvider>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
),
|
||||||
|
root,
|
||||||
|
)
|
||||||
|
|||||||
417
tasks/done/012-markdown-rendering.md
Normal file
417
tasks/done/012-markdown-rendering.md
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
# Task 012: Markdown Rendering
|
||||||
|
|
||||||
|
**Status:** Todo
|
||||||
|
**Estimated Time:** 3-4 hours
|
||||||
|
**Phase:** 3 - Essential Features
|
||||||
|
**Dependencies:** 007 (Message Display)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Implement proper markdown rendering for assistant messages with syntax-highlighted code blocks. Replace basic text display with rich markdown formatting using Marked and Shiki.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Currently messages display as plain text. We need to parse and render markdown content from assistant messages, including:
|
||||||
|
|
||||||
|
- Headings, bold, italic, links
|
||||||
|
- Code blocks with syntax highlighting
|
||||||
|
- Inline code
|
||||||
|
- Lists (ordered and unordered)
|
||||||
|
- Blockquotes
|
||||||
|
- Tables (if needed)
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
1. **Markdown Parser Integration**
|
||||||
|
- Use `marked` library for markdown parsing
|
||||||
|
- Configure for safe HTML rendering
|
||||||
|
- Support GitHub-flavored markdown
|
||||||
|
|
||||||
|
2. **Syntax Highlighting**
|
||||||
|
- Use `shiki` for code block highlighting
|
||||||
|
- Support light and dark themes
|
||||||
|
- Support common languages: TypeScript, JavaScript, Python, Bash, JSON, HTML, CSS, etc.
|
||||||
|
|
||||||
|
3. **Code Block Features**
|
||||||
|
- Language label displayed
|
||||||
|
- Copy button on hover
|
||||||
|
- Line numbers (optional for MVP)
|
||||||
|
|
||||||
|
4. **Inline Code**
|
||||||
|
- Distinct background color
|
||||||
|
- Monospace font
|
||||||
|
- Subtle padding
|
||||||
|
|
||||||
|
5. **Links**
|
||||||
|
- Open in external browser
|
||||||
|
- Show external link icon
|
||||||
|
- Prevent opening in same window
|
||||||
|
|
||||||
|
### Technical Requirements
|
||||||
|
|
||||||
|
1. **Dependencies**
|
||||||
|
- Install `marked` and `@types/marked`
|
||||||
|
- Install `shiki`
|
||||||
|
- Install `marked-highlight` for integration
|
||||||
|
|
||||||
|
2. **Theme Support**
|
||||||
|
- Light mode: `github-light` theme
|
||||||
|
- Dark mode: `github-dark` theme
|
||||||
|
- Respect system theme preference
|
||||||
|
|
||||||
|
3. **Security**
|
||||||
|
- Sanitize HTML output
|
||||||
|
- No script execution
|
||||||
|
- Safe link handling
|
||||||
|
|
||||||
|
4. **Performance**
|
||||||
|
- Lazy load Shiki highlighter
|
||||||
|
- Cache highlighter instance
|
||||||
|
- Don't re-parse unchanged messages
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
### Step 1: Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/opencode-client
|
||||||
|
npm install marked shiki
|
||||||
|
npm install -D @types/marked
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Create Markdown Utility
|
||||||
|
|
||||||
|
Create `src/lib/markdown.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { marked } from "marked"
|
||||||
|
import { getHighlighter, type Highlighter } from "shiki"
|
||||||
|
|
||||||
|
let highlighter: Highlighter | null = null
|
||||||
|
|
||||||
|
async function getOrCreateHighlighter() {
|
||||||
|
if (!highlighter) {
|
||||||
|
highlighter = await getHighlighter({
|
||||||
|
themes: ["github-light", "github-dark"],
|
||||||
|
langs: ["typescript", "javascript", "python", "bash", "json", "html", "css", "markdown", "yaml", "sql"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return highlighter
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initMarkdown(isDark: boolean) {
|
||||||
|
const hl = await getOrCreateHighlighter()
|
||||||
|
|
||||||
|
marked.use({
|
||||||
|
async: false,
|
||||||
|
breaks: true,
|
||||||
|
gfm: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderer = new marked.Renderer()
|
||||||
|
|
||||||
|
renderer.code = (code: string, language: string | undefined) => {
|
||||||
|
if (!language) {
|
||||||
|
return `<pre><code>${escapeHtml(code)}</code></pre>`
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const html = hl.codeToHtml(code, {
|
||||||
|
lang: language,
|
||||||
|
theme: isDark ? "github-dark" : "github-light",
|
||||||
|
})
|
||||||
|
return html
|
||||||
|
} catch (e) {
|
||||||
|
return `<pre><code class="language-${language}">${escapeHtml(code)}</code></pre>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.link = (href: string, title: string | null, text: string) => {
|
||||||
|
const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""
|
||||||
|
return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
|
||||||
|
}
|
||||||
|
|
||||||
|
marked.use({ renderer })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderMarkdown(content: string): string {
|
||||||
|
return marked.parse(content) as string
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
}
|
||||||
|
return text.replace(/[&<>"']/g, (m) => map[m])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Create Markdown Component
|
||||||
|
|
||||||
|
Create `src/components/markdown.tsx`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createEffect, createSignal, onMount } from 'solid-js'
|
||||||
|
import { initMarkdown, renderMarkdown } from '../lib/markdown'
|
||||||
|
|
||||||
|
interface MarkdownProps {
|
||||||
|
content: string
|
||||||
|
isDark?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Markdown(props: MarkdownProps) {
|
||||||
|
const [html, setHtml] = createSignal('')
|
||||||
|
const [ready, setReady] = createSignal(false)
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await initMarkdown(props.isDark ?? false)
|
||||||
|
setReady(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (ready()) {
|
||||||
|
const rendered = renderMarkdown(props.content)
|
||||||
|
setHtml(rendered)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(async () => {
|
||||||
|
if (props.isDark !== undefined) {
|
||||||
|
await initMarkdown(props.isDark)
|
||||||
|
const rendered = renderMarkdown(props.content)
|
||||||
|
setHtml(rendered)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="prose prose-sm dark:prose-invert max-w-none"
|
||||||
|
innerHTML={html()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Add Copy Button to Code Blocks
|
||||||
|
|
||||||
|
Create `src/components/code-block.tsx`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createSignal, Show } from 'solid-js'
|
||||||
|
|
||||||
|
interface CodeBlockProps {
|
||||||
|
code: string
|
||||||
|
language?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeBlockWrapper(props: CodeBlockProps) {
|
||||||
|
const [copied, setCopied] = createSignal(false)
|
||||||
|
|
||||||
|
const copyCode = async () => {
|
||||||
|
await navigator.clipboard.writeText(props.code)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="relative group">
|
||||||
|
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={copyCode}
|
||||||
|
class="px-2 py-1 text-xs bg-gray-700 text-white rounded hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
<Show when={copied()} fallback="Copy">
|
||||||
|
Copied!
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Show when={props.language}>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 px-4 pt-2">
|
||||||
|
{props.language}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div innerHTML={props.code} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Update Message Component
|
||||||
|
|
||||||
|
Update `src/components/message-item.tsx` to use Markdown component:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Markdown } from './markdown'
|
||||||
|
|
||||||
|
// In the assistant message rendering:
|
||||||
|
<For each={textParts()}>
|
||||||
|
{(part) => (
|
||||||
|
<Markdown
|
||||||
|
content={part.content}
|
||||||
|
isDark={/* get from theme context */}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Add Markdown Styles
|
||||||
|
|
||||||
|
Add to `src/index.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Markdown prose styles */
|
||||||
|
.prose {
|
||||||
|
@apply text-gray-900 dark:text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose code {
|
||||||
|
@apply bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose pre {
|
||||||
|
@apply bg-gray-50 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose pre code {
|
||||||
|
@apply bg-transparent p-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose a {
|
||||||
|
@apply text-blue-600 dark:text-blue-400 hover:underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose blockquote {
|
||||||
|
@apply border-l-4 border-gray-300 dark:border-gray-700 pl-4 italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose ul {
|
||||||
|
@apply list-disc list-inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose ol {
|
||||||
|
@apply list-decimal list-inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h1 {
|
||||||
|
@apply text-2xl font-bold mb-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h2 {
|
||||||
|
@apply text-xl font-bold mb-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h3 {
|
||||||
|
@apply text-lg font-bold mb-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose table {
|
||||||
|
@apply border-collapse w-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose th {
|
||||||
|
@apply border border-gray-300 dark:border-gray-700 px-4 py-2 bg-gray-100 dark:bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose td {
|
||||||
|
@apply border border-gray-300 dark:border-gray-700 px-4 py-2;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 7: Handle Theme Changes
|
||||||
|
|
||||||
|
Create or update theme context to track light/dark mode:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createContext, createSignal, useContext } from 'solid-js'
|
||||||
|
|
||||||
|
const ThemeContext = createContext<{
|
||||||
|
isDark: () => boolean
|
||||||
|
toggleTheme: () => void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
export function ThemeProvider(props: { children: any }) {
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
const [isDark, setIsDark] = createSignal(prefersDark)
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setIsDark(!isDark())
|
||||||
|
document.documentElement.classList.toggle('dark')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ isDark, toggleTheme }}>
|
||||||
|
{props.children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const context = useContext(ThemeContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useTheme must be used within ThemeProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 8: Test Markdown Rendering
|
||||||
|
|
||||||
|
Test with various markdown inputs:
|
||||||
|
|
||||||
|
1. **Headings**: `# Heading 1\n## Heading 2`
|
||||||
|
2. **Code blocks**: ` ```typescript\nconst x = 1\n``` `
|
||||||
|
3. **Inline code**: `` `npm install` ``
|
||||||
|
4. **Lists**: `- Item 1\n- Item 2`
|
||||||
|
5. **Links**: `[OpenCode](https://opencode.ai)`
|
||||||
|
6. **Bold/Italic**: `**bold** and *italic*`
|
||||||
|
7. **Blockquotes**: `> Quote`
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Markdown content renders with proper formatting
|
||||||
|
- [ ] Code blocks have syntax highlighting
|
||||||
|
- [ ] Light and dark themes work correctly
|
||||||
|
- [ ] Copy button appears on code block hover
|
||||||
|
- [ ] Copy button successfully copies code to clipboard
|
||||||
|
- [ ] Language label shows for code blocks
|
||||||
|
- [ ] Inline code has distinct styling
|
||||||
|
- [ ] Links open in external browser
|
||||||
|
- [ ] No XSS vulnerabilities (sanitized output)
|
||||||
|
- [ ] Theme changes update code highlighting
|
||||||
|
- [ ] Headings, lists, blockquotes render correctly
|
||||||
|
- [ ] Performance is acceptable (no lag when rendering)
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Test all markdown syntax types
|
||||||
|
- [ ] Test code blocks with various languages
|
||||||
|
- [ ] Test switching between light and dark mode
|
||||||
|
- [ ] Test copy functionality
|
||||||
|
- [ ] Test external link opening
|
||||||
|
- [ ] Test very long code blocks (scrolling)
|
||||||
|
- [ ] Test malformed markdown
|
||||||
|
- [ ] Test HTML in markdown (should be escaped)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Shiki loads language grammars asynchronously, so first render may be slower
|
||||||
|
- Consider caching rendered markdown if re-rendering same content
|
||||||
|
- For MVP, don't implement line numbers or advanced code block features
|
||||||
|
- Keep the language list limited to common ones to reduce bundle size
|
||||||
|
|
||||||
|
## Future Enhancements (Post-MVP)
|
||||||
|
|
||||||
|
- Line numbers in code blocks
|
||||||
|
- Code block diff highlighting
|
||||||
|
- Collapsible long code blocks
|
||||||
|
- Search within code blocks
|
||||||
|
- More language support
|
||||||
|
- Custom syntax themes
|
||||||
|
- LaTeX/Math rendering
|
||||||
|
- Mermaid diagram support
|
||||||
Reference in New Issue
Block a user