diff --git a/bun.lock b/bun.lock index f5ade78..9468bf3 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "ansi-styles": "^6.2.3", "chalk": "^5.6.2", "happy-dom-without-node": "^14.12.3", + "marked": "^18.0.4", "shiki": "^4.0.2", "terminal-link": "^5.0.0", }, @@ -142,6 +143,8 @@ "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "marked": ["marked@18.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA=="], + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], diff --git a/package.json b/package.json index 3be45bd..0ad14b9 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "ansi-styles": "^6.2.3", "chalk": "^5.6.2", "happy-dom-without-node": "^14.12.3", + "marked": "^18.0.4", "shiki": "^4.0.2", "terminal-link": "^5.0.0" } diff --git a/src/render/markdown.ts b/src/render/markdown.ts index 53461df..dcd85af 100644 --- a/src/render/markdown.ts +++ b/src/render/markdown.ts @@ -1,5 +1,6 @@ import { Cell, Table } from "@cliffy/table"; import chalk from "chalk"; +import { marked, Renderer, type Tokens } from "marked"; import { type BundledLanguage, type BundledTheme, @@ -18,6 +19,164 @@ class SWCell extends Cell { } } +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, @@ -38,127 +197,13 @@ export async function renderMarkdown( } catch {} } - let header: SWCell[] = []; - let body: SWCell[][] = []; - - const rendered = Bun.markdown.render(md, { - heading: (children, { level }) => { - const colors = [ - chalk.cyan.underline, - chalk.blue, - chalk.green, - chalk.cyanBright, - chalk.blueBright, - chalk.greenBright, - ]; - const color = colors[level - 1] ?? chalk.white; - return Bun.wrapAnsi( - color.bold(`${"#".repeat(level)} ${children}\n\n`), - columns, - { trim: false }, - ); - }, - paragraph: (children) => - Bun.wrapAnsi(`${children}\n\n`, columns, { trim: false }), - blockquote: (children) => { - const lines = Bun.wrapAnsi(`\n${children.trim()}`, columns - 2, { - trim: false, - }) - .replace(/\n+/g, "\n") - .concat("\n") - .split("\n"); - const toRender = lines - .map((line) => `> ${line}`) - .join("\n") - .replace(/(\n> +\S+) *\n> */g, "$1 ") - .replaceAll(">", chalk.dim(">")) - .concat("\n\n"); - return toRender; - }, - code: (children, meta) => { - let toRender = children; - - if (highlighter && meta?.language) { - try { - toRender = codeToANSI( - highlighter, - children, - meta.language as BundledLanguage, - "kanagawa-wave", - ); - } catch {} - } - - return `\`\`\`${chalk.blue(meta?.language ?? "")}\n${toRender}\`\`\`\n`; - }, - list: (children, { depth }) => { - const trimmed = children.replace(/\n+/g, "\n"); - return depth === 0 ? `${trimmed}\n` : `\n${trimmed}`; - }, - listItem: (children, { index, depth, ordered, start, checked }) => { - let marker = ""; - if (checked !== undefined) { - marker = checked ? "• [✓]" : "• [ ]"; - } else if (ordered) { - const n = (start ?? 1) + index; - marker = `${n}.`; - } else { - marker = "•"; - } - - const cols = columns - (depth + 1) * 2; - return Bun.wrapAnsi(`${marker} ${children}`, cols, { trim: false }) - .split("\n") - .map((line) => (line.length > 0 ? ` ${line}` : "")) - .join("\n") - .replace(/(\n +\S+) *\n */g, "$1 ") - .concat("\n"); - }, - hr: () => `${chalk.dim("─".repeat(columns))}\n\n`, - table: (_children) => { - const table = new Table() - .header(header) - .body(body) - .border() - .maxColWidth(Math.floor((columns - 3) / header.length)) - .toString(); - header = []; - body = []; - return `${table}\n\n`; - }, - tr: (children) => { - if (!children) return; - body.push( - children - .split(":tablesep:") - .slice(1) - .map((value) => new SWCell(value)), - ); - return null; - }, - td: (children) => `:tablesep:${children}`, - th: (children) => { - header.push(new SWCell(chalk.red.bold(children))); - return null; - }, - strong: (children) => chalk.bold.rgb(254, 254, 254)(children), - emphasis: (children) => chalk.italic.rgb(254, 254, 254)(children), - strikethrough: (children) => chalk.grey.strikethrough(children), - codespan: (children) => chalk.bgYellow.black.bold(children), - link: (children, { href }) => - chalk.blueBright.underline(terminalLink(children, href)), - image: () => chalk.red("Images are unsupported (for now)\n"), - text: (children) => - children - .replaceAll("$\\rightarrow$", "-->") - .replaceAll("$\\leftarrow$", "<--") - .replaceAll("$\\leftrightarrow$", "<->") - .replaceAll("$\\Rightarrow$", "==>") - .replaceAll("$\\Leftarrow$", "<==") - .replaceAll("$\\Leftrightarrow$", "<=>") - .replaceAll("$\\to$", "->"), - html: () => chalk.red("HTML is unsupported\n"), + const renderer = new LmcRenderer(columns); + marked.setOptions({ + renderer, + gfm: true, }); + const rendered = await marked.parse(md); + return rendered.replace(/\n\n$/, "\n"); }