mdsyntax 0.2.0__tar.gz → 0.3.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.2.0 → mdsyntax-0.3.0}/.github/workflows/ci.yml +3 -1
- {mdsyntax-0.2.0 → mdsyntax-0.3.0}/.gitignore +3 -0
- mdsyntax-0.3.0/CHANGELOG.md +81 -0
- {mdsyntax-0.2.0 → mdsyntax-0.3.0}/CONTRIBUTING.md +3 -3
- {mdsyntax-0.2.0 → mdsyntax-0.3.0}/PKG-INFO +8 -8
- {mdsyntax-0.2.0 → mdsyntax-0.3.0}/README.md +2 -2
- {mdsyntax-0.2.0 → mdsyntax-0.3.0}/pyproject.toml +8 -5
- {mdsyntax-0.2.0 → mdsyntax-0.3.0}/src/mdsyntax/__init__.py +1 -1
- {mdsyntax-0.2.0 → mdsyntax-0.3.0}/src/mdsyntax/cli.py +31 -8
- {mdsyntax-0.2.0 → mdsyntax-0.3.0}/src/mdsyntax/renderer.py +166 -72
- {mdsyntax-0.2.0 → mdsyntax-0.3.0}/tests/test_renderer.py +185 -2
- mdsyntax-0.2.0/CHANGELOG.md +0 -44
- {mdsyntax-0.2.0 → mdsyntax-0.3.0}/.github/workflows/release.yml +0 -0
- {mdsyntax-0.2.0 → mdsyntax-0.3.0}/LICENSE +0 -0
- {mdsyntax-0.2.0 → mdsyntax-0.3.0}/src/mdsyntax/py.typed +0 -0
|
@@ -0,0 +1,81 @@
|
|
|
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.3.0] - 2026-09-06
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Links following bold, italic, or strikethrough text on the same line no longer
|
|
15
|
+
produce corrupted escape sequences; links are now isolated before emphasis
|
|
16
|
+
processing
|
|
17
|
+
- URLs containing `__` or `*` (for example `#object.__init__`) are no longer
|
|
18
|
+
mangled by emphasis rules
|
|
19
|
+
- Code block background no longer disappears mid-line for styles with bold or
|
|
20
|
+
italic tokens (dracula, one-dark, nord, github-dark, default, ...)
|
|
21
|
+
- CLI reads files and stdin as UTF-8 and writes UTF-8 to stdout, fixing
|
|
22
|
+
mojibake and `UnicodeEncodeError` on Windows when output is piped
|
|
23
|
+
- CLI reports an unknown `--style` as a one-line error (exit code 2) instead of
|
|
24
|
+
a traceback; `SyntaxHighlighter` and `MarkdownRenderer` raise `ValueError`
|
|
25
|
+
- 256-color mode (`true_color=False`) no longer emits 24-bit escapes for the
|
|
26
|
+
code block background, blockquotes, or link URLs
|
|
27
|
+
- Code block padding accounts for wide (CJK, emoji) and combining characters;
|
|
28
|
+
tabs are expanded to four spaces
|
|
29
|
+
- Spaced asterisks such as `2 * 3 * 4` are no longer rendered as italic
|
|
30
|
+
- Fenced code blocks honour fence length, so a four-backtick fence can contain
|
|
31
|
+
a three-backtick fence; `~~~` fences are supported
|
|
32
|
+
- Header and blockquote colors are restored after inline code and links
|
|
33
|
+
- Link URLs use a muted color instead of dim, so a link inside bold text no
|
|
34
|
+
longer switches bold off early
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- Package version is read from `mdsyntax.__version__` at build time
|
|
39
|
+
- Project metadata (author, URLs) now points at the real repository
|
|
40
|
+
|
|
41
|
+
## [0.2.0] - 2025-12-31
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- New `Style` class for styling arbitrary text
|
|
46
|
+
- Static methods: `Style.bold_text()`, `Style.italic_text()`, `Style.color()`, etc.
|
|
47
|
+
- Chainable API: `style("text").bold().italic().fg("red")`
|
|
48
|
+
- RGB color support via `Style.color(text, fg=(255, 0, 0))`
|
|
49
|
+
- `Ansi` class now exported for direct access to ANSI codes
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- Switched from `colorama.init(autoreset=True)` to `colorama.just_fix_windows_console()` for better compatibility
|
|
54
|
+
- Code blocks now expand width to fit long lines instead of truncating
|
|
55
|
+
- Use targeted ANSI resets instead of full reset for better style preservation
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
|
|
59
|
+
- Inline code in blockquotes no longer breaks italic styling for subsequent text
|
|
60
|
+
- Code block width now properly constrains content
|
|
61
|
+
|
|
62
|
+
## [0.1.1] - 2025-12-24
|
|
63
|
+
|
|
64
|
+
### Changed
|
|
65
|
+
|
|
66
|
+
- Version bump; no functional changes
|
|
67
|
+
|
|
68
|
+
## [0.1.0] - 2025-12-24
|
|
69
|
+
|
|
70
|
+
### Added
|
|
71
|
+
|
|
72
|
+
- Initial release
|
|
73
|
+
- Markdown rendering with ANSI formatting
|
|
74
|
+
- Syntax highlighting for code blocks (powered by Pygments)
|
|
75
|
+
- Support for headers, bold, italic, strikethrough, inline code
|
|
76
|
+
- Support for lists (ordered, unordered, task lists)
|
|
77
|
+
- Support for blockquotes and horizontal rules
|
|
78
|
+
- Support for links
|
|
79
|
+
- CLI tool (`mdsyntax`)
|
|
80
|
+
- Auto-detection of 24-bit true color support
|
|
81
|
+
- Configurable code block styling and width
|
|
@@ -6,7 +6,7 @@ Thanks for your interest in contributing!
|
|
|
6
6
|
|
|
7
7
|
1. Clone the repository:
|
|
8
8
|
```bash
|
|
9
|
-
git clone https://github.com/
|
|
9
|
+
git clone https://github.com/Azaias/mdsyntax.git
|
|
10
10
|
cd mdsyntax
|
|
11
11
|
```
|
|
12
12
|
|
|
@@ -33,8 +33,8 @@ This project uses [ruff](https://github.com/astral-sh/ruff) for linting:
|
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
35
|
pip install ruff
|
|
36
|
-
ruff check src/
|
|
37
|
-
ruff format src/
|
|
36
|
+
ruff check src/ tests/
|
|
37
|
+
ruff format src/ tests/
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
## Submitting Changes
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: mdsyntax
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Render markdown with syntax highlighting in the terminal
|
|
5
|
-
Project-URL: Homepage, https://github.com/
|
|
6
|
-
Project-URL: Repository, https://github.com/
|
|
7
|
-
Project-URL: Issues, https://github.com/
|
|
8
|
-
Author-email:
|
|
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
9
|
License-Expression: MIT
|
|
10
10
|
License-File: LICENSE
|
|
11
11
|
Keywords: ansi,cli,console,markdown,syntax-highlighting,terminal
|
|
@@ -51,7 +51,7 @@ pip install mdsyntax
|
|
|
51
51
|
|
|
52
52
|
### Python API
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
````python
|
|
55
55
|
from mdsyntax import md_print, md_render
|
|
56
56
|
|
|
57
57
|
# Print directly to terminal
|
|
@@ -68,7 +68,7 @@ def greet(name):
|
|
|
68
68
|
|
|
69
69
|
# Get ANSI string for further processing
|
|
70
70
|
output = md_render("Some `inline code` here")
|
|
71
|
-
|
|
71
|
+
````
|
|
72
72
|
|
|
73
73
|
### Command Line
|
|
74
74
|
|
|
@@ -17,7 +17,7 @@ pip install mdsyntax
|
|
|
17
17
|
|
|
18
18
|
### Python API
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
````python
|
|
21
21
|
from mdsyntax import md_print, md_render
|
|
22
22
|
|
|
23
23
|
# Print directly to terminal
|
|
@@ -34,7 +34,7 @@ def greet(name):
|
|
|
34
34
|
|
|
35
35
|
# Get ANSI string for further processing
|
|
36
36
|
output = md_render("Some `inline code` here")
|
|
37
|
-
|
|
37
|
+
````
|
|
38
38
|
|
|
39
39
|
### Command Line
|
|
40
40
|
|
|
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mdsyntax"
|
|
7
|
-
|
|
7
|
+
dynamic = ["version"]
|
|
8
8
|
description = "Render markdown with syntax highlighting in the terminal"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
requires-python = ">=3.10"
|
|
12
12
|
authors = [
|
|
13
|
-
{ name = "
|
|
13
|
+
{ name = "Izaiah Meyer", email = "lolduderlly@gmail.com" }
|
|
14
14
|
]
|
|
15
15
|
keywords = [
|
|
16
16
|
"markdown",
|
|
@@ -49,13 +49,16 @@ dev = [
|
|
|
49
49
|
]
|
|
50
50
|
|
|
51
51
|
[project.urls]
|
|
52
|
-
Homepage = "https://github.com/
|
|
53
|
-
Repository = "https://github.com/
|
|
54
|
-
Issues = "https://github.com/
|
|
52
|
+
Homepage = "https://github.com/Azaias/mdsyntax"
|
|
53
|
+
Repository = "https://github.com/Azaias/mdsyntax"
|
|
54
|
+
Issues = "https://github.com/Azaias/mdsyntax/issues"
|
|
55
55
|
|
|
56
56
|
[project.scripts]
|
|
57
57
|
mdsyntax = "mdsyntax.cli:main"
|
|
58
58
|
|
|
59
|
+
[tool.hatch.version]
|
|
60
|
+
path = "src/mdsyntax/__init__.py"
|
|
61
|
+
|
|
59
62
|
[tool.hatch.build.targets.wheel]
|
|
60
63
|
packages = ["src/mdsyntax"]
|
|
61
64
|
|
|
@@ -1,16 +1,34 @@
|
|
|
1
|
-
"""Command-line interface for
|
|
1
|
+
"""Command-line interface for mdsyntax."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import sys
|
|
7
|
+
from typing import IO
|
|
7
8
|
|
|
8
9
|
from mdsyntax import __version__, md_print
|
|
9
10
|
from mdsyntax.renderer import SyntaxHighlighter
|
|
10
11
|
|
|
11
12
|
|
|
13
|
+
def _use_utf8(stream: IO[str]) -> None:
|
|
14
|
+
"""Switch a text stream to UTF-8 regardless of the platform locale.
|
|
15
|
+
|
|
16
|
+
On Windows the default encoding for redirected or piped streams is the
|
|
17
|
+
legacy code page, which cannot represent the box-drawing and bullet
|
|
18
|
+
characters this tool emits. Markdown is UTF-8 in practice, so read it
|
|
19
|
+
that way too.
|
|
20
|
+
"""
|
|
21
|
+
try:
|
|
22
|
+
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
|
23
|
+
except (AttributeError, ValueError, OSError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
12
27
|
def main(argv: list[str] | None = None) -> int:
|
|
13
28
|
"""Main CLI entry point."""
|
|
29
|
+
_use_utf8(sys.stdin)
|
|
30
|
+
_use_utf8(sys.stdout)
|
|
31
|
+
|
|
14
32
|
parser = argparse.ArgumentParser(
|
|
15
33
|
prog="mdsyntax",
|
|
16
34
|
description="Render markdown with syntax highlighting in the terminal.",
|
|
@@ -18,7 +36,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
18
36
|
parser.add_argument(
|
|
19
37
|
"file",
|
|
20
38
|
nargs="?",
|
|
21
|
-
type=argparse.FileType("r"),
|
|
39
|
+
type=argparse.FileType("r", encoding="utf-8", errors="replace"),
|
|
22
40
|
default=sys.stdin,
|
|
23
41
|
help="Markdown file to render (default: stdin)",
|
|
24
42
|
)
|
|
@@ -68,12 +86,17 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
68
86
|
|
|
69
87
|
true_color = None if not args.no_true_color else False
|
|
70
88
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
89
|
+
try:
|
|
90
|
+
md_print(
|
|
91
|
+
text,
|
|
92
|
+
code_style=args.style,
|
|
93
|
+
code_width=args.width,
|
|
94
|
+
true_color=true_color,
|
|
95
|
+
)
|
|
96
|
+
except ValueError as exc:
|
|
97
|
+
print(f"mdsyntax: error: {exc}", file=sys.stderr)
|
|
98
|
+
print("Run 'mdsyntax --list-styles' to see valid style names.", file=sys.stderr)
|
|
99
|
+
return 2
|
|
77
100
|
|
|
78
101
|
return 0
|
|
79
102
|
|
|
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
import os
|
|
8
8
|
import re
|
|
9
9
|
import shutil
|
|
10
|
+
import unicodedata
|
|
10
11
|
from collections.abc import Iterator
|
|
11
12
|
from dataclasses import dataclass, field
|
|
12
13
|
from typing import Literal
|
|
@@ -16,6 +17,7 @@ from pygments import highlight
|
|
|
16
17
|
from pygments.formatters import Terminal256Formatter, TerminalTrueColorFormatter
|
|
17
18
|
from pygments.lexers import TextLexer, get_lexer_by_name, guess_lexer
|
|
18
19
|
from pygments.styles import get_all_styles, get_style_by_name
|
|
20
|
+
from pygments.util import ClassNotFound
|
|
19
21
|
|
|
20
22
|
just_fix_windows_console()
|
|
21
23
|
|
|
@@ -254,48 +256,104 @@ def _detect_true_color() -> bool:
|
|
|
254
256
|
return colorterm in ("truecolor", "24bit")
|
|
255
257
|
|
|
256
258
|
|
|
257
|
-
|
|
259
|
+
_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
|
|
260
|
+
|
|
261
|
+
_MUTED_RGB = (180, 180, 180)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _rgb_to_256(r: int, g: int, b: int) -> int:
|
|
265
|
+
"""Nearest xterm-256 palette index for an RGB triple."""
|
|
266
|
+
steps = (0, 95, 135, 175, 215, 255)
|
|
267
|
+
|
|
268
|
+
def nearest_step(v: int) -> int:
|
|
269
|
+
return min(range(6), key=lambda i: abs(steps[i] - v))
|
|
270
|
+
|
|
271
|
+
ri, gi, bi = nearest_step(r), nearest_step(g), nearest_step(b)
|
|
272
|
+
cube = (steps[ri], steps[gi], steps[bi])
|
|
273
|
+
|
|
274
|
+
gray_idx = max(0, min(23, round(((r + g + b) / 3 - 8) / 10)))
|
|
275
|
+
gray = 8 + gray_idx * 10
|
|
276
|
+
|
|
277
|
+
def dist(c: tuple[int, int, int]) -> int:
|
|
278
|
+
return (c[0] - r) ** 2 + (c[1] - g) ** 2 + (c[2] - b) ** 2
|
|
279
|
+
|
|
280
|
+
if dist((gray, gray, gray)) < dist(cube):
|
|
281
|
+
return 232 + gray_idx
|
|
282
|
+
return 16 + 36 * ri + 6 * gi + bi
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _fg_code(rgb: tuple[int, int, int], true_color: bool) -> str:
|
|
286
|
+
"""Foreground escape for an RGB color, degraded to 256 colors if needed."""
|
|
287
|
+
if true_color:
|
|
288
|
+
return Ansi.rgb_fg(*rgb)
|
|
289
|
+
return f"\033[38;5;{_rgb_to_256(*rgb)}m"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _bg_code(rgb: tuple[int, int, int], true_color: bool) -> str:
|
|
293
|
+
"""Background escape for an RGB color, degraded to 256 colors if needed."""
|
|
294
|
+
if true_color:
|
|
295
|
+
return Ansi.rgb_bg(*rgb)
|
|
296
|
+
return f"\033[48;5;{_rgb_to_256(*rgb)}m"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _get_style_bg(style_name: str, true_color: bool = True) -> str:
|
|
258
300
|
"""Extract background color from pygments style as ANSI escape."""
|
|
259
301
|
try:
|
|
260
302
|
style = get_style_by_name(style_name)
|
|
261
303
|
bg = style.background_color
|
|
262
304
|
if bg and bg.startswith("#") and len(bg) == 7:
|
|
263
|
-
|
|
264
|
-
return
|
|
305
|
+
rgb = (int(bg[1:3], 16), int(bg[3:5], 16), int(bg[5:7], 16))
|
|
306
|
+
return _bg_code(rgb, true_color)
|
|
265
307
|
except Exception:
|
|
266
308
|
pass
|
|
267
309
|
return "\033[48;5;236m" # fallback gray
|
|
268
310
|
|
|
269
311
|
|
|
270
|
-
def
|
|
271
|
-
"""
|
|
272
|
-
|
|
312
|
+
def _is_full_reset(seq: str) -> bool:
|
|
313
|
+
"""True if an SGR escape sequence contains a full attribute reset (SGR 0)."""
|
|
314
|
+
body = seq[2:-1]
|
|
315
|
+
params = body.split(";") if body else [""]
|
|
316
|
+
i = 0
|
|
317
|
+
while i < len(params):
|
|
318
|
+
p = params[i]
|
|
319
|
+
if p in ("", "0", "00"):
|
|
320
|
+
return True
|
|
321
|
+
# Skip the arguments of extended color sequences: 38;5;N / 38;2;R;G;B
|
|
322
|
+
if p in ("38", "48", "58") and i + 1 < len(params):
|
|
323
|
+
if params[i + 1] == "5":
|
|
324
|
+
i += 3
|
|
325
|
+
continue
|
|
326
|
+
if params[i + 1] == "2":
|
|
327
|
+
i += 5
|
|
328
|
+
continue
|
|
329
|
+
i += 1
|
|
330
|
+
return False
|
|
273
331
|
|
|
274
332
|
|
|
275
|
-
def
|
|
276
|
-
"""
|
|
277
|
-
if _visible_len(text) <= width:
|
|
278
|
-
return text
|
|
333
|
+
def _reapply_after_reset(text: str, codes: str) -> str:
|
|
334
|
+
"""Re-emit ``codes`` after every full SGR reset in ``text``.
|
|
279
335
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
336
|
+
Pygments terminates bold/italic tokens with SGR 0, which would otherwise
|
|
337
|
+
wipe out the code block background for the rest of the line.
|
|
338
|
+
"""
|
|
339
|
+
return _ANSI_RE.sub(
|
|
340
|
+
lambda m: m.group(0) + codes if _is_full_reset(m.group(0)) else m.group(0),
|
|
341
|
+
text,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _char_width(ch: str) -> int:
|
|
346
|
+
"""Terminal column width of a single character."""
|
|
347
|
+
if unicodedata.combining(ch) or unicodedata.category(ch) in ("Mn", "Me", "Cf"):
|
|
348
|
+
return 0
|
|
349
|
+
if unicodedata.east_asian_width(ch) in ("W", "F"):
|
|
350
|
+
return 2
|
|
351
|
+
return 1
|
|
285
352
|
|
|
286
|
-
while i < len(text) and visible_count < target:
|
|
287
|
-
if text[i] == "\033":
|
|
288
|
-
# Capture entire ANSI sequence
|
|
289
|
-
end = text.find("m", i)
|
|
290
|
-
if end != -1:
|
|
291
|
-
result.append(text[i : end + 1])
|
|
292
|
-
i = end + 1
|
|
293
|
-
continue
|
|
294
|
-
result.append(text[i])
|
|
295
|
-
visible_count += 1
|
|
296
|
-
i += 1
|
|
297
353
|
|
|
298
|
-
|
|
354
|
+
def _visible_len(s: str) -> int:
|
|
355
|
+
"""Terminal column width of a string, excluding ANSI escape sequences."""
|
|
356
|
+
return sum(_char_width(ch) for ch in _ANSI_RE.sub("", s))
|
|
299
357
|
|
|
300
358
|
|
|
301
359
|
def _pad_to_width(text: str, width: int) -> str:
|
|
@@ -304,12 +362,6 @@ def _pad_to_width(text: str, width: int) -> str:
|
|
|
304
362
|
return text + " " * max(0, padding)
|
|
305
363
|
|
|
306
364
|
|
|
307
|
-
def _fit_to_width(text: str, width: int) -> str:
|
|
308
|
-
"""Truncate if too long, then pad to exact width."""
|
|
309
|
-
truncated = _truncate_to_width(text, width)
|
|
310
|
-
return _pad_to_width(truncated, width)
|
|
311
|
-
|
|
312
|
-
|
|
313
365
|
def _max_visible_width(lines: list[str]) -> int:
|
|
314
366
|
"""Get the maximum visible width across all lines."""
|
|
315
367
|
if not lines:
|
|
@@ -332,8 +384,15 @@ class SyntaxHighlighter:
|
|
|
332
384
|
formatter_cls = (
|
|
333
385
|
TerminalTrueColorFormatter if true_color else Terminal256Formatter
|
|
334
386
|
)
|
|
335
|
-
|
|
387
|
+
try:
|
|
388
|
+
self.formatter = formatter_cls(style=style)
|
|
389
|
+
except ClassNotFound:
|
|
390
|
+
raise ValueError(
|
|
391
|
+
f"Unknown syntax highlighting style: {style!r}. "
|
|
392
|
+
"See SyntaxHighlighter.available_styles() for valid names."
|
|
393
|
+
) from None
|
|
336
394
|
self.style = style
|
|
395
|
+
self.true_color = true_color
|
|
337
396
|
|
|
338
397
|
def highlight(self, code: str, language: str = "") -> str:
|
|
339
398
|
"""Highlight code and return ANSI-formatted string."""
|
|
@@ -360,6 +419,19 @@ class SyntaxHighlighter:
|
|
|
360
419
|
return list(get_all_styles())
|
|
361
420
|
|
|
362
421
|
|
|
422
|
+
_FENCE_RE = re.compile(r"^(`{3,}|~{3,})(.*)$")
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _closes_fence(line: str, marker: str) -> bool:
|
|
426
|
+
"""True if ``line`` is a closing fence for a block opened with ``marker``.
|
|
427
|
+
|
|
428
|
+
Per CommonMark the closing fence must use the same character and be at
|
|
429
|
+
least as long as the opening fence, with nothing else on the line.
|
|
430
|
+
"""
|
|
431
|
+
stripped = line.strip()
|
|
432
|
+
return len(stripped) >= len(marker) and set(stripped) == {marker[0]}
|
|
433
|
+
|
|
434
|
+
|
|
363
435
|
# =============================================================================
|
|
364
436
|
# Markdown Rendering
|
|
365
437
|
# =============================================================================
|
|
@@ -375,12 +447,15 @@ class MarkdownRenderer:
|
|
|
375
447
|
|
|
376
448
|
_highlighter: SyntaxHighlighter = field(init=False, repr=False)
|
|
377
449
|
_code_bg: str = field(init=False, repr=False)
|
|
450
|
+
_muted_fg: str = field(init=False, repr=False)
|
|
378
451
|
|
|
379
452
|
def __post_init__(self):
|
|
380
453
|
self._highlighter = SyntaxHighlighter(
|
|
381
454
|
style=self.code_style, true_color=self.true_color
|
|
382
455
|
)
|
|
383
|
-
|
|
456
|
+
true_color = self._highlighter.true_color
|
|
457
|
+
self._code_bg = _get_style_bg(self.code_style, true_color)
|
|
458
|
+
self._muted_fg = _fg_code(_MUTED_RGB, true_color)
|
|
384
459
|
|
|
385
460
|
def render(self, text: str) -> str:
|
|
386
461
|
"""Render markdown text to ANSI-formatted string."""
|
|
@@ -397,18 +472,19 @@ class MarkdownRenderer:
|
|
|
397
472
|
line = lines[i]
|
|
398
473
|
stripped = line.strip()
|
|
399
474
|
|
|
400
|
-
#
|
|
401
|
-
if
|
|
402
|
-
|
|
475
|
+
# Fenced code block (``` or ~~~, any length >= 3)
|
|
476
|
+
if fence := _FENCE_RE.match(stripped):
|
|
477
|
+
marker = fence.group(1)
|
|
478
|
+
lang = fence.group(2).strip()
|
|
403
479
|
code_lines = []
|
|
404
480
|
i += 1
|
|
405
481
|
|
|
406
|
-
while i < len(lines) and not lines[i]
|
|
482
|
+
while i < len(lines) and not _closes_fence(lines[i], marker):
|
|
407
483
|
code_lines.append(lines[i])
|
|
408
484
|
i += 1
|
|
409
485
|
|
|
410
486
|
yield from self._render_code_block(code_lines, lang)
|
|
411
|
-
i += 1 # skip closing
|
|
487
|
+
i += 1 # skip closing fence
|
|
412
488
|
continue
|
|
413
489
|
|
|
414
490
|
yield self._render_line(line)
|
|
@@ -427,9 +503,11 @@ class MarkdownRenderer:
|
|
|
427
503
|
# Highlight code first to measure actual output width
|
|
428
504
|
highlighted_lines: list[str] = []
|
|
429
505
|
if code_lines:
|
|
430
|
-
code = "\n".join(code_lines)
|
|
506
|
+
code = "\n".join(line.expandtabs(4) for line in code_lines)
|
|
431
507
|
highlighted = self._highlighter.highlight(code, language)
|
|
432
|
-
highlighted_lines =
|
|
508
|
+
highlighted_lines = [
|
|
509
|
+
_reapply_after_reset(line, bg) for line in highlighted.split("\n")
|
|
510
|
+
]
|
|
433
511
|
|
|
434
512
|
# Calculate width: use the largest of terminal width, specified width, or content width
|
|
435
513
|
terminal_width = shutil.get_terminal_size().columns
|
|
@@ -474,9 +552,9 @@ class MarkdownRenderer:
|
|
|
474
552
|
# Blockquotes
|
|
475
553
|
if stripped.startswith(">"):
|
|
476
554
|
content = stripped.lstrip(">").strip()
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
return f"{
|
|
555
|
+
muted = self._muted_fg
|
|
556
|
+
rendered = self._render_inline(content, parent_styles=[muted, Ansi.ITALIC])
|
|
557
|
+
return f"{muted}│ {Ansi.ITALIC}{rendered}{Ansi.RESET}"
|
|
480
558
|
|
|
481
559
|
# Task lists
|
|
482
560
|
if m := re.match(r"^[-*]\s+\[([ xX])\]\s+(.+)$", stripped):
|
|
@@ -511,7 +589,7 @@ class MarkdownRenderer:
|
|
|
511
589
|
# Visual prefix for h1-h3
|
|
512
590
|
prefix = "█" * (4 - level) + " " if level <= 3 else ""
|
|
513
591
|
|
|
514
|
-
rendered_text = self._render_inline(text)
|
|
592
|
+
rendered_text = self._render_inline(text, parent_styles=[color, Ansi.BOLD])
|
|
515
593
|
return f"{color}{Ansi.BOLD}{prefix}{rendered_text}{Ansi.RESET}"
|
|
516
594
|
|
|
517
595
|
def _render_inline(self, text: str, parent_styles: list[str] | None = None) -> str:
|
|
@@ -519,69 +597,85 @@ class MarkdownRenderer:
|
|
|
519
597
|
parent_styles = parent_styles or []
|
|
520
598
|
restore = "".join(parent_styles)
|
|
521
599
|
|
|
522
|
-
#
|
|
600
|
+
# Fully rendered fragments are swapped out for placeholders so that
|
|
601
|
+
# neither their source text nor their escape codes are visible to the
|
|
602
|
+
# emphasis patterns below. Placeholders contain no markdown syntax.
|
|
603
|
+
fragments: list[str] = []
|
|
523
604
|
|
|
524
|
-
|
|
525
|
-
|
|
605
|
+
def stash(rendered: str) -> str:
|
|
606
|
+
fragments.append(rendered)
|
|
607
|
+
return f"\x00{len(fragments) - 1}\x00"
|
|
526
608
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
609
|
+
# Inline code first (protects contents from further processing)
|
|
610
|
+
def extract_code(m: re.Match[str]) -> str:
|
|
611
|
+
return stash(
|
|
612
|
+
f"{Ansi.BG_BLACK}{Ansi.FG_YELLOW} {m.group(1)} "
|
|
613
|
+
f"{Ansi.BG_DEFAULT}{Ansi.FG_DEFAULT}{restore}"
|
|
614
|
+
)
|
|
532
615
|
|
|
533
616
|
text = re.sub(r"`([^`]+)`", extract_code, text)
|
|
534
617
|
|
|
618
|
+
# Links next: the URL must never be touched by emphasis rules, and the
|
|
619
|
+
# link's own escape codes contain "[" which would confuse this pattern
|
|
620
|
+
# if it ran later.
|
|
621
|
+
def extract_link(m: re.Match[str]) -> str:
|
|
622
|
+
label = self._render_inline(
|
|
623
|
+
m.group(1), parent_styles + [Ansi.UNDERLINE, Ansi.FG_BLUE]
|
|
624
|
+
)
|
|
625
|
+
return stash(
|
|
626
|
+
f"{Ansi.UNDERLINE}{Ansi.FG_BLUE}{label}"
|
|
627
|
+
f"{Ansi.UNDERLINE_OFF}{Ansi.FG_DEFAULT}{restore}"
|
|
628
|
+
f"{self._muted_fg} ({m.group(2)}){Ansi.FG_DEFAULT}{restore}"
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
text = re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", extract_link, text)
|
|
632
|
+
|
|
535
633
|
# Bold + italic (must come before bold and italic)
|
|
536
634
|
text = re.sub(
|
|
537
|
-
r"\*\*\*(.+?)\*\*\*",
|
|
538
|
-
lambda m:
|
|
635
|
+
r"\*\*\*(?!\s)(.+?)(?<!\s)\*\*\*",
|
|
636
|
+
lambda m: (
|
|
637
|
+
f"{Ansi.BOLD}{Ansi.ITALIC}{m.group(1)}{Ansi.ITALIC_OFF}{Ansi.BOLD_OFF}"
|
|
638
|
+
),
|
|
539
639
|
text,
|
|
540
640
|
)
|
|
541
641
|
|
|
542
642
|
# Bold
|
|
543
643
|
text = re.sub(
|
|
544
|
-
r"\*\*(.+?)\*\*",
|
|
644
|
+
r"\*\*(?!\s)(.+?)(?<!\s)\*\*",
|
|
545
645
|
lambda m: f"{Ansi.BOLD}{m.group(1)}{Ansi.BOLD_OFF}",
|
|
546
646
|
text,
|
|
547
647
|
)
|
|
548
648
|
text = re.sub(
|
|
549
|
-
r"__(.+?)__",
|
|
649
|
+
r"__(?!\s)(.+?)(?<!\s)__",
|
|
550
650
|
lambda m: f"{Ansi.BOLD}{m.group(1)}{Ansi.BOLD_OFF}",
|
|
551
651
|
text,
|
|
552
652
|
)
|
|
553
653
|
|
|
554
|
-
# Italic with asterisks
|
|
654
|
+
# Italic with asterisks; delimiters must hug the text, so "2 * 3 * 4"
|
|
655
|
+
# is left alone
|
|
555
656
|
text = re.sub(
|
|
556
|
-
r"(?<!\*)\*(
|
|
657
|
+
r"(?<!\*)\*(?![\s*])(.+?)(?<![\s*])\*(?!\*)",
|
|
557
658
|
lambda m: f"{Ansi.ITALIC}{m.group(1)}{Ansi.ITALIC_OFF}",
|
|
558
659
|
text,
|
|
559
660
|
)
|
|
560
661
|
|
|
561
662
|
# Italic with underscores (only at word boundaries)
|
|
562
663
|
text = re.sub(
|
|
563
|
-
r"(?<!\w)_(?!
|
|
664
|
+
r"(?<!\w)_(?![\s_])(.+?)(?<![\s_])_(?!\w)",
|
|
564
665
|
lambda m: f"{Ansi.ITALIC}{m.group(1)}{Ansi.ITALIC_OFF}",
|
|
565
666
|
text,
|
|
566
667
|
)
|
|
567
668
|
|
|
568
669
|
# Strikethrough
|
|
569
670
|
text = re.sub(
|
|
570
|
-
r"~~(.+?)~~",
|
|
671
|
+
r"~~(?!\s)(.+?)(?<!\s)~~",
|
|
571
672
|
lambda m: f"{Ansi.STRIKETHROUGH}{m.group(1)}{Ansi.STRIKETHROUGH_OFF}",
|
|
572
673
|
text,
|
|
573
674
|
)
|
|
574
675
|
|
|
575
|
-
#
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
lambda m: f"{Ansi.UNDERLINE}{Ansi.FG_BLUE}{m.group(1)}{Ansi.UNDERLINE_OFF}{Ansi.FG_DEFAULT}{Ansi.DIM} ({m.group(2)}){Ansi.DIM_OFF}{restore}",
|
|
579
|
-
text,
|
|
580
|
-
)
|
|
581
|
-
|
|
582
|
-
# Restore code spans
|
|
583
|
-
for i, code in enumerate(code_spans):
|
|
584
|
-
text = text.replace(f"\x00CODE{i}\x00", code)
|
|
676
|
+
# Restore stashed fragments
|
|
677
|
+
for i, fragment in enumerate(fragments):
|
|
678
|
+
text = text.replace(f"\x00{i}\x00", fragment)
|
|
585
679
|
|
|
586
680
|
return text
|
|
587
681
|
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
"""Tests for mdsyntax."""
|
|
2
2
|
|
|
3
|
+
import io
|
|
3
4
|
import re
|
|
4
5
|
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
5
8
|
from mdsyntax import (
|
|
6
9
|
LANG_ALIASES,
|
|
7
10
|
Ansi,
|
|
@@ -11,6 +14,8 @@ from mdsyntax import (
|
|
|
11
14
|
md_render,
|
|
12
15
|
style,
|
|
13
16
|
)
|
|
17
|
+
from mdsyntax.cli import main as cli_main
|
|
18
|
+
from mdsyntax.renderer import _rgb_to_256, _visible_len
|
|
14
19
|
|
|
15
20
|
|
|
16
21
|
def strip_ansi(text: str) -> str:
|
|
@@ -142,8 +147,8 @@ class TestBlockFormatting:
|
|
|
142
147
|
# The long line should be fully present (not truncated)
|
|
143
148
|
assert long_line in plain
|
|
144
149
|
# All lines should be padded to the same width (the longest line's width)
|
|
145
|
-
lines = [
|
|
146
|
-
widths = [len(
|
|
150
|
+
lines = [line for line in plain.split("\n") if line.strip()]
|
|
151
|
+
widths = [len(line) for line in lines]
|
|
147
152
|
assert all(w == widths[0] for w in widths), "All lines should have same width"
|
|
148
153
|
assert widths[0] >= 100, "Width should expand to fit content"
|
|
149
154
|
|
|
@@ -293,3 +298,181 @@ class TestStyleAPI:
|
|
|
293
298
|
def test_empty_style(self):
|
|
294
299
|
s = Style("plain")
|
|
295
300
|
assert str(s) == "plain"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class TestLinksAndEmphasis:
|
|
304
|
+
"""Regression tests: links must be isolated from emphasis processing."""
|
|
305
|
+
|
|
306
|
+
@pytest.mark.parametrize(
|
|
307
|
+
"src",
|
|
308
|
+
[
|
|
309
|
+
"**bold** then [x](y)",
|
|
310
|
+
"*i* then [x](y)",
|
|
311
|
+
"~~s~~ [x](y)",
|
|
312
|
+
"**see [x](y) now**",
|
|
313
|
+
],
|
|
314
|
+
)
|
|
315
|
+
def test_style_before_link_does_not_corrupt_escapes(self, src):
|
|
316
|
+
result = md_render(src)
|
|
317
|
+
plain = strip_ansi(result)
|
|
318
|
+
# No stray ESC bytes may survive stripping well-formed sequences
|
|
319
|
+
assert "\x1b" not in plain
|
|
320
|
+
assert "x (y)" in plain
|
|
321
|
+
assert "[" not in plain and "]" not in plain
|
|
322
|
+
|
|
323
|
+
def test_link_inside_bold_keeps_bold_after(self):
|
|
324
|
+
result = md_render("**see [x](y) now**")
|
|
325
|
+
# bold is switched on before "see" and only switched off after "now"
|
|
326
|
+
assert result.index(Ansi.BOLD) < result.index("see")
|
|
327
|
+
assert result.rindex(Ansi.BOLD_OFF) > result.index("now")
|
|
328
|
+
|
|
329
|
+
def test_dunder_in_url_preserved(self):
|
|
330
|
+
url = "https://docs.python.org/3/reference/datamodel.html#object.__init__"
|
|
331
|
+
result = md_render(f"[init]({url})")
|
|
332
|
+
assert url in strip_ansi(result)
|
|
333
|
+
|
|
334
|
+
def test_asterisks_in_url_preserved(self):
|
|
335
|
+
url = "https://example.com/a*b*c"
|
|
336
|
+
result = md_render(f"[x]({url})")
|
|
337
|
+
assert url in strip_ansi(result)
|
|
338
|
+
|
|
339
|
+
def test_emphasis_inside_link_text(self):
|
|
340
|
+
result = md_render("[**bold** link](u)")
|
|
341
|
+
assert "bold link (u)" in strip_ansi(result)
|
|
342
|
+
assert Ansi.BOLD in result
|
|
343
|
+
|
|
344
|
+
def test_spaced_asterisks_are_not_italic(self):
|
|
345
|
+
result = md_render("2 * 3 * 4 = 24")
|
|
346
|
+
assert strip_ansi(result) == "2 * 3 * 4 = 24"
|
|
347
|
+
assert Ansi.ITALIC not in result
|
|
348
|
+
|
|
349
|
+
def test_header_color_restored_after_link(self):
|
|
350
|
+
result = md_render("# Title [l](u) more")
|
|
351
|
+
# the header color must be re-emitted before the trailing text
|
|
352
|
+
tail = result[result.index("(u)") :]
|
|
353
|
+
assert Ansi.FG_CYAN in tail
|
|
354
|
+
assert "more" in tail
|
|
355
|
+
|
|
356
|
+
def test_blockquote_color_restored_after_code(self):
|
|
357
|
+
renderer = MarkdownRenderer(true_color=True)
|
|
358
|
+
result = renderer.render("> a `c` b")
|
|
359
|
+
tail = result[result.index(" c ") :]
|
|
360
|
+
assert Ansi.rgb_fg(180, 180, 180) in tail
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class TestCodeBlockRendering:
|
|
364
|
+
@pytest.mark.parametrize(
|
|
365
|
+
"code_style", ["default", "nord", "github-dark", "dracula"]
|
|
366
|
+
)
|
|
367
|
+
def test_background_survives_pygments_reset(self, code_style):
|
|
368
|
+
"""Pygments emits SGR 0 after bold tokens; bg must be re-applied."""
|
|
369
|
+
renderer = MarkdownRenderer(
|
|
370
|
+
code_style=code_style, true_color=True, code_width=40
|
|
371
|
+
)
|
|
372
|
+
result = renderer.render("```python\ndef f():\n return 1\n```")
|
|
373
|
+
bg = renderer._code_bg
|
|
374
|
+
for line in result.split("\n"):
|
|
375
|
+
# every reset that is not the final one must be followed by bg
|
|
376
|
+
body = line[: -len(Ansi.RESET)]
|
|
377
|
+
for m in re.finditer(r"\x1b\[[0-9;]*\b0?0m", body):
|
|
378
|
+
assert body.startswith(bg, m.end()), (code_style, line)
|
|
379
|
+
|
|
380
|
+
def test_256_color_mode_emits_no_24bit_escapes(self):
|
|
381
|
+
renderer = MarkdownRenderer(true_color=False, code_width=20)
|
|
382
|
+
result = renderer.render("```python\nx = 1\n```\n> quote\n[l](u)")
|
|
383
|
+
assert "38;2;" not in result
|
|
384
|
+
assert "48;2;" not in result
|
|
385
|
+
|
|
386
|
+
def test_true_color_mode_uses_style_background(self):
|
|
387
|
+
renderer = MarkdownRenderer(
|
|
388
|
+
code_style="monokai", true_color=True, code_width=20
|
|
389
|
+
)
|
|
390
|
+
result = renderer.render("```\nx\n```")
|
|
391
|
+
assert Ansi.rgb_bg(39, 40, 34) in result # monokai #272822
|
|
392
|
+
|
|
393
|
+
def test_tabs_expanded(self):
|
|
394
|
+
result = md_render("```\n\tx\n```", code_width=10)
|
|
395
|
+
plain = strip_ansi(result)
|
|
396
|
+
assert "\t" not in plain
|
|
397
|
+
assert " x" in plain
|
|
398
|
+
|
|
399
|
+
def test_wide_chars_padded_by_column_width(self):
|
|
400
|
+
result = md_render("```\n漢字\n```", code_width=10)
|
|
401
|
+
for line in strip_ansi(result).split("\n"):
|
|
402
|
+
if line:
|
|
403
|
+
assert _visible_len(line) == 10
|
|
404
|
+
|
|
405
|
+
def test_invalid_style_raises_value_error(self):
|
|
406
|
+
with pytest.raises(ValueError, match="Unknown syntax highlighting style"):
|
|
407
|
+
MarkdownRenderer(code_style="no-such-style")
|
|
408
|
+
with pytest.raises(ValueError):
|
|
409
|
+
SyntaxHighlighter(style="no-such-style")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class TestWidthHelpers:
|
|
413
|
+
def test_visible_len_ignores_ansi(self):
|
|
414
|
+
assert _visible_len(f"{Ansi.BOLD}abc{Ansi.RESET}") == 3
|
|
415
|
+
|
|
416
|
+
def test_visible_len_wide_and_combining(self):
|
|
417
|
+
assert _visible_len("漢字") == 4
|
|
418
|
+
assert _visible_len("e\u0301") == 1 # e + combining acute
|
|
419
|
+
|
|
420
|
+
def test_rgb_to_256_grays_and_cube(self):
|
|
421
|
+
assert _rgb_to_256(0, 0, 0) == 16
|
|
422
|
+
assert _rgb_to_256(255, 255, 255) == 231
|
|
423
|
+
assert 232 <= _rgb_to_256(128, 128, 128) <= 255
|
|
424
|
+
assert _rgb_to_256(255, 0, 0) == 196
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class TestCLI:
|
|
428
|
+
def test_renders_utf8_file(self, tmp_path, capsys):
|
|
429
|
+
md = tmp_path / "doc.md"
|
|
430
|
+
md.write_bytes("# Café — test 🚀\n".encode())
|
|
431
|
+
assert cli_main([str(md)]) == 0
|
|
432
|
+
out = strip_ansi(capsys.readouterr().out)
|
|
433
|
+
assert "Café — test 🚀" in out
|
|
434
|
+
|
|
435
|
+
def test_reads_stdin(self, monkeypatch, capsys):
|
|
436
|
+
monkeypatch.setattr("sys.stdin", io.StringIO("**hi**\n"))
|
|
437
|
+
assert cli_main([]) == 0
|
|
438
|
+
assert "hi" in strip_ansi(capsys.readouterr().out)
|
|
439
|
+
|
|
440
|
+
def test_list_styles(self, capsys):
|
|
441
|
+
assert cli_main(["--list-styles"]) == 0
|
|
442
|
+
assert "monokai" in capsys.readouterr().out
|
|
443
|
+
|
|
444
|
+
def test_invalid_style_is_clean_error(self, tmp_path, capsys):
|
|
445
|
+
md = tmp_path / "doc.md"
|
|
446
|
+
md.write_text("x\n", encoding="utf-8")
|
|
447
|
+
assert cli_main(["--style", "nope", str(md)]) == 2
|
|
448
|
+
captured = capsys.readouterr()
|
|
449
|
+
assert "Unknown syntax highlighting style" in captured.err
|
|
450
|
+
assert "Traceback" not in captured.err
|
|
451
|
+
|
|
452
|
+
def test_version(self, capsys):
|
|
453
|
+
with pytest.raises(SystemExit) as exc:
|
|
454
|
+
cli_main(["--version"])
|
|
455
|
+
assert exc.value.code == 0
|
|
456
|
+
assert "mdsyntax" in capsys.readouterr().out
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
class TestFences:
|
|
460
|
+
def test_longer_fence_can_contain_shorter_fence(self):
|
|
461
|
+
src = "````markdown\nouter\n```python\ninner\n```\nstill code\n````\nafter"
|
|
462
|
+
result = md_render(src, code_width=20)
|
|
463
|
+
plain = strip_ansi(result)
|
|
464
|
+
lines = [line.rstrip() for line in plain.split("\n")]
|
|
465
|
+
assert lines[0].strip() == "markdown" # label, no stray backtick
|
|
466
|
+
assert "```python" in plain and "inner" in plain and "still code" in plain
|
|
467
|
+
assert lines[-1] == "after" # rendered outside the block
|
|
468
|
+
|
|
469
|
+
def test_tilde_fence(self):
|
|
470
|
+
result = md_render("~~~python\nx = 1\n~~~", code_width=20)
|
|
471
|
+
plain = strip_ansi(result)
|
|
472
|
+
assert "x = 1" in plain
|
|
473
|
+
assert "~~~" not in plain
|
|
474
|
+
|
|
475
|
+
def test_shorter_fence_does_not_close(self):
|
|
476
|
+
result = md_render("````\n```\nx\n````", code_width=10)
|
|
477
|
+
plain = strip_ansi(result)
|
|
478
|
+
assert "```" in plain and "x" in plain
|
mdsyntax-0.2.0/CHANGELOG.md
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
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.2.0] - 2025-XX-XX
|
|
11
|
-
|
|
12
|
-
### Added
|
|
13
|
-
|
|
14
|
-
- New `Style` class for styling arbitrary text
|
|
15
|
-
- Static methods: `Style.bold_text()`, `Style.italic_text()`, `Style.color()`, etc.
|
|
16
|
-
- Chainable API: `style("text").bold().italic().fg("red")`
|
|
17
|
-
- RGB color support via `Style.color(text, fg=(255, 0, 0))`
|
|
18
|
-
- `Ansi` class now exported for direct access to ANSI codes
|
|
19
|
-
|
|
20
|
-
### Changed
|
|
21
|
-
|
|
22
|
-
- Switched from `colorama.init(autoreset=True)` to `colorama.just_fix_windows_console()` for better compatibility
|
|
23
|
-
- Code blocks now expand width to fit long lines instead of truncating
|
|
24
|
-
- Use targeted ANSI resets instead of full reset for better style preservation
|
|
25
|
-
|
|
26
|
-
### Fixed
|
|
27
|
-
|
|
28
|
-
- Inline code in blockquotes no longer breaks italic styling for subsequent text
|
|
29
|
-
- Code block width now properly constrains content
|
|
30
|
-
|
|
31
|
-
## [0.1.0] - 2025-01-XX
|
|
32
|
-
|
|
33
|
-
### Added
|
|
34
|
-
|
|
35
|
-
- Initial release
|
|
36
|
-
- Markdown rendering with ANSI formatting
|
|
37
|
-
- Syntax highlighting for code blocks (powered by Pygments)
|
|
38
|
-
- Support for headers, bold, italic, strikethrough, inline code
|
|
39
|
-
- Support for lists (ordered, unordered, task lists)
|
|
40
|
-
- Support for blockquotes and horizontal rules
|
|
41
|
-
- Support for links
|
|
42
|
-
- CLI tool (`mdsyntax`)
|
|
43
|
-
- Auto-detection of 24-bit true color support
|
|
44
|
-
- Configurable code block styling and width
|
|
File without changes
|
|
File without changes
|
|
File without changes
|