md2mrkdwn 0.1.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.
@@ -0,0 +1,77 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ .idea
6
+ media
7
+
8
+ # C extensions
9
+ *.so
10
+
11
+ # Distribution / packaging
12
+ .Python
13
+ env/
14
+ build/
15
+ develop-eggs/
16
+ dist/
17
+ downloads/
18
+ eggs/
19
+ .eggs/
20
+ parts/
21
+ sdist/
22
+ var/
23
+ *.egg-info/
24
+ .installed.cfg
25
+ *.egg
26
+
27
+ configs/local_settings.py
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .coverage
43
+ .coverage.*
44
+ .cache
45
+ nosetests.xml
46
+ coverage.xml
47
+ *,cover
48
+ .hypothesis/
49
+
50
+ # Translations
51
+ *.pot
52
+
53
+ # Django stuff:
54
+ *.log
55
+
56
+ # Sphinx documentation
57
+ docs/_build/
58
+
59
+ # PyBuilder
60
+ target/
61
+
62
+ #Ipython Notebook
63
+ .ipynb_checkpoints
64
+ venv/*
65
+ env/*
66
+ .venv/*
67
+ .env/*
68
+
69
+ # Idea modules
70
+ *.iml
71
+
72
+ .ruff_cache
73
+ .pytest_cache
74
+ .mypy_cache
75
+
76
+ .claude/
77
+ CLAUDE.MD
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Dave Allie
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,280 @@
1
+ Metadata-Version: 2.4
2
+ Name: md2mrkdwn
3
+ Version: 0.1.0
4
+ Summary: Convert Markdown to Slack mrkdwn format
5
+ Project-URL: Homepage, https://github.com/bigbag/md2mrkdwn
6
+ Project-URL: Repository, https://github.com/bigbag/md2mrkdwn
7
+ Project-URL: Issues, https://github.com/bigbag/md2mrkdwn/issues
8
+ Author-email: Pavel Liashkov <pavel.liashkov@protonamil.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: converter,formatting,markdown,mrkdwn,slack
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Communications :: Chat
21
+ Classifier: Topic :: Text Processing :: Markup
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+
25
+ # md2mrkdwn
26
+
27
+ [![CI](https://github.com/bigbag/md2mrkdwn/workflows/CI/badge.svg)](https://github.com/bigbag/md2mrkdwn/actions?query=workflow%3ACI)
28
+ [![pypi](https://img.shields.io/pypi/v/md2mrkdwn.svg)](https://pypi.python.org/pypi/md2mrkdwn)
29
+ [![downloads](https://img.shields.io/pypi/dm/md2mrkdwn.svg)](https://pypistats.org/packages/md2mrkdwn)
30
+ [![versions](https://img.shields.io/pypi/pyversions/md2mrkdwn.svg)](https://github.com/bigbag/md2mrkdwn)
31
+ [![license](https://img.shields.io/github/license/bigbag/md2mrkdwn.svg)](https://github.com/bigbag/md2mrkdwn/blob/master/LICENSE)
32
+
33
+ Pure Python library for converting Markdown to Slack's mrkdwn format. Zero dependencies, comprehensive formatting support, and proper handling of edge cases.
34
+
35
+ ## Features
36
+
37
+ - **Zero dependencies** - Pure Python implementation with no external packages required
38
+ - **Comprehensive formatting** - Supports bold, italic, strikethrough, links, images, lists, and more
39
+ - **Code block handling** - Preserves content inside code blocks without conversion
40
+ - **Table support** - Wraps markdown tables in code blocks for Slack display
41
+ - **Task lists** - Converts checkbox syntax to Unicode symbols (☐/☑)
42
+ - **Edge case handling** - Properly handles nested formatting and special characters
43
+
44
+ ## Quick Start
45
+
46
+ ```python
47
+ from md2mrkdwn import convert
48
+
49
+ markdown = "**Hello** *World*! Check out [Slack](https://slack.com)"
50
+ mrkdwn = convert(markdown)
51
+ print(mrkdwn)
52
+ # Output: *Hello* _World_! Check out <https://slack.com|Slack>
53
+ ```
54
+
55
+ ## Installation
56
+
57
+ ```bash
58
+ # Install with pip
59
+ pip install md2mrkdwn
60
+
61
+ # Or install with uv
62
+ uv add md2mrkdwn
63
+
64
+ # Or install with pipx (for CLI tools that use this library)
65
+ pipx install md2mrkdwn
66
+ ```
67
+
68
+ ## Usage
69
+
70
+ ### Simple Function
71
+
72
+ The `convert()` function provides a simple interface for one-off conversions:
73
+
74
+ ```python
75
+ from md2mrkdwn import convert
76
+
77
+ markdown = """
78
+ # Hello World
79
+
80
+ This is **bold** and *italic* text.
81
+
82
+ - Item 1
83
+ - Item 2
84
+
85
+ Check out [this link](https://example.com)!
86
+ """
87
+
88
+ mrkdwn = convert(markdown)
89
+ print(mrkdwn)
90
+ ```
91
+
92
+ Output:
93
+ ```
94
+ *Hello World*
95
+
96
+ This is *bold* and _italic_ text.
97
+
98
+ • Item 1
99
+ • Item 2
100
+
101
+ Check out <https://example.com|this link>!
102
+ ```
103
+
104
+ ### Class-based Usage
105
+
106
+ For multiple conversions, use the `MrkdwnConverter` class:
107
+
108
+ ```python
109
+ from md2mrkdwn import MrkdwnConverter
110
+
111
+ converter = MrkdwnConverter()
112
+
113
+ # Convert multiple texts
114
+ text1 = converter.convert("**bold** and *italic*")
115
+ text2 = converter.convert("# Header\n\n- List item")
116
+
117
+ print(text1) # *bold* and _italic_
118
+ print(text2) # *Header*\n\n• List item
119
+ ```
120
+
121
+ ### Handling Tables
122
+
123
+ Markdown tables are automatically wrapped in code blocks since Slack doesn't support native table rendering:
124
+
125
+ ```python
126
+ from md2mrkdwn import convert
127
+
128
+ markdown = """
129
+ | Name | Age |
130
+ |------|-----|
131
+ | Alice | 30 |
132
+ | Bob | 25 |
133
+ """
134
+
135
+ print(convert(markdown))
136
+ ```
137
+
138
+ Output:
139
+ ```
140
+ ```
141
+ | Name | Age |
142
+ |------|-----|
143
+ | Alice | 30 |
144
+ | Bob | 25 |
145
+ ```
146
+ ```
147
+
148
+ ## Conversion Reference
149
+
150
+ | Markdown | mrkdwn | Notes |
151
+ |----------|--------|-------|
152
+ | `**bold**` or `__bold__` | `*bold*` | Slack uses single asterisk |
153
+ | `*italic*` or `_italic_` | `_italic_` | Slack uses underscores |
154
+ | `***bold+italic***` | `*_text_*` | Combined formatting |
155
+ | `~~strikethrough~~` | `~text~` | Single tilde |
156
+ | `[text](url)` | `<url\|text>` | Slack link format |
157
+ | `![alt](url)` | `<url>` | Images become plain URLs |
158
+ | `# Header` (all levels) | `*Header*` | Bold (Slack has no headers) |
159
+ | `- item` / `* item` | `• item` | Bullet character (U+2022) |
160
+ | `1. item` | `1. item` | Preserved as-is |
161
+ | `- [ ] task` | `• ☐ task` | Unchecked checkbox (U+2610) |
162
+ | `- [x] task` | `• ☑ task` | Checked checkbox (U+2611) |
163
+ | `> quote` | `> quote` | Same syntax |
164
+ | `` `code` `` | `` `code` `` | Same syntax |
165
+ | ``` code block ``` | ``` code block ``` | Same syntax |
166
+ | `---` / `***` | `──────────` | Horizontal rule (U+2500) |
167
+ | Tables | Wrapped in ``` | Slack has no native tables |
168
+
169
+ ## How It Works
170
+
171
+ ### Conversion Pipeline
172
+
173
+ md2mrkdwn processes text through a multi-stage pipeline:
174
+
175
+ 1. **Table extraction** - Tables are detected, validated, and replaced with placeholders
176
+ 2. **Code block tracking** - Lines inside code blocks are skipped during conversion
177
+ 3. **Pattern application** - Regex patterns convert formatting using placeholder protection
178
+ 4. **Placeholder restoration** - Tables and temporary markers are replaced with final output
179
+
180
+ ### Pattern Interference Prevention
181
+
182
+ A key challenge in markdown conversion is preventing patterns from interfering with each other. For example, converting `**bold**` to `*bold*` could then be matched by the italic pattern.
183
+
184
+ md2mrkdwn solves this using placeholder substitution:
185
+ 1. Bold text is temporarily marked with null-byte placeholders
186
+ 2. Italic patterns run without matching the placeholders
187
+ 3. Placeholders are replaced with final mrkdwn characters
188
+
189
+ ### Table Handling
190
+
191
+ Tables are detected using these criteria:
192
+ - Lines matching `|...|` pattern
193
+ - Second row contains separator cells (dashes with optional alignment colons)
194
+ - Header and separator have matching column counts
195
+
196
+ Valid tables are wrapped in triple-backtick code blocks for monospace display in Slack.
197
+
198
+ ### Code Block Protection
199
+
200
+ Content inside code blocks (both fenced and inline) is protected from conversion:
201
+ - Fenced blocks: State machine tracks opening/closing ``` markers
202
+ - Inline code: Segments are extracted before conversion and restored after
203
+
204
+ ## Development
205
+
206
+ ### Setup
207
+
208
+ ```bash
209
+ git clone https://github.com/bigbag/md2mrkdwn.git
210
+ cd md2mrkdwn
211
+ make install
212
+ ```
213
+
214
+ ### Commands
215
+
216
+ ```bash
217
+ make install # Install all dependencies
218
+ make test # Run tests with coverage
219
+ make lint # Run linters (ruff + mypy)
220
+ make format # Format code with ruff
221
+ make clean # Clean cache and build files
222
+ ```
223
+
224
+ ### Running Tests
225
+
226
+ ```bash
227
+ # Run all tests with coverage
228
+ uv run pytest --cov=md2mrkdwn --cov-report=term-missing
229
+
230
+ # Run specific test class
231
+ uv run pytest tests/test_converter.py::TestBasicFormatting -v
232
+
233
+ # Run with verbose output
234
+ uv run pytest -v
235
+ ```
236
+
237
+ ### Project Structure
238
+
239
+ ```
240
+ md2mrkdwn/
241
+ ├── src/
242
+ │ └── md2mrkdwn/
243
+ │ ├── __init__.py # Package exports
244
+ │ └── converter.py # MrkdwnConverter class
245
+ ├── tests/
246
+ │ ├── conftest.py # Pytest fixtures
247
+ │ └── test_converter.py # Test suite (49 tests)
248
+ ├── pyproject.toml # Project configuration
249
+ ├── Makefile # Development commands
250
+ └── README.md
251
+ ```
252
+
253
+ ## API Reference
254
+
255
+ ### `convert(markdown: str) -> str`
256
+
257
+ Convert Markdown text to Slack mrkdwn format.
258
+
259
+ **Parameters:**
260
+ - `markdown` - Input text in Markdown format
261
+
262
+ **Returns:**
263
+ - Text converted to Slack mrkdwn format
264
+
265
+ ### `MrkdwnConverter`
266
+
267
+ Class for converting Markdown to mrkdwn.
268
+
269
+ **Methods:**
270
+ - `convert(markdown: str) -> str` - Convert Markdown text to mrkdwn
271
+
272
+ **Example:**
273
+ ```python
274
+ converter = MrkdwnConverter()
275
+ result = converter.convert("**Hello** *World*")
276
+ ```
277
+
278
+ ## License
279
+
280
+ MIT License - see [LICENSE](LICENSE) file.
@@ -0,0 +1,256 @@
1
+ # md2mrkdwn
2
+
3
+ [![CI](https://github.com/bigbag/md2mrkdwn/workflows/CI/badge.svg)](https://github.com/bigbag/md2mrkdwn/actions?query=workflow%3ACI)
4
+ [![pypi](https://img.shields.io/pypi/v/md2mrkdwn.svg)](https://pypi.python.org/pypi/md2mrkdwn)
5
+ [![downloads](https://img.shields.io/pypi/dm/md2mrkdwn.svg)](https://pypistats.org/packages/md2mrkdwn)
6
+ [![versions](https://img.shields.io/pypi/pyversions/md2mrkdwn.svg)](https://github.com/bigbag/md2mrkdwn)
7
+ [![license](https://img.shields.io/github/license/bigbag/md2mrkdwn.svg)](https://github.com/bigbag/md2mrkdwn/blob/master/LICENSE)
8
+
9
+ Pure Python library for converting Markdown to Slack's mrkdwn format. Zero dependencies, comprehensive formatting support, and proper handling of edge cases.
10
+
11
+ ## Features
12
+
13
+ - **Zero dependencies** - Pure Python implementation with no external packages required
14
+ - **Comprehensive formatting** - Supports bold, italic, strikethrough, links, images, lists, and more
15
+ - **Code block handling** - Preserves content inside code blocks without conversion
16
+ - **Table support** - Wraps markdown tables in code blocks for Slack display
17
+ - **Task lists** - Converts checkbox syntax to Unicode symbols (☐/☑)
18
+ - **Edge case handling** - Properly handles nested formatting and special characters
19
+
20
+ ## Quick Start
21
+
22
+ ```python
23
+ from md2mrkdwn import convert
24
+
25
+ markdown = "**Hello** *World*! Check out [Slack](https://slack.com)"
26
+ mrkdwn = convert(markdown)
27
+ print(mrkdwn)
28
+ # Output: *Hello* _World_! Check out <https://slack.com|Slack>
29
+ ```
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ # Install with pip
35
+ pip install md2mrkdwn
36
+
37
+ # Or install with uv
38
+ uv add md2mrkdwn
39
+
40
+ # Or install with pipx (for CLI tools that use this library)
41
+ pipx install md2mrkdwn
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ ### Simple Function
47
+
48
+ The `convert()` function provides a simple interface for one-off conversions:
49
+
50
+ ```python
51
+ from md2mrkdwn import convert
52
+
53
+ markdown = """
54
+ # Hello World
55
+
56
+ This is **bold** and *italic* text.
57
+
58
+ - Item 1
59
+ - Item 2
60
+
61
+ Check out [this link](https://example.com)!
62
+ """
63
+
64
+ mrkdwn = convert(markdown)
65
+ print(mrkdwn)
66
+ ```
67
+
68
+ Output:
69
+ ```
70
+ *Hello World*
71
+
72
+ This is *bold* and _italic_ text.
73
+
74
+ • Item 1
75
+ • Item 2
76
+
77
+ Check out <https://example.com|this link>!
78
+ ```
79
+
80
+ ### Class-based Usage
81
+
82
+ For multiple conversions, use the `MrkdwnConverter` class:
83
+
84
+ ```python
85
+ from md2mrkdwn import MrkdwnConverter
86
+
87
+ converter = MrkdwnConverter()
88
+
89
+ # Convert multiple texts
90
+ text1 = converter.convert("**bold** and *italic*")
91
+ text2 = converter.convert("# Header\n\n- List item")
92
+
93
+ print(text1) # *bold* and _italic_
94
+ print(text2) # *Header*\n\n• List item
95
+ ```
96
+
97
+ ### Handling Tables
98
+
99
+ Markdown tables are automatically wrapped in code blocks since Slack doesn't support native table rendering:
100
+
101
+ ```python
102
+ from md2mrkdwn import convert
103
+
104
+ markdown = """
105
+ | Name | Age |
106
+ |------|-----|
107
+ | Alice | 30 |
108
+ | Bob | 25 |
109
+ """
110
+
111
+ print(convert(markdown))
112
+ ```
113
+
114
+ Output:
115
+ ```
116
+ ```
117
+ | Name | Age |
118
+ |------|-----|
119
+ | Alice | 30 |
120
+ | Bob | 25 |
121
+ ```
122
+ ```
123
+
124
+ ## Conversion Reference
125
+
126
+ | Markdown | mrkdwn | Notes |
127
+ |----------|--------|-------|
128
+ | `**bold**` or `__bold__` | `*bold*` | Slack uses single asterisk |
129
+ | `*italic*` or `_italic_` | `_italic_` | Slack uses underscores |
130
+ | `***bold+italic***` | `*_text_*` | Combined formatting |
131
+ | `~~strikethrough~~` | `~text~` | Single tilde |
132
+ | `[text](url)` | `<url\|text>` | Slack link format |
133
+ | `![alt](url)` | `<url>` | Images become plain URLs |
134
+ | `# Header` (all levels) | `*Header*` | Bold (Slack has no headers) |
135
+ | `- item` / `* item` | `• item` | Bullet character (U+2022) |
136
+ | `1. item` | `1. item` | Preserved as-is |
137
+ | `- [ ] task` | `• ☐ task` | Unchecked checkbox (U+2610) |
138
+ | `- [x] task` | `• ☑ task` | Checked checkbox (U+2611) |
139
+ | `> quote` | `> quote` | Same syntax |
140
+ | `` `code` `` | `` `code` `` | Same syntax |
141
+ | ``` code block ``` | ``` code block ``` | Same syntax |
142
+ | `---` / `***` | `──────────` | Horizontal rule (U+2500) |
143
+ | Tables | Wrapped in ``` | Slack has no native tables |
144
+
145
+ ## How It Works
146
+
147
+ ### Conversion Pipeline
148
+
149
+ md2mrkdwn processes text through a multi-stage pipeline:
150
+
151
+ 1. **Table extraction** - Tables are detected, validated, and replaced with placeholders
152
+ 2. **Code block tracking** - Lines inside code blocks are skipped during conversion
153
+ 3. **Pattern application** - Regex patterns convert formatting using placeholder protection
154
+ 4. **Placeholder restoration** - Tables and temporary markers are replaced with final output
155
+
156
+ ### Pattern Interference Prevention
157
+
158
+ A key challenge in markdown conversion is preventing patterns from interfering with each other. For example, converting `**bold**` to `*bold*` could then be matched by the italic pattern.
159
+
160
+ md2mrkdwn solves this using placeholder substitution:
161
+ 1. Bold text is temporarily marked with null-byte placeholders
162
+ 2. Italic patterns run without matching the placeholders
163
+ 3. Placeholders are replaced with final mrkdwn characters
164
+
165
+ ### Table Handling
166
+
167
+ Tables are detected using these criteria:
168
+ - Lines matching `|...|` pattern
169
+ - Second row contains separator cells (dashes with optional alignment colons)
170
+ - Header and separator have matching column counts
171
+
172
+ Valid tables are wrapped in triple-backtick code blocks for monospace display in Slack.
173
+
174
+ ### Code Block Protection
175
+
176
+ Content inside code blocks (both fenced and inline) is protected from conversion:
177
+ - Fenced blocks: State machine tracks opening/closing ``` markers
178
+ - Inline code: Segments are extracted before conversion and restored after
179
+
180
+ ## Development
181
+
182
+ ### Setup
183
+
184
+ ```bash
185
+ git clone https://github.com/bigbag/md2mrkdwn.git
186
+ cd md2mrkdwn
187
+ make install
188
+ ```
189
+
190
+ ### Commands
191
+
192
+ ```bash
193
+ make install # Install all dependencies
194
+ make test # Run tests with coverage
195
+ make lint # Run linters (ruff + mypy)
196
+ make format # Format code with ruff
197
+ make clean # Clean cache and build files
198
+ ```
199
+
200
+ ### Running Tests
201
+
202
+ ```bash
203
+ # Run all tests with coverage
204
+ uv run pytest --cov=md2mrkdwn --cov-report=term-missing
205
+
206
+ # Run specific test class
207
+ uv run pytest tests/test_converter.py::TestBasicFormatting -v
208
+
209
+ # Run with verbose output
210
+ uv run pytest -v
211
+ ```
212
+
213
+ ### Project Structure
214
+
215
+ ```
216
+ md2mrkdwn/
217
+ ├── src/
218
+ │ └── md2mrkdwn/
219
+ │ ├── __init__.py # Package exports
220
+ │ └── converter.py # MrkdwnConverter class
221
+ ├── tests/
222
+ │ ├── conftest.py # Pytest fixtures
223
+ │ └── test_converter.py # Test suite (49 tests)
224
+ ├── pyproject.toml # Project configuration
225
+ ├── Makefile # Development commands
226
+ └── README.md
227
+ ```
228
+
229
+ ## API Reference
230
+
231
+ ### `convert(markdown: str) -> str`
232
+
233
+ Convert Markdown text to Slack mrkdwn format.
234
+
235
+ **Parameters:**
236
+ - `markdown` - Input text in Markdown format
237
+
238
+ **Returns:**
239
+ - Text converted to Slack mrkdwn format
240
+
241
+ ### `MrkdwnConverter`
242
+
243
+ Class for converting Markdown to mrkdwn.
244
+
245
+ **Methods:**
246
+ - `convert(markdown: str) -> str` - Convert Markdown text to mrkdwn
247
+
248
+ **Example:**
249
+ ```python
250
+ converter = MrkdwnConverter()
251
+ result = converter.convert("**Hello** *World*")
252
+ ```
253
+
254
+ ## License
255
+
256
+ MIT License - see [LICENSE](LICENSE) file.
@@ -0,0 +1,77 @@
1
+ [project]
2
+ name = "md2mrkdwn"
3
+ version = "0.1.0"
4
+ description = "Convert Markdown to Slack mrkdwn format"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ authors = [{ name = "Pavel Liashkov", email = "pavel.liashkov@protonamil.com" }]
9
+ keywords = ["markdown", "slack", "mrkdwn", "converter", "formatting"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Topic :: Text Processing :: Markup",
20
+ "Topic :: Communications :: Chat",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/bigbag/md2mrkdwn"
26
+ Repository = "https://github.com/bigbag/md2mrkdwn"
27
+ Issues = "https://github.com/bigbag/md2mrkdwn/issues"
28
+
29
+ [build-system]
30
+ requires = ["hatchling"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/md2mrkdwn"]
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ include = ["src/md2mrkdwn"]
38
+
39
+ [dependency-groups]
40
+ dev = [
41
+ "mypy>=1.16.0",
42
+ "pyclean>=3.1.0",
43
+ "pytest>=8.4.0",
44
+ "pytest-cov>=6.1.1",
45
+ "pytest-mock>=3.14.1",
46
+ "ruff>=0.9.0",
47
+ ]
48
+
49
+ [tool.ruff]
50
+ line-length = 120
51
+ target-version = "py310"
52
+
53
+ [tool.ruff.lint]
54
+ select = ["E", "F", "W", "I", "B", "C4", "UP", "SIM"]
55
+ ignore = ["E203", "E501", "B008"]
56
+
57
+ [tool.ruff.format]
58
+ quote-style = "double"
59
+ indent-style = "space"
60
+ skip-magic-trailing-comma = false
61
+ line-ending = "auto"
62
+
63
+ [tool.pytest.ini_options]
64
+ testpaths = ["tests"]
65
+ python_files = ["test_*.py"]
66
+ python_functions = ["test_*"]
67
+ addopts = "-v --tb=short --cov=md2mrkdwn --cov-report=term-missing"
68
+
69
+ [tool.mypy]
70
+ python_version = "3.10"
71
+ warn_return_any = true
72
+ warn_unused_configs = true
73
+ ignore_missing_imports = true
74
+
75
+ [tool.coverage.run]
76
+ source = ["src/md2mrkdwn"]
77
+ omit = ["tests/*"]
@@ -0,0 +1,6 @@
1
+ """md2mrkdwn - Convert Markdown to Slack mrkdwn format."""
2
+
3
+ from md2mrkdwn.converter import MrkdwnConverter, convert
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["MrkdwnConverter", "convert", "__version__"]
@@ -0,0 +1,386 @@
1
+ """Markdown to Slack mrkdwn converter."""
2
+
3
+ import hashlib
4
+ import re
5
+
6
+ # =============================================================================
7
+ # Compiled regex patterns (module-level for efficiency)
8
+ # =============================================================================
9
+
10
+ # Table detection
11
+ TABLE_ROW_PATTERN = re.compile(r"^\s*\|.+\|\s*$")
12
+ SEPARATOR_CELL_PATTERN = re.compile(r"^:?[-\u2013\u2014\u2212]+:?$")
13
+
14
+ # Markdown formatting (for stripping inside code blocks)
15
+ BOLD_STRIP_PATTERN = re.compile(r"\*\*(.+?)\*\*")
16
+ ITALIC_STRIP_PATTERN = re.compile(r"\*(.+?)\*")
17
+
18
+ # Inline code protection
19
+ INLINE_CODE_PATTERN = re.compile(r"`[^`]+`")
20
+
21
+ # Conversion patterns
22
+ HEADER_PATTERN = re.compile(r"^#{1,6}\s+(.+?)(?:\s+#+)?$", re.MULTILINE)
23
+ BOLD_ITALIC_ASTERISKS_PATTERN = re.compile(r"\*\*\*(.+?)\*\*\*")
24
+ BOLD_ITALIC_UNDERSCORES_PATTERN = re.compile(r"___(.+?)___")
25
+ BOLD_ASTERISKS_PATTERN = re.compile(r"\*\*(.+?)\*\*")
26
+ BOLD_UNDERSCORES_PATTERN = re.compile(r"__(.+?)__")
27
+ ITALIC_ASTERISKS_PATTERN = re.compile(r"(?<!\*)\*([^*]+?)\*(?!\*)")
28
+ ITALIC_UNDERSCORES_PATTERN = re.compile(r"(?<!_)_([^_]+?)_(?!_)")
29
+ STRIKETHROUGH_PATTERN = re.compile(r"~~(.+?)~~")
30
+ IMAGE_PATTERN = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
31
+ LINK_PATTERN = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
32
+ TASK_CHECKED_PATTERN = re.compile(r"^(\s*)[-*+]\s+\[x\]\s*", re.MULTILINE | re.IGNORECASE)
33
+ TASK_UNCHECKED_PATTERN = re.compile(r"^(\s*)[-*+]\s+\[ \]\s*", re.MULTILINE)
34
+ UNORDERED_LIST_PATTERN = re.compile(r"^(\s*)[-*+]\s+", re.MULTILINE)
35
+ HORIZONTAL_RULE_PATTERN = re.compile(r"^[-*_]{3,}\s*$", re.MULTILINE)
36
+
37
+ # =============================================================================
38
+ # Constants
39
+ # =============================================================================
40
+
41
+ # Unicode characters for replacements
42
+ BULLET = "•" # U+2022
43
+ CHECKBOX_CHECKED = "☑" # U+2611
44
+ CHECKBOX_UNCHECKED = "☐" # U+2610
45
+ HORIZONTAL_LINE = "─" # U+2500
46
+
47
+ # Temporary placeholders to prevent pattern interference
48
+ _BOLD_PLACEHOLDER = "\x00BOLD\x00"
49
+ _ITALIC_PLACEHOLDER = "\x00ITALIC\x00"
50
+
51
+
52
+ # =============================================================================
53
+ # Converter class
54
+ # =============================================================================
55
+
56
+
57
+ class MrkdwnConverter:
58
+ """Convert Markdown to Slack mrkdwn format.
59
+
60
+ This converter transforms standard CommonMark Markdown into Slack's
61
+ proprietary mrkdwn format, handling the differences in syntax for
62
+ bold, italic, links, and other formatting elements.
63
+ """
64
+
65
+ def __init__(self) -> None:
66
+ """Initialize the converter."""
67
+ self._in_code_block = False
68
+ self._table_placeholders: dict[str, str] = {}
69
+
70
+ def convert(self, markdown: str) -> str:
71
+ """Convert Markdown text to Slack mrkdwn format.
72
+
73
+ Args:
74
+ markdown: Input text in Markdown format
75
+
76
+ Returns:
77
+ Text converted to Slack mrkdwn format
78
+ """
79
+ if not markdown:
80
+ return markdown
81
+
82
+ # Reset state
83
+ self._in_code_block = False
84
+ self._table_placeholders = {}
85
+
86
+ text = markdown.strip()
87
+
88
+ # Step 1: Extract and placeholder tables (before any conversion)
89
+ text = self._process_tables(text)
90
+
91
+ # Step 2: Process line by line, skipping code blocks
92
+ lines = text.splitlines()
93
+ result_lines = []
94
+
95
+ for line in lines:
96
+ # Check for code block markers
97
+ stripped = line.strip()
98
+ if stripped.startswith("```"):
99
+ self._in_code_block = not self._in_code_block
100
+ result_lines.append(line)
101
+ continue
102
+
103
+ # Skip conversion inside code blocks
104
+ if self._in_code_block:
105
+ result_lines.append(line)
106
+ continue
107
+
108
+ # Apply conversion patterns
109
+ converted_line = self._apply_patterns(line)
110
+ result_lines.append(converted_line)
111
+
112
+ text = "\n".join(result_lines)
113
+
114
+ # Step 3: Restore tables
115
+ for placeholder, table in self._table_placeholders.items():
116
+ text = text.replace(placeholder, table)
117
+
118
+ return text
119
+
120
+ def _apply_patterns(self, line: str) -> str:
121
+ """Apply all conversion patterns to a line.
122
+
123
+ Uses placeholders to prevent pattern interference (e.g., bold converted
124
+ result being matched by italic pattern).
125
+
126
+ Args:
127
+ line: Single line of text
128
+
129
+ Returns:
130
+ Converted line
131
+ """
132
+ # Check if line contains inline code - we need to protect it
133
+ code_segments: dict[str, str] = {}
134
+ if "`" in line:
135
+ line, code_segments = self._protect_inline_code(line)
136
+
137
+ # Step 1: Convert bold+italic first (uses both asterisks and underscores)
138
+ line = BOLD_ITALIC_ASTERISKS_PATTERN.sub(
139
+ lambda m: f"{_BOLD_PLACEHOLDER}{_ITALIC_PLACEHOLDER}{m.group(1)}{_ITALIC_PLACEHOLDER}{_BOLD_PLACEHOLDER}",
140
+ line,
141
+ )
142
+ line = BOLD_ITALIC_UNDERSCORES_PATTERN.sub(
143
+ lambda m: f"{_BOLD_PLACEHOLDER}{_ITALIC_PLACEHOLDER}{m.group(1)}{_ITALIC_PLACEHOLDER}{_BOLD_PLACEHOLDER}",
144
+ line,
145
+ )
146
+
147
+ # Step 2: Convert bold (before italic to prevent interference)
148
+ line = BOLD_ASTERISKS_PATTERN.sub(
149
+ lambda m: f"{_BOLD_PLACEHOLDER}{m.group(1)}{_BOLD_PLACEHOLDER}",
150
+ line,
151
+ )
152
+ line = BOLD_UNDERSCORES_PATTERN.sub(
153
+ lambda m: f"{_BOLD_PLACEHOLDER}{m.group(1)}{_BOLD_PLACEHOLDER}",
154
+ line,
155
+ )
156
+
157
+ # Step 3: Convert italic
158
+ line = ITALIC_ASTERISKS_PATTERN.sub(
159
+ lambda m: f"{_ITALIC_PLACEHOLDER}{m.group(1)}{_ITALIC_PLACEHOLDER}",
160
+ line,
161
+ )
162
+ line = ITALIC_UNDERSCORES_PATTERN.sub(
163
+ lambda m: f"{_ITALIC_PLACEHOLDER}{m.group(1)}{_ITALIC_PLACEHOLDER}",
164
+ line,
165
+ )
166
+
167
+ # Step 4: Convert other patterns
168
+ line = STRIKETHROUGH_PATTERN.sub(r"~\1~", line)
169
+ line = IMAGE_PATTERN.sub(r"<\2>", line)
170
+ line = LINK_PATTERN.sub(r"<\2|\1>", line)
171
+ line = TASK_CHECKED_PATTERN.sub(f"\\1{BULLET} {CHECKBOX_CHECKED} ", line)
172
+ line = TASK_UNCHECKED_PATTERN.sub(f"\\1{BULLET} {CHECKBOX_UNCHECKED} ", line)
173
+ line = UNORDERED_LIST_PATTERN.sub(f"\\1{BULLET} ", line)
174
+ line = HORIZONTAL_RULE_PATTERN.sub(HORIZONTAL_LINE * 10, line)
175
+ line = HEADER_PATTERN.sub(
176
+ lambda m: f"{_BOLD_PLACEHOLDER}{m.group(1)}{_BOLD_PLACEHOLDER}",
177
+ line,
178
+ )
179
+
180
+ # Step 5: Replace placeholders with final mrkdwn characters
181
+ line = line.replace(_BOLD_PLACEHOLDER, "*")
182
+ line = line.replace(_ITALIC_PLACEHOLDER, "_")
183
+
184
+ # Step 6: Restore inline code segments
185
+ for placeholder, code in code_segments.items():
186
+ line = line.replace(placeholder, code)
187
+
188
+ return line
189
+
190
+ def _protect_inline_code(self, line: str) -> tuple[str, dict[str, str]]:
191
+ """Protect inline code segments with placeholders.
192
+
193
+ Args:
194
+ line: Line containing inline code
195
+
196
+ Returns:
197
+ Tuple of (protected line, mapping of placeholder to code)
198
+ """
199
+ code_segments: dict[str, str] = {}
200
+ counter = 0
201
+
202
+ def save_code(match: re.Match[str]) -> str:
203
+ nonlocal counter
204
+ placeholder = f"%%CODE_{counter}%%"
205
+ code_segments[placeholder] = match.group(0)
206
+ counter += 1
207
+ return placeholder
208
+
209
+ protected_line = INLINE_CODE_PATTERN.sub(save_code, line)
210
+ return protected_line, code_segments
211
+
212
+ def _process_tables(self, text: str) -> str:
213
+ """Find and wrap markdown tables in code blocks.
214
+
215
+ Slack doesn't support markdown tables natively, so we wrap them
216
+ in code blocks to preserve formatting with monospace display.
217
+
218
+ Args:
219
+ text: Full text content
220
+
221
+ Returns:
222
+ Text with tables wrapped in code blocks via placeholders
223
+ """
224
+ lines = text.split("\n")
225
+ result_lines: list[str] = []
226
+ i = 0
227
+ in_code_block = False
228
+
229
+ while i < len(lines):
230
+ line = lines[i]
231
+
232
+ # Track code block state
233
+ if line.strip().startswith("```"):
234
+ in_code_block = not in_code_block
235
+ result_lines.append(line)
236
+ i += 1
237
+ continue
238
+
239
+ # Skip table detection inside code blocks
240
+ if in_code_block:
241
+ result_lines.append(line)
242
+ i += 1
243
+ continue
244
+
245
+ # Check for potential table start
246
+ if not TABLE_ROW_PATTERN.match(line):
247
+ result_lines.append(line)
248
+ i += 1
249
+ continue
250
+
251
+ # Collect consecutive table-like lines
252
+ table_lines = [line]
253
+ j = i + 1
254
+
255
+ while j < len(lines) and TABLE_ROW_PATTERN.match(lines[j]):
256
+ table_lines.append(lines[j])
257
+ j += 1
258
+
259
+ # Validate as a proper table (header + separator + data)
260
+ if len(table_lines) >= 2 and self._is_valid_table(table_lines):
261
+ # Create wrapped table
262
+ wrapped = self._wrap_table(table_lines)
263
+ # Generate unique placeholder
264
+ placeholder = self._generate_placeholder(wrapped)
265
+ self._table_placeholders[placeholder] = wrapped
266
+ result_lines.append(placeholder)
267
+ i = j
268
+ continue
269
+
270
+ # Not a valid table
271
+ result_lines.append(line)
272
+ i += 1
273
+
274
+ return "\n".join(result_lines)
275
+
276
+ def _is_valid_table(self, table_lines: list[str]) -> bool:
277
+ """Check if lines form a valid markdown table.
278
+
279
+ A valid table has:
280
+ - A header row
281
+ - A separator row (dashes with optional alignment colons)
282
+ - Matching column counts
283
+
284
+ Args:
285
+ table_lines: Lines to validate
286
+
287
+ Returns:
288
+ True if valid markdown table
289
+ """
290
+ if len(table_lines) < 2:
291
+ return False
292
+
293
+ header_cells = self._parse_row(table_lines[0])
294
+ separator_cells = self._parse_row(table_lines[1])
295
+
296
+ if len(header_cells) != len(separator_cells):
297
+ return False
298
+
299
+ return self._is_separator_row(separator_cells)
300
+
301
+ def _parse_row(self, row: str) -> list[str]:
302
+ """Parse a markdown table row into cells.
303
+
304
+ Args:
305
+ row: Table row string
306
+
307
+ Returns:
308
+ List of cell contents
309
+ """
310
+ stripped = row.strip()
311
+ if stripped.startswith("|"):
312
+ stripped = stripped[1:]
313
+ if stripped.endswith("|"):
314
+ stripped = stripped[:-1]
315
+ return [cell.strip() for cell in stripped.split("|")]
316
+
317
+ def _is_separator_row(self, cells: list[str]) -> bool:
318
+ """Check if cells form a separator row.
319
+
320
+ Args:
321
+ cells: Parsed cells from a row
322
+
323
+ Returns:
324
+ True if all cells match separator pattern
325
+ """
326
+ return bool(cells) and all(SEPARATOR_CELL_PATTERN.match(cell) for cell in cells)
327
+
328
+ def _wrap_table(self, table_lines: list[str]) -> str:
329
+ """Wrap table lines in a code block.
330
+
331
+ Strips markdown formatting from table content for clean display.
332
+
333
+ Args:
334
+ table_lines: Lines of the table
335
+
336
+ Returns:
337
+ Table wrapped in code block
338
+ """
339
+ clean_lines = [self._strip_markdown(line) for line in table_lines]
340
+ return "```\n" + "\n".join(clean_lines) + "\n```"
341
+
342
+ def _strip_markdown(self, text: str) -> str:
343
+ """Strip markdown bold/italic formatting from text.
344
+
345
+ Args:
346
+ text: Text with potential markdown formatting
347
+
348
+ Returns:
349
+ Text with formatting removed
350
+ """
351
+ text = BOLD_STRIP_PATTERN.sub(r"\1", text)
352
+ text = ITALIC_STRIP_PATTERN.sub(r"\1", text)
353
+ return text
354
+
355
+ def _generate_placeholder(self, content: str) -> str:
356
+ """Generate a unique placeholder for content.
357
+
358
+ Args:
359
+ content: Content to generate placeholder for
360
+
361
+ Returns:
362
+ Unique placeholder string
363
+ """
364
+ hash_val = hashlib.md5(content.encode(), usedforsecurity=False).hexdigest()[:8]
365
+ return f"%%TABLE_{hash_val}%%"
366
+
367
+
368
+ def convert(markdown: str) -> str:
369
+ """Convert Markdown text to Slack mrkdwn format.
370
+
371
+ This is a convenience function that creates a converter instance
372
+ and performs the conversion.
373
+
374
+ Args:
375
+ markdown: Input text in Markdown format
376
+
377
+ Returns:
378
+ Text converted to Slack mrkdwn format
379
+
380
+ Example:
381
+ >>> from md2mrkdwn import convert
382
+ >>> convert("**Hello** *World*")
383
+ '*Hello* _World_'
384
+ """
385
+ converter = MrkdwnConverter()
386
+ return converter.convert(markdown)