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.
- md2mrkdwn-0.1.0/.gitignore +77 -0
- md2mrkdwn-0.1.0/LICENSE +21 -0
- md2mrkdwn-0.1.0/PKG-INFO +280 -0
- md2mrkdwn-0.1.0/README.md +256 -0
- md2mrkdwn-0.1.0/pyproject.toml +77 -0
- md2mrkdwn-0.1.0/src/md2mrkdwn/__init__.py +6 -0
- md2mrkdwn-0.1.0/src/md2mrkdwn/converter.py +386 -0
|
@@ -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
|
md2mrkdwn-0.1.0/LICENSE
ADDED
|
@@ -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.
|
md2mrkdwn-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://github.com/bigbag/md2mrkdwn/actions?query=workflow%3ACI)
|
|
28
|
+
[](https://pypi.python.org/pypi/md2mrkdwn)
|
|
29
|
+
[](https://pypistats.org/packages/md2mrkdwn)
|
|
30
|
+
[](https://github.com/bigbag/md2mrkdwn)
|
|
31
|
+
[](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
|
+
| `` | `<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
|
+
[](https://github.com/bigbag/md2mrkdwn/actions?query=workflow%3ACI)
|
|
4
|
+
[](https://pypi.python.org/pypi/md2mrkdwn)
|
|
5
|
+
[](https://pypistats.org/packages/md2mrkdwn)
|
|
6
|
+
[](https://github.com/bigbag/md2mrkdwn)
|
|
7
|
+
[](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
|
+
| `` | `<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,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)
|