/** * KHAELOR * File: src/tui/markdown/render.ts * Description: Settled-block markdown renderer — headings, inline styles, fenced code, lists, tables, quotes, rules. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { highlightLine } from "./highlight.js"; import type { StyleRole, Theme } from "../theme.js"; interface InlineSpan { text: string; roles: StyleRole[]; } const FENCE_RE = /^ {0,3}(```+|~~~+)\s*(\S+)?\s*$/; const HEADING_RE = /^(#{1,6})\s+(.*)$/; const HR_RE = /^ {0,3}(?:-{3,}|_{3,}|\*{3,})\s*$/; const UL_RE = /^(\s*)([-*+])\s+(.*)$/; const OL_RE = /^(\s*)(\d+)[.)]\s+(.*)$/; const QUOTE_RE = /^ {0,3}>\s?(.*)$/; /** * Render one settled markdown block to styled terminal lines. Called exactly * once per block (TUI_DESIGN §8) — output is printed to scrollback and never * touched again. Pure: (source, width, theme) → lines. */ export function renderMarkdownBlock(source: string, width: number, theme: Theme): string[] { const w = Math.max(20, width); const lines = source.split("\n"); const out: string[] = []; let i = 0; while (i < lines.length) { const line = lines[i] as string; const fence = FENCE_RE.exec(line); if (fence) { const lang = fence[2] ?? null; const body: string[] = []; i += 1; while (i < lines.length && !FENCE_RE.exec(lines[i] as string)) { body.push(lines[i] as string); i += 1; } i += 1; // closing fence (or end) out.push(...renderCodeBlock(body, lang, w, theme)); continue; } const heading = HEADING_RE.exec(line); if (heading) { const level = (heading[1] as string).length; const raw = heading[2] as string; const text = renderInline(parseInline(raw), theme); if (out.length > 0) out.push(""); // H1 gets the brand gradient when it carries no inline markup; // gradients only ever run over plain text (never over escapes). if (level === 1 && !/[`*[]/.test(raw)) { out.push(theme.paint("bold", theme.paintGradient("brand", raw))); } else if (level <= 2) { out.push(theme.paint("bold", theme.paint("accent", text))); } else { out.push(theme.paint("bold", text)); } i += 1; continue; } if (HR_RE.test(line) && line.trim().length >= 3) { out.push(theme.paint("dim", "─".repeat(Math.min(w, 60)))); i += 1; continue; } const quote = QUOTE_RE.exec(line); if (quote) { const quoted: string[] = []; while (i < lines.length) { const q = QUOTE_RE.exec(lines[i] as string); if (!q) break; quoted.push(q[1] as string); i += 1; } const inner = wrapSpans(parseInline(quoted.join(" ")), w - 2); for (const spanLine of inner) { out.push(theme.paint("dim", "│ ") + renderInline(spanLine, theme, "dim")); } continue; } const ul = UL_RE.exec(line); const ol = OL_RE.exec(line); if (ul || ol) { const indent = ((ul ?? ol) as RegExpExecArray)[1] as string; const marker = ul ? "•" : `${(ol as RegExpExecArray)[2] as string}.`; const body = ((ul ?? ol) as RegExpExecArray)[3] as string; const pad = " ".repeat(Math.min(indent.length, 8)); const head = `${pad}${theme.paint("dim", marker)} `; const hang = " ".repeat(pad.length + [...marker].length + 1); const wrapped = wrapSpans(parseInline(body), w - hang.length); wrapped.forEach((spanLine, idx) => { out.push((idx === 0 ? head : hang) + renderInline(spanLine, theme)); }); i += 1; continue; } if (line.includes("|") && isTableRow(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1] as string)) { const rows: string[] = [line]; i += 1; // separator consumed below const separator = lines[i] as string; i += 1; while (i < lines.length && isTableRow(lines[i] as string)) { rows.push(lines[i] as string); i += 1; } out.push(...renderTable(rows, separator, w, theme)); continue; } if (line.trim() === "") { out.push(""); i += 1; continue; } // Paragraph: merge consecutive plain lines, wrap once. const para: string[] = [line]; i += 1; while (i < lines.length && isPlainParagraphLine(lines[i] as string)) { para.push(lines[i] as string); i += 1; } for (const spanLine of wrapSpans(parseInline(para.join(" ")), w)) { out.push(renderInline(spanLine, theme)); } } return out; } /** * Cheap styling for the live raw tail (TUI_DESIGN §8 step 1): inline code and * bold get regex styling, nothing structural. Pure per-line. */ export function renderTailLine(line: string, theme: Theme): string { return line .replace(/`([^`]+)`/g, (_m, code: string) => theme.paint("code", code)) .replace(/\*\*([^*]+)\*\*/g, (_m, b: string) => theme.paint("bold", b)); } // ───────────────────────────── internals ───────────────────────────── function isPlainParagraphLine(line: string): boolean { return ( line.trim() !== "" && !FENCE_RE.test(line) && !HEADING_RE.test(line) && !HR_RE.test(line) && !UL_RE.test(line) && !OL_RE.test(line) && !QUOTE_RE.test(line) && !isTableRow(line) ); } function isTableRow(line: string): boolean { const t = line.trim(); return t.startsWith("|") && t.endsWith("|") && t.length > 2; } function isTableSeparator(line: string): boolean { const t = line.trim(); return isTableRow(line) && /^\|(?:\s*:?-+:?\s*\|)+$/.test(t); } function splitCells(row: string): string[] { const t = row.trim().replace(/^\|/, "").replace(/\|$/, ""); return t.split("|").map((c) => c.trim()); } /** Tables render as aligned plain columns; degrade further when too wide (§8). */ function renderTable(rows: string[], _separator: string, width: number, theme: Theme): string[] { const parsed = rows.map(splitCells); const cols = Math.max(...parsed.map((r) => r.length)); const widths: number[] = []; for (let c = 0; c < cols; c++) { widths.push(Math.max(...parsed.map((r) => [...(r[c] ?? "")].length))); } const total = widths.reduce((a, b) => a + b, 0) + (cols - 1) * 3; if (total > width) { // Too wide: plain row-per-line degradation, no alignment games. return parsed.map((r, idx) => { const text = r.join(theme.paint("dim", " · ")); return idx === 0 ? theme.paint("bold", text) : text; }); } const out: string[] = []; parsed.forEach((r, idx) => { const cells = r.map((cell, c) => cell.padEnd(widths[c] ?? 0)); const rowText = cells.join(theme.paint("dim", " │ ")); out.push(idx === 0 ? theme.paint("bold", rowText) : rowText); if (idx === 0) { out.push(theme.paint("dim", widths.map((cw) => "─".repeat(cw)).join("─┼─"))); } }); return out; } /** * Fenced code: dim `│` gutter (no background fills that poison copied text), * per-line syntax highlighting, hard wrap with a dim `↪` continuation marker. */ function renderCodeBlock(body: string[], lang: string | null, width: number, theme: Theme): string[] { const gutter = theme.paint("dim", "│ "); const contWidth = Math.max(8, width - 4); const out: string[] = []; for (const raw of body) { const chunks: { text: string; first: boolean }[] = []; if ([...raw].length <= contWidth) { chunks.push({ text: raw, first: true }); } else { const cps = [...raw]; for (let start = 0; start < cps.length; start += contWidth) { chunks.push({ text: cps.slice(start, start + contWidth).join(""), first: start === 0 }); } } for (const chunk of chunks) { const spans = highlightLine(chunk.text, lang); const styled = spans .map((s) => (s.role === "text" ? s.text : theme.paint(s.role, s.text))) .join(""); out.push(gutter + (chunk.first ? "" : theme.paint("dim", "↪ ")) + styled); } } return out; } // Inline parsing: `code`, **bold**, *italic*/_italic_, ~~strike~~, [text](url). function parseInline(text: string): InlineSpan[] { const spans: InlineSpan[] = []; const re = /(`[^`]+`)|(\*\*[^*]+\*\*)|(~~[^~]+~~)|(\*[^*\s][^*]*\*)|(_[^_\s][^_]*_)|(\[[^\]]+\]\([^)]+\))/g; let last = 0; let m: RegExpExecArray | null; while ((m = re.exec(text)) !== null) { if (m.index > last) spans.push({ text: text.slice(last, m.index), roles: [] }); const token = m[0]; if (m[1]) spans.push({ text: token.slice(1, -1), roles: ["code"] }); else if (m[2]) spans.push({ text: token.slice(2, -2), roles: ["bold"] }); else if (m[3]) spans.push({ text: token.slice(2, -2), roles: ["strike"] }); else if (m[4] || m[5]) spans.push({ text: token.slice(1, -1), roles: ["italic"] }); else if (m[6]) { const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token); if (link) { spans.push({ text: link[1] as string, roles: ["accent"] }); spans.push({ text: ` (${link[2] as string})`, roles: ["dim"] }); } } last = m.index + token.length; } if (last < text.length) spans.push({ text: text.slice(last), roles: [] }); return spans; } /** Word-wrap styled spans by visible width — styling applied after wrapping. */ function wrapSpans(spans: InlineSpan[], width: number): InlineSpan[][] { const w = Math.max(8, width); const lines: InlineSpan[][] = []; let current: InlineSpan[] = []; let used = 0; const pushWord = (word: string, roles: StyleRole[]): void => { const wordLen = [...word].length; const sep = used > 0 ? 1 : 0; if (used + sep + wordLen <= w) { if (sep) appendText(current, " ", []); appendText(current, word, roles); used += sep + wordLen; return; } if (current.length > 0) { lines.push(current); current = []; used = 0; } let rest = word; while ([...rest].length > w) { lines.push([{ text: [...rest].slice(0, w).join(""), roles }]); rest = [...rest].slice(w).join(""); } appendText(current, rest, roles); used = [...rest].length; }; for (const span of spans) { for (const word of span.text.split(" ")) { if (word === "") continue; pushWord(word, span.roles); } } if (current.length > 0) lines.push(current); return lines.length > 0 ? lines : [[]]; } function appendText(line: InlineSpan[], text: string, roles: StyleRole[]): void { const lastSpan = line[line.length - 1]; if (lastSpan && sameRoles(lastSpan.roles, roles)) lastSpan.text += text; else line.push({ text, roles }); } function sameRoles(a: StyleRole[], b: StyleRole[]): boolean { return a.length === b.length && a.every((r, i) => r === b[i]); } function renderInline(spans: InlineSpan[], theme: Theme, baseRole?: StyleRole): string { return spans .map((s) => { let text = s.text; for (const role of s.roles) text = theme.paint(role, text); if (s.roles.length === 0 && baseRole) text = theme.paint(baseRole, text); return text; }) .join(""); }