66
77Node construction goes through ``substack.nodes`` so the (undocumented) schema
88lives 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.
916"""
1017
1118from __future__ import annotations
1219
20+ import copy
1321from typing import Dict , List , Optional
1422
1523from markdown_it import MarkdownIt
@@ -46,7 +54,7 @@ def _coalesce(out_nodes: List[Dict]) -> List[Dict]:
4654 return merged
4755
4856
49- def _render_inline (node : SyntaxTreeNode , marks : List [Dict ]) -> List [Dict ]:
57+ def _render_inline (node : SyntaxTreeNode , marks : List [Dict ], ctx : Dict ) -> List [Dict ]:
5058 """Render an inline subtree into a flat list of text / anchor nodes."""
5159 out : List [Dict ] = []
5260 for child in node .children :
@@ -57,14 +65,17 @@ def _render_inline(node: SyntaxTreeNode, marks: List[Dict]) -> List[Dict]:
5765 elif t == "code_inline" :
5866 out .append (nodes .text (child .content , marks + [nodes .code_mark ()]))
5967 elif t in _MARK_FOR :
60- out .extend (_render_inline (child , marks + [_MARK_FOR [t ]]))
68+ out .extend (_render_inline (child , marks + [_MARK_FOR [t ]], ctx ))
6169 elif t == "link" :
6270 href = child .attrs .get ("href" , "" )
63- out .extend (_render_inline (child , marks + [nodes .link_mark (href )]))
71+ out .extend (_render_inline (child , marks + [nodes .link_mark (href )], ctx ))
6472 elif t in ("softbreak" , "hardbreak" ):
6573 out .append (nodes .text (" " , marks ))
6674 elif t == "footnote_ref" :
67- out .append (nodes .footnote_anchor (child .meta ["id" ] + 1 ))
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" ])))
6879 elif t == "image" :
6980 # Inline images are rare in this schema; fall back to alt text.
7081 alt = child .attrs .get ("alt" ) or "" .join (
@@ -107,7 +118,7 @@ def _captioned_image(img: SyntaxTreeNode, api) -> Dict:
107118 )
108119
109120
110- def _render_block (node : SyntaxTreeNode , api ) -> List [Dict ]:
121+ def _render_block (node : SyntaxTreeNode , api , ctx : Dict ) -> List [Dict ]:
111122 """Render a block-level node into zero or more Substack nodes."""
112123 t = node .type
113124
@@ -116,11 +127,11 @@ def _render_block(node: SyntaxTreeNode, api) -> List[Dict]:
116127 img = _only_image (inline )
117128 if img is not None :
118129 return [_captioned_image (img , api )]
119- return [nodes .paragraph (_render_inline (inline , []))]
130+ return [nodes .paragraph (_render_inline (inline , [], ctx ))]
120131
121132 if t == "heading" :
122133 level = int (node .tag [1 ])
123- return [nodes .heading (_render_inline (node .children [0 ], []), level = level )]
134+ return [nodes .heading (_render_inline (node .children [0 ], [], ctx ), level = level )]
124135
125136 if t == "hr" :
126137 return [nodes .horizontal_rule ()]
@@ -135,47 +146,61 @@ def _render_block(node: SyntaxTreeNode, api) -> List[Dict]:
135146 if t == "blockquote" :
136147 paras : List [Dict ] = []
137148 for child in node .children :
138- paras .extend (_render_block (child , api ))
149+ paras .extend (_render_block (child , api , ctx ))
139150 return [nodes .blockquote (paras )]
140151
141152 if t == "bullet_list" :
142- return [nodes .bullet_list (_render_list_items (node , api ))]
153+ return [nodes .bullet_list (_render_list_items (node , api , ctx ))]
143154
144155 if t == "ordered_list" :
145- return [nodes .ordered_list (_render_list_items (node , api ))]
146-
147- if t == "footnote_block" :
148- out = []
149- for fn in node .children :
150- number = fn .meta ["id" ] + 1
151- # Render every child block (paragraphs, lists, code, ...) so footnote
152- # content is preserved. The trailing footnote_anchor backref renders to
153- # nothing and is dropped.
154- children_nodes = []
155- for child in fn .children :
156- children_nodes .extend (_render_block (child , api ))
157- out .append (nodes .footnote (number , children_nodes ))
158- return out
156+ return [nodes .ordered_list (_render_list_items (node , api , ctx ))]
159157
158+ # footnote_block is handled separately in markdown_to_doc; ignore it here.
160159 return []
161160
162161
163- def _render_list_items (list_node : SyntaxTreeNode , api ) -> List [Dict ]:
162+ def _render_list_items (list_node : SyntaxTreeNode , api , ctx : Dict ) -> List [Dict ]:
164163 items = []
165164 for li in list_node .children :
166- # A list_item built by nodes.list_item wraps inline content in a single
167- # paragraph; here items may already contain block nodes, so build directly.
168165 content : List [Dict ] = []
169166 for child in li .children :
170- content .extend (_render_block (child , api ))
167+ content .extend (_render_block (child , api , ctx ))
171168 items .append ({"type" : NodeType .LIST_ITEM , "content" : content })
172169 return items
173170
174171
172+ def _footnote_definitions (tree : SyntaxTreeNode , api ) -> Dict [int , List [Dict ]]:
173+ """Map each footnote id to its rendered block content."""
174+ definitions : Dict [int , List [Dict ]] = {}
175+ for node in tree .children :
176+ if node .type != "footnote_block" :
177+ continue
178+ for fn in node .children :
179+ # A footnote's own content should not register anchors of its own.
180+ local_ctx = {"order" : []}
181+ content : List [Dict ] = []
182+ for child in fn .children :
183+ content .extend (_render_block (child , api , local_ctx ))
184+ definitions [fn .meta ["id" ]] = content
185+ return definitions
186+
187+
175188def markdown_to_doc (markdown_content : str , api = None ) -> List [Dict ]:
176189 """Convert Markdown into a list of Substack ProseMirror block nodes."""
177190 tree = SyntaxTreeNode (_make_parser ().parse (markdown_content ))
191+
192+ definitions = _footnote_definitions (tree , api )
193+
194+ ctx : Dict = {"order" : []}
178195 out : List [Dict ] = []
179196 for node in tree .children :
180- out .extend (_render_block (node , api ))
197+ if node .type == "footnote_block" :
198+ continue
199+ out .extend (_render_block (node , api , ctx ))
200+
201+ # Emit one footnote block per reference, in anchor order, numbered to match.
202+ for number , footnote_id in enumerate (ctx ["order" ], start = 1 ):
203+ content = copy .deepcopy (definitions .get (footnote_id , []))
204+ out .append (nodes .footnote (number , content ))
205+
181206 return out
0 commit comments