python-substack 0.7.0__tar.gz → 0.9.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.9.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
@@ -94,6 +94,7 @@ Substack result:
94
94
  - Set audience, comment permissions, SEO metadata, slug, sections, and tags.
95
95
  - List and inspect publications and drafts.
96
96
  - Export drafts to loss-aware Markdown backups without server writes.
97
+ - Safely update drafts from Markdown, preserving unsupported editor widgets by default.
97
98
  - Schedule, unschedule, publish, and delete drafts with explicit safeguards.
98
99
  - Use stable JSON envelopes in scripts and automation.
99
100
  - Authenticate with browser cookies or email and password.
@@ -145,6 +146,7 @@ substack publications list
145
146
  substack drafts list --limit 10
146
147
  substack drafts get 12345
147
148
  substack drafts export 12345 --output backup.md
149
+ substack drafts update 12345 revised-post.md --dry-run --yes
148
150
  substack --publication-url https://example.substack.com drafts list
149
151
  ```
150
152
 
@@ -217,6 +219,17 @@ print(backup["markdown"])
217
219
  print(backup["unsupported_nodes"])
218
220
  ```
219
221
 
222
+ Review a Markdown update before writing it, then update deliberately:
223
+
224
+ ```python
225
+ result = api.update_draft_from_markdown(12345, "# Revised draft", dry_run=True)
226
+ result = api.update_draft_from_markdown(12345, "# Revised draft")
227
+ ```
228
+
229
+ Unsupported editor widgets exported as `python-substack-node:v1` markers are
230
+ preserved by default. An update refuses to remove or alter them unless
231
+ `allow_unsupported_change=True` is explicitly passed.
232
+
220
233
  For direct ProseMirror node construction, see the
221
234
  [low-level Python API](docs/low-level-api.md). YAML workflows are documented in
222
235
  [YAML drafts](docs/yaml.md).
@@ -269,11 +282,12 @@ See [MCP server](docs/mcp.md) for the tool list and safety notes.
269
282
  - [Python SDK](docs/python-sdk.md)
270
283
  - [Authentication](docs/authentication.md)
271
284
  - [Markdown reference](docs/markdown.md)
285
+ - [Draft backup and safe updates](docs/cli.md)
272
286
  - [Legacy CLI commands](docs/legacy-cli.md)
273
287
  - [Low-level Python API](docs/low-level-api.md)
274
288
  - [YAML drafts](docs/yaml.md)
275
289
  - [MCP server](docs/mcp.md)
276
- - [Gemini CLI with Vertex AI](docs/gemini-vertex-ai.md)
290
+ - [Agent workflow](docs/agents.md)
277
291
  - [Safety and publishing behavior](docs/safety.md)
278
292
  - [Troubleshooting](docs/troubleshooting.md)
279
293
  - [Compatibility policy](docs/compatibility.md)
@@ -58,6 +58,7 @@ Substack result:
58
58
  - Set audience, comment permissions, SEO metadata, slug, sections, and tags.
59
59
  - List and inspect publications and drafts.
60
60
  - Export drafts to loss-aware Markdown backups without server writes.
61
+ - Safely update drafts from Markdown, preserving unsupported editor widgets by default.
61
62
  - Schedule, unschedule, publish, and delete drafts with explicit safeguards.
62
63
  - Use stable JSON envelopes in scripts and automation.
63
64
  - Authenticate with browser cookies or email and password.
@@ -109,6 +110,7 @@ substack publications list
109
110
  substack drafts list --limit 10
110
111
  substack drafts get 12345
111
112
  substack drafts export 12345 --output backup.md
113
+ substack drafts update 12345 revised-post.md --dry-run --yes
112
114
  substack --publication-url https://example.substack.com drafts list
113
115
  ```
114
116
 
@@ -181,6 +183,17 @@ print(backup["markdown"])
181
183
  print(backup["unsupported_nodes"])
182
184
  ```
183
185
 
186
+ Review a Markdown update before writing it, then update deliberately:
187
+
188
+ ```python
189
+ result = api.update_draft_from_markdown(12345, "# Revised draft", dry_run=True)
190
+ result = api.update_draft_from_markdown(12345, "# Revised draft")
191
+ ```
192
+
193
+ Unsupported editor widgets exported as `python-substack-node:v1` markers are
194
+ preserved by default. An update refuses to remove or alter them unless
195
+ `allow_unsupported_change=True` is explicitly passed.
196
+
184
197
  For direct ProseMirror node construction, see the
185
198
  [low-level Python API](docs/low-level-api.md). YAML workflows are documented in
186
199
  [YAML drafts](docs/yaml.md).
@@ -233,11 +246,12 @@ See [MCP server](docs/mcp.md) for the tool list and safety notes.
233
246
  - [Python SDK](docs/python-sdk.md)
234
247
  - [Authentication](docs/authentication.md)
235
248
  - [Markdown reference](docs/markdown.md)
249
+ - [Draft backup and safe updates](docs/cli.md)
236
250
  - [Legacy CLI commands](docs/legacy-cli.md)
237
251
  - [Low-level Python API](docs/low-level-api.md)
238
252
  - [YAML drafts](docs/yaml.md)
239
253
  - [MCP server](docs/mcp.md)
240
- - [Gemini CLI with Vertex AI](docs/gemini-vertex-ai.md)
254
+ - [Agent workflow](docs/agents.md)
241
255
  - [Safety and publishing behavior](docs/safety.md)
242
256
  - [Troubleshooting](docs/troubleshooting.md)
243
257
  - [Compatibility policy](docs/compatibility.md)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-substack"
3
- version = "0.7.0"
3
+ version = "0.9.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.9.0"
7
7
  __url__ = "https://github.com/ma2za/python-substack"
8
8
  __download_url__ = "https://pypi.python.org/pypi/python-substack"
9
9
  __description__ = (
@@ -21,6 +21,19 @@ logger = logging.getLogger(__name__)
21
21
  __all__ = ["Api"]
22
22
 
23
23
 
24
+ def _image_attrs(document):
25
+ images = []
26
+ if isinstance(document, dict):
27
+ if document.get("type") == "image2" and isinstance(document.get("attrs"), dict):
28
+ images.append(document["attrs"])
29
+ for value in document.values():
30
+ images.extend(_image_attrs(value))
31
+ elif isinstance(document, list):
32
+ for value in document:
33
+ images.extend(_image_attrs(value))
34
+ return images
35
+
36
+
24
37
  class Api:
25
38
  """
26
39
 
@@ -432,7 +445,10 @@ class Api:
432
445
  f"{self.publication_url}/drafts",
433
446
  params={"filter": filter, "offset": offset, "limit": limit},
434
447
  )
435
- return Api._handle_response(response=response)
448
+ drafts = Api._handle_response(response=response)
449
+ if isinstance(drafts, dict) and isinstance(drafts.get("posts"), list):
450
+ return drafts["posts"]
451
+ return drafts
436
452
 
437
453
  def get_draft(self, draft_id):
438
454
  """
@@ -478,6 +494,8 @@ class Api:
478
494
  draft_section_id: int = None,
479
495
  tags=None,
480
496
  dry_run: bool = False,
497
+ allow_unsupported_change: bool = False,
498
+ allow_image_replacement: bool = False,
481
499
  ) -> dict:
482
500
  """
483
501
  Update an existing draft body from Markdown, with optional metadata changes.
@@ -497,12 +515,7 @@ class Api:
497
515
  if not isinstance(draft_body, dict):
498
516
  raise ValueError("Malformed draft body: draft_body must be a JSON object")
499
517
 
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
- )
518
+ _, remote_unsupported = document_to_markdown(draft_body)
506
519
 
507
520
  post = Post(
508
521
  title=draft.get("title", ""),
@@ -515,6 +528,48 @@ class Api:
515
528
  )
516
529
  post.from_markdown(markdown, api=self)
517
530
 
531
+ remote_images = _image_attrs(draft_body)
532
+ submitted_images = _image_attrs(post.draft_body)
533
+ for submitted in submitted_images:
534
+ matches = [
535
+ image
536
+ for image in remote_images
537
+ if image.get("src") == submitted.get("src")
538
+ ]
539
+ if len(matches) == 1:
540
+ source, alt, href = (
541
+ submitted.get("src"),
542
+ submitted.get("alt"),
543
+ submitted.get("href"),
544
+ )
545
+ submitted.update(matches[0])
546
+ submitted.update(
547
+ {"src": source, "alt": alt, "href": href, "isProcessing": False}
548
+ )
549
+ elif not allow_image_replacement:
550
+ raise ValueError(
551
+ "Refusing to update: image cannot be matched uniquely; set allow_image_replacement=True to proceed."
552
+ )
553
+
554
+ _, submitted_unsupported = document_to_markdown(post.draft_body)
555
+
556
+ if not allow_unsupported_change:
557
+ remote_serialized = [
558
+ json.dumps(n, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
559
+ for n in remote_unsupported
560
+ ]
561
+ submitted_serialized = [
562
+ json.dumps(n, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
563
+ for n in submitted_unsupported
564
+ ]
565
+ if sorted(remote_serialized) != sorted(submitted_serialized):
566
+ raise ValueError(
567
+ "Refusing to update: remote unsupported nodes are missing, duplicated, stale, or altered. "
568
+ "Export the draft first or set allow_unsupported_change=True to proceed."
569
+ )
570
+
571
+ unsupported_nodes = remote_unsupported
572
+
518
573
  update_payload = {"draft_body": json.dumps(post.draft_body)}
519
574
 
520
575
  if subtitle is not None:
@@ -310,6 +310,11 @@ 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
+ if args.allow_image_replacement and not args.yes:
316
+ raise CLIUsageError("--yes is required when using --allow-image-replacement")
317
+
313
318
  if not args.yes and (args.json_output or not sys.stdin.isatty()):
314
319
  raise CLIUsageError("--yes is required in non-interactive or JSON mode")
315
320
 
@@ -347,6 +352,8 @@ def _drafts_update(api, args):
347
352
  draft_section_id=args.draft_section_id,
348
353
  tags=args.tags,
349
354
  dry_run=args.dry_run,
355
+ allow_unsupported_change=args.allow_unsupported_change,
356
+ allow_image_replacement=args.allow_image_replacement,
350
357
  )
351
358
 
352
359
  if args.json_output:
@@ -508,6 +515,8 @@ def _build_parser():
508
515
  drafts_update.add_argument("--draft-section-id", type=int)
509
516
  drafts_update.add_argument("--tag", action="append", dest="tags", metavar="TAG")
510
517
  drafts_update.add_argument("--dry-run", action="store_true")
518
+ drafts_update.add_argument("--allow-unsupported-change", action="store_true")
519
+ drafts_update.add_argument("--allow-image-replacement", action="store_true")
511
520
  drafts_update.add_argument("--yes", action="store_true")
512
521
  drafts_update.set_defaults(handler=_drafts_update)
513
522
 
@@ -57,6 +57,21 @@ def _marker(node, unsupported):
57
57
  return f"<!-- python-substack-node:v1 {encoded} -->"
58
58
 
59
59
 
60
+ def _image_marker(attrs):
61
+ preserved = {
62
+ key: value
63
+ for key, value in attrs.items()
64
+ if key not in {"src", "alt", "href", "isProcessing"}
65
+ }
66
+ if not preserved:
67
+ return ""
68
+ payload = json.dumps(
69
+ preserved, ensure_ascii=False, separators=(",", ":"), sort_keys=True
70
+ ).encode("utf-8")
71
+ encoded = base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
72
+ return f"\n<!-- python-substack-image:v1 {encoded} -->"
73
+
74
+
60
75
  def _require_dict(node, context):
61
76
  if not isinstance(node, dict):
62
77
  raise ValueError(f"Malformed draft body: {context} must be an object")
@@ -247,7 +262,7 @@ def _render_image(node, unsupported):
247
262
  if not isinstance(href, str):
248
263
  return _marker(node, unsupported)
249
264
  image_markdown = f"[{image_markdown}]({_escape_destination(href)})"
250
- return image_markdown
265
+ return image_markdown + _image_marker(attrs)
251
266
 
252
267
 
253
268
  def _render_footnote(node, unsupported):
@@ -1,248 +1,354 @@
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")
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 pathlib import Path
25
+ from typing import Dict, List, Optional
26
+ from urllib.parse import unquote, urlsplit
27
+
28
+ from markdown_it import MarkdownIt
29
+ from markdown_it.tree import SyntaxTreeNode
30
+ from mdit_py_plugins.container import container_plugin
31
+ from mdit_py_plugins.dollarmath import dollarmath_plugin
32
+ from mdit_py_plugins.footnote import footnote_plugin
33
+ from mdit_py_plugins.subscript import sub_plugin
34
+ from mdit_py_plugins.superscript import superscript_plugin
35
+
36
+ from substack import nodes
37
+ from substack.nodes import MarkType, NodeType
38
+
39
+ _MARK_FOR = {
40
+ "strong": {"type": MarkType.STRONG},
41
+ "em": {"type": MarkType.EM},
42
+ "s": {"type": MarkType.STRIKETHROUGH},
43
+ "sup": {"type": MarkType.SUPERSCRIPT},
44
+ "sub": {"type": MarkType.SUBSCRIPT},
45
+ }
46
+
47
+
48
+ def parse_node_marker(comment_content: str) -> dict | None:
49
+ """
50
+ Parse a python-substack-node:v1 comment marker and return the parsed JSON dictionary.
51
+
52
+ If it is not a python-substack-node:v1 marker, returns None.
53
+ If it is an attempted marker but is corrupt/malformed, raises ValueError.
54
+ """
55
+ clean = comment_content.strip()
56
+ match = re.match(
57
+ r"^<!--\s*python-substack-node:v1\s+([A-Za-z0-9_-]+=*)\s*-->$", clean
58
+ )
59
+ if not match:
60
+ if "python-substack-node:v1" in clean:
61
+ raise ValueError("Corrupt marker format")
62
+ return None
63
+
64
+ encoded = match.group(1)
65
+
66
+ try:
67
+ padding = len(encoded) % 4
68
+ if padding:
69
+ encoded += "=" * (4 - padding)
70
+ decoded_bytes = base64.urlsafe_b64decode(encoded.encode("ascii"))
71
+ except Exception as exc:
72
+ raise ValueError("Invalid URL-safe base64 in marker") from exc
73
+
74
+ try:
75
+ decoded_str = decoded_bytes.decode("utf-8")
76
+ except Exception as exc:
77
+ raise ValueError("Invalid UTF-8 in marker") from exc
78
+
79
+ try:
80
+ data = json.loads(decoded_str)
81
+ except Exception as exc:
82
+ raise ValueError("Invalid JSON in marker") from exc
83
+
84
+ if not isinstance(data, dict):
85
+ raise ValueError("Marker payload is not a top-level JSON object")
86
+
87
+ node_type = data.get("type")
88
+ if not isinstance(node_type, str) or not node_type:
89
+ raise ValueError("Marker payload has missing or empty 'type'")
90
+
91
+ return data
92
+
93
+
94
+ def parse_image_marker(comment_content: str) -> dict | None:
95
+ clean = comment_content.strip()
96
+ match = re.match(
97
+ r"^<!--\s*python-substack-image:v1\s+([A-Za-z0-9_-]+=*)\s*-->$", clean
56
98
  )
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] = []
99
+ if not match:
100
+ if "python-substack-image:v1" in clean:
101
+ raise ValueError("Corrupt image marker format")
102
+ return None
103
+ encoded = match.group(1)
104
+ encoded += "=" * (-len(encoded) % 4)
105
+ try:
106
+ attrs = json.loads(base64.urlsafe_b64decode(encoded.encode("ascii")))
107
+ except Exception as exc:
108
+ raise ValueError("Invalid image marker") from exc
109
+ if not isinstance(attrs, dict) or any(
110
+ key in {"src", "alt", "href", "isProcessing"} for key in attrs
111
+ ):
112
+ raise ValueError("Invalid image marker attributes")
113
+ return attrs
114
+
115
+
116
+ def _make_parser() -> MarkdownIt:
117
+ return (
118
+ MarkdownIt("commonmark")
119
+ .use(footnote_plugin)
120
+ # Pandoc-style delimiters: no whitespace just inside the dollars and no
121
+ # digit just outside them, so paired currency amounts ("$5 ... $10")
122
+ # stay plain text instead of becoming math.
123
+ .use(dollarmath_plugin, allow_space=False, allow_digits=False)
124
+ .use(sub_plugin)
125
+ .use(superscript_plugin)
126
+ .use(container_plugin, name="pullquote")
127
+ .use(container_plugin, name="callout")
128
+ .enable("strikethrough")
129
+ )
130
+
131
+
132
+ def _coalesce(out_nodes: List[Dict]) -> List[Dict]:
133
+ """Merge adjacent text nodes that carry identical marks (e.g. softbreaks)."""
134
+ merged: List[Dict] = []
135
+ for node in out_nodes:
136
+ if (
137
+ merged
138
+ and node.get("type") == NodeType.TEXT
139
+ and merged[-1].get("type") == NodeType.TEXT
140
+ and node.get("marks") == merged[-1].get("marks")
141
+ ):
142
+ merged[-1]["text"] += node["text"]
143
+ else:
144
+ merged.append(node)
145
+ return merged
146
+
147
+
148
+ def _render_inline(node: SyntaxTreeNode, marks: List[Dict], ctx: Dict) -> List[Dict]:
149
+ """Render an inline subtree into a flat list of text / anchor nodes."""
150
+ out: List[Dict] = []
151
+ for child in node.children:
152
+ t = child.type
153
+ if t == "text":
154
+ if child.content:
155
+ out.append(nodes.text(child.content, marks))
156
+ elif t == "code_inline":
157
+ out.append(nodes.text(child.content, marks + [nodes.code_mark()]))
158
+ elif t == "math_inline":
159
+ out.append(nodes.latex_inline(child.content.strip()))
160
+ elif t in _MARK_FOR:
161
+ out.extend(_render_inline(child, marks + [_MARK_FOR[t]], ctx))
162
+ elif t == "link":
163
+ href = child.attrs.get("href", "")
164
+ out.extend(_render_inline(child, marks + [nodes.link_mark(href)], ctx))
165
+ elif t in ("softbreak", "hardbreak"):
166
+ out.append(nodes.text(" ", marks))
167
+ elif t == "footnote_ref":
168
+ # Number anchors by document position and record which definition each
169
+ # one points to, so matching blocks can be emitted 1:1 afterwards.
170
+ ctx["order"].append(child.meta["id"])
171
+ out.append(nodes.footnote_anchor(len(ctx["order"])))
172
+ elif t == "image":
173
+ # Inline images are rare in this schema; fall back to alt text.
174
+ alt = child.attrs.get("alt") or "".join(
175
+ c.content for c in child.children if c.type == "text"
176
+ )
177
+ if alt:
178
+ out.append(nodes.text(alt, marks))
179
+ elif t == "html_inline":
180
+ marker_data = parse_node_marker(child.content)
181
+ if marker_data is not None:
182
+ out.append(marker_data)
183
+ return _coalesce(out)
184
+
185
+
186
+ def _only_image(inline: SyntaxTreeNode) -> Optional[SyntaxTreeNode]:
187
+ """If an inline node is just an image (optionally wrapped in a link), return it."""
188
+ kids = [c for c in inline.children if c.type != "softbreak"]
189
+ if len(kids) == 1 and kids[0].type == "image":
190
+ return kids[0]
191
+ if len(kids) == 1 and kids[0].type == "link":
192
+ inner = [c for c in kids[0].children if c.type != "softbreak"]
193
+ if len(inner) == 1 and inner[0].type == "image":
194
+ img = inner[0]
195
+ img._link_href = kids[0].attrs.get("href") # type: ignore[attr-defined]
196
+ return img
197
+ return None
198
+
199
+
200
+ def _captioned_image(img: SyntaxTreeNode, api) -> Dict:
201
+ src = img.attrs.get("src", "")
202
+ if api is not None and (
203
+ urlsplit(src).scheme.lower() not in ("http", "https")
204
+ and not src.startswith("//")
205
+ ):
206
+ # Markdown destinations encode spaces and non-ASCII characters as URLs.
207
+ path = Path(unquote(src)).expanduser()
208
+ if not path.is_file() and src.startswith("/"):
209
+ # Preserve legacy root-relative asset paths only when they resolve
210
+ # to a real file; an existing absolute path always takes precedence.
211
+ relative = Path(unquote(src[1:]))
212
+ if relative.is_file():
213
+ path = relative
214
+ if not path.is_file():
215
+ raise FileNotFoundError(f"Local image file not found: {path}")
216
+ try:
217
+ uploaded = api.get_image(str(path))
218
+ url = uploaded.get("url")
219
+ if not isinstance(url, str) or not url.startswith(("https://", "http://")):
220
+ raise ValueError("Upload did not return an HTTP image URL")
221
+ except Exception as exc:
222
+ raise ValueError(f"Failed to upload local image: {path}") from exc
223
+ src = url
224
+ # markdown-it stores the image alt text as the node's content, not in attrs.
225
+ alt = img.content or img.attrs.get("alt") or None
226
+ # Standard markdown image title `![alt](src "caption")` maps to Substack's caption node.
227
+ title = img.attrs.get("title") or None
228
+ caption = [nodes.text(title)] if title else None
229
+ return nodes.captioned_image(
230
+ src,
231
+ alt=alt,
232
+ href=getattr(img, "_link_href", None),
233
+ caption=caption,
234
+ )
235
+
236
+
237
+ def _render_block(node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
238
+ """Render a block-level node into zero or more Substack nodes."""
239
+ t = node.type
240
+
241
+ if t == "html_block":
242
+ marker_data = parse_node_marker(node.content)
243
+ if marker_data is not None:
244
+ return [marker_data]
245
+ return []
246
+
247
+ if t == "paragraph":
248
+ inline = node.children[0]
249
+ img = _only_image(inline)
250
+ if img is not None:
251
+ return [_captioned_image(img, api)]
252
+ return [nodes.paragraph(_render_inline(inline, [], ctx))]
253
+
254
+ if t == "heading":
255
+ level = int(node.tag[1])
256
+ return [nodes.heading(_render_inline(node.children[0], [], ctx), level=level)]
257
+
258
+ if t == "hr":
259
+ return [nodes.horizontal_rule()]
260
+
261
+ if t in ("fence", "code_block"):
262
+ return [
263
+ nodes.code_block(
264
+ node.content.rstrip("\n"), language=node.info.strip() or None
265
+ )
266
+ ]
267
+
268
+ if t == "blockquote":
269
+ paras: List[Dict] = []
270
+ for child in node.children:
271
+ paras.extend(_render_block(child, api, ctx))
272
+ return [nodes.blockquote(paras)]
273
+
274
+ if t == "bullet_list":
275
+ return [nodes.bullet_list(_render_list_items(node, api, ctx))]
276
+
277
+ if t == "ordered_list":
278
+ return [nodes.ordered_list(_render_list_items(node, api, ctx))]
279
+
280
+ # "$$...$$ (label)" tokenizes as math_block_label; Substack has no equation
281
+ # labels, so it renders like an unlabeled block.
282
+ if t in ("math_block", "math_block_label"):
283
+ return [nodes.latex_block(node.content.strip())]
284
+
285
+ if t == "container_pullquote":
286
+ return [nodes.pullquote(_render_container_body(node, api, ctx))]
287
+
288
+ if t == "container_callout":
289
+ return [nodes.callout_block(_render_container_body(node, api, ctx))]
290
+
291
+ # footnote_block is handled separately in markdown_to_doc; ignore it here.
292
+ return []
293
+
294
+
295
+ def _render_container_body(node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
296
+ body: List[Dict] = []
297
+ for child in node.children:
298
+ body.extend(_render_block(child, api, ctx))
299
+ return body
300
+
301
+
302
+ def _render_list_items(list_node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
303
+ items = []
304
+ for li in list_node.children:
305
+ content: List[Dict] = []
306
+ for child in li.children:
307
+ content.extend(_render_block(child, api, ctx))
308
+ items.append({"type": NodeType.LIST_ITEM, "content": content})
309
+ return items
310
+
311
+
312
+ def _footnote_definitions(tree: SyntaxTreeNode, api) -> Dict[int, List[Dict]]:
313
+ """Map each footnote id to its rendered block content."""
314
+ definitions: Dict[int, List[Dict]] = {}
315
+ for node in tree.children:
316
+ if node.type != "footnote_block":
317
+ continue
318
+ for fn in node.children:
319
+ # A footnote's own content should not register anchors of its own.
320
+ local_ctx = {"order": []}
321
+ content: List[Dict] = []
322
+ for child in fn.children:
323
+ content.extend(_render_block(child, api, local_ctx))
324
+ definitions[fn.meta["id"]] = content
325
+ return definitions
326
+
327
+
328
+ def markdown_to_doc(markdown_content: str, api=None) -> List[Dict]:
329
+ """Convert Markdown into a list of Substack ProseMirror block nodes."""
330
+ tree = SyntaxTreeNode(_make_parser().parse(markdown_content))
331
+
332
+ definitions = _footnote_definitions(tree, api)
333
+
334
+ ctx: Dict = {"order": []}
335
+ out: List[Dict] = []
238
336
  for node in tree.children:
239
337
  if node.type == "footnote_block":
240
338
  continue
339
+ if node.type == "html_block":
340
+ attrs = parse_image_marker(node.content)
341
+ if attrs is not None:
342
+ if not out or out[-1].get("type") != "captionedImage":
343
+ raise ValueError("Image marker must immediately follow an image")
344
+ out[-1]["content"][0]["attrs"].update(attrs)
345
+ out[-1]["content"][0]["attrs"]["isProcessing"] = False
346
+ continue
241
347
  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
348
+
349
+ # Emit one footnote block per reference, in anchor order, numbered to match.
350
+ for number, footnote_id in enumerate(ctx["order"], start=1):
351
+ content = copy.deepcopy(definitions.get(footnote_id, []))
352
+ out.append(nodes.footnote(number, content))
353
+
354
+ return out
File without changes