import { Cell, Table } from "@cliffy/table"; import chalk from "chalk"; import { marked, Renderer, type Tokens } from "marked"; import { type BundledLanguage, type BundledTheme, createHighlighter, type HighlighterGeneric, } from "shiki"; import terminalLink from "terminal-link"; import { codeToANSI } from "./codeToANSI"; let highlighter: HighlighterGeneric | undefined; let highligherLangs = ""; class SWCell extends Cell { public override get length() { return Bun.stringWidth(this.toString(), { ambiguousIsNarrow: true }); } } class LmcRenderer extends Renderer { private columns: number; constructor(columns: number) { super(); this.columns = columns; } override heading({ tokens, depth }: Tokens.Heading) { const colors = [ chalk.cyan.underline, chalk.blue, chalk.green, chalk.cyanBright, chalk.blueBright, chalk.greenBright, ]; const color = colors[depth - 1] ?? chalk.white; const text = this.parser.parseInline(tokens); return Bun.wrapAnsi( color.bold(`${"#".repeat(depth)} ${text}\n\n`), this.columns, { trim: false }, ); } override paragraph({ tokens }: Tokens.Paragraph) { const text = this.parser.parseInline(tokens); return Bun.wrapAnsi(`${text}\n\n`, this.columns, { trim: false }); } override blockquote({ tokens }: Tokens.Blockquote) { const text = this.parser.parse(tokens).trim(); const lines = Bun.wrapAnsi(text, this.columns - 2, { trim: false }) .replace(/\n+/g, "\n") .concat("\n") .split("\n"); const toRender = lines .map((line) => `> ${line}`) .join("\n") .concat("\n\n"); return toRender.replaceAll(">", chalk.dim(">")); } override code({ text, lang, escaped: _ }: Tokens.Code) { let toRender = text; if (highlighter && lang) { try { toRender = codeToANSI( highlighter, text, lang as BundledLanguage, "kanagawa-wave", ); } catch {} } return `\`\`\`${chalk.blue(lang ?? "")}\n${toRender}\`\`\`\n`; } override list({ items }: Tokens.List) { const renderedItems = items.map((item) => this.listitem(item)).join(""); const trimmed = renderedItems.replace(/\n+/g, "\n"); return `${trimmed}\n`; } override listitem({ task, checked, tokens }: Tokens.ListItem) { const text = this.parser.parse(tokens); let marker = ""; if (task) { marker = checked ? "• [✓]" : "• [ ]"; } else { marker = "•"; } const cols = this.columns - 2; return Bun.wrapAnsi(`${marker} ${text}`, cols, { trim: false }) .split("\n") .map((line) => (line.length > 0 ? ` ${line}` : "")) .join("\n") .concat("\n"); } override hr() { return `${chalk.dim("─".repeat(this.columns))}\n\n`; } override table({ header, rows }: Tokens.Table) { const headerCells = header.map((col) => { const text = this.parser.parseInline(col.tokens); return new SWCell(chalk.red.bold(text)); }); const body = rows.map((row) => row.map((col) => { const text = this.parser.parseInline(col.tokens); return new SWCell(text); }), ); const table = new Table() .header(headerCells) .body(body) .border() .maxColWidth(Math.floor((this.columns - 3) / headerCells.length)) .toString(); return `${table}\n\n`; } override strong({ tokens }: Tokens.Strong) { const text = this.parser.parseInline(tokens); return chalk.bold.rgb(254, 254, 254)(text); } override em({ tokens }: Tokens.Em) { const text = this.parser.parseInline(tokens); return chalk.italic.rgb(254, 254, 254)(text); } override del({ tokens }: Tokens.Del) { const text = this.parser.parseInline(tokens); return chalk.grey.strikethrough(text); } override codespan({ text }: Tokens.Codespan) { return chalk.bgYellow.black.bold(text); } override link({ href, tokens }: Tokens.Link) { const text = this.parser.parseInline(tokens); return chalk.blueBright.underline(terminalLink(text, href)); } override image({ text: _ }: Tokens.Image) { return chalk.red("Images are unsupported (for now)\n"); } override text(token: Tokens.Text) { if (token.tokens) { return this.parser.parseInline(token.tokens); } return token.text .replaceAll("$\\rightarrow$", "-->") .replaceAll("$\\leftarrow$", "<--") .replaceAll("$\\leftrightarrow$", "<->") .replaceAll("$\\Rightarrow$", "==>") .replaceAll("$\\Leftarrow$", "<==") .replaceAll("$\\Leftrightarrow$", "<=>") .replaceAll("$\\to$", "->"); } override html({ text: _ }: Tokens.HTML | Tokens.Tag) { return chalk.red("HTML is unsupported\n"); } } export async function renderMarkdown( text: PromiseLike | string, columns: number, ): Promise { const md = await text; const langs = md .match(/```[a-z]+\n/g) ?.map((lang) => lang.replace("```", "").trimEnd()); if (langs && langs?.join() !== highligherLangs) { try { highligherLangs = langs.join(); highlighter = await createHighlighter({ langs, themes: ["kanagawa-wave", "kanagawa-lotus"], }); } catch {} } const renderer = new LmcRenderer(columns); marked.setOptions({ renderer, gfm: true, }); const rendered = await marked.parse(md); return rendered.replace(/\n\n$/, "\n"); }