Source-grounded rewrite of 529 published docs pages with per-unit information-loss verification: 1,713 factual corrections cited to src/**, generated surfaces regenerated, frontmatter titles preserved for i18n, release notes pages untouched. All docs gates green. Closes #100141
6.5 KiB
summary, read_when, title
| summary | read_when | title | |||
|---|---|---|---|---|---|
| Markdown formatting pipeline for outbound channels |
|
Markdown formatting |
OpenClaw converts outbound Markdown into a shared intermediate representation (IR) before rendering channel-specific output. The IR keeps plain text plus style/link spans, so one parse step feeds every channel and chunking never splits formatting mid-span.
Pipeline
- Parse Markdown into IR (
markdownToIR) - plain text plus style spans (bold, italic, strikethrough, code, code block, spoiler, blockquote, heading 1-6) and link spans. Offsets are UTF-16 code units so Signal style ranges align with its API directly. Tables parse only when the channel opts into a table mode. - Chunk the IR (
chunkMarkdownIR/renderMarkdownIRChunksWithinLimit)- splitting happens on IR text before rendering, so inline styles and links are sliced per chunk instead of breaking across a boundary.
- Render per channel (
renderMarkdownWithMarkers) - a style-marker map turns spans into the channel's native markup.
| Channel | Renderer | Notes |
|---|---|---|
| Slack | mrkdwn tokens (*bold*, _italic_, `code`, code fences) |
Links become <url|label>; autolink disabled during parse to avoid double-linking |
| Telegram | HTML tags (<b>, <i>, <s>, <code>, <pre><code>, <a href>, <tg-spoiler>) |
Also supports rich-message tables and headings (<h1>-<h6>) when richMessages is on |
| Signal | plain text + text-style ranges |
Links render as label (url) when the label differs from the URL |
| Discord, WhatsApp, iMessage, Microsoft Teams, and other channels | plain text | No IR-based styling; Markdown table conversion still runs via convertMarkdownTables |
IR example
Input Markdown:
Hello **world** - see [docs](https://docs.openclaw.ai).
IR (schematic):
{
"text": "Hello world - see docs.",
"styles": [{ "start": 6, "end": 11, "style": "bold" }],
"links": [{ "start": 19, "end": 23, "href": "https://docs.openclaw.ai" }]
}
Table handling
markdown.tables controls how a channel converts Markdown tables, per
channel and optionally per account:
| Mode | Behavior |
|---|---|
code |
Render as an aligned ASCII table inside a code block (fallback default) |
bullets |
Convert each row into label: value bullet points |
block |
Keep native tables where the transport supports them; falls back to code otherwise |
off |
Disable table parsing; raw table text passes through unchanged |
Per-channel plugin defaults: Signal, WhatsApp, and Matrix default to
bullets; Mattermost defaults to off; Telegram defaults to block (which
resolves to code unless the account has richMessages enabled). Any
channel without an explicit plugin default falls back to code.
channels:
discord:
markdown:
tables: code
accounts:
work:
markdown:
tables: off
Chunking rules
- Chunk limits come from channel adapters/config and apply to IR text, not rendered output.
- Fenced code blocks are kept as one block with a trailing newline so channels render the closing fence correctly.
- List and blockquote prefixes are part of the IR text, so chunking never splits mid-prefix.
- Inline styles never split across chunks; the renderer reopens an open style at the start of the next chunk.
See Streaming and chunking for chunk-boundary and delivery behavior across channels.
Link policy
- Slack:
[label](url)-><url|label>; bare URLs stay bare. - Telegram:
[label](url)-><a href="url">label</a>(HTML parse mode). - Signal:
[label](url)->label (url)unless the label already matches the URL.
Spoilers
Spoiler markers (||spoiler||) are parsed for Signal (mapped to SPOILER
style ranges) and Telegram (mapped to <tg-spoiler>). Other channels treat
||...|| as plain text.
Adding or updating a channel formatter
- Parse once with
markdownToIR(...), passing channel-appropriate options (autolink,headingStyle,blockquotePrefix,tableMode). - Render with
renderMarkdownWithMarkers(...)and a style-marker map (or custom style-range logic for transports like Signal). - Chunk with
chunkMarkdownIR(...)orrenderMarkdownIRChunksWithinLimit(...)before rendering each chunk. - Wire the adapter to call the new chunker and renderer from the outbound send path.
- Test with format tests plus an outbound delivery test if the channel chunks.
Common gotchas
- Slack angle-bracket tokens (
<@U123>,<#C123>,<https://...>) must survive escaping; raw HTML still needs to be escaped safely. - Telegram HTML requires escaping text outside tags to avoid broken markup.
- Signal style ranges use UTF-16 offsets, not code-point offsets.
- Preserve trailing newlines on fenced code blocks so the closing marker lands on its own line.