python-substack 0.7.0__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-substack
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Write and safely manage Substack drafts from Markdown with Python, CLI, and MCP.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-substack"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "Write and safely manage Substack drafts from Markdown with Python, CLI, and MCP."
5
5
  authors = ["Paolo Mazza <mazzapaolo2019@gmail.com>"]
6
6
  license = "MIT"
@@ -3,7 +3,7 @@
3
3
  __author__ = "Paolo Mazza"
4
4
  __email__ = "mazzapaolo2019@gmail.com"
5
5
  __license__ = "MIT License"
6
- __version__ = "0.7.0"
6
+ __version__ = "0.8.0"
7
7
  __url__ = "https://github.com/ma2za/python-substack"
8
8
  __download_url__ = "https://pypi.python.org/pypi/python-substack"
9
9
  __description__ = (
@@ -478,6 +478,7 @@ class Api:
478
478
  draft_section_id: int = None,
479
479
  tags=None,
480
480
  dry_run: bool = False,
481
+ allow_unsupported_change: bool = False,
481
482
  ) -> dict:
482
483
  """
483
484
  Update an existing draft body from Markdown, with optional metadata changes.
@@ -497,12 +498,7 @@ class Api:
497
498
  if not isinstance(draft_body, dict):
498
499
  raise ValueError("Malformed draft body: draft_body must be a JSON object")
499
500
 
500
- _, unsupported_nodes = document_to_markdown(draft_body)
501
- if unsupported_nodes:
502
- raise ValueError(
503
- "Refusing to update: remote draft contains unsupported Substack nodes. "
504
- "Export it first or remove the nodes manually to avoid data loss."
505
- )
501
+ _, remote_unsupported = document_to_markdown(draft_body)
506
502
 
507
503
  post = Post(
508
504
  title=draft.get("title", ""),
@@ -515,6 +511,25 @@ class Api:
515
511
  )
516
512
  post.from_markdown(markdown, api=self)
517
513
 
514
+ _, submitted_unsupported = document_to_markdown(post.draft_body)
515
+
516
+ if not allow_unsupported_change:
517
+ remote_serialized = [
518
+ json.dumps(n, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
519
+ for n in remote_unsupported
520
+ ]
521
+ submitted_serialized = [
522
+ json.dumps(n, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
523
+ for n in submitted_unsupported
524
+ ]
525
+ if sorted(remote_serialized) != sorted(submitted_serialized):
526
+ raise ValueError(
527
+ "Refusing to update: remote unsupported nodes are missing, duplicated, stale, or altered. "
528
+ "Export the draft first or set allow_unsupported_change=True to proceed."
529
+ )
530
+
531
+ unsupported_nodes = remote_unsupported
532
+
518
533
  update_payload = {"draft_body": json.dumps(post.draft_body)}
519
534
 
520
535
  if subtitle is not None:
@@ -310,6 +310,9 @@ def _drafts_create(api, args):
310
310
 
311
311
 
312
312
  def _drafts_update(api, args):
313
+ if args.allow_unsupported_change and not args.yes:
314
+ raise CLIUsageError("--yes is required when using --allow-unsupported-change")
315
+
313
316
  if not args.yes and (args.json_output or not sys.stdin.isatty()):
314
317
  raise CLIUsageError("--yes is required in non-interactive or JSON mode")
315
318
 
@@ -347,6 +350,7 @@ def _drafts_update(api, args):
347
350
  draft_section_id=args.draft_section_id,
348
351
  tags=args.tags,
349
352
  dry_run=args.dry_run,
353
+ allow_unsupported_change=args.allow_unsupported_change,
350
354
  )
351
355
 
352
356
  if args.json_output:
@@ -508,6 +512,7 @@ def _build_parser():
508
512
  drafts_update.add_argument("--draft-section-id", type=int)
509
513
  drafts_update.add_argument("--tag", action="append", dest="tags", metavar="TAG")
510
514
  drafts_update.add_argument("--dry-run", action="store_true")
515
+ drafts_update.add_argument("--allow-unsupported-change", action="store_true")
511
516
  drafts_update.add_argument("--yes", action="store_true")
512
517
  drafts_update.set_defaults(handler=_drafts_update)
513
518
 
@@ -1,248 +1,305 @@
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.container import container_plugin
26
- from mdit_py_plugins.dollarmath import dollarmath_plugin
27
- from mdit_py_plugins.footnote import footnote_plugin
28
- from mdit_py_plugins.subscript import sub_plugin
29
- from mdit_py_plugins.superscript import superscript_plugin
30
-
31
- from substack import nodes
32
- from substack.nodes import MarkType, NodeType
33
-
34
- _MARK_FOR = {
35
- "strong": {"type": MarkType.STRONG},
36
- "em": {"type": MarkType.EM},
37
- "s": {"type": MarkType.STRIKETHROUGH},
38
- "sup": {"type": MarkType.SUPERSCRIPT},
39
- "sub": {"type": MarkType.SUBSCRIPT},
40
- }
41
-
42
-
43
- def _make_parser() -> MarkdownIt:
44
- return (
45
- MarkdownIt("commonmark")
46
- .use(footnote_plugin)
47
- # Pandoc-style delimiters: no whitespace just inside the dollars and no
48
- # digit just outside them, so paired currency amounts ("$5 ... $10")
49
- # stay plain text instead of becoming math.
50
- .use(dollarmath_plugin, allow_space=False, allow_digits=False)
51
- .use(sub_plugin)
52
- .use(superscript_plugin)
53
- .use(container_plugin, name="pullquote")
54
- .use(container_plugin, name="callout")
55
- .enable("strikethrough")
56
- )
57
-
58
-
59
- def _coalesce(out_nodes: List[Dict]) -> List[Dict]:
60
- """Merge adjacent text nodes that carry identical marks (e.g. softbreaks)."""
61
- merged: List[Dict] = []
62
- for node in out_nodes:
63
- if (
64
- merged
65
- and node.get("type") == NodeType.TEXT
66
- and merged[-1].get("type") == NodeType.TEXT
67
- and node.get("marks") == merged[-1].get("marks")
68
- ):
69
- merged[-1]["text"] += node["text"]
70
- else:
71
- merged.append(node)
72
- return merged
73
-
74
-
75
- def _render_inline(node: SyntaxTreeNode, marks: List[Dict], ctx: Dict) -> List[Dict]:
76
- """Render an inline subtree into a flat list of text / anchor nodes."""
77
- out: List[Dict] = []
78
- for child in node.children:
79
- t = child.type
80
- if t == "text":
81
- if child.content:
82
- out.append(nodes.text(child.content, marks))
83
- elif t == "code_inline":
84
- out.append(nodes.text(child.content, marks + [nodes.code_mark()]))
85
- elif t == "math_inline":
86
- out.append(nodes.latex_inline(child.content.strip()))
87
- elif t in _MARK_FOR:
88
- out.extend(_render_inline(child, marks + [_MARK_FOR[t]], ctx))
89
- elif t == "link":
90
- href = child.attrs.get("href", "")
91
- out.extend(_render_inline(child, marks + [nodes.link_mark(href)], ctx))
92
- elif t in ("softbreak", "hardbreak"):
93
- out.append(nodes.text(" ", marks))
94
- elif t == "footnote_ref":
95
- # Number anchors by document position and record which definition each
96
- # one points to, so matching blocks can be emitted 1:1 afterwards.
97
- ctx["order"].append(child.meta["id"])
98
- out.append(nodes.footnote_anchor(len(ctx["order"])))
99
- elif t == "image":
100
- # Inline images are rare in this schema; fall back to alt text.
101
- alt = child.attrs.get("alt") or "".join(
102
- c.content for c in child.children if c.type == "text"
103
- )
104
- if alt:
105
- out.append(nodes.text(alt, marks))
106
- return _coalesce(out)
107
-
108
-
109
- def _only_image(inline: SyntaxTreeNode) -> Optional[SyntaxTreeNode]:
110
- """If an inline node is just an image (optionally wrapped in a link), return it."""
111
- kids = [c for c in inline.children if c.type != "softbreak"]
112
- if len(kids) == 1 and kids[0].type == "image":
113
- return kids[0]
114
- if len(kids) == 1 and kids[0].type == "link":
115
- inner = [c for c in kids[0].children if c.type != "softbreak"]
116
- if len(inner) == 1 and inner[0].type == "image":
117
- img = inner[0]
118
- img._link_href = kids[0].attrs.get("href") # type: ignore[attr-defined]
119
- return img
120
- return None
121
-
122
-
123
- def _captioned_image(img: SyntaxTreeNode, api) -> Dict:
124
- src = img.attrs.get("src", "")
125
- if src.startswith("/"):
126
- src = src[1:]
127
- if api is not None and not src.startswith("http"):
128
- try:
129
- src = api.get_image(src).get("url")
130
- except Exception:
131
- pass
132
- # markdown-it stores the image alt text as the node's content, not in attrs.
133
- alt = img.content or img.attrs.get("alt") or None
134
- # Standard markdown image title `![alt](src "caption")` maps to Substack's caption node.
135
- title = img.attrs.get("title") or None
136
- caption = [nodes.text(title)] if title else None
137
- return nodes.captioned_image(
138
- src,
139
- alt=alt,
140
- href=getattr(img, "_link_href", None),
141
- caption=caption,
142
- )
143
-
144
-
145
- def _render_block(node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
146
- """Render a block-level node into zero or more Substack nodes."""
147
- t = node.type
148
-
149
- if t == "paragraph":
150
- inline = node.children[0]
151
- img = _only_image(inline)
152
- if img is not None:
153
- return [_captioned_image(img, api)]
154
- return [nodes.paragraph(_render_inline(inline, [], ctx))]
155
-
156
- if t == "heading":
157
- level = int(node.tag[1])
158
- return [nodes.heading(_render_inline(node.children[0], [], ctx), level=level)]
159
-
160
- if t == "hr":
161
- return [nodes.horizontal_rule()]
162
-
163
- if t in ("fence", "code_block"):
164
- return [
165
- nodes.code_block(
166
- node.content.rstrip("\n"), language=node.info.strip() or None
167
- )
168
- ]
169
-
170
- if t == "blockquote":
171
- paras: List[Dict] = []
172
- for child in node.children:
173
- paras.extend(_render_block(child, api, ctx))
174
- return [nodes.blockquote(paras)]
175
-
176
- if t == "bullet_list":
177
- return [nodes.bullet_list(_render_list_items(node, api, ctx))]
178
-
179
- if t == "ordered_list":
180
- return [nodes.ordered_list(_render_list_items(node, api, ctx))]
181
-
182
- # "$$...$$ (label)" tokenizes as math_block_label; Substack has no equation
183
- # labels, so it renders like an unlabeled block.
184
- if t in ("math_block", "math_block_label"):
185
- return [nodes.latex_block(node.content.strip())]
186
-
187
- if t == "container_pullquote":
188
- return [nodes.pullquote(_render_container_body(node, api, ctx))]
189
-
190
- if t == "container_callout":
191
- return [nodes.callout_block(_render_container_body(node, api, ctx))]
192
-
193
- # footnote_block is handled separately in markdown_to_doc; ignore it here.
194
- return []
195
-
196
-
197
- def _render_container_body(node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
198
- body: List[Dict] = []
199
- for child in node.children:
200
- body.extend(_render_block(child, api, ctx))
201
- return body
202
-
203
-
204
- def _render_list_items(list_node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
205
- items = []
206
- for li in list_node.children:
207
- content: List[Dict] = []
208
- for child in li.children:
209
- content.extend(_render_block(child, api, ctx))
210
- items.append({"type": NodeType.LIST_ITEM, "content": content})
211
- return items
212
-
213
-
214
- def _footnote_definitions(tree: SyntaxTreeNode, api) -> Dict[int, List[Dict]]:
215
- """Map each footnote id to its rendered block content."""
216
- definitions: Dict[int, List[Dict]] = {}
217
- for node in tree.children:
218
- if node.type != "footnote_block":
219
- continue
220
- for fn in node.children:
221
- # A footnote's own content should not register anchors of its own.
222
- local_ctx = {"order": []}
223
- content: List[Dict] = []
224
- for child in fn.children:
225
- content.extend(_render_block(child, api, local_ctx))
226
- definitions[fn.meta["id"]] = content
227
- return definitions
228
-
229
-
230
- def markdown_to_doc(markdown_content: str, api=None) -> List[Dict]:
231
- """Convert Markdown into a list of Substack ProseMirror block nodes."""
232
- tree = SyntaxTreeNode(_make_parser().parse(markdown_content))
233
-
234
- definitions = _footnote_definitions(tree, api)
235
-
236
- ctx: Dict = {"order": []}
237
- out: List[Dict] = []
238
- for node in tree.children:
239
- if node.type == "footnote_block":
240
- continue
241
- out.extend(_render_block(node, api, ctx))
242
-
243
- # Emit one footnote block per reference, in anchor order, numbered to match.
244
- for number, footnote_id in enumerate(ctx["order"], start=1):
245
- content = copy.deepcopy(definitions.get(footnote_id, []))
246
- out.append(nodes.footnote(number, content))
247
-
248
- return out
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 base64
21
+ import copy
22
+ import json
23
+ import re
24
+ from typing import Dict, List, Optional
25
+
26
+ from markdown_it import MarkdownIt
27
+ from markdown_it.tree import SyntaxTreeNode
28
+ from mdit_py_plugins.container import container_plugin
29
+ from mdit_py_plugins.dollarmath import dollarmath_plugin
30
+ from mdit_py_plugins.footnote import footnote_plugin
31
+ from mdit_py_plugins.subscript import sub_plugin
32
+ from mdit_py_plugins.superscript import superscript_plugin
33
+
34
+ from substack import nodes
35
+ from substack.nodes import MarkType, NodeType
36
+
37
+ _MARK_FOR = {
38
+ "strong": {"type": MarkType.STRONG},
39
+ "em": {"type": MarkType.EM},
40
+ "s": {"type": MarkType.STRIKETHROUGH},
41
+ "sup": {"type": MarkType.SUPERSCRIPT},
42
+ "sub": {"type": MarkType.SUBSCRIPT},
43
+ }
44
+
45
+
46
+ def parse_node_marker(comment_content: str) -> dict | None:
47
+ """
48
+ Parse a python-substack-node:v1 comment marker and return the parsed JSON dictionary.
49
+
50
+ If it is not a python-substack-node:v1 marker, returns None.
51
+ If it is an attempted marker but is corrupt/malformed, raises ValueError.
52
+ """
53
+ clean = comment_content.strip()
54
+ match = re.match(r"^<!--\s*python-substack-node:v1\s+([A-Za-z0-9_-]+=*)\s*-->$", clean)
55
+ if not match:
56
+ if "python-substack-node:v1" in clean:
57
+ raise ValueError("Corrupt marker format")
58
+ return None
59
+
60
+ encoded = match.group(1)
61
+
62
+ try:
63
+ padding = len(encoded) % 4
64
+ if padding:
65
+ encoded += "=" * (4 - padding)
66
+ decoded_bytes = base64.urlsafe_b64decode(encoded.encode("ascii"))
67
+ except Exception as exc:
68
+ raise ValueError("Invalid URL-safe base64 in marker") from exc
69
+
70
+ try:
71
+ decoded_str = decoded_bytes.decode("utf-8")
72
+ except Exception as exc:
73
+ raise ValueError("Invalid UTF-8 in marker") from exc
74
+
75
+ try:
76
+ data = json.loads(decoded_str)
77
+ except Exception as exc:
78
+ raise ValueError("Invalid JSON in marker") from exc
79
+
80
+ if not isinstance(data, dict):
81
+ raise ValueError("Marker payload is not a top-level JSON object")
82
+
83
+ node_type = data.get("type")
84
+ if not isinstance(node_type, str) or not node_type:
85
+ raise ValueError("Marker payload has missing or empty 'type'")
86
+
87
+ return data
88
+
89
+
90
+ def _make_parser() -> MarkdownIt:
91
+ return (
92
+ MarkdownIt("commonmark")
93
+ .use(footnote_plugin)
94
+ # Pandoc-style delimiters: no whitespace just inside the dollars and no
95
+ # digit just outside them, so paired currency amounts ("$5 ... $10")
96
+ # stay plain text instead of becoming math.
97
+ .use(dollarmath_plugin, allow_space=False, allow_digits=False)
98
+ .use(sub_plugin)
99
+ .use(superscript_plugin)
100
+ .use(container_plugin, name="pullquote")
101
+ .use(container_plugin, name="callout")
102
+ .enable("strikethrough")
103
+ )
104
+
105
+
106
+ def _coalesce(out_nodes: List[Dict]) -> List[Dict]:
107
+ """Merge adjacent text nodes that carry identical marks (e.g. softbreaks)."""
108
+ merged: List[Dict] = []
109
+ for node in out_nodes:
110
+ if (
111
+ merged
112
+ and node.get("type") == NodeType.TEXT
113
+ and merged[-1].get("type") == NodeType.TEXT
114
+ and node.get("marks") == merged[-1].get("marks")
115
+ ):
116
+ merged[-1]["text"] += node["text"]
117
+ else:
118
+ merged.append(node)
119
+ return merged
120
+
121
+
122
+ def _render_inline(node: SyntaxTreeNode, marks: List[Dict], ctx: Dict) -> List[Dict]:
123
+ """Render an inline subtree into a flat list of text / anchor nodes."""
124
+ out: List[Dict] = []
125
+ for child in node.children:
126
+ t = child.type
127
+ if t == "text":
128
+ if child.content:
129
+ out.append(nodes.text(child.content, marks))
130
+ elif t == "code_inline":
131
+ out.append(nodes.text(child.content, marks + [nodes.code_mark()]))
132
+ elif t == "math_inline":
133
+ out.append(nodes.latex_inline(child.content.strip()))
134
+ elif t in _MARK_FOR:
135
+ out.extend(_render_inline(child, marks + [_MARK_FOR[t]], ctx))
136
+ elif t == "link":
137
+ href = child.attrs.get("href", "")
138
+ out.extend(_render_inline(child, marks + [nodes.link_mark(href)], ctx))
139
+ elif t in ("softbreak", "hardbreak"):
140
+ out.append(nodes.text(" ", marks))
141
+ elif t == "footnote_ref":
142
+ # Number anchors by document position and record which definition each
143
+ # one points to, so matching blocks can be emitted 1:1 afterwards.
144
+ ctx["order"].append(child.meta["id"])
145
+ out.append(nodes.footnote_anchor(len(ctx["order"])))
146
+ elif t == "image":
147
+ # Inline images are rare in this schema; fall back to alt text.
148
+ alt = child.attrs.get("alt") or "".join(
149
+ c.content for c in child.children if c.type == "text"
150
+ )
151
+ if alt:
152
+ out.append(nodes.text(alt, marks))
153
+ elif t == "html_inline":
154
+ marker_data = parse_node_marker(child.content)
155
+ if marker_data is not None:
156
+ out.append(marker_data)
157
+ return _coalesce(out)
158
+
159
+
160
+ def _only_image(inline: SyntaxTreeNode) -> Optional[SyntaxTreeNode]:
161
+ """If an inline node is just an image (optionally wrapped in a link), return it."""
162
+ kids = [c for c in inline.children if c.type != "softbreak"]
163
+ if len(kids) == 1 and kids[0].type == "image":
164
+ return kids[0]
165
+ if len(kids) == 1 and kids[0].type == "link":
166
+ inner = [c for c in kids[0].children if c.type != "softbreak"]
167
+ if len(inner) == 1 and inner[0].type == "image":
168
+ img = inner[0]
169
+ img._link_href = kids[0].attrs.get("href") # type: ignore[attr-defined]
170
+ return img
171
+ return None
172
+
173
+
174
+ def _captioned_image(img: SyntaxTreeNode, api) -> Dict:
175
+ src = img.attrs.get("src", "")
176
+ if src.startswith("/"):
177
+ src = src[1:]
178
+ if api is not None and not src.startswith("http"):
179
+ try:
180
+ src = api.get_image(src).get("url")
181
+ except Exception:
182
+ pass
183
+ # markdown-it stores the image alt text as the node's content, not in attrs.
184
+ alt = img.content or img.attrs.get("alt") or None
185
+ # Standard markdown image title `![alt](src "caption")` maps to Substack's caption node.
186
+ title = img.attrs.get("title") or None
187
+ caption = [nodes.text(title)] if title else None
188
+ return nodes.captioned_image(
189
+ src,
190
+ alt=alt,
191
+ href=getattr(img, "_link_href", None),
192
+ caption=caption,
193
+ )
194
+
195
+
196
+ def _render_block(node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
197
+ """Render a block-level node into zero or more Substack nodes."""
198
+ t = node.type
199
+
200
+ if t == "html_block":
201
+ marker_data = parse_node_marker(node.content)
202
+ if marker_data is not None:
203
+ return [marker_data]
204
+ return []
205
+
206
+ if t == "paragraph":
207
+ inline = node.children[0]
208
+ img = _only_image(inline)
209
+ if img is not None:
210
+ return [_captioned_image(img, api)]
211
+ return [nodes.paragraph(_render_inline(inline, [], ctx))]
212
+
213
+ if t == "heading":
214
+ level = int(node.tag[1])
215
+ return [nodes.heading(_render_inline(node.children[0], [], ctx), level=level)]
216
+
217
+ if t == "hr":
218
+ return [nodes.horizontal_rule()]
219
+
220
+ if t in ("fence", "code_block"):
221
+ return [
222
+ nodes.code_block(
223
+ node.content.rstrip("\n"), language=node.info.strip() or None
224
+ )
225
+ ]
226
+
227
+ if t == "blockquote":
228
+ paras: List[Dict] = []
229
+ for child in node.children:
230
+ paras.extend(_render_block(child, api, ctx))
231
+ return [nodes.blockquote(paras)]
232
+
233
+ if t == "bullet_list":
234
+ return [nodes.bullet_list(_render_list_items(node, api, ctx))]
235
+
236
+ if t == "ordered_list":
237
+ return [nodes.ordered_list(_render_list_items(node, api, ctx))]
238
+
239
+ # "$$...$$ (label)" tokenizes as math_block_label; Substack has no equation
240
+ # labels, so it renders like an unlabeled block.
241
+ if t in ("math_block", "math_block_label"):
242
+ return [nodes.latex_block(node.content.strip())]
243
+
244
+ if t == "container_pullquote":
245
+ return [nodes.pullquote(_render_container_body(node, api, ctx))]
246
+
247
+ if t == "container_callout":
248
+ return [nodes.callout_block(_render_container_body(node, api, ctx))]
249
+
250
+ # footnote_block is handled separately in markdown_to_doc; ignore it here.
251
+ return []
252
+
253
+
254
+ def _render_container_body(node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
255
+ body: List[Dict] = []
256
+ for child in node.children:
257
+ body.extend(_render_block(child, api, ctx))
258
+ return body
259
+
260
+
261
+ def _render_list_items(list_node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
262
+ items = []
263
+ for li in list_node.children:
264
+ content: List[Dict] = []
265
+ for child in li.children:
266
+ content.extend(_render_block(child, api, ctx))
267
+ items.append({"type": NodeType.LIST_ITEM, "content": content})
268
+ return items
269
+
270
+
271
+ def _footnote_definitions(tree: SyntaxTreeNode, api) -> Dict[int, List[Dict]]:
272
+ """Map each footnote id to its rendered block content."""
273
+ definitions: Dict[int, List[Dict]] = {}
274
+ for node in tree.children:
275
+ if node.type != "footnote_block":
276
+ continue
277
+ for fn in node.children:
278
+ # A footnote's own content should not register anchors of its own.
279
+ local_ctx = {"order": []}
280
+ content: List[Dict] = []
281
+ for child in fn.children:
282
+ content.extend(_render_block(child, api, local_ctx))
283
+ definitions[fn.meta["id"]] = content
284
+ return definitions
285
+
286
+
287
+ def markdown_to_doc(markdown_content: str, api=None) -> List[Dict]:
288
+ """Convert Markdown into a list of Substack ProseMirror block nodes."""
289
+ tree = SyntaxTreeNode(_make_parser().parse(markdown_content))
290
+
291
+ definitions = _footnote_definitions(tree, api)
292
+
293
+ ctx: Dict = {"order": []}
294
+ out: List[Dict] = []
295
+ for node in tree.children:
296
+ if node.type == "footnote_block":
297
+ continue
298
+ out.extend(_render_block(node, api, ctx))
299
+
300
+ # Emit one footnote block per reference, in anchor order, numbered to match.
301
+ for number, footnote_id in enumerate(ctx["order"], start=1):
302
+ content = copy.deepcopy(definitions.get(footnote_id, []))
303
+ out.append(nodes.footnote(number, content))
304
+
305
+ return out
File without changes