import { Box, Text } from "ink"; import { parseMarkdown, type MarkdownNode } from "../utils/markdown"; export interface MarkdownRendererProps { content: string; /** Available width for the README content, excluding its surrounding box. */ width?: number; maxHeight?: number; scrollOffset?: number; } type SegmentStyle = "plain" | "heading1" | "heading2" | "heading" | "bold" | "italic" | "code" | "link" | "muted"; interface RenderSegment { text: string; style: SegmentStyle; } interface RenderLine { segments: RenderSegment[]; } function appendSegment(line: RenderLine, segment: RenderSegment): void { if (!segment.text) return; const previous = line.segments[line.segments.length - 1]; if (previous?.style === segment.style) { previous.text += segment.text; } else { line.segments.push({ ...segment }); } } function segmentLength(segments: RenderSegment[]): number { return segments.reduce((length, segment) => length + segment.text.length, 0); } /** * Wrap styled text without allowing a long URL, identifier, or path to escape * the README viewport. Whitespace is used as a preferred break point, then * long unbroken tokens are hard-wrapped. */ function wrapSegments(segments: RenderSegment[], rawWidth: number): RenderLine[] { const width = Math.max(1, Math.floor(rawWidth)); const lines: RenderLine[] = []; let current: RenderLine = { segments: [] }; const pushCurrent = () => { lines.push(current); current = { segments: [] }; }; for (const segment of segments) { let remaining = segment.text.replace(/\n/g, " "); while (remaining.length > 0) { const whitespaceMatch = remaining.match(/^\s+/); if (whitespaceMatch) { const whitespace = whitespaceMatch[0]; remaining = remaining.slice(whitespace.length); // Do not put leading whitespace on a wrapped line. if (segmentLength(current.segments) === 0) continue; const available = width - segmentLength(current.segments); if (available <= 0) { pushCurrent(); continue; } const kept = whitespace.slice(0, available); appendSegment(current, { text: kept, style: segment.style }); if (kept.length < whitespace.length || segmentLength(current.segments) >= width) { pushCurrent(); } continue; } const nextWhitespace = remaining.search(/\s/); const token = nextWhitespace === -1 ? remaining : remaining.slice(0, nextWhitespace); remaining = nextWhitespace === -1 ? "" : remaining.slice(nextWhitespace); if (segmentLength(current.segments) >= width) pushCurrent(); const available = width - segmentLength(current.segments); if (token.length > available && segmentLength(current.segments) > 0) { pushCurrent(); continue; } const take = Math.min(token.length, width - segmentLength(current.segments)); appendSegment(current, { text: token.slice(0, take), style: segment.style }); if (take < token.length || segmentLength(current.segments) >= width) { pushCurrent(); remaining = token.slice(take) + remaining; } } } if (segmentLength(current.segments) > 0 || lines.length === 0) { lines.push(current); } return lines; } function inlineSegments(children: MarkdownNode[] | undefined): RenderSegment[] { if (!children) return []; return children.flatMap((child): RenderSegment[] => { const text = child.content ?? ""; switch (child.type) { case "bold": return [{ text, style: "bold" }]; case "italic": return [{ text, style: "italic" }]; case "code": return [{ text, style: "code" }]; case "link": return [{ text, style: "link" }]; default: return [{ text, style: "plain" }]; } }); } function inlineSegmentsFromText(text: string): RenderSegment[] { const firstNode = parseMarkdown(text)[0]; if (firstNode?.type === "paragraph") return inlineSegments(firstNode.children); return [{ text, style: "plain" }]; } function withPrefix( content: RenderSegment[], prefix: string, width: number, continuationPrefix = prefix, ): RenderLine[] { if (width <= prefix.length) { return [{ segments: [{ text: prefix.slice(0, Math.max(1, width)), style: "plain" }] }]; } const prefixWidth = prefix.length; const contentLines = wrapSegments(content, Math.max(1, width - prefixWidth)); return contentLines.map((line, index) => ({ segments: [ { text: index === 0 ? prefix : continuationPrefix, style: "plain" }, ...line.segments, ], })); } function codeBorder(width: number, left: string, right: string): string { if (width <= 1) return left.slice(0, width); return `${left}${"─".repeat(Math.max(0, width - 2))}${right}`; } function renderNode(node: MarkdownNode, width: number, indentLevel = 0): RenderLine[] { const indent = " ".repeat(indentLevel); switch (node.type) { case "heading": { const style: SegmentStyle = node.level === 1 ? "heading1" : node.level === 2 ? "heading2" : "heading"; return wrapSegments([{ text: node.content ?? "", style }], width); } case "paragraph": return wrapSegments(inlineSegments(node.children), width); case "codeblock": { const innerWidth = Math.max(1, width - 4); const lines: RenderLine[] = [ { segments: [{ text: codeBorder(width, "┌", "┐"), style: "muted" }] }, ]; for (const rawLine of (node.content ?? "").split("\n")) { const chunks = rawLine.length === 0 ? [""] : Array.from({ length: Math.ceil(rawLine.length / innerWidth) }, (_, index) => rawLine.slice(index * innerWidth, (index + 1) * innerWidth) ); for (const chunk of chunks) { const lineText = `│ ${chunk} │`; lines.push({ segments: [{ text: lineText.slice(0, width), style: "plain" }] }); } } lines.push({ segments: [{ text: codeBorder(width, "└", "┘"), style: "muted" }] }); return lines; } case "list": return (node.children ?? []).flatMap((item) => withPrefix(inlineSegmentsFromText(item.content ?? ""), `${indent}• `, width, `${indent} `) ); case "blockquote": return withPrefix(inlineSegmentsFromText(node.content ?? ""), "│ ", width); case "hr": return [{ segments: [{ text: "─".repeat(Math.max(1, width)), style: "muted" }] }]; default: return []; } } /** Return the exact physical lines used by MarkdownRenderer at a width. */ export function getMarkdownLines(content: string, width = 80): RenderLine[] { return parseMarkdown(content).flatMap((node) => renderNode(node, width)); } /** Return the physical line count used for README scrolling. */ export function getMarkdownLineCount(content: string, width = 80): number { return getMarkdownLines(content, width).length; } function renderSegment(segment: RenderSegment, key: string): JSX.Element { switch (segment.style) { case "heading1": return {segment.text}; case "heading2": return {segment.text}; case "heading": return {segment.text}; case "bold": return {segment.text}; case "italic": return {segment.text}; case "code": return {segment.text}; case "link": return {segment.text}; case "muted": return {segment.text}; default: return {segment.text}; } } export function MarkdownRenderer({ content, width = 80, maxHeight, scrollOffset = 0, }: MarkdownRendererProps): JSX.Element { const lines = getMarkdownLines(content, width); const start = Math.max(0, Math.min(scrollOffset, Math.max(0, lines.length - 1))); const visibleLines = maxHeight === undefined ? lines.slice(start) : lines.slice(start, start + Math.max(0, maxHeight)); return ( {visibleLines.map((line, lineIndex) => ( {line.segments.map((segment, segmentIndex) => renderSegment(segment, `${start}-${lineIndex}-${segmentIndex}`) )} ))} ); }