Skip to content

Commit c4c37ad

Browse files
jdmonacoclaude
andcommitted
Add consecutive blank line normalization
Postprocessor now limits runs of 3+ empty lines to a maximum of 2, preserving content inside fenced code blocks. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent dc17421 commit c4c37ad

4 files changed

Lines changed: 150 additions & 4 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ mdformat-space-control is an mdformat plugin that provides unified control over
88
- **EditorConfig support**: Configure list indentation via `.editorconfig` files
99
- **Tight list formatting**: Automatically removes unnecessary blank lines between list items
1010
- **Frontmatter spacing**: Normalizes spacing after YAML frontmatter (works with mdformat-frontmatter)
11+
- **Consecutive blank line normalization**: Limits runs of 3+ empty lines to a maximum of 2
1112
- **Trailing whitespace removal**: Strips trailing whitespace outside code blocks
1213
- **Escaped link repair**: Fixes malformed multi-line links from web-clipped content
1314

@@ -39,7 +40,7 @@ mdformat_space_control/
3940
- `_render_list_item`: Per-item tight/loose formatting based on paragraph count
4041
- `_render_bullet_list`: Configurable indent + content-based tight/loose
4142
- `_render_ordered_list`: Configurable indent + content-based tight/loose
42-
- `_postprocess_root`: Combined postprocessor applying frontmatter spacing, escaped link repair, and trailing whitespace removal
43+
- `_postprocess_root`: Combined postprocessor applying frontmatter spacing, escaped link repair, consecutive blank line normalization, and trailing whitespace removal
4344

4445
## Plugin Extension Points
4546

