Skip to content

Commit c8efd7f

Browse files
authored
Merge pull request #18 from jlevy/feature/fix-multiline-tag-closing
Fix multi-line opening tags placing closing tags on own line
2 parents 9b30914 + 167f130 commit c8efd7f

File tree

7 files changed

+277
-5
lines changed

7 files changed

+277
-5
lines changed

src/flowmark/linewrapping/tag_handling.py

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -253,15 +253,18 @@ def add_tag_newline_handling(base_wrapper: LineWrapper) -> LineWrapper:
253253
"""
254254

255255
def enhanced_wrapper(text: str, initial_indent: str, subsequent_indent: str) -> str:
256-
# If no newlines, nothing to preserve
256+
# If no newlines in input, just wrap and apply post-processing fixes.
257+
# The base_wrapper may produce multi-line output that needs fixing.
257258
if "\n" not in text:
258-
return base_wrapper(text, initial_indent, subsequent_indent)
259+
result = base_wrapper(text, initial_indent, subsequent_indent)
260+
return _fix_multiline_opening_tag_with_closing(result)
259261

260262
lines = text.split("\n")
261263

262-
# If only one line after split, nothing to preserve
264+
# If only one line after split, same as above
263265
if len(lines) <= 1:
264-
return base_wrapper(text, initial_indent, subsequent_indent)
266+
result = base_wrapper(text, initial_indent, subsequent_indent)
267+
return _fix_multiline_opening_tag_with_closing(result)
265268

266269
# Check if there are any tags in the text - only apply block content
267270
# heuristics when tags are present to avoid changing normal markdown behavior
@@ -299,7 +302,8 @@ def enhanced_wrapper(text: str, initial_indent: str, subsequent_indent: str) ->
299302

300303
# If we only have one segment, no tag boundaries were found
301304
if len(segments) == 1:
302-
return base_wrapper(text, initial_indent, subsequent_indent)
305+
result = base_wrapper(text, initial_indent, subsequent_indent)
306+
return _fix_multiline_opening_tag_with_closing(result)
303307

304308
# Wrap each segment separately
305309
wrapped_segments: list[str] = []
@@ -345,6 +349,10 @@ def enhanced_wrapper(text: str, initial_indent: str, subsequent_indent: str) ->
345349
# The Markdown parser may indent closing tags due to lazy continuation.
346350
result = _fix_closing_tag_spacing(result)
347351

352+
# Fix multi-line opening tags that have closing tags on the same line.
353+
# This works around a Markdoc parser bug (see GitHub issue #17).
354+
result = _fix_multiline_opening_tag_with_closing(result)
355+
348356
return result
349357

350358
return enhanced_wrapper
@@ -394,3 +402,79 @@ def _fix_closing_tag_spacing(text: str) -> str:
394402
fixed_lines.append(line)
395403

396404
return "\n".join(fixed_lines)
405+
406+
407+
# Pattern to detect closing delimiter of opening tag followed by a closing tag.
408+
# This handles cases like: %}{% /tag %} or --><!-- /tag -->
409+
# where a multi-line opening tag ends and a closing tag follows on the same line.
410+
# Uses named group "closing_tag" to capture the start of the closing tag.
411+
_multiline_closing_pattern: re.Pattern[str] = re.compile(
412+
rf"{JINJA_TAG_CLOSE_RE}\s*(?P<closing_tag>{JINJA_TAG_OPEN_RE}\s*/)|" # %}{% /
413+
rf"{JINJA_COMMENT_CLOSE_RE}\s*(?P<closing_comment>{JINJA_COMMENT_OPEN_RE}\s*/)|" # #}{# /
414+
rf"{JINJA_VAR_CLOSE_RE}\s*(?P<closing_var>{JINJA_VAR_OPEN_RE}\s*/)|" # }}{{ /
415+
rf"{HTML_COMMENT_CLOSE_RE}\s*(?P<closing_html>{HTML_COMMENT_OPEN_RE}\s*/)" # --><!-- /
416+
)
417+
418+
419+
def _fix_multiline_opening_tag_with_closing(text: str) -> str:
420+
"""
421+
Ensure closing tags are on their own line when the opening tag spans multiple lines.
422+
423+
This works around a Markdoc parser bug where multi-line opening tags with
424+
closing tags on the same line cause incorrect AST parsing.
425+
426+
Problem pattern (triggers Markdoc bug):
427+
{% tag attr1=value1
428+
attr2=value2 %}{% /tag %}
429+
430+
Fixed pattern:
431+
{% tag attr1=value1
432+
attr2=value2 %}
433+
{% /tag %}
434+
435+
Single-line paired tags like `{% field %}{% /field %}` are NOT affected.
436+
Tags in the middle of prose like `Before {% field %}{% /field %} after` are
437+
also NOT affected because the line contains content before the tag.
438+
"""
439+
# Only apply fix if there are multiple lines - single line input means
440+
# no multi-line tags to fix
441+
if "\n" not in text:
442+
return text
443+
444+
lines = text.split("\n")
445+
result_lines: list[str] = []
446+
447+
for i, line in enumerate(lines):
448+
# Skip the first line - it can't be a continuation of a multi-line tag
449+
if i == 0:
450+
result_lines.append(line)
451+
continue
452+
453+
stripped = line.lstrip()
454+
455+
# Only process lines that are continuations (don't start with a tag opener).
456+
# If a line starts with a tag opener, the tag began on that line, not a continuation.
457+
is_tag_start = (
458+
stripped.startswith(JINJA_TAG_OPEN)
459+
or stripped.startswith(JINJA_COMMENT_OPEN)
460+
or stripped.startswith(JINJA_VAR_OPEN)
461+
or stripped.startswith(HTML_COMMENT_OPEN)
462+
)
463+
464+
if not is_tag_start:
465+
match = _multiline_closing_pattern.search(line)
466+
if match:
467+
# Find which named group matched and split at the closing tag
468+
for group_name in ["closing_tag", "closing_comment", "closing_var", "closing_html"]:
469+
if match.group(group_name) is not None:
470+
split_pos = match.start(group_name)
471+
before = line[:split_pos].rstrip()
472+
closing = line[split_pos:].lstrip()
473+
result_lines.append(before)
474+
result_lines.append(closing)
475+
break
476+
continue
477+
478+
result_lines.append(line)
479+
480+
return "\n".join(result_lines)

tests/test_tag_formatting.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,3 +419,81 @@ def test_smart_quotes_multiline_tag_with_prose():
419419
# Tag quotes should NOT be converted
420420
assert 'kind="string"' in result
421421
assert 'label="Full Name"' in result
422+
423+
424+
def test_multiline_opening_tag_closing_on_own_line():
425+
"""
426+
Test that closing tags are placed on their own line after multiline opening tags.
427+
428+
This is a regression test for GitHub issue #17: When an opening tag spans
429+
multiple lines, having the closing tag on the same line as the opening tag's
430+
closing delimiter breaks Markdoc's parser.
431+
"""
432+
from flowmark.linewrapping.tag_handling import (
433+
_fix_multiline_opening_tag_with_closing, # pyright: ignore[reportPrivateUsage]
434+
)
435+
436+
# Pattern that triggers Markdoc bug: multi-line opening tag with closing on same line
437+
problematic = "{% field kind='string'\nrequired=true %}{% /field %}"
438+
result = _fix_multiline_opening_tag_with_closing(problematic)
439+
440+
# Closing tag should be on its own line
441+
assert "%}\n{% /field %}" in result, f"Closing tag not on own line: {result}"
442+
443+
444+
def test_single_line_paired_tags_not_split():
445+
"""
446+
Test that single-line paired tags like {% field %}{% /field %} are NOT split.
447+
448+
This is a regression test to ensure the fix for issue #17 doesn't affect
449+
single-line tags.
450+
"""
451+
from flowmark.linewrapping.tag_handling import (
452+
_fix_multiline_opening_tag_with_closing, # pyright: ignore[reportPrivateUsage]
453+
)
454+
455+
# Single-line paired tag - should NOT be split
456+
single_line = "{% field kind='string' %}{% /field %}"
457+
result = _fix_multiline_opening_tag_with_closing(single_line)
458+
459+
# Should remain on single line
460+
assert result == single_line, f"Single-line tag was incorrectly split: {result}"
461+
462+
463+
def test_multiline_tag_through_pipeline():
464+
"""Test multiline tags with closing on same line through the full pipeline."""
465+
# Use a tag that's long enough to actually trigger wrapping at width 88
466+
# This should produce the problematic pattern that triggers the Markdoc bug
467+
long_tag = (
468+
'{% field kind="string" id="name" label="Full Name" role="user" '
469+
'required=true minLength=2 maxLength=100 placeholder="Enter your full name" %}'
470+
"{% /field %}"
471+
)
472+
473+
result = fill_markdown(long_tag, semantic=True, width=88)
474+
lines = result.strip().split("\n")
475+
476+
# If the tag wrapped (longer than line width), closing tag should be on its own line
477+
if len(lines) >= 2:
478+
# Last line should be the closing tag on its own
479+
assert lines[-1].strip() == "{% /field %}", (
480+
f"Last line should be closing tag, got: {lines[-1]}"
481+
)
482+
# The line before closing tag should end with %}
483+
assert lines[-2].strip().endswith("%}"), (
484+
f"Line before closing should end with %}}, got: {lines[-2]}"
485+
)
486+
487+
488+
def test_html_comment_multiline_closing():
489+
"""Test HTML comment tags with multi-line opening and closing on same line."""
490+
from flowmark.linewrapping.tag_handling import (
491+
_fix_multiline_opening_tag_with_closing, # pyright: ignore[reportPrivateUsage]
492+
)
493+
494+
# HTML comment pattern
495+
text = "<!-- f:field kind='string'\nlabel='Name' --><!-- /f:field -->"
496+
result = _fix_multiline_opening_tag_with_closing(text)
497+
498+
# Closing comment should be on its own line
499+
assert "-->\n<!-- /f:field -->" in result, f"HTML closing tag not split: {result}"

tests/testdocs/testdoc.expected.auto.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1693,6 +1693,29 @@ Self-closing tags with tables:
16931693
16941694
<!-- end-section -->
16951695
1696+
### Issue 7: Multi-Line Opening Tags (GitHub Issue #17)
1697+
1698+
When an opening tag is long enough to wrap across multiple lines, the closing tag should
1699+
be placed on its own line to avoid triggering a Markdoc parser bug.
1700+
1701+
This tag is long enough to wrap and should have its closing tag on a separate line:
1702+
1703+
{% field kind="number" id="age" label="Your Age" role="user" required=true min=0 max=150
1704+
integer=true placeholder="Enter your age" %}
1705+
{% /field %}
1706+
1707+
HTML comment version:
1708+
1709+
<!-- f:field kind="number" id="score" label="Score" role="user" required=true min=0
1710+
max=100 integer=true placeholder="Enter score" -->
1711+
<!-- /f:field -->
1712+
1713+
Short tags that fit on one line should remain together:
1714+
1715+
{% field kind="string" id="name" %}{% /field %}
1716+
1717+
<!-- f:field id="email" --><!-- /f:field -->
1718+
16961719
### Mixed Content Test
16971720
16981721
A form with various content types:

tests/testdocs/testdoc.expected.cleaned.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1693,6 +1693,29 @@ Self-closing tags with tables:
16931693
16941694
<!-- end-section -->
16951695
1696+
### Issue 7: Multi-Line Opening Tags (GitHub Issue #17)
1697+
1698+
When an opening tag is long enough to wrap across multiple lines, the closing tag should
1699+
be placed on its own line to avoid triggering a Markdoc parser bug.
1700+
1701+
This tag is long enough to wrap and should have its closing tag on a separate line:
1702+
1703+
{% field kind="number" id="age" label="Your Age" role="user" required=true min=0 max=150
1704+
integer=true placeholder="Enter your age" %}
1705+
{% /field %}
1706+
1707+
HTML comment version:
1708+
1709+
<!-- f:field kind="number" id="score" label="Score" role="user" required=true min=0
1710+
max=100 integer=true placeholder="Enter score" -->
1711+
<!-- /f:field -->
1712+
1713+
Short tags that fit on one line should remain together:
1714+
1715+
{% field kind="string" id="name" %}{% /field %}
1716+
1717+
<!-- f:field id="email" --><!-- /f:field -->
1718+
16961719
### Mixed Content Test
16971720
16981721
A form with various content types:

tests/testdocs/testdoc.expected.plain.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1644,6 +1644,29 @@ Self-closing tags with tables:
16441644
16451645
<!-- end-section -->
16461646
1647+
### Issue 7: Multi-Line Opening Tags (GitHub Issue #17)
1648+
1649+
When an opening tag is long enough to wrap across multiple lines, the closing tag should
1650+
be placed on its own line to avoid triggering a Markdoc parser bug.
1651+
1652+
This tag is long enough to wrap and should have its closing tag on a separate line:
1653+
1654+
{% field kind="number" id="age" label="Your Age" role="user" required=true min=0 max=150
1655+
integer=true placeholder="Enter your age" %}
1656+
{% /field %}
1657+
1658+
HTML comment version:
1659+
1660+
<!-- f:field kind="number" id="score" label="Score" role="user" required=true min=0
1661+
max=100 integer=true placeholder="Enter score" -->
1662+
<!-- /f:field -->
1663+
1664+
Short tags that fit on one line should remain together:
1665+
1666+
{% field kind="string" id="name" %}{% /field %}
1667+
1668+
<!-- f:field id="email" --><!-- /f:field -->
1669+
16471670
### Mixed Content Test
16481671
16491672
A form with various content types:

tests/testdocs/testdoc.expected.semantic.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1693,6 +1693,29 @@ Self-closing tags with tables:
16931693
16941694
<!-- end-section -->
16951695
1696+
### Issue 7: Multi-Line Opening Tags (GitHub Issue #17)
1697+
1698+
When an opening tag is long enough to wrap across multiple lines, the closing tag should
1699+
be placed on its own line to avoid triggering a Markdoc parser bug.
1700+
1701+
This tag is long enough to wrap and should have its closing tag on a separate line:
1702+
1703+
{% field kind="number" id="age" label="Your Age" role="user" required=true min=0 max=150
1704+
integer=true placeholder="Enter your age" %}
1705+
{% /field %}
1706+
1707+
HTML comment version:
1708+
1709+
<!-- f:field kind="number" id="score" label="Score" role="user" required=true min=0
1710+
max=100 integer=true placeholder="Enter score" -->
1711+
<!-- /f:field -->
1712+
1713+
Short tags that fit on one line should remain together:
1714+
1715+
{% field kind="string" id="name" %}{% /field %}
1716+
1717+
<!-- f:field id="email" --><!-- /f:field -->
1718+
16961719
### Mixed Content Test
16971720
16981721
A form with various content types:

tests/testdocs/testdoc.orig.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1299,6 +1299,24 @@ Self-closing tags with tables:
12991299
| X | 1 |
13001300
<!-- end-section -->
13011301
1302+
### Issue 7: Multi-Line Opening Tags (GitHub Issue #17)
1303+
1304+
When an opening tag is long enough to wrap across multiple lines, the closing tag should be placed on its own line to avoid triggering a Markdoc parser bug.
1305+
1306+
This tag is long enough to wrap and should have its closing tag on a separate line:
1307+
1308+
{% field kind="number" id="age" label="Your Age" role="user" required=true min=0 max=150 integer=true placeholder="Enter your age" %}{% /field %}
1309+
1310+
HTML comment version:
1311+
1312+
<!-- f:field kind="number" id="score" label="Score" role="user" required=true min=0 max=100 integer=true placeholder="Enter score" --><!-- /f:field -->
1313+
1314+
Short tags that fit on one line should remain together:
1315+
1316+
{% field kind="string" id="name" %}{% /field %}
1317+
1318+
<!-- f:field id="email" --><!-- /f:field -->
1319+
13021320
### Mixed Content Test
13031321
13041322
A form with various content types:

0 commit comments

Comments
 (0)