from __future__ import annotations import json import re from yuxi.channel.security.html_guard import sanitize_text_content_preserve_entities, sanitize_url from yuxi.channel.render.parser import parse_markdown from yuxi.channel.render.ir import ( BlockQuote, Bold, BoldItalic, CodeBlock, Document, Heading, Image, InlineCode, IrNode, Italic, LineBreak, Link, ListItem, OrderedList, Paragraph, Strikethrough, Table, Text, ThematicBreak, UnorderedList, ) _FEISHU_ESCAPE_RE = re.compile(r"([\\*_{}\[\]()#+\-!|>~])") def _escape_fs(text: str) -> str: return _FEISHU_ESCAPE_RE.sub(r"\\\1", text) def _build_text(content: str, bold: bool = False, italic: bool = False, strikethrough: bool = False) -> dict: style: list[str] = [] if bold: style.append("bold") if italic: style.append("italic") if strikethrough: style.append("strikethrough") return {"tag": "text", "text": sanitize_text_content_preserve_entities(content), "style": style} def _build_paragraph(elements: list[dict]) -> dict: return {"tag": "p", "children": elements if elements else [_build_text("")]} def _build_code_block(code: str, language: str = "") -> dict: return { "tag": "pre", "children": [ { "tag": "code", "code": sanitize_text_content_preserve_entities(code), "code_language": sanitize_text_content_preserve_entities(language) or "plaintext", } ], } def _render_inline_fs(nodes: list[IrNode]) -> list[dict]: elements: list[dict] = [] for node in nodes: if isinstance(node, Text): cleaned = sanitize_text_content_preserve_entities(node.content) elements.append(_build_text(_escape_fs(cleaned))) elif isinstance(node, Bold): inner = _render_inline_fs(node.children) for el in inner: if el["tag"] == "text": el.setdefault("style", []).append("bold") elements.extend(inner) elif isinstance(node, Italic): inner = _render_inline_fs(node.children) for el in inner: if el["tag"] == "text": el.setdefault("style", []).append("italic") elements.extend(inner) elif isinstance(node, BoldItalic): inner = _render_inline_fs(node.children) for el in inner: if el["tag"] == "text": style = el.setdefault("style", []) style.append("bold") style.append("italic") elements.extend(inner) elif isinstance(node, Strikethrough): inner = _render_inline_fs(node.children) for el in inner: if el["tag"] == "text": el.setdefault("style", []).append("strikethrough") elements.extend(inner) elif isinstance(node, InlineCode): elements.append(_build_text(node.code)) elif isinstance(node, Link): inner = _render_inline_fs(node.children) safe_url = sanitize_url(node.url) for el in inner: if el["tag"] == "text": el["text"] = f"{el['text']} ({safe_url})" elements.extend(inner) elif isinstance(node, Image): elements.append(_build_text(f"[{node.alt or 'Image'}]")) elif isinstance(node, LineBreak): elements.append(_build_text("\n")) return elements def _render_list_item_fs(item: ListItem) -> list[list[dict]]: paragraphs: list[list[dict]] = [] inline_nodes: list[IrNode] = [] for child in item.children: if isinstance(child, (UnorderedList, OrderedList)): if inline_nodes: paragraphs.append(_render_inline_fs(inline_nodes)) inline_nodes = [] paragraphs.extend(_render_block_fs(child)) elif isinstance(child, Paragraph): if inline_nodes: paragraphs.append(_render_inline_fs(inline_nodes)) inline_nodes = [] paragraphs.append(_render_inline_fs(child.children)) else: inline_nodes.append(child) if inline_nodes: paragraphs.append(_render_inline_fs(inline_nodes)) return paragraphs def _render_block_fs(node: IrNode) -> list[list[dict]]: if isinstance(node, Paragraph): inline = _render_inline_fs(node.children) return [inline] if inline else [[_build_text("")]] elif isinstance(node, Heading): inline = _render_inline_fs(node.children) for el in inline: if el["tag"] == "text": el.setdefault("style", []).append("bold") return [inline] elif isinstance(node, CodeBlock): return [[_build_text("")]] elif isinstance(node, BlockQuote): result: list[list[dict]] = [] for child in node.children: result.extend(_render_block_fs(child)) return result elif isinstance(node, UnorderedList): result: list[list[dict]] = [] for item in node.items: item_paras = _render_list_item_fs(item) for para in item_paras: result.append([_build_text("\u2022 " + "".join(el.get("text", "") for el in para))]) return result elif isinstance(node, OrderedList): result: list[list[dict]] = [] for idx, item in enumerate(node.items, start=node.start): item_paras = _render_list_item_fs(item) for para in item_paras: result.append([_build_text(f"{idx}. " + "".join(el.get("text", "") for el in para))]) return result elif isinstance(node, ThematicBreak): return [[_build_text("---")]] elif isinstance(node, Table): rows: list[list[dict]] = [] if node.header: header_text = " | ".join( "".join(el.get("text", "") for el in _render_inline_fs(cell.children)) for cell in node.header.cells ) rows.append([_build_text(header_text, bold=True)]) for row in node.rows: row_text = " | ".join( "".join(el.get("text", "") for el in _render_inline_fs(cell.children)) for cell in row.cells ) rows.append([_build_text(row_text)]) if rows: rows.insert(1, [_build_text("-" * 20)]) return rows else: return [[_build_text("")]] def render_to_feishu_post(doc: Document) -> dict: content: list[list[dict]] = [] for node in doc.children: if isinstance(node, CodeBlock): content.append([_build_text("")]) continue content.extend(_render_block_fs(node)) if not content: content = [[_build_text("")]] return {"zh_cn": {"title": "", "content": [content]}} def render_to_feishu_post_json(doc: Document) -> str: post = render_to_feishu_post(doc) return json.dumps(post, ensure_ascii=False) def render_markdown_to_feishu_post(md_text: str) -> dict: doc = parse_markdown(md_text) return render_to_feishu_post(doc) def render_markdown_to_feishu_post_json(md_text: str) -> str: doc = parse_markdown(md_text) return render_to_feishu_post_json(doc)