Source code for chatom.format.text

"""Text formatting nodes for chatom.

This module provides a composable system for building formatted text
that can be rendered to different output formats (Markdown, HTML, etc.).
"""

from abc import ABC, abstractmethod
from typing import Union

from pydantic import Field

from chatom.base import BaseModel

from .variant import FORMAT, Format

__all__ = (
    "Bold",
    "ChannelMention",
    "Code",
    "CodeBlock",
    "Document",
    "Emoji",
    "Heading",
    "HorizontalRule",
    "Italic",
    "LineBreak",
    "Link",
    "ListItem",
    "OrderedList",
    "Paragraph",
    "Quote",
    "Raw",
    "Span",
    "Strikethrough",
    "Text",
    "TextNode",
    "Underline",
    "UnorderedList",
    "UserMention",
)


[docs] class TextNode(BaseModel, ABC): """Base class for all text formatting nodes. TextNodes represent semantic text content that can be rendered to different output formats. They form a tree structure allowing complex nested formatting. """
[docs] @abstractmethod def render(self, format: FORMAT = Format.MARKDOWN) -> str: """Render this node to the specified format. Args: format: The output format to render to. Returns: str: The rendered string. """ ...
def __str__(self) -> str: """Return plaintext representation.""" return self.render(Format.PLAINTEXT) def __add__(self, other: Union["TextNode", str]) -> "Span": """Concatenate nodes with + operator.""" if isinstance(other, str): other = Text(content=other) return Span(children=[self, other])
[docs] class Text(TextNode): """Plain text content. Attributes: content: The text content. """ content: str = Field( default="", description="The text content.", )
[docs] def render(self, format: FORMAT = Format.MARKDOWN) -> str: """Render plain text, escaping special characters as needed.""" text = self.content if format in (Format.HTML, "html", Format.TELEGRAM_HTML, "telegram-html", Format.SYMPHONY_MESSAGEML, "symphony-messageml"): # Escape HTML special characters text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") elif format in (Format.SYMPHONY_MESSAGEML, "symphony-messageml"): # Additional Symphony escapes text = text.replace("${", "&#36;{").replace("#{", "&#35;{") return text
[docs] class Raw(TextNode): """Raw content that is not escaped. Use this for inserting platform-specific markup that should not be escaped, such as Symphony hashtags, cashtags, or mentions. Attributes: content: The raw content (will be inserted as-is). """ content: str = Field( default="", description="The raw content (not escaped).", )
[docs] def render(self, format: FORMAT = Format.MARKDOWN) -> str: """Render raw content without any escaping.""" return self.content
[docs] class Bold(TextNode): """Bold formatted text. Attributes: child: The content to make bold. """ child: "TextNode" = Field(description="The content to make bold.")
[docs] def render(self, format: FORMAT = Format.MARKDOWN) -> str: content = self.child.render(format) fmt = Format(format) if isinstance(format, str) else format if fmt in (Format.MARKDOWN, Format.DISCORD_MARKDOWN): return f"**{content}**" elif fmt == Format.SLACK_MARKDOWN: return f"*{content}*" elif fmt in (Format.HTML, Format.TELEGRAM_HTML, Format.SYMPHONY_MESSAGEML): return f"<b>{content}</b>" return content
[docs] class Italic(TextNode): """Italic formatted text. Attributes: child: The content to italicize. """ child: "TextNode" = Field(description="The content to italicize.")
[docs] def render(self, format: FORMAT = Format.MARKDOWN) -> str: content = self.child.render(format) fmt = Format(format) if isinstance(format, str) else format if fmt in (Format.MARKDOWN, Format.DISCORD_MARKDOWN): return f"*{content}*" elif fmt == Format.SLACK_MARKDOWN: return f"_{content}_" elif fmt in (Format.HTML, Format.TELEGRAM_HTML, Format.SYMPHONY_MESSAGEML): return f"<i>{content}</i>" return content
[docs] class Strikethrough(TextNode): """Strikethrough formatted text. Attributes: child: The content to strike through. """ child: "TextNode" = Field(description="The content to strike through.")
[docs] def render(self, format: FORMAT = Format.MARKDOWN) -> str: content = self.child.render(format) fmt = Format(format) if isinstance(format, str) else format if fmt in (Format.MARKDOWN, Format.DISCORD_MARKDOWN): return f"~~{content}~~" elif fmt == Format.SLACK_MARKDOWN: return f"~{content}~" elif fmt == Format.SYMPHONY_MESSAGEML: # Symphony does not support <s>; render with dash decoration return f"-{content}-" elif fmt in (Format.HTML, Format.TELEGRAM_HTML): return f"<s>{content}</s>" return content
[docs] class Underline(TextNode): """Underlined text. Note: Not all formats support underline. Attributes: child: The content to underline. """ child: "TextNode" = Field(description="The content to underline.")
[docs] def render(self, format: FORMAT = Format.MARKDOWN) -> str: content = self.child.render(format) fmt = Format(format) if isinstance(format, str) else format if fmt == Format.DISCORD_MARKDOWN: return f"__{content}__" elif fmt == Format.SYMPHONY_MESSAGEML: # Symphony does not support <u>; render content as-is return content elif fmt in (Format.HTML, Format.TELEGRAM_HTML): return f"<u>{content}</u>" # Most formats don't support underline return content
[docs] class Code(TextNode): """Inline code. Attributes: content: The code content. """ content: str = Field(description="The code content.")
[docs] def render(self, format: FORMAT = Format.MARKDOWN) -> str: fmt = Format(format) if isinstance(format, str) else format if fmt in (Format.MARKDOWN, Format.DISCORD_MARKDOWN, Format.SLACK_MARKDOWN): return f"`{self.content}`" elif fmt in (Format.HTML, Format.TELEGRAM_HTML, Format.SYMPHONY_MESSAGEML): escaped = self.content.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") return f"<code>{escaped}</code>" return self.content
[docs] class CodeBlock(TextNode): """Code block with optional language specification. Attributes: content: The code content. language: The programming language for syntax highlighting. """ content: str = Field(description="The code content.") language: str = Field(default="", description="Programming language for syntax highlighting.")
[docs] def render(self, format: FORMAT = Format.MARKDOWN) -> str: fmt = Format(format) if isinstance(format, str) else format if fmt in (Format.MARKDOWN, Format.DISCORD_MARKDOWN, Format.SLACK_MARKDOWN): return f"```{self.language}\n{self.content}\n```" elif fmt == Format.SYMPHONY_MESSAGEML: # Symphony MessageML doesn't allow <code> inside <pre> escaped = self.content.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") return f"<pre>{escaped}</pre>" elif fmt == Format.TELEGRAM_HTML: escaped = self.content.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") lang_attr = f' class="language-{self.language}"' if self.language else "" return f"<pre><code{lang_attr}>{escaped}</code></pre>" if lang_attr else f"<pre>{escaped}</pre>" elif fmt == Format.HTML: escaped = self.content.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") lang_attr = f' class="language-{self.language}"' if self.language else "" return f"<pre><code{lang_attr}>{escaped}</code></pre>" return self.content
[docs] class Quote(TextNode): """Block quote. Attributes: child: The quoted content. """ child: "TextNode" = Field(description="The quoted content.")
[docs] def render(self, format: FORMAT = Format.MARKDOWN) -> str: content = self.child.render(format) fmt = Format(format) if isinstance(format, str) else format if fmt in (Format.MARKDOWN, Format.DISCORD_MARKDOWN, Format.SLACK_MARKDOWN): # Prefix each line with > lines = content.split("\n") return "\n".join(f"> {line}" for line in lines) elif fmt == Format.SYMPHONY_MESSAGEML: # Symphony does not support <blockquote>; render with visual quote prefix if "<p>" in content: return content.replace("<p>", "<p>\u258e ") return f"<p>\u258e {content}</p>" elif fmt in (Format.HTML, Format.TELEGRAM_HTML): return f"<blockquote>{content}</blockquote>" return content
[docs] class Paragraph(TextNode): """Paragraph of text. Attributes: children: The content nodes within the paragraph. """ children: list["TextNode"] = Field( default_factory=list, description="Content nodes within the paragraph.", )
[docs] def render(self, format: FORMAT = Format.MARKDOWN) -> str: content = "".join(child.render(format) for child in self.children) fmt = Format(format) if isinstance(format, str) else format if fmt == Format.TELEGRAM_HTML: return content + "\n" if fmt in (Format.HTML, Format.SYMPHONY_MESSAGEML): return f"<p>{content}</p>" return content + "\n"
[docs] class LineBreak(TextNode): """Line break."""
[docs] def render(self, format: FORMAT = Format.MARKDOWN) -> str: fmt = Format(format) if isinstance(format, str) else format if fmt == Format.TELEGRAM_HTML: return "\n" if fmt in (Format.HTML, Format.SYMPHONY_MESSAGEML): return "<br/>" return "\n"
[docs] class HorizontalRule(TextNode): """Horizontal rule/divider."""
[docs] def render(self, format: FORMAT = Format.MARKDOWN) -> str: fmt = Format(format) if isinstance(format, str) else format if fmt in (Format.MARKDOWN, Format.DISCORD_MARKDOWN) or fmt == Format.SLACK_MARKDOWN or fmt == Format.TELEGRAM_HTML: return "\n---\n" elif fmt in (Format.HTML, Format.SYMPHONY_MESSAGEML): return "<hr/>" return "\n" + "-" * 40 + "\n"
[docs] class ListItem(TextNode): """List item. Attributes: child: The item content. """ child: "TextNode" = Field(description="The item content.")
[docs] def render(self, format: FORMAT = Format.MARKDOWN) -> str: return self.child.render(format)
[docs] class UnorderedList(TextNode): """Unordered (bulleted) list. Attributes: items: List items. """ items: list[ListItem] = Field( default_factory=list, description="List items.", )
[docs] def render(self, format: FORMAT = Format.MARKDOWN) -> str: fmt = Format(format) if isinstance(format, str) else format if fmt in (Format.MARKDOWN, Format.DISCORD_MARKDOWN, Format.SLACK_MARKDOWN) or fmt == Format.TELEGRAM_HTML: lines = [f"- {item.render(format)}" for item in self.items] return "\n".join(lines) elif fmt in (Format.HTML, Format.SYMPHONY_MESSAGEML): items_html = "".join(f"<li>{item.render(format)}</li>" for item in self.items) return f"<ul>{items_html}</ul>" else: lines = [f"• {item.render(format)}" for item in self.items] return "\n".join(lines)
[docs] class OrderedList(TextNode): """Ordered (numbered) list. Attributes: items: List items. start: Starting number. """ items: list[ListItem] = Field( default_factory=list, description="List items.", ) start: int = Field(default=1, description="Starting number.")
[docs] def render(self, format: FORMAT = Format.MARKDOWN) -> str: fmt = Format(format) if isinstance(format, str) else format if fmt in (Format.MARKDOWN, Format.DISCORD_MARKDOWN, Format.SLACK_MARKDOWN) or fmt == Format.TELEGRAM_HTML: lines = [f"{i + self.start}. {item.render(format)}" for i, item in enumerate(self.items)] return "\n".join(lines) elif fmt in (Format.HTML, Format.SYMPHONY_MESSAGEML): items_html = "".join(f"<li>{item.render(format)}</li>" for item in self.items) start_attr = f' start="{self.start}"' if self.start != 1 else "" return f"<ol{start_attr}>{items_html}</ol>" else: lines = [f"{i + self.start}. {item.render(format)}" for i, item in enumerate(self.items)] return "\n".join(lines)
[docs] class Heading(TextNode): """Heading. Attributes: child: The heading content. level: Heading level (1-6). """ child: "TextNode" = Field(description="The heading content.") level: int = Field(default=1, ge=1, le=6, description="Heading level (1-6).")
[docs] def render(self, format: FORMAT = Format.MARKDOWN) -> str: content = self.child.render(format) fmt = Format(format) if isinstance(format, str) else format if fmt in (Format.MARKDOWN, Format.DISCORD_MARKDOWN): return f"{'#' * self.level} {content}\n" elif fmt == Format.SLACK_MARKDOWN: # Slack doesn't have headings, use bold return f"*{content}*\n" elif fmt == Format.TELEGRAM_HTML: return f"<b>{content}</b>\n" elif fmt in (Format.HTML, Format.SYMPHONY_MESSAGEML): return f"<h{self.level}>{content}</h{self.level}>" return content.upper() + "\n"
[docs] class UserMention(TextNode): """User mention. Attributes: user_id: Platform-specific user ID. display_name: Fallback display name. """ user_id: str = Field(description="Platform-specific user ID.") display_name: str = Field(default="", description="Fallback display name.")
[docs] def render(self, format: FORMAT = Format.MARKDOWN) -> str: fmt = Format(format) if isinstance(format, str) else format if fmt == Format.DISCORD_MARKDOWN or fmt == Format.SLACK_MARKDOWN: return f"<@{self.user_id}>" elif fmt == Format.SYMPHONY_MESSAGEML: return f'<mention uid="{self.user_id}"/>' elif fmt == Format.TELEGRAM_HTML: return f'<a href="tg://user?id={self.user_id}">@{self.display_name or self.user_id}</a>' elif fmt in (Format.HTML,): return f'<span class="mention" data-user-id="{self.user_id}">@{self.display_name or self.user_id}</span>' return f"@{self.display_name or self.user_id}"
[docs] class ChannelMention(TextNode): """Channel mention. Attributes: channel_id: Platform-specific channel ID. display_name: Fallback display name. """ channel_id: str = Field(description="Platform-specific channel ID.") display_name: str = Field(default="", description="Fallback display name.")
[docs] def render(self, format: FORMAT = Format.MARKDOWN) -> str: fmt = Format(format) if isinstance(format, str) else format if fmt == Format.DISCORD_MARKDOWN or fmt == Format.SLACK_MARKDOWN: return f"<#{self.channel_id}>" elif fmt == Format.TELEGRAM_HTML: return f"#{self.display_name or self.channel_id}" elif fmt in (Format.HTML,): return f'<span class="channel-mention" data-channel-id="{self.channel_id}">#{self.display_name or self.channel_id}</span>' return f"#{self.display_name or self.channel_id}"
[docs] class Emoji(TextNode): """Emoji. Attributes: name: Emoji name (without colons). unicode: Unicode representation if standard emoji. custom_id: Platform-specific ID for custom emoji. """ name: str = Field(description="Emoji name (without colons).") unicode: str = Field(default="", description="Unicode representation.") custom_id: str = Field(default="", description="Platform-specific custom emoji ID.")
[docs] def render(self, format: FORMAT = Format.MARKDOWN) -> str: fmt = Format(format) if isinstance(format, str) else format # If we have unicode, prefer that if self.unicode: return self.unicode if fmt == Format.DISCORD_MARKDOWN: if self.custom_id: return f"<:{self.name}:{self.custom_id}>" return f":{self.name}:" elif fmt == Format.SLACK_MARKDOWN or fmt == Format.TELEGRAM_HTML: return f":{self.name}:" elif fmt in (Format.HTML, Format.SYMPHONY_MESSAGEML): if self.unicode: return self.unicode return f'<span class="emoji" data-emoji="{self.name}">:{self.name}:</span>' return f":{self.name}:"
[docs] class Span(TextNode): """Container for multiple text nodes. Attributes: children: The child nodes. """ children: list["TextNode"] = Field( default_factory=list, description="Child nodes.", )
[docs] def render(self, format: FORMAT = Format.MARKDOWN) -> str: return "".join(child.render(format) for child in self.children)
def __add__(self, other: Union["TextNode", str]) -> "Span": if isinstance(other, str): other = Text(content=other) return Span(children=[*self.children, other])
[docs] class Document(TextNode): """Top-level document containing multiple nodes. Attributes: children: The document content nodes. """ children: list["TextNode"] = Field( default_factory=list, description="Document content nodes.", )
[docs] def render(self, format: FORMAT = Format.MARKDOWN) -> str: fmt = Format(format) if isinstance(format, str) else format content = "".join(child.render(format) for child in self.children) if fmt in (Format.HTML,): return f"<div>{content}</div>" return content
[docs] def append(self, node: "TextNode") -> "Document": """Append a node to the document. Args: node: The node to append. Returns: Self for method chaining. """ self.children.append(node) return self
# Helper functions for building text nodes def text(content: str) -> Text: """Create a Text node.""" return Text(content=content) def bold(content: str | TextNode) -> Bold: """Create a Bold node.""" if isinstance(content, str): content = Text(content=content) return Bold(child=content) def italic(content: str | TextNode) -> Italic: """Create an Italic node.""" if isinstance(content, str): content = Text(content=content) return Italic(child=content) def code(content: str) -> Code: """Create an inline Code node.""" return Code(content=content) def code_block(content: str, language: str = "") -> CodeBlock: """Create a CodeBlock node.""" return CodeBlock(content=content, language=language) def link(text: str, url: str, title: str = "") -> Link: """Create a Link node.""" return Link(text=text, url=url, title=title)