From eddc6b1f352a86e4790e564c2cb4ea75e89b4bfd Mon Sep 17 00:00:00 2001 From: Ahmad Ragab Date: Tue, 24 Mar 2026 02:20:19 +0000 Subject: [PATCH] feat: add blockquote support to from_markdown() and Post.blockquote() --- substack/post.py | 90 ++++++++++++++++++++++++++++++-- tests/substack/test_post.py | 100 ++++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 5 deletions(-) diff --git a/substack/post.py b/substack/post.py index 3d9c01f..60feaaf 100644 --- a/substack/post.py +++ b/substack/post.py @@ -227,6 +227,42 @@ def heading(self, content=None, level: int = 1): item["level"] = level return self.add(item) + def blockquote(self, content=None): + """ + Add a blockquote to the post. + + The blockquote wraps one or more paragraph nodes. + + Args: + content: Text string or list of inline token dicts. When a plain + string is provided it is wrapped in a single paragraph node. + + Returns: + Self for method chaining. + """ + paragraphs: List[Dict] = [] + if content is not None: + if isinstance(content, str): + tokens = parse_inline(content) + text_nodes = [ + {"type": "text", "text": t["content"]} for t in tokens if t + ] + if text_nodes: + paragraphs.append({"type": "paragraph", "content": text_nodes}) + elif isinstance(content, list): + for item in content: + if isinstance(item, dict) and item.get("type") == "paragraph": + paragraphs.append(item) + elif isinstance(item, dict): + text_nodes = [{"type": "text", "text": item.get("content", "")}] + paragraphs.append({"type": "paragraph", "content": text_nodes}) + + node: Dict = {"type": "blockquote"} + if paragraphs: + node["content"] = paragraphs + self.draft_body["content"] = self.draft_body.get("content", []) + [node] + return self + def horizontal_rule(self): """ @@ -464,6 +500,7 @@ def from_markdown(self, markdown_content: str, api=None): - Linked images: [![Alt](image_url)](link_url) - images that are also links - Links: [text](url) - inline links in paragraphs - Code blocks: Fenced code blocks with ```language or ``` + - Blockquotes: Lines starting with '>' (consecutive lines grouped) - Paragraphs: Regular text blocks - Bullet lists: Lines starting with '*' or '-' - Inline formatting: **bold** and *italic* within paragraphs @@ -611,12 +648,14 @@ def from_markdown(self, markdown_content: str, api=None): self.add({"type": "captionedImage", "src": image_url}) - # Process paragraphs or bullet lists + # Process paragraphs, bullet lists, or blockquotes else: if "\n" in text_content: # Process each line, grouping consecutive bullets - # into a single bullet_list node + # into a single bullet_list node and consecutive + # blockquote lines into a single blockquote node. pending_bullets: List[List[Dict]] = [] + pending_quotes: List[str] = [] def flush_bullets(): if not pending_bullets: @@ -632,10 +671,36 @@ def flush_bullets(): ) pending_bullets.clear() + def flush_quotes(): + if not pending_quotes: + return + paragraphs: List[Dict] = [] + for quote_line in pending_quotes: + tokens = parse_inline(quote_line) + text_nodes = [ + {"type": "text", "text": t["content"]} + for t in tokens if t + ] + if text_nodes: + paragraphs.append({"type": "paragraph", "content": text_nodes}) + node: Dict = {"type": "blockquote"} + if paragraphs: + node["content"] = paragraphs + self.draft_body["content"].append(node) + pending_quotes.clear() + for line in text_content.split("\n"): line = line.strip() if not line: flush_bullets() + flush_quotes() + continue + + # Check for blockquote marker + if line.startswith("> ") or line == ">": + flush_bullets() + quote_text = line[2:] if line.startswith("> ") else "" + pending_quotes.append(quote_text) continue # Check for bullet marker @@ -648,18 +713,33 @@ def flush_bullets(): bullet_text = line[1:].strip() if bullet_text is not None: + flush_quotes() tokens = parse_inline(bullet_text) if tokens: pending_bullets.append(tokens) else: flush_bullets() + flush_quotes() tokens = parse_inline(line) self.add({"type": "paragraph", "content": tokens}) flush_bullets() + flush_quotes() else: - # Single paragraph - tokens = parse_inline(text_content) - self.add({"type": "paragraph", "content": tokens}) + # Single line — could be a blockquote or paragraph + if text_content.startswith("> ") or text_content == ">": + quote_text = text_content[2:] if text_content.startswith("> ") else "" + tokens = parse_inline(quote_text) + text_nodes = [ + {"type": "text", "text": t["content"]} + for t in tokens if t + ] + para = {"type": "paragraph", "content": text_nodes} if text_nodes else {"type": "paragraph"} + self.draft_body["content"] = self.draft_body.get("content", []) + [ + {"type": "blockquote", "content": [para]} + ] + else: + tokens = parse_inline(text_content) + self.add({"type": "paragraph", "content": tokens}) return self diff --git a/tests/substack/test_post.py b/tests/substack/test_post.py index c8e1512..701c2a2 100644 --- a/tests/substack/test_post.py +++ b/tests/substack/test_post.py @@ -80,3 +80,103 @@ def test_marks_preserves_href_from_top_level(self): assert mark["attrs"]["href"] == "https://example.com" return raise AssertionError("No link mark found in output") + + +class TestBlockquoteFromMarkdown: + """Tests for blockquote parsing in from_markdown().""" + + def test_single_blockquote_line(self): + """A single '> text' line produces a blockquote with one paragraph.""" + post = Post(title="T", subtitle="S", user_id=1) + post.from_markdown("> This is a quote") + body = json.loads(post.get_draft()["draft_body"]) + bq = body["content"][0] + assert bq["type"] == "blockquote" + assert len(bq["content"]) == 1 + assert bq["content"][0]["type"] == "paragraph" + assert bq["content"][0]["content"][0]["text"] == "This is a quote" + + def test_multiline_blockquote_grouped(self): + """Consecutive '>' lines become a single blockquote with multiple paragraphs.""" + post = Post(title="T", subtitle="S", user_id=1) + post.from_markdown("> Line one\n> Line two\n> Line three") + body = json.loads(post.get_draft()["draft_body"]) + bq = body["content"][0] + assert bq["type"] == "blockquote" + assert len(bq["content"]) == 3 + texts = [p["content"][0]["text"] for p in bq["content"]] + assert texts == ["Line one", "Line two", "Line three"] + + def test_blockquote_separated_by_blank_line(self): + """A blank line between '>' groups creates two separate blockquotes.""" + post = Post(title="T", subtitle="S", user_id=1) + post.from_markdown("> First block\n\n> Second block") + body = json.loads(post.get_draft()["draft_body"]) + blockquotes = [n for n in body["content"] if n["type"] == "blockquote"] + assert len(blockquotes) == 2 + + def test_blockquote_then_paragraph(self): + """A blockquote followed by a regular paragraph produces both node types.""" + post = Post(title="T", subtitle="S", user_id=1) + post.from_markdown("> A quote\n\nA regular paragraph") + body = json.loads(post.get_draft()["draft_body"]) + assert body["content"][0]["type"] == "blockquote" + assert body["content"][1]["type"] == "paragraph" + + def test_paragraph_blockquote_paragraph(self): + """Blockquote sandwiched between paragraphs preserves order.""" + post = Post(title="T", subtitle="S", user_id=1) + post.from_markdown("Before\n\n> The quote\n\nAfter") + body = json.loads(post.get_draft()["draft_body"]) + types = [n["type"] for n in body["content"]] + assert types == ["paragraph", "blockquote", "paragraph"] + + def test_blockquote_with_inline_link(self): + """Links inside blockquotes are parsed as marks.""" + post = Post(title="T", subtitle="S", user_id=1) + post.from_markdown("> See [example](https://example.com)") + body = json.loads(post.get_draft()["draft_body"]) + bq = body["content"][0] + assert bq["type"] == "blockquote" + para = bq["content"][0] + assert para["type"] == "paragraph" + + def test_blockquote_adjacent_to_bullet_list(self): + """Blockquote followed immediately by bullets flushes correctly.""" + post = Post(title="T", subtitle="S", user_id=1) + post.from_markdown("> A quote\n- bullet one\n- bullet two") + body = json.loads(post.get_draft()["draft_body"]) + types = [n["type"] for n in body["content"]] + assert types == ["blockquote", "bullet_list"] + + def test_empty_continuation_line(self): + """A bare '>' between quoted lines keeps them in one blockquote.""" + post = Post(title="T", subtitle="S", user_id=1) + post.from_markdown("> First\n>\n> Third") + body = json.loads(post.get_draft()["draft_body"]) + blockquotes = [n for n in body["content"] if n["type"] == "blockquote"] + assert len(blockquotes) == 1 + paras_with_content = [p for p in blockquotes[0]["content"] if p.get("content")] + assert len(paras_with_content) == 2 + + +class TestBlockquoteMethod: + """Tests for the Post.blockquote() convenience method.""" + + def test_blockquote_string(self): + """blockquote('text') wraps text in a blockquote node.""" + post = Post(title="T", subtitle="S", user_id=1) + post.blockquote("Hello world") + body = json.loads(post.get_draft()["draft_body"]) + bq = body["content"][0] + assert bq["type"] == "blockquote" + assert bq["content"][0]["content"][0]["text"] == "Hello world" + + def test_blockquote_chaining(self): + """blockquote() returns self for method chaining.""" + post = Post(title="T", subtitle="S", user_id=1) + result = post.blockquote("one").blockquote("two") + assert result is post + body = json.loads(post.get_draft()["draft_body"]) + blockquotes = [n for n in body["content"] if n["type"] == "blockquote"] + assert len(blockquotes) == 2