Markdown formatting
Fased formats outbound Markdown by converting it into a shared intermediate representation (IR) before rendering channel-specific output. The IR stores the rendered plain text plus style/link spans, so chunking and rendering can stay consistent across channels.Goals
- Consistency: one parse step, multiple renderers.
- Safe chunking: split text before rendering so inline formatting never breaks across chunks.
- Channel fit: map the same IR to Slack mrkdwn, Telegram HTML, and Signal style ranges without re-parsing Markdown.
Pipeline
- Parse Markdown to IR
- IR is plain text plus style spans (bold/italic/strike/code/spoiler) and link spans.
- Offsets are UTF-16 code units so Signal style ranges align with its API.
- Tables are parsed only when
tableModeis enabled for that channel/account path.
- Chunk IR (format-first)
- Chunking happens on the IR text before rendering.
- Style and link spans are sliced per chunk so rendered markers/ranges remain valid.
- Render per channel
- Slack: mrkdwn tokens (bold/italic/strike/code), links as
<url|label>. - Telegram: HTML tags (
<b>,<i>,<s>,<code>,<pre><code>,<a href>,<tg-spoiler>). - Signal: plain text +
text-styleranges; links becomelabel (url)when label differs.
- Slack: mrkdwn tokens (bold/italic/strike/code), links as
IR example
Input Markdown:Where it is used
- Slack, Telegram, and Signal render full outbound Markdown from the IR.
- Other channels use plain text or their own formatting rules, with Markdown table conversion applied before chunking when enabled.
- LINE has its own Markdown-to-Line renderer for table/card conversion.
Table handling
Markdown tables are not consistently supported across chat clients. Usemarkdown.tables to control conversion per channel (and per account).
code: render tables as code blocks (default for most channels).bullets: convert each row into bullet points (default for Signal + WhatsApp).off: disable table parsing and conversion; raw table text passes through.
Chunking rules
- Chunk limits come from channel adapters/config and are applied to the IR text.
- Fenced code keeps a
code_blockstyle. If a long code block must be split, each chunk receives the relevant slice of the code style. - List prefixes and blockquote prefixes are part of the IR text, so chunking does not split mid-prefix.
- Inline styles and links are sliced across chunks; renderers reopen markers or emit style ranges for the chunk-local span.
Link policy
- Slack:
[label](url)-><url|label>; bare URLs remain bare. Autolink is disabled during parse to avoid double-linking. - Telegram:
[label](url)-><a href="url">label</a>(HTML parse mode). - Signal:
[label](url)->label (url)unless label matches the URL.
Spoilers
Spoiler markers (||spoiler||) are parsed for Signal and Telegram:
- Signal:
SPOILERstyle ranges. - Telegram:
<tg-spoiler>HTML tags. - Other IR renderers treat spoiler spans as plain text unless they define a spoiler marker.
How to add or update a channel formatter
- Parse once: use the shared
markdownToIR(...)helper with channel-appropriate options (linkify,autolink, heading style, blockquote prefix, spoilers, and table mode). - Render: implement a renderer with
renderMarkdownWithMarkers(...)and a style marker map (or Signal style ranges). - Chunk: call
chunkMarkdownIR(...)before rendering; render each chunk. - Wire adapter: update the channel outbound adapter to use the new chunker and renderer.
- Test: add or update format tests and an outbound delivery test if the channel uses chunking.
Common gotchas
- Slack angle-bracket tokens (
<@U123>,<#C123>,<https://...>) must be preserved; escape raw HTML safely. - Telegram HTML requires escaping text outside tags to avoid broken markup.
- Signal style ranges depend on UTF-16 offsets; do not use code point offsets.
- Preserve trailing newlines for fenced code blocks so closing markers land on their own line.