import { CodeLanguage } from 'feishu-docx';
import YAML from 'js-yaml';
import { marked } from 'marked';
import { markedXhtml } from 'marked-xhtml';
import { createElement } from './dom';
import { getEmojiChar } from './emoji';
import { Buffer } from './string_buffer';
import { Renderer, escapeHTMLTags, trimLastNewline } from './renderer';
import {
Block,
BlockType,
CalloutBackgroundColorMap,
CalloutBorderColorMap,
FontColorMap,
ImageBlock,
TableBlock,
TableMergeInfo,
TextBlock,
TextElement,
TextRun,
getAlignStyle,
getCodeLanguage,
} from './types';
marked.use(markedXhtml());
/**
* Markdown Renderer
*
* Convert Feishu Docx to Markdown GFM
*
* This is inspired by feishu2md (Go version)
* https://github.com/Wsine/feishu2md/blob/cb906109235b07b82b5a6348bdf1103c9fa1e62c/core/parser.go
*/
export class MarkdownRenderer extends Renderer {
parseBlock(block: Block, indent: number) {
this.indent = indent;
if (!block) {
return '';
}
const buf = new Buffer();
buf.writeIndent(this.indent);
this.currentBlock = block;
switch (block.block_type) {
case BlockType.Page:
buf.write(this.parsePageBlock(block));
break;
case BlockType.Text:
buf.write(this.parseTextBlock(block, block.text));
break;
case BlockType.Heading1:
buf.write('# ');
buf.write(this.parseTextBlock(block, block.heading1).trimStart());
break;
case BlockType.Heading2:
buf.write('## ');
buf.write(this.parseTextBlock(block, block.heading2).trimStart());
break;
case BlockType.Heading3:
buf.write('### ');
buf.write(this.parseTextBlock(block, block.heading3).trimStart());
break;
case BlockType.Heading4:
buf.write('#### ');
buf.write(this.parseTextBlock(block, block.heading4).trimStart());
break;
case BlockType.Heading5:
buf.write('##### ');
buf.write(this.parseTextBlock(block, block.heading5).trimStart());
break;
case BlockType.Heading6:
buf.write('###### ');
buf.write(this.parseTextBlock(block, block.heading6).trimStart());
break;
case BlockType.Heading7:
buf.write('####### ');
buf.write(this.parseTextBlock(block, block.heading7).trimStart());
break;
case BlockType.Heading8:
buf.write('######## ');
buf.write(this.parseTextBlock(block, block.heading8).trimStart());
break;
case BlockType.Heading9:
buf.write('######### ');
buf.write(this.parseTextBlock(block, block.heading9).trimStart());
break;
case BlockType.Bullet:
buf.write(this.parseBulletBlock(block, indent).trimStart());
break;
case BlockType.Ordered:
buf.write(this.parseOrderedBlock(block, indent));
break;
case BlockType.Code:
buf.write('```');
buf.write(getCodeLanguage(block.code.style.language));
buf.write('\n');
buf.write(this.parseTextBlock(block, block.code).toString().trim());
buf.write('\n```\n');
break;
case BlockType.Quote:
buf.write('> ');
buf.write(this.parseTextBlock(block, block.quote));
break;
case BlockType.TodoList:
buf.write('- [');
buf.write(block.todo.style.done ? 'x' : ' ');
buf.write('] ');
buf.write(this.parseTextBlock(block, block.todo));
break;
case BlockType.Divider:
buf.write('---\n');
break;
case BlockType.Image:
buf.write(this.parseImage(block.image));
break;
case BlockType.TableCell:
buf.write(this.parseTableCell(block));
break;
case BlockType.Table:
buf.write(this.parseTable(block.table));
break;
case BlockType.QuoteContainer:
buf.write(this.parseQuoteContainer(block));
break;
case BlockType.View:
buf.write(this.parseView(block));
break;
case BlockType.File:
buf.write(this.parseFile(block));
break;
case BlockType.Grid:
buf.write(this.parseGrid(block));
break;
case BlockType.GridColumn:
break;
case BlockType.Callout:
buf.write(this.parseCallout(block));
break;
case BlockType.Iframe:
buf.write(this.parseIframe(block));
break;
case BlockType.Board:
buf.write(this.parseBoard(block.board));
break;
case BlockType.SyncedBlock:
buf.write(this.parseSyncedBlock(block));
break;
default:
buf.write(this.parseUnsupport(block));
break;
}
return buf.toString();
}
/**
* Parse this first block as PageMeta
*
* Return false if not found first code block.
* Otherwise return true if parsed as YAML, false if not YAML.
*
* https://longbridgeapp.github.io/feishu-pages/zh-CN/page-meta
*
* @param block
* @returns
*/
parsePageMeta(block: Block) {
if (block?.block_type !== BlockType.Code) {
if (block.children?.length > 0) {
return this.parsePageMeta(this.blockMap[block.children[0]]);
} else {
return false;
}
}
// Only support YAML
if (block?.code?.style?.language !== CodeLanguage.YAML) {
return false;
}
let code = this.parseTextBlock(block, block.code).toString().trim();
if (!code) {
return false;
}
const language = block?.code?.style?.language;
try {
if (language === CodeLanguage.YAML) {
this.meta = YAML.load(code);
} else if (language === CodeLanguage.JSON) {
this.meta = JSON.parse(code);
}
} catch {
console.error(`Invalid ${language} content, ignored.\n\n` + code);
}
return true;
}
parsePageBlock(block: Block): Buffer | string {
const buf = new Buffer();
buf.write('# ');
buf.write(this.parseTextBlock(block, block.page));
buf.write('\n');
block.children?.forEach((childId, idx) => {
const child = this.blockMap[childId];
this.nextBlock = this.blockMap[block.children[idx + 1]];
// Extract PageMeta from first code block
if (idx == 0) {
if (this.parsePageMeta(child)) {
return;
}
}
this.withSubIndent(() => {
let childText = this.parseBlock(child, 0);
if (childText.length > 0) {
buf.write(childText);
buf.write('\n');
}
});
});
return buf;
}
parseTextBlock(block: Block, textBlock: TextBlock): Buffer | string {
const buf = new Buffer();
const inline = textBlock.elements.length > 1;
textBlock.elements?.forEach((el) => {
this.parseTextElement(buf, el, inline);
});
if (buf.length > 0) {
buf.write('\n');
}
// For type: 2 render children
if (block.block_type == BlockType.Text) {
block.children?.forEach((childId, _) => {
const child = this.blockMap[childId];
buf.write(this.parseBlock(child, this.indent));
});
}
return buf;
}
parseBulletBlock(block: Block, indent: number = 0): Buffer | string {
const buf = new Buffer();
buf.write('- ');
let itemText = this.parseTextBlock(block, block.bullet).toString();
if (
this.nextBlock?.block_type == block.block_type &&
this.nextBlock?.parent_id == block.parent_id &&
!block.children?.length
) {
itemText = trimLastNewline(itemText);
}
buf.write(itemText);
this.withSubIndent(() => {
block.children?.forEach((childId, idx) => {
const child = this.blockMap[childId];
this.nextBlock = null;
buf.write(this.parseBlock(child, indent + 1));
});
});
return buf;
}
parseOrderedBlock(block: Block, indent: number = 0): Buffer | string {
const buf = new Buffer();
const parent = this.blockMap[block.parent_id];
let order = 1;
// Calc the order number
parent?.children?.forEach((childId, idx) => {
if (childId == block.block_id) {
for (let i = idx - 1; i >= 0; i--) {
if (
this.blockMap[parent.children[i]].block_type == BlockType.Ordered
) {
order++;
} else {
break;
}
}
}
});
buf.write(`${order}. `);
let itemText = this.parseTextBlock(block, block.ordered).toString();
if (
this.nextBlock?.block_type == block.block_type &&
this.nextBlock?.parent_id == block.parent_id &&
!block.children?.length
) {
itemText = trimLastNewline(itemText);
}
buf.write(itemText);
// Sub items
this.withSubIndent(() => {
block.children?.forEach((childId, idx) => {
const child = this.blockMap[childId];
// Peek next block
this.nextBlock = null;
buf.write(this.parseBlock(child, indent + 1));
});
});
return buf;
}
parseTextElement(buf: Buffer, el: TextElement, inline: boolean) {
if (el.text_run) {
this.parseTextRun(buf, el.text_run);
} else if (el.equation) {
let symbol = inline ? '$' : '$$';
buf.write(symbol);
buf.write(el.equation.content.trimEnd());
buf.write(symbol);
} else if (el.mention_doc) {
const node_token = decodeURIComponent(el.mention_doc.token);
buf.write(`[${el.mention_doc.title}](${node_token})`);
}
}
parseTextRun(buf: Buffer, textRun: TextRun) {
let preWrite = '';
let postWrite = '';
let style = textRun.text_element_style;
let escape = true;
if (style) {
if (style.bold) {
preWrite = '';
postWrite = '';
} else if (style.italic) {
preWrite = '';
postWrite = '';
} else if (style.strikethrough) {
preWrite = '';
postWrite = '';
} else if (style.underline) {
preWrite = '';
postWrite = '';
} else if (style.inline_code) {
preWrite = '`';
postWrite = '`';
escape = false;
} else if (style.link) {
const unescapeURL = decodeURIComponent(style.link.url);
preWrite = `[`;
postWrite = `](${unescapeURL})`;
}
}
let plainText = textRun.content || '';
// Only escape HTML tags when not in style
// For example: `