Skip to content

Commit b561943

Browse files
authored
Merge pull request #12 from jlevy/claude/setup-gh-cli-fix-issues-70Vzi
Fix GitHub alert/callout blocks being dropped during normalization
2 parents 45a01cd + 2c40e9d commit b561943

File tree

9 files changed

+772
-5
lines changed

9 files changed

+772
-5
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ classifiers = [
3737
]
3838

3939
dependencies = [
40-
"marko>=2.1.3",
40+
"marko>=2.2.0", # 2.2.0 added GFM Alert support
4141
"regex>=2024.11.6",
4242
"strif>=3.0.1",
4343
"typing-extensions>=4.12.2",

src/flowmark/formats/flowmark_markdown.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,37 @@ def render_url(self, element: gfm_elements.Url) -> str:
547547
"""For GFM autolink URLs, just output the URL directly."""
548548
return element.dest
549549

550+
def render_alert(
551+
self,
552+
element: block.Quote, # pyright: ignore[reportUnknownParameterType]
553+
) -> str:
554+
"""
555+
Render a GFM alert/callout block.
556+
557+
GitHub-flavored Markdown supports alert blocks like:
558+
> [!NOTE]
559+
> Content here
560+
561+
Valid alert types are: NOTE, TIP, IMPORTANT, WARNING, CAUTION
562+
563+
Note: The element is typed as block.Quote since Alert extends Quote but isn't
564+
in Marko's type stubs. At runtime, element is gfm_elements.Alert.
565+
"""
566+
# Reset the skip flag since we're not rendering a blank line
567+
self._skip_next_blank_line = False
568+
569+
# First render the alert header (Alert has alert_type attribute)
570+
alert_type: str = element.alert_type # pyright: ignore
571+
alert_header = f"> [!{alert_type}]\n"
572+
573+
with self.container("> ", "> "):
574+
result = self.render_children(element).rstrip("\n")
575+
576+
self._prefix = self._second_prefix
577+
# After rendering an alert block, don't suppress the next item break
578+
self._suppress_item_break = False
579+
return f"{alert_header}{result}\n"
580+
550581

551582
DEFAULT_SEMANTIC_LINE_WRAPPER = line_wrap_by_sentence(width=DEFAULT_WRAP_WIDTH, is_markdown=True)
552583
"""

tests/test_alerts.py

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
"""
2+
Test GitHub alert/callout block handling.
3+
4+
This test documents the expected behavior for GitHub-flavored Markdown alerts
5+
(callouts) like > [!NOTE], > [!TIP], etc.
6+
7+
Critical robustness requirement: Quote formatting must NEVER be stripped,
8+
even for misspelled or unknown alert types. Unknown types should fall back
9+
to regular quote handling, preserving all content.
10+
"""
11+
12+
from textwrap import dedent
13+
14+
from flowmark.linewrapping.markdown_filling import fill_markdown
15+
16+
17+
def test_basic_note_alert():
18+
"""Test that NOTE alerts are preserved correctly."""
19+
input_doc = dedent(
20+
"""
21+
> [!NOTE]
22+
> This is a note alert.
23+
"""
24+
).strip()
25+
26+
expected_doc = (
27+
dedent(
28+
"""
29+
> [!NOTE]
30+
> This is a note alert.
31+
"""
32+
).strip()
33+
+ "\n"
34+
)
35+
36+
normalized_doc = fill_markdown(input_doc, semantic=True)
37+
assert normalized_doc == expected_doc
38+
39+
40+
def test_all_valid_alert_types():
41+
"""Test all five valid GitHub alert types are preserved."""
42+
alert_types = ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"]
43+
44+
for alert_type in alert_types:
45+
input_doc = f"> [!{alert_type}]\n> Content for {alert_type.lower()} alert."
46+
normalized_doc = fill_markdown(input_doc, semantic=True)
47+
48+
# Verify alert header is preserved
49+
assert f"> [!{alert_type}]" in normalized_doc, f"Alert type {alert_type} was not preserved"
50+
# Verify content is preserved
51+
assert f"{alert_type.lower()} alert" in normalized_doc, f"Content for {alert_type} was lost"
52+
# Verify quote formatting is preserved
53+
assert normalized_doc.startswith(">"), f"Quote formatting lost for {alert_type}"
54+
55+
56+
def test_lowercase_alert_normalized_to_uppercase():
57+
"""Test that lowercase alert types are normalized to uppercase."""
58+
input_doc = dedent(
59+
"""
60+
> [!note]
61+
> This lowercase alert should be normalized.
62+
"""
63+
).strip()
64+
65+
normalized_doc = fill_markdown(input_doc, semantic=True)
66+
67+
# Should be normalized to uppercase
68+
assert "> [!NOTE]" in normalized_doc
69+
assert "> [!note]" not in normalized_doc
70+
# Content preserved
71+
assert "normalized" in normalized_doc
72+
73+
74+
def test_misspelled_alert_preserves_quote():
75+
"""
76+
CRITICAL: Misspelled alert types must NOT cause quote formatting to be stripped.
77+
They should fall back to regular quote handling.
78+
"""
79+
test_cases = [
80+
("> [!NOOT]\n> Content here", "[!NOOT]"),
81+
("> [!WARNNG]\n> Content here", "[!WARNNG]"), # Missing 'I'
82+
("> [!WARNUNG]\n> Content here", "[!WARNUNG]"),
83+
("> [!NOTEE]\n> Content here", "[!NOTEE]"),
84+
("> [!HINT]\n> Content here", "[!HINT]"),
85+
]
86+
87+
for input_doc, misspelled_type in test_cases:
88+
normalized_doc = fill_markdown(input_doc, semantic=True)
89+
90+
# Quote formatting MUST be preserved
91+
assert normalized_doc.startswith(">"), f"Quote formatting lost for {misspelled_type}"
92+
# Content must be preserved (the misspelled type becomes part of the content)
93+
assert misspelled_type in normalized_doc, f"Content lost for {misspelled_type}"
94+
assert "Content here" in normalized_doc, f"Body content lost for {misspelled_type}"
95+
96+
97+
def test_unknown_alert_types_preserve_quote():
98+
"""
99+
CRITICAL: Unknown/custom alert types must NOT cause quote formatting to be stripped.
100+
"""
101+
test_cases = [
102+
"> [!FOO]\n> Foo type",
103+
"> [!CUSTOM]\n> Custom type",
104+
"> [!INFO]\n> Info type",
105+
"> [!DANGER]\n> Danger type",
106+
"> [!SUCCESS]\n> Success type",
107+
]
108+
109+
for input_doc in test_cases:
110+
normalized_doc = fill_markdown(input_doc, semantic=True)
111+
112+
# Quote formatting MUST be preserved
113+
assert normalized_doc.startswith(">"), f"Quote formatting lost for: {input_doc[:20]}"
114+
# All content must be preserved
115+
lines = input_doc.split("\n")
116+
for line in lines:
117+
# The content should be in the output (possibly reformatted)
118+
content = line.lstrip("> ")
119+
assert content in normalized_doc, f"Content '{content}' was lost"
120+
121+
122+
def test_empty_alert_type_preserves_quote():
123+
"""Empty alert brackets should not cause quote formatting to be stripped."""
124+
input_doc = "> [!]\n> Some content"
125+
normalized_doc = fill_markdown(input_doc, semantic=True)
126+
127+
assert normalized_doc.startswith(">"), "Quote formatting lost for empty alert type"
128+
assert "Some content" in normalized_doc
129+
130+
131+
def test_malformed_alert_preserves_quote():
132+
"""Malformed alert syntax should not cause quote formatting to be stripped."""
133+
test_cases = [
134+
"> [NOTE]\n> Missing exclamation mark",
135+
"> [!NOTE\n> Missing closing bracket",
136+
"> ![NOTE]\n> Wrong order of symbols",
137+
"> [! NOTE]\n> Space after exclamation",
138+
]
139+
140+
for input_doc in test_cases:
141+
normalized_doc = fill_markdown(input_doc, semantic=True)
142+
# Quote formatting MUST be preserved
143+
assert normalized_doc.startswith(">"), f"Quote formatting lost for: {input_doc[:30]}"
144+
145+
146+
def test_alert_with_multiline_content():
147+
"""Test alerts with multiple lines of content."""
148+
input_doc = dedent(
149+
"""
150+
> [!NOTE]
151+
> First line of content.
152+
> Second line of content.
153+
> Third line of content.
154+
"""
155+
).strip()
156+
157+
normalized_doc = fill_markdown(input_doc, semantic=True)
158+
159+
assert "> [!NOTE]" in normalized_doc
160+
# Content should be wrapped but preserved
161+
assert "First line" in normalized_doc
162+
assert "content" in normalized_doc
163+
164+
165+
def test_alert_with_multiple_paragraphs():
166+
"""Test alerts with multiple paragraphs separated by blank quote lines."""
167+
input_doc = dedent(
168+
"""
169+
> [!TIP]
170+
> First paragraph.
171+
>
172+
> Second paragraph.
173+
"""
174+
).strip()
175+
176+
# Note: blank lines in quotes are rendered as "> " (with trailing space)
177+
expected_doc = "> [!TIP]\n> First paragraph.\n> \n> Second paragraph.\n"
178+
179+
normalized_doc = fill_markdown(input_doc, semantic=True)
180+
assert normalized_doc == expected_doc
181+
182+
183+
def test_alert_with_code_block():
184+
"""Test alerts containing fenced code blocks."""
185+
input_doc = dedent(
186+
"""
187+
> [!WARNING]
188+
> Be careful with this code:
189+
>
190+
> ```python
191+
> dangerous_operation()
192+
> ```
193+
"""
194+
).strip()
195+
196+
normalized_doc = fill_markdown(input_doc, semantic=True)
197+
198+
assert "> [!WARNING]" in normalized_doc
199+
assert "```python" in normalized_doc
200+
assert "dangerous_operation()" in normalized_doc
201+
202+
203+
def test_alert_with_list():
204+
"""Test alerts containing bullet lists."""
205+
input_doc = dedent(
206+
"""
207+
> [!IMPORTANT]
208+
> Remember:
209+
>
210+
> - First item
211+
> - Second item
212+
"""
213+
).strip()
214+
215+
normalized_doc = fill_markdown(input_doc, semantic=True)
216+
217+
assert "> [!IMPORTANT]" in normalized_doc
218+
assert "First item" in normalized_doc
219+
assert "Second item" in normalized_doc
220+
221+
222+
def test_multiple_alerts_in_document():
223+
"""Test multiple alerts in the same document."""
224+
input_doc = dedent(
225+
"""
226+
> [!NOTE]
227+
> First note.
228+
229+
Some text between.
230+
231+
> [!WARNING]
232+
> A warning.
233+
"""
234+
).strip()
235+
236+
normalized_doc = fill_markdown(input_doc, semantic=True)
237+
238+
assert "> [!NOTE]" in normalized_doc
239+
assert "> [!WARNING]" in normalized_doc
240+
assert "First note" in normalized_doc
241+
assert "A warning" in normalized_doc
242+
assert "Some text between" in normalized_doc
243+
244+
245+
def test_alert_after_heading():
246+
"""Test alert immediately after a heading."""
247+
input_doc = dedent(
248+
"""
249+
## Section Title
250+
251+
> [!NOTE]
252+
> Important note for this section.
253+
"""
254+
).strip()
255+
256+
normalized_doc = fill_markdown(input_doc, semantic=True)
257+
258+
assert "## Section Title" in normalized_doc
259+
assert "> [!NOTE]" in normalized_doc
260+
assert "Important note" in normalized_doc
261+
262+
263+
def test_regular_quote_still_works():
264+
"""Ensure regular quotes without alert syntax still work correctly."""
265+
input_doc = dedent(
266+
"""
267+
> This is a regular quote.
268+
> It has multiple lines.
269+
"""
270+
).strip()
271+
272+
normalized_doc = fill_markdown(input_doc, semantic=True)
273+
274+
assert normalized_doc.startswith(">")
275+
assert "regular quote" in normalized_doc
276+
277+
278+
def test_quote_with_link_like_content():
279+
"""Test quote with content that looks like but isn't an alert."""
280+
input_doc = dedent(
281+
"""
282+
> Check out [!this link](https://example.com) for more info.
283+
"""
284+
).strip()
285+
286+
normalized_doc = fill_markdown(input_doc, semantic=True)
287+
288+
assert normalized_doc.startswith(">")
289+
assert "[!this link]" in normalized_doc or "this link" in normalized_doc

0 commit comments

Comments
 (0)