slack-markdown-parser 2.3.2__tar.gz → 2.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. {slack_markdown_parser-2.3.2 → slack_markdown_parser-2.4.0}/CHANGELOG.md +10 -0
  2. {slack_markdown_parser-2.3.2/slack_markdown_parser.egg-info → slack_markdown_parser-2.4.0}/PKG-INFO +19 -12
  3. {slack_markdown_parser-2.3.2 → slack_markdown_parser-2.4.0}/README-ja.md +17 -10
  4. {slack_markdown_parser-2.3.2 → slack_markdown_parser-2.4.0}/README.md +17 -10
  5. {slack_markdown_parser-2.3.2 → slack_markdown_parser-2.4.0}/docs/spec-ja.md +14 -6
  6. {slack_markdown_parser-2.3.2 → slack_markdown_parser-2.4.0}/docs/spec.md +14 -6
  7. {slack_markdown_parser-2.3.2 → slack_markdown_parser-2.4.0}/pyproject.toml +2 -2
  8. {slack_markdown_parser-2.3.2 → slack_markdown_parser-2.4.0}/slack_markdown_parser/__init__.py +1 -1
  9. {slack_markdown_parser-2.3.2 → slack_markdown_parser-2.4.0}/slack_markdown_parser/converter.py +511 -17
  10. {slack_markdown_parser-2.3.2 → slack_markdown_parser-2.4.0/slack_markdown_parser.egg-info}/PKG-INFO +19 -12
  11. {slack_markdown_parser-2.3.2 → slack_markdown_parser-2.4.0}/LICENSE +0 -0
  12. {slack_markdown_parser-2.3.2 → slack_markdown_parser-2.4.0}/MANIFEST.in +0 -0
  13. {slack_markdown_parser-2.3.2 → slack_markdown_parser-2.4.0}/setup.cfg +0 -0
  14. {slack_markdown_parser-2.3.2 → slack_markdown_parser-2.4.0}/slack_markdown_parser/py.typed +0 -0
  15. {slack_markdown_parser-2.3.2 → slack_markdown_parser-2.4.0}/slack_markdown_parser.egg-info/SOURCES.txt +0 -0
  16. {slack_markdown_parser-2.3.2 → slack_markdown_parser-2.4.0}/slack_markdown_parser.egg-info/dependency_links.txt +0 -0
  17. {slack_markdown_parser-2.3.2 → slack_markdown_parser-2.4.0}/slack_markdown_parser.egg-info/requires.txt +0 -0
  18. {slack_markdown_parser-2.3.2 → slack_markdown_parser-2.4.0}/slack_markdown_parser.egg-info/top_level.txt +0 -0
@@ -6,6 +6,16 @@ The format is based on Keep a Changelog, and the project follows Semantic Versio
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.4.0] - 2026-05-14
10
+
11
+ ### Added
12
+
13
+ - Added automatic richer Block Kit output for unambiguous standalone Markdown constructs, including image blocks, dividers, fenced code, simple quotes, and simple lists, while leaving Markdown headings in `markdown` blocks so Slack can preserve heading levels.
14
+
15
+ ### Documentation
16
+
17
+ - Documented the Slack mobile `markdown` block limitation where list-item continuation lines are re-prefixed with the list marker, and linked the tracking issue so users can check upstream status instead of expecting a parser-side workaround.
18
+
9
19
  ## [2.3.2] - 2026-04-17
10
20
 
11
21
  ### Fixed
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slack-markdown-parser
3
- Version: 2.3.2
4
- Summary: Convert LLM Markdown into Slack Block Kit markdown/table messages
3
+ Version: 2.4.0
4
+ Summary: Convert LLM Markdown into Slack Block Kit messages
5
5
  Author: darkgaldragon
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/darkgaldragon/slack-markdown-parser
@@ -30,7 +30,8 @@ Dynamic: license-file
30
30
 
31
31
  # slack-markdown-parser
32
32
 
33
- `slack-markdown-parser` is a Python library that converts ordinary Markdown generated by LLMs into Slack Block Kit messages built from `markdown` and `table` blocks.
33
+ `slack-markdown-parser` is a Python library that converts general Markdown generated by LLMs into Slack Block Kit messages built from `markdown`, `table`, and selected richer blocks so the output renders cleanly in Slack.
34
+ Basic headings and text formatting stay in `markdown` blocks, while constructs that benefit from dedicated Slack rendering, such as tables and simple standalone lists, are converted into native Block Kit blocks to produce a ChatGPT-like reading experience.
34
35
 
35
36
  ## Why this library exists
36
37
 
@@ -42,23 +43,25 @@ Many Slack AI bots have traditionally converted model output into Slack-specific
42
43
 
43
44
  ## Design approach
44
45
 
45
- This library uses Slack Block Kit `markdown` blocks for regular Markdown and `table` blocks for tables.
46
+ This library combines Slack Block Kit's newer `markdown` block, `table` blocks, and richer block conversion for Markdown patterns that can be mapped safely.
46
47
 
47
48
  | Problem | Approach |
48
49
  |---|---|
49
- | Extra conversion work | Send ordinary Markdown through Slack `markdown` blocks without rewriting it into `mrkdwn`. |
50
- | Formatting instability | Prefer zero-width spaces (`U+200B`) around formatting markers, and only add visible spaces in a few language-specific cases where Slack still renders Japanese, Chinese, or Korean text incorrectly. |
51
- | No table syntax in `mrkdwn` | Detect Markdown tables and convert them into Slack `table` blocks, including repair of common LLM-generated table inconsistencies. |
50
+ | Extra conversion work | Slack `markdown` blocks can render a useful subset of Markdown other than tables, so LLM output can usually be sent with minimal rewriting instead of being converted into `mrkdwn`. |
51
+ | Formatting instability | Slack's `markdown` parser is not complete, so this library adds zero-width spaces (`U+200B`) around formatting-marker boundaries and uses visible spaces only for a few Japanese, Chinese, and Korean cases where that is still needed. This keeps emphasis stable across English and dense CJK text. |
52
+ | No table syntax in `mrkdwn` | Detect Markdown tables and render them as Slack `table` blocks, while also repairing common LLM-generated table issues such as missing pipes. |
53
+ | Rich LLM output | Convert standalone images, dividers, simple quotes, code fences, and simple lists into native Block Kit blocks where the Markdown structure is unambiguous. |
52
54
 
53
- The goal is natural rendering on Slack, not full CommonMark or HTML fidelity.
55
+ The goal is natural rendering on Slack without exposing raw formatting markers, not full CommonMark or HTML fidelity.
54
56
  If Slack itself does not support a construct in `markdown` blocks, this library prefers safe plain-text rendering or explicit `table` blocks over aggressive rewrites into old `mrkdwn`.
55
57
 
56
58
  ## Features
57
59
 
58
- - Convert ordinary Markdown into Slack `markdown` blocks
60
+ - Convert general Markdown into Slack `markdown` blocks
59
61
  - Convert Markdown tables into Slack `table` blocks
62
+ - Promote safe standalone Markdown constructs into richer Block Kit blocks: `image`, `divider`, and `rich_text`
60
63
  - Repair common LLM table issues such as missing outer pipes, missing separator rows, mismatched column counts, and empty cells
61
- - Split output into multiple Slack messages when needed to satisfy Slack's "one table per message" constraint
64
+ - Split output into multiple Slack messages when needed to satisfy Slack's "one table per message" and per-message block-count constraints
62
65
  - Remove ANSI/control characters and neutralize invalid Slack angle-bracket tokens before block generation
63
66
  - Add zero-width spaces around inline formatting markers to reduce rendering issues outside fenced code blocks, while preserving English-like punctuation-only boundaries that Slack already renders reliably
64
67
  - Add visible spaces for a small set of nested inline-code cases in dense Japanese, Chinese, and Korean text when zero-width spaces alone are not enough
@@ -73,7 +76,7 @@ The library is built around how Slack actually renders `markdown` and `table` bl
73
76
 
