markrender 1.1.1__tar.gz → 1.2.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.
Files changed (49) hide show
  1. markrender-1.2.0/CHANGELOG.md +46 -0
  2. {markrender-1.1.1/markrender.egg-info → markrender-1.2.0}/PKG-INFO +14 -2
  3. {markrender-1.1.1 → markrender-1.2.0}/README.md +13 -1
  4. {markrender-1.1.1 → markrender-1.2.0}/markrender/_version.py +2 -2
  5. {markrender-1.1.1 → markrender-1.2.0}/markrender/formatters.py +57 -25
  6. {markrender-1.1.1 → markrender-1.2.0}/markrender/parser.py +16 -0
  7. {markrender-1.1.1 → markrender-1.2.0}/markrender/renderer.py +18 -5
  8. {markrender-1.1.1 → markrender-1.2.0/markrender.egg-info}/PKG-INFO +14 -2
  9. {markrender-1.1.1 → markrender-1.2.0}/markrender.egg-info/SOURCES.txt +2 -0
  10. {markrender-1.1.1 → markrender-1.2.0}/markrender.egg-info/scm_file_list.json +3 -0
  11. markrender-1.2.0/tests/test_changelog.py +150 -0
  12. {markrender-1.1.1 → markrender-1.2.0}/tests/test_formatters.py +15 -0
  13. {markrender-1.1.1 → markrender-1.2.0}/tests/test_parser.py +18 -0
  14. {markrender-1.1.1 → markrender-1.2.0}/tests/test_renderer.py +59 -0
  15. {markrender-1.1.1 → markrender-1.2.0}/tests/test_versioning.py +29 -0
  16. {markrender-1.1.1 → markrender-1.2.0}/.gitignore +0 -0
  17. {markrender-1.1.1 → markrender-1.2.0}/LICENSE +0 -0
  18. {markrender-1.1.1 → markrender-1.2.0}/MANIFEST.in +0 -0
  19. {markrender-1.1.1 → markrender-1.2.0}/check_truecolor.py +0 -0
  20. {markrender-1.1.1 → markrender-1.2.0}/examples/basic_usage.py +0 -0
  21. {markrender-1.1.1 → markrender-1.2.0}/examples/complete_showcase.py +0 -0
  22. {markrender-1.1.1 → markrender-1.2.0}/examples/definition_lists.py +0 -0
  23. {markrender-1.1.1 → markrender-1.2.0}/examples/footnotes.py +0 -0
  24. {markrender-1.1.1 → markrender-1.2.0}/examples/highlights.py +0 -0
  25. {markrender-1.1.1 → markrender-1.2.0}/examples/link_highlighting.py +0 -0
  26. {markrender-1.1.1 → markrender-1.2.0}/examples/mermaid_diagrams.py +0 -0
  27. {markrender-1.1.1 → markrender-1.2.0}/examples/new_features_demo.py +0 -0
  28. {markrender-1.1.1 → markrender-1.2.0}/examples/progress_bars.py +0 -0
  29. {markrender-1.1.1 → markrender-1.2.0}/examples/streaming_demo.py +0 -0
  30. {markrender-1.1.1 → markrender-1.2.0}/examples/theme_showcase.py +0 -0
  31. {markrender-1.1.1 → markrender-1.2.0}/examples/v1_0_7_showcase.py +0 -0
  32. {markrender-1.1.1 → markrender-1.2.0}/markrender/__init__.py +0 -0
  33. {markrender-1.1.1 → markrender-1.2.0}/markrender/__main__.py +0 -0
  34. {markrender-1.1.1 → markrender-1.2.0}/markrender/cli.py +0 -0
  35. {markrender-1.1.1 → markrender-1.2.0}/markrender/colors.py +0 -0
  36. {markrender-1.1.1 → markrender-1.2.0}/markrender/config.py +0 -0
  37. {markrender-1.1.1 → markrender-1.2.0}/markrender/themes.py +0 -0
  38. {markrender-1.1.1 → markrender-1.2.0}/markrender.egg-info/dependency_links.txt +0 -0
  39. {markrender-1.1.1 → markrender-1.2.0}/markrender.egg-info/entry_points.txt +0 -0
  40. {markrender-1.1.1 → markrender-1.2.0}/markrender.egg-info/requires.txt +0 -0
  41. {markrender-1.1.1 → markrender-1.2.0}/markrender.egg-info/top_level.txt +0 -0
  42. {markrender-1.1.1 → markrender-1.2.0}/pyproject.toml +0 -0
  43. {markrender-1.1.1 → markrender-1.2.0}/requirements.txt +0 -0
  44. {markrender-1.1.1 → markrender-1.2.0}/setup.cfg +0 -0
  45. {markrender-1.1.1 → markrender-1.2.0}/setup.py +0 -0
  46. {markrender-1.1.1 → markrender-1.2.0}/test.md +0 -0
  47. {markrender-1.1.1 → markrender-1.2.0}/tests/test_colors.py +0 -0
  48. {markrender-1.1.1 → markrender-1.2.0}/tests/test_dim_mode.py +0 -0
  49. {markrender-1.1.1 → markrender-1.2.0}/tests/test_regressions.py +0 -0
