Skip to content

Commit 0382f22

Browse files
committed
Fix dash conversion corrupting tables inside blockquotes
Dash conversion scans line by line, so a blockquoted table separator arrives as "> | -- | -- |", which never matched the table separator pattern (anchored at "|"). The row was treated as prose and smart- punctuated into "| - | - |", breaking table rendering in Obsidian. Fenced code blocks inside blockquotes had the same blind spot, since fence tracking only stripped whitespace, and blockquoted thematic breaks were missed because that check ran against the raw line. Add _strip_block_markers() to remove leading blockquote markers and indentation, and use it wherever block-level constructs are detected in _convert_dash_sequences() and _strip_trailing_whitespace(). Prose dashes inside blockquotes still convert. Tests exercise _convert_dash_sequences() directly rather than through mdformat.text(), so they do not pull in the gfm parser extension, which conflicts with this plugin's list_item renderer.
1 parent 5502154 commit 0382f22

4 files changed

Lines changed: 112 additions & 6 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,9 @@ The result—unexpected as it was—changed everything.
165165
Pages 10–20 of the report.
166166
```
167167

168-
Dashes are preserved inside fenced code blocks, inline code spans, HTML comments, and HTML tags. Thematic breaks (`---`) and frontmatter delimiters are not affected. Sequences of 4+ dashes are left unchanged.
168+
Dashes are preserved inside fenced code blocks, inline code spans, HTML comments, and HTML tags. Thematic breaks (`---`), GFM table separator rows (`| -- | -- |`), and frontmatter delimiters are not affected. Sequences of 4+ dashes are left unchanged.
169+
170+
These exclusions apply equally inside blockquotes, at any nesting depth: leading `>` markers are stripped before block-level patterns are matched, so tables and code fences in a blockquote or Obsidian callout are preserved just as they are at the top level.
169171

170172
### Wikilink Preservation
171173

mdformat_space_control/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""An mdformat plugin for space control: EditorConfig indentation, tight lists, frontmatter spacing, smart dash conversion, soft break joining, and wikilinks."""
22

3-
__version__ = "0.4.9"
3+
__version__ = "0.4.10"
44

55
from .config import (
66
get_current_file,

mdformat_space_control/plugin.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,19 @@ def _normalize_frontmatter_spacing(text: str) -> str:
425425
return before_content + after_content
426426

427427

428+
# Leading blockquote markers ('>' with optional spaces), possibly nested and
429+
# possibly indented. Block-level constructs inside a blockquote -- code fences,
430+
# GFM table separator rows, thematic breaks -- are recognized only after these
431+
# markers are stripped, so line-scanning postprocessors must strip them first
432+
# or they mis-handle every blockquoted table and fenced block.
433+
_BLOCKQUOTE_PREFIX_RE = re.compile(r"^[ \t]*(?:>[ \t]?)+")
434+
435+
436+
def _strip_block_markers(line: str) -> str:
437+
"""Return ``line`` without leading blockquote markers and indentation."""
438+
return _BLOCKQUOTE_PREFIX_RE.sub("", line).lstrip()
439+
440+
428441
def _strip_trailing_whitespace(text: str) -> str:
429442
"""Strip trailing whitespace, preserving code blocks.
430443
@@ -436,8 +449,8 @@ def _strip_trailing_whitespace(text: str) -> str:
436449
in_code_block = False
437450

438451
for line in lines:
439-
# Track fenced code block state
440-
stripped = line.lstrip()
452+
# Track fenced code block state (blockquoted fences included)
453+
stripped = _strip_block_markers(line)
441454
if stripped.startswith("```") or stripped.startswith("~~~"):
442455
in_code_block = not in_code_block
443456
result.append(line.rstrip()) # Strip fence line itself
@@ -494,7 +507,9 @@ def _convert_dash_sequences(text: str) -> str:
494507
in_html_comment = False
495508

