import React from "react"; import { Box, Text } from "ink"; import { marked, MarkedToken, Token, Tokens } from "marked"; import stringWidth from "string-width"; import { isImageToken, isLinkToken, isTextToken, isStrongToken, isEmToken, isDelToken, isCodespanToken } from "./types.ts"; import { HighlightedCode } from "./highlight-code.tsx"; export function Markdown({ markdown }: { markdown: string }) { const tokens = marked.lexer(markdown); return { tokens.map((token, index) => ) } ; } function TokenRenderer({ token }: { token: Token }): React.ReactElement { if (!isMarkedToken(token)) { throw new Error(`Unknown markdown token type: ${token.type}`); } switch (token.type) { case "blockquote": return ; case "br": return ; case "code": return ; case "codespan": return ; case "def": return ; case "del": return ; case "em": return ; case "escape": return ; case "heading": return ; case "hr": return ; case "html": return ; case "image": return ; case "link": return ; case "list": return ; case "list_item": return ; case "paragraph": return ; case "strong": return ; case "table": return ; case "text": return ; case "space": return ; } } function BlockquoteRenderer({ token }: { token: Tokens.Blockquote }) { return {renderTokensAsPlaintext(token.tokens)} } function BrRenderer() { return {'\n'}; } function CodeRenderer({ token }: { token: Tokens.Code }) { if (token.lang || token.codeBlockStyle !== "indented") { const langTag = token.lang ? `┌─ ${token.lang} ` + '─'.repeat(Math.max(0, 40 - token.lang.length)) : '┌' + '─'.repeat(42); const footer = '└' + '─'.repeat(42); return ( {langTag} {footer} ); } return ; } function CodespanRenderer({ token }: { token: Tokens.Codespan }) { return {token.text} ; } function DefRenderer({ token }: { token: Tokens.Def }) { // Don't render definition links which are usually referenced elsewhere. return <>; } function DelRenderer({ token }: { token: Tokens.Del }) { return {renderTokensAsPlaintext(token.tokens)}; } function EmRenderer({ token }: { token: Tokens.Em }) { return {renderTokensAsPlaintext(token.tokens)}; } function EscapeRenderer({ token }: { token: Tokens.Escape }) { return {token.text}; } function HeadingRenderer({ token }: { token: Tokens.Heading }) { const indent = Math.max(0, token.depth - 1) * 2; // Convert to padding units const colors = [ "magenta", "blue", "cyan", "green", "yellow", "red" ] as const; const color = colors[Math.min(token.depth - 1, colors.length - 1)]; const marker = token.depth === 1 ? "█" : token.depth === 2 ? "▆" : "▉"; return ( {marker} {renderTokensAsPlaintext(token.tokens)} ); } function HrRenderer() { const width = Math.min(process.stdout.columns || 80, 80); return ( {"─".repeat(width)} ); } function HtmlRenderer({ token }: { token: Tokens.HTML | Tokens.Tag }) { return {token.text}; } function ImageRenderer({ token }: { token: Tokens.Image }) { return [Image: {token.text}]; } function LinkRenderer({ token }: { token: Tokens.Link }) { // For now, combine link text and URL in a single text element const linkText = renderTokensAsPlaintext(token.tokens); return {linkText} ({token.href}); } function ListRenderer({ token }: { token: Tokens.List }) { return ( {token.items.map((item, index) => ( {token.ordered ? `${(typeof token.start === "number" ? token.start : 1) + index}. ` : "• " } ))} ); } function ListItemRenderer({ token }: { token: Tokens.ListItem }) { if (token.task && typeof token.checked === "boolean") { // For task items, render checkbox and content inline return ( {token.checked ? "[✓]" : "[ ]"}{" "} {token.tokens.map((childToken, index) => ( ))} ); } // For regular list items return ( {token.tokens.map((childToken, index) => ( ))} ); } function ParagraphRenderer({ token }: { token: Tokens.Paragraph }) { return {renderTokensAsPlaintext(token.tokens)}; } function StrongRenderer({ token }: { token: Tokens.Strong }) { return {renderTokensAsPlaintext(token.tokens)}; } function TableRenderer({ token }: { token: Tokens.Table }) { // Calculate column widths by measuring display width of all content const allRows = [token.header, ...token.rows]; const columnWidths = token.header.map((_, colIndex) => { const maxWidth = Math.max( ...allRows.map(row => { const cell = row[colIndex]; if (cell) { const cellText = renderTokensAsPlaintext(cell.tokens); return stringWidth(cellText); } return 0; }) ); return Math.max(maxWidth, 3); // Minimum width of 3 }); const separator = "├" + columnWidths.map(w => "─".repeat(w + 2)).join("┼") + "┤"; return ( {separator} {token.rows.map((row, index) => ( ))} ); } function TableRowRenderer({ cells, columnWidths, isHeader }: { cells: Tokens.TableCell[]; columnWidths: number[]; isHeader: boolean; }) { return ( {cells.map((cell, index) => { const cellText = renderTokensAsPlaintext(cell.tokens); const paddedText = cellText.padEnd(columnWidths[index]); return ( {paddedText} ); })} ); } function TextRenderer({ token }: { token: Tokens.Text }) { if (token.tokens) { return {renderTokensAsPlaintext(token.tokens)}; } return {token.text}; } function SpaceRenderer() { return <>; } function renderTokensAsPlaintext(tokens: Token[]): string { return tokens.map(token => { if (isTextToken(token)) { return token.text; } if (isLinkToken(token)) { return `${renderTokensAsPlaintext(token.tokens)} (${token.href})`; } if (isImageToken(token)) { return `[Image: ${token.text}]`; } if (isStrongToken(token)) { return renderTokensAsPlaintext(token.tokens); } if (isEmToken(token)) { return renderTokensAsPlaintext(token.tokens); } if (isDelToken(token)) { return renderTokensAsPlaintext(token.tokens); } if (isCodespanToken(token)) { return ` ${token.text} `; } if ('tokens' in token && Array.isArray(token.tokens)) { return renderTokensAsPlaintext(token.tokens); } if ('text' in token) { return token.text; } return ''; }).join(''); } const MARKED_TOKEN_TYPES = [ "blockquote", "br", "code", "codespan", "def", "del", "em", "escape", "heading", "hr", "html", "image", "link", "list", "list_item", "paragraph", "space", "strong", "table", "text", ]; /** * Marked provides a `Tokens.Generic` interface that accepts any string for `type`, which breaks * type narrowing for `Token`. We check that the token is not generic (ie. a `MarkedToken`) before * filtering for token types to preserve type narrowing. * https://github.com/markedjs/marked/issues/2938 */ function isMarkedToken(token: Token): token is MarkedToken { return MARKED_TOKEN_TYPES.includes(token.type); }