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.
@@ -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.
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/mdsyntax.svg)](https://pypi.org/project/mdsyntax/)
38
+ [![Python versions](https://img.shields.io/pypi/pyversions/mdsyntax.svg)](https://pypi.org/project/mdsyntax/)
39
+ [![CI](https://github.com/Azaias/mdsyntax/actions/workflows/ci.yml/badge.svg)](https://github.com/Azaias/mdsyntax/actions/workflows/ci.yml)
40
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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.
@@ -0,0 +1,142 @@
1
+ # mdsyntax
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/mdsyntax.svg)](https://pypi.org/project/mdsyntax/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/mdsyntax.svg)](https://pypi.org/project/mdsyntax/)
5
+ [![CI](https://github.com/Azaias/mdsyntax/actions/workflows/ci.yml/badge.svg)](https://github.com/Azaias/mdsyntax/actions/workflows/ci.yml)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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"