74
77
  Slack updated its [`markdown` block docs](https://docs.slack.dev/reference/block-kit/blocks/markdown-block/)
75
78
  and [changelog entry](https://docs.slack.dev/changelog/2026/03/06/block-kit-rich-text)
76
- on March 6, 2026 to describe broader support for headings, dividers, task lists,
79
+ on March 6, 2026 as it started supporting broader Markdown features such as headings, dividers, task lists,
77
80
  native Markdown tables, and syntax-highlighted code blocks. In the Slack Web
78
81
  workspace used for this project's April 8, 2026 validation, raw `markdown`
79
82
  blocks rendered those constructs natively, including distinct heading levels
@@ -90,6 +93,7 @@ Reliable in current Slack rendering:
90
93
  - Bare URLs, `<https://...>` style links, Markdown links, reference-style links, and mailto links
91
94
  - Bullet lists, ordered lists, task lists, and simple blockquotes
92
95
  - Explicit Slack `table` blocks generated from Markdown tables
96
+ - Explicit richer blocks generated from unambiguous Markdown, such as standalone images, code fences, simple lists, and simple quotes
93
97
  - In environments where Slack has enabled the newer renderer, raw Markdown headings, dividers, and tables inside `markdown` blocks
94
98
 
95
99
  Known Slack-side limitations:
@@ -100,19 +104,22 @@ Known Slack-side limitations:
100
104
  - Raw Markdown tables inside `markdown` blocks now render in some newer Slack environments, but explicit Slack `table` blocks remain the reliable option across workspaces and delivery paths
101
105
  - Markdown image syntax does not become an embedded image in `markdown` blocks
102
106
  - Math, raw HTML, HTML comments, `<details>`, admonition syntax, and Mermaid are rendered as plain text or code, not as rich features
107
+ - Some newly documented Block Kit block types can be unavailable on a given `chat.postMessage` path; this library emits only a conservative subset of richer block types that has been validated through real Slack posting checks.
108
+ - The Slack **mobile** app re-prefixes the list marker onto each continuation line of a list item inside `markdown` blocks (e.g. `1. Heading` followed by an indented paragraph shows as `1. Heading` / `1.continuation` on mobile, while Slack desktop and Slack Web render the same payload correctly). This is a Slack client-side rendering behavior, not a parser bug. Tracking: [issue #45](https://github.com/darkgaldragon/slack-markdown-parser/issues/45).
103
109
 
104
110
  What this library compensates for:
105
111
 
106
112
  - Normalizes underscore emphasis (`_..._`, `__...__`) into Slack-friendly asterisk emphasis
107
113
  - Wraps bare URLs into Slack-friendly `<https://...>` link form before sending `markdown` blocks
108
114
  - Repairs malformed LLM-generated tables before converting them into Slack `table` blocks
115
+ - Converts unambiguous standalone Markdown constructs into native Block Kit blocks when that is safer than relying on raw `markdown` rendering
109
116
  - Keeps table-like rows inside fenced code blocks out of table normalization
110
117
  - Optionally turns internal blank lines into placeholder lines that keep paragraphs visibly separated in Slack `markdown` blocks
111
118
  - Neutralizes invalid Slack angle-bracket tokens such as raw HTML-like tags
112
119
 
113
120
  ## Requirements
114
121
 
115
- - Your Slack integration must support Block Kit payloads with `markdown` and `table` blocks.
122
+ - Your Slack integration must support Block Kit payloads with `markdown`, `table`, and the richer blocks emitted by this library (`rich_text`, `image`, and `divider`).
116
123
  - This library does not help when your delivery path only accepts plain `text` or `mrkdwn` strings.
117
124
 
118
125
  ## Installation
@@ -1,6 +1,7 @@
1
1
  # slack-markdown-parser
2
2
 
3
- LLM が生成するふつうの Markdown を、Slack Block Kit(`markdown` + `table` ブロック)に変換する Python ライブラリです。
3
+ LLM が生成する一般的な Markdown を、Slackで綺麗にレンダリングできるように、Slack Block Kit(`markdown` / `table` / 一部のリッチブロック)に変換する Python ライブラリです。
4
+ 基本的な見出しや文字修飾は `markdown` ブロックで対応し、さらにテーブルや単純な単独リストなど、Slack ネイティブの表示が有効な記法は専用の Block Kit ブロックに変換することで、ChatGPT のような読み心地に近づけています。
4
5
 
5
6
  ## 背景
6
7
 
@@ -12,23 +13,25 @@ Slack で AI BOT を動かすとき、従来は Slack 独自の `mrkdwn` 形式
12
13
 
13
14
  ## 設計方針
14
15
 
15
- Slack Block Kit `markdown` ブロックと `table` ブロックを使って、上の課題に対応します。
16
+ Slack Block Kit の比較的あたらしい `markdown` ブロック、`table` ブロック、そして安全に判定できる Markdown だけを対象にしたリッチブロック変換で、上の課題に対応できるようにしました。
16
17
 
17
18
  | 課題 | 解決手段 |
18
19
  |---|---|
19
- | 変換の手間 | `markdown` ブロックがふつうの Markdown を受け取れるので、LLM 出力を `mrkdwn` に書き換えずに使う |
20
- | 装飾の崩れ | 基本はゼロ幅スペース(`U+200B`)で装飾記号の境界を補い、それでも崩れる一部の日本語・中国語・韓国語のケースだけ可視スペースを使う |
21
- | テーブル非対応 | Markdown テーブルを見つけて `table` ブロックに変換し、LLM が作りがちな表の崩れも自動で補う |
20
+ | 変換の手間 | `markdown` ブロックがテーブル以外のある程度の Markdown をレンダリングできるので、LLM 出力を `mrkdwn` に書き換えずになるべくそのまま使う |
21
+ | 装飾の崩れ | `markdown` ブロックのMarkdownパーサーが完全ではないため、文字修飾が反映されない問題に対して、ゼロ幅スペース(`U+200B`)で装飾記号の境界を補い、それでも崩れる一部の日本語・中国語・韓国語のケースだけ可視スペースを使うことで、英語だけでなく、日本語、中国語、韓国語のレンダリングが崩れやすい言語についても、安定して装飾記号が反映される |
22
+ | テーブル非対応 | Markdown テーブルを検知して `table` ブロックに変換してレンダリングすると同時に、LLM が起こしがちな表の崩れ(パイプ不足等)も自動で補う |
23
+ | リッチなLLM出力 | 単独画像、水平線、単純な引用、コードフェンス、単純なリストを、意味が明確な範囲で Slack ネイティブの Block Kit ブロックへ変換する |
22
24
 
23
- このライブラリの目標は、CommonMark や HTML を完全再現することではなく、Slack 上で自然に読める表示を作ることです。
25
+ このライブラリの目標は、CommonMark や HTML を完全再現することではなく、Slack 上で修飾記号を露出させず、自然に読める表示を作ることです。
24
26
  Slack の `markdown` ブロック自体が対応していない構文は、古い `mrkdwn` へ無理に書き換えるより、安全なプレーンテキスト表示や `table` ブロック化を優先します。
25
27
 
26
28
  ## 主な機能
27
29
 
28
- - ふつうの Markdown テキストを `markdown` ブロックに変換
30
+ - 一般的な Markdown テキストを `markdown` ブロックに変換
29
31
  - Markdown テーブルを `table` ブロックに変換
32
+ - 安全に判定できる単独 Markdown 構文を `image` / `divider` / `rich_text` ブロックに変換
30
33
  - LLM が生成する表で起こりやすい崩れ(外枠パイプ不足、区切り行不足、列数不一致、空セル)を補正
31
- - テーブルごとにメッセージを自動分割し、Slack の「1メッセージ1テーブル」制約に対応
34
+ - 必要に応じてメッセージを自動分割し、Slack の「1メッセージ1テーブル」制約とメッセージあたりのブロック数制限に対応
32
35
  - ANSI escape / 制御文字を除去し、不正な Slack 角括弧トークンを無害化
33
36
  - フェンスドコードブロック外では、装飾記号の前後にゼロ幅スペースを入れて表示崩れを減らす
34
37
  - 日本語・中国語・韓国語の詰まった文で、インラインコードを含む装飾が崩れる一部のケースでは可視スペースを補って安定化
@@ -41,7 +44,7 @@ Slack の `markdown` ブロック自体が対応していない構文は、古
41
44
 
42
45
  本ライブラリは、Slack の `markdown` / `table` ブロックが実際にどう見えるかを前提に設計しています。
43
46
 
44
- Slack は 2026-03-06 に `markdown` ブロックの公式ドキュメントを更新し、見出し、水平線、タスクリスト、表、言語付きコードブロックなど、これまでより多くの Markdown 記法を案内し始めました。ただし、これらの機能は環境ごとに順番に有効化されるとも書かれています。つまり、ワークスペースやクライアントによって見え方が違う可能性があります。
47
+ Slack は 2026-03-06 に `markdown` ブロックの公式ドキュメントを更新し、見出し、水平線、タスクリスト、表、言語付きコードブロックなど、これまでより多くの Markdown 記法をサポートし始めました。ただし、これらの機能は環境ごとに順番に有効化されるとも書かれています。つまり、ワークスペースやクライアントによって見え方が違う可能性があります。
45
48
 
46
49
  現在の Slack で比較的安定して表示されるもの:
47
50
 
@@ -49,6 +52,7 @@ Slack は 2026-03-06 に `markdown` ブロックの公式ドキュメントを
49
52
  - bare URL, `<https://...>` 形式リンク, Markdown リンク, 参照リンク, mailto リンク
50
53
  - 箇条書き, 番号付きリスト, タスクリスト, 単純な引用
51
54
  - Markdown テーブルを変換した明示的な Slack `table` ブロック
55
+ - 単独画像、コードフェンス、単純なリスト、単純な引用から生成したリッチブロック
52
56
  - Slack 側で新しい Markdown 表示が有効な環境では、`markdown` ブロック内の見出し・水平線・表
53
57
 
54
58
  Slack 側の制約として残るもの:
@@ -59,19 +63,22 @@ Slack 側の制約として残るもの:
59
63
  - `markdown` ブロック内の生 Markdown テーブルは一部環境では表示されるが、安定性では明示的な Slack `table` ブロックの方が上
60
64
  - Markdown 画像記法は `markdown` ブロック内では埋め込み画像にならない
61
65
  - 数式, 生 HTML, HTML comment, `<details>`, admonition 記法, Mermaid は特別な表示にならず、テキストまたはコードとして出る
66
+ - 新しく公式記載された Block Kit ブロックでも、利用する `chat.postMessage` 経路では未対応の場合があるため、このライブラリは実際の Slack 送信で確認した保守的なリッチブロックだけを出力する
67
+ - Slack **モバイル**アプリは、`markdown` ブロック内のリスト項目に属する継続行(CommonMark で同じリスト項目に紐づくインデントされた段落)の先頭に、リストマーカーを再付加してしまう。例: `1. 見出し` の次にインデントされた継続段落を置くと、モバイルでは `1. 見出し` と `1.継続行...` のように番号が重複して表示される。Slack デスクトップ/Web は同じペイロードを正しく描画する。これはパーサー側の不具合ではなく Slack クライアントのレンダリング挙動。追跡: [issue #45](https://github.com/darkgaldragon/slack-markdown-parser/issues/45)。
62
68
 
63
69
  このライブラリが吸収するもの:
64
70
 
65
71
  - underscore 装飾 (`_..._`, `__...__`) を Slack 互換の asterisk 装飾へ正規化
66
72
  - bare URL を Slack の `<https://...>` 形式にそろえる
67
73
  - 崩れた Markdown テーブルを補って Slack `table` ブロックへ変換
74
+ - 意味が明確な単独 Markdown 構文を、raw `markdown` 表示に頼らず Slack ネイティブの Block Kit ブロックへ変換
68
75
  - フェンスドコード内の table 風行をテーブル処理から除外
69
76
  - 必要に応じて、内部空行を補助用の行に置き換えて段落の区切りを見えやすくする
70
77
  - 生 HTML 風タグなど、Slack の特殊記法としては無効な `<...>` 形式を無害化
71
78
 
72
79
  ## 利用前提
73
80
 
74
- - Slack Block Kit の `blocks` で `markdown` / `table` ブロックを送信できる実装が必要です。
81
+ - Slack Block Kit の `blocks` で `markdown` / `table` と、このライブラリが出力するリッチブロック(`rich_text`, `image`, `divider`)を送信できる実装が必要です。
75
82
  - `text` / `mrkdwn` のみ送信できる経路では利用できません。
76
83
 
77
84
  ## インストール
@@ -1,6 +1,7 @@
1
1
  # slack-markdown-parser
2
2
 
3
- `slack-markdown-parser` is a Python library that converts ordinary Markdown generated by LLMs into Slack Block Kit messages built from `markdown` and `table` blocks.
3
+ `slack-markdown-parser` is a Python library that converts general Markdown generated by LLMs into Slack Block Kit messages built from `markdown`, `table`, and selected richer blocks so the output renders cleanly in Slack.
4
+ Basic headings and text formatting stay in `markdown` blocks, while constructs that benefit from dedicated Slack rendering, such as tables and simple standalone lists, are converted into native Block Kit blocks to produce a ChatGPT-like reading experience.
4
5
 
5
6
  ## Why this library exists
6
7
 
@@ -12,23 +13,25 @@ Many Slack AI bots have traditionally converted model output into Slack-specific
12
13
 
13
14
  ## Design approach
14
15
 
15
- This library uses Slack Block Kit `markdown` blocks for regular Markdown and `table` blocks for tables.
16
+ This library combines Slack Block Kit's newer `markdown` block, `table` blocks, and richer block conversion for Markdown patterns that can be mapped safely.
16
17
 
17
18
  | Problem | Approach |
18
19
  |---|---|
19
- | Extra conversion work | Send ordinary Markdown through Slack `markdown` blocks without rewriting it into `mrkdwn`. |
20
- | Formatting instability | Prefer zero-width spaces (`U+200B`) around formatting markers, and only add visible spaces in a few language-specific cases where Slack still renders Japanese, Chinese, or Korean text incorrectly. |
21
- | No table syntax in `mrkdwn` | Detect Markdown tables and convert them into Slack `table` blocks, including repair of common LLM-generated table inconsistencies. |
20
+ | Extra conversion work | Slack `markdown` blocks can render a useful subset of Markdown other than tables, so LLM output can usually be sent with minimal rewriting instead of being converted into `mrkdwn`. |
21
+ | Formatting instability | Slack's `markdown` parser is not complete, so this library adds zero-width spaces (`U+200B`) around formatting-marker boundaries and uses visible spaces only for a few Japanese, Chinese, and Korean cases where that is still needed. This keeps emphasis stable across English and dense CJK text. |
22
+ | No table syntax in `mrkdwn` | Detect Markdown tables and render them as Slack `table` blocks, while also repairing common LLM-generated table issues such as missing pipes. |
23
+ | Rich LLM output | Convert standalone images, dividers, simple quotes, code fences, and simple lists into native Block Kit blocks where the Markdown structure is unambiguous. |
22
24
 
23
- The goal is natural rendering on Slack, not full CommonMark or HTML fidelity.
25
+ The goal is natural rendering on Slack without exposing raw formatting markers, not full CommonMark or HTML fidelity.
24
26
  If Slack itself does not support a construct in `markdown` blocks, this library prefers safe plain-text rendering or explicit `table` blocks over aggressive rewrites into old `mrkdwn`.
25
27
 
26
28
  ## Features
27
29
 
28
- - Convert ordinary Markdown into Slack `markdown` blocks
30
+ - Convert general Markdown into Slack `markdown` blocks
29
31
  - Convert Markdown tables into Slack `table` blocks
32
+ - Promote safe standalone Markdown constructs into richer Block Kit blocks: `image`, `divider`, and `rich_text`
30
33
  - Repair common LLM table issues such as missing outer pipes, missing separator rows, mismatched column counts, and empty cells
31
- - Split output into multiple Slack messages when needed to satisfy Slack's "one table per message" constraint
34
+ - Split output into multiple Slack messages when needed to satisfy Slack's "one table per message" and per-message block-count constraints
32
35
  - Remove ANSI/control characters and neutralize invalid Slack angle-bracket tokens before block generation
33
36
  - Add zero-width spaces around inline formatting markers to reduce rendering issues outside fenced code blocks, while preserving English-like punctuation-only boundaries that Slack already renders reliably
34
37
  - Add visible spaces for a small set of nested inline-code cases in dense Japanese, Chinese, and Korean text when zero-width spaces alone are not enough
@@ -43,7 +46,7 @@ The library is built around how Slack actually renders `markdown` and `table` bl
43
46
 
44
47
  Slack updated its [`markdown` block docs](https://docs.slack.dev/reference/block-kit/blocks/markdown-block/)
45
48
  and [changelog entry](https://docs.slack.dev/changelog/2026/03/06/block-kit-rich-text)
46
- on March 6, 2026 to describe broader support for headings, dividers, task lists,
49
+ on March 6, 2026 as it started supporting broader Markdown features such as headings, dividers, task lists,
47
50
  native Markdown tables, and syntax-highlighted code blocks. In the Slack Web
48
51
  workspace used for this project's April 8, 2026 validation, raw `markdown`
49
52
  blocks rendered those constructs natively, including distinct heading levels
@@ -60,6 +63,7 @@ Reliable in current Slack rendering:
60
63
  - Bare URLs, `<https://...>` style links, Markdown links, reference-style links, and mailto links
61
64
  - Bullet lists, ordered lists, task lists, and simple blockquotes
62
65
  - Explicit Slack `table` blocks generated from Markdown tables
66
+ - Explicit richer blocks generated from unambiguous Markdown, such as standalone images, code fences, simple lists, and simple quotes
63
67
  - In environments where Slack has enabled the newer renderer, raw Markdown headings, dividers, and tables inside `markdown` blocks
64
68
 
65
69
  Known Slack-side limitations:
@@ -70,19 +74,22 @@ Known Slack-side limitations:
70
74
  - Raw Markdown tables inside `markdown` blocks now render in some newer Slack environments, but explicit Slack `table` blocks remain the reliable option across workspaces and delivery paths
71
75
  - Markdown image syntax does not become an embedded image in `markdown` blocks
72
76
  - Math, raw HTML, HTML comments, `<details>`, admonition syntax, and Mermaid are rendered as plain text or code, not as rich features
77
+ - Some newly documented Block Kit block types can be unavailable on a given `chat.postMessage` path; this library emits only a conservative subset of richer block types that has been validated through real Slack posting checks.
78
+ - The Slack **mobile** app re-prefixes the list marker onto each continuation line of a list item inside `markdown` blocks (e.g. `1. Heading` followed by an indented paragraph shows as `1. Heading` / `1.continuation` on mobile, while Slack desktop and Slack Web render the same payload correctly). This is a Slack client-side rendering behavior, not a parser bug. Tracking: [issue #45](https://github.com/darkgaldragon/slack-markdown-parser/issues/45).
73
79
 
74
80
  What this library compensates for:
75
81
 
76
82
  - Normalizes underscore emphasis (`_..._`, `__...__`) into Slack-friendly asterisk emphasis
77
83
  - Wraps bare URLs into Slack-friendly `<https://...>` link form before sending `markdown` blocks
78
84
  - Repairs malformed LLM-generated tables before converting them into Slack `table` blocks
85
+ - Converts unambiguous standalone Markdown constructs into native Block Kit blocks when that is safer than relying on raw `markdown` rendering
79
86
  - Keeps table-like rows inside fenced code blocks out of table normalization
80
87
  - Optionally turns internal blank lines into placeholder lines that keep paragraphs visibly separated in Slack `markdown` blocks
81
88
  - Neutralizes invalid Slack angle-bracket tokens such as raw HTML-like tags
82
89
 
83
90
  ## Requirements
84
91
 
85
- - Your Slack integration must support Block Kit payloads with `markdown` and `table` blocks.
92
+ - Your Slack integration must support Block Kit payloads with `markdown`, `table`, and the richer blocks emitted by this library (`rich_text`, `image`, and `divider`).
86
93
  - This library does not help when your delivery path only accepts plain `text` or `mrkdwn` strings.
87
94
 
88
95
  ## Installation
@@ -9,13 +9,13 @@
9
9
 
10
10
  ## 出力
11
11
 
12
- - Slack Block Kit ブロック(`markdown` / `table`)
13
- - 複数テーブル入力時は「1メッセージ1テーブル」を満たすメッセージ群
12
+ - Slack Block Kit ブロック(`markdown`, `table`, `rich_text`, `image`, `divider`)
13
+ - 複数テーブルや多数の昇格ブロックがある入力時は、「1メッセージ1テーブル」と Slack のメッセージあたりブロック数制限を満たすメッセージ群
14
14
 
15
15
  ## 設計目標
16
16
 
17
17
  このパーサーは、CommonMark 全体や HTML / リッチドキュメントの完全再現を目標にはしません。
18
- 目的は、Slack Block Kit の `markdown` / `table` ブロックで送ったときに自然に読めるメッセージを作ることです。
18
+ 目的は、Slack Block Kit で送ったときに自然に読めるメッセージを作ることです。
19
19
 
20
20
  Markdown としての厳密さより Slack 上での読みやすさが重要になる場合は、Slack 上で安定して読める表現を優先します。
21
21
 
@@ -31,10 +31,10 @@ Markdown としての厳密さより Slack 上での読みやすさが重要に
31
31
  6. テーブル領域と非テーブル領域に分割する
32
32
  7. 領域ごとにブロックを作る
33
33
  - テーブル領域: セル内装飾を解析して `table` ブロックを生成。変換に失敗した場合は `markdown` ブロックに戻す
34
- - 非テーブル領域: 必要に応じてゼロ幅スペースを加えた上で `markdown` ブロックを生成する
35
- - `preserve_visual_blank_lines=True` の場合は、非テーブル領域の内部空行を「ノーブレークスペースだけを含む行」に置き換えてから `markdown` ブロックを作る
34
+ - 非テーブル領域: 安全に判定できる単独 Markdown 構文を先にリッチブロックへ変換し、残りのテキストは必要に応じてゼロ幅スペースを加えた上で `markdown` ブロックを生成する
35
+ - `preserve_visual_blank_lines=True` の場合は、残った `markdown` ブロックの内部空行を「ノーブレークスペースだけを含む行」に置き換えてから `markdown` ブロックを作る
36
36
 
37
- `convert_markdown_to_slack_messages` は上記の結果を「1メッセージ1テーブル」制約に沿って分割します。
37
+ `convert_markdown_to_slack_messages` は上記の結果を「1メッセージ1テーブル」制約と Slack のメッセージあたりブロック数制限に沿って分割します。
38
38
  `convert_markdown_to_slack_payloads` は、同じ分割結果に `chat.postMessage.text` 用のプレビュー文字列を付けた送信データを返します。
39
39
 
40
40
  ## 実測ベースの Slack の挙動
@@ -49,6 +49,7 @@ Markdown としての厳密さより Slack 上での読みやすさが重要に
49
49
  - bare URL, `<https://...>` 形式リンク, Markdown リンク, 参照リンク, mailto リンク
50
50
  - 箇条書き, 番号付きリスト, タスクリスト, 単純な1段引用
51
51
  - Slack `table` ブロック
52
+ - 意味が明確な単独 Markdown 構文から生成した Slack ネイティブのリッチブロック
52
53
 
53
54
  Slack は 2026-03-06 に `markdown` ブロックの公式ドキュメントを更新し、これまでより多くの Markdown 記法を案内し始めました。本プロジェクトの 2026-04-08 の Slack Web 実測では、raw の `markdown` ブロックで次を確認しています。
54
55
 
@@ -73,6 +74,13 @@ Slack は 2026-03-06 に `markdown` ブロックの公式ドキュメントを
73
74
  - `_..._` / `__...__` を Slack 互換の `*...*` / `**...**` に正規化する
74
75
  - bare URL を Slack で安定しやすい `<https://...>` 形式にそろえる
75
76
  - 崩れた Markdown テーブルを補って `table` ブロックへ変換する
77
+ - 意味が明確な単独 Markdown 構文を Slack ネイティブのブロックへ変換する
78
+ - 単独行の画像構文 `![alt](https://...)` → `image`
79
+ - 水平線 → `divider`
80
+ - フェンスドコード → `rich_text_preformatted`
81
+ - 単純な1段引用 → `rich_text_quote`
82
+ - 単純な箇条書き / 番号付きリスト → `rich_text_list`
83
+ - リストは、テキスト領域の先頭または空行の直後から始まり、連続する非空行がすべてリスト項目で、1〜3スペースの曖昧なネストインデントや Markdown バックスラッシュエスケープに依存せず、直後にインデント付きの継続段落がない場合だけ昇格する
76
84
  - フェンスドコード内の table 風行をテーブル解析対象から除外する
77
85
  - 内部空行を、必要に応じて段落区切りを見えやすくする補助行へ置き換える
78
86
  - `<foo>` や生 HTML 風タグのような、Slack の特殊記法としては無効な `<...>` 形式を無害化する
@@ -9,13 +9,13 @@ This document describes how `slack-markdown-parser` converts Markdown into Slack
9
9
 
10
10
  ## Output
11
11
 
12
- - Slack Block Kit blocks (`markdown` / `table`)
13
- - When the input contains multiple tables, a list of messages that satisfies the "one table per message" rule
12
+ - Slack Block Kit blocks (`markdown`, `table`, `rich_text`, `image`, and `divider`)
13
+ - When the input contains multiple tables or many promoted blocks, a list of messages that satisfies the "one table per message" rule and Slack's per-message block-count limit
14
14
 
15
15
  ## Design target
16
16
 
17
17
  This parser does not try to reproduce full CommonMark, HTML, or rich-document rendering.
18
- Its goal is to produce Slack messages that read naturally when delivered through Slack Block Kit `markdown` and `table` blocks.
18
+ Its goal is to produce Slack messages that read naturally when delivered through Slack Block Kit blocks.
19
19
 
20
20
  When exact Markdown fidelity conflicts with Slack readability, readable Slack output takes priority.
21
21
 
@@ -31,10 +31,10 @@ When exact Markdown fidelity conflicts with Slack readability, readable Slack ou
31
31
  6. Split the text into table regions and non-table regions
32
32
  7. Build blocks for each region:
33
33
  - Table regions: parse inline cell styling and generate a `table` block. If conversion fails, such as when there are fewer than two candidate lines or the parse result is empty, fall back to a `markdown` block.
34
- - Non-table regions: add zero-width spaces where needed and generate a `markdown` block.
35
- - If `preserve_visual_blank_lines=True`, replace internal blank lines in non-table regions with lines that contain only a non-breaking space before emitting the `markdown` block.
34
+ - Non-table regions: first promote safe standalone Markdown constructs into richer Block Kit blocks, then add zero-width spaces where needed and generate `markdown` blocks for the remaining text.
35
+ - If `preserve_visual_blank_lines=True`, replace internal blank lines in remaining `markdown` blocks with lines that contain only a non-breaking space before emitting the `markdown` block.
36
36
 
37
- `convert_markdown_to_slack_messages` then splits the resulting block list to satisfy the "one table per message" rule.
37
+ `convert_markdown_to_slack_messages` then splits the resulting block list to satisfy the "one table per message" rule and Slack's per-message block-count limit.
38
38
  `convert_markdown_to_slack_payloads` returns the same split blocks plus preview `text` values ready for `chat.postMessage`.
39
39
 
40
40
  ## How Slack behaved in testing
@@ -49,6 +49,7 @@ The behaviors below are based on practical validation against real Slack clients
49
49
  - Bare URLs, `<https://...>` style links, Markdown links, reference-style links, and mailto links
50
50
  - Bullet lists, ordered lists, task lists, and simple one-level blockquotes
51
51
  - Slack `table` blocks
52
+ - Native richer blocks generated from unambiguous standalone Markdown constructs
52
53
 
53
54
  Slack published updated `markdown` block documentation and a changelog entry on March 6, 2026. In the Slack Web workspace validated for this project on April 8, 2026, raw `markdown` blocks rendered:
54
55
 
@@ -73,6 +74,13 @@ Slack still controls when those newer features appear and how they look, so trea
73
74
  - `_..._` and `__...__` are normalized into Slack-friendly `*...*` and `**...**`
74
75
  - Bare URLs are wrapped into Slack-friendly `<https://...>` form before `markdown` block delivery
75
76
  - Malformed Markdown tables are repaired before `table` block generation
77
+ - Unambiguous standalone Markdown constructs are promoted into native Slack blocks:
78
+ - standalone image syntax `![alt](https://...)` to `image`
79
+ - thematic-break lines to `divider`
80
+ - fenced code blocks to `rich_text_preformatted`
81
+ - simple one-level quotes to `rich_text_quote`
82
+ - simple bullet and ordered lists to `rich_text_list`
83
+ - Lists are promoted only when the list starts at the beginning of the text region or after a blank line, each non-blank line in the run is a list item, the list does not use ambiguous 1-3-space nested indentation, the item text does not rely on Markdown backslash escapes, and the run is not followed by an indented continuation paragraph.
76
84
  - Table-like rows inside fenced code blocks are kept out of table parsing
77
85
  - Internal blank lines can optionally be rewritten into placeholder lines so Slack keeps visible paragraph separation
78
86
  - Unsupported Slack angle-bracket tokens such as `<foo>` or raw HTML-like tags are neutralized
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "slack-markdown-parser"
7
- version = "2.3.2"
8
- description = "Convert LLM Markdown into Slack Block Kit markdown/table messages"
7
+ version = "2.4.0"
8
+ description = "Convert LLM Markdown into Slack Block Kit messages"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  """slack-markdown-parser public package API."""
2
2
 
3
- __version__ = "2.3.2"
3
+ __version__ = "2.4.0"
4
4
  __license__ = "MIT"
5
5
 
6
6
  from .converter import (
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  import html
10
10
  import re
11
11
  from typing import Any
12
+ from urllib.parse import urlparse
12
13
 
13
14
  ZWSP = "\u200b"
14
15
  NBSP = "\u00a0"
@@ -24,6 +25,11 @@ CONTROL_CHAR_PATTERN = re.compile(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]")
24
25
  SLACK_ANGLE_TOKEN_PATTERN = re.compile(r"<[^>\n]+>")
25
26
  BARE_URL_PATTERN = re.compile(r"https?://[^\s<]+", re.IGNORECASE)
26
27
  FENCE_OPEN_PATTERN = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})([^\n]*)$")
28
+ STANDALONE_IMAGE_PATTERN = re.compile(
29
+ r"^[ \t]*!\[(?P<alt>[^\]\n]*)\]\((?P<url>https?://[^\s)]+)"
30
+ r"(?:[ \t]+(?P<title>\"[^\"\n]*\"|'[^'\n]*'))?[ \t]*\)[ \t]*$",
31
+ re.IGNORECASE,
32
+ )
27
33
  MARKDOWN_LINK_PATTERN = re.compile(r"\[[^\]\n]+\]\([^\)\n]+\)")
28
34
  INLINE_CODE_SPAN_PATTERN = re.compile(r"(?<!`)`[^`\n]+`(?!`)", flags=re.DOTALL)
29
35
  EMPHASIS_PATTERNS = (
@@ -48,6 +54,8 @@ THEMATIC_BREAK_PATTERN = re.compile(
48
54
  LIST_ITEM_PATTERN = re.compile(
49
55
  r"^(?P<indent>[ \t]*)(?P<marker>\d+[.)]|[-+*])(?P<spacing>[ \t]+|$)"
50
56
  )
57
+ TASK_LIST_MARKER_PATTERN = re.compile(r"^\[[ xX]\](?:[ \t]+|$)")
58
+ MARKDOWN_BACKSLASH_ESCAPE_PATTERN = re.compile(r"\\[\\`*_{}\[\]()#+\-.!>|]")
51
59
  DOUBLE_UNDERSCORE_EMPHASIS_PATTERN = re.compile(
52
60
  r"(?<![\\0-9A-Za-z_])__(?=\S)(.+?\S)__(?![0-9A-Za-z_])"
53
61
  )
@@ -78,6 +86,7 @@ ALLOWED_SLACK_ANGLE_TOKEN_PATTERNS = (
78
86
  re.compile(r"^<!subteam\^[A-Z0-9]+(?:\|[^>\n]+)?>$"),
79
87
  re.compile(r"^<!date\^[^>\n]+>$"),
80
88
  )
89
+ SLACK_MAX_BLOCKS_PER_MESSAGE = 50
81
90
 
82
91
 
83
92
  def decode_html_entities(text: str) -> str:
@@ -210,6 +219,31 @@ def _is_ordered_list_marker(marker: str) -> bool:
210
219
  return bool(marker) and marker[0].isdigit()
211
220
 
212
221
 
222
+ def _ordered_list_marker_number(marker: str) -> int:
223
+ if not _is_ordered_list_marker(marker):
224
+ return 1
225
+ try:
226
+ return int(marker[:-1])
227
+ except ValueError:
228
+ return 1
229
+
230
+
231
+ def _list_indent_level(indent: str) -> int:
232
+ width = _indent_width(indent or "")
233
+ if width <= 3:
234
+ return 0
235
+ return min(8, ((width - 4) // 4) + 1)
236
+
237
+
238
+ def _is_ambiguous_rich_list_indent(indent: str) -> bool:
239
+ width = _indent_width(indent or "")
240
+ return 0 < width <= 3
241
+
242
+
243
+ def _has_markdown_backslash_escape(text: str) -> bool:
244
+ return bool(MARKDOWN_BACKSLASH_ESCAPE_PATTERN.search(text or ""))
245
+
246
+
213
247
  def _is_thematic_break_line(line: str) -> bool:
214
248
  return bool(THEMATIC_BREAK_PATTERN.match(line))
215
249
 
@@ -1146,12 +1180,15 @@ def looks_like_markdown_table(text: str) -> bool:
1146
1180
  return table_like_lines >= 2
1147
1181
 
1148
1182
 
1149
- def _create_table_cell(text: str) -> dict[str, Any]:
1150
- """Build Slack rich_text cell from markdown fragment."""
1183
+ def _create_rich_text_inline_elements(
1184
+ text: str, *, empty_text: str = ""
1185
+ ) -> list[dict[str, Any]]:
1186
+ """Build Slack rich text inline elements from a small markdown fragment."""
1151
1187
  clean_text = strip_zero_width_spaces(text or "")
1152
1188
  clean_text = clean_text.replace("\\|", "|")
1153
1189
  if not clean_text.strip():
1154
- clean_text = "-"
1190
+ clean_text = empty_text
1191
+
1155
1192
  elements: list[dict[str, Any]] = []
1156
1193
  last_index = 0
1157
1194
 
@@ -1207,9 +1244,21 @@ def _create_table_cell(text: str) -> dict[str, Any]:
1207
1244
  if not elements:
1208
1245
  elements.append({"type": "text", "text": clean_text})
1209
1246
 
1247
+ return elements
1248
+
1249
+
1250
+ def _create_rich_text_section(text: str, *, empty_text: str = "") -> dict[str, Any]:
1251
+ return {
1252
+ "type": "rich_text_section",
1253
+ "elements": _create_rich_text_inline_elements(text, empty_text=empty_text),
1254
+ }
1255
+
1256
+
1257
+ def _create_table_cell(text: str) -> dict[str, Any]:
1258
+ """Build Slack rich_text cell from markdown fragment."""
1210
1259
  return {
1211
1260
  "type": "rich_text",
1212
- "elements": [{"type": "rich_text_section", "elements": elements}],
1261
+ "elements": [_create_rich_text_section(text, empty_text="-")],
1213
1262
  }
1214
1263
 
1215
1264
 
@@ -1274,6 +1323,419 @@ def markdown_table_to_slack_table(table_markdown: str) -> dict[str, Any] | None:
1274
1323
  markdown_table_to_table_block = markdown_table_to_slack_table
1275
1324
 
1276
1325
 
1326
+ def _truncate_plain_text(text: str, max_length: int) -> str:
1327
+ if len(text) <= max_length:
1328
+ return text
1329
+ return text[: max_length - 1].rstrip() + "…"
1330
+
1331
+
1332
+ def _is_http_url(url: str) -> bool:
1333
+ try:
1334
+ parsed = urlparse(url)
1335
+ except ValueError:
1336
+ return False
1337
+ return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
1338
+
1339
+
1340
+ def _rich_text_inline_elements_to_plain_text(elements: list[dict[str, Any]]) -> str:
1341
+ texts: list[str] = []
1342
+ for element in elements or []:
1343
+ if not isinstance(element, dict):
1344
+ continue
1345
+ element_type = element.get("type")
1346
+ if element_type == "link":
1347
+ texts.append(str(element.get("text") or element.get("url", "")))
1348
+ else:
1349
+ texts.append(str(element.get("text", "")))
1350
+ return "".join(texts)
1351
+
1352
+
1353
+ def _rich_text_object_to_plain_text(element: dict[str, Any]) -> str:
1354
+ element_type = element.get("type")
1355
+ if element_type == "rich_text_section":
1356
+ return _rich_text_inline_elements_to_plain_text(element.get("elements", []))
1357
+ if element_type in {"rich_text_preformatted", "rich_text_quote"}:
1358
+ return _rich_text_inline_elements_to_plain_text(element.get("elements", []))
1359
+ if element_type == "rich_text_list":
1360
+ style = element.get("style")
1361
+ indent = max(0, int(element.get("indent") or 0))
1362
+ offset = max(0, int(element.get("offset") or 0))
1363
+ prefix = " " * indent
1364
+ lines: list[str] = []
1365
+ for idx, child in enumerate(element.get("elements", []), start=1):
1366
+ if not isinstance(child, dict):
1367
+ continue
1368
+ child_text = _rich_text_object_to_plain_text(child).strip()
1369
+ if not child_text:
1370
+ continue
1371
+ marker = f"{offset + idx}." if style == "ordered" else "-"
1372
+ lines.append(f"{prefix}{marker} {child_text}")
1373
+ return "\n".join(lines)
1374
+ return ""
1375
+
1376
+
1377
+ def _rich_text_block_to_plain_text(block: dict[str, Any]) -> str:
1378
+ annotated_plain_text = getattr(block, "_plain_text", None)
1379
+ if annotated_plain_text:
1380
+ return str(annotated_plain_text).strip()
1381
+
1382
+ parts = [
1383
+ _rich_text_object_to_plain_text(element)
1384
+ for element in block.get("elements", [])
1385
+ if isinstance(element, dict)
1386
+ ]
1387
+ return "\n".join(part for part in parts if part).strip()
1388
+
1389
+
1390
+ def _plain_text_from_markdown_fragment(text: str) -> str:
1391
+ return _rich_text_inline_elements_to_plain_text(
1392
+ _create_rich_text_inline_elements(text)
1393
+ ).strip()
1394
+
1395
+
1396
+ def _create_markdown_block(
1397
+ content: str, *, preserve_visual_blank_lines: bool = False
1398
+ ) -> dict[str, Any] | None:
1399
+ formatted, synthetic_indices = _format_markdown_with_spacing_metadata(content)
1400
+ plain_text = _build_markdown_block_plain_text(formatted, synthetic_indices)
1401
+ synthetic_blank_line_indices: list[int] = []
1402
+ if preserve_visual_blank_lines:
1403
+ formatted, synthetic_blank_line_indices = (
1404
+ _inject_visual_blank_line_placeholders(formatted)
1405
+ )
1406
+ if not formatted.strip():
1407
+ return None
1408
+
1409
+ block = _AnnotatedSlackBlock({"type": "markdown", "text": formatted})
1410
+ block._plain_text = plain_text
1411
+ block._synthetic_space_indices = synthetic_indices
1412
+ block._synthetic_blank_line_indices = synthetic_blank_line_indices
1413
+ return block
1414
+
1415
+
1416
+ def _create_rich_text_block(
1417
+ elements: list[dict[str, Any]], *, plain_text: str | None = None
1418
+ ) -> dict[str, Any]:
1419
+ block = _AnnotatedSlackBlock({"type": "rich_text", "elements": elements})
1420
+ if plain_text is not None:
1421
+ block._plain_text = plain_text
1422
+ return block
1423
+
1424
+
1425
+ def _create_image_block_from_line(line: str) -> dict[str, Any] | None:
1426
+ match = STANDALONE_IMAGE_PATTERN.match(line)
1427
+ if not match:
1428
+ return None
1429
+
1430
+ image_url = match.group("url").strip()
1431
+ if not _is_http_url(image_url) or len(image_url) > 3000:
1432
+ return None
1433
+
1434
+ alt_text = _plain_text_from_markdown_fragment(match.group("alt") or "")
1435
+ alt_text = _truncate_plain_text(alt_text or "Image", 2000)
1436
+ return {"type": "image", "image_url": image_url, "alt_text": alt_text}
1437
+
1438
+
1439
+ def _is_setext_heading_underline(lines: list[str], index: int) -> bool:
1440
+ if index <= 0:
1441
+ return False
1442
+ line = lines[index].strip()
1443
+ if not line or set(line) != {"-"}:
1444
+ return False
1445
+ return bool(lines[index - 1].strip())
1446
+
1447
+
1448
+ def _create_divider_block_from_line(
1449
+ lines: list[str], index: int
1450
+ ) -> dict[str, Any] | None:
1451
+ if not _is_thematic_break_line(lines[index]):
1452
+ return None
1453
+ if _is_setext_heading_underline(lines, index):
1454
+ return None
1455
+ block = _AnnotatedSlackBlock({"type": "divider"})
1456
+ block._plain_text = lines[index].strip()
1457
+ return block
1458
+
1459
+
1460
+ def _strip_quote_marker(line: str) -> str | None:
1461
+ match = re.match(r"^[ \t]{0,3}>[ \t]?(?P<text>.*)$", line)
1462
+ if not match:
1463
+ return None
1464
+ return match.group("text")
1465
+
1466
+
1467
+ def _quote_lines_are_simple(lines: list[str]) -> bool:
1468
+ for line in lines:
1469
+ stripped = line.strip()
1470
+ if not stripped:
1471
+ return False
1472
+ if stripped.startswith(">"):
1473
+ return False
1474
+ if LIST_ITEM_PATTERN.match(stripped):
1475
+ return False
1476
+ if _match_fence_open(stripped):
1477
+ return False
1478
+ if _has_markdown_backslash_escape(stripped):
1479
+ return False
1480
+ return True
1481
+
1482
+
1483
+ def _consume_quote_block(
1484
+ lines: list[str], start: int
1485
+ ) -> tuple[dict[str, Any], int] | None:
1486
+ if _strip_quote_marker(lines[start]) is None:
1487
+ return None
1488
+
1489
+ quote_lines: list[str] = []
1490
+ cursor = start
1491
+ while cursor < len(lines):
1492
+ stripped = _strip_quote_marker(lines[cursor])
1493
+ if stripped is None:
1494
+ break
1495
+ quote_lines.append(stripped)
1496
+ cursor += 1
1497
+
1498
+ if not _quote_lines_are_simple(quote_lines):
1499
+ return None
1500
+
1501
+ quote_text = "\n".join(quote_lines).strip()
1502
+ if not quote_text:
1503
+ return None
1504
+
1505
+ block = _create_rich_text_block(
1506
+ [
1507
+ {
1508
+ "type": "rich_text_quote",
1509
+ "elements": _create_rich_text_inline_elements(quote_text),
1510
+ }
1511
+ ],
1512
+ plain_text="\n".join(lines[start:cursor]),
1513
+ )
1514
+ return block, cursor
1515
+
1516
+
1517
+ def _parse_simple_list_item(line: str) -> dict[str, Any] | None:
1518
+ if _is_thematic_break_line(line):
1519
+ return None
1520
+
1521
+ match = LIST_ITEM_PATTERN.match(line)
1522
+ if not match:
1523
+ return None
1524
+
1525
+ text = line[match.end() :].rstrip()
1526
+ if TASK_LIST_MARKER_PATTERN.match(text):
1527
+ return None
1528
+
1529
+ indent = match.group("indent") or ""
1530
+ if _is_ambiguous_rich_list_indent(indent):
1531
+ return None
1532
+ if _has_markdown_backslash_escape(text):
1533
+ return None
1534
+
1535
+ marker = match.group("marker")
1536
+ return {
1537
+ "style": "ordered" if _is_ordered_list_marker(marker) else "bullet",
1538
+ "number": _ordered_list_marker_number(marker),
1539
+ "indent": _list_indent_level(indent),
1540
+ "text": text.strip() or " ",
1541
+ }
1542
+
1543
+
1544
+ def _consume_list_block(
1545
+ lines: list[str], start: int
1546
+ ) -> tuple[dict[str, Any], int] | None:
1547
+ first_entry = _parse_simple_list_item(lines[start])
1548
+ if first_entry is None:
1549
+ return None
1550
+
1551
+ first_match = LIST_ITEM_PATTERN.match(lines[start])
1552
+ first_indent = _indent_width(first_match.group("indent") if first_match else "")
1553
+ if first_indent > 3:
1554
+ return None
1555
+
1556
+ if start > 0 and lines[start - 1].strip():
1557
+ return None
1558
+
1559
+ entries: list[dict[str, Any]] = []
1560
+ cursor = start
1561
+ while cursor < len(lines) and lines[cursor].strip():
1562
+ entry = _parse_simple_list_item(lines[cursor])
1563
+ if entry is None:
1564
+ return None
1565
+ entries.append(entry)
1566
+ cursor += 1
1567
+
1568
+ if not entries:
1569
+ return None
1570
+
1571
+ lookahead = cursor
1572
+ while lookahead < len(lines) and not lines[lookahead].strip():
1573
+ lookahead += 1
1574
+ if lookahead < len(lines):
1575
+ next_line = lines[lookahead]
1576
+ if _indent_width(next_line) > 0 and _parse_simple_list_item(next_line) is None:
1577
+ return None
1578
+
1579
+ rich_elements: list[dict[str, Any]] = []
1580
+ current_group: dict[str, Any] | None = None
1581
+
1582
+ def flush_group() -> None:
1583
+ nonlocal current_group
1584
+ if current_group:
1585
+ rich_elements.append(current_group)
1586
+ current_group = None
1587
+
1588
+ for entry in entries:
1589
+ group_key = (entry["style"], entry["indent"])
1590
+ if current_group is None or current_group["_key"] != group_key:
1591
+ flush_group()
1592
+ current_group = {
1593
+ "type": "rich_text_list",
1594
+ "style": entry["style"],
1595
+ "indent": entry["indent"],
1596
+ "elements": [],
1597
+ "_key": group_key,
1598
+ }
1599
+ if entry["style"] == "ordered" and entry["number"] > 1:
1600
+ current_group["offset"] = entry["number"] - 1
1601
+
1602
+ current_group["elements"].append(_create_rich_text_section(entry["text"]))
1603
+
1604
+ flush_group()
1605
+ for element in rich_elements:
1606
+ element.pop("_key", None)
1607
+
1608
+ return (
1609
+ _create_rich_text_block(
1610
+ rich_elements,
1611
+ plain_text="\n".join(lines[start:cursor]),
1612
+ ),
1613
+ cursor,
1614
+ )
1615
+
1616
+
1617
+ def _find_fence_close_index(
1618
+ lines: list[str], start: int, fence: tuple[str, int]
1619
+ ) -> int | None:
1620
+ cursor = start + 1
1621
+ while cursor < len(lines):
1622
+ if _is_fence_close(lines[cursor], fence):
1623
+ return cursor
1624
+ cursor += 1
1625
+ return None
1626
+
1627
+
1628
+ def _consume_fenced_code_block(
1629
+ lines: list[str], start: int
1630
+ ) -> tuple[dict[str, Any], int] | None:
1631
+ open_match = FENCE_OPEN_PATTERN.match(lines[start])
1632
+ if not open_match:
1633
+ return None
1634
+
1635
+ fence = _match_fence_open(lines[start])
1636
+ if fence is None:
1637
+ return None
1638
+
1639
+ close_index = _find_fence_close_index(lines, start, fence)
1640
+ if close_index is None:
1641
+ return None
1642
+
1643
+ info = (open_match.group(2) or "").strip()
1644
+ language = info.split()[0] if info else ""
1645
+ code_text = "\n".join(lines[start + 1 : close_index])
1646
+ preformatted: dict[str, Any] = {
1647
+ "type": "rich_text_preformatted",
1648
+ "elements": [{"type": "text", "text": code_text}],
1649
+ }
1650
+ if language and re.match(r"^[A-Za-z0-9_+.#-]+$", language):
1651
+ preformatted["language"] = language
1652
+ return (
1653
+ _create_rich_text_block(
1654
+ [preformatted],
1655
+ plain_text="\n".join(lines[start : close_index + 1]),
1656
+ ),
1657
+ close_index + 1,
1658
+ )
1659
+
1660
+
1661
+ def _consume_rich_markdown_block(
1662
+ lines: list[str], index: int
1663
+ ) -> tuple[dict[str, Any], int] | None:
1664
+ if not lines[index].strip():
1665
+ return None
1666
+
1667
+ consumers = (
1668
+ lambda: _consume_fenced_code_block(lines, index),
1669
+ lambda: (
1670
+ (block, index + 1)
1671
+ if (block := _create_image_block_from_line(lines[index]))
1672
+ else None
1673
+ ),
1674
+ lambda: (
1675
+ (block, index + 1)
1676
+ if (block := _create_divider_block_from_line(lines, index))
1677
+ else None
1678
+ ),
1679
+ lambda: _consume_quote_block(lines, index),
1680
+ lambda: _consume_list_block(lines, index),
1681
+ )
1682
+
1683
+ for consumer in consumers:
1684
+ consumed = consumer()
1685
+ if consumed:
1686
+ return consumed
1687
+ return None
1688
+
1689
+
1690
+ def _convert_markdown_text_segment_to_blocks(
1691
+ content: str, *, preserve_visual_blank_lines: bool = False
1692
+ ) -> list[dict[str, Any]]:
1693
+ blocks: list[dict[str, Any]] = []
1694
+ markdown_buffer: list[str] = []
1695
+ lines = content.splitlines()
1696
+ cursor = 0
1697
+
1698
+ def flush_markdown_buffer() -> None:
1699
+ nonlocal markdown_buffer
1700
+ if not markdown_buffer:
1701
+ return
1702
+ while markdown_buffer and not markdown_buffer[0].strip():
1703
+ markdown_buffer.pop(0)
1704
+ while markdown_buffer and not markdown_buffer[-1].strip():
1705
+ markdown_buffer.pop()
1706
+ if not markdown_buffer:
1707
+ return
1708
+ markdown_block = _create_markdown_block(
1709
+ "\n".join(markdown_buffer),
1710
+ preserve_visual_blank_lines=preserve_visual_blank_lines,
1711
+ )
1712
+ if markdown_block:
1713
+ blocks.append(markdown_block)
1714
+ markdown_buffer = []
1715
+
1716
+ while cursor < len(lines):
1717
+ fence = _match_fence_open(lines[cursor])
1718
+ if fence is not None and _find_fence_close_index(lines, cursor, fence) is None:
1719
+ markdown_buffer.extend(lines[cursor:])
1720
+ cursor = len(lines)
1721
+ break
1722
+
1723
+ consumed = _consume_rich_markdown_block(lines, cursor)
1724
+ if consumed:
1725
+ flush_markdown_buffer()
1726
+ block, cursor = consumed
1727
+ blocks.append(block)
1728
+ while cursor < len(lines) and not lines[cursor].strip():
1729
+ cursor += 1
1730
+ continue
1731
+
1732
+ markdown_buffer.append(lines[cursor])
1733
+ cursor += 1
1734
+
1735
+ flush_markdown_buffer()
1736
+ return blocks
1737
+
1738
+
1277
1739
  def split_markdown_into_segments(markdown_text: str) -> list[dict[str, str]]:
1278
1740
  """Split markdown into alternating text/table segments."""
1279
1741
  segments: list[dict[str, str]] = []
@@ -1352,19 +1814,12 @@ def convert_markdown_to_slack_blocks(
1352
1814
  blocks.append(table_block)
1353
1815
  continue
1354
1816
 
1355
- formatted, synthetic_indices = _format_markdown_with_spacing_metadata(content)
1356
- plain_text = _build_markdown_block_plain_text(formatted, synthetic_indices)
1357
- synthetic_blank_line_indices: list[int] = []
1358
- if preserve_visual_blank_lines:
1359
- formatted, synthetic_blank_line_indices = (
1360
- _inject_visual_blank_line_placeholders(formatted)
1817
+ blocks.extend(
1818
+ _convert_markdown_text_segment_to_blocks(
1819
+ content,
1820
+ preserve_visual_blank_lines=preserve_visual_blank_lines,
1361
1821
  )
1362
- if formatted.strip():
1363
- block = _AnnotatedSlackBlock({"type": "markdown", "text": formatted})
1364
- block._plain_text = plain_text
1365
- block._synthetic_space_indices = synthetic_indices
1366
- block._synthetic_blank_line_indices = synthetic_blank_line_indices
1367
- blocks.append(block)
1822
+ )
1368
1823
 
1369
1824
  return blocks
1370
1825
 
@@ -1374,7 +1829,7 @@ convert_markdown_text_to_blocks = convert_markdown_to_slack_blocks
1374
1829
 
1375
1830
 
1376
1831
  def split_blocks_by_table(blocks: list[dict[str, Any]]) -> list[list[dict[str, Any]]]:
1377
- """Split blocks into multiple messages to satisfy one-table-per-message constraint."""
1832
+ """Split blocks to satisfy Slack table and per-message block constraints."""
1378
1833
  messages: list[list[dict[str, Any]]] = []
1379
1834
  current_message: list[dict[str, Any]] = []
1380
1835
 
@@ -1385,6 +1840,9 @@ def split_blocks_by_table(blocks: list[dict[str, Any]]) -> list[list[dict[str, A
1385
1840
  messages.append([block])
1386
1841
  current_message = []
1387
1842
  else:
1843
+ if len(current_message) >= SLACK_MAX_BLOCKS_PER_MESSAGE:
1844
+ messages.append(current_message)
1845
+ current_message = []
1388
1846
  current_message.append(block)
1389
1847
 
1390
1848
  if current_message:
@@ -1457,6 +1915,24 @@ def blocks_to_plain_text(blocks: list[dict[str, Any]]) -> str:
1457
1915
  cell_texts.append(strip_zero_width_spaces(cell_text))
1458
1916
  if cell_texts:
1459
1917
  parts.append(" | ".join(cell_texts))
1918
+ elif block_type == "rich_text":
1919
+ text = _rich_text_block_to_plain_text(block)
1920
+ if text:
1921
+ parts.append(text)
1922
+ elif block_type == "header":
1923
+ text = block.get("text", {})
1924
+ if isinstance(text, dict) and text.get("text"):
1925
+ parts.append(str(text.get("text", "")))
1926
+ elif block_type == "image":
1927
+ alt_text = str(block.get("alt_text", "")).strip()
1928
+ image_url = str(block.get("image_url", "")).strip()
1929
+ image_text = alt_text or image_url
1930
+ if alt_text and image_url:
1931
+ image_text = f"{alt_text} ({image_url})"
1932
+ if image_text:
1933
+ parts.append(image_text)
1934
+ elif block_type == "divider":
1935
+ parts.append(getattr(block, "_plain_text", None) or "---")
1460
1936
  elif isinstance(block, dict):
1461
1937
  text = block.get("text", "")
1462
1938
  if text:
@@ -1497,6 +1973,24 @@ def build_fallback_text_from_blocks(blocks: list[dict[str, Any]]) -> str:
1497
1973
  table_lines.append(" | ".join(cells))
1498
1974
  if table_lines:
1499
1975
  plain_parts.append("\n".join(table_lines))
1976
+ elif block.get("type") == "rich_text":
1977
+ text = _rich_text_block_to_plain_text(block)
1978
+ if text.strip():
1979
+ plain_parts.append(text)
1980
+ elif block.get("type") == "header":
1981
+ text = block.get("text", {})
1982
+ if isinstance(text, dict) and text.get("text"):
1983
+ plain_parts.append(str(text.get("text", "")))
1984
+ elif block.get("type") == "image":
1985
+ alt_text = str(block.get("alt_text", "")).strip()
1986
+ image_url = str(block.get("image_url", "")).strip()
1987
+ image_text = alt_text or image_url
1988
+ if alt_text and image_url:
1989
+ image_text = f"{alt_text} ({image_url})"
1990
+ if image_text:
1991
+ plain_parts.append(image_text)
1992
+ elif block.get("type") == "divider":
1993
+ plain_parts.append(getattr(block, "_plain_text", None) or "---")
1500
1994
 
1501
1995
  return "\n\n".join([part for part in plain_parts if part.strip()])
1502
1996
 
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slack-markdown-parser
3
- Version: 2.3.2
4
- Summary: Convert LLM Markdown into Slack Block Kit markdown/table messages
3
+ Version: 2.4.0
4
+ Summary: Convert LLM Markdown into Slack Block Kit messages
5
5
  Author: darkgaldragon
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/darkgaldragon/slack-markdown-parser
@@ -30,7 +30,8 @@ Dynamic: license-file
30
30
 
31
31
  # slack-markdown-parser
32
32
 
33
- `slack-markdown-parser` is a Python library that converts ordinary Markdown generated by LLMs into Slack Block Kit messages built from `markdown` and `table` blocks.
33
+ `slack-markdown-parser` is a Python library that converts general Markdown generated by LLMs into Slack Block Kit messages built from `markdown`, `table`, and selected richer blocks so the output renders cleanly in Slack.
34
+ Basic headings and text formatting stay in `markdown` blocks, while constructs that benefit from dedicated Slack rendering, such as tables and simple standalone lists, are converted into native Block Kit blocks to produce a ChatGPT-like reading experience.
34
35
 
35
36
  ## Why this library exists
36
37
 
@@ -42,23 +43,25 @@ Many Slack AI bots have traditionally converted model output into Slack-specific
42
43
 
43
44
  ## Design approach
44
45
 
45
- This library uses Slack Block Kit `markdown` blocks for regular Markdown and `table` blocks for tables.
46
+ This library combines Slack Block Kit's newer `markdown` block, `table` blocks, and richer block conversion for Markdown patterns that can be mapped safely.
46
47
 
47
48
  | Problem | Approach |
48
49
  |---|---|
49
- | Extra conversion work | Send ordinary Markdown through Slack `markdown` blocks without rewriting it into `mrkdwn`. |
50
- | Formatting instability | Prefer zero-width spaces (`U+200B`) around formatting markers, and only add visible spaces in a few language-specific cases where Slack still renders Japanese, Chinese, or Korean text incorrectly. |
51
- | No table syntax in `mrkdwn` | Detect Markdown tables and convert them into Slack `table` blocks, including repair of common LLM-generated table inconsistencies. |
50
+ | Extra conversion work | Slack `markdown` blocks can render a useful subset of Markdown other than tables, so LLM output can usually be sent with minimal rewriting instead of being converted into `mrkdwn`. |
51
+ | Formatting instability | Slack's `markdown` parser is not complete, so this library adds zero-width spaces (`U+200B`) around formatting-marker boundaries and uses visible spaces only for a few Japanese, Chinese, and Korean cases where that is still needed. This keeps emphasis stable across English and dense CJK text. |
52
+ | No table syntax in `mrkdwn` | Detect Markdown tables and render them as Slack `table` blocks, while also repairing common LLM-generated table issues such as missing pipes. |
53
+ | Rich LLM output | Convert standalone images, dividers, simple quotes, code fences, and simple lists into native Block Kit blocks where the Markdown structure is unambiguous. |
52
54
 
53
- The goal is natural rendering on Slack, not full CommonMark or HTML fidelity.
55
+ The goal is natural rendering on Slack without exposing raw formatting markers, not full CommonMark or HTML fidelity.
54
56
  If Slack itself does not support a construct in `markdown` blocks, this library prefers safe plain-text rendering or explicit `table` blocks over aggressive rewrites into old `mrkdwn`.
55
57
 
56
58
  ## Features
57
59
 
58
- - Convert ordinary Markdown into Slack `markdown` blocks
60
+ - Convert general Markdown into Slack `markdown` blocks
59
61
  - Convert Markdown tables into Slack `table` blocks
62
+ - Promote safe standalone Markdown constructs into richer Block Kit blocks: `image`, `divider`, and `rich_text`
60
63
  - Repair common LLM table issues such as missing outer pipes, missing separator rows, mismatched column counts, and empty cells
61
- - Split output into multiple Slack messages when needed to satisfy Slack's "one table per message" constraint
64
+ - Split output into multiple Slack messages when needed to satisfy Slack's "one table per message" and per-message block-count constraints
62
65
  - Remove ANSI/control characters and neutralize invalid Slack angle-bracket tokens before block generation
63
66
  - Add zero-width spaces around inline formatting markers to reduce rendering issues outside fenced code blocks, while preserving English-like punctuation-only boundaries that Slack already renders reliably
64
67
  - Add visible spaces for a small set of nested inline-code cases in dense Japanese, Chinese, and Korean text when zero-width spaces alone are not enough
@@ -73,7 +76,7 @@ The library is built around how Slack actually renders `markdown` and `table` bl
73
76
 
74
77
  Slack updated its [`markdown` block docs](https://docs.slack.dev/reference/block-kit/blocks/markdown-block/)
75
78
  and [changelog entry](https://docs.slack.dev/changelog/2026/03/06/block-kit-rich-text)
76
- on March 6, 2026 to describe broader support for headings, dividers, task lists,
79
+ on March 6, 2026 as it started supporting broader Markdown features such as headings, dividers, task lists,
77
80
  native Markdown tables, and syntax-highlighted code blocks. In the Slack Web
78
81
  workspace used for this project's April 8, 2026 validation, raw `markdown`
79
82
  blocks rendered those constructs natively, including distinct heading levels
@@ -90,6 +93,7 @@ Reliable in current Slack rendering:
90
93
  - Bare URLs, `<https://...>` style links, Markdown links, reference-style links, and mailto links
91
94
  - Bullet lists, ordered lists, task lists, and simple blockquotes
92
95
  - Explicit Slack `table` blocks generated from Markdown tables
96
+ - Explicit richer blocks generated from unambiguous Markdown, such as standalone images, code fences, simple lists, and simple quotes
93
97
  - In environments where Slack has enabled the newer renderer, raw Markdown headings, dividers, and tables inside `markdown` blocks
94
98
 
95
99
  Known Slack-side limitations:
@@ -100,19 +104,22 @@ Known Slack-side limitations:
100
104
  - Raw Markdown tables inside `markdown` blocks now render in some newer Slack environments, but explicit Slack `table` blocks remain the reliable option across workspaces and delivery paths
101
105
  - Markdown image syntax does not become an embedded image in `markdown` blocks
102
106
  - Math, raw HTML, HTML comments, `<details>`, admonition syntax, and Mermaid are rendered as plain text or code, not as rich features
107
+ - Some newly documented Block Kit block types can be unavailable on a given `chat.postMessage` path; this library emits only a conservative subset of richer block types that has been validated through real Slack posting checks.
108
+ - The Slack **mobile** app re-prefixes the list marker onto each continuation line of a list item inside `markdown` blocks (e.g. `1. Heading` followed by an indented paragraph shows as `1. Heading` / `1.continuation` on mobile, while Slack desktop and Slack Web render the same payload correctly). This is a Slack client-side rendering behavior, not a parser bug. Tracking: [issue #45](https://github.com/darkgaldragon/slack-markdown-parser/issues/45).
103
109
 
104
110
  What this library compensates for:
105
111
 
106
112
  - Normalizes underscore emphasis (`_..._`, `__...__`) into Slack-friendly asterisk emphasis
107
113
  - Wraps bare URLs into Slack-friendly `<https://...>` link form before sending `markdown` blocks
108
114
  - Repairs malformed LLM-generated tables before converting them into Slack `table` blocks
115
+ - Converts unambiguous standalone Markdown constructs into native Block Kit blocks when that is safer than relying on raw `markdown` rendering
109
116
  - Keeps table-like rows inside fenced code blocks out of table normalization
110
117
  - Optionally turns internal blank lines into placeholder lines that keep paragraphs visibly separated in Slack `markdown` blocks
111
118
  - Neutralizes invalid Slack angle-bracket tokens such as raw HTML-like tags
112
119
 
113
120
  ## Requirements
114
121
 
115
- - Your Slack integration must support Block Kit payloads with `markdown` and `table` blocks.
122
+ - Your Slack integration must support Block Kit payloads with `markdown`, `table`, and the richer blocks emitted by this library (`rich_text`, `image`, and `divider`).
116
123
  - This library does not help when your delivery path only accepts plain `text` or `mrkdwn` strings.
117
124
 
118
125
  ## Installation