Skip to content

Commit 57e95d6

Browse files
committed
Preserve footnote block content; make e2e opt-in
- Render footnote definition children through the block renderer so block content (e.g. a list) is preserved instead of dropped; verified in the browser that Substack renders it as a real nested list. Adds a regression test and a footnote-with-list case to the e2e fixture. - The live e2e test now requires RUN_SUBSTACK_E2E=1 in addition to credentials, so a normal pytest run with a .env does not create/delete real drafts by surprise.
1 parent c0da8a7 commit 57e95d6

5 files changed

Lines changed: 102 additions & 12 deletions

File tree

‎substack/mdrender.py‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,13 @@ def _render_block(node: SyntaxTreeNode, api) -> List[Dict]:
148148
out = []
149149
for fn in node.children:
150150
number = fn.meta["id"] + 1
151-
paras = [
152-
nodes.paragraph(_render_inline(child.children[0], []))
153-
for child in fn.children
154-
if child.type == "paragraph"
155-
]
156-
out.append(nodes.footnote(number, paras))
151+
# Render every child block (paragraphs, lists, code, ...) so footnote
152+
# content is preserved. The trailing footnote_anchor backref renders to
153+
# nothing and is dropped.
154+
children_nodes = []
155+
for child in fn.children:
156+
children_nodes.extend(_render_block(child, api))
157+
out.append(nodes.footnote(number, children_nodes))
157158
return out
158159

159160
return []

‎tests/substack/fixtures/full_features.expected.json‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,21 @@
199199
}
200200
]
201201
},
202+
{
203+
"type": "paragraph",
204+
"content": [
205+
{
206+
"type": "text",
207+
"text": "A footnote whose definition is a list."
208+
},
209+
{
210+
"type": "footnoteAnchor",
211+
"attrs": {
212+
"number": 3
213+
}
214+
}
215+
]
216+
},
202217
{
203218
"type": "bullet_list",
204219
"content": [
@@ -749,5 +764,46 @@
749764
]
750765
}
751766
]
767+
},
768+
{
769+
"type": "footnote",
770+
"attrs": {
771+
"number": 3
772+
},
773+
"content": [
774+
{
775+
"type": "bullet_list",
776+
"content": [
777+
{
778+
"type": "list_item",
779+
"content": [
780+
{
781+
"type": "paragraph",
782+
"content": [
783+
{
784+
"type": "text",
785+
"text": "list item one"
786+
}
787+
]
788+
}
789+
]
790+
},
791+
{
792+
"type": "list_item",
793+
"content": [
794+
{
795+
"type": "paragraph",
796+
"content": [
797+
{
798+
"type": "text",
799+
"text": "list item two"
800+
}
801+
]
802+
}
803+
]
804+
}
805+
]
806+
}
807+
]
752808
}
753809
]

‎tests/substack/fixtures/full_features.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ A paragraph with **bold**, *italic*, ***bold italic***, `inline code`, ~~striket
1414

1515
A sentence with a numeric footnote[^1] and a named footnote[^note], plus a repeat of the first.[^1]
1616

17+
A footnote whose definition is a list.[^listnote]
18+
1719
- Bullet one
1820
- Bullet two with **bold**
1921

@@ -71,3 +73,5 @@ An autolink to <https://example.com> becomes a link.
7173

7274
And a second paragraph after a blank line.
7375
[^unused]: This definition is never referenced and should be dropped.
76+
[^listnote]: - list item one
77+
- list item two

‎tests/substack/test_end_to_end.py‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
retrieves it, and compares the (normalised) stored document against a saved
55
golden file (``full_features.expected.json``).
66
7-
Requires live credentials in the environment (a ``.env`` file is loaded):
7+
This test creates and deletes a real draft, so it is opt-in: it only runs when
8+
``RUN_SUBSTACK_E2E`` is set **and** credentials are configured (a ``.env`` file
9+
is loaded). A normal ``pytest`` run with a ``.env`` present will skip it rather
10+
than touching the account.
811
12+
- ``RUN_SUBSTACK_E2E=1`` to enable
913
- ``COOKIES_STRING`` (or ``COOKIES_PATH``), **or** ``EMAIL`` + ``PASSWORD``
1014
- ``PUBLICATION_URL`` (optional but recommended)
1115
12-
The test is skipped when no credentials are configured, so it is safe to run in
13-
CI without secrets.
14-
1516
To regenerate the golden file after an intentional change::
1617
1718
python -m tests.substack.test_end_to_end --generate
@@ -60,14 +61,23 @@ def walk(node):
6061

6162

6263
def _has_credentials() -> bool:
63-
"""Whether auth is configured, checked without any network calls."""
6464
return bool(
6565
os.getenv("COOKIES_STRING")
6666
or os.getenv("COOKIES_PATH")
6767
or (os.getenv("EMAIL") and os.getenv("PASSWORD"))
6868
)
6969

7070

71+
def _e2e_enabled() -> bool:
72+
"""Only run when explicitly opted in AND credentials are present.
73+
74+
Creating/deleting real drafts should never happen by surprise on a local
75+
``pytest`` run that merely has a ``.env`` file, so an explicit
76+
``RUN_SUBSTACK_E2E`` opt-in is required.
77+
"""
78+
return bool(os.getenv("RUN_SUBSTACK_E2E")) and _has_credentials()
79+
80+
7181
def _api_from_env() -> Api:
7282
cookies_string = os.getenv("COOKIES_STRING")
7383
cookies_path = os.getenv("COOKIES_PATH")
@@ -141,7 +151,10 @@ def _roundtrip(api: Api):
141151
pass
142152

143153

144-
@pytest.mark.skipif(not _has_credentials(), reason="no Substack credentials configured")
154+
@pytest.mark.skipif(
155+
not _e2e_enabled(),
156+
reason="set RUN_SUBSTACK_E2E=1 and configure credentials to run the live e2e test",
157+
)
145158
def test_full_features_roundtrip():
146159
api = _api_from_env()
147160
content = _roundtrip(api)

‎tests/substack/test_footnotes.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,22 @@ def test_footnote_helper_splits_paragraphs(self):
243243
assert len(block["content"]) == 2
244244
assert block["content"][1]["content"][0]["text"] == "Para two."
245245

246+
def test_definition_with_block_content_is_preserved(self):
247+
# A footnote whose definition is a list (or other block content) must keep
248+
# its text rather than dropping it.
249+
post = make_post()
250+
post.from_markdown("Claim[^1]\n\n[^1]: - item one\n - item two")
251+
note = footnotes(post)[0]
252+
text = " ".join(
253+
n.get("text", "")
254+
for para in find_nodes(note, "paragraph")
255+
for n in para.get("content", [])
256+
)
257+
assert "item one" in text
258+
assert "item two" in text
259+
# rendered as a real nested list inside the footnote
260+
assert find_nodes(note, "bullet_list")
261+
246262
def test_no_footnotes_is_unchanged(self):
247263
post = make_post()
248264
post.from_markdown("Just a plain paragraph.")

0 commit comments

Comments
 (0)