python-substack 0.1.23__py3-none-any.whl → 0.1.24__py3-none-any.whl

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.1.23
3
+ Version: 0.1.24
4
4
  Summary: A Python wrapper around the Substack API.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -16,6 +16,8 @@ Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Programming Language :: Python :: 3.14
18
18
  Requires-Dist: PyYAML (>=6.0,<7.0)
19
+ Requires-Dist: markdown-it-py (>=3.0,<4.0)
20
+ Requires-Dist: mdit-py-plugins (>=0.4,<0.5)
19
21
  Requires-Dist: python-dotenv (>=1.2.1,<2.0.0)
20
22
  Requires-Dist: requests (>=2.32.0,<3.0.0)
21
23
  Project-URL: Homepage, https://github.com/ma2za/python-substack
@@ -195,6 +197,7 @@ post.from_markdown(footnote_markdown, api=api)
195
197
  post.paragraph(content=[{"content": "Some claim."}]).footnote_anchor(1)
196
198
  post.footnote(1, "The note text, with **formatting** allowed.")
197
199
 
200
+
198
201
  draft = api.post_draft(post.get_draft())
199
202
 
200
203
  # set section (can only be done after first posting the draft)
@@ -0,0 +1,10 @@
1
+ substack/__init__.py,sha256=esdUffBuSMESXcG4GDcHU81TIPXLyDosmBN3OCtAFY4,390
2
+ substack/api.py,sha256=VPzQ_bpqGcE4ZXWD2lUppnvWsuLRjvtBN7bHmW4Bgww,20422
3
+ substack/exceptions.py,sha256=BbP5W5UpzFcM5SYIxx6snWD_Rmj7F_YjYIYC_r03gZY,911
4
+ substack/mdrender.py,sha256=cB0fLFzOF7CmWHk-9eRvQormP8StqIgy9hYE6CSKPH0,7770
5
+ substack/nodes.py,sha256=eFVxoVwi684g_DLbz8BRHfU7nyHsJb-m8w17UHocXgY,4106
6
+ substack/post.py,sha256=nXeZMAZ6-lx1Qf4yuAlrEQ0lcWFYF2afai1_TxVEl0Q,19704
7
+ python_substack-0.1.24.dist-info/METADATA,sha256=0SKSa0SYQ9Ju5jy7br0JOwAKKIttnCmGd70qMrcQmDk,9868
8
+ python_substack-0.1.24.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
9
+ python_substack-0.1.24.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
10
+ python_substack-0.1.24.dist-info/RECORD,,
substack/__init__.py CHANGED
@@ -3,7 +3,7 @@
3
3
  __author__ = "Paolo Mazza"
4
4
  __email__ = "mazzapaolo2019@gmail.com"
5
5
  __license__ = "MIT License"
6
- __version__ = "0.1.23"
6
+ __version__ = "0.1.24"
7
7
  __url__ = "https://github.com/ma2za/python-substack"
8
8
  __download_url__ = "https://pypi.python.org/pypi/python-substack"
9
9
  __description__ = "A Python wrapper around the Substack API"
