diff --git a/README.md b/README.md index c4f57fa..8a42266 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,22 @@ This is a paragraph with **bold** and *italic* text. """ post.from_markdown(markdown_content, api=api) +# Markdown footnotes are supported too. References become inline anchors and +# definitions become footnote blocks, numbered by order of first appearance. +# Labels can be numbers or names (e.g. [^1] or [^source]). +footnote_markdown = """ +A claim that needs support.[^1] Another, with a named label.[^source] + +[^1]: The supporting detail, with a [link](https://example.com). +[^source]: Author, *Title* (2025). +""" +post.from_markdown(footnote_markdown, api=api) + +# Or build footnotes manually: +post.paragraph(content=[{"content": "Some claim."}]).footnote_anchor(1) +post.footnote(1, "The note text, with **formatting** allowed.") + + draft = api.post_draft(post.get_draft()) # set section (can only be done after first posting the draft) diff --git a/poetry.lock b/poetry.lock index 025d557..c22db48 100644 --- a/poetry.lock +++ b/poetry.lock @@ -65,7 +65,7 @@ version = "1.6.12" description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." optional = false python-versions = ">=3.9" -groups = ["dev", "mcp"] +groups = ["mcp"] files = [ {file = "authlib-1.6.12-py2.py3-none-any.whl", hash = "sha256:e9229ad7fde610b139dd12f5edbe97eab9ee78bfb85691247e767727850b99ab"}, {file = "authlib-1.6.12.tar.gz", hash = "sha256:0656d8482f28fc8221929d5f35b2bde5d13e10555ebc06b4561b0d622e83b1bd"}, @@ -176,7 +176,7 @@ version = "2.0.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" -groups = ["dev", "mcp"] +groups = ["mcp"] markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, @@ -437,76 +437,69 @@ files = [ [[package]] name = "cryptography" -version = "46.0.7" +version = "48.0.1" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false -python-versions = "!=3.9.0,!=3.9.1,>=3.8" -groups = ["dev", "mcp"] -files = [ - {file = "cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b"}, - {file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85"}, - {file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e"}, - {file = "cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457"}, - {file = "cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b"}, - {file = "cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2"}, - {file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e"}, - {file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee"}, - {file = "cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298"}, - {file = "cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb"}, - {file = "cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0"}, - {file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85"}, - {file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e"}, - {file = "cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246"}, - {file = "cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4"}, - {file = "cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5"}, +python-versions = "!=3.9.0,!=3.9.1,>=3.9" +groups = ["mcp"] +files = [ + {file = "cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1"}, + {file = "cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225"}, + {file = "cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691"}, + {file = "cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242"}, + {file = "cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08"}, + {file = "cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6"}, + {file = "cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8"}, + {file = "cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429"}, + {file = "cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f"}, + {file = "cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f"}, + {file = "cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41"}, + {file = "cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6"}, + {file = "cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158"}, + {file = "cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24"}, + {file = "cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345"}, + {file = "cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4"}, + {file = "cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991"}, + {file = "cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265"}, + {file = "cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17"}, + {file = "cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411"}, + {file = "cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02"}, + {file = "cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa"}, + {file = "cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3"}, + {file = "cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c"}, + {file = "cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72"}, + {file = "cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9"}, + {file = "cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471"}, + {file = "cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2"}, + {file = "cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67"}, + {file = "cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8"}, + {file = "cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a"}, + {file = "cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577"}, + {file = "cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1"}, + {file = "cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d"}, + {file = "cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6"}, + {file = "cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46"}, + {file = "cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401"}, + {file = "cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b"}, + {file = "cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1"}, + {file = "cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475"}, + {file = "cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1"}, + {file = "cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac"}, + {file = "cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a"}, + {file = "cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd"}, + {file = "cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c"}, + {file = "cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9"}, + {file = "cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92"}, + {file = "cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a"}, + {file = "cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a"}, ] [package.dependencies] -cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} [package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] -docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox[uv] (>=2024.4.15)"] -pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] -sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==46.0.7)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] -test-randomorder = ["pytest-randomly"] [[package]] name = "cyclopts" @@ -958,14 +951,14 @@ type = ["pygobject-stubs", "pytest-mypy (>=1.0.1)", "shtab", "types-pywin32"] [[package]] name = "markdown-it-py" -version = "4.0.0" +version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false -python-versions = ">=3.10" -groups = ["mcp"] +python-versions = ">=3.8" +groups = ["main", "mcp"] files = [ - {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, - {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, ] [package.dependencies] @@ -973,12 +966,13 @@ mdurl = ">=0.1,<1.0" [package.extras] benchmarking = ["psutil", "pytest", "pytest-benchmark"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins (>=0.5.0)"] +plugins = ["mdit-py-plugins"] profiling = ["gprof2dot"] -rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] name = "mcp" @@ -1013,13 +1007,33 @@ cli = ["python-dotenv (>=1.0.0)", "typer (>=0.16.0)"] rich = ["rich (>=13.9.4)"] ws = ["websockets (>=15.0.1)"] +[[package]] +name = "mdit-py-plugins" +version = "0.4.2" +description = "Collection of plugins for markdown-it-py" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636"}, + {file = "mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5"}, +] + +[package.dependencies] +markdown-it-py = ">=1.0.0,<4.0.0" + +[package.extras] +code-style = ["pre-commit"] +rtd = ["myst-parser", "sphinx-book-theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + [[package]] name = "mdurl" version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" -groups = ["mcp"] +groups = ["main", "mcp"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -1154,7 +1168,7 @@ version = "3.0" description = "C parser in Python" optional = false python-versions = ">=3.10" -groups = ["dev", "mcp"] +groups = ["mcp"] markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" files = [ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, @@ -1320,14 +1334,14 @@ typing-extensions = ">=4.14.1" [[package]] name = "pydantic-settings" -version = "2.13.1" +version = "2.14.2" description = "Settings management using Pydantic" optional = false python-versions = ">=3.10" groups = ["mcp"] files = [ - {file = "pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237"}, - {file = "pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025"}, + {file = "pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440"}, + {file = "pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f"}, ] [package.dependencies] @@ -1336,7 +1350,7 @@ python-dotenv = ">=0.21.0" typing-inspection = ">=0.4.0" [package.extras] -aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] +aws-secrets-manager = ["boto3 (>=1.35.0)", "types-boto3[secretsmanager]"] azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] toml = ["tomli (>=2.0.1)"] @@ -1359,14 +1373,14 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyjwt" -version = "2.12.1" +version = "2.13.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" groups = ["mcp"] files = [ - {file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"}, - {file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"}, + {file = "pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728"}, + {file = "pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423"}, ] [package.dependencies] @@ -1375,9 +1389,6 @@ typing_extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} [package.extras] crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] -docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] [[package]] name = "pyperclip" @@ -1408,14 +1419,14 @@ cli = ["click (>=5.0)"] [[package]] name = "python-multipart" -version = "0.0.27" +version = "0.0.31" description = "A streaming multipart parser for Python" optional = false python-versions = ">=3.10" groups = ["mcp"] files = [ - {file = "python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645"}, - {file = "python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602"}, + {file = "python_multipart-0.0.31-py3-none-any.whl", hash = "sha256:8408153d68a9773291fc1da39a8b85a50044bddbabd2dd72e9229776b7b15e28"}, + {file = "python_multipart-0.0.31.tar.gz", hash = "sha256:fc631183bb13e56db3158a4909908dfb2e23565286744e798241e63750e5d680"}, ] [[package]] @@ -1789,14 +1800,14 @@ uvicorn = ["uvicorn (>=0.34.0)"] [[package]] name = "starlette" -version = "1.0.0" +version = "1.3.1" description = "The little ASGI library that shines." optional = false python-versions = ">=3.10" groups = ["mcp"] files = [ - {file = "starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b"}, - {file = "starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149"}, + {file = "starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6"}, + {file = "starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0"}, ] [package.dependencies] @@ -1804,7 +1815,7 @@ anyio = ">=3.6.2,<5" typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} [package.extras] -full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] +full = ["httpx (>=0.27.0,<0.29.0)", "httpx2 (>=2.0.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] [[package]] name = "tomli" @@ -1870,12 +1881,11 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["dev", "mcp"] +groups = ["mcp"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -markers = {dev = "python_version == \"3.10\""} [[package]] name = "typing-inspection" @@ -2158,4 +2168,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "<4.0,>=3.10" -content-hash = "add336e7fc3c6fa9a72fb7e0b3e2950d49783e2eeb82e1783c61de0c6f09054f" +content-hash = "11a5871352eb39ede1d5316615d0d98fb5da8481a3b3a294ba6a772de59c4420" diff --git a/pyproject.toml b/pyproject.toml index 9a96686..6a59a52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-substack" -version = "0.1.22" +version = "0.1.24" description = "A Python wrapper around the Substack API." authors = ["Paolo Mazza "] license = "MIT" @@ -21,6 +21,8 @@ python = "<4.0,>=3.10" requests = "^2.32.0" python-dotenv = "^1.2.1" PyYAML = "^6.0" +markdown-it-py = "^3.0" +mdit-py-plugins = "^0.4" [tool.poetry.group.dev.dependencies] diff --git a/substack/__init__.py b/substack/__init__.py index 4963dbe..49457ba 100644 --- a/substack/__init__.py +++ b/substack/__init__.py @@ -3,7 +3,7 @@ __author__ = "Paolo Mazza" __email__ = "mazzapaolo2019@gmail.com" __license__ = "MIT License" -__version__ = "0.1.21" +__version__ = "0.1.24" __url__ = "https://github.com/ma2za/python-substack" __download_url__ = "https://pypi.python.org/pypi/python-substack" __description__ = "A Python wrapper around the Substack API" diff --git a/substack/mdrender.py b/substack/mdrender.py new file mode 100644 index 0000000..e04b97e --- /dev/null +++ b/substack/mdrender.py @@ -0,0 +1,210 @@ +"""Markdown -> Substack ProseMirror via markdown-it-py. + +Implements Post.from_markdown() using a real CommonMark parser (markdown-it-py) +plus the standard footnote plugin, with a small renderer that walks the syntax +tree into Substack's node schema. + +Node construction goes through ``substack.nodes`` so the (undocumented) schema +lives in exactly one place. + +Footnotes: Substack numbers footnote anchors by their position in the document +and pairs them one-to-one, in order, with the footnote blocks at the end (it +ignores any explicit number and does not support one block serving several +anchors). So each reference is emitted as its own sequentially-numbered anchor, +and a matching footnote block is appended for each -- a definition referenced +more than once is duplicated, which mirrors how Substack's own editor behaves. +""" + +from __future__ import annotations + +import copy +from typing import Dict, List, Optional + +from markdown_it import MarkdownIt +from markdown_it.tree import SyntaxTreeNode +from mdit_py_plugins.footnote import footnote_plugin + +from substack import nodes +from substack.nodes import MarkType, NodeType + +_MARK_FOR = { + "strong": {"type": MarkType.STRONG}, + "em": {"type": MarkType.EM}, + "s": {"type": MarkType.STRIKETHROUGH}, +} + + +def _make_parser() -> MarkdownIt: + return MarkdownIt("commonmark").use(footnote_plugin).enable("strikethrough") + + +def _coalesce(out_nodes: List[Dict]) -> List[Dict]: + """Merge adjacent text nodes that carry identical marks (e.g. softbreaks).""" + merged: List[Dict] = [] + for node in out_nodes: + if ( + merged + and node.get("type") == NodeType.TEXT + and merged[-1].get("type") == NodeType.TEXT + and node.get("marks") == merged[-1].get("marks") + ): + merged[-1]["text"] += node["text"] + else: + merged.append(node) + return merged + + +def _render_inline(node: SyntaxTreeNode, marks: List[Dict], ctx: Dict) -> List[Dict]: + """Render an inline subtree into a flat list of text / anchor nodes.""" + out: List[Dict] = [] + for child in node.children: + t = child.type + if t == "text": + if child.content: + out.append(nodes.text(child.content, marks)) + elif t == "code_inline": + out.append(nodes.text(child.content, marks + [nodes.code_mark()])) + elif t in _MARK_FOR: + out.extend(_render_inline(child, marks + [_MARK_FOR[t]], ctx)) + elif t == "link": + href = child.attrs.get("href", "") + out.extend(_render_inline(child, marks + [nodes.link_mark(href)], ctx)) + elif t in ("softbreak", "hardbreak"): + out.append(nodes.text(" ", marks)) + elif t == "footnote_ref": + # Number anchors by document position and record which definition each + # one points to, so matching blocks can be emitted 1:1 afterwards. + ctx["order"].append(child.meta["id"]) + out.append(nodes.footnote_anchor(len(ctx["order"]))) + elif t == "image": + # Inline images are rare in this schema; fall back to alt text. + alt = child.attrs.get("alt") or "".join( + c.content for c in child.children if c.type == "text" + ) + if alt: + out.append(nodes.text(alt, marks)) + return _coalesce(out) + + +def _only_image(inline: SyntaxTreeNode) -> Optional[SyntaxTreeNode]: + """If an inline node is just an image (optionally wrapped in a link), return it.""" + kids = [c for c in inline.children if c.type != "softbreak"] + if len(kids) == 1 and kids[0].type == "image": + return kids[0] + if len(kids) == 1 and kids[0].type == "link": + inner = [c for c in kids[0].children if c.type != "softbreak"] + if len(inner) == 1 and inner[0].type == "image": + img = inner[0] + img._link_href = kids[0].attrs.get("href") # type: ignore[attr-defined] + return img + return None + + +def _captioned_image(img: SyntaxTreeNode, api) -> Dict: + src = img.attrs.get("src", "") + if src.startswith("/"): + src = src[1:] + if api is not None and not src.startswith("http"): + try: + src = api.get_image(src).get("url") + except Exception: + pass + # markdown-it stores the image alt text as the node's content, not in attrs. + alt = img.content or img.attrs.get("alt") or None + # Standard markdown image title `![alt](src "caption")` maps to Substack's caption node. + title = img.attrs.get("title") or None + caption = [nodes.text(title)] if title else None + return nodes.captioned_image( + src, + alt=alt, + href=getattr(img, "_link_href", None), + caption=caption, + ) + + +def _render_block(node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]: + """Render a block-level node into zero or more Substack nodes.""" + t = node.type + + if t == "paragraph": + inline = node.children[0] + img = _only_image(inline) + if img is not None: + return [_captioned_image(img, api)] + return [nodes.paragraph(_render_inline(inline, [], ctx))] + + if t == "heading": + level = int(node.tag[1]) + return [nodes.heading(_render_inline(node.children[0], [], ctx), level=level)] + + if t == "hr": + return [nodes.horizontal_rule()] + + if t in ("fence", "code_block"): + return [ + nodes.code_block( + node.content.rstrip("\n"), language=node.info.strip() or None + ) + ] + + if t == "blockquote": + paras: List[Dict] = [] + for child in node.children: + paras.extend(_render_block(child, api, ctx)) + return [nodes.blockquote(paras)] + + if t == "bullet_list": + return [nodes.bullet_list(_render_list_items(node, api, ctx))] + + if t == "ordered_list": + return [nodes.ordered_list(_render_list_items(node, api, ctx))] + + # footnote_block is handled separately in markdown_to_doc; ignore it here. + return [] + + +def _render_list_items(list_node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]: + items = [] + for li in list_node.children: + content: List[Dict] = [] + for child in li.children: + content.extend(_render_block(child, api, ctx)) + items.append({"type": NodeType.LIST_ITEM, "content": content}) + return items + + +def _footnote_definitions(tree: SyntaxTreeNode, api) -> Dict[int, List[Dict]]: + """Map each footnote id to its rendered block content.""" + definitions: Dict[int, List[Dict]] = {} + for node in tree.children: + if node.type != "footnote_block": + continue + for fn in node.children: + # A footnote's own content should not register anchors of its own. + local_ctx = {"order": []} + content: List[Dict] = [] + for child in fn.children: + content.extend(_render_block(child, api, local_ctx)) + definitions[fn.meta["id"]] = content + return definitions + + +def markdown_to_doc(markdown_content: str, api=None) -> List[Dict]: + """Convert Markdown into a list of Substack ProseMirror block nodes.""" + tree = SyntaxTreeNode(_make_parser().parse(markdown_content)) + + definitions = _footnote_definitions(tree, api) + + ctx: Dict = {"order": []} + out: List[Dict] = [] + for node in tree.children: + if node.type == "footnote_block": + continue + out.extend(_render_block(node, api, ctx)) + + # Emit one footnote block per reference, in anchor order, numbered to match. + for number, footnote_id in enumerate(ctx["order"], start=1): + content = copy.deepcopy(definitions.get(footnote_id, [])) + out.append(nodes.footnote(number, content)) + + return out diff --git a/substack/nodes.py b/substack/nodes.py new file mode 100644 index 0000000..c5571ba --- /dev/null +++ b/substack/nodes.py @@ -0,0 +1,148 @@ +"""ProseMirror node builders for Substack documents. + +Centralises the (undocumented) Substack ProseMirror schema in one place. +The node-type strings ("paragraph", "footnoteAnchor", "image2", ...) and +their shapes live here rather than as inline dict literals scattered across +post.py, giving: + + * one source of truth for node shapes (so a schema change is a one-line fix), + * discoverable, typed constructors instead of bare dict literals, + * a natural seam for validation. + +The builders intentionally return plain dicts so they stay 100% compatible with +the existing draft_body structure. +""" + +from __future__ import annotations + +from typing import Dict, List, Optional + + +class NodeType: + DOC = "doc" + PARAGRAPH = "paragraph" + HEADING = "heading" + TEXT = "text" + BLOCKQUOTE = "blockquote" + CODE_BLOCK = "codeBlock" + HORIZONTAL_RULE = "horizontal_rule" + BULLET_LIST = "bullet_list" + ORDERED_LIST = "ordered_list" + LIST_ITEM = "list_item" + FOOTNOTE = "footnote" + FOOTNOTE_ANCHOR = "footnoteAnchor" + CAPTIONED_IMAGE = "captionedImage" + CAPTION = "caption" + + +class MarkType: + STRONG = "strong" + EM = "em" + CODE = "code" + STRIKETHROUGH = "strikethrough" + LINK = "link" + + +def code_mark() -> Dict: + return {"type": MarkType.CODE} + + +def text(value: str, marks: Optional[List[Dict]] = None) -> Dict: + node: Dict = {"type": NodeType.TEXT, "text": value} + if marks: + node["marks"] = marks + return node + + +def link_mark(href: str) -> Dict: + return {"type": MarkType.LINK, "attrs": {"href": href}} + + +def paragraph(content: Optional[List[Dict]] = None) -> Dict: + return {"type": NodeType.PARAGRAPH, "content": content or []} + + +def heading(content: List[Dict], level: int = 1) -> Dict: + return {"type": NodeType.HEADING, "content": content, "attrs": {"level": level}} + + +def horizontal_rule() -> Dict: + return {"type": NodeType.HORIZONTAL_RULE} + + +def blockquote(paragraphs: List[Dict]) -> Dict: + node: Dict = {"type": NodeType.BLOCKQUOTE} + if paragraphs: + node["content"] = paragraphs + return node + + +def list_item(content_nodes: List[Dict]) -> Dict: + return { + "type": NodeType.LIST_ITEM, + "content": [paragraph(content_nodes)], + } + + +def bullet_list(items: List[Dict]) -> Dict: + return {"type": NodeType.BULLET_LIST, "content": items} + + +def ordered_list(items: List[Dict]) -> Dict: + return {"type": NodeType.ORDERED_LIST, "content": items} + + +def code_block(code: str, language: Optional[str] = None) -> Dict: + node: Dict = {"type": NodeType.CODE_BLOCK, "content": [text(code)]} + if language: + node["attrs"] = {"language": language} + return node + + +def captioned_image( + src: str, + alt: Optional[str] = None, + href: Optional[str] = None, + caption: Optional[List[Dict]] = None, + image_size: str = "normal", +) -> Dict: + content: List[Dict] = [ + { + "type": "image2", + "attrs": { + "src": src, + "srcNoWatermark": None, + "fullscreen": False, + "imageSize": image_size, + "height": 819, + "width": 1456, + "resizeWidth": 728 if image_size == "normal" else None, + "bytes": None, + "alt": alt, + "title": None, + "type": None, + "href": href, + "belowTheFold": False, + "topImage": False, + "internalRedirect": None, + "isProcessing": False, + "align": None, + "offset": False, + }, + } + ] + if caption: + content.append({"type": NodeType.CAPTION, "content": caption}) + return {"type": NodeType.CAPTIONED_IMAGE, "content": content} + + +def footnote_anchor(number: int) -> Dict: + return {"type": NodeType.FOOTNOTE_ANCHOR, "attrs": {"number": number}} + + +def footnote(number: int, paragraphs: List[Dict]) -> Dict: + return { + "type": NodeType.FOOTNOTE, + "attrs": {"number": number}, + "content": paragraphs or [paragraph()], + } diff --git a/substack/post.py b/substack/post.py index 8a9d55d..f325048 100644 --- a/substack/post.py +++ b/substack/post.py @@ -11,6 +11,7 @@ __all__ = ["Post", "parse_inline", "tokens_to_text_nodes"] from substack.exceptions import SectionNotExistsException +from substack import nodes def tokens_to_text_nodes(tokens: List[Dict]) -> List[Dict]: @@ -543,6 +544,58 @@ def code_block(self, content, attrs=None): return self + def footnote_anchor(self, number: int): + """ + + Add an inline footnote reference (the superscript marker) to the last block. + + Args: + number: The footnote number this anchor points to. + + Returns: + Self for method chaining. + + """ + content = self.draft_body["content"][-1].get("content", []) + content += [nodes.footnote_anchor(number)] + self.draft_body["content"][-1]["content"] = content + return self + + def footnote(self, number: int, content=None): + """ + + Append a footnote block (the note shown at the foot of the post). + + Args: + number: The footnote number, matching a footnote_anchor. + content: Text string or list of inline token dicts. A plain string is + parsed for inline Markdown and may contain blank-line-separated + paragraphs; a parse_inline() token list or a list of ready text + nodes is also accepted (single paragraph). + + Returns: + Self for method chaining. + + """ + paragraphs: List[Dict] = [] + if isinstance(content, str): + # Blank lines separate paragraphs within the footnote. + for chunk in re.split(r"\n\s*\n", content): + chunk = chunk.strip() + if chunk: + paragraphs.append(nodes.paragraph(tokens_to_text_nodes(parse_inline(chunk)))) + elif isinstance(content, list): + # Accept either parse_inline tokens ({"content": ...}) or text nodes. + if content and content[0].get("type") == "text": + text_nodes = content + else: + text_nodes = tokens_to_text_nodes(content) + paragraphs.append(nodes.paragraph(text_nodes)) + + node: Dict = nodes.footnote(number, paragraphs) + self.draft_body["content"] = self.draft_body.get("content", []) + [node] + return self + def from_markdown(self, markdown_content: str, api=None): """ Parse Markdown content and add it to the post. @@ -559,6 +612,10 @@ def from_markdown(self, markdown_content: str, api=None): - Ordered lists: Lines starting with '1.', '2.', etc. - Horizontal rules: Lines with ---, ***, or ___ - Inline formatting: **bold**, *italic*, ***bold+italic***, `code`, ~~strikethrough~~ + - Footnotes: ``text.[^label]`` references plus ``[^label]: definition`` + lines. References become inline anchors and definitions become + footnote blocks, numbered by order of first appearance. Labels may be + numbers or names (e.g. ``[^1]`` or ``[^agi-book]``). Args: markdown_content: Markdown string to parse and add to the post. @@ -572,276 +629,8 @@ def from_markdown(self, markdown_content: str, api=None): >>> post = Post("Title", "Subtitle", user_id) >>> post.from_markdown("# Heading\\n\\nThis is **bold** text with [a link](https://example.com).") """ - lines = markdown_content.split("\n") - blocks = [] - current_block: List[str] = [] - in_code_block = False - code_block_language = None - - for line in lines: - # Check for fenced code block start/end - if line.strip().startswith("```"): - if in_code_block: - # End of code block - if current_block: - blocks.append({ - "type": "code", - "language": code_block_language, - "content": "\n".join(current_block) - }) - current_block = [] - in_code_block = False - code_block_language = None - else: - # Start of code block - if current_block: - blocks.append({"type": "text", "content": "\n".join(current_block)}) - current_block = [] - # Extract language if specified - language = line.strip()[3:].strip() - code_block_language = language if language else None - in_code_block = True - continue - - if in_code_block: - # Inside code block - collect lines as-is - current_block.append(line) - else: - # Regular content - if line.strip() == "": - # Empty line - end current block if it has content - if current_block: - blocks.append({"type": "text", "content": "\n".join(current_block)}) - current_block = [] - else: - current_block.append(line) - - # Add any remaining content - if current_block: - if in_code_block: - blocks.append({ - "type": "code", - "language": code_block_language, - "content": "\n".join(current_block) - }) - else: - blocks.append({"type": "text", "content": "\n".join(current_block)}) - - # Process blocks - for block in blocks: - if block["type"] == "code": - # Add code block - code_content = block.get("content", "").strip() - if code_content: - # Substack uses "codeBlock" type - code_attrs = {} - if block.get("language"): - code_attrs["language"] = block["language"] - self.add({ - "type": "codeBlock", - "content": code_content, # Pass as string, code_block method will handle it - "attrs": code_attrs - }) - else: - # Process text block - text_content = block.get("content", "").strip() - if not text_content: - continue - - # Check for horizontal rule: ---, ***, ___ - if re.match(r'^(\*{3,}|-{3,}|_{3,})\s*$', text_content): - self.horizontal_rule() - continue - - # Process headings (lines starting with '#' characters) - if text_content.startswith("#"): - level = len(text_content) - len(text_content.lstrip("#")) - heading_text = text_content.lstrip("#").strip() - if heading_text: # Only add if there's actual text - self.heading(content=heading_text, level=min(level, 6)) - - # Process images using Markdown image syntax: ![Alt](URL) - # Also handle linked images: [![Alt](image_url)](link_url) - elif text_content.startswith("!") or (text_content.startswith("[") and "![" in text_content): - # Check for linked image first: [![alt](img)](link) - linked_image_match = re.match(r'\[!\[([^\]]*)\]\(([^)]+)\)\]\(([^)]+)\)', text_content) - if linked_image_match: - # Linked image - create image with href - alt_text = linked_image_match.group(1) - image_url = linked_image_match.group(2) - link_url = linked_image_match.group(3) - - # Adjust image URL if it starts with a slash - image_url = image_url[1:] if image_url.startswith("/") else image_url - - # If api is provided and image_url is a local file, upload it - if api is not None: - try: - image = api.get_image(image_url) - image_url = image.get("url") - except Exception: - # If upload fails, use original URL - pass - - self.add({ - "type": "captionedImage", - "src": image_url, - "alt": alt_text, - "href": link_url - }) - else: - # Regular image: ![Alt](URL) - match = re.match(r"!\[.*?\]\((.*?)\)", text_content) - if match: - image_url = match.group(1) - # Adjust image URL if it starts with a slash - image_url = image_url[1:] if image_url.startswith("/") else image_url - - # If api is provided and image_url is a local file, upload it - if api is not None: - try: - image = api.get_image(image_url) - image_url = image.get("url") - except Exception: - # If upload fails, use original URL - pass - - self.add({"type": "captionedImage", "src": image_url}) - - # Process paragraphs, bullet lists, ordered lists, or blockquotes - else: - if "\n" in text_content: - # Process each line, grouping consecutive bullets/ordered items - # into list nodes and consecutive blockquote lines into a - # single blockquote node. - pending_bullets: List[List[Dict]] = [] - pending_quotes: List[str] = [] - pending_ordered: List[List[Dict]] = [] - - def flush_bullets(): - if not pending_bullets: - return - list_items = [] - for bullet_nodes in pending_bullets: - list_items.append({ - "type": "list_item", - "content": [{"type": "paragraph", "content": bullet_nodes}], - }) - self.draft_body["content"].append( - {"type": "bullet_list", "content": list_items} - ) - pending_bullets.clear() - - def flush_quotes(): - if not pending_quotes: - return - paragraphs: List[Dict] = [] - for quote_line in pending_quotes: - tokens = parse_inline(quote_line) - text_nodes = tokens_to_text_nodes(tokens) - if text_nodes: - paragraphs.append({"type": "paragraph", "content": text_nodes}) - node: Dict = {"type": "blockquote"} - if paragraphs: - node["content"] = paragraphs - self.draft_body["content"].append(node) - pending_quotes.clear() - - def flush_ordered(): - if not pending_ordered: - return - list_items = [] - for item_nodes in pending_ordered: - list_items.append({ - "type": "list_item", - "content": [{"type": "paragraph", "content": item_nodes}], - }) - self.draft_body["content"].append( - {"type": "ordered_list", "content": list_items} - ) - pending_ordered.clear() - - for line in text_content.split("\n"): - line = line.strip() - if not line: - flush_bullets() - flush_ordered() - flush_quotes() - continue - - # Check for blockquote marker - if line.startswith("> ") or line == ">": - flush_bullets() - flush_ordered() - quote_text = line[2:] if line.startswith("> ") else "" - pending_quotes.append(quote_text) - continue - - # Check for ordered list marker - ordered_match = re.match(r'^(\d+)\.\s+(.*)', line) - if ordered_match: - flush_bullets() - flush_quotes() - item_text = ordered_match.group(2).strip() - tokens = parse_inline(item_text) - text_nodes = tokens_to_text_nodes(tokens) - if text_nodes: - pending_ordered.append(text_nodes) - continue - - # Check for bullet marker - bullet_text = None - if line.startswith("* "): - bullet_text = line[2:].strip() - elif line.startswith("- "): - bullet_text = line[2:].strip() - elif line.startswith("*") and not line.startswith("**"): - bullet_text = line[1:].strip() - - if bullet_text is not None: - flush_ordered() - flush_quotes() - tokens = parse_inline(bullet_text) - text_nodes = tokens_to_text_nodes(tokens) - if text_nodes: - pending_bullets.append(text_nodes) - else: - flush_bullets() - flush_ordered() - flush_quotes() - tokens = parse_inline(line) - self.add({"type": "paragraph", "content": tokens}) - - flush_bullets() - flush_ordered() - flush_quotes() - else: - # Single line — blockquote, ordered list, or paragraph - if text_content.startswith("> ") or text_content == ">": - quote_text = text_content[2:] if text_content.startswith("> ") else "" - tokens = parse_inline(quote_text) - text_nodes = tokens_to_text_nodes(tokens) - para = {"type": "paragraph", "content": text_nodes} if text_nodes else {"type": "paragraph"} - self.draft_body["content"] = self.draft_body.get("content", []) + [ - {"type": "blockquote", "content": [para]} - ] - - elif re.match(r'^(\d+)\.\s+(.*)', text_content): - ordered_match = re.match(r'^(\d+)\.\s+(.*)', text_content) - item_text = ordered_match.group(2).strip() - tokens = parse_inline(item_text) - text_nodes = tokens_to_text_nodes(tokens) - if text_nodes: - list_item = { - "type": "list_item", - "content": [{"type": "paragraph", "content": text_nodes}], - } - self.draft_body["content"].append( - {"type": "ordered_list", "content": [list_item]} - ) - - else: - tokens = parse_inline(text_content) - self.add({"type": "paragraph", "content": tokens}) + from substack import mdrender + rendered = mdrender.markdown_to_doc(markdown_content, api=api) + self.draft_body["content"] = self.draft_body.get("content", []) + rendered return self diff --git a/tests/substack/fixtures/full_features.expected.json b/tests/substack/fixtures/full_features.expected.json new file mode 100644 index 0000000..ad7e75c --- /dev/null +++ b/tests/substack/fixtures/full_features.expected.json @@ -0,0 +1,894 @@ +[ + { + "type": "heading", + "content": [ + { + "type": "text", + "text": "Heading level 1" + } + ], + "attrs": { + "level": 1 + } + }, + { + "type": "heading", + "content": [ + { + "type": "text", + "text": "Heading level 2" + } + ], + "attrs": { + "level": 2 + } + }, + { + "type": "heading", + "content": [ + { + "type": "text", + "text": "Heading level 3" + } + ], + "attrs": { + "level": 3 + } + }, + { + "type": "heading", + "content": [ + { + "type": "text", + "text": "Heading level 4" + } + ], + "attrs": { + "level": 4 + } + }, + { + "type": "heading", + "content": [ + { + "type": "text", + "text": "Heading level 5" + } + ], + "attrs": { + "level": 5 + } + }, + { + "type": "heading", + "content": [ + { + "type": "text", + "text": "Heading level 6" + } + ], + "attrs": { + "level": 6 + } + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A paragraph with " + }, + { + "type": "text", + "text": "bold", + "marks": [ + { + "type": "strong" + } + ] + }, + { + "type": "text", + "text": ", " + }, + { + "type": "text", + "text": "italic", + "marks": [ + { + "type": "em" + } + ] + }, + { + "type": "text", + "text": ", " + }, + { + "type": "text", + "text": "bold italic", + "marks": [ + { + "type": "em" + }, + { + "type": "strong" + } + ] + }, + { + "type": "text", + "text": ", " + }, + { + "type": "text", + "text": "inline code", + "marks": [ + { + "type": "code" + } + ] + }, + { + "type": "text", + "text": ", " + }, + { + "type": "text", + "text": "strikethrough", + "marks": [ + { + "type": "strikethrough" + } + ] + }, + { + "type": "text", + "text": ", and a " + }, + { + "type": "text", + "text": "link", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://example.com" + } + } + ] + }, + { + "type": "text", + "text": "." + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A sentence with a numeric footnote" + }, + { + "type": "footnoteAnchor", + "attrs": { + "number": 1 + } + }, + { + "type": "text", + "text": " and a named footnote" + }, + { + "type": "footnoteAnchor", + "attrs": { + "number": 2 + } + }, + { + "type": "text", + "text": ", plus a repeat of the first." + }, + { + "type": "footnoteAnchor", + "attrs": { + "number": 3 + } + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A footnote whose definition is a list." + }, + { + "type": "footnoteAnchor", + "attrs": { + "number": 4 + } + } + ] + }, + { + "type": "bullet_list", + "content": [ + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Bullet one" + } + ] + } + ] + }, + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Bullet two with " + }, + { + "type": "text", + "text": "bold", + "marks": [ + { + "type": "strong" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "ordered_list", + "content": [ + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Ordered one" + } + ] + } + ] + }, + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Ordered two" + } + ] + } + ] + } + ] + }, + { + "type": "bullet_list", + "content": [ + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Bulleted list with a nested bulleted list:" + } + ] + }, + { + "type": "bullet_list", + "content": [ + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "inner bullet a" + } + ] + } + ] + }, + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "inner bullet b" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Bulleted list with a nested numbered list:" + } + ] + }, + { + "type": "ordered_list", + "content": [ + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "inner number a" + } + ] + } + ] + }, + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "inner number b" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "ordered_list", + "content": [ + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Numbered list with a nested bulleted list:" + } + ] + }, + { + "type": "bullet_list", + "content": [ + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "inner bullet a" + } + ] + } + ] + }, + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "inner bullet b" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Numbered list with a nested numbered list:" + } + ] + }, + { + "type": "ordered_list", + "content": [ + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "inner number a" + } + ] + } + ] + }, + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "inner number b" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "blockquote", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A blockquote across two wrapped lines." + } + ] + } + ] + }, + { + "type": "blockquote", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "First quote paragraph." + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Second quote paragraph." + } + ] + } + ] + }, + { + "type": "codeBlock", + "content": [ + { + "type": "text", + "text": "# fenced code: footnote-like text must stay literal\nx = \"[^1]: not a footnote\"\nprint(\"reference [^1] stays text\")" + } + ], + "attrs": { + "language": "python" + } + }, + { + "type": "codeBlock", + "content": [ + { + "type": "text", + "text": "plain code block without a language" + } + ] + }, + { + "type": "horizontal_rule" + }, + { + "type": "captionedImage", + "content": [ + { + "type": "image2", + "attrs": { + "src": "", + "srcNoWatermark": null, + "fullscreen": false, + "imageSize": "normal", + "height": "", + "width": "", + "resizeWidth": "", + "bytes": null, + "alt": "A captioned image", + "title": null, + "type": null, + "href": null, + "belowTheFold": false, + "topImage": false, + "internalRedirect": null, + "isProcessing": false, + "align": null, + "offset": false + } + } + ] + }, + { + "type": "captionedImage", + "content": [ + { + "type": "image2", + "attrs": { + "src": "", + "srcNoWatermark": null, + "fullscreen": false, + "imageSize": "normal", + "height": "", + "width": "", + "resizeWidth": "", + "bytes": null, + "alt": "Linked image alt", + "title": null, + "type": null, + "href": "https://example.com/target", + "belowTheFold": false, + "topImage": false, + "internalRedirect": null, + "isProcessing": false, + "align": null, + "offset": false + } + } + ] + }, + { + "type": "captionedImage", + "content": [ + { + "type": "image2", + "attrs": { + "src": "", + "srcNoWatermark": null, + "fullscreen": false, + "imageSize": "normal", + "height": "", + "width": "", + "resizeWidth": "", + "bytes": null, + "alt": "Captioned with title", + "title": null, + "type": null, + "href": null, + "belowTheFold": false, + "topImage": false, + "internalRedirect": null, + "isProcessing": false, + "align": null, + "offset": false + } + }, + { + "type": "caption", + "content": [ + { + "type": "text", + "text": "Caption text from markdown title" + } + ] + } + ] + }, + { + "type": "captionedImage", + "content": [ + { + "type": "image2", + "attrs": { + "src": "", + "srcNoWatermark": null, + "fullscreen": false, + "imageSize": "normal", + "height": "", + "width": "", + "resizeWidth": "", + "bytes": null, + "alt": "A locally uploaded image", + "title": null, + "type": null, + "href": null, + "belowTheFold": false, + "topImage": false, + "internalRedirect": null, + "isProcessing": false, + "align": null, + "offset": false + } + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "inline code with [^1] inside stays literal", + "marks": [ + { + "type": "code" + } + ] + } + ] + }, + { + "type": "heading", + "content": [ + { + "type": "text", + "text": "Heading with " + }, + { + "type": "text", + "text": "bold", + "marks": [ + { + "type": "strong" + } + ] + }, + { + "type": "text", + "text": ", " + }, + { + "type": "text", + "text": "italic", + "marks": [ + { + "type": "em" + } + ] + }, + { + "type": "text", + "text": ", and a " + }, + { + "type": "text", + "text": "link", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://example.com" + } + } + ] + } + ], + "attrs": { + "level": 2 + } + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "An autolink to " + }, + { + "type": "text", + "text": "https://example.com", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://example.com" + } + } + ] + }, + { + "type": "text", + "text": " becomes a link." + } + ] + }, + { + "type": "footnote", + "attrs": { + "number": 1 + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "The first footnote, with a " + }, + { + "type": "text", + "text": "link", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://example.com" + } + } + ] + }, + { + "type": "text", + "text": "." + } + ] + } + ] + }, + { + "type": "footnote", + "attrs": { + "number": 2 + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A named footnote whose definition spans a continuation line in the same paragraph." + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "And a second paragraph after a blank line." + } + ] + } + ] + }, + { + "type": "footnote", + "attrs": { + "number": 3 + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "The first footnote, with a " + }, + { + "type": "text", + "text": "link", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://example.com" + } + } + ] + }, + { + "type": "text", + "text": "." + } + ] + } + ] + }, + { + "type": "footnote", + "attrs": { + "number": 4 + }, + "content": [ + { + "type": "bullet_list", + "content": [ + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "list item one" + } + ] + } + ] + }, + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "list item two" + } + ] + } + ] + } + ] + } + ] + } +] diff --git a/tests/substack/fixtures/full_features.md b/tests/substack/fixtures/full_features.md new file mode 100644 index 0000000..f2eca43 --- /dev/null +++ b/tests/substack/fixtures/full_features.md @@ -0,0 +1,79 @@ +# Heading level 1 + +## Heading level 2 + +### Heading level 3 + +#### Heading level 4 + +##### Heading level 5 + +###### Heading level 6 + +A paragraph with **bold**, *italic*, ***bold italic***, `inline code`, ~~strikethrough~~, and a [link](https://example.com). + +A sentence with a numeric footnote[^1] and a named footnote[^note], plus a repeat of the first.[^1] + +A footnote whose definition is a list.[^listnote] + +- Bullet one +- Bullet two with **bold** + +1. Ordered one +2. Ordered two + +- Bulleted list with a nested bulleted list: + - inner bullet a + - inner bullet b +- Bulleted list with a nested numbered list: + 1. inner number a + 2. inner number b + +1. Numbered list with a nested bulleted list: + - inner bullet a + - inner bullet b +2. Numbered list with a nested numbered list: + 1. inner number a + 2. inner number b + +> A blockquote across +> two wrapped lines. + +> First quote paragraph. +> +> Second quote paragraph. + +```python +# fenced code: footnote-like text must stay literal +x = "[^1]: not a footnote" +print("reference [^1] stays text") +``` + +``` +plain code block without a language +``` + +--- + +![A captioned image](https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png) + +[![Linked image alt](https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png)](https://example.com/target) + +![Captioned with title](https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png "Caption text from markdown title") + +![A locally uploaded image](local_image.png) + +`inline code with [^1] inside stays literal` + +## Heading with **bold**, *italic*, and a [link](https://example.com) + +An autolink to becomes a link. + +[^1]: The first footnote, with a [link](https://example.com). +[^note]: A named footnote whose definition spans + a continuation line in the same paragraph. + + And a second paragraph after a blank line. +[^unused]: This definition is never referenced and should be dropped. +[^listnote]: - list item one + - list item two diff --git a/tests/substack/fixtures/local_image.png b/tests/substack/fixtures/local_image.png new file mode 100644 index 0000000..e1aabd8 Binary files /dev/null and b/tests/substack/fixtures/local_image.png differ diff --git a/tests/substack/test_api.py b/tests/substack/test_api.py index 0d76597..64c5175 100644 --- a/tests/substack/test_api.py +++ b/tests/substack/test_api.py @@ -1,5 +1,6 @@ import os import unittest +from unittest.mock import Mock, patch from dotenv import load_dotenv @@ -9,61 +10,62 @@ load_dotenv() +def _api_from_env() -> Api: + cookies_string = os.getenv("COOKIES_STRING") + cookies_path = os.getenv("COOKIES_PATH") + publication_url = os.getenv("PUBLICATION_URL") + if cookies_string or cookies_path: + return Api( + cookies_string=cookies_string, + cookies_path=cookies_path, + publication_url=publication_url, + ) + return Api( + email=os.getenv("EMAIL"), + password=os.getenv("PASSWORD"), + publication_url=publication_url, + ) + + +_e2e = unittest.skipUnless( + os.getenv("RUN_SUBSTACK_E2E"), + "set RUN_SUBSTACK_E2E=1 and configure credentials to run live API tests", +) + + class ApiTest(unittest.TestCase): def test_api_exception(self): + response = Mock(status_code=401, text="Unauthorized") with self.assertRaises(SubstackAPIException): - Api(email="", password="") - - def test_login(self): - api = Api( - email=os.getenv("EMAIL"), - password=os.getenv("PASSWORD"), - ) - self.assertIsNotNone(api) + with patch("requests.Session.post", return_value=response): + Api(email="", password="") + @_e2e def test_get_posts(self): - api = Api(email=os.getenv("EMAIL"), password=os.getenv("PASSWORD")) + api = _api_from_env() posts = api.get_posts() self.assertIsNotNone(posts) + @_e2e def test_get_drafts(self): - api = Api( - email=os.getenv("EMAIL"), - password=os.getenv("PASSWORD"), - ) + api = _api_from_env() drafts = api.get_drafts() self.assertIsNotNone(drafts) - def test_post_draft(self): - api = Api( - email=os.getenv("EMAIL"), - password=os.getenv("PASSWORD"), - ) - posted_draft = api.post_draft([{"id": os.getenv("USER_ID"), "is_guest": False}]) - self.assertIsNotNone(posted_draft) - + @_e2e def test_publication_users(self): - api = Api( - email=os.getenv("EMAIL"), - password=os.getenv("PASSWORD"), - ) + api = _api_from_env() users = api.get_publication_users() self.assertIsNotNone(users) - def test_put_draft(self): - api = Api( - email=os.getenv("EMAIL"), - password=os.getenv("PASSWORD"), - ) - posted_draft = api.put_draft("") - self.assertIsNotNone(posted_draft) - + @_e2e def test_get_categories(self): - api = Api() + api = _api_from_env() categories = api.get_categories() self.assertIsNotNone(categories) + @_e2e def test_get_single_category(self): - api = Api() + api = _api_from_env() category = api.get_single_category(4, "all", limit=100) self.assertIsNotNone(category) diff --git a/tests/substack/test_end_to_end.py b/tests/substack/test_end_to_end.py new file mode 100644 index 0000000..6558b86 --- /dev/null +++ b/tests/substack/test_end_to_end.py @@ -0,0 +1,190 @@ +"""End-to-end Markdown -> Substack draft round-trip test. + +Reads a feature-complete Markdown fixture, creates a real draft via the API, +retrieves it, and compares the (normalised) stored document against a saved +golden file (``full_features.expected.json``). + +This test creates and deletes a real draft, so it is opt-in: it only runs when +``RUN_SUBSTACK_E2E`` is set **and** credentials are configured (a ``.env`` file +is loaded). A normal ``pytest`` run with a ``.env`` present will skip it rather +than touching the account. + + - ``RUN_SUBSTACK_E2E=1`` to enable + - ``COOKIES_STRING`` (or ``COOKIES_PATH``), **or** ``EMAIL`` + ``PASSWORD`` + - ``PUBLICATION_URL`` (optional but recommended) + +To regenerate the golden file after an intentional change:: + + python -m tests.substack.test_end_to_end --generate +""" + +import copy +import json +import os +import sys +from pathlib import Path + +import pytest +from dotenv import load_dotenv + +from substack import Api +from substack.post import Post + +load_dotenv() + +FIXTURES = Path(__file__).parent / "fixtures" +MARKDOWN_FILE = FIXTURES / "full_features.md" +EXPECTED_FILE = FIXTURES / "full_features.expected.json" + +TITLE = "python-substack e2e feature test" +SUBTITLE = "Automated round-trip fixture" + +LOCAL_IMAGE_ALT = "A locally uploaded image" + + +def _image_srcs_by_alt(content, alt): + """Collect every image2 src whose alt matches (for upload verification).""" + found = [] + + def walk(node): + if isinstance(node, dict): + if node.get("type") == "image2" and node.get("attrs", {}).get("alt") == alt: + found.append(node["attrs"].get("src")) + for value in node.values(): + walk(value) + elif isinstance(node, list): + for item in node: + walk(item) + + walk(content) + return found + + +def _has_credentials() -> bool: + return bool( + os.getenv("COOKIES_STRING") + or os.getenv("COOKIES_PATH") + or (os.getenv("EMAIL") and os.getenv("PASSWORD")) + ) + + +def _e2e_enabled() -> bool: + """Only run when explicitly opted in AND credentials are present. + + Creating/deleting real drafts should never happen by surprise on a local + ``pytest`` run that merely has a ``.env`` file, so an explicit + ``RUN_SUBSTACK_E2E`` opt-in is required. + """ + return bool(os.getenv("RUN_SUBSTACK_E2E")) and _has_credentials() + + +def _api_from_env() -> Api: + cookies_string = os.getenv("COOKIES_STRING") + cookies_path = os.getenv("COOKIES_PATH") + publication_url = os.getenv("PUBLICATION_URL") + if cookies_string or cookies_path: + return Api( + cookies_string=cookies_string, + cookies_path=cookies_path, + publication_url=publication_url, + ) + return Api( + email=os.getenv("EMAIL"), + password=os.getenv("PASSWORD"), + publication_url=publication_url, + ) + + +def _normalize(content): + """Replace values that vary between runs/environments. + + Image ``src`` is a per-upload URL, and Substack fills in the dimension + attributes (``height``/``width``/``resizeWidth``) for uploaded images via its + own image pipeline (not deterministically), so all of these are blanked + before comparison. + """ + volatile = { + "src": "", + "height": "", + "width": "", + "resizeWidth": "", + } + + def walk(node): + if isinstance(node, dict): + return { + key: (volatile[key] if key in volatile else walk(value)) + for key, value in node.items() + } + if isinstance(node, list): + return [walk(item) for item in node] + return node + + return walk(copy.deepcopy(content)) + + +def _roundtrip(api: Api): + """Post the fixture as a draft, read it back, and return its stored content.""" + markdown = MARKDOWN_FILE.read_text(encoding="utf-8") + post = Post(TITLE, SUBTITLE, user_id=api.get_user_id()) + # Markdown image paths resolve relative to the working directory (this is how + # get_image uploads local files), so render from the fixtures directory just as + # a user authoring alongside their images would. + previous_cwd = os.getcwd() + os.chdir(FIXTURES) + try: + post.from_markdown(markdown, api=api) + finally: + os.chdir(previous_cwd) + draft = api.post_draft(post.get_draft()) + draft_id = draft.get("id") + try: + stored = api.get_draft(draft_id) + body = stored.get("draft_body") + if isinstance(body, str): + body = json.loads(body) + return body["content"] + finally: + try: + api.delete_draft(draft_id) + except Exception: + pass + + +@pytest.mark.skipif( + not _e2e_enabled(), + reason="set RUN_SUBSTACK_E2E=1 and configure credentials to run the live e2e test", +) +def test_full_features_roundtrip(): + api = _api_from_env() + content = _roundtrip(api) + + # The locally-sourced image must have been uploaded to a remote Substack URL + # rather than left as the local file path. + local_srcs = _image_srcs_by_alt(content, LOCAL_IMAGE_ALT) + assert local_srcs, "local image node not found in stored draft" + assert local_srcs[0].startswith("http"), ( + f"local image was not uploaded: {local_srcs[0]!r}" + ) + + expected = json.loads(EXPECTED_FILE.read_text(encoding="utf-8")) + assert _normalize(content) == expected + + +def _generate(): + """Regenerate the golden file from a live round-trip.""" + if not _has_credentials(): + raise SystemExit("No credentials configured; cannot generate golden file.") + api = _api_from_env() + content = _normalize(_roundtrip(api)) + EXPECTED_FILE.write_text( + json.dumps(content, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + print(f"Wrote {EXPECTED_FILE}") + + +if __name__ == "__main__": + if "--generate" in sys.argv: + _generate() + else: + print(__doc__) diff --git a/tests/substack/test_footnotes.py b/tests/substack/test_footnotes.py new file mode 100644 index 0000000..0416862 --- /dev/null +++ b/tests/substack/test_footnotes.py @@ -0,0 +1,272 @@ +"""Tests for Markdown footnote support in post.py.""" + +from substack.post import Post + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def make_post(): + """Create a fresh Post instance for testing.""" + return Post(title="Test", subtitle="Sub", user_id=1) + + +def body_content(post): + """Return the content list from the post's draft body.""" + return post.draft_body["content"] + + +def find_nodes(node, node_type, acc=None): + """Recursively collect every node of a given type from a doc tree.""" + if acc is None: + acc = [] + if isinstance(node, dict): + if node.get("type") == node_type: + acc.append(node) + for value in node.values(): + find_nodes(value, node_type, acc) + elif isinstance(node, list): + for value in node: + find_nodes(value, node_type, acc) + return acc + + +def anchors(post): + return find_nodes(post.draft_body, "footnoteAnchor") + + +def footnotes(post): + return find_nodes(post.draft_body, "footnote") + + +# --------------------------------------------------------------------------- +# TestFootnoteHelpers +# --------------------------------------------------------------------------- + + +class TestFootnoteHelpers: + def test_footnote_anchor_added_inline(self): + post = make_post() + post.paragraph(content=[{"content": "See here."}]) + post.footnote_anchor(1) + para = body_content(post)[0] + assert para["content"][-1] == {"type": "footnoteAnchor", "attrs": {"number": 1}} + + def test_footnote_block_from_string(self): + post = make_post() + post.footnote(1, "A simple note.") + block = body_content(post)[-1] + assert block["type"] == "footnote" + assert block["attrs"] == {"number": 1} + assert block["content"][0]["type"] == "paragraph" + assert block["content"][0]["content"][0]["text"] == "A simple note." + + def test_footnote_block_parses_inline_markdown(self): + post = make_post() + post.footnote(2, "See [the source](https://example.com).") + block = footnotes(post)[0] + text_nodes = block["content"][0]["content"] + link_node = next(n for n in text_nodes if n.get("marks")) + assert link_node["text"] == "the source" + assert link_node["marks"] == [ + {"type": "link", "attrs": {"href": "https://example.com"}} + ] + + +# --------------------------------------------------------------------------- +# TestFromMarkdownFootnotes +# --------------------------------------------------------------------------- + + +class TestFromMarkdownFootnotes: + def test_basic_reference_and_definition(self): + post = make_post() + post.from_markdown("A claim.[^1]\n\n[^1]: The supporting detail.") + assert len(anchors(post)) == 1 + assert anchors(post)[0]["attrs"]["number"] == 1 + blocks = footnotes(post) + assert len(blocks) == 1 + assert blocks[0]["attrs"]["number"] == 1 + assert blocks[0]["content"][0]["content"][0]["text"] == "The supporting detail." + + def test_definition_removed_from_body(self): + post = make_post() + post.from_markdown("A claim.[^1]\n\n[^1]: The note.") + # The definition line must not leak into a paragraph. + paragraphs = find_nodes(post.draft_body, "paragraph") + body_text = " ".join( + n.get("text", "") for p in paragraphs for n in p.get("content", []) + ) + assert "[^1]:" not in body_text + + def test_anchor_injected_mid_sentence(self): + post = make_post() + post.from_markdown("Before[^1] and after.\n\n[^1]: Note.") + para = find_nodes(post.draft_body, "paragraph")[0] + types = [c["type"] for c in para["content"]] + assert types == ["text", "footnoteAnchor", "text"] + assert para["content"][0]["text"] == "Before" + assert para["content"][2]["text"] == " and after." + + def test_named_labels_numbered_by_first_appearance(self): + post = make_post() + md = ( + "First[^book] then second[^study].\n\n" + "[^study]: Second definition.\n" + "[^book]: First definition.\n" + ) + post.from_markdown(md) + nums = [a["attrs"]["number"] for a in anchors(post)] + assert nums == [1, 2] # order of reference, not of definition + blocks = sorted(footnotes(post), key=lambda b: b["attrs"]["number"]) + assert blocks[0]["content"][0]["content"][0]["text"] == "First definition." + assert blocks[1]["content"][0]["content"][0]["text"] == "Second definition." + + def test_repeated_reference_duplicates_footnote(self): + # Substack numbers anchors by position and pairs them 1:1 with footnote + # blocks, so a definition referenced twice yields two sequentially-numbered + # anchors and two footnote blocks with identical content. + post = make_post() + post.from_markdown("One[^a] two[^a].\n\n[^a]: Note.") + nums = [a["attrs"]["number"] for a in anchors(post)] + assert nums == [1, 2] + blocks = footnotes(post) + assert [b["attrs"]["number"] for b in blocks] == [1, 2] + assert blocks[0]["content"] == blocks[1]["content"] + assert blocks[0]["content"][0]["content"][0]["text"] == "Note." + + def test_link_inside_definition_preserved(self): + post = make_post() + post.from_markdown("Claim.[^1]\n\n[^1]: See [docs](https://example.com).") + block = footnotes(post)[0] + link_node = next(n for n in block["content"][0]["content"] if n.get("marks")) + assert link_node["marks"][0]["attrs"]["href"] == "https://example.com" + + def test_multiline_definition(self): + post = make_post() + md = "Claim.[^1]\n\n[^1]: First line\n continued on the next line." + post.from_markdown(md) + text = footnotes(post)[0]["content"][0]["content"][0]["text"] + assert text == "First line continued on the next line." + + def test_unreferenced_definition_is_dropped(self): + # CommonMark footnote semantics: a definition that is never referenced is + # not rendered, and must not leak into the body text. + post = make_post() + post.from_markdown("No references here.\n\n[^1]: Orphan note.") + assert len(anchors(post)) == 0 + assert len(footnotes(post)) == 0 + paragraphs = find_nodes(post.draft_body, "paragraph") + body_text = " ".join( + n.get("text", "") for para in paragraphs for n in para.get("content", []) + ) + assert "Orphan note" not in body_text + + def test_reference_without_definition_left_as_text(self): + post = make_post() + post.from_markdown("A dangling[^missing] reference.") + assert len(anchors(post)) == 0 + assert len(footnotes(post)) == 0 + para = find_nodes(post.draft_body, "paragraph")[0] + assert "[^missing]" in para["content"][0]["text"] + + def test_definition_in_middle_moves_to_end(self): + post = make_post() + md = "First paragraph.[^1]\n\n[^1]: First footnote.\n\nSecond paragraph." + post.from_markdown(md) + + types = [node["type"] for node in body_content(post)] + # Both paragraphs come first; the footnote block is last regardless of + # where the definition appeared in the source. + assert types == ["paragraph", "paragraph", "footnote"] + + paragraphs = find_nodes(post.draft_body, "paragraph") + assert paragraphs[0]["content"][0]["text"] == "First paragraph." + # The definition line did not become a paragraph in the body. + assert paragraphs[1]["content"][0]["text"] == "Second paragraph." + + assert len(anchors(post)) == 1 + block = footnotes(post)[0] + assert block["content"][0]["content"][0]["text"] == "First footnote." + + def test_footnote_definition_inside_fenced_code_stays_code(self): + post = make_post() + post.from_markdown("```\n[^1]: not a footnote\n```") + content = body_content(post) + assert len(content) == 1 + assert content[0]["type"] == "codeBlock" + assert content[0]["content"][0]["text"] == "[^1]: not a footnote" + + def test_footnote_reference_inside_fenced_code_stays_text(self): + post = make_post() + post.from_markdown("```\ncode [^1]\n```\n\n[^1]: note") + content = body_content(post) + assert content[0]["type"] == "codeBlock" + assert content[0]["content"][0]["text"] == "code [^1]" + + def test_footnote_reference_inside_inline_code_stays_text(self): + post = make_post() + post.from_markdown("`code [^1]`\n\n[^1]: note") + content = body_content(post) + assert content[0]["type"] == "paragraph" + assert content[0]["content"][0]["text"] == "code [^1]" + assert content[0]["content"][0]["marks"] == [{"type": "code"}] + + def test_multiparagraph_definition(self): + post = make_post() + md = "Claim.[^1]\n\n[^1]: First para.\n\n Second para." + post.from_markdown(md) + # The second paragraph must stay in the footnote, not leak into the body. + assert [n["type"] for n in body_content(post)] == ["paragraph", "footnote"] + block = footnotes(post)[0] + assert len(block["content"]) == 2 + assert block["content"][0]["content"][0]["text"] == "First para." + assert block["content"][1]["content"][0]["text"] == "Second para." + + def test_multiparagraph_definition_in_middle(self): + post = make_post() + md = ( + "First.[^1]\n\n" + "[^1]: Note para one.\n\n" + " Note para two.\n\n" + "Back to the body." + ) + post.from_markdown(md) + types = [n["type"] for n in body_content(post)] + assert types == ["paragraph", "paragraph", "footnote"] + assert body_content(post)[1]["content"][0]["text"] == "Back to the body." + assert len(footnotes(post)[0]["content"]) == 2 + + def test_footnote_helper_splits_paragraphs(self): + post = make_post() + post.footnote(1, "Para one.\n\nPara two.") + block = footnotes(post)[0] + assert len(block["content"]) == 2 + assert block["content"][1]["content"][0]["text"] == "Para two." + + def test_definition_with_block_content_is_preserved(self): + # A footnote whose definition is a list (or other block content) must keep + # its text rather than dropping it. + post = make_post() + post.from_markdown("Claim[^1]\n\n[^1]: - item one\n - item two") + note = footnotes(post)[0] + text = " ".join( + n.get("text", "") + for para in find_nodes(note, "paragraph") + for n in para.get("content", []) + ) + assert "item one" in text + assert "item two" in text + # rendered as a real nested list inside the footnote + assert find_nodes(note, "bullet_list") + + def test_no_footnotes_is_unchanged(self): + post = make_post() + post.from_markdown("Just a plain paragraph.") + assert len(anchors(post)) == 0 + assert len(footnotes(post)) == 0 + assert find_nodes(post.draft_body, "paragraph")[0]["content"][0]["text"] == ( + "Just a plain paragraph." + ) diff --git a/tests/substack/test_from_markdown_features.py b/tests/substack/test_from_markdown_features.py new file mode 100644 index 0000000..5a56323 --- /dev/null +++ b/tests/substack/test_from_markdown_features.py @@ -0,0 +1,176 @@ +"""End-to-end coverage of every feature listed in Post.from_markdown(). + +These exercise the renderer through from_markdown() (as opposed to the +parse_inline() unit tests), so they cover the actual Markdown -> Substack path. +""" + +from substack.post import Post + + +def make_post(): + return Post(title="T", subtitle="S", user_id=1) + + +def body(post): + return post.draft_body["content"] + + +def first_para_nodes(post): + return body(post)[0]["content"] + + +def marked(nodes, text): + """Return the marks on the text node with the given text.""" + node = next(n for n in nodes if n.get("text") == text) + return node.get("marks", []) + + +class TestInlineFormatting: + def test_bold(self): + post = make_post() + post.from_markdown("x **b** y") + assert {"type": "strong"} in marked(first_para_nodes(post), "b") + + def test_italic(self): + post = make_post() + post.from_markdown("x *i* y") + assert {"type": "em"} in marked(first_para_nodes(post), "i") + + def test_bold_italic(self): + post = make_post() + post.from_markdown("***bi***") + marks = marked(first_para_nodes(post), "bi") + assert {"type": "strong"} in marks + assert {"type": "em"} in marks + + def test_inline_code(self): + post = make_post() + post.from_markdown("use `code` now") + assert marked(first_para_nodes(post), "code") == [{"type": "code"}] + + def test_strikethrough(self): + post = make_post() + post.from_markdown("a ~~s~~ b") + assert marked(first_para_nodes(post), "s") == [{"type": "strikethrough"}] + + def test_link(self): + post = make_post() + post.from_markdown("[t](https://e.com)") + assert marked(first_para_nodes(post), "t") == [ + {"type": "link", "attrs": {"href": "https://e.com"}} + ] + + def test_multiple_marks_in_one_paragraph(self): + post = make_post() + post.from_markdown("**b** and *i* and `c` and [l](https://e.com)") + nodes = first_para_nodes(post) + assert {"type": "strong"} in marked(nodes, "b") + assert {"type": "em"} in marked(nodes, "i") + assert marked(nodes, "c") == [{"type": "code"}] + assert marked(nodes, "l")[0]["type"] == "link" + + +class TestBlocks: + def test_all_heading_levels(self): + for level in range(1, 7): + post = make_post() + post.from_markdown("#" * level + " Heading") + block = body(post)[0] + assert block["type"] == "heading" + assert block["attrs"]["level"] == level + + def test_paragraph(self): + post = make_post() + post.from_markdown("Just a plain paragraph.") + block = body(post)[0] + assert block["type"] == "paragraph" + assert block["content"][0]["text"] == "Just a plain paragraph." + + def test_bullet_list(self): + post = make_post() + post.from_markdown("- a\n- b") + block = body(post)[0] + assert block["type"] == "bullet_list" + assert len(block["content"]) == 2 + assert block["content"][0]["type"] == "list_item" + + def test_ordered_list(self): + post = make_post() + post.from_markdown("1. a\n2. b") + block = body(post)[0] + assert block["type"] == "ordered_list" + assert len(block["content"]) == 2 + + def test_code_block_with_language(self): + post = make_post() + post.from_markdown("```python\nprint('hi')\n```") + block = body(post)[0] + assert block["type"] == "codeBlock" + assert block["attrs"]["language"] == "python" + assert block["content"][0]["text"] == "print('hi')" + + def test_code_block_without_language(self): + post = make_post() + post.from_markdown("```\nplain\n```") + block = body(post)[0] + assert block["type"] == "codeBlock" + assert "attrs" not in block or "language" not in block.get("attrs", {}) + + def test_horizontal_rule(self): + post = make_post() + post.from_markdown("a\n\n---\n\nb") + assert [n["type"] for n in body(post)] == ["paragraph", "horizontal_rule", "paragraph"] + + def test_blockquote(self): + post = make_post() + post.from_markdown("> quote") + block = body(post)[0] + assert block["type"] == "blockquote" + assert block["content"][0]["type"] == "paragraph" + + def test_image(self): + post = make_post() + post.from_markdown("![alt](https://example.com/img.png)") + block = body(post)[0] + assert block["type"] == "captionedImage" + assert len(block["content"]) == 1 + assert block["content"][0]["type"] == "image2" + attrs = block["content"][0]["attrs"] + assert attrs["src"] == "https://example.com/img.png" + assert attrs["alt"] == "alt" + assert attrs["href"] is None + assert attrs["imageSize"] == "normal" + assert attrs["srcNoWatermark"] is None + assert attrs["topImage"] is False + assert attrs["isProcessing"] is False + assert attrs["align"] is None + assert attrs["offset"] is False + + def test_image_with_caption(self): + post = make_post() + post.from_markdown('![alt](https://example.com/img.png "My caption")') + block = body(post)[0] + assert block["type"] == "captionedImage" + assert len(block["content"]) == 2 + assert block["content"][0]["type"] == "image2" + assert block["content"][0]["attrs"]["alt"] == "alt" + caption = block["content"][1] + assert caption["type"] == "caption" + assert caption["content"] == [{"type": "text", "text": "My caption"}] + + def test_image_without_caption_has_no_caption_node(self): + post = make_post() + post.from_markdown("![alt](https://example.com/img.png)") + block = body(post)[0] + types = [n["type"] for n in block["content"]] + assert "caption" not in types + + def test_linked_image(self): + post = make_post() + post.from_markdown("[![alt](https://i/x.png)](https://link)") + block = body(post)[0] + assert block["type"] == "captionedImage" + assert block["content"][0]["type"] == "image2" + attrs = block["content"][0]["attrs"] + assert attrs["src"] == "https://i/x.png" + assert attrs["href"] == "https://link" diff --git a/tests/substack/test_post.py b/tests/substack/test_post.py index 701c2a2..c619d88 100644 --- a/tests/substack/test_post.py +++ b/tests/substack/test_post.py @@ -97,15 +97,14 @@ def test_single_blockquote_line(self): assert bq["content"][0]["content"][0]["text"] == "This is a quote" def test_multiline_blockquote_grouped(self): - """Consecutive '>' lines become a single blockquote with multiple paragraphs.""" + """Consecutive '>' lines are one paragraph (CommonMark); blank '>' lines split them.""" post = Post(title="T", subtitle="S", user_id=1) post.from_markdown("> Line one\n> Line two\n> Line three") body = json.loads(post.get_draft()["draft_body"]) bq = body["content"][0] assert bq["type"] == "blockquote" - assert len(bq["content"]) == 3 - texts = [p["content"][0]["text"] for p in bq["content"]] - assert texts == ["Line one", "Line two", "Line three"] + assert len(bq["content"]) == 1 + assert bq["content"][0]["content"][0]["text"] == "Line one Line two Line three" def test_blockquote_separated_by_blank_line(self): """A blank line between '>' groups creates two separate blockquotes."""