Skip to content

Commit 101590a

Browse files
authored
Merge pull request #61 from MattFisher/markdown-it-from-markdown
Alternative: render from_markdown via markdown-it-py
2 parents 0fc190b + 498bf51 commit 101590a

14 files changed

Lines changed: 1830 additions & 495 deletions

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ post.from_markdown(footnote_markdown, api=api)
171171
post.paragraph(content=[{"content": "Some claim."}]).footnote_anchor(1)
172172
post.footnote(1, "The note text, with **formatting** allowed.")
173173

174+
174175
draft = api.post_draft(post.get_draft())
175176

176177
# set section (can only be done after first posting the draft)

‎poetry.lock‎

Lines changed: 39 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎pyproject.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ python = "<4.0,>=3.10"
2121
requests = "^2.32.0"
2222
python-dotenv = "^1.2.1"
2323
PyYAML = "^6.0"
24+
markdown-it-py = "^3.0"
25+
mdit-py-plugins = "^0.4"
2426

2527
[tool.poetry.group.dev.dependencies]
2628

‎substack/mdrender.py‎

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
"""Markdown -> Substack ProseMirror via markdown-it-py.
2+
3+
Implements Post.from_markdown() using a real CommonMark parser (markdown-it-py)
4+
plus the standard footnote plugin, with a small renderer that walks the syntax
5+
tree into Substack's node schema.
6+
7+
Node construction goes through ``substack.nodes`` so the (undocumented) schema
8+
lives in exactly one place.
9+
10+
Footnotes: Substack numbers footnote anchors by their position in the document
11+
and pairs them one-to-one, in order, with the footnote blocks at the end (it
12+
ignores any explicit number and does not support one block serving several
13+
anchors). So each reference is emitted as its own sequentially-numbered anchor,
14+
and a matching footnote block is appended for each -- a definition referenced
15+
more than once is duplicated, which mirrors how Substack's own editor behaves.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import copy
21+
from typing import Dict, List, Optional
22+
23+
from markdown_it import MarkdownIt
24+
from markdown_it.tree import SyntaxTreeNode
25+
from mdit_py_plugins.footnote import footnote_plugin
26+
27+
from substack import nodes
28+
from substack.nodes import MarkType, NodeType
29+
30+
_MARK_FOR = {
31+
"strong": {"type": MarkType.STRONG},
32+
"em": {"type": MarkType.EM},
33+
"s": {"type": MarkType.STRIKETHROUGH},
34+
}
35+
36+
37+
def _make_parser() -> MarkdownIt:
38+
return MarkdownIt("commonmark").use(footnote_plugin).enable("strikethrough")
39+
40+
41+
def _coalesce(out_nodes: List[Dict]) -> List[Dict]:
42+
"""Merge adjacent text nodes that carry identical marks (e.g. softbreaks)."""
43+
merged: List[Dict] = []
44+
for node in out_nodes:
45+
if (
46+
merged
47+
and node.get("type") == NodeType.TEXT
48+
and merged[-1].get("type") == NodeType.TEXT
49+
and node.get("marks") == merged[-1].get("marks")
50+
):
51+
merged[-1]["text"] += node["text"]
52+
else:
53+
merged.append(node)
54+
return merged
55+
56+
57+
def _render_inline(node: SyntaxTreeNode, marks: List[Dict], ctx: Dict) -> List[Dict]:
58+
"""Render an inline subtree into a flat list of text / anchor nodes."""
59+
out: List[Dict] = []
60+
for child in node.children:
61+
t = child.type
62+
if t == "text":
63+
if child.content:
64+
out.append(nodes.text(child.content, marks))
65+
elif t == "code_inline":
66+
out.append(nodes.text(child.content, marks + [nodes.code_mark()]))
67+
elif t in _MARK_FOR:
68+
out.extend(_render_inline(child, marks + [_MARK_FOR[t]], ctx))
69+
elif t == "link":
70+
href = child.attrs.get("href", "")
71+
out.extend(_render_inline(child, marks + [nodes.link_mark(href)], ctx))
72+
elif t in ("softbreak", "hardbreak"):
73+
out.append(nodes.text(" ", marks))
74+
elif t == "footnote_ref":
75+
# Number anchors by document position and record which definition each
76+
# one points to, so matching blocks can be emitted 1:1 afterwards.
77+
ctx["order"].append(child.meta["id"])
78+
out.append(nodes.footnote_anchor(len(ctx["order"])))
79+
elif t == "image":
80+
# Inline images are rare in this schema; fall back to alt text.
81+
alt = child.attrs.get("alt") or "".join(
82+
c.content for c in child.children if c.type == "text"
83+
)
84+
if alt:
85+
out.append(nodes.text(alt, marks))
86+
return _coalesce(out)
87+
88+
89+
def _only_image(inline: SyntaxTreeNode) -> Optional[SyntaxTreeNode]:
90+
"""If an inline node is just an image (optionally wrapped in a link), return it."""
91+
kids = [c for c in inline.children if c.type != "softbreak"]
92+
if len(kids) == 1 and kids[0].type == "image":
93+
return kids[0]
94+
if len(kids) == 1 and kids[0].type == "link":
95+
inner = [c for c in kids[0].children if c.type != "softbreak"]
96+
if len(inner) == 1 and inner[0].type == "image":
97+
img = inner[0]
98+
img._link_href = kids[0].attrs.get("href") # type: ignore[attr-defined]
99+
return img
100+
return None
101+
102+
103+
def _captioned_image(img: SyntaxTreeNode, api) -> Dict:
104+
src = img.attrs.get("src", "")
105+
if src.startswith("/"):
106+
src = src[1:]
107+
if api is not None and not src.startswith("http"):
108+
try:
109+
src = api.get_image(src).get("url")
110+
except Exception:
111+
pass
112+
# markdown-it stores the image alt text as the node's content, not in attrs.
113+
alt = img.content or img.attrs.get("alt") or None
114+
# Standard markdown image title `![alt](src "caption")` maps to Substack's caption node.
115+
title = img.attrs.get("title") or None
116+
caption = [nodes.text(title)] if title else None
117+
return nodes.captioned_image(
118+
src,
119+
alt=alt,
120+
href=getattr(img, "_link_href", None),
121+
caption=caption,
122+
)
123+
124+
125+
def _render_block(node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
126+
"""Render a block-level node into zero or more Substack nodes."""
127+
t = node.type
128+
129+
if t == "paragraph":
130+
inline = node.children[0]
131+
img = _only_image(inline)
132+
if img is not None:
133+
return [_captioned_image(img, api)]
134+
return [nodes.paragraph(_render_inline(inline, [], ctx))]
135+
136+
if t == "heading":
137+
level = int(node.tag[1])
138+
return [nodes.heading(_render_inline(node.children[0], [], ctx), level=level)]
139+
140+
if t == "hr":
141+
return [nodes.horizontal_rule()]
142+
143+
if t in ("fence", "code_block"):
144+
return [
145+
nodes.code_block(
146+
node.content.rstrip("\n"), language=node.info.strip() or None
147+
)
148+
]
149+
150+
if t == "blockquote":
151+
paras: List[Dict] = []
152+
for child in node.children:
153+
paras.extend(_render_block(child, api, ctx))
154+
return [nodes.blockquote(paras)]
155+
156+
if t == "bullet_list":
157+
return [nodes.bullet_list(_render_list_items(node, api, ctx))]
158+
159+
if t == "ordered_list":
160+
return [nodes.ordered_list(_render_list_items(node, api, ctx))]
161+
162+
# footnote_block is handled separately in markdown_to_doc; ignore it here.
163+
return []
164+
165+
166+
def _render_list_items(list_node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
167+
items = []
168+
for li in list_node.children:
169+
content: List[Dict] = []
170+
for child in li.children:
171+
content.extend(_render_block(child, api, ctx))
172+
items.append({"type": NodeType.LIST_ITEM, "content": content})
173+
return items
174+
175+
176+
def _footnote_definitions(tree: SyntaxTreeNode, api) -> Dict[int, List[Dict]]:
177+
"""Map each footnote id to its rendered block content."""
178+
definitions: Dict[int, List[Dict]] = {}
179+
for node in tree.children:
180+
if node.type != "footnote_block":
181+
continue
182+
for fn in node.children:
183+
# A footnote's own content should not register anchors of its own.
184+
local_ctx = {"order": []}
185+
content: List[Dict] = []
186+
for child in fn.children:
187+
content.extend(_render_block(child, api, local_ctx))
188+
definitions[fn.meta["id"]] = content
189+
return definitions
190+
191+
192+
def markdown_to_doc(markdown_content: str, api=None) -> List[Dict]:
193+
"""Convert Markdown into a list of Substack ProseMirror block nodes."""
194+
tree = SyntaxTreeNode(_make_parser().parse(markdown_content))
195+
196+
definitions = _footnote_definitions(tree, api)
197+
198+
ctx: Dict = {"order": []}
199+
out: List[Dict] = []
200+
for node in tree.children:
201+
if node.type == "footnote_block":
202+
continue
203+
out.extend(_render_block(node, api, ctx))
204+
205+
# Emit one footnote block per reference, in anchor order, numbered to match.
206+
for number, footnote_id in enumerate(ctx["order"], start=1):
207+
content = copy.deepcopy(definitions.get(footnote_id, []))
208+
out.append(nodes.footnote(number, content))
209+
210+
return out

0 commit comments

Comments
 (0)