Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 85 additions & 5 deletions substack/post.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
100 changes: 100 additions & 0 deletions tests/substack/test_post.py
Original file line number Diff line number Diff line change
Expand Up @@ -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