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