|
| 1 | +export type MarkdownRenderOptions = { |
| 2 | + width?: number; |
| 3 | + colors?: boolean; |
| 4 | +}; |
| 5 | + |
| 6 | +const ANSI = { |
| 7 | + aqua: "\x1b[38;2;46;230;214m", |
| 8 | + bold: "\x1b[1m", |
| 9 | + gray: "\x1b[90m", |
| 10 | + reset: "\x1b[0m" |
| 11 | +}; |
| 12 | + |
| 13 | +export function renderTerminalMarkdown( |
| 14 | + markdown: string, |
| 15 | + options: MarkdownRenderOptions = {} |
| 16 | +): string { |
| 17 | + const width = Math.max(32, options.width ?? 80); |
| 18 | + const colors = options.colors ?? true; |
| 19 | + const lines = markdown.replace(/\r\n?/g, "\n").split("\n"); |
| 20 | + const rendered: string[] = []; |
| 21 | + let index = 0; |
| 22 | + let inCodeBlock = false; |
| 23 | + |
| 24 | + while (index < lines.length) { |
| 25 | + const line = lines[index]; |
| 26 | + |
| 27 | + if (/^\s*```/.test(line)) { |
| 28 | + inCodeBlock = !inCodeBlock; |
| 29 | + index += 1; |
| 30 | + continue; |
| 31 | + } |
| 32 | + |
| 33 | + if (inCodeBlock) { |
| 34 | + rendered.push(style(` ${line}`, "gray", colors)); |
| 35 | + index += 1; |
| 36 | + continue; |
| 37 | + } |
| 38 | + |
| 39 | + if (isTableStart(lines, index)) { |
| 40 | + const tableEnd = findTableEnd(lines, index); |
| 41 | + rendered.push(...renderTable(lines.slice(index, tableEnd), width, colors)); |
| 42 | + index = tableEnd; |
| 43 | + continue; |
| 44 | + } |
| 45 | + |
| 46 | + const heading = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/); |
| 47 | + if (heading) { |
| 48 | + const title = cleanInline(heading[1]); |
| 49 | + rendered.push(style(title, "heading", colors)); |
| 50 | + rendered.push(style("-".repeat(Math.min(width, Math.max(12, title.length))), "gray", colors)); |
| 51 | + index += 1; |
| 52 | + continue; |
| 53 | + } |
| 54 | + |
| 55 | + const listItem = line.match(/^(\s*)([-+*]|\d+\.)\s+(.+)$/); |
| 56 | + if (listItem) { |
| 57 | + const marker = /^\d/.test(listItem[2]) ? listItem[2] : "-"; |
| 58 | + const indent = listItem[1].length + (marker === "-" ? 0 : 0); |
| 59 | + rendered.push(...wrapWithPrefix( |
| 60 | + cleanInline(listItem[3]), |
| 61 | + `${" ".repeat(indent)}${marker} `, |
| 62 | + width |
| 63 | + )); |
| 64 | + index += 1; |
| 65 | + continue; |
| 66 | + } |
| 67 | + |
| 68 | + if (line.trim() === "") { |
| 69 | + if (rendered.at(-1) !== "") { |
| 70 | + rendered.push(""); |
| 71 | + } |
| 72 | + index += 1; |
| 73 | + continue; |
| 74 | + } |
| 75 | + |
| 76 | + rendered.push(...wrapText(cleanInline(line.trim()), width)); |
| 77 | + index += 1; |
| 78 | + } |
| 79 | + |
| 80 | + return trimBlankLines(rendered).join("\n"); |
| 81 | +} |
| 82 | + |
| 83 | +function isTableStart(lines: string[], index: number): boolean { |
| 84 | + return ( |
| 85 | + index + 1 < lines.length |
| 86 | + && lines[index].includes("|") |
| 87 | + && /^\s*\|?\s*:?-{3,}/.test(lines[index + 1]) |
| 88 | + ); |
| 89 | +} |
| 90 | + |
| 91 | +function findTableEnd(lines: string[], start: number): number { |
| 92 | + let index = start + 2; |
| 93 | + while (index < lines.length && lines[index].includes("|") && lines[index].trim() !== "") { |
| 94 | + index += 1; |
| 95 | + } |
| 96 | + return index; |
| 97 | +} |
| 98 | + |
| 99 | +function renderTable( |
| 100 | + tableLines: string[], |
| 101 | + width: number, |
| 102 | + colors: boolean |
| 103 | +): string[] { |
| 104 | + const headers = parseTableRow(tableLines[0]).map(cleanInline); |
| 105 | + const rows = tableLines.slice(2).map((line) => parseTableRow(line).map(cleanInline)); |
| 106 | + const labelWidth = Math.min( |
| 107 | + 18, |
| 108 | + Math.max(8, ...headers.slice(1).map((header) => header.length)) |
| 109 | + ); |
| 110 | + const rendered: string[] = []; |
| 111 | + |
| 112 | + for (const row of rows) { |
| 113 | + const title = row[0] || "Item"; |
| 114 | + rendered.push(style(title, "heading", colors)); |
| 115 | + |
| 116 | + for (let index = 1; index < headers.length; index += 1) { |
| 117 | + const label = headers[index] || `Column ${index + 1}`; |
| 118 | + const value = row[index] || "-"; |
| 119 | + rendered.push(...wrapWithPrefix(value, ` ${label.padEnd(labelWidth)} `, width)); |
| 120 | + } |
| 121 | + |
| 122 | + rendered.push(""); |
| 123 | + } |
| 124 | + |
| 125 | + return trimBlankLines(rendered); |
| 126 | +} |
| 127 | + |
| 128 | +function parseTableRow(line: string): string[] { |
| 129 | + return line |
| 130 | + .trim() |
| 131 | + .replace(/^\|/, "") |
| 132 | + .replace(/\|$/, "") |
| 133 | + .split("|") |
| 134 | + .map((cell) => cell.trim()); |
| 135 | +} |
| 136 | + |
| 137 | +function cleanInline(value: string): string { |
| 138 | + return value |
| 139 | + .replace(/!\[([^\]]*)]\([^)]*\)/g, "$1") |
| 140 | + .replace(/\[([^\]]+)]\(([^)]+)\)/g, "$1 ($2)") |
| 141 | + .replace(/(\*\*|__)(.*?)\1/g, "$2") |
| 142 | + .replace(/\*([^*]+)\*/g, "$1") |
| 143 | + .replace(/~~(.*?)~~/g, "$1") |
| 144 | + .replace(/`([^`]+)`/g, "$1") |
| 145 | + .replace(/\\([\\`*_[\]{}()#+\-.!|>])/g, "$1"); |
| 146 | +} |
| 147 | + |
| 148 | +function wrapText(value: string, width: number): string[] { |
| 149 | + return wrapWithPrefix(value, "", width); |
| 150 | +} |
| 151 | + |
| 152 | +function wrapWithPrefix(value: string, prefix: string, width: number): string[] { |
| 153 | + const continuation = " ".repeat(prefix.length); |
| 154 | + const available = Math.max(12, width - prefix.length); |
| 155 | + const words = value.split(/\s+/).filter(Boolean); |
| 156 | + const lines: string[] = []; |
| 157 | + let current = ""; |
| 158 | + |
| 159 | + for (const word of words) { |
| 160 | + if (!current) { |
| 161 | + current = word; |
| 162 | + continue; |
| 163 | + } |
| 164 | + |
| 165 | + if (`${current} ${word}`.length <= available) { |
| 166 | + current = `${current} ${word}`; |
| 167 | + continue; |
| 168 | + } |
| 169 | + |
| 170 | + lines.push(`${lines.length === 0 ? prefix : continuation}${current}`); |
| 171 | + current = word; |
| 172 | + } |
| 173 | + |
| 174 | + if (current || lines.length === 0) { |
| 175 | + lines.push(`${lines.length === 0 ? prefix : continuation}${current}`); |
| 176 | + } |
| 177 | + |
| 178 | + return lines; |
| 179 | +} |
| 180 | + |
| 181 | +function style( |
| 182 | + value: string, |
| 183 | + tone: "heading" | "gray", |
| 184 | + colors: boolean |
| 185 | +): string { |
| 186 | + if (!colors) { |
| 187 | + return value; |
| 188 | + } |
| 189 | + |
| 190 | + if (tone === "heading") { |
| 191 | + return `${ANSI.bold}${ANSI.aqua}${value}${ANSI.reset}`; |
| 192 | + } |
| 193 | + |
| 194 | + return `${ANSI.gray}${value}${ANSI.reset}`; |
| 195 | +} |
| 196 | + |
| 197 | +function trimBlankLines(lines: string[]): string[] { |
| 198 | + let start = 0; |
| 199 | + let end = lines.length; |
| 200 | + |
| 201 | + while (start < end && lines[start] === "") { |
| 202 | + start += 1; |
| 203 | + } |
| 204 | + |
| 205 | + while (end > start && lines[end - 1] === "") { |
| 206 | + end -= 1; |
| 207 | + } |
| 208 | + |
| 209 | + return lines.slice(start, end); |
| 210 | +} |
0 commit comments