mdsyntax 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.
- mdsyntax-0.1.0/.github/workflows/ci.yml +76 -0
- mdsyntax-0.1.0/.github/workflows/release.yml +32 -0
- mdsyntax-0.1.0/.gitignore +56 -0
- mdsyntax-0.1.0/CHANGELOG.md +23 -0
- mdsyntax-0.1.0/CONTRIBUTING.md +56 -0
- mdsyntax-0.1.0/LICENSE +21 -0
- mdsyntax-0.1.0/PKG-INFO +176 -0
- mdsyntax-0.1.0/README.md +142 -0
- mdsyntax-0.1.0/pyproject.toml +83 -0
- mdsyntax-0.1.0/src/mdsyntax/__init__.py +26 -0
- mdsyntax-0.1.0/src/mdsyntax/cli.py +82 -0
- mdsyntax-0.1.0/src/mdsyntax/py.typed +0 -0
- mdsyntax-0.1.0/src/mdsyntax/renderer.py +368 -0
- mdsyntax-0.1.0/tests/test_renderer.py +185 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
21
|
+
uses: actions/setup-python@v5
|
|
22
|
+
with:
|
|
23
|
+
python-version: ${{ matrix.python-version }}
|
|
24
|
+
|
|
25
|
+
- name: Install dependencies
|
|
26
|
+
run: |
|
|
27
|
+
python -m pip install --upgrade pip
|
|
28
|
+
pip install -e ".[dev]"
|
|
29
|
+
|
|
30
|
+
- name: Run tests
|
|
31
|
+
run: pytest tests/ -v
|
|
32
|
+
|
|
33
|
+
lint:
|
|
34
|
+
runs-on: ubuntu-latest
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/checkout@v4
|
|
37
|
+
|
|
38
|
+
- name: Set up Python
|
|
39
|
+
uses: actions/setup-python@v5
|
|
40
|
+
with:
|
|
41
|
+
python-version: "3.12"
|
|
42
|
+
|
|
43
|
+
- name: Install dependencies
|
|
44
|
+
run: |
|
|
45
|
+
python -m pip install --upgrade pip
|
|
46
|
+
pip install ruff
|
|
47
|
+
|
|
48
|
+
- name: Run ruff
|
|
49
|
+
run: ruff check src/
|
|
50
|
+
|
|
51
|
+
build:
|
|
52
|
+
runs-on: ubuntu-latest
|
|
53
|
+
steps:
|
|
54
|
+
- uses: actions/checkout@v4
|
|
55
|
+
|
|
56
|
+
- name: Set up Python
|
|
57
|
+
uses: actions/setup-python@v5
|
|
58
|
+
with:
|
|
59
|
+
python-version: "3.12"
|
|
60
|
+
|
|
61
|
+
- name: Install build tools
|
|
62
|
+
run: |
|
|
63
|
+
python -m pip install --upgrade pip
|
|
64
|
+
pip install build twine
|
|
65
|
+
|
|
66
|
+
- name: Build package
|
|
67
|
+
run: python -m build
|
|
68
|
+
|
|
69
|
+
- name: Check package
|
|
70
|
+
run: twine check dist/*
|
|
71
|
+
|
|
72
|
+
- name: Upload artifacts
|
|
73
|
+
uses: actions/upload-artifact@v4
|
|
74
|
+
with:
|
|
75
|
+
name: dist
|
|
76
|
+
path: dist/
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
release:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
environment: release
|
|
12
|
+
permissions:
|
|
13
|
+
id-token: write # Required for trusted publishing
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- name: Set up Python
|
|
19
|
+
uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: "3.12"
|
|
22
|
+
|
|
23
|
+
- name: Install build tools
|
|
24
|
+
run: |
|
|
25
|
+
python -m pip install --upgrade pip
|
|
26
|
+
pip install build
|
|
27
|
+
|
|
28
|
+
- name: Build package
|
|
29
|
+
run: python -m build
|
|
30
|
+
|
|
31
|
+
- name: Publish to PyPI
|
|
32
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# Distribution / packaging
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
*.egg
|
|
11
|
+
.eggs/
|
|
12
|
+
wheels/
|
|
13
|
+
MANIFEST
|
|
14
|
+
|
|
15
|
+
# Installer logs
|
|
16
|
+
pip-log.txt
|
|
17
|
+
pip-delete-this-directory.txt
|
|
18
|
+
|
|
19
|
+
# Virtual environments
|
|
20
|
+
venv/
|
|
21
|
+
.venv/
|
|
22
|
+
env/
|
|
23
|
+
ENV/
|
|
24
|
+
|
|
25
|
+
# IDE / Editor
|
|
26
|
+
.idea/
|
|
27
|
+
.vscode/
|
|
28
|
+
*.swp
|
|
29
|
+
*.swo
|
|
30
|
+
*~
|
|
31
|
+
.project
|
|
32
|
+
.pydevproject
|
|
33
|
+
.settings/
|
|
34
|
+
|
|
35
|
+
# Testing
|
|
36
|
+
.coverage
|
|
37
|
+
.pytest_cache/
|
|
38
|
+
htmlcov/
|
|
39
|
+
.tox/
|
|
40
|
+
.nox/
|
|
41
|
+
coverage.xml
|
|
42
|
+
*.cover
|
|
43
|
+
|
|
44
|
+
# mypy
|
|
45
|
+
.mypy_cache/
|
|
46
|
+
|
|
47
|
+
# ruff
|
|
48
|
+
.ruff_cache/
|
|
49
|
+
|
|
50
|
+
# OS
|
|
51
|
+
.DS_Store
|
|
52
|
+
Thumbs.db
|
|
53
|
+
|
|
54
|
+
# Local development
|
|
55
|
+
*.local
|
|
56
|
+
.env
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2025-12-24
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Initial release
|
|
15
|
+
- Markdown rendering with ANSI formatting
|
|
16
|
+
- Syntax highlighting for code blocks (powered by Pygments)
|
|
17
|
+
- Support for headers, bold, italic, strikethrough, inline code
|
|
18
|
+
- Support for lists (ordered, unordered, task lists)
|
|
19
|
+
- Support for blockquotes and horizontal rules
|
|
20
|
+
- Support for links
|
|
21
|
+
- CLI tool (`mdsyntax`)
|
|
22
|
+
- Auto-detection of 24-bit true color support
|
|
23
|
+
- Configurable code block styling and width
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Contributing to mdsyntax
|
|
2
|
+
|
|
3
|
+
Thanks for your interest in contributing!
|
|
4
|
+
|
|
5
|
+
## Development Setup
|
|
6
|
+
|
|
7
|
+
1. Clone the repository:
|
|
8
|
+
```bash
|
|
9
|
+
git clone https://github.com/Azaias/mdsyntax.git
|
|
10
|
+
cd mdsyntax
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
2. Create a virtual environment:
|
|
14
|
+
```bash
|
|
15
|
+
python -m venv venv
|
|
16
|
+
source venv/bin/activate # or `venv\Scripts\activate` on Windows
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
3. Install in editable mode with dev dependencies:
|
|
20
|
+
```bash
|
|
21
|
+
pip install -e ".[dev]"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Running Tests
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pytest tests/ -v
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Code Style
|
|
31
|
+
|
|
32
|
+
This project uses [ruff](https://github.com/astral-sh/ruff) for linting:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install ruff
|
|
36
|
+
ruff check src/
|
|
37
|
+
ruff format src/
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Submitting Changes
|
|
41
|
+
|
|
42
|
+
1. Fork the repository
|
|
43
|
+
2. Create a feature branch (`git checkout -b feature/my-feature`)
|
|
44
|
+
3. Make your changes
|
|
45
|
+
4. Run tests and linting
|
|
46
|
+
5. Commit with a descriptive message
|
|
47
|
+
6. Push to your fork
|
|
48
|
+
7. Open a Pull Request
|
|
49
|
+
|
|
50
|
+
## Reporting Issues
|
|
51
|
+
|
|
52
|
+
Please include:
|
|
53
|
+
- Python version
|
|
54
|
+
- OS and terminal
|
|
55
|
+
- Minimal code to reproduce
|
|
56
|
+
- Expected vs actual behavior
|
mdsyntax-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Izaiah Meyer
|
|
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.
|
mdsyntax-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mdsyntax
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Render markdown with syntax highlighting in the terminal
|
|
5
|
+
Project-URL: Homepage, https://github.com/Azaias/mdsyntax
|
|
6
|
+
Project-URL: Repository, https://github.com/Azaias/mdsyntax
|
|
7
|
+
Project-URL: Issues, https://github.com/Azaias/mdsyntax/issues
|
|
8
|
+
Author-email: Izaiah Meyer <lolduderlly@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ansi,cli,console,markdown,syntax-highlighting,terminal
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Terminals
|
|
23
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: colorama>=0.4.6
|
|
27
|
+
Requires-Dist: pygments>=2.17.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: build; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
32
|
+
Requires-Dist: twine; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# mdsyntax
|
|
36
|
+
|
|
37
|
+
[](https://pypi.org/project/mdsyntax/)
|
|
38
|
+
[](https://pypi.org/project/mdsyntax/)
|
|
39
|
+
[](https://github.com/Azaias/mdsyntax/actions/workflows/ci.yml)
|
|
40
|
+
[](https://opensource.org/licenses/MIT)
|
|
41
|
+
|
|
42
|
+
Render markdown with syntax highlighting in the terminal.
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install mdsyntax
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
### Python API
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from mdsyntax import md_print, md_render
|
|
56
|
+
|
|
57
|
+
# Print directly to terminal
|
|
58
|
+
md_print("""
|
|
59
|
+
# Hello World
|
|
60
|
+
|
|
61
|
+
This is **bold** and *italic* text.
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
def greet(name):
|
|
65
|
+
return f"Hello, {name}!"
|
|
66
|
+
```
|
|
67
|
+
""")
|
|
68
|
+
|
|
69
|
+
# Get ANSI string for further processing
|
|
70
|
+
output = md_render("Some `inline code` here")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Command Line
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# Render a file
|
|
77
|
+
mdsyntax README.md
|
|
78
|
+
|
|
79
|
+
# Pipe from stdin
|
|
80
|
+
echo "# Hello" | mdsyntax
|
|
81
|
+
|
|
82
|
+
# Use a different syntax theme
|
|
83
|
+
mdsyntax --style dracula document.md
|
|
84
|
+
|
|
85
|
+
# List available themes
|
|
86
|
+
mdsyntax --list-styles
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Features
|
|
90
|
+
|
|
91
|
+
- Headers (h1-h6) with color coding
|
|
92
|
+
- **Bold**, *italic*, ***bold italic***
|
|
93
|
+
- ~~Strikethrough~~
|
|
94
|
+
- `Inline code`
|
|
95
|
+
- Fenced code blocks with syntax highlighting
|
|
96
|
+
- [Links](https://example.com)
|
|
97
|
+
- Unordered and ordered lists
|
|
98
|
+
- Task lists
|
|
99
|
+
- Blockquotes
|
|
100
|
+
- Horizontal rules
|
|
101
|
+
|
|
102
|
+
## Configuration
|
|
103
|
+
|
|
104
|
+
### Code Styles
|
|
105
|
+
|
|
106
|
+
Any [Pygments style](https://pygments.org/styles/) is supported. Popular options:
|
|
107
|
+
|
|
108
|
+
- `monokai` (default)
|
|
109
|
+
- `dracula`
|
|
110
|
+
- `one-dark`
|
|
111
|
+
- `gruvbox-dark`
|
|
112
|
+
- `nord`
|
|
113
|
+
- `github-dark`
|
|
114
|
+
|
|
115
|
+
### True Color
|
|
116
|
+
|
|
117
|
+
By default, md-print auto-detects 24-bit color support via the `COLORTERM` environment variable. You can override this:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
# Force 256-color mode
|
|
121
|
+
md_print(text, true_color=False)
|
|
122
|
+
|
|
123
|
+
# Force true color
|
|
124
|
+
md_print(text, true_color=True)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## API Reference
|
|
128
|
+
|
|
129
|
+
### `md_print(text, *, code_style="monokai", code_width=None, true_color=None)`
|
|
130
|
+
|
|
131
|
+
Print markdown to terminal.
|
|
132
|
+
|
|
133
|
+
- `text`: Markdown string to render
|
|
134
|
+
- `code_style`: Pygments style name for code blocks
|
|
135
|
+
- `code_width`: Fixed width for code blocks (default: terminal width)
|
|
136
|
+
- `true_color`: Use 24-bit color (default: auto-detect)
|
|
137
|
+
|
|
138
|
+
### `md_render(...) -> str`
|
|
139
|
+
|
|
140
|
+
Same arguments as `md_print`, but returns the ANSI-formatted string instead of printing.
|
|
141
|
+
|
|
142
|
+
### `MarkdownRenderer`
|
|
143
|
+
|
|
144
|
+
Dataclass for more control:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from mdsyntax import MarkdownRenderer
|
|
148
|
+
|
|
149
|
+
renderer = MarkdownRenderer(
|
|
150
|
+
code_style="dracula",
|
|
151
|
+
code_width=80,
|
|
152
|
+
true_color=True,
|
|
153
|
+
)
|
|
154
|
+
output = renderer.render(markdown_text)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### `SyntaxHighlighter`
|
|
158
|
+
|
|
159
|
+
Standalone code highlighter:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from mdsyntax import SyntaxHighlighter
|
|
163
|
+
|
|
164
|
+
hl = SyntaxHighlighter(style="monokai")
|
|
165
|
+
print(hl.highlight("print('hello')", "python"))
|
|
166
|
+
print(SyntaxHighlighter.available_styles())
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
MIT
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
## Contributing
|
|
175
|
+
|
|
176
|
+
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
mdsyntax-0.1.0/README.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# mdsyntax
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/mdsyntax/)
|
|
4
|
+
[](https://pypi.org/project/mdsyntax/)
|
|
5
|
+
[](https://github.com/Azaias/mdsyntax/actions/workflows/ci.yml)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
|
|
8
|
+
Render markdown with syntax highlighting in the terminal.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install mdsyntax
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
### Python API
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from mdsyntax import md_print, md_render
|
|
22
|
+
|
|
23
|
+
# Print directly to terminal
|
|
24
|
+
md_print("""
|
|
25
|
+
# Hello World
|
|
26
|
+
|
|
27
|
+
This is **bold** and *italic* text.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
def greet(name):
|
|
31
|
+
return f"Hello, {name}!"
|
|
32
|
+
```
|
|
33
|
+
""")
|
|
34
|
+
|
|
35
|
+
# Get ANSI string for further processing
|
|
36
|
+
output = md_render("Some `inline code` here")
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Command Line
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Render a file
|
|
43
|
+
mdsyntax README.md
|
|
44
|
+
|
|
45
|
+
# Pipe from stdin
|
|
46
|
+
echo "# Hello" | mdsyntax
|
|
47
|
+
|
|
48
|
+
# Use a different syntax theme
|
|
49
|
+
mdsyntax --style dracula document.md
|
|
50
|
+
|
|
51
|
+
# List available themes
|
|
52
|
+
mdsyntax --list-styles
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Features
|
|
56
|
+
|
|
57
|
+
- Headers (h1-h6) with color coding
|
|
58
|
+
- **Bold**, *italic*, ***bold italic***
|
|
59
|
+
- ~~Strikethrough~~
|
|
60
|
+
- `Inline code`
|
|
61
|
+
- Fenced code blocks with syntax highlighting
|
|
62
|
+
- [Links](https://example.com)
|
|
63
|
+
- Unordered and ordered lists
|
|
64
|
+
- Task lists
|
|
65
|
+
- Blockquotes
|
|
66
|
+
- Horizontal rules
|
|
67
|
+
|
|
68
|
+
## Configuration
|
|
69
|
+
|
|
70
|
+
### Code Styles
|
|
71
|
+
|
|
72
|
+
Any [Pygments style](https://pygments.org/styles/) is supported. Popular options:
|
|
73
|
+
|
|
74
|
+
- `monokai` (default)
|
|
75
|
+
- `dracula`
|
|
76
|
+
- `one-dark`
|
|
77
|
+
- `gruvbox-dark`
|
|
78
|
+
- `nord`
|
|
79
|
+
- `github-dark`
|
|
80
|
+
|
|
81
|
+
### True Color
|
|
82
|
+
|
|
83
|
+
By default, md-print auto-detects 24-bit color support via the `COLORTERM` environment variable. You can override this:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
# Force 256-color mode
|
|
87
|
+
md_print(text, true_color=False)
|
|
88
|
+
|
|
89
|
+
# Force true color
|
|
90
|
+
md_print(text, true_color=True)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## API Reference
|
|
94
|
+
|
|
95
|
+
### `md_print(text, *, code_style="monokai", code_width=None, true_color=None)`
|
|
96
|
+
|
|
97
|
+
Print markdown to terminal.
|
|
98
|
+
|
|
99
|
+
- `text`: Markdown string to render
|
|
100
|
+
- `code_style`: Pygments style name for code blocks
|
|
101
|
+
- `code_width`: Fixed width for code blocks (default: terminal width)
|
|
102
|
+
- `true_color`: Use 24-bit color (default: auto-detect)
|
|
103
|
+
|
|
104
|
+
### `md_render(...) -> str`
|
|
105
|
+
|
|
106
|
+
Same arguments as `md_print`, but returns the ANSI-formatted string instead of printing.
|
|
107
|
+
|
|
108
|
+
### `MarkdownRenderer`
|
|
109
|
+
|
|
110
|
+
Dataclass for more control:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from mdsyntax import MarkdownRenderer
|
|
114
|
+
|
|
115
|
+
renderer = MarkdownRenderer(
|
|
116
|
+
code_style="dracula",
|
|
117
|
+
code_width=80,
|
|
118
|
+
true_color=True,
|
|
119
|
+
)
|
|
120
|
+
output = renderer.render(markdown_text)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### `SyntaxHighlighter`
|
|
124
|
+
|
|
125
|
+
Standalone code highlighter:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from mdsyntax import SyntaxHighlighter
|
|
129
|
+
|
|
130
|
+
hl = SyntaxHighlighter(style="monokai")
|
|
131
|
+
print(hl.highlight("print('hello')", "python"))
|
|
132
|
+
print(SyntaxHighlighter.available_styles())
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
## Contributing
|
|
141
|
+
|
|
142
|
+
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mdsyntax"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Render markdown with syntax highlighting in the terminal"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Izaiah Meyer", email = "lolduderlly@gmail.com" }
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"markdown",
|
|
17
|
+
"terminal",
|
|
18
|
+
"console",
|
|
19
|
+
"syntax-highlighting",
|
|
20
|
+
"ansi",
|
|
21
|
+
"cli",
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 4 - Beta",
|
|
25
|
+
"Environment :: Console",
|
|
26
|
+
"Intended Audience :: Developers",
|
|
27
|
+
"License :: OSI Approved :: MIT License",
|
|
28
|
+
"Operating System :: OS Independent",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3.10",
|
|
31
|
+
"Programming Language :: Python :: 3.11",
|
|
32
|
+
"Programming Language :: Python :: 3.12",
|
|
33
|
+
"Programming Language :: Python :: 3.13",
|
|
34
|
+
"Topic :: Text Processing :: Markup :: Markdown",
|
|
35
|
+
"Topic :: Terminals",
|
|
36
|
+
"Typing :: Typed",
|
|
37
|
+
]
|
|
38
|
+
dependencies = [
|
|
39
|
+
"colorama>=0.4.6",
|
|
40
|
+
"pygments>=2.17.0",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.optional-dependencies]
|
|
44
|
+
dev = [
|
|
45
|
+
"pytest>=8.0",
|
|
46
|
+
"ruff>=0.4",
|
|
47
|
+
"build",
|
|
48
|
+
"twine",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[project.urls]
|
|
52
|
+
Homepage = "https://github.com/Azaias/mdsyntax"
|
|
53
|
+
Repository = "https://github.com/Azaias/mdsyntax"
|
|
54
|
+
Issues = "https://github.com/Azaias/mdsyntax/issues"
|
|
55
|
+
|
|
56
|
+
[project.scripts]
|
|
57
|
+
mdsyntax = "mdsyntax.cli:main"
|
|
58
|
+
|
|
59
|
+
[tool.hatch.build.targets.wheel]
|
|
60
|
+
packages = ["src/mdsyntax"]
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
testpaths = ["tests"]
|
|
64
|
+
|
|
65
|
+
[tool.ruff]
|
|
66
|
+
target-version = "py310"
|
|
67
|
+
line-length = 88
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint]
|
|
70
|
+
select = [
|
|
71
|
+
"E", # pycodestyle errors
|
|
72
|
+
"W", # pycodestyle warnings
|
|
73
|
+
"F", # pyflakes
|
|
74
|
+
"I", # isort
|
|
75
|
+
"UP", # pyupgrade
|
|
76
|
+
"B", # flake8-bugbear
|
|
77
|
+
]
|
|
78
|
+
ignore = [
|
|
79
|
+
"E501", # line too long (handled by formatter)
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
[tool.ruff.lint.isort]
|
|
83
|
+
known-first-party = ["mdsyntax"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
mdsyntax: Render markdown with syntax highlighting in the terminal.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
>>> from mdsyntax import md_print, md_render
|
|
6
|
+
>>> md_print("# Hello **world**")
|
|
7
|
+
>>> output = md_render("Some `code` here")
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from mdsyntax.renderer import (
|
|
11
|
+
LANG_ALIASES,
|
|
12
|
+
MarkdownRenderer,
|
|
13
|
+
SyntaxHighlighter,
|
|
14
|
+
md_print,
|
|
15
|
+
md_render,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
__all__ = [
|
|
20
|
+
"md_print",
|
|
21
|
+
"md_render",
|
|
22
|
+
"MarkdownRenderer",
|
|
23
|
+
"SyntaxHighlighter",
|
|
24
|
+
"LANG_ALIASES",
|
|
25
|
+
"__version__",
|
|
26
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Command-line interface for md-print."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from mdsyntax import __version__, md_print
|
|
9
|
+
from mdsyntax.renderer import SyntaxHighlighter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv: list[str] | None = None) -> int:
|
|
13
|
+
"""Main CLI entry point."""
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="mdsyntax",
|
|
16
|
+
description="Render markdown with syntax highlighting in the terminal.",
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"file",
|
|
20
|
+
nargs="?",
|
|
21
|
+
type=argparse.FileType("r"),
|
|
22
|
+
default=sys.stdin,
|
|
23
|
+
help="Markdown file to render (default: stdin)",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"-s",
|
|
27
|
+
"--style",
|
|
28
|
+
default="monokai",
|
|
29
|
+
metavar="STYLE",
|
|
30
|
+
help="Pygments style for code blocks (default: monokai)",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"-w",
|
|
34
|
+
"--width",
|
|
35
|
+
type=int,
|
|
36
|
+
default=None,
|
|
37
|
+
metavar="N",
|
|
38
|
+
help="Width for code blocks (default: terminal width)",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--no-true-color",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help="Disable 24-bit true color (use 256 colors)",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--list-styles",
|
|
47
|
+
action="store_true",
|
|
48
|
+
help="List available syntax highlighting styles and exit",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"-V",
|
|
52
|
+
"--version",
|
|
53
|
+
action="version",
|
|
54
|
+
version=f"%(prog)s {__version__}",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
args = parser.parse_args(argv)
|
|
58
|
+
|
|
59
|
+
if args.list_styles:
|
|
60
|
+
print("Available styles:")
|
|
61
|
+
for style in sorted(SyntaxHighlighter.available_styles()):
|
|
62
|
+
print(f" {style}")
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
text = args.file.read()
|
|
66
|
+
if args.file is not sys.stdin:
|
|
67
|
+
args.file.close()
|
|
68
|
+
|
|
69
|
+
true_color = None if not args.no_true_color else False
|
|
70
|
+
|
|
71
|
+
md_print(
|
|
72
|
+
text,
|
|
73
|
+
code_style=args.style,
|
|
74
|
+
code_width=args.width,
|
|
75
|
+
true_color=true_color,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
sys.exit(main())
|
|
File without changes
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Terminal markdown renderer with syntax highlighting.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from colorama import Back, Fore, Style, init
|
|
14
|
+
from pygments import highlight
|
|
15
|
+
from pygments.formatters import Terminal256Formatter, TerminalTrueColorFormatter
|
|
16
|
+
from pygments.lexers import TextLexer, get_lexer_by_name, guess_lexer
|
|
17
|
+
from pygments.styles import get_all_styles, get_style_by_name
|
|
18
|
+
|
|
19
|
+
init(autoreset=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Ansi:
|
|
23
|
+
"""ANSI escape codes not exposed by colorama."""
|
|
24
|
+
|
|
25
|
+
ITALIC = "\033[3m"
|
|
26
|
+
ITALIC_OFF = "\033[23m"
|
|
27
|
+
UNDERLINE = "\033[4m"
|
|
28
|
+
UNDERLINE_OFF = "\033[24m"
|
|
29
|
+
DIM = "\033[2m"
|
|
30
|
+
DIM_OFF = "\033[22m"
|
|
31
|
+
STRIKETHROUGH = "\033[9m"
|
|
32
|
+
STRIKETHROUGH_OFF = "\033[29m"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
LANG_ALIASES: dict[str, str] = {
|
|
36
|
+
"py": "python",
|
|
37
|
+
"js": "javascript",
|
|
38
|
+
"ts": "typescript",
|
|
39
|
+
"sh": "bash",
|
|
40
|
+
"shell": "bash",
|
|
41
|
+
"yml": "yaml",
|
|
42
|
+
"md": "markdown",
|
|
43
|
+
"c++": "cpp",
|
|
44
|
+
"c#": "csharp",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _detect_true_color() -> bool:
|
|
49
|
+
"""Check if terminal supports 24-bit color."""
|
|
50
|
+
colorterm = os.environ.get("COLORTERM", "")
|
|
51
|
+
return colorterm in ("truecolor", "24bit")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _get_style_bg(style_name: str) -> str:
|
|
55
|
+
"""Extract background color from pygments style as ANSI escape."""
|
|
56
|
+
try:
|
|
57
|
+
style = get_style_by_name(style_name)
|
|
58
|
+
bg = style.background_color
|
|
59
|
+
if bg and bg.startswith("#") and len(bg) == 7:
|
|
60
|
+
r, g, b = int(bg[1:3], 16), int(bg[3:5], 16), int(bg[5:7], 16)
|
|
61
|
+
return f"\033[48;2;{r};{g};{b}m"
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
return "\033[48;5;236m" # fallback gray
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _visible_len(s: str) -> int:
|
|
68
|
+
"""Length of string excluding ANSI escape sequences."""
|
|
69
|
+
return len(re.sub(r"\033\[[0-9;]*m", "", s))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _pad_to_width(text: str, width: int) -> str:
|
|
73
|
+
"""Pad string to width, accounting for ANSI codes."""
|
|
74
|
+
padding = width - _visible_len(text)
|
|
75
|
+
return text + " " * max(0, padding)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SyntaxHighlighter:
|
|
79
|
+
"""Syntax highlighter using pygments."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, style: str = "monokai", true_color: bool | None = None):
|
|
82
|
+
"""
|
|
83
|
+
Args:
|
|
84
|
+
style: Pygments style name (monokai, dracula, gruvbox-dark, one-dark, etc.)
|
|
85
|
+
true_color: Use 24-bit color. None = auto-detect from COLORTERM env var.
|
|
86
|
+
"""
|
|
87
|
+
if true_color is None:
|
|
88
|
+
true_color = _detect_true_color()
|
|
89
|
+
|
|
90
|
+
formatter_cls = (
|
|
91
|
+
TerminalTrueColorFormatter if true_color else Terminal256Formatter
|
|
92
|
+
)
|
|
93
|
+
self.formatter = formatter_cls(style=style)
|
|
94
|
+
self.style = style
|
|
95
|
+
|
|
96
|
+
def highlight(self, code: str, language: str = "") -> str:
|
|
97
|
+
"""Highlight code and return ANSI-formatted string."""
|
|
98
|
+
lexer = self._get_lexer(code, language)
|
|
99
|
+
return highlight(code, lexer, self.formatter).rstrip("\n")
|
|
100
|
+
|
|
101
|
+
def _get_lexer(self, code: str, language: str):
|
|
102
|
+
language = LANG_ALIASES.get(language.lower(), language.lower())
|
|
103
|
+
|
|
104
|
+
if language:
|
|
105
|
+
try:
|
|
106
|
+
return get_lexer_by_name(language)
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
return guess_lexer(code)
|
|
112
|
+
except Exception:
|
|
113
|
+
return TextLexer()
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def available_styles() -> list[str]:
|
|
117
|
+
"""Return list of available pygments style names."""
|
|
118
|
+
return list(get_all_styles())
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class MarkdownRenderer:
|
|
123
|
+
"""Renders markdown to ANSI-formatted terminal output."""
|
|
124
|
+
|
|
125
|
+
code_style: str = "monokai"
|
|
126
|
+
code_width: int | None = None # None = terminal width
|
|
127
|
+
true_color: bool | None = None # None = auto-detect
|
|
128
|
+
|
|
129
|
+
_highlighter: SyntaxHighlighter = field(init=False, repr=False)
|
|
130
|
+
_code_bg: str = field(init=False, repr=False)
|
|
131
|
+
|
|
132
|
+
def __post_init__(self):
|
|
133
|
+
self._highlighter = SyntaxHighlighter(
|
|
134
|
+
style=self.code_style, true_color=self.true_color
|
|
135
|
+
)
|
|
136
|
+
self._code_bg = _get_style_bg(self.code_style)
|
|
137
|
+
|
|
138
|
+
def render(self, text: str) -> str:
|
|
139
|
+
"""Render markdown text to ANSI-formatted string."""
|
|
140
|
+
# Normalize line endings
|
|
141
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
142
|
+
return "\n".join(self._render_blocks(text))
|
|
143
|
+
|
|
144
|
+
def _render_blocks(self, text: str) -> Iterator[str]:
|
|
145
|
+
"""Process block-level elements."""
|
|
146
|
+
lines = text.split("\n")
|
|
147
|
+
i = 0
|
|
148
|
+
|
|
149
|
+
while i < len(lines):
|
|
150
|
+
line = lines[i]
|
|
151
|
+
stripped = line.strip()
|
|
152
|
+
|
|
153
|
+
# Code block
|
|
154
|
+
if stripped.startswith("```"):
|
|
155
|
+
lang = stripped[3:].strip()
|
|
156
|
+
code_lines = []
|
|
157
|
+
i += 1
|
|
158
|
+
|
|
159
|
+
while i < len(lines) and not lines[i].strip().startswith("```"):
|
|
160
|
+
code_lines.append(lines[i])
|
|
161
|
+
i += 1
|
|
162
|
+
|
|
163
|
+
yield from self._render_code_block(code_lines, lang)
|
|
164
|
+
i += 1 # skip closing ```
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
yield self._render_line(line)
|
|
168
|
+
i += 1
|
|
169
|
+
|
|
170
|
+
def _get_code_width(self) -> int:
|
|
171
|
+
if self.code_width:
|
|
172
|
+
return self.code_width
|
|
173
|
+
return shutil.get_terminal_size().columns
|
|
174
|
+
|
|
175
|
+
def _render_code_block(self, code_lines: list[str], language: str) -> Iterator[str]:
|
|
176
|
+
"""Render a fenced code block with syntax highlighting."""
|
|
177
|
+
width = self._get_code_width()
|
|
178
|
+
bg = self._code_bg
|
|
179
|
+
reset = Style.RESET_ALL
|
|
180
|
+
|
|
181
|
+
# Header with language label
|
|
182
|
+
label = f"{language}" if language else ""
|
|
183
|
+
if label:
|
|
184
|
+
yield f"{bg}{Fore.LIGHTBLACK_EX}{_pad_to_width(label, width)}{reset}"
|
|
185
|
+
|
|
186
|
+
# Highlighted code
|
|
187
|
+
if code_lines:
|
|
188
|
+
code = "\n".join(code_lines)
|
|
189
|
+
highlighted = self._highlighter.highlight(code, language)
|
|
190
|
+
|
|
191
|
+
for hl_line in highlighted.split("\n"):
|
|
192
|
+
yield f"{bg}{_pad_to_width(hl_line, width)}{reset}"
|
|
193
|
+
|
|
194
|
+
def _render_line(self, line: str) -> str:
|
|
195
|
+
"""Render a single line of markdown."""
|
|
196
|
+
stripped = line.strip()
|
|
197
|
+
|
|
198
|
+
if not stripped:
|
|
199
|
+
return ""
|
|
200
|
+
|
|
201
|
+
# Horizontal rule
|
|
202
|
+
if re.match(r"^[-*_]{3,}$", stripped):
|
|
203
|
+
return f"{Ansi.DIM}{Fore.WHITE}{'─' * 50}{Style.RESET_ALL}"
|
|
204
|
+
|
|
205
|
+
# Headers
|
|
206
|
+
if m := re.match(r"^(#{1,6})\s+(.+)$", stripped):
|
|
207
|
+
return self._render_header(len(m.group(1)), m.group(2))
|
|
208
|
+
|
|
209
|
+
# Blockquotes
|
|
210
|
+
if stripped.startswith(">"):
|
|
211
|
+
content = stripped.lstrip(">").strip()
|
|
212
|
+
rendered = self._render_inline(content)
|
|
213
|
+
return f"{Fore.MAGENTA}│ {Ansi.ITALIC}{rendered}{Ansi.ITALIC_OFF}{Style.RESET_ALL}"
|
|
214
|
+
|
|
215
|
+
# Task lists
|
|
216
|
+
if m := re.match(r"^[-*]\s+\[([ xX])\]\s+(.+)$", stripped):
|
|
217
|
+
checked = m.group(1).lower() == "x"
|
|
218
|
+
marker = f"{Fore.GREEN}✓" if checked else f"{Fore.RED}○"
|
|
219
|
+
return f" {marker} {self._render_inline(m.group(2))}{Style.RESET_ALL}"
|
|
220
|
+
|
|
221
|
+
# Unordered lists
|
|
222
|
+
if m := re.match(r"^[-*+]\s+(.+)$", stripped):
|
|
223
|
+
indent = len(line) - len(line.lstrip())
|
|
224
|
+
return f"{' ' * indent}{Fore.GREEN}• {Style.RESET_ALL}{self._render_inline(m.group(1))}"
|
|
225
|
+
|
|
226
|
+
# Ordered lists
|
|
227
|
+
if m := re.match(r"^(\d+)\.\s+(.+)$", stripped):
|
|
228
|
+
indent = len(line) - len(line.lstrip())
|
|
229
|
+
return f"{' ' * indent}{Fore.GREEN}{m.group(1)}. {Style.RESET_ALL}{self._render_inline(m.group(2))}"
|
|
230
|
+
|
|
231
|
+
return self._render_inline(line)
|
|
232
|
+
|
|
233
|
+
def _render_header(self, level: int, text: str) -> str:
|
|
234
|
+
"""Render a header with level-appropriate styling."""
|
|
235
|
+
colors = [
|
|
236
|
+
Fore.CYAN,
|
|
237
|
+
Fore.BLUE,
|
|
238
|
+
Fore.MAGENTA,
|
|
239
|
+
Fore.GREEN,
|
|
240
|
+
Fore.YELLOW,
|
|
241
|
+
Fore.WHITE,
|
|
242
|
+
]
|
|
243
|
+
color = colors[min(level, 6) - 1]
|
|
244
|
+
|
|
245
|
+
# Visual prefix for h1-h3
|
|
246
|
+
prefix = "█" * (4 - level) + " " if level <= 3 else ""
|
|
247
|
+
|
|
248
|
+
rendered_text = self._render_inline(text)
|
|
249
|
+
return f"{color}{Style.BRIGHT}{prefix}{rendered_text}{Style.RESET_ALL}"
|
|
250
|
+
|
|
251
|
+
def _render_inline(self, text: str) -> str:
|
|
252
|
+
"""Render inline markdown elements."""
|
|
253
|
+
# Order matters: process from most specific to least specific
|
|
254
|
+
|
|
255
|
+
# Inline code first (protects contents from further processing)
|
|
256
|
+
code_spans: list[str] = []
|
|
257
|
+
|
|
258
|
+
def extract_code(m):
|
|
259
|
+
code_spans.append(
|
|
260
|
+
f"{Back.BLACK}{Fore.YELLOW} {m.group(1)} {Style.RESET_ALL}"
|
|
261
|
+
)
|
|
262
|
+
return f"\x00CODE{len(code_spans) - 1}\x00"
|
|
263
|
+
|
|
264
|
+
text = re.sub(r"`([^`]+)`", extract_code, text)
|
|
265
|
+
|
|
266
|
+
# Bold + italic (must come before bold and italic)
|
|
267
|
+
text = re.sub(
|
|
268
|
+
r"\*\*\*(.+?)\*\*\*",
|
|
269
|
+
lambda m: f"{Style.BRIGHT}{Ansi.ITALIC}{m.group(1)}{Ansi.ITALIC_OFF}{Style.NORMAL}",
|
|
270
|
+
text,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# Bold
|
|
274
|
+
text = re.sub(
|
|
275
|
+
r"\*\*(.+?)\*\*",
|
|
276
|
+
lambda m: f"{Style.BRIGHT}{m.group(1)}{Style.NORMAL}",
|
|
277
|
+
text,
|
|
278
|
+
)
|
|
279
|
+
text = re.sub(
|
|
280
|
+
r"__(.+?)__",
|
|
281
|
+
lambda m: f"{Style.BRIGHT}{m.group(1)}{Style.NORMAL}",
|
|
282
|
+
text,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Italic with asterisks (works anywhere)
|
|
286
|
+
text = re.sub(
|
|
287
|
+
r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)",
|
|
288
|
+
lambda m: f"{Ansi.ITALIC}{m.group(1)}{Ansi.ITALIC_OFF}",
|
|
289
|
+
text,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# Italic with underscores (only at word boundaries)
|
|
293
|
+
text = re.sub(
|
|
294
|
+
r"(?<!\w)_(?!_)(.+?)(?<!_)_(?!\w)",
|
|
295
|
+
lambda m: f"{Ansi.ITALIC}{m.group(1)}{Ansi.ITALIC_OFF}",
|
|
296
|
+
text,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# Strikethrough
|
|
300
|
+
text = re.sub(
|
|
301
|
+
r"~~(.+?)~~",
|
|
302
|
+
lambda m: f"{Ansi.STRIKETHROUGH}{m.group(1)}{Ansi.STRIKETHROUGH_OFF}",
|
|
303
|
+
text,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Links
|
|
307
|
+
text = re.sub(
|
|
308
|
+
r"\[([^\]]+)\]\(([^)]+)\)",
|
|
309
|
+
lambda m: f"{Ansi.UNDERLINE}{Fore.BLUE}{m.group(1)}{Ansi.UNDERLINE_OFF}{Style.RESET_ALL}{Ansi.DIM} ({m.group(2)}){Ansi.DIM_OFF}",
|
|
310
|
+
text,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# Restore code spans
|
|
314
|
+
for i, code in enumerate(code_spans):
|
|
315
|
+
text = text.replace(f"\x00CODE{i}\x00", code)
|
|
316
|
+
|
|
317
|
+
return text
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def md_print(
|
|
321
|
+
text: str,
|
|
322
|
+
*,
|
|
323
|
+
code_style: str = "monokai",
|
|
324
|
+
code_width: int | None = None,
|
|
325
|
+
true_color: bool | None = None,
|
|
326
|
+
) -> None:
|
|
327
|
+
"""
|
|
328
|
+
Print markdown-formatted text to the terminal.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
text: Markdown text to render.
|
|
332
|
+
code_style: Pygments style for code blocks.
|
|
333
|
+
code_width: Width for code blocks (None = terminal width).
|
|
334
|
+
true_color: Use 24-bit color (None = auto-detect).
|
|
335
|
+
"""
|
|
336
|
+
renderer = MarkdownRenderer(
|
|
337
|
+
code_style=code_style,
|
|
338
|
+
code_width=code_width,
|
|
339
|
+
true_color=true_color,
|
|
340
|
+
)
|
|
341
|
+
print(renderer.render(text))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def md_render(
|
|
345
|
+
text: str,
|
|
346
|
+
*,
|
|
347
|
+
code_style: str = "monokai",
|
|
348
|
+
code_width: int | None = None,
|
|
349
|
+
true_color: bool | None = None,
|
|
350
|
+
) -> str:
|
|
351
|
+
"""
|
|
352
|
+
Render markdown text to ANSI-formatted string.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
text: Markdown text to render.
|
|
356
|
+
code_style: Pygments style for code blocks.
|
|
357
|
+
code_width: Width for code blocks (None = terminal width).
|
|
358
|
+
true_color: Use 24-bit color (None = auto-detect).
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
ANSI-formatted string ready for terminal output.
|
|
362
|
+
"""
|
|
363
|
+
renderer = MarkdownRenderer(
|
|
364
|
+
code_style=code_style,
|
|
365
|
+
code_width=code_width,
|
|
366
|
+
true_color=true_color,
|
|
367
|
+
)
|
|
368
|
+
return renderer.render(text)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Tests for mdsyntax."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from mdsyntax import LANG_ALIASES, MarkdownRenderer, SyntaxHighlighter, md_render
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def strip_ansi(text: str) -> str:
|
|
9
|
+
"""Remove ANSI escape sequences from text."""
|
|
10
|
+
return re.sub(r"\033\[[0-9;]*m", "", text)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestInlineFormatting:
|
|
14
|
+
def test_bold_asterisks(self):
|
|
15
|
+
result = md_render("**bold**")
|
|
16
|
+
assert "bold" in strip_ansi(result)
|
|
17
|
+
assert "**" not in strip_ansi(result)
|
|
18
|
+
|
|
19
|
+
def test_bold_underscores(self):
|
|
20
|
+
result = md_render("__bold__")
|
|
21
|
+
assert "bold" in strip_ansi(result)
|
|
22
|
+
assert "__" not in strip_ansi(result)
|
|
23
|
+
|
|
24
|
+
def test_italic_asterisks(self):
|
|
25
|
+
result = md_render("*italic*")
|
|
26
|
+
assert "italic" in strip_ansi(result)
|
|
27
|
+
assert strip_ansi(result).count("*") == 0
|
|
28
|
+
|
|
29
|
+
def test_italic_underscores(self):
|
|
30
|
+
result = md_render("_italic_")
|
|
31
|
+
assert "italic" in strip_ansi(result)
|
|
32
|
+
|
|
33
|
+
def test_underscore_in_word_preserved(self):
|
|
34
|
+
result = md_render("some_variable_name")
|
|
35
|
+
assert "some_variable_name" in strip_ansi(result)
|
|
36
|
+
|
|
37
|
+
def test_multiple_italics(self):
|
|
38
|
+
result = md_render("*a* and *b*")
|
|
39
|
+
plain = strip_ansi(result)
|
|
40
|
+
assert "a" in plain
|
|
41
|
+
assert "b" in plain
|
|
42
|
+
assert "*" not in plain
|
|
43
|
+
|
|
44
|
+
def test_bold_italic(self):
|
|
45
|
+
result = md_render("***both***")
|
|
46
|
+
assert "both" in strip_ansi(result)
|
|
47
|
+
|
|
48
|
+
def test_strikethrough(self):
|
|
49
|
+
result = md_render("~~deleted~~")
|
|
50
|
+
assert "deleted" in strip_ansi(result)
|
|
51
|
+
assert "~~" not in strip_ansi(result)
|
|
52
|
+
|
|
53
|
+
def test_inline_code(self):
|
|
54
|
+
result = md_render("`code`")
|
|
55
|
+
assert "code" in strip_ansi(result)
|
|
56
|
+
|
|
57
|
+
def test_code_protects_formatting(self):
|
|
58
|
+
result = md_render("`**not bold**`")
|
|
59
|
+
assert "**not bold**" in strip_ansi(result)
|
|
60
|
+
|
|
61
|
+
def test_link(self):
|
|
62
|
+
result = md_render("[text](https://example.com)")
|
|
63
|
+
plain = strip_ansi(result)
|
|
64
|
+
assert "text" in plain
|
|
65
|
+
assert "example.com" in plain
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestBlockFormatting:
|
|
69
|
+
def test_header_h1(self):
|
|
70
|
+
result = md_render("# Title")
|
|
71
|
+
assert "Title" in strip_ansi(result)
|
|
72
|
+
assert "#" not in strip_ansi(result)
|
|
73
|
+
|
|
74
|
+
def test_header_h2(self):
|
|
75
|
+
result = md_render("## Subtitle")
|
|
76
|
+
assert "Subtitle" in strip_ansi(result)
|
|
77
|
+
|
|
78
|
+
def test_unordered_list(self):
|
|
79
|
+
result = md_render("- item")
|
|
80
|
+
assert "item" in strip_ansi(result)
|
|
81
|
+
assert "•" in strip_ansi(result)
|
|
82
|
+
|
|
83
|
+
def test_ordered_list(self):
|
|
84
|
+
result = md_render("1. first")
|
|
85
|
+
assert "first" in strip_ansi(result)
|
|
86
|
+
assert "1." in strip_ansi(result)
|
|
87
|
+
|
|
88
|
+
def test_task_list_checked(self):
|
|
89
|
+
result = md_render("- [x] done")
|
|
90
|
+
plain = strip_ansi(result)
|
|
91
|
+
assert "done" in plain
|
|
92
|
+
assert "✓" in plain
|
|
93
|
+
|
|
94
|
+
def test_task_list_unchecked(self):
|
|
95
|
+
result = md_render("- [ ] todo")
|
|
96
|
+
plain = strip_ansi(result)
|
|
97
|
+
assert "todo" in plain
|
|
98
|
+
assert "○" in plain
|
|
99
|
+
|
|
100
|
+
def test_blockquote(self):
|
|
101
|
+
result = md_render("> quoted")
|
|
102
|
+
plain = strip_ansi(result)
|
|
103
|
+
assert "quoted" in plain
|
|
104
|
+
assert "│" in plain
|
|
105
|
+
|
|
106
|
+
def test_horizontal_rule(self):
|
|
107
|
+
result = md_render("---")
|
|
108
|
+
assert "─" in strip_ansi(result)
|
|
109
|
+
|
|
110
|
+
def test_code_block(self):
|
|
111
|
+
result = md_render("```python\nprint('hi')\n```")
|
|
112
|
+
assert "print" in strip_ansi(result)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class TestEdgeCases:
|
|
116
|
+
def test_empty_string(self):
|
|
117
|
+
result = md_render("")
|
|
118
|
+
assert result == ""
|
|
119
|
+
|
|
120
|
+
def test_whitespace_only(self):
|
|
121
|
+
result = md_render(" ")
|
|
122
|
+
assert strip_ansi(result) == ""
|
|
123
|
+
|
|
124
|
+
def test_unclosed_bold(self):
|
|
125
|
+
result = md_render("**unclosed")
|
|
126
|
+
assert "**unclosed" in strip_ansi(result)
|
|
127
|
+
|
|
128
|
+
def test_unclosed_code_block(self):
|
|
129
|
+
# Should not crash
|
|
130
|
+
result = md_render("```python\ncode")
|
|
131
|
+
assert "code" in strip_ansi(result)
|
|
132
|
+
|
|
133
|
+
def test_crlf_normalized(self):
|
|
134
|
+
result = md_render("line1\r\nline2")
|
|
135
|
+
assert "\r" not in result
|
|
136
|
+
|
|
137
|
+
def test_nested_formatting(self):
|
|
138
|
+
result = md_render("**bold with `code` inside**")
|
|
139
|
+
plain = strip_ansi(result)
|
|
140
|
+
assert "bold with" in plain
|
|
141
|
+
assert "code" in plain
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TestSyntaxHighlighter:
|
|
145
|
+
def test_highlight_python(self):
|
|
146
|
+
hl = SyntaxHighlighter()
|
|
147
|
+
result = hl.highlight("def foo(): pass", "python")
|
|
148
|
+
assert "def" in strip_ansi(result)
|
|
149
|
+
|
|
150
|
+
def test_language_alias(self):
|
|
151
|
+
hl = SyntaxHighlighter()
|
|
152
|
+
result = hl.highlight("x = 1", "py")
|
|
153
|
+
# Should not crash, should highlight
|
|
154
|
+
assert "x" in strip_ansi(result)
|
|
155
|
+
|
|
156
|
+
def test_available_styles(self):
|
|
157
|
+
styles = SyntaxHighlighter.available_styles()
|
|
158
|
+
assert "monokai" in styles
|
|
159
|
+
assert len(styles) > 10
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class TestMarkdownRenderer:
|
|
163
|
+
def test_custom_style(self):
|
|
164
|
+
renderer = MarkdownRenderer(code_style="dracula")
|
|
165
|
+
result = renderer.render("# Test")
|
|
166
|
+
assert "Test" in strip_ansi(result)
|
|
167
|
+
|
|
168
|
+
def test_custom_width(self):
|
|
169
|
+
renderer = MarkdownRenderer(code_width=40)
|
|
170
|
+
result = renderer.render("```\ncode\n```")
|
|
171
|
+
# Code block lines should be padded to 40 chars
|
|
172
|
+
lines = result.split("\n")
|
|
173
|
+
for line in lines:
|
|
174
|
+
plain = strip_ansi(line)
|
|
175
|
+
# All code block lines should be exactly 40 chars (padded)
|
|
176
|
+
if plain: # non-empty lines
|
|
177
|
+
assert len(plain) == 40, f"Expected 40, got {len(plain)}: {plain!r}"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class TestLangAliases:
|
|
181
|
+
def test_common_aliases(self):
|
|
182
|
+
assert LANG_ALIASES["py"] == "python"
|
|
183
|
+
assert LANG_ALIASES["js"] == "javascript"
|
|
184
|
+
assert LANG_ALIASES["ts"] == "typescript"
|
|
185
|
+
assert LANG_ALIASES["sh"] == "bash"
|