substack/mdrender.py ADDED
@@ -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
substack/nodes.py ADDED
@@ -0,0 +1,148 @@
1
+ """ProseMirror node builders for Substack documents.
2
+
3
+ Centralises the (undocumented) Substack ProseMirror schema in one place.
4
+ The node-type strings ("paragraph", "footnoteAnchor", "image2", ...) and
5
+ their shapes live here rather than as inline dict literals scattered across
6
+ post.py, giving:
7
+
8
+ * one source of truth for node shapes (so a schema change is a one-line fix),
9
+ * discoverable, typed constructors instead of bare dict literals,
10
+ * a natural seam for validation.
11
+
12
+ The builders intentionally return plain dicts so they stay 100% compatible with
13
+ the existing draft_body structure.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Dict, List, Optional
19
+
20
+
21
+ class NodeType:
22
+ DOC = "doc"
23
+ PARAGRAPH = "paragraph"
24
+ HEADING = "heading"
25
+ TEXT = "text"
26
+ BLOCKQUOTE = "blockquote"
27
+ CODE_BLOCK = "codeBlock"
28
+ HORIZONTAL_RULE = "horizontal_rule"
29
+ BULLET_LIST = "bullet_list"
30
+ ORDERED_LIST = "ordered_list"
31
+ LIST_ITEM = "list_item"
32
+ FOOTNOTE = "footnote"
33
+ FOOTNOTE_ANCHOR = "footnoteAnchor"
34
+ CAPTIONED_IMAGE = "captionedImage"
35
+ CAPTION = "caption"
36
+
37
+
38
+ class MarkType:
39
+ STRONG = "strong"
40
+ EM = "em"
41
+ CODE = "code"
42
+ STRIKETHROUGH = "strikethrough"
43
+ LINK = "link"
44
+
45
+
46
+ def code_mark() -> Dict:
47
+ return {"type": MarkType.CODE}
48
+
49
+
50
+ def text(value: str, marks: Optional[List[Dict]] = None) -> Dict:
51
+ node: Dict = {"type": NodeType.TEXT, "text": value}
52
+ if marks:
53
+ node["marks"] = marks
54
+ return node
55
+
56
+
57
+ def link_mark(href: str) -> Dict:
58
+ return {"type": MarkType.LINK, "attrs": {"href": href}}
59
+
60
+
61
+ def paragraph(content: Optional[List[Dict]] = None) -> Dict:
62
+ return {"type": NodeType.PARAGRAPH, "content": content or []}
63
+
64
+
65
+ def heading(content: List[Dict], level: int = 1) -> Dict:
66
+ return {"type": NodeType.HEADING, "content": content, "attrs": {"level": level}}
67
+
68
+
69
+ def horizontal_rule() -> Dict:
70
+ return {"type": NodeType.HORIZONTAL_RULE}
71
+
72
+
73
+ def blockquote(paragraphs: List[Dict]) -> Dict:
74
+ node: Dict = {"type": NodeType.BLOCKQUOTE}
75
+ if paragraphs:
76
+ node["content"] = paragraphs
77
+ return node
78
+
79
+
80
+ def list_item(content_nodes: List[Dict]) -> Dict:
81
+ return {
82
+ "type": NodeType.LIST_ITEM,
83
+ "content": [paragraph(content_nodes)],
84
+ }
85
+
86
+
87
+ def bullet_list(items: List[Dict]) -> Dict:
88
+ return {"type": NodeType.BULLET_LIST, "content": items}
89
+
90
+
91
+ def ordered_list(items: List[Dict]) -> Dict:
92
+ return {"type": NodeType.ORDERED_LIST, "content": items}
93
+
94
+
95
+ def code_block(code: str, language: Optional[str] = None) -> Dict:
96
+ node: Dict = {"type": NodeType.CODE_BLOCK, "content": [text(code)]}
97
+ if language:
98
+ node["attrs"] = {"language": language}
99
+ return node
100
+
101
+
102
+ def captioned_image(
103
+ src: str,
104
+ alt: Optional[str] = None,
105
+ href: Optional[str] = None,
106
+ caption: Optional[List[Dict]] = None,
107
+ image_size: str = "normal",
108
+ ) -> Dict:
109
+ content: List[Dict] = [
110
+ {
111
+ "type": "image2",
112
+ "attrs": {
113
+ "src": src,
114
+ "srcNoWatermark": None,
115
+ "fullscreen": False,
116
+ "imageSize": image_size,
117
+ "height": 819,
118
+ "width": 1456,
119
+ "resizeWidth": 728 if image_size == "normal" else None,
120
+ "bytes": None,
121
+ "alt": alt,
122
+ "title": None,
123
+ "type": None,
124
+ "href": href,
125
+ "belowTheFold": False,
126
+ "topImage": False,
127
+ "internalRedirect": None,
128
+ "isProcessing": False,
129
+ "align": None,
130
+ "offset": False,
131
+ },
132
+ }
133
+ ]
134
+ if caption:
135
+ content.append({"type": NodeType.CAPTION, "content": caption})
136
+ return {"type": NodeType.CAPTIONED_IMAGE, "content": content}
137
+
138
+
139
+ def footnote_anchor(number: int) -> Dict:
140
+ return {"type": NodeType.FOOTNOTE_ANCHOR, "attrs": {"number": number}}
141
+
142
+
143
+ def footnote(number: int, paragraphs: List[Dict]) -> Dict:
144
+ return {
145
+ "type": NodeType.FOOTNOTE,
146
+ "attrs": {"number": number},
147
+ "content": paragraphs or [paragraph()],
148
+ }
substack/post.py CHANGED
@@ -11,10 +11,7 @@ from typing import Dict, List
11
11
  __all__ = ["Post", "parse_inline", "tokens_to_text_nodes"]
12
12
 
13
13
  from substack.exceptions import SectionNotExistsException
14
-
15
- # Markdown footnotes: ``text.[^label]`` references and ``[^label]: definition`` lines.
16
- FOOTNOTE_REFERENCE_PATTERN = re.compile(r"\[\^([^\]]+)\]")
17
- FOOTNOTE_DEFINITION_PATTERN = re.compile(r"^\[\^([^\]]+)\]:\s?(.*)$")
14
+ from substack import nodes
18
15
 
19
16
 
20
17
  def tokens_to_text_nodes(tokens: List[Dict]) -> List[Dict]:
@@ -560,7 +557,7 @@ class Post:
560
557
 
561
558
  """
