58be04a4e7
The chat renderer's paragraph collector never stopped at a table header, so a table right after a line of text was shown as raw pipes. A table now interrupts a paragraph as in GFM, and the divider accepts single dashes. Closes #83 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
672 lines
22 KiB
TypeScript
672 lines
22 KiB
TypeScript
/**
|
||
* @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 <CodeBlock /> for syntax highlighting and
|
||
* copy-to-clipboard.
|
||
*
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
/* =============================================================================
|
||
* 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 (
|
||
<a href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
||
{url}
|
||
</a>
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<button
|
||
type="button"
|
||
title={path}
|
||
onClick={() => open(stripLine(path))}
|
||
className={`${LINK_CLASS} text-left`}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/** 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 ? (
|
||
<UrlLink key={i} url={url} />
|
||
) : (
|
||
<PathLink key={i} path={hit}>
|
||
{hit}
|
||
</PathLink>
|
||
)
|
||
);
|
||
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(<React.Fragment key={`${baseKey}-${n++}`}>{node}</React.Fragment>);
|
||
};
|
||
|
||
while (i < text.length) {
|
||
const rest = text.slice(i);
|
||
|
||
// Inline code: `...`
|
||
const codeM = rest.match(/^`([^`\n]+)`/);
|
||
if (codeM) {
|
||
const code = (
|
||
<code className="rounded bg-surface-4 border border-surface-3 px-1.5 py-0.5 font-mono text-[12.5px] text-status-warning">
|
||
{codeM[1]}
|
||
</code>
|
||
);
|
||
push(matchPath(codeM[1]!) === codeM[1] ? <PathLink path={codeM[1]!}>{code}</PathLink> : code);
|
||
i += codeM[0].length;
|
||
continue;
|
||
}
|
||
|
||
// Bold: **...** or __...__
|
||
const boldM = rest.match(/^(\*\*|__)(.+?)\1/);
|
||
if (boldM) {
|
||
push(
|
||
<strong className="font-bold text-accent">
|
||
{renderInline(boldM[2]!, `${baseKey}-b${n}`)}
|
||
</strong>
|
||
);
|
||
i += boldM[0].length;
|
||
continue;
|
||
}
|
||
|
||
// Italic: *...* or _..._
|
||
const italicM = rest.match(/^(\*|_)([^*_\n]+?)\1/);
|
||
if (italicM) {
|
||
push(
|
||
<em className="italic text-fg-secondary">
|
||
{renderInline(italicM[2]!, `${baseKey}-i${n}`)}
|
||
</em>
|
||
);
|
||
i += italicM[0].length;
|
||
continue;
|
||
}
|
||
|
||
// Strikethrough
|
||
const strikeM = rest.match(/^~~(.+?)~~/);
|
||
if (strikeM) {
|
||
push(
|
||
<span className="line-through text-fg-muted">
|
||
{renderInline(strikeM[1]!, `${baseKey}-s${n}`)}
|
||
</span>
|
||
);
|
||
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(
|
||
<PathLink path={decodeHref(href)}>{renderInline(linkM[1]!, `${baseKey}-l${n}`)}</PathLink>
|
||
);
|
||
i += linkM[0].length;
|
||
continue;
|
||
}
|
||
push(
|
||
<a
|
||
href={href}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="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"
|
||
>
|
||
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
|
||
</a>
|
||
);
|
||
i += linkM[0].length;
|
||
continue;
|
||
}
|
||
|
||
// Auto-link
|
||
const urlM = rest.match(URL_RE);
|
||
if (urlM) {
|
||
push(<UrlLink url={urlM[0]} />);
|
||
i += urlM[0].length;
|
||
continue;
|
||
}
|
||
|
||
// An absolute path at a word start
|
||
const pathM = atWordStart(text, i) ? matchPath(rest) : null;
|
||
if (pathM) {
|
||
push(<PathLink path={pathM}>{pathM}</PathLink>);
|
||
i += pathM.length;
|
||
continue;
|
||
}
|
||
|
||
const labelM = atWordStart(text, i) ? LABEL_RE.exec(rest) : null;
|
||
if (labelM) {
|
||
push(<span className={`font-bold ${LABEL_CLASS(labelM[1]!)}`}>{labelM[0]}</span>);
|
||
i += labelM[0].length;
|
||
continue;
|
||
}
|
||
|
||
const numM = numStart(text, i) ? NUM_RE.exec(rest) : null;
|
||
if (numM) {
|
||
push(
|
||
<span className="font-semibold tabular-nums text-teal-700 dark:text-teal-300">
|
||
{numM[0]}
|
||
</span>
|
||
);
|
||
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 (
|
||
<span className="inline-flex items-baseline gap-2">
|
||
<span
|
||
className={`inline-block w-3 h-3 rounded-sm border flex-shrink-0 translate-y-0.5 ${
|
||
checked
|
||
? "bg-status-success/40 border-status-success/60"
|
||
: "bg-surface-4 border-surface-3"
|
||
}`}
|
||
aria-hidden="true"
|
||
/>
|
||
<span className={checked ? "text-fg-muted line-through" : ""}>
|
||
{renderInline(taskMatch[2]!, key)}
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
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 (
|
||
<div
|
||
className={`${prose ? "font-serif text-[15px] leading-7 text-fg-primary" : "text-sm text-fg-secondary leading-relaxed"} ${gap}`}
|
||
>
|
||
{blocks.map((b, idx) => {
|
||
switch (b.kind) {
|
||
case "code":
|
||
return <CodeBlock key={idx} code={b.code} lang={b.lang} />;
|
||
|
||
case "heading": {
|
||
const cls = HEADING_STYLES[b.level - 1] ?? HEADING_STYLES[5];
|
||
return (
|
||
<div key={idx} className={cls}>
|
||
{renderInline(b.text, `h${idx}`)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case "list":
|
||
if (b.ordered) {
|
||
return (
|
||
<ol
|
||
key={idx}
|
||
className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
|
||
>
|
||
{b.items.map((item, i) => (
|
||
<li key={i} className={bodyText}>
|
||
{renderListItem(item, `li${idx}-${i}`)}
|
||
</li>
|
||
))}
|
||
</ol>
|
||
);
|
||
}
|
||
return (
|
||
<ul
|
||
key={idx}
|
||
className="list-disc pl-5 space-y-1 marker:text-violet-700/60 dark:marker:text-violet-400/60"
|
||
>
|
||
{b.items.map((item, i) => (
|
||
<li key={i} className={bodyText}>
|
||
{renderListItem(item, `li${idx}-${i}`)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
);
|
||
|
||
case "quote":
|
||
return (
|
||
<blockquote
|
||
key={idx}
|
||
className="relative border-l-2 border-violet-400/50 pl-3 pr-2 py-1 text-fg-secondary italic bg-violet-500/[0.04] rounded-r"
|
||
>
|
||
{renderInline(b.text, `q${idx}`)}
|
||
</blockquote>
|
||
);
|
||
|
||
case "hr":
|
||
return (
|
||
<hr
|
||
key={idx}
|
||
className="border-0 h-px bg-gradient-to-r from-transparent via-surface-3 to-transparent my-2"
|
||
/>
|
||
);
|
||
|
||
case "table": {
|
||
const alignClass = (a: "left" | "center" | "right" | null) =>
|
||
a === "center" ? "text-center" : a === "right" ? "text-right" : "text-left";
|
||
return (
|
||
<div
|
||
key={idx}
|
||
className="overflow-x-auto rounded-md border border-surface-3 bg-surface-4/40"
|
||
>
|
||
<table className="w-full text-xs border-collapse">
|
||
<thead className="bg-surface-3/60">
|
||
<tr>
|
||
{b.header.map((cell, i) => (
|
||
<th
|
||
key={i}
|
||
className={`px-3 py-1.5 font-semibold text-fg-secondary border-b border-surface-3 ${alignClass(b.aligns[i] ?? null)}`}
|
||
>
|
||
{renderInline(cell, `th${idx}-${i}`)}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{b.rows.map((row, ri) => (
|
||
<tr
|
||
key={ri}
|
||
className="border-b border-surface-3/50 last:border-b-0 hover:bg-surface-3/30"
|
||
>
|
||
{row.map((cell, ci) => (
|
||
<td
|
||
key={ci}
|
||
className={`px-3 py-1.5 text-fg-secondary align-top ${alignClass(b.aligns[ci] ?? null)}`}
|
||
>
|
||
{renderInline(cell, `td${idx}-${ri}-${ci}`)}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case "para":
|
||
return (
|
||
<p key={idx} className={`${bodyText} whitespace-pre-wrap break-words`}>
|
||
{renderInline(b.text, `p${idx}`)}
|
||
</p>
|
||
);
|
||
}
|
||
})}
|
||
</div>
|
||
);
|
||
}
|