@@ -60,7 +61,7 @@ space_control = "mdformat_space_control"
6061
- **`tests/test_fixtures.py`**: Parametrized fixture tests
6162
- **`tests/test_editorconfig.py`**: EditorConfig-specific tests using temp directories
6263
- **`tests/test_frontmatter.py`**: Frontmatter spacing tests (requires mdformat-frontmatter)
63-
- **`tests/test_spacing_features.py`**: Trailing whitespace, hard breaks, escaped link repair tests
64+
- **`tests/test_spacing_features.py`**: Trailing whitespace, hard breaks, escaped link repair, consecutive blank line tests
6465
- **`tests/test_integration.py`**: Full-stack integration tests combining multiple features
6566
- **`tests/test_plugin_interactions.py`**: Tests for compatibility with other mdformat plugins
6667

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ An [mdformat](https://github.com/executablebooks/mdformat) plugin that provides
88
- **EditorConfig support**: Configure list indentation via `.editorconfig` files
99
- **Tight list formatting**: Automatically removes unnecessary blank lines between list items
1010
- **Frontmatter spacing**: Normalizes spacing after YAML frontmatter (works with [mdformat-frontmatter](https://github.com/butler54/mdformat-frontmatter))
11+
- **Consecutive blank line normalization**: Limits runs of 3+ empty lines to a maximum of 2
1112
- **Trailing whitespace removal**: Strips trailing whitespace outside code blocks
1213
- **Escaped link repair**: Fixes malformed multi-line links from web-clipped content
1314

mdformat_space_control/plugin.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,21 +279,58 @@ def _strip_trailing_whitespace(text: str) -> str:
279279
return "\n".join(result)
280280

281281

282+
def _normalize_consecutive_blank_lines(text: str) -> str:
283+
"""Limit consecutive blank lines to a maximum of 2.
284+
285+
Collapses runs of 3+ empty lines down to 2 empty lines (3 newlines).
286+
Preserves content inside fenced code blocks.
287+
"""
288+
lines = text.split("\n")
289+
result = []
290+
in_code_block = False
291+
consecutive_empty = 0
292+
293+
for line in lines:
294+
# Track code block state
295+
stripped = line.strip()
296+
if stripped.startswith("```") or stripped.startswith("~~~"):
297+
in_code_block = not in_code_block
298+
299+
if in_code_block:
300+
# Preserve everything inside code blocks
301+
result.append(line)
302+
consecutive_empty = 0
303+
elif line == "":
304+
consecutive_empty += 1
305+
if consecutive_empty <= 2:
306+
result.append(line)
307+
# else: skip this empty line (collapse)
308+
else:
309+
consecutive_empty = 0
310+
result.append(line)
311+
312+
return "\n".join(result)
313+
314+
282315
def _postprocess_root(text: str, node: RenderTreeNode, context: RenderContext) -> str:
283316
"""Combined postprocessor for all space control features.
284317
285318
Applies the following transformations in order:
286319
1. Frontmatter spacing normalization
287320
2. Escaped link repair
288-
3. Trailing whitespace removal
321+
3. Consecutive blank line normalization
322+
4. Trailing whitespace removal
289323
"""
290324
# 1. Frontmatter spacing
291325
text = _normalize_frontmatter_spacing(text)
292326

293327
# 2. Repair escaped links (before trailing whitespace removal)
294328
text = _repair_escaped_links(text)
295329

296-
# 3. Trailing whitespace removal
330+
# 3. Limit consecutive blank lines to 2
331+
text = _normalize_consecutive_blank_lines(text)
332+
333+
# 4. Trailing whitespace removal
297334
text = _strip_trailing_whitespace(text)
298335

299336
return text

tests/test_spacing_features.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,113 @@ def test_link_with_prefix_text(self):
269269
assert result == expected
270270

271271

272+
class TestConsecutiveBlankLines:
273+
"""Tests for limiting consecutive blank lines.
274+
275+
Note: mdformat's AST-based rendering normalizes blank lines between
276+
content blocks to a single blank line. The postprocessor can only work
277+
on the rendered output, which has already been normalized.
278+
279+
These tests verify the postprocessor correctly handles:
280+
1. Already-normalized content (pass-through)
281+
2. Code blocks (preserve internal blank lines)
282+
3. Direct function testing for edge cases
283+
"""
284+
285+
def test_mdformat_normalizes_multiple_blank_lines(self):
286+
"""mdformat normalizes multiple blank lines to one during rendering."""
287+
# This verifies the expected behavior - mdformat already handles this
288+
input_text = "First paragraph.\n\n\n\nSecond paragraph.\n"
289+
expected = "First paragraph.\n\nSecond paragraph.\n"
290+
result = mdformat.text(input_text, extensions={"space_control"})
291+
assert result == expected
292+
293+
def test_single_empty_line_unchanged(self):
294+
"""Single empty line should remain unchanged."""
295+
input_text = "First.\n\nSecond.\n"
296+
expected = "First.\n\nSecond.\n"
297+
result = mdformat.text(input_text, extensions={"space_control"})
298+
assert result == expected
299+
300+
def test_code_block_preserved(self):
301+
"""Empty lines inside code blocks should be preserved."""
302+
input_text = "Text.\n\n```\n\n\n\n\ncode\n\n\n\n\n```\n\nMore text.\n"
303+
expected = "Text.\n\n```\n\n\n\n\ncode\n\n\n\n\n```\n\nMore text.\n"
304+
result = mdformat.text(input_text, extensions={"space_control"})
305+
assert result == expected
306+
307+
308+
class TestConsecutiveBlankLinesFunction:
309+
"""Direct tests for _normalize_consecutive_blank_lines function.
310+
311+
These tests verify the function works correctly in isolation,
312+
independent of mdformat's rendering normalization.
313+
"""
314+
315+
def test_three_empty_lines_reduced_to_two(self):
316+
"""Three empty lines should be reduced to two."""
317+
from mdformat_space_control.plugin import _normalize_consecutive_blank_lines
318+
319+
input_text = "First paragraph.\n\n\n\nSecond paragraph.\n"
320+
expected = "First paragraph.\n\n\nSecond paragraph.\n"
321+
result = _normalize_consecutive_blank_lines(input_text)
322+
assert result == expected
323+
324+
def test_many_empty_lines_reduced_to_two(self):
325+
"""Many empty lines should be reduced to two."""
326+
from mdformat_space_control.plugin import _normalize_consecutive_blank_lines
327+
328+
input_text = "First.\n\n\n\n\n\n\nSecond.\n"
329+
expected = "First.\n\n\nSecond.\n"
330+
result = _normalize_consecutive_blank_lines(input_text)
331+
assert result == expected
332+
333+
def test_two_empty_lines_unchanged(self):
334+
"""Two empty lines should remain unchanged."""
335+
from mdformat_space_control.plugin import _normalize_consecutive_blank_lines
336+
337+
input_text = "First.\n\n\nSecond.\n"
338+
expected = "First.\n\n\nSecond.\n"
339+
result = _normalize_consecutive_blank_lines(input_text)
340+
assert result == expected
341+
342+
def test_single_empty_line_unchanged(self):
343+
"""Single empty line should remain unchanged."""
344+
from mdformat_space_control.plugin import _normalize_consecutive_blank_lines
345+
346+
input_text = "First.\n\nSecond.\n"
347+
expected = "First.\n\nSecond.\n"
348+
result = _normalize_consecutive_blank_lines(input_text)
349+
assert result == expected
350+
351+
def test_code_block_preserved(self):
352+
"""Empty lines inside code blocks should be preserved."""
353+
from mdformat_space_control.plugin import _normalize_consecutive_blank_lines
354+
355+
input_text = "Text.\n\n```\n\n\n\n\ncode\n\n\n\n\n```\n\nMore text.\n"
356+
expected = "Text.\n\n```\n\n\n\n\ncode\n\n\n\n\n```\n\nMore text.\n"
357+
result = _normalize_consecutive_blank_lines(input_text)
358+
assert result == expected
359+
360+
def test_multiple_sections(self):
361+
"""Multiple sections with excess blank lines."""
362+
from mdformat_space_control.plugin import _normalize_consecutive_blank_lines
363+
364+
input_text = "# One\n\n\n\n\nPara.\n\n\n\n\n# Two\n"
365+
expected = "# One\n\n\nPara.\n\n\n# Two\n"
366+
result = _normalize_consecutive_blank_lines(input_text)
367+
assert result == expected
368+
369+
def test_tilde_code_block_preserved(self):
370+
"""Empty lines inside tilde-fenced code blocks should be preserved."""
371+
from mdformat_space_control.plugin import _normalize_consecutive_blank_lines
372+
373+
input_text = "Text.\n\n~~~\n\n\n\n\ncode\n\n\n\n\n~~~\n\nMore text.\n"
374+
expected = "Text.\n\n~~~\n\n\n\n\ncode\n\n\n\n\n~~~\n\nMore text.\n"
375+
result = _normalize_consecutive_blank_lines(input_text)
376+
assert result == expected
377+
378+
272379
class TestIntegration:
273380
"""Integration tests for multiple features working together."""
274381

0 commit comments

Comments
 (0)