562
559
  content = self.draft_body["content"][-1].get("content", [])
563
- content += [{"type": "footnoteAnchor", "attrs": {"number": number}}]
560
+ content += [nodes.footnote_anchor(number)]
564
561
  self.draft_body["content"][-1]["content"] = content
565
562
  return self
566
563
 
@@ -586,147 +583,19 @@ class Post:
586
583
  for chunk in re.split(r"\n\s*\n", content):
587
584
  chunk = chunk.strip()
588
585
  if chunk:
589
- paragraphs.append(
590
- {"type": "paragraph", "content": tokens_to_text_nodes(parse_inline(chunk))}
591
- )
586
+ paragraphs.append(nodes.paragraph(tokens_to_text_nodes(parse_inline(chunk))))
592
587
  elif isinstance(content, list):
593
588
  # Accept either parse_inline tokens ({"content": ...}) or text nodes.
594
589
  if content and content[0].get("type") == "text":
595
590
  text_nodes = content
596
591
  else:
597
592
  text_nodes = tokens_to_text_nodes(content)
598
- paragraphs.append({"type": "paragraph", "content": text_nodes})
599
-
600
- if not paragraphs:
601
- paragraphs = [{"type": "paragraph", "content": []}]
593
+ paragraphs.append(nodes.paragraph(text_nodes))
602
594
 
603
- node: Dict = {
604
- "type": "footnote",
605
- "attrs": {"number": number},
606
- "content": paragraphs,
607
- }
595
+ node: Dict = nodes.footnote(number, paragraphs)
608
596
  self.draft_body["content"] = self.draft_body.get("content", []) + [node]
609
597
  return self
610
598
 
