/** * @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. A file path — absolute, or relative with a folder and * an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in * inline code or as a markdown link's target — opens through `OpenFileContext` * when the page provides one (the page resolves a relative one against its own * folder); `linkify` does the same links for plain text. To make a reply easy to scan, bold is coloured * (accent), standalone numbers (87%, 3.5s, v2.7.1, dates) are tinted and * tabular, and a flag label ("Lưu ý:", "Warning:", "Lỗi:") takes the colour * of how serious it is. * * 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. * * ## 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. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `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*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/; /** A GFM table starts at a line holding a pipe that is followed by a divider. */ function isTableStart(lines: string[], i: number): boolean { return lines[i]!.includes("|") && i + 1 < lines.length && TABLE_DIVIDER_RE.test(lines[i + 1]!); } 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 (isTableStart(lines, i)) { 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 — // a table included, which (as in GFM) may follow prose with no blank line. 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) || isTableStart(lines, i) ) { break; } paraLines.push(nl); i++; } blocks.push({ kind: "para", text: paraLines.join("\n") }); } return blocks; } /** Opens a file a message names; provided by a view that can show one. */ export const OpenFileContext = React.createContext<((path: string) => void) | undefined>(undefined); const LINK_CLASS = "text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all"; const URL_RE = /^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/; // Two segments at least, so "/" or "/s" stays text; a trailing full stop is prose. // A `:12` or `:12:5` line suffix belongs to the link (Claude writes `path:line`). const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}(?::\d+(?::\d+)?)?/; // A relative path needs a folder AND an extension starting with a letter // (`docs/API.md`, `./a/b.ts:9`), so "and/or" and "3/4.5" stay text; a first segment with an inner dot is a host // (`github.com/x.js`), not a folder — a leading one (`.claude/…`) is a folder. const REL_PATH_RE = /^(?:\.{1,2}\/)*(?:\.?[\w@-][\w@-]*\/)(?:[\w.@-]+\/)*[\w.@-]*[\w@-]\.[A-Za-z][A-Za-z0-9]{0,9}(?::\d+(?::\d+)?)?(?![\p{L}\p{N}_/])/u; /** A path at the start of `text`, absolute or relative, or null. */ const matchPath = (text: string) => PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null; /** `a/b.ts:12:5` → `a/b.ts`: what is opened. */ export const stripLine = (p: string) => p.replace(/(?::\d+){1,2}$/, ""); // A markdown link target that is a page, not a file. const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; // A link starts a word: "and/or" or "a/b/c" stays text. const atWordStart = (text: string, i: number) => i === 0 || /[\s([{"'`]/.test(text[i - 1]!); // A number worth spotting: 42, 1,234.5, 87%, $1.20, 3.5s, 12MB, v2.7.1, 2026-10-12, 12:30, // 2026-10-12T08:00:00Z. It must stand alone, so "abc123", "file.2", "#67" and a sha like // "2fd0c903" stay text. const NUM_RE = /^(?:[-+−]?[$€£¥]?|v)\d+(?:[.,:/-]\d+)*(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?(?:%|×|x|k|K|M|B|KB|MB|GB|TB|ms|s|m|h|d|px|đ)?(?![\p{L}\p{N}_])/u; const numStart = (text: string, i: number) => i === 0 || !/[\p{L}\p{N}_.\/\\#@-]/u.test(text[i - 1]!); // A label that flags what follows — "Lưu ý:", "Warning:" — coloured by how serious it is. const LABEL_RE = /^(Error|Lỗi|Caution|Danger|Breaking|Warning|Cảnh báo|Important|Quan trọng|Chú ý|Note|Lưu ý|Tip|Mẹo):/i; const LABEL_CLASS = (word: string) => /^(error|lỗi|caution|danger|breaking)$/i.test(word) ? "text-status-danger" : /^(warning|cảnh báo|important|quan trọng|chú ý)$/i.test(word) ? "text-status-warning" : "text-accent"; function UrlLink({ url }: { url: string }) { return ( {url} ); } /** A markdown link's file target as a path: `%20` decoded, `#L12` → `:12`. */ function decodeHref(href: string): string { let p = href .replace(/^file:\/\//i, "") .replace(/#L(\d+)(?:-L?\d+)?$/, ":$1") .replace(/#.*$/, ""); try { p = decodeURIComponent(p); } catch { /* a stray % stays as written */ } return p; } /** A path as a button that opens it, or as it was when nothing can. */ function PathLink({ path, children }: { path: string; children: React.ReactNode }) { const open = React.useContext(OpenFileContext); if (!open) return <>{children}; return ( ); } /** Plain text with its URLs and absolute paths as links — nothing else parsed. */ export function linkify(text: string): React.ReactNode[] { const out: React.ReactNode[] = []; let buf = ""; for (let i = 0; i < text.length;) { const rest = text.slice(i); const url = atWordStart(text, i) && URL_RE.exec(rest)?.[0]; const path = !url && atWordStart(text, i) && matchPath(rest); const hit = url || path; if (!hit) { buf += text[i++]; continue; } if (buf) out.push(buf); buf = ""; out.push( url ? ( ) : ( {hit} ) ); i += hit.length; } if (buf) out.push(buf); return out; } /** 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) { const code = ( {codeM[1]} ); push(matchPath(codeM[1]!) === codeM[1] ? {code} : code); 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) { const href = linkM[2]!; // `[x.ts](src/x.ts#L12)` names a file: open it like a bare path. if ( /^file:\/\//i.test(href) || (!SCHEME_RE.test(href) && !href.startsWith("#") && !href.startsWith("//")) ) { push( {renderInline(linkM[1]!, `${baseKey}-l${n}`)} ); i += linkM[0].length; continue; } push( {renderInline(linkM[1]!, `${baseKey}-l${n}`)} ); i += linkM[0].length; continue; } // Auto-link const urlM = rest.match(URL_RE); if (urlM) { push(); i += urlM[0].length; continue; } // An absolute path at a word start const pathM = atWordStart(text, i) ? matchPath(rest) : null; if (pathM) { push({pathM}); i += pathM.length; continue; } const labelM = atWordStart(text, i) ? LABEL_RE.exec(rest) : null; if (labelM) { push({labelM[0]}); i += labelM[0].length; continue; } const numM = numStart(text, i) ? NUM_RE.exec(rest) : null; if (numM) { push( {numM[0]} ); i += numM[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; /** The assistant's reply in a chat: larger serif body text, as Claude Desktop. */ prose?: boolean; } const HEADING_STYLES = [ "text-[18px] font-semibold text-fg-primary mt-2 pb-1 border-b border-surface-3", "text-[16px] font-semibold text-fg-primary mt-2", "text-[15px] font-semibold text-fg-primary", "text-sm font-semibold text-fg-primary", "text-sm font-medium text-fg-secondary", "text-xs font-medium text-fg-secondary uppercase tracking-wider", ]; export function MarkdownContent({ text, dense = false, prose = false }: MarkdownContentProps) { const blocks = parseBlocks(text); const gap = dense ? "space-y-1.5" : "space-y-2.5"; // A paragraph or list item takes the root's size and colour in prose. const bodyText = prose ? "" : "text-sm text-fg-secondary"; 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}`)}

); } })}
); }