@muxik/md-viewer
Version:
A CLI tool for rendering Markdown files with syntax highlighting and pagination, optimized for AI output content display
553 lines • 24.3 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TerminalRenderer = void 0;
const chalk_1 = __importDefault(require("chalk"));
const highlight_js_1 = __importDefault(require("highlight.js"));
class TerminalRenderer {
constructor(options) {
this.theme = options.theme;
this.width = options.width;
this.showLineNumbers = options.showLineNumbers || false;
}
render(tokens) {
const lines = [];
for (const token of tokens) {
const tokenLines = this.renderToken(token);
lines.push(...tokenLines);
if (token.type !== 'list_item') {
lines.push('');
}
}
return lines.filter((line, index, arr) => {
if (index === arr.length - 1)
return line.trim() !== '';
return true;
});
}
renderToken(token) {
switch (token.type) {
case 'heading':
return this.renderHeading(token);
case 'paragraph':
return this.renderParagraph(token);
case 'code':
return this.renderCodeBlock(token);
case 'blockquote':
return this.renderBlockquote(token, 0);
case 'list':
return this.renderList(token, 0);
case 'list_item':
return this.renderListItem(token, 0);
case 'table':
return this.renderTable(token);
case 'hr':
return this.renderHr();
case 'html':
return this.renderHtml(token);
case 'text':
return this.renderText(token);
case 'del':
return this.renderDel(token);
case 'image':
return this.renderImage(token);
default:
return [];
}
}
renderHeading(token) {
const level = token.level || 1;
const prefix = this.theme.styles.heading.prefix;
const color = this.theme.colors.heading;
let content = token.content;
let formattedContent = '';
switch (level) {
case 1:
formattedContent = chalk_1.default.hex(color).bold.underline(content);
break;
case 2:
formattedContent = chalk_1.default.hex(color).bold(content);
break;
case 3:
formattedContent = chalk_1.default.hex(color).bold(content);
break;
default:
formattedContent = chalk_1.default.hex(color)(content);
}
const line = `${chalk_1.default.hex(color)(prefix)} ${formattedContent}`;
return [line];
}
renderParagraph(token) {
if (token.children) {
// 处理段落中的内联token
let content = '';
for (const child of token.children) {
switch (child.type) {
case 'text':
content += child.content;
break;
case 'del':
content += chalk_1.default.hex(this.theme.colors.del).strikethrough(child.content);
break;
case 'strong':
content += chalk_1.default.hex(this.theme.colors.strong).bold(child.content);
break;
case 'em':
content += chalk_1.default.hex(this.theme.colors.em).italic(child.content);
break;
case 'code_inline':
content += chalk_1.default.hex(this.theme.colors.code)(`${this.theme.styles.code.prefix}${child.content}${this.theme.styles.code.suffix}`);
break;
case 'link':
content += chalk_1.default.hex(this.theme.colors.link).underline(child.content);
break;
case 'image':
const alt = child.content || child.alt || '图片';
const url = child.href || child.url || '';
content += chalk_1.default.hex(this.theme.colors.link).underline(`[图片: ${alt}] ${url}`);
break;
default:
content += child.content;
}
}
return this.wrapText(content, this.width);
}
else {
// 回退到原有的处理方式
const content = this.processInlineTokens(token.content);
return this.wrapText(content, this.width);
}
}
renderCodeBlock(token) {
const lang = token.lang || 'text';
const content = token.content;
let highlightedCode;
try {
const result = highlight_js_1.default.highlight(content, { language: lang });
highlightedCode = this.convertHtmlToTerminalColors(result.value);
}
catch (error) {
highlightedCode = chalk_1.default.hex(this.theme.colors.codeBlock)(content);
}
const lines = highlightedCode.split('\n');
const paddedLines = lines.map(line => ` ${line}`);
const borderColor = this.theme.colors.border;
const topBorder = chalk_1.default.hex(borderColor)('┌' + '─'.repeat(this.width - 2) + '┐');
const bottomBorder = chalk_1.default.hex(borderColor)('└' + '─'.repeat(this.width - 2) + '┘');
return [topBorder, ...paddedLines, bottomBorder];
}
renderBlockquote(token, depth = 0) {
const prefix = this.theme.styles.quote.prefix;
const color = this.theme.colors.quote;
const lines = [];
// 计算缩进和可用宽度
const indentSize = depth * 2;
const availableWidth = this.width - indentSize - 2;
if (token.children) {
// 处理引用块中的子token
for (const child of token.children) {
let childLines;
if (child.type === 'blockquote') {
// 递归处理嵌套引用
childLines = this.renderBlockquote(child, depth + 1);
}
else {
// 处理其他类型的子token
childLines = this.renderTokenWithWidth(child, availableWidth);
}
lines.push(...childLines);
}
}
else {
// 回退到原有的处理方式
const content = this.processInlineTokens(token.content);
const textLines = this.wrapText(content, availableWidth);
lines.push(...textLines);
}
// 添加引用前缀和颜色
const indent = ' '.repeat(indentSize);
return lines.map(line => `${indent}${chalk_1.default.hex(color)(prefix)}${chalk_1.default.hex(color)(line)}`);
}
renderList(token, depth = 0) {
const lines = [];
const ordered = token.ordered || false;
const indent = ' '.repeat(depth);
if (token.children) {
token.children.forEach((item, index) => {
let marker;
let markerColor = this.theme.colors.listBullet;
if (item.task) {
// 待办事项
marker = item.checked ? '☑' : '☐';
markerColor = item.checked ? this.theme.colors.accent : this.theme.colors.text;
}
else {
// 普通列表项
marker = ordered ? `${index + 1}${this.theme.styles.list.number}` : this.theme.styles.list.bullet;
}
const itemLines = this.renderListItem(item, depth);
if (itemLines.length > 0) {
const firstLine = `${indent} ${chalk_1.default.hex(markerColor)(marker)} ${itemLines[0]}`;
const restLines = itemLines.slice(1).map(line => `${indent} ${line}`);
lines.push(firstLine, ...restLines);
}
});
}
return lines;
}
renderListItem(token, depth = 0) {
const lines = [];
if (token.children) {
// 处理列表项的所有子token
for (const child of token.children) {
if (child.type === 'text') {
const content = this.processInlineTokens(child.content);
const textLines = this.wrapText(content, this.width - 5 - (depth * 2));
lines.push(...textLines);
}
else if (child.type === 'list') {
// 递归处理嵌套列表
const nestedLines = this.renderList(child, depth + 1);
lines.push(...nestedLines);
}
else {
// 处理其他类型的token
const childLines = this.renderToken(child);
lines.push(...childLines);
}
}
}
else {
// 如果没有子token,使用原来的逻辑
const content = this.processInlineTokens(token.content);
const textLines = this.wrapText(content, this.width - 5 - (depth * 2));
lines.push(...textLines);
}
return lines;
}
renderTable(token) {
if (!token.children || token.children.length === 0) {
return [];
}
const rows = token.children.map(child => child.content.split('|').map(cell => cell.trim()));
const maxCols = Math.max(...rows.map(row => row.length));
// 计算每列的最大宽度(考虑中文字符)
const colWidths = Array(maxCols).fill(0);
rows.forEach(row => {
row.forEach((cell, index) => {
if (index < colWidths.length) {
const displayWidth = this.getDisplayWidth(cell);
colWidths[index] = Math.max(colWidths[index], displayWidth);
}
});
});
// 确保表格不会超出终端宽度
const totalWidth = colWidths.reduce((sum, width) => sum + width, 0) + (maxCols - 1) * 3 + 4; // 3 for " | ", 4 for "| ... |"
if (totalWidth > this.width) {
this.adjustColumnWidths(colWidths, this.width - (maxCols - 1) * 3 - 4);
}
const lines = [];
const borderColor = this.theme.colors.border;
const headerColor = this.theme.colors.heading;
// 顶部边框
const topBorder = '┌' + colWidths.map(width => '─'.repeat(width + 2)).join('┬') + '┐';
lines.push(chalk_1.default.hex(borderColor)(topBorder));
rows.forEach((row, rowIndex) => {
const formattedCells = row.map((cell, colIndex) => {
const width = colWidths[colIndex] || 0;
// 处理表格单元格中的行内格式
let processedCell = cell;
if (rowIndex === 0) {
// 标题行:先加粗,再处理内联格式
processedCell = chalk_1.default.hex(headerColor).bold(cell);
}
else {
// 普通行:处理内联格式
processedCell = this.processInlineTokens(cell);
}
const content = this.padCellWithAnsi(processedCell, width);
return content;
});
const rowLine = chalk_1.default.hex(borderColor)('│') + ' ' + formattedCells.join(' ' + chalk_1.default.hex(borderColor)('│') + ' ') + ' ' + chalk_1.default.hex(borderColor)('│');
lines.push(rowLine);
if (rowIndex === 0) {
// 标题行下的分隔线
const separator = '├' + colWidths.map(width => '─'.repeat(width + 2)).join('┼') + '┤';
lines.push(chalk_1.default.hex(borderColor)(separator));
}
});
// 底部边框
const bottomBorder = '└' + colWidths.map(width => '─'.repeat(width + 2)).join('┴') + '┘';
lines.push(chalk_1.default.hex(borderColor)(bottomBorder));
return lines;
}
getDisplayWidth(text) {
// 简单的中文字符宽度计算
let width = 0;
for (let i = 0; i < text.length; i++) {
const char = text[i];
const code = char.charCodeAt(0);
// 中文字符、全角字符和表情符号占用2个字符宽度
if ((code >= 0x4e00 && code <= 0x9fff) || // 中文字符
(code >= 0x3400 && code <= 0x4dbf) || // 扩展中文字符
(code >= 0xff00 && code <= 0xffef) || // 全角字符
(code >= 0x2600 && code <= 0x26ff) || // 表情符号
(code >= 0x2700 && code <= 0x27bf) || // 装饰符号
char.match(/[\u{1f600}-\u{1f64f}]/u) || // 表情符号
char.match(/[\u{1f300}-\u{1f5ff}]/u) || // 符号和象形文字
char.match(/[\u{1f680}-\u{1f6ff}]/u) || // 交通和地图符号
char.match(/[\u{1f700}-\u{1f77f}]/u) || // 炼金术符号
char.match(/[\u{1f780}-\u{1f7ff}]/u) || // 几何形状扩展
char.match(/[\u{1f800}-\u{1f8ff}]/u) || // 补充箭头C
char.match(/[\u{1f900}-\u{1f9ff}]/u) // 补充符号和象形文字
) {
width += 2;
}
else {
width += 1;
}
}
return width;
}
padCell(text, targetWidth) {
const displayWidth = this.getDisplayWidth(text);
const padding = Math.max(0, targetWidth - displayWidth);
return text + ' '.repeat(padding);
}
padCellWithAnsi(text, targetWidth) {
// 清理 ANSI 转义序列后计算显示宽度
const cleanText = this.stripAnsiCodes(text);
const displayWidth = this.getDisplayWidth(cleanText);
// 计算需要的填充空格数
const padding = Math.max(0, targetWidth - displayWidth);
// 返回原文本加上填充空格
return text + ' '.repeat(padding);
}
stripAnsiCodes(text) {
// 移除所有 ANSI 转义序列,包括颜色、样式等
return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
}
adjustColumnWidths(colWidths, availableWidth) {
const totalWidth = colWidths.reduce((sum, width) => sum + width, 0);
if (totalWidth <= availableWidth)
return;
// 按比例缩减列宽
const scale = availableWidth / totalWidth;
for (let i = 0; i < colWidths.length; i++) {
colWidths[i] = Math.floor(colWidths[i] * scale);
// 确保最小宽度为3
if (colWidths[i] < 3)
colWidths[i] = 3;
}
}
renderHr() {
const char = this.theme.styles.hr.char;
const length = Math.min(this.theme.styles.hr.length, this.width);
const line = char.repeat(length);
return [chalk_1.default.hex(this.theme.colors.hr)(line)];
}
renderHtml(token) {
let content = token.content;
// 移除所有HTML注释
content = content.replace(/<!--[\s\S]*?-->/g, '');
// 如果移除注释后内容为空或只有空白,则不渲染
if (!content.trim()) {
return [];
}
// 对于其他HTML内容,以淡色显示
return [chalk_1.default.hex(this.theme.colors.text).dim(content)];
}
renderText(token) {
const content = this.processInlineTokens(token.content);
return this.wrapText(content, this.width);
}
renderDel(token) {
const content = chalk_1.default.hex(this.theme.colors.del).strikethrough(token.content);
return this.wrapText(content, this.width);
}
renderImage(token) {
const alt = token.content || token.alt || '图片';
const url = token.href || token.url || '';
const content = chalk_1.default.hex(this.theme.colors.link).underline(`[图片: ${alt}] ${url}`);
return this.wrapText(content, this.width);
}
renderTokenWithWidth(token, width) {
const originalWidth = this.width;
this.width = width;
try {
const result = this.renderToken(token);
return result;
}
finally {
this.width = originalWidth;
}
}
processInlineTokens(content) {
const textColor = this.theme.colors.text;
const linkColor = this.theme.colors.link;
const codeColor = this.theme.colors.code;
const strongColor = this.theme.colors.strong;
const emColor = this.theme.colors.em;
const delColor = this.theme.colors.del;
// 使用占位符保护已处理的内容
const placeholders = [];
let result = content;
// 1. 先处理行内代码,用占位符替换
result = result.replace(/`([^`]+)`/g, (match, code) => {
const placeholder = `__PLACEHOLDER_${placeholders.length}__`;
const formatted = chalk_1.default.hex(codeColor)(`${this.theme.styles.code.prefix}${code}${this.theme.styles.code.suffix}`);
placeholders.push(formatted);
return placeholder;
});
// 2. 处理图片链接
result = result.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => {
const placeholder = `__PLACEHOLDER_${placeholders.length}__`;
const formatted = chalk_1.default.hex(linkColor).underline(`[图片: ${alt || '图片'}] ${url}`);
placeholders.push(formatted);
return placeholder;
});
// 3. 处理普通链接
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
const placeholder = `__PLACEHOLDER_${placeholders.length}__`;
const formatted = chalk_1.default.hex(linkColor).underline(text);
placeholders.push(formatted);
return placeholder;
});
// 4. 处理直接URL
result = result.replace(/https?:\/\/[^\s]+/g, (url) => {
const placeholder = `__PLACEHOLDER_${placeholders.length}__`;
const formatted = chalk_1.default.hex(linkColor).underline(url);
placeholders.push(formatted);
return placeholder;
});
// 5. 处理粗斜体(三个星号)
result = result.replace(/\*\*\*(.*?)\*\*\*/g, (_, text) => chalk_1.default.hex(strongColor).bold.italic(text));
// 6. 处理粗体(两个星号)
result = result.replace(/\*\*(.*?)\*\*/g, (_, text) => chalk_1.default.hex(strongColor).bold(text));
// 7. 处理斜体(一个星号)
result = result.replace(/\*(.*?)\*/g, (_, text) => chalk_1.default.hex(emColor).italic(text));
// 8. 处理删除线
result = result.replace(/~~(.*?)~~/g, (_, text) => chalk_1.default.hex(delColor).strikethrough(text));
// 9. 恢复占位符
placeholders.forEach((formatted, index) => {
result = result.replace(`__PLACEHOLDER_${index}__`, formatted);
});
return result || chalk_1.default.hex(textColor)(content);
}
wrapText(text, width) {
if (!text)
return [''];
const words = text.split(' ');
const lines = [];
let currentLine = '';
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word;
const cleanTestLine = testLine.replace(/\x1b\[[0-9;]*m/g, '');
if (cleanTestLine.length <= width) {
currentLine = testLine;
}
else {
if (currentLine) {
lines.push(currentLine);
currentLine = word;
}
else {
lines.push(word);
}
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines.length > 0 ? lines : [''];
}
convertHtmlToTerminalColors(html) {
// One Dark 配色方案
const colorMap = {
'hljs-keyword': '#c678dd',
'hljs-string': '#98c379',
'hljs-comment': '#5c6370',
'hljs-number': '#d19a66',
'hljs-function': '#61afef',
'hljs-variable': '#e06c75',
'hljs-type': '#56b6c2',
'hljs-built_in': '#e5c07b',
'hljs-title': '#61afef',
'hljs-params': '#abb2bf',
'hljs-literal': '#56b6c2',
'hljs-regexp': '#e06c75',
'hljs-class': '#e5c07b',
'hljs-attr': '#d19a66',
'hljs-tag': '#e06c75',
'hljs-name': '#e06c75',
'hljs-selector-tag': '#e06c75',
'hljs-selector-class': '#d19a66',
'hljs-selector-id': '#61afef',
'hljs-meta': '#61afef',
'hljs-doctag': '#c678dd',
'hljs-section': '#e5c07b',
'hljs-attribute': '#d19a66',
'hljs-subst': '#e06c75',
'hljs-formula': '#56b6c2',
'hljs-addition': '#98c379',
'hljs-deletion': '#e06c75',
'hljs-quote': '#5c6370',
'hljs-emphasis': '#abb2bf',
'hljs-strong': '#abb2bf',
'hljs-link': '#61afef',
'hljs-symbol': '#56b6c2',
'hljs-bullet': '#abb2bf',
'hljs-code': '#e06c75',
'hljs-template-tag': '#c678dd',
'hljs-template-variable': '#e06c75',
'hljs-operator': '#56b6c2',
'hljs-punctuation': '#abb2bf',
'hljs-property': '#e06c75',
'hljs-title.function': '#61afef',
'hljs-title.class': '#e5c07b',
'hljs-variable.language': '#c678dd',
'hljs-variable.constant': '#d19a66', // 常量 - 橙色 (@hue-6)
};
let result = html;
// 递归处理嵌套的 HTML 标签,从内到外
let hasMatches = true;
while (hasMatches) {
const beforeReplace = result;
result = result.replace(/<span class="([^"]+)">([^<]*)<\/span>/g, (match, className, content) => {
// 处理复合类名,取第一个有效的类名
const classes = className.split(' ');
let color = this.theme.colors.codeBlock;
for (const cls of classes) {
if (colorMap[cls]) {
color = colorMap[cls];
break;
}
}
return chalk_1.default.hex(color)(content);
});
hasMatches = result !== beforeReplace;
}
// 处理剩余的 HTML 标签
result = result.replace(/<[^>]*>/g, '');
// 解码 HTML 实体
result = result
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/'/g, "'")
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/&/g, '&');
// 如果没有任何高亮,使用默认颜色
if (result === html) {
result = chalk_1.default.hex(this.theme.colors.codeBlock)(html);
}
return result;
}
cleanHtmlTags(html) {
return html.replace(/<[^>]*>/g, '');
}
}
exports.TerminalRenderer = TerminalRenderer;
//# sourceMappingURL=terminal-renderer.js.map