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}`)
)}
))}
);
}