Files
NomadArch/tasks/done/012-markdown-rendering.md
Gemini AI 157449a9ad restore: recover deleted documentation, CI/CD, and infrastructure files
Restored from origin/main (b4663fb):
- .github/ workflows and issue templates
- .gitignore (proper exclusions)
- .opencode/agent/web_developer.md
- AGENTS.md, BUILD.md, PROGRESS.md
- dev-docs/ (9 architecture/implementation docs)
- docs/screenshots/ (4 UI screenshots)
- images/ (CodeNomad icons)
- package-lock.json (dependency lockfile)
- tasks/ (25+ project task files)

Also restored original source files that were modified:
- packages/ui/src/App.tsx
- packages/ui/src/lib/logger.ts
- packages/ui/src/stores/instances.ts
- packages/server/src/server/routes/workspaces.ts
- packages/server/src/workspaces/manager.ts
- packages/server/src/workspaces/runtime.ts
- packages/server/package.json

Kept new additions:
- Install-*.bat/.sh (enhanced installers)
- Launch-*.bat/.sh (new launchers)
- README.md (SEO optimized with GLM 4.7)
2025-12-23 13:03:48 +04:00

9.7 KiB

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

cd packages/opencode-client
npm install marked shiki
npm install -D @types/marked

Step 2: Create Markdown Utility

Create src/lib/markdown.ts:

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> = {
    "&": "&amp;",
    "<": "&lt;",
    ">": "&gt;",
    '"': "&quot;",
    "'": "&#039;",
  }
  return text.replace(/[&<>"']/g, (m) => map[m])
}

Step 3: Create Markdown Component

Create src/components/markdown.tsx:

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:

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:

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:

/* 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:

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