/** * @file MarkdownContent.tsx * @description Lightweight markdown renderer for conversation messages. Supports the * subset of CommonMark + GFM that actually appears in Claude Code transcripts: * fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes, * horizontal rules, simple tables, inline code, bold, italic, strikethrough, links, * and auto-linked URLs. * * Output is built as a React element tree (no dangerouslySetInnerHTML) so user content * is escaped by React. Code blocks delegate to for syntax highlighting and * copy-to-clipboard. * * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Observability * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and * Docker Compose profiles are documented in `monitoring/README.md`. * * ## Internal dependencies * - `./CodeBlock` * * ## Public surface * - `MarkdownContent` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `.claude/skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **MarkdownContent** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import React from "react"; import { CodeBlock } from "./CodeBlock"; type Block = | { kind: "code"; lang: string; code: string } | { kind: "heading"; level: number; text: string } | { kind: "list"; ordered: boolean; items: string[] } | { kind: "quote"; text: string } | { kind: "hr" } | { kind: "table"; header: string[]; aligns: ("left" | "center" | "right" | null)[]; rows: string[][]; } | { kind: "para"; text: string }; const FENCE_RE = /^([ \t]*)(```|~~~)(\s*[\w+-]*)\s*$/; const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/; const HR_RE = /^\s*(-{3,}|\*{3,}|_{3,})\s*$/; const UL_RE = /^(\s*)([-*+])\s+(.*)$/; const OL_RE = /^(\s*)(\d+)\.\s+(.*)$/; const QUOTE_RE = /^\s*>\s?(.*)$/; const TABLE_DIVIDER_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/; function splitTableRow(line: string): string[] { // Trim leading/trailing pipes, then split, respecting escaped pipes. let s = line.trim(); if (s.startsWith("|")) s = s.slice(1); if (s.endsWith("|")) s = s.slice(0, -1); // Split on unescaped pipes const parts: string[] = []; let cur = ""; for (let i = 0; i < s.length; i++) { if (s[i] === "\\" && s[i + 1] === "|") { cur += "|"; i++; continue; } if (s[i] === "|") { parts.push(cur.trim()); cur = ""; } else { cur += s[i]; } } parts.push(cur.trim()); return parts; } function parseAlignments(divider: string): ("left" | "center" | "right" | null)[] { return splitTableRow(divider).map((cell) => { const left = cell.startsWith(":"); const right = cell.endsWith(":"); if (left && right) return "center"; if (right) return "right"; if (left) return "left"; return null; }); } function parseBlocks(src: string): Block[] { const lines = src.split("\n"); const blocks: Block[] = []; let i = 0; while (i < lines.length) { const line = lines[i]!; // Fenced code block const fence = line.match(FENCE_RE); if (fence) { const fenceMarker = fence[2]!; const lang = (fence[3] ?? "").trim(); const codeLines: string[] = []; i++; while (i < lines.length) { const closing = lines[i]!.match(/^([ \t]*)(```|~~~)\s*$/); if (closing && closing[2] === fenceMarker) { i++; break; } codeLines.push(lines[i]!); i++; } blocks.push({ kind: "code", lang, code: codeLines.join("\n") }); continue; } // Blank line if (line.trim() === "") { i++; continue; } // ATX heading const heading = line.match(HEADING_RE); if (heading) { blocks.push({ kind: "heading", level: heading[1]!.length, text: heading[2]! }); i++; continue; } // Horizontal rule if (HR_RE.test(line)) { blocks.push({ kind: "hr" }); i++; continue; } // Table: header line followed by an alignment divider if (line.includes("|") && i + 1 < lines.length && TABLE_DIVIDER_RE.test(lines[i + 1]!)) { const header = splitTableRow(line); const aligns = parseAlignments(lines[i + 1]!); i += 2; const rows: string[][] = []; while (i < lines.length && lines[i]!.includes("|") && lines[i]!.trim() !== "") { rows.push(splitTableRow(lines[i]!)); i++; } blocks.push({ kind: "table", header, aligns, rows }); continue; } // Lists const ulMatch = line.match(UL_RE); const olMatch = line.match(OL_RE); if (ulMatch || olMatch) { const ordered = !!olMatch; const itemRe = ordered ? OL_RE : UL_RE; const items: string[] = []; while (i < lines.length) { const m = lines[i]!.match(itemRe); if (m) { items.push(m[3]!); i++; while ( i < lines.length && lines[i]!.trim() !== "" && !lines[i]!.match(UL_RE) && !lines[i]!.match(OL_RE) && /^\s+\S/.test(lines[i]!) ) { items[items.length - 1] += "\n" + lines[i]!.trim(); i++; } } else { break; } } blocks.push({ kind: "list", ordered, items }); continue; } // Blockquote if (QUOTE_RE.test(line)) { const qLines: string[] = []; while (i < lines.length) { const m = lines[i]!.match(QUOTE_RE); if (!m) break; qLines.push(m[1]!); i++; } blocks.push({ kind: "quote", text: qLines.join("\n") }); continue; } // Paragraph: collect until a blank line or the start of another block const paraLines: string[] = [line]; i++; while (i < lines.length) { const nl = lines[i]!; if ( nl.trim() === "" || FENCE_RE.test(nl) || HEADING_RE.test(nl) || HR_RE.test(nl) || UL_RE.test(nl) || OL_RE.test(nl) || QUOTE_RE.test(nl) ) { break; } paraLines.push(nl); i++; } blocks.push({ kind: "para", text: paraLines.join("\n") }); } return blocks; } /** Render inline markdown (bold/italic/code/strikethrough/links/auto-links). */ function renderInline(text: string, baseKey = ""): React.ReactNode[] { const out: React.ReactNode[] = []; let i = 0; let buf = ""; let n = 0; const flush = () => { if (buf) { out.push(buf); buf = ""; } }; const push = (node: React.ReactNode) => { flush(); out.push({node}); }; while (i < text.length) { const rest = text.slice(i); // Inline code: `...` const codeM = rest.match(/^`([^`\n]+)`/); if (codeM) { push( {codeM[1]} ); i += codeM[0].length; continue; } // Bold: **...** or __...__ const boldM = rest.match(/^(\*\*|__)(.+?)\1/); if (boldM) { push( {renderInline(boldM[2]!, `${baseKey}-b${n}`)} ); i += boldM[0].length; continue; } // Italic: *...* or _..._ const italicM = rest.match(/^(\*|_)([^*_\n]+?)\1/); if (italicM) { push( {renderInline(italicM[2]!, `${baseKey}-i${n}`)} ); i += italicM[0].length; continue; } // Strikethrough const strikeM = rest.match(/^~~(.+?)~~/); if (strikeM) { push( {renderInline(strikeM[1]!, `${baseKey}-s${n}`)} ); i += strikeM[0].length; continue; } // Markdown link const linkM = rest.match(/^\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/); if (linkM) { push( {renderInline(linkM[1]!, `${baseKey}-l${n}`)} ); i += linkM[0].length; continue; } // Auto-link const urlM = rest.match(/^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/); if (urlM) { push( {urlM[0]} ); i += urlM[0].length; continue; } buf += text[i]!; i++; } flush(); return out; } /** Render a single list item, handling [ ] / [x] task list prefixes. */ function renderListItem(item: string, key: string): React.ReactNode { const taskMatch = item.match(/^\[([ xX])\]\s+(.*)$/s); if (taskMatch) { const checked = taskMatch[1]!.toLowerCase() === "x"; return ( ); } return renderInline(item, key); } interface MarkdownContentProps { text: string; /** Tighter spacing for nested contexts (list items, quotes). */ dense?: boolean; } const HEADING_STYLES = [ "text-[18px] font-semibold text-gray-50 mt-2 pb-1 border-b border-surface-3", "text-[16px] font-semibold text-gray-50 mt-2", "text-[15px] font-semibold text-gray-100", "text-sm font-semibold text-gray-100", "text-sm font-medium text-gray-200", "text-xs font-medium text-gray-300 uppercase tracking-wider", ]; export function MarkdownContent({ text, dense = false }: MarkdownContentProps) { const blocks = parseBlocks(text); const gap = dense ? "space-y-1.5" : "space-y-2.5"; return (
{blocks.map((b, idx) => { switch (b.kind) { case "code": return ; case "heading": { const cls = HEADING_STYLES[b.level - 1] ?? HEADING_STYLES[5]; return (
{renderInline(b.text, `h${idx}`)}
); } case "list": if (b.ordered) { return (
    {b.items.map((item, i) => (
  1. {renderListItem(item, `li${idx}-${i}`)}
  2. ))}
); } return (
    {b.items.map((item, i) => (
  • {renderListItem(item, `li${idx}-${i}`)}
  • ))}
); case "quote": return (
{renderInline(b.text, `q${idx}`)}
); case "hr": return (
); case "table": { const alignClass = (a: "left" | "center" | "right" | null) => a === "center" ? "text-center" : a === "right" ? "text-right" : "text-left"; return (
{b.header.map((cell, i) => ( ))} {b.rows.map((row, ri) => ( {row.map((cell, ci) => ( ))} ))}
{renderInline(cell, `th${idx}-${i}`)}
{renderInline(cell, `td${idx}-${ri}-${ci}`)}
); } case "para": return (

{renderInline(b.text, `p${idx}`)}

); } })}
); }