213 lines
7.2 KiB
Python
213 lines
7.2 KiB
Python
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) |