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.
@@ -46,7 +46,9 @@ jobs:
46
46
  pip install ruff
47
47
 
48
48
  - name: Run ruff
49
- run: ruff check src/
49
+ run: |
50
+ ruff check src/ tests/
51
+ ruff format --check src/ tests/
50
52
 
51
53
  build:
52
54
  runs-on: ubuntu-latest
@@ -54,3 +54,6 @@ Thumbs.db
54
54
  # Local development
55
55
  *.local
56
56
  .env
57
+
58
+ # Claude Code local settings
59
+ .claude/
@@ -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/yourusername/mdsyntax.git
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.4
1
+ Metadata-Version: 2.5
2
2
  Name: mdsyntax
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Render markdown with syntax highlighting in the terminal
5
- Project-URL: Homepage, https://github.com/yourusername/mdsyntax
6
- Project-URL: Repository, https://github.com/yourusername/mdsyntax
7
- Project-URL: Issues, https://github.com/yourusername/mdsyntax/issues
8
- Author-email: Your Name <you@example.com>
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
- ```python
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
- ```python
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
- version = "0.2.0"
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 = "Your Name", email = "you@example.com" }
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/yourusername/mdsyntax"
53
- Repository = "https://github.com/yourusername/mdsyntax"
54
- Issues = "https://github.com/yourusername/mdsyntax/issues"
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
 
@@ -23,7 +23,7 @@ from mdsyntax.renderer import (
23
23
  style,
24
24
  )
25
25
 
26
- __version__ = "0.2.0"
26
+ __version__ = "0.3.0"
27
27
  __all__ = [
28
28
  "md_print",
29
29
  "md_render",
@@ -1,16 +1,34 @@
1
- """Command-line interface for md-print."""
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
- md_print(
72
- text,
73
- code_style=args.style,
74
- code_width=args.width,
75
- true_color=true_color,
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
- def _get_style_bg(style_name: str) -> str:
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
- r, g, b = int(bg[1:3], 16), int(bg[3:5], 16), int(bg[5:7], 16)
264
- return f"\033[48;2;{r};{g};{b}m"
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 _visible_len(s: str) -> int:
271
- """Length of string excluding ANSI escape sequences."""
272
- return len(re.sub(r"\033\[[0-9;]*m", "", s))
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 _truncate_to_width(text: str, width: int, indicator: str = "…") -> str:
276
- """Truncate string to visible width, accounting for ANSI codes."""
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
- # We need to truncate while preserving ANSI codes
281
- result = []
282
- visible_count = 0
283
- target = width - len(indicator)
284
- i = 0
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
- return "".join(result) + indicator
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
- self.formatter = formatter_cls(style=style)
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
- self._code_bg = _get_style_bg(self.code_style)
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
- # Code block
401
- if stripped.startswith("```"):
402
- lang = stripped[3:].strip()
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].strip().startswith("```"):
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 = highlighted.split("\n")
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
- rendered = self._render_inline(content, parent_styles=[Ansi.ITALIC])
478
- grey = Ansi.rgb_fg(180, 180, 180)
479
- return f"{grey}│ {Ansi.ITALIC}{rendered}{Ansi.ITALIC_OFF}{Ansi.RESET}"
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
- # Order matters: process from most specific to least specific
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
- # Inline code first (protects contents from further processing)
525
- code_spans: list[str] = []
605
+ def stash(rendered: str) -> str:
606
+ fragments.append(rendered)
607
+ return f"\x00{len(fragments) - 1}\x00"
526
608
 
527
- def extract_code(m):
528
- # Use targeted reset: just reset bg/fg, then restore parent styles
529
- code_content = f"{Ansi.BG_BLACK}{Ansi.FG_YELLOW} {m.group(1)} {Ansi.BG_DEFAULT}{Ansi.FG_DEFAULT}{restore}"
530
- code_spans.append(code_content)
531
- return f"\x00CODE{len(code_spans) - 1}\x00"
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: f"{Ansi.BOLD}{Ansi.ITALIC}{m.group(1)}{Ansi.ITALIC_OFF}{Ansi.BOLD_OFF}",
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 (works anywhere)
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)_(?!_)(.+?)(?<!_)_(?!\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
- # Links - use targeted resets
576
- text = re.sub(
577
- r"\[([^\]]+)\]\(([^)]+)\)",
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 = [l for l in plain.split("\n") if l.strip()]
146
- widths = [len(l) for l in lines]
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
@@ -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