Skip to content

Commit 7ede834

Browse files
ASRagabAhmad Ragab
andauthored
feat: add blockquote support to from_markdown() and Post.blockquote() (#40)
Co-authored-by: Ahmad Ragab <ahmad.ragab.0001@gmail.com>
1 parent 3a3bfdf commit 7ede834

File tree

2 files changed

+185
-5
lines changed

2 files changed

+185
-5
lines changed

substack/post.py

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,42 @@ def heading(self, content=None, level: int = 1):
227227
item["level"] = level
228228
return self.add(item)
229229

230+
def blockquote(self, content=None):
231+
"""
232+
Add a blockquote to the post.
233+
234+
The blockquote wraps one or more paragraph nodes.
235+
236+
Args:
237+
content: Text string or list of inline token dicts. When a plain
238+
string is provided it is wrapped in a single paragraph node.
239+
240+
Returns:
241+
Self for method chaining.
242+
"""
243+
paragraphs: List[Dict] = []
244+
if content is not None:
245+
if isinstance(content, str):
246+
tokens = parse_inline(content)
247+
text_nodes = [
248+
{"type": "text", "text": t["content"]} for t in tokens if t
249+
]
250+
if text_nodes:
251+
paragraphs.append({"type": "paragraph", "content": text_nodes})
252+
elif isinstance(content, list):
253+
for item in content:
254+
if isinstance(item, dict) and item.get("type") == "paragraph":
255+
paragraphs.append(item)
256+
elif isinstance(item, dict):
257+
text_nodes = [{"type": "text", "text": item.get("content", "")}]
258+
paragraphs.append({"type": "paragraph", "content": text_nodes})
259+
260+
node: Dict = {"type": "blockquote"}
261+
if paragraphs:
262+
node["content"] = paragraphs
263+
self.draft_body["content"] = self.draft_body.get("content", []) + [node]
264+
return self
265+
230266
def horizontal_rule(self):
231267
"""
232268
@@ -464,6 +500,7 @@ def from_markdown(self, markdown_content: str, api=None):
464500
- Linked images: [![Alt](image_url)](link_url) - images that are also links
465501
- Links: [text](url) - inline links in paragraphs
466502
- Code blocks: Fenced code blocks with ```language or ```
503+
- Blockquotes: Lines starting with '>' (consecutive lines grouped)
467504
- Paragraphs: Regular text blocks
468505
- Bullet lists: Lines starting with '*' or '-'
469506
- Inline formatting: **bold** and *italic* within paragraphs
@@ -611,12 +648,14 @@ def from_markdown(self, markdown_content: str, api=None):
611648

612649
self.add({"type": "captionedImage", "src": image_url})
613650

614-
# Process paragraphs or bullet lists
651+
# Process paragraphs, bullet lists, or blockquotes
615652
else:
616653
if "\n" in text_content:
617654
# Process each line, grouping consecutive bullets
618-
# into a single bullet_list node
655+
# into a single bullet_list node and consecutive
656+
# blockquote lines into a single blockquote node.
619657
pending_bullets: List[List[Dict]] = []
658+
pending_quotes: List[str] = []
620659

621660
def flush_bullets():
622661
if not pending_bullets:
@@ -632,10 +671,36 @@ def flush_bullets():
632671
)
633672
pending_bullets.clear()
634673

674+
def flush_quotes():
675+
if not pending_quotes:
676+
return
677+
paragraphs: List[Dict] = []
678+
for quote_line in pending_quotes:
679+
tokens = parse_inline(quote_line)
680+
text_nodes = [
681+
{"type": "text", "text": t["content"]}
682+
for t in tokens if t
683+
]
684+
if text_nodes:
685+
paragraphs.append({"type": "paragraph", "content": text_nodes})
686+
node: Dict = {"type": "blockquote"}
687+
if paragraphs:
688+
node["content"] = paragraphs
689+
self.draft_body["content"].append(node)
690+
pending_quotes.clear()
691+
635692
for line in text_content.split("\n"):
636693
line = line.strip()
637694
if not line:
638695
flush_bullets()
696+
flush_quotes()
697+
continue
698+
699+
# Check for blockquote marker
700+
if line.startswith("> ") or line == ">":
701+
flush_bullets()
702+
quote_text = line[2:] if line.startswith("> ") else ""
703+
pending_quotes.append(quote_text)
639704
continue
640705

641706
# Check for bullet marker
@@ -648,18 +713,33 @@ def flush_bullets():
648713
bullet_text = line[1:].strip()
649714

650715
if bullet_text is not None:
716+
flush_quotes()
651717
tokens = parse_inline(bullet_text)
652718
if tokens:
653719
pending_bullets.append(tokens)
654720
else:
655721
flush_bullets()
722+
flush_quotes()
656723
tokens = parse_inline(line)
657724
self.add({"type": "paragraph", "content": tokens})
658725

659726
flush_bullets()
727+
flush_quotes()
660728
else:
661-
# Single paragraph
662-
tokens = parse_inline(text_content)
663-
self.add({"type": "paragraph", "content": tokens})
729+
# Single line — could be a blockquote or paragraph
730+
if text_content.startswith("> ") or text_content == ">":
731+
quote_text = text_content[2:] if text_content.startswith("> ") else ""
732+
tokens = parse_inline(quote_text)
733+
text_nodes = [
734+
{"type": "text", "text": t["content"]}
735+
for t in tokens if t
736+
]
737+
para = {"type": "paragraph", "content": text_nodes} if text_nodes else {"type": "paragraph"}
738+
self.draft_body["content"] = self.draft_body.get("content", []) + [
739+
{"type": "blockquote", "content": [para]}
740+
]
741+
else:
742+
tokens = parse_inline(text_content)
743+
self.add({"type": "paragraph", "content": tokens})
664744

665745
return self

tests/substack/test_post.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,103 @@ def test_marks_preserves_href_from_top_level(self):
8080
assert mark["attrs"]["href"] == "https://example.com"
8181
return
8282
raise AssertionError("No link mark found in output")
83+
84+
85+
class TestBlockquoteFromMarkdown:
86+
"""Tests for blockquote parsing in from_markdown()."""
87+
88+
def test_single_blockquote_line(self):
89+
"""A single '> text' line produces a blockquote with one paragraph."""
90+
post = Post(title="T", subtitle="S", user_id=1)
91+
post.from_markdown("> This is a quote")
92+
body = json.loads(post.get_draft()["draft_body"])
93+
bq = body["content"][0]
94+
assert bq["type"] == "blockquote"
95+
assert len(bq["content"]) == 1
96+
assert bq["content"][0]["type"] == "paragraph"
97+
assert bq["content"][0]["content"][0]["text"] == "This is a quote"
98+
99+
def test_multiline_blockquote_grouped(self):
100+
"""Consecutive '>' lines become a single blockquote with multiple paragraphs."""
101+
post = Post(title="T", subtitle="S", user_id=1)
102+
post.from_markdown("> Line one\n> Line two\n> Line three")
103+
body = json.loads(post.get_draft()["draft_body"])
104+
bq = body["content"][0]
105+
assert bq["type"] == "blockquote"
106+
assert len(bq["content"]) == 3
107+
texts = [p["content"][0]["text"] for p in bq["content"]]
108+
assert texts == ["Line one", "Line two", "Line three"]
109+
110+
def test_blockquote_separated_by_blank_line(self):
111+
"""A blank line between '>' groups creates two separate blockquotes."""
112+
post = Post(title="T", subtitle="S", user_id=1)
113+
post.from_markdown("> First block\n\n> Second block")
114+
body = json.loads(post.get_draft()["draft_body"])
115+
blockquotes = [n for n in body["content"] if n["type"] == "blockquote"]
116+
assert len(blockquotes) == 2
117+
118+
def test_blockquote_then_paragraph(self):
119+
"""A blockquote followed by a regular paragraph produces both node types."""
120+
post = Post(title="T", subtitle="S", user_id=1)
121+
post.from_markdown("> A quote\n\nA regular paragraph")
122+
body = json.loads(post.get_draft()["draft_body"])
123+
assert body["content"][0]["type"] == "blockquote"
124+
assert body["content"][1]["type"] == "paragraph"
125+
126+
def test_paragraph_blockquote_paragraph(self):
127+
"""Blockquote sandwiched between paragraphs preserves order."""
128+
post = Post(title="T", subtitle="S", user_id=1)
129+
post.from_markdown("Before\n\n> The quote\n\nAfter")
130+
body = json.loads(post.get_draft()["draft_body"])
131+
types = [n["type"] for n in body["content"]]
132+
assert types == ["paragraph", "blockquote", "paragraph"]
133+
134+
def test_blockquote_with_inline_link(self):
135+
"""Links inside blockquotes are parsed as marks."""
136+
post = Post(title="T", subtitle="S", user_id=1)
137+
post.from_markdown("> See [example](https://example.com)")
138+
body = json.loads(post.get_draft()["draft_body"])
139+
bq = body["content"][0]
140+
assert bq["type"] == "blockquote"
141+
para = bq["content"][0]
142+
assert para["type"] == "paragraph"
143+
144+
def test_blockquote_adjacent_to_bullet_list(self):
145+
"""Blockquote followed immediately by bullets flushes correctly."""
146+
post = Post(title="T", subtitle="S", user_id=1)
147+
post.from_markdown("> A quote\n- bullet one\n- bullet two")
148+
body = json.loads(post.get_draft()["draft_body"])
149+
types = [n["type"] for n in body["content"]]
150+
assert types == ["blockquote", "bullet_list"]
151+
152+
def test_empty_continuation_line(self):
153+
"""A bare '>' between quoted lines keeps them in one blockquote."""
154+
post = Post(title="T", subtitle="S", user_id=1)
155+
post.from_markdown("> First\n>\n> Third")
156+
body = json.loads(post.get_draft()["draft_body"])
157+
blockquotes = [n for n in body["content"] if n["type"] == "blockquote"]
158+
assert len(blockquotes) == 1
159+
paras_with_content = [p for p in blockquotes[0]["content"] if p.get("content")]
160+
assert len(paras_with_content) == 2
161+
162+
163+
class TestBlockquoteMethod:
164+
"""Tests for the Post.blockquote() convenience method."""
165+
166+
def test_blockquote_string(self):
167+
"""blockquote('text') wraps text in a blockquote node."""
168+
post = Post(title="T", subtitle="S", user_id=1)
169+
post.blockquote("Hello world")
170+
body = json.loads(post.get_draft()["draft_body"])
171+
bq = body["content"][0]
172+
assert bq["type"] == "blockquote"
173+
assert bq["content"][0]["content"][0]["text"] == "Hello world"
174+
175+
def test_blockquote_chaining(self):
176+
"""blockquote() returns self for method chaining."""
177+
post = Post(title="T", subtitle="S", user_id=1)
178+
result = post.blockquote("one").blockquote("two")
179+
assert result is post
180+
body = json.loads(post.get_draft()["draft_body"])
181+
blockquotes = [n for n in body["content"] if n["type"] == "blockquote"]
182+
assert len(blockquotes) == 2

0 commit comments

Comments
 (0)