@@ -0,0 +1,46 @@
1
+ # Changelog
2
+
3
+ All notable changes to MarkRender are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and the project uses [Semantic Versioning](https://semver.org/). Releases are
7
+ cut from conventional commits, and new entries are added by the publish
8
+ workflow when a release goes out.
9
+
10
+ ## [1.1.1] - 2026-10-30
11
+
12
+ ### Fixed
13
+
14
+ - Dim mode now renders the whole response in one uniform muted gray, including paragraphs, lists, tables, quotes, and code blocks.
15
+
16
+ ### Documentation
17
+
18
+ - Clarified the CLI and Python usage examples.
19
+
20
+ ## [1.1.0] - 2026-10-30
21
+
22
+ ### Added
23
+
24
+ - Dim mode for streaming responses, enabled per render call.
25
+ - Theme previews in the README and a `preview-themes` command.
26
+ - `--width`, `--force-color`, and `--version` CLI flags.
27
+ - TOML config file support with warnings for unknown keys.
28
+ - Dynamic code block line-number widths.
29
+ - Better handling of split stream chunks and buffer boundaries.
30
+
31
+ ### Changed
32
+
33
+ - Rewrote the README around CLI and Python usage instead of feature marketing.
34
+ - Dim mode moved from the constructor to a `render()` argument.
35
+ - Themes are now deep-copied so rendering cannot mutate shared theme state.
36
+ - Improved the parser regexes for bold + italic, inline code, links, and blockquotes.
37
+
38
+ ### Fixed
39
+
40
+ - Blockquotes falling through to other block handlers.
41
+ - Dictionary themes loaded from TOML files failing to render.
42
+ - Table overflow when content is wider than the terminal.
43
+ - Encoding fallback when the output stream is not UTF-8.
44
+ - Config files now warn about unknown keys instead of failing silently.
45
+
46
+ Releases before 1.1.0 were published before this changelog existed.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markrender
3
- Version: 1.1.1
3
+ Version: 1.2.0
4
4
  Summary: Professional terminal markdown renderer for streaming LLM responses
5
5
  Author: Praneeth Gandodi
6
6
  License: MIT
@@ -422,6 +422,11 @@ Hello :wave: Let's :rocket: go!
422
422
  - **Note-Taking Apps** - Terminal-based markdown note viewers
423
423
  - **Log Viewers** - Render structured logs with markdown
424
424
 
425
+ ## Changelog
426
+
427
+ Release notes come from conventional commit messages, so the history stays in
428
+ one place. See [CHANGELOG.md](CHANGELOG.md) for what changed in each version.
429
+
425
430
  ## Development
426
431
 
427
432
  ### Setup Development Environment
@@ -458,12 +463,19 @@ no version number to edit by hand.
458
463
  2. Choose `auto` (recommended) or an explicit `patch`, `minor`, or `major` bump.
459
464
  3. The workflow runs the tests, creates the next `vX.Y.Z` tag, builds the
460
465
  sdist and wheel, publishes to PyPI, and creates a GitHub release.
466
+ 4. Release notes are generated from the commits since the last tag, and the
467
+ same entry is added to [CHANGELOG.md](CHANGELOG.md) on `main`.
461
468
 
462
469
  With `auto`, commit messages since the last tag decide the bump:
463
470
 
464
471
  - `feat!:` or a `BREAKING CHANGE:` footer → major
465
472
  - `feat:` → minor
466
- - everything else → patch
473
+ - `fix:`, `perf:`, `refactor:`, or `revert:` → patch
474
+ - `docs:`, `test:`, `chore:`, `ci:`, `build:`, and `style:` don't trigger a
475
+ release on their own
476
+
477
+ Write commit subjects in the `type(scope): summary` form so the changelog and
478
+ version bump stay accurate.
467
479
 
468
480
  Publishing uses PyPI Trusted Publishing (OIDC), so no API token is stored in
469
481
  the repository. Configure it once:
@@ -390,6 +390,11 @@ Hello :wave: Let's :rocket: go!
390
390
  - **Note-Taking Apps** - Terminal-based markdown note viewers
391
391
  - **Log Viewers** - Render structured logs with markdown
392
392
 
393
+ ## Changelog
394
+
395
+ Release notes come from conventional commit messages, so the history stays in
396
+ one place. See [CHANGELOG.md](CHANGELOG.md) for what changed in each version.
397
+
393
398
  ## Development
394
399
 
395
400
  ### Setup Development Environment
@@ -426,12 +431,19 @@ no version number to edit by hand.
426
431
  2. Choose `auto` (recommended) or an explicit `patch`, `minor`, or `major` bump.
427
432
  3. The workflow runs the tests, creates the next `vX.Y.Z` tag, builds the
428
433
  sdist and wheel, publishes to PyPI, and creates a GitHub release.
434
+ 4. Release notes are generated from the commits since the last tag, and the
435
+ same entry is added to [CHANGELOG.md](CHANGELOG.md) on `main`.
429
436
 
430
437
  With `auto`, commit messages since the last tag decide the bump:
431
438
 
432
439
  - `feat!:` or a `BREAKING CHANGE:` footer → major
433
440
  - `feat:` → minor
434
- - everything else → patch
441
+ - `fix:`, `perf:`, `refactor:`, or `revert:` → patch
442
+ - `docs:`, `test:`, `chore:`, `ci:`, `build:`, and `style:` don't trigger a
443
+ release on their own
444
+
445
+ Write commit subjects in the `type(scope): summary` form so the changelog and
446
+ version bump stay accurate.
435
447
 
436
448
  Publishing uses PyPI Trusted Publishing (OIDC), so no API token is stored in
437
449
  the repository. Configure it once:
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.1.1'
22
- __version_tuple__ = version_tuple = (1, 1, 1)
21
+ __version__ = version = '1.2.0'
22
+ __version_tuple__ = version_tuple = (1, 2, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -3,7 +3,6 @@ Output formatters for markdown elements
3
3
  Handles rendering of headings, code, tables, lists, and more
4
4
  """
5
5
 
6
- import re
7
6
  from pygments import highlight
8
7
  from pygments.lexers import get_lexer_by_name, TextLexer
9
8
  from pygments.formatters import Terminal256Formatter
@@ -12,7 +11,7 @@ try:
12
11
  except ImportError:
13
12
  emoji_lib = None
14
13
 
15
- from .colors import colorize, Colors, get_terminal_width
14
+ from .colors import colorize, strip_ansi, Colors, get_terminal_width
16
15
 
17
16
 
18
17
  class MarkdownFormatter:
@@ -135,7 +134,31 @@ class MarkdownFormatter:
135
134
  """
136
135
  return self._colorize(f'`{text}`', self.inline_code_color)
137
136
 
138
- def format_table(self, rows):
137
+ @staticmethod
138
+ def _visible_width(text):
139
+ """Return the printable width of text, ignoring ANSI escapes."""
140
+ return len(strip_ansi(str(text)))
141
+
142
+ @staticmethod
143
+ def _fit_table_widths(widths, available):
144
+ """Keep short columns intact and share the loss across long ones."""
145
+ widths = list(widths)
146
+ if sum(widths) <= available:
147
+ return widths
148
+
149
+ fitted = [0] * len(widths)
150
+ remaining = available
151
+ remaining_cols = len(widths)
152
+
153
+ for index in sorted(range(len(widths)), key=widths.__getitem__):
154
+ share = max(1, remaining // remaining_cols)
155
+ fitted[index] = min(widths[index], share)
156
+ remaining -= fitted[index]
157
+ remaining_cols -= 1
158
+
159
+ return fitted
160
+
161
+ def format_table(self, rows, alignments=None):
139
162
  if not rows:
140
163
  return ''
141
164
 
@@ -149,21 +172,15 @@ class MarkdownFormatter:
149
172
  col_widths = [0] * max_cols
150
173
  for row in normalized_rows:
151
174
  for i, cell in enumerate(row):
152
- clean_cell = re.sub(r'\033\[[0-9;]*m', '', str(cell))
153
- col_widths[i] = max(col_widths[i], len(clean_cell))
175
+ col_widths[i] = max(col_widths[i], self._visible_width(cell))
154
176
 
155
177
  total_border_chars = max_cols * 3 + 1
156
- total_table_width = sum(col_widths) + total_border_chars
157
- max_width = self.width - 2
178
+ available = self.width - 2 - total_border_chars
179
+ available = max(available, max_cols)
180
+ col_widths = self._fit_table_widths(col_widths, available)
158
181
 
159
- if total_table_width > max_width:
160
- available = max_width - total_border_chars
161
- widths_sum = sum(col_widths)
162
- if available < max_cols:
163
- col_widths = [max(1, available // max_cols)] * max_cols
164
- else:
165
- for i in range(max_cols):
166
- col_widths[i] = max(1, col_widths[i] * available // widths_sum)
182
+ alignments = list(alignments or [])
183
+ alignments += ['left'] * (max_cols - len(alignments))
167
184
 
168
185
  border_color = self.theme['table_border']
169
186
  header_color = self.theme.get('table_header')
@@ -172,18 +189,33 @@ class MarkdownFormatter:
172
189
  for row_idx, row in enumerate(normalized_rows):
173
190
  padded = []
174
191
  for i, cell in enumerate(row):
175
- clean_cell = re.sub(r'\033\[[0-9;]*m', '', str(cell))
176
- if len(clean_cell) > col_widths[i]:
177
- cell_str = clean_cell[:col_widths[i] - 1] + '…'
178
- if row_idx == 0 and header_color:
179
- cell_str = self._colorize(cell_str, header_color + Colors.BOLD)
192
+ clean_cell = strip_ansi(str(cell))
193
+ width = col_widths[i]
194
+
195
+ if self._visible_width(clean_cell) > width:
196
+ if width > 1:
197
+ cell_str = clean_cell[:width - 1] + '…'
198
+ else:
199
+ cell_str = '…'
180
200
  else:
181
201
  cell_str = str(cell)
182
- padding = col_widths[i] - len(clean_cell)
183
- if padding:
184
- cell_str = cell_str + ' ' * padding
185
- if row_idx == 0 and header_color:
186
- cell_str = self._colorize(cell_str, header_color + Colors.BOLD)
202
+
203
+ padding = width - self._visible_width(cell_str)
204
+ alignment = alignments[i]
205
+ if alignment == 'right':
206
+ cell_str = ' ' * padding + cell_str
207
+ elif alignment == 'center':
208
+ left = padding // 2
209
+ cell_str = (
210
+ ' ' * left + cell_str + ' ' * (padding - left)
211
+ )
212
+ else:
213
+ cell_str = cell_str + ' ' * padding
214
+
215
+ if row_idx == 0 and header_color:
216
+ cell_str = self._colorize(
217
+ cell_str, header_color + Colors.BOLD
218
+ )
187
219
  padded.append(cell_str)
188
220
 
189
221
  cells_formatted = [f' {cell} ' for cell in padded]
@@ -78,6 +78,22 @@ class MarkdownParser:
78
78
  def is_separator_row(cells):
79
79
  return bool(cells and all(re.match(r"^:?-+:?$", cell) for cell in cells))
80
80
 
81
+ @staticmethod
82
+ def parse_table_alignments(cells):
83
+ """Return the alignment for each column from a separator row."""
84
+ alignments = []
85
+ for cell in cells:
86
+ cell = cell.strip()
87
+ left = cell.startswith(':')
88
+ right = cell.endswith(':')
89
+ if left and right:
90
+ alignments.append('center')
91
+ elif right:
92
+ alignments.append('right')
93
+ else:
94
+ alignments.append('left')
95
+ return alignments
96
+
81
97
  def parse_checkbox(self, line):
82
98
  match = self.CHECKBOX_PATTERN.match(line)
83
99
  if match:
@@ -23,6 +23,7 @@ class RenderState:
23
23
  self.in_table = False
24
24
  self.table_rows = []
25
25
  self.table_row_count = 0
26
+ self.table_alignments = []
26
27
  self.in_blockquote = False
27
28
  self.blockquote_lines = []
28
29
  self.alert_type: Optional[str] = None
@@ -38,6 +39,7 @@ class RenderState:
38
39
  self.in_table = False
39
40
  self.table_rows = []
40
41
  self.table_row_count = 0
42
+ self.table_alignments = []
41
43
 
42
44
  def reset_blockquote(self):
43
45
  self.in_blockquote = False
@@ -227,12 +229,16 @@ class MarkdownRenderer:
227
229
  stripped = line.rstrip()
228
230
  table_row = self.parser.parse_table_row(stripped)
229
231
  if table_row is not None:
230
- if not self.parser.is_separator_row(table_row):
232
+ if self.parser.is_separator_row(table_row):
233
+ self.state.table_alignments = self.parser.parse_table_alignments(table_row)
234
+ else:
231
235
  formatted_row = [self.parser.apply_inline_formatting(cell, self.formatter) for cell in table_row]
232
236
  self.state.table_rows.append(formatted_row)
233
237
  else:
234
238
  if self.state.table_rows:
235
- formatted = self.formatter.format_table(self.state.table_rows)
239
+ formatted = self.formatter.format_table(
240
+ self.state.table_rows, self.state.table_alignments
241
+ )
236
242
  self._write(formatted, 'table')
237
243
  self.state.reset_table()
238
244
  self.buffer = self._rebuild_buffer(complete_lines[i:], tail)
@@ -294,12 +300,17 @@ class MarkdownRenderer:
294
300
  if not self.state.in_table:
295
301
  self.state.in_table = True
296
302
  self.state.table_rows = []
297
- if not self.parser.is_separator_row(table_row):
303
+ self.state.table_alignments = []
304
+ if self.parser.is_separator_row(table_row):
305
+ self.state.table_alignments = self.parser.parse_table_alignments(table_row)
306
+ else:
298
307
  formatted_row = [self.parser.apply_inline_formatting(cell, self.formatter) for cell in table_row]
299
308
  self.state.table_rows.append(formatted_row)
300
309
  return
301
310
  elif self.state.in_table:
302
- formatted = self.formatter.format_table(self.state.table_rows)
311
+ formatted = self.formatter.format_table(
312
+ self.state.table_rows, self.state.table_alignments
313
+ )
303
314
  self._write(formatted, 'table')
304
315
  self.state.reset_table()
305
316
 
@@ -383,7 +394,9 @@ class MarkdownRenderer:
383
394
  self.state.reset_code()
384
395
 
385
396
  if self.state.in_table and self.state.table_rows:
386
- formatted = self.formatter.format_table(self.state.table_rows)
397
+ formatted = self.formatter.format_table(
398
+ self.state.table_rows, self.state.table_alignments
399
+ )
387
400
  self._write(formatted, 'table')
388
401
  self.state.reset_table()
389
402
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markrender
3
- Version: 1.1.1
3
+ Version: 1.2.0
4
4
  Summary: Professional terminal markdown renderer for streaming LLM responses
5
5
  Author: Praneeth Gandodi
6
6
  License: MIT
@@ -422,6 +422,11 @@ Hello :wave: Let's :rocket: go!
422
422
  - **Note-Taking Apps** - Terminal-based markdown note viewers
423
423
  - **Log Viewers** - Render structured logs with markdown
424
424
 
425
+ ## Changelog
426
+
427
+ Release notes come from conventional commit messages, so the history stays in
428
+ one place. See [CHANGELOG.md](CHANGELOG.md) for what changed in each version.
429
+
425
430
  ## Development
426
431
 
427
432
  ### Setup Development Environment
@@ -458,12 +463,19 @@ no version number to edit by hand.
458
463
  2. Choose `auto` (recommended) or an explicit `patch`, `minor`, or `major` bump.
459
464
  3. The workflow runs the tests, creates the next `vX.Y.Z` tag, builds the
460
465
  sdist and wheel, publishes to PyPI, and creates a GitHub release.
466
+ 4. Release notes are generated from the commits since the last tag, and the
467
+ same entry is added to [CHANGELOG.md](CHANGELOG.md) on `main`.
461
468
 
462
469
  With `auto`, commit messages since the last tag decide the bump:
463
470
 
464
471
  - `feat!:` or a `BREAKING CHANGE:` footer → major
465
472
  - `feat:` → minor
466
- - everything else → patch
473
+ - `fix:`, `perf:`, `refactor:`, or `revert:` → patch
474
+ - `docs:`, `test:`, `chore:`, `ci:`, `build:`, and `style:` don't trigger a
475
+ release on their own
476
+
477
+ Write commit subjects in the `type(scope): summary` form so the changelog and
478
+ version bump stay accurate.
467
479
 
468
480
  Publishing uses PyPI Trusted Publishing (OIDC), so no API token is stored in
469
481
  the repository. Configure it once:
@@ -1,4 +1,5 @@
1
1
  .gitignore
2
+ CHANGELOG.md
2
3
  LICENSE
3
4
  MANIFEST.in
4
5
  README.md
@@ -36,6 +37,7 @@ markrender.egg-info/entry_points.txt
36
37
  markrender.egg-info/requires.txt
37
38
  markrender.egg-info/scm_file_list.json
38
39
  markrender.egg-info/top_level.txt
40
+ tests/test_changelog.py
39
41
  tests/test_colors.py
40
42
  tests/test_dim_mode.py
41
43
  tests/test_formatters.py
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "files": [
3
+ ".github/scripts/changelog.py",
3
4
  ".github/scripts/next_version.py",
4
5
  ".github/workflows/ci.yml",
5
6
  ".github/workflows/publish.yml",
6
7
  ".gitignore",
8
+ "CHANGELOG.md",
7
9
  "LICENSE",
8
10
  "MANIFEST.in",
9
11
  "README.md",
@@ -33,6 +35,7 @@
33
35
  "requirements.txt",
34
36
  "setup.py",
35
37
  "test.md",
38
+ "tests/test_changelog.py",
36
39
  "tests/test_colors.py",
37
40
  "tests/test_dim_mode.py",
38
41
  "tests/test_formatters.py",
@@ -0,0 +1,150 @@
1
+ """Tests for the changelog helper used by the publish workflow."""
2
+
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ REPO_ROOT = Path(__file__).resolve().parents[1]
10
+ SCRIPT = REPO_ROOT / ".github" / "scripts" / "changelog.py"
11
+
12
+ pytestmark = pytest.mark.skipif(
13
+ not SCRIPT.exists(), reason="release helper is not part of this checkout"
14
+ )
15
+
16
+
17
+ def git(repo, *args):
18
+ subprocess.run(
19
+ ["git", *args],
20
+ cwd=repo,
21
+ check=True,
22
+ capture_output=True,
23
+ text=True,
24
+ )
25
+
26
+
27
+ def commit(repo, message, filename):
28
+ (repo / filename).write_text(filename, encoding="utf-8")
29
+ git(repo, "add", filename)
30
+ git(repo, "commit", "-m", message)
31
+
32
+
33
+ def run_changelog(repo, *args):
34
+ return subprocess.run(
35
+ [sys.executable, str(SCRIPT), *args],
36
+ cwd=repo,
37
+ capture_output=True,
38
+ text=True,
39
+ )
40
+
41
+
42
+ def make_repo(path):
43
+ git(path, "init", "-q")
44
+ git(path, "config", "user.email", "test@example.com")
45
+ git(path, "config", "user.name", "Test")
46
+ commit(path, "feat: initial release", "initial.txt")
47
+ git(path, "tag", "-a", "v1.1.0", "-m", "v1.1.0")
48
+ commit(path, "feat(renderer): add table alignment", "table.txt")
49
+ commit(path, "fix: stop crushing short columns", "fit.txt")
50
+ commit(path, "docs: mention the changelog", "readme.txt")
51
+ commit(path, "chore: tidy up", "chore.txt")
52
+ commit(path, "test: cover narrow tables", "test.txt")
53
+ return path
54
+
55
+
56
+ def test_entry_groups_conventional_commits(tmp_path):
57
+ repo = make_repo(tmp_path)
58
+
59
+ result = run_changelog(
60
+ repo, "--version", "1.1.1", "--tag", "v1.1.1", "--date", "2026-10-30"
61
+ )
62
+
63
+ assert result.returncode == 0
64
+ assert "## [1.1.1] - 2026-10-30" in result.stdout
65
+ assert "### Added" in result.stdout
66
+ assert "**renderer:** Add table alignment" in result.stdout
67
+ assert "### Fixed" in result.stdout
68
+ assert "Stop crushing short columns" in result.stdout
69
+ assert "### Documentation" in result.stdout
70
+ assert "Tidy up" not in result.stdout
71
+ assert "Cover narrow tables" not in result.stdout
72
+
73
+
74
+ def test_notes_file_omits_the_version_heading(tmp_path):
75
+ repo = make_repo(tmp_path)
76
+ notes = repo / "notes.md"
77
+
78
+ result = run_changelog(
79
+ repo,
80
+ "--version",
81
+ "1.1.1",
82
+ "--tag",
83
+ "v1.1.1",
84
+ "--date",
85
+ "2026-10-30",
86
+ "--notes-file",
87
+ str(notes),
88
+ )
89
+
90
+ assert result.returncode == 0
91
+ body = notes.read_text(encoding="utf-8")
92
+ assert "## [1.1.1]" not in body
93
+ assert "### Added" in body
94
+
95
+
96
+ def test_write_inserts_above_the_newest_release(tmp_path):
97
+ repo = make_repo(tmp_path)
98
+ changelog = repo / "CHANGELOG.md"
99
+ changelog.write_text(
100
+ "# Changelog\n\n"
101
+ "## [1.1.0] - 2026-10-01\n\n"
102
+ "### Added\n\n- Initial release.\n",
103
+ encoding="utf-8",
104
+ )
105
+
106
+ result = run_changelog(
107
+ repo,
108
+ "--version",
109
+ "1.1.1",
110
+ "--tag",
111
+ "v1.1.1",
112
+ "--date",
113
+ "2026-10-30",
114
+ "--write",
115
+ str(changelog),
116
+ )
117
+
118
+ assert result.returncode == 0
119
+ text = changelog.read_text(encoding="utf-8")
120
+ assert text.index("## [1.1.1]") < text.index("## [1.1.0]")
121
+ assert text.startswith("# Changelog")
122
+
123
+
124
+ def test_write_is_idempotent(tmp_path):
125
+ repo = make_repo(tmp_path)
126
+ changelog = repo / "CHANGELOG.md"
127
+ changelog.write_text("# Changelog\n", encoding="utf-8")
128
+
129
+ args = ("--version", "1.1.1", "--tag", "v1.1.1", "--write", str(changelog))
130
+ run_changelog(repo, *args)
131
+ first = changelog.read_text(encoding="utf-8")
132
+ run_changelog(repo, *args)
133
+
134
+ assert changelog.read_text(encoding="utf-8") == first
135
+ assert first.count("## [1.1.1]") == 1
136
+
137
+
138
+ def test_release_with_only_maintenance_commits(tmp_path):
139
+ repo = tmp_path
140
+ git(repo, "init", "-q")
141
+ git(repo, "config", "user.email", "test@example.com")
142
+ git(repo, "config", "user.name", "Test")
143
+ commit(repo, "feat: initial release", "initial.txt")
144
+ git(repo, "tag", "-a", "v1.1.0", "-m", "v1.1.0")
145
+ commit(repo, "chore: tidy up", "chore.txt")
146
+
147
+ result = run_changelog(repo, "--version", "1.1.1", "--tag", "v1.1.1")
148
+
149
+ assert result.returncode == 0
150
+ assert "No user-facing changes" in result.stdout
@@ -367,3 +367,18 @@ class TestMarkdownFormatter:
367
367
  """Test strikethrough formatting"""
368
368
  result = self.formatter.format_strikethrough("strike")
369
369
  assert "strike" in result
370
+
371
+
372
+ class TestTableAlignment:
373
+ """Column alignment in formatted tables."""
374
+
375
+ def test_format_table_alignment(self):
376
+ formatter = MarkdownFormatter(get_theme('github-dark'), width=80)
377
+ rows = [
378
+ ["Name", "Qty", "Status"],
379
+ ["A", "2", "ok"],
380
+ ]
381
+
382
+ result = formatter.format_table(rows, ["left", "right", "center"])
383
+
384
+ assert "│ A │ 2 │ ok │" in result
@@ -309,3 +309,21 @@ class TestMarkdownParser:
309
309
  assert result == ""
310
310
 
311
311
 
312
+
313
+
314
+ class TestTableAlignments:
315
+ """Alignment markers on table separator rows."""
316
+
317
+ def test_parse_table_alignments(self):
318
+ parser = MarkdownParser()
319
+ cells = parser.parse_table_row("|:---|:---:|---:|")
320
+
321
+ assert parser.parse_table_alignments(cells) == [
322
+ "left", "center", "right"
323
+ ]
324
+
325
+ def test_parse_table_alignments_defaults_to_left(self):
326
+ parser = MarkdownParser()
327
+ cells = parser.parse_table_row("|---|---|")
328
+
329
+ assert parser.parse_table_alignments(cells) == ["left", "left"]
@@ -620,3 +620,62 @@ class TestMarkdownRenderer:
620
620
  result = output.getvalue()
621
621
  assert "A" in result
622
622
  assert "code" in result
623
+
624
+
625
+ class TestTableAlignment:
626
+ """Table alignment and width handling through the renderer."""
627
+
628
+ def test_alignment_markers_are_rendered(self):
629
+ output = StringIO()
630
+ renderer = MarkdownRenderer(output=output)
631
+
632
+ renderer.render("| Item | Qty | Price |\n")
633
+ renderer.render("|:-----|----:|------:|\n")
634
+ renderer.render("| Apples | 3 | 1.20 |\n")
635
+ renderer.render("\n")
636
+ renderer.finalize()
637
+
638
+ assert "│ Apples │ 3 │ 1.20 │" in output.getvalue()
639
+
640
+ def test_short_columns_keep_their_text(self):
641
+ output = StringIO()
642
+ renderer = MarkdownRenderer(output=output, width=60)
643
+
644
+ renderer.render("| Name | Description | Status |\n")
645
+ renderer.render("|---|---|---|\n")
646
+ renderer.render(
647
+ "| markrender | Render markdown in the terminal with "
648
+ "streaming support and syntax highlighting | active |\n"
649
+ )
650
+ renderer.render("\n")
651
+ renderer.finalize()
652
+
653
+ result = output.getvalue()
654
+ assert "markrender" in result
655
+ assert "Status" in result
656
+ assert "markre…" not in result
657
+ assert "Stat…" not in result
658
+
659
+ def test_narrow_table_does_not_crush_a_single_column(self):
660
+ output = StringIO()
661
+ renderer = MarkdownRenderer(output=output, width=34)
662
+
663
+ renderer.render("| Name | Description | Status |\n")
664
+ renderer.render("|---|---|---|\n")
665
+ renderer.render(
666
+ "| markrender | Render markdown in the terminal with "
667
+ "streaming support and syntax highlighting | active |\n"
668
+ )
669
+ renderer.render("\n")
670
+ renderer.finalize()
671
+
672
+ lines = [
673
+ line for line in output.getvalue().splitlines()
674
+ if line.startswith("┌")
675
+ ]
676
+ widths = [
677
+ len(segment) for segment in lines[0][1:-1].split("┬")
678
+ ]
679
+
680
+ assert max(widths) - min(widths) <= 2
681
+ assert all(len(line) <= 34 for line in output.getvalue().splitlines())
@@ -77,6 +77,35 @@ def test_feature_commit_bumps_minor(tmp_path):
77
77
  assert result.stdout.strip() == "1.1.0"
78
78
 
79
79
 
80
+ def test_docs_only_commit_does_not_release(tmp_path):
81
+ repo = make_repo(tmp_path)
82
+ commit(repo, "docs: explain the width flag", "readme.txt")
83
+
84
+ result = next_version(repo, "--bump", "auto")
85
+
86
+ assert result.returncode == 3
87
+
88
+
89
+ def test_chore_only_commit_does_not_release(tmp_path):
90
+ repo = make_repo(tmp_path)
91
+ commit(repo, "chore: remove a local harness", "chore.txt")
92
+
93
+ result = next_version(repo, "--bump", "auto")
94
+
95
+ assert result.returncode == 3
96
+
97
+
98
+ def test_maintenance_commits_do_not_raise_the_bump(tmp_path):
99
+ repo = make_repo(tmp_path)
100
+ commit(repo, "feat: add a renderer option", "feature.txt")
101
+ commit(repo, "chore: tidy up", "chore.txt")
102
+
103
+ result = next_version(repo, "--bump", "auto")
104
+
105
+ assert result.returncode == 0
106
+ assert result.stdout.strip() == "1.1.0"
107
+
108
+
80
109
  def test_breaking_commit_bumps_major(tmp_path):
81
110
  repo = make_repo(tmp_path)
82
111
  commit(repo, "feat!: change the public API", "breaking.txt")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes