import React from "react"; 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"; import { Span } from "paintcannon-react"; import { TerminalFlex } from "../components/terminal-flex.tsx"; import { MARKDOWN_BLOCKQUOTE_BORDER_COLOR, MARKDOWN_CHECKED_TASK_COLOR, MARKDOWN_CODE_BLOCK_BORDER_COLOR, MARKDOWN_HEADING_COLORS, MARKDOWN_HORIZONTAL_RULE_COLOR, MARKDOWN_IMAGE_COLOR, MARKDOWN_INLINE_CODE_BACKGROUND_COLOR, MARKDOWN_INLINE_CODE_FOREGROUND_COLOR, MARKDOWN_LINK_COLOR, MARKDOWN_LIST_MARKER_COLOR, MARKDOWN_STRIKETHROUGH_COLOR, MARKDOWN_TABLE_BORDER_COLOR, MARKDOWN_TABLE_CELL_COLOR, MARKDOWN_TABLE_HEADER_COLOR, MARKDOWN_UNCHECKED_TASK_COLOR, } from "../theme.ts"; export function Markdown({ markdown }: { markdown: string }) { const tokens = marked.lexer(markdown); return ( {tokens.map((token, index) => ( ))} ); } function renderChildren(tokens: Token[]): React.ReactNode { 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 ( {renderChildren(token.tokens)} ); } function EmRenderer({ token }: { token: Tokens.Em }) { return ( {renderChildren(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 color = MARKDOWN_HEADING_COLORS[Math.min(token.depth - 1, MARKDOWN_HEADING_COLORS.length - 1)]; const marker = "#".repeat(token.depth); return ( {marker} {renderChildren(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}){renderChildren(token.tokens)} ({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 ( {renderChildren(token.tokens)} ); } function StrongRenderer({ token }: { token: Tokens.Strong }) { return ( {renderChildren(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 {renderChildren(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); }