496509
for line in lines:
497-
stripped = line.lstrip()
510+
# Strip blockquote markers so block constructs nested in a blockquote
511+
# (fences, table separator rows, thematic breaks) are recognized.
512+
stripped = _strip_block_markers(line)
498513
if stripped.startswith("```") or stripped.startswith("~~~"):
499514
in_code_block = not in_code_block
500515
result.append(line)
@@ -505,7 +520,8 @@ def _convert_dash_sequences(text: str) -> str:
505520
if "-->" in line:
506521
in_html_comment = False
507522
result.append(line)
508-
elif (only_dashes_re.match(line) or separator_line_re.match(stripped)
523+
elif (only_dashes_re.match(stripped)
524+
or separator_line_re.match(stripped)
509525
or table_sep_re.match(stripped)):
510526
result.append(line)
511527
else:

tests/test_dash_conversion.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,3 +385,91 @@ def test_dashes_outside_html_still_convert(self):
385385
expected = "word\u2013word and word\u2014word\n"
386386
result = mdformat.text(input_text, extensions={"space_control"})
387387
assert result == expected
388+
389+
390+
class TestDashConversionInBlockquotes:
391+
"""Block constructs nested in a blockquote must be recognized.
392+
393+
Dash conversion scans line by line, so it sees '> | -- | -- |', not
394+
'| -- | -- |'. Blockquote markers must be stripped before block-level
395+
patterns are matched; otherwise GFM table separator rows inside a
396+
blockquote or Obsidian callout get smart-punctuated into '| - | - |',
397+
which breaks table rendering in Obsidian.
398+
399+
These test _convert_dash_sequences directly rather than through
400+
mdformat.text() so they do not depend on the gfm parser extension,
401+
which is deliberately not a dependency of this plugin.
402+
"""
403+
404+
def test_blockquote_table_separator_preserved(self):
405+
"""A table separator row inside a blockquote must keep its dashes."""
406+
from mdformat_space_control.plugin import _convert_dash_sequences
407+
408+
text = "> | A | B |\n> | -- | -- |\n> | 1 | 2 |"
409+
assert _convert_dash_sequences(text) == text
410+
411+
def test_obsidian_callout_table_separator_preserved(self):
412+
"""Tables inside an Obsidian callout must survive conversion."""
413+
from mdformat_space_control.plugin import _convert_dash_sequences
414+
415+
text = (
416+
"> [!note] Landscape\n>\n> | Model | Size |\n"
417+
"> | -- | -- |\n> | Qwen3 | 27B |"
418+
)
419+
assert _convert_dash_sequences(text) == text
420+
421+
def test_nested_blockquote_table_separator_preserved(self):
422+
"""Separator rows survive at any blockquote nesting depth."""
423+
from mdformat_space_control.plugin import _convert_dash_sequences
424+
425+
text = ">> | A | B |\n>> | -- | -- |\n>> | 1 | 2 |"
426+
assert _convert_dash_sequences(text) == text
427+
428+
def test_indented_blockquote_separator_preserved(self):
429+
"""Indented blockquote markers are stripped before matching."""
430+
from mdformat_space_control.plugin import _convert_dash_sequences
431+
432+
text = " > | A | B |\n > | -- | -- |"
433+
assert _convert_dash_sequences(text) == text
434+
435+
def test_blockquote_alignment_separator_preserved(self):
436+
"""Alignment colons in a blockquoted separator row are preserved."""
437+
from mdformat_space_control.plugin import _convert_dash_sequences
438+
439+
text = "> | A | B |\n> | :-- | --: |\n> | 1 | 2 |"
440+
assert _convert_dash_sequences(text) == text
441+
442+
def test_blockquote_code_fence_preserved(self):
443+
"""Dashes in a fenced code block inside a blockquote are preserved."""
444+
from mdformat_space_control.plugin import _convert_dash_sequences
445+
446+
text = "> ```bash\n> mdfmt -- file.md\n> a -- b\n> ```"
447+
assert _convert_dash_sequences(text) == text
448+
449+
def test_blockquote_thematic_break_preserved(self):
450+
"""A thematic break inside a blockquote is not converted."""
451+
from mdformat_space_control.plugin import _convert_dash_sequences
452+
453+
assert _convert_dash_sequences("> ---") == "> ---"
454+
455+
def test_blockquote_prose_dash_still_converts(self):
456+
"""Genuine prose dashes inside a blockquote still convert."""
457+
from mdformat_space_control.plugin import _convert_dash_sequences
458+
459+
result = _convert_dash_sequences("> quoted prose -- with a dash")
460+
assert result == "> quoted prose \u2013 with a dash"
461+
462+
def test_blockquote_conversion_is_idempotent(self):
463+
"""Repeated conversion of a blockquoted table is a fixed point."""
464+
from mdformat_space_control.plugin import _convert_dash_sequences
465+
466+
text = "> | A | B |\n> | -- | -- |\n> | 1 | 2 |"
467+
once = _convert_dash_sequences(text)
468+
assert _convert_dash_sequences(once) == once == text
469+
470+
def test_plain_table_separator_still_preserved(self):
471+
"""The non-blockquoted case keeps working."""
472+
from mdformat_space_control.plugin import _convert_dash_sequences
473+
474+
text = "| A | B |\n| -- | -- |\n| 1 | 2 |"
475+
assert _convert_dash_sequences(text) == text

0 commit comments

Comments
 (0)