611
- @staticmethod
612
- def _extract_footnote_definitions(markdown_content: str):
613
- """
614
-
615
- Pull ``[^label]: definition`` lines out of the Markdown.
616
-
617
- Definitions may wrap onto indented continuation lines and may contain
618
- multiple paragraphs (blank line followed by an indented block). Returns
619
- the body with definitions removed plus a {label: definition_text} mapping,
620
- where paragraphs are separated by a blank line.
621
-
622
- """
623
- lines = markdown_content.split("\n")
624
- body_lines: List[str] = []
625
- definitions: Dict[str, str] = {}
626
- in_code_fence = False
627
- i = 0
628
- while i < len(lines):
629
- # Track fenced code blocks so footnote-like lines inside them are
630
- # left untouched.
631
- if lines[i].lstrip().startswith("```"):
632
- in_code_fence = not in_code_fence
633
- body_lines.append(lines[i])
634
- i += 1
635
- continue
636
- match = None if in_code_fence else FOOTNOTE_DEFINITION_PATTERN.match(lines[i])
637
- if match:
638
- label, first = match.group(1), match.group(2)
639
- paragraphs: List[str] = []
640
- current = [first.strip()] if first.strip() else []
641
- i += 1
642
- while i < len(lines):
643
- line = lines[i]
644
- if line.strip() == "":
645
- # A blank line stays in the footnote only if the next
646
- # non-empty line is indented (a further paragraph).
647
- nxt = i + 1
648
- if (
649
- nxt < len(lines)
650
- and lines[nxt].strip()
651
- and lines[nxt][:1] in (" ", "\t")
652
- ):
653
- if current:
654
- paragraphs.append(" ".join(current))
655
- current = []
656
- i += 1
657
- continue
658
- break
659
- if line[:1] in (" ", "\t"):
660
- current.append(line.strip())
661
- i += 1
662
- else:
663
- break
664
- if current:
665
- paragraphs.append(" ".join(current))
666
- definitions[label] = "\n\n".join(paragraphs)
667
- else:
668
- body_lines.append(lines[i])
669
- i += 1
670
- return "\n".join(body_lines), definitions
671
-
672
- @staticmethod
673
- def _number_footnotes(markdown_content: str, definitions: Dict[str, str]):
674
- """Number footnotes by order of first inline reference in the body."""
675
- order: List[str] = []
676
- for match in FOOTNOTE_REFERENCE_PATTERN.finditer(markdown_content):
677
- label = match.group(1)
678
- if label in definitions and label not in order:
679
- order.append(label)
680
- # Defined-but-unreferenced footnotes go last, in definition order.
681
- for label in definitions:
682
- if label not in order:
683
- order.append(label)
684
- return {label: index + 1 for index, label in enumerate(order)}
685
-
686
- def _inject_footnote_anchors(self, node: Dict, numbers_by_label: Dict[str, int]):
687
- """Recursively replace ``[^label]`` in text nodes with footnoteAnchor nodes."""
688
- # Never rewrite the contents of a code block.
689
- if node.get("type") == "codeBlock":
690
- return
691
- content = node.get("content")
692
- if not isinstance(content, list):
693
- return
694
- new_content: List[Dict] = []
695
- for child in content:
696
- text = child.get("text", "")
697
- has_code_mark = any(
698
- mark.get("type") == "code" for mark in (child.get("marks") or [])
699
- )
700
- if (
701
- child.get("type") == "text"
702
- and not has_code_mark
703
- and FOOTNOTE_REFERENCE_PATTERN.search(text)
704
- ):
705
- marks = child.get("marks")
706
- last = 0
707
- for match in FOOTNOTE_REFERENCE_PATTERN.finditer(text):
708
- label = match.group(1)
709
- if label not in numbers_by_label:
710
- continue # Unknown label: leave the literal text in place.
711
- if match.start() > last:
712
- segment = {"type": "text", "text": text[last:match.start()]}
713
- if marks:
714
- segment["marks"] = marks
715
- new_content.append(segment)
716
- new_content.append(
717
- {"type": "footnoteAnchor", "attrs": {"number": numbers_by_label[label]}}
718
- )
719
- last = match.end()
720
- if last < len(text):
721
- segment = {"type": "text", "text": text[last:]}
722
- if marks:
723
- segment["marks"] = marks
724
- new_content.append(segment)
725
- else:
726
- self._inject_footnote_anchors(child, numbers_by_label)
727
- new_content.append(child)
728
- node["content"] = new_content
729
-
730
599
  def from_markdown(self, markdown_content: str, api=None):
731
600
  """
732
601
  Parse Markdown content and add it to the post.
@@ -760,290 +629,8 @@ class Post:
760
629
  >>> post = Post("Title", "Subtitle", user_id)
761
630
  >>> post.from_markdown("# Heading\\n\\nThis is **bold** text with [a link](https://example.com).")
762
631
  """
763
- # Footnotes: extract ``[^label]: ...`` definitions and number them by
764
- # order of first reference before parsing the rest of the body.
765
- markdown_content, footnote_definitions = self._extract_footnote_definitions(
766
- markdown_content
767
- )
768
- footnote_numbers = self._number_footnotes(markdown_content, footnote_definitions)
769
-
770
- lines = markdown_content.split("\n")
771
- blocks = []
772
- current_block: List[str] = []
773
- in_code_block = False
774
- code_block_language = None
775
-
776
- for line in lines:
777
- # Check for fenced code block start/end
778
- if line.strip().startswith("```"):
779
- if in_code_block:
780
- # End of code block
781
- if current_block:
782
- blocks.append({
783
- "type": "code",
784
- "language": code_block_language,
785
- "content": "\n".join(current_block)
786
- })
787
- current_block = []
788
- in_code_block = False
789
- code_block_language = None
790
- else:
791
- # Start of code block
792
- if current_block:
793
- blocks.append({"type": "text", "content": "\n".join(current_block)})
794
- current_block = []
795
- # Extract language if specified
796
- language = line.strip()[3:].strip()
797
- code_block_language = language if language else None
798
- in_code_block = True
799
- continue
800
-
801
- if in_code_block:
802
- # Inside code block - collect lines as-is
803
- current_block.append(line)
804
- else:
805
- # Regular content
806
- if line.strip() == "":
807
- # Empty line - end current block if it has content
808
- if current_block:
809
- blocks.append({"type": "text", "content": "\n".join(current_block)})
810
- current_block = []
811
- else:
812
- current_block.append(line)
813
-
814
- # Add any remaining content
815
- if current_block:
816
- if in_code_block:
817
- blocks.append({
818
- "type": "code",
819
- "language": code_block_language,
820
- "content": "\n".join(current_block)
821
- })
822
- else:
823
- blocks.append({"type": "text", "content": "\n".join(current_block)})
824
-
825
- # Process blocks
826
- for block in blocks:
827
- if block["type"] == "code":
828
- # Add code block
829
- code_content = block.get("content", "").strip()
830
- if code_content:
831
- # Substack uses "codeBlock" type
832
- code_attrs = {}
833
- if block.get("language"):
834
- code_attrs["language"] = block["language"]
835
- self.add({
836
- "type": "codeBlock",
837
- "content": code_content, # Pass as string, code_block method will handle it
838
- "attrs": code_attrs
839
- })
840
- else:
841
- # Process text block
842
- text_content = block.get("content", "").strip()
843
- if not text_content:
844
- continue
845
-
846
- # Check for horizontal rule: ---, ***, ___
847
- if re.match(r'^(\*{3,}|-{3,}|_{3,})\s*$', text_content):
848
- self.horizontal_rule()
849
- continue
850
-
851
- # Process headings (lines starting with '#' characters)
852
- if text_content.startswith("#"):
853
- level = len(text_content) - len(text_content.lstrip("#"))
854
- heading_text = text_content.lstrip("#").strip()
855
- if heading_text: # Only add if there's actual text
856
- self.heading(content=heading_text, level=min(level, 6))
857
-
858
- # Process images using Markdown image syntax: ![Alt](URL)
859
- # Also handle linked images: [![Alt](image_url)](link_url)
860
- elif text_content.startswith("!") or (text_content.startswith("[") and "![" in text_content):
861
- # Check for linked image first: [![alt](img)](link)
862
- linked_image_match = re.match(r'\[!\[([^\]]*)\]\(([^)]+)\)\]\(([^)]+)\)', text_content)
863
- if linked_image_match:
864
- # Linked image - create image with href
865
- alt_text = linked_image_match.group(1)
866
- image_url = linked_image_match.group(2)
867
- link_url = linked_image_match.group(3)
868
-
869
- # Adjust image URL if it starts with a slash
870
- image_url = image_url[1:] if image_url.startswith("/") else image_url
871
-
872
- # If api is provided and image_url is a local file, upload it
873
- if api is not None:
874
- try:
875
- image = api.get_image(image_url)
876
- image_url = image.get("url")
877
- except Exception:
878
- # If upload fails, use original URL
879
- pass
880
-
881
- self.add({
882
- "type": "captionedImage",
883
- "src": image_url,
884
- "alt": alt_text,
885
- "href": link_url
886
- })
887
- else:
888
- # Regular image: ![Alt](URL)
889
- match = re.match(r"!\[.*?\]\((.*?)\)", text_content)
890
- if match:
891
- image_url = match.group(1)
892
- # Adjust image URL if it starts with a slash
893
- image_url = image_url[1:] if image_url.startswith("/") else image_url
894
-
895
- # If api is provided and image_url is a local file, upload it
896
- if api is not None:
897
- try:
898
- image = api.get_image(image_url)
899
- image_url = image.get("url")
900
- except Exception:
901
- # If upload fails, use original URL
902
- pass
903
-
904
- self.add({"type": "captionedImage", "src": image_url})
905
-
906
- # Process paragraphs, bullet lists, ordered lists, or blockquotes
907
- else:
908
- if "\n" in text_content:
909
- # Process each line, grouping consecutive bullets/ordered items
910
- # into list nodes and consecutive blockquote lines into a
911
- # single blockquote node.
912
- pending_bullets: List[List[Dict]] = []
913
- pending_quotes: List[str] = []
914
- pending_ordered: List[List[Dict]] = []
915
-
916
- def flush_bullets():
917
- if not pending_bullets:
918
- return
919
- list_items = []
920
- for bullet_nodes in pending_bullets:
921
- list_items.append({
922
- "type": "list_item",
923
- "content": [{"type": "paragraph", "content": bullet_nodes}],
924
- })
925
- self.draft_body["content"].append(
926
- {"type": "bullet_list", "content": list_items}
927
- )
928
- pending_bullets.clear()
929
-
930
- def flush_quotes():
931
- if not pending_quotes:
932
- return
933
- paragraphs: List[Dict] = []
934
- for quote_line in pending_quotes:
935
- tokens = parse_inline(quote_line)
936
- text_nodes = tokens_to_text_nodes(tokens)
937
- if text_nodes:
938
- paragraphs.append({"type": "paragraph", "content": text_nodes})
939
- node: Dict = {"type": "blockquote"}
940
- if paragraphs:
941
- node["content"] = paragraphs
942
- self.draft_body["content"].append(node)
943
- pending_quotes.clear()
944
-
945
- def flush_ordered():
946
- if not pending_ordered:
947
- return
948
- list_items = []
949
- for item_nodes in pending_ordered:
950
- list_items.append({
951
- "type": "list_item",
952
- "content": [{"type": "paragraph", "content": item_nodes}],
953
- })
954
- self.draft_body["content"].append(
955
- {"type": "ordered_list", "content": list_items}
956
- )
957
- pending_ordered.clear()
958
-
959
- for line in text_content.split("\n"):
960
- line = line.strip()
961
- if not line:
962
- flush_bullets()
963
- flush_ordered()
964
- flush_quotes()
965
- continue
966
-
967
- # Check for blockquote marker
968
- if line.startswith("> ") or line == ">":
969
- flush_bullets()
970
- flush_ordered()
971
- quote_text = line[2:] if line.startswith("> ") else ""
972
- pending_quotes.append(quote_text)
973
- continue
974
-
975
- # Check for ordered list marker
976
- ordered_match = re.match(r'^(\d+)\.\s+(.*)', line)
977
- if ordered_match:
978
- flush_bullets()
979
- flush_quotes()
980
- item_text = ordered_match.group(2).strip()
981
- tokens = parse_inline(item_text)
982
- text_nodes = tokens_to_text_nodes(tokens)
983
- if text_nodes:
984
- pending_ordered.append(text_nodes)
985
- continue
986
-
987
- # Check for bullet marker
988
- bullet_text = None
989
- if line.startswith("* "):
990
- bullet_text = line[2:].strip()
991
- elif line.startswith("- "):
992
- bullet_text = line[2:].strip()
993
- elif line.startswith("*") and not line.startswith("**"):
994
- bullet_text = line[1:].strip()
995
-
996
- if bullet_text is not None:
997
- flush_ordered()
998
- flush_quotes()
999
- tokens = parse_inline(bullet_text)
1000
- text_nodes = tokens_to_text_nodes(tokens)
1001
- if text_nodes:
1002
- pending_bullets.append(text_nodes)
1003
- else:
1004
- flush_bullets()
1005
- flush_ordered()
1006
- flush_quotes()
1007
- tokens = parse_inline(line)
1008
- self.add({"type": "paragraph", "content": tokens})
1009
-
1010
- flush_bullets()
1011
- flush_ordered()
1012
- flush_quotes()
1013
- else:
1014
- # Single line — blockquote, ordered list, or paragraph
1015
- if text_content.startswith("> ") or text_content == ">":
1016
- quote_text = text_content[2:] if text_content.startswith("> ") else ""
1017
- tokens = parse_inline(quote_text)
1018
- text_nodes = tokens_to_text_nodes(tokens)
1019
- para = {"type": "paragraph", "content": text_nodes} if text_nodes else {"type": "paragraph"}
1020
- self.draft_body["content"] = self.draft_body.get("content", []) + [
1021
- {"type": "blockquote", "content": [para]}
1022
- ]
1023
-
1024
- elif re.match(r'^(\d+)\.\s+(.*)', text_content):
1025
- ordered_match = re.match(r'^(\d+)\.\s+(.*)', text_content)
1026
- item_text = ordered_match.group(2).strip()
1027
- tokens = parse_inline(item_text)
1028
- text_nodes = tokens_to_text_nodes(tokens)
1029
- if text_nodes:
1030
- list_item = {
1031
- "type": "list_item",
1032
- "content": [{"type": "paragraph", "content": text_nodes}],
1033
- }
1034
- self.draft_body["content"].append(
1035
- {"type": "ordered_list", "content": [list_item]}
1036
- )
1037
-
1038
- else:
1039
- tokens = parse_inline(text_content)
1040
- self.add({"type": "paragraph", "content": tokens})
1041
-
1042
- # Footnotes: turn ``[^label]`` references into inline anchors, then append
1043
- # the footnote blocks in numbered order.
1044
- if footnote_numbers:
1045
- self._inject_footnote_anchors(self.draft_body, footnote_numbers)
1046
- for label, number in sorted(footnote_numbers.items(), key=lambda item: item[1]):
1047
- self.footnote(number, footnote_definitions[label])
632
+ from substack import mdrender
1048
633
 
634
+ rendered = mdrender.markdown_to_doc(markdown_content, api=api)
635
+ self.draft_body["content"] = self.draft_body.get("content", []) + rendered
1049
636
  return self
@@ -1,8 +0,0 @@
1
- substack/__init__.py,sha256=I3u5zUvxfPpbPYQTCV0VR3lGS4gyQQISC_0_GPN3FGk,390
2
- substack/api.py,sha256=VPzQ_bpqGcE4ZXWD2lUppnvWsuLRjvtBN7bHmW4Bgww,20422
3
- substack/exceptions.py,sha256=BbP5W5UpzFcM5SYIxx6snWD_Rmj7F_YjYIYC_r03gZY,911
4
- substack/post.py,sha256=ZObXe62J7iXhSI_XBpadVTMXpN6xqLNHXUrOHSidkgM,39440
5
- python_substack-0.1.23.dist-info/METADATA,sha256=90wvACIgduBlYv9RyB6euQq59V0ZJXgl39mB-isWWb8,9780
6
- python_substack-0.1.23.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
7
- python_substack-0.1.23.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
8
- python_substack-0.1.23.dist-info/RECORD,,