python-substack 0.7.0__py3-none-any.whl → 0.8.0__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.
- {python_substack-0.7.0.dist-info → python_substack-0.8.0.dist-info}/METADATA +1 -1
- python_substack-0.8.0.dist-info/RECORD +14 -0
- substack/__init__.py +1 -1
- substack/api.py +21 -6
- substack/cli.py +5 -0
- substack/mdrender.py +305 -248
- python_substack-0.7.0.dist-info/RECORD +0 -14
- {python_substack-0.7.0.dist-info → python_substack-0.8.0.dist-info}/WHEEL +0 -0
- {python_substack-0.7.0.dist-info → python_substack-0.8.0.dist-info}/entry_points.txt +0 -0
- {python_substack-0.7.0.dist-info → python_substack-0.8.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
substack/__init__.py,sha256=WVrmiMIx9EAaWFyqP7Bd9794hRFTK9-Hh0d3YsyU1Ps,450
|
|
2
|
+
substack/api.py,sha256=-qJiMrgnYTWuBpM6RdGJQLstuNGSPPaiO6VjH6z0cyg,30585
|
|
3
|
+
substack/cli.py,sha256=L0loIUXhL7d9yZRc8N8xJ7xuudLp4D0ToIyrAbFeVK0,26009
|
|
4
|
+
substack/exceptions.py,sha256=Y61XSPyxDxXPbVlioFvKhb94MRY0tpD389F5YdL1rGM,1390
|
|
5
|
+
substack/mdexport.py,sha256=_pSjioPJIA1wuJ6hYpvODZVswPSUROuzEndVoYnv-yU,13541
|
|
6
|
+
substack/mdrender.py,sha256=BsuwNv0UhXLDBrSN5riLqpCjsGz59ezhv8QwyVSgXh0,11470
|
|
7
|
+
substack/nodes.py,sha256=fTsGO0-lTztcXiloIjXrHVnJes9EcltGcrX-mEV2ceQ,5004
|
|
8
|
+
substack/post.py,sha256=qDXu-xzO3nRXtahc-yG2EhxKAXB2hG_Z1nLa55UMBGg,19697
|
|
9
|
+
substack_mcp/mcp_server.py,sha256=M5XH3C4iWjzvCw3uOPbY2NFljTQAl8FLt14RKaTzka0,11324
|
|
10
|
+
python_substack-0.8.0.dist-info/METADATA,sha256=EctUjZG8YTJove4oWmeaAo1c_Z3L9WmLiZAytrQBdD4,8689
|
|
11
|
+
python_substack-0.8.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
12
|
+
python_substack-0.8.0.dist-info/entry_points.txt,sha256=MKPjaBUd-0PtvxsBviStsVq1c0h8JZ_qUoYEsBK1xJc,236
|
|
13
|
+
python_substack-0.8.0.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
|
|
14
|
+
python_substack-0.8.0.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.
|
|
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__ = (
|
substack/api.py
CHANGED
|
@@ -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
|
-
_,
|
|
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:
|
substack/cli.py
CHANGED
|
@@ -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
|
|
substack/mdrender.py
CHANGED
|
@@ -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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
from
|
|
25
|
-
|
|
26
|
-
from
|
|
27
|
-
from
|
|
28
|
-
from mdit_py_plugins.
|
|
29
|
-
from mdit_py_plugins.
|
|
30
|
-
|
|
31
|
-
from
|
|
32
|
-
from
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if
|
|
164
|
-
return [
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
#
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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 `` 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
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
substack/__init__.py,sha256=soBT45w3E1XVhTYcV9Y_1D363e35S4_Q0mFrF13pHHM,450
|
|
2
|
-
substack/api.py,sha256=nBEnD2ipxSAWR99iwNgCfGD4-y8YYwKNmilq2y1VP8M,29903
|
|
3
|
-
substack/cli.py,sha256=L-nR45tJDTuvZrinj-dApXzw6-CgXcwaodyX7CpYFm4,25720
|
|
4
|
-
substack/exceptions.py,sha256=Y61XSPyxDxXPbVlioFvKhb94MRY0tpD389F5YdL1rGM,1390
|
|
5
|
-
substack/mdexport.py,sha256=_pSjioPJIA1wuJ6hYpvODZVswPSUROuzEndVoYnv-yU,13541
|
|
6
|
-
substack/mdrender.py,sha256=QGJkdp1isFmhVyTRMogIabAK8OO2xQh9CrjnXZvFTY4,9308
|
|
7
|
-
substack/nodes.py,sha256=fTsGO0-lTztcXiloIjXrHVnJes9EcltGcrX-mEV2ceQ,5004
|
|
8
|
-
substack/post.py,sha256=qDXu-xzO3nRXtahc-yG2EhxKAXB2hG_Z1nLa55UMBGg,19697
|
|
9
|
-
substack_mcp/mcp_server.py,sha256=M5XH3C4iWjzvCw3uOPbY2NFljTQAl8FLt14RKaTzka0,11324
|
|
10
|
-
python_substack-0.7.0.dist-info/METADATA,sha256=-_DF7Ln42oZYd1uPqaFQgAAZHsgka8-FqwP6VfbshVQ,8689
|
|
11
|
-
python_substack-0.7.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
12
|
-
python_substack-0.7.0.dist-info/entry_points.txt,sha256=MKPjaBUd-0PtvxsBviStsVq1c0h8JZ_qUoYEsBK1xJc,236
|
|
13
|
-
python_substack-0.7.0.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
|
|
14
|
-
python_substack-0.7.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|