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