From 608fdf2701e80673cb84c9c920f88b2f41800189 Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Fri, 10 Jul 2026 12:20:20 +1000 Subject: [PATCH 1/5] Add table and LaTeX block support GFM tables (`| col | col |`) map to Substack's table/table_row/ table_header/table_cell schema. Table node shape was confirmed by probing the live API. LaTeX math blocks ($$...$$) map to latex_block via mdit_py_plugins' dollarmath plugin. Both features are covered by unit tests and the e2e fixture/golden file. Co-Authored-By: Claude Sonnet 4.6 --- substack/mdrender.py | 31 ++- substack/nodes.py | 28 +++ .../fixtures/full_features.expected.json | 187 +++++++++++++----- tests/substack/fixtures/full_features.md | 13 +- tests/substack/test_from_markdown_features.py | 41 ++++ 5 files changed, 249 insertions(+), 51 deletions(-) diff --git a/substack/mdrender.py b/substack/mdrender.py index e04b97e..c1583db 100644 --- a/substack/mdrender.py +++ b/substack/mdrender.py @@ -22,6 +22,7 @@ from markdown_it import MarkdownIt from markdown_it.tree import SyntaxTreeNode +from mdit_py_plugins.dollarmath import dollarmath_plugin from mdit_py_plugins.footnote import footnote_plugin from substack import nodes @@ -35,7 +36,13 @@ def _make_parser() -> MarkdownIt: - return MarkdownIt("commonmark").use(footnote_plugin).enable("strikethrough") + return ( + MarkdownIt("commonmark") + .use(footnote_plugin) + .use(dollarmath_plugin) + .enable("strikethrough") + .enable("table") + ) def _coalesce(out_nodes: List[Dict]) -> List[Dict]: @@ -159,10 +166,32 @@ def _render_block(node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]: if t == "ordered_list": return [nodes.ordered_list(_render_list_items(node, api, ctx))] + if t == "math_block": + return [nodes.latex_block(node.content.strip())] + + if t == "table": + return [_render_table(node, None, ctx)] + # footnote_block is handled separately in markdown_to_doc; ignore it here. return [] +def _render_table(node: SyntaxTreeNode, _api, ctx: Dict) -> Dict: + rows = [] + for section in node.children: # thead, tbody + for tr in section.children: + cells = [] + for cell in tr.children: # th or td + inline = cell.children[0] if cell.children else None + content = _render_inline(inline, [], ctx) if inline else [] + if cell.type == "th": + cells.append(nodes.table_header([nodes.paragraph(content)])) + else: + cells.append(nodes.table_cell([nodes.paragraph(content)])) + rows.append(nodes.table_row(cells)) + return nodes.table(rows) + + def _render_list_items(list_node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]: items = [] for li in list_node.children: diff --git a/substack/nodes.py b/substack/nodes.py index c5571ba..ad35f89 100644 --- a/substack/nodes.py +++ b/substack/nodes.py @@ -33,6 +33,11 @@ class NodeType: FOOTNOTE_ANCHOR = "footnoteAnchor" CAPTIONED_IMAGE = "captionedImage" CAPTION = "caption" + LATEX_BLOCK = "latex_block" + TABLE = "table" + TABLE_ROW = "table_row" + TABLE_HEADER = "table_header" + TABLE_CELL = "table_cell" class MarkType: @@ -146,3 +151,26 @@ def footnote(number: int, paragraphs: List[Dict]) -> Dict: "attrs": {"number": number}, "content": paragraphs or [paragraph()], } + + +def latex_block(expression: str) -> Dict: + return { + "type": NodeType.LATEX_BLOCK, + "attrs": {"persistentExpression": expression, "dirty": True}, + } + + +def table(rows: List[Dict]) -> Dict: + return {"type": NodeType.TABLE, "content": rows} + + +def table_row(cells: List[Dict]) -> Dict: + return {"type": NodeType.TABLE_ROW, "content": cells} + + +def table_header(content: List[Dict]) -> Dict: + return {"type": NodeType.TABLE_HEADER, "content": content} + + +def table_cell(content: List[Dict]) -> Dict: + return {"type": NodeType.TABLE_CELL, "content": content} diff --git a/tests/substack/fixtures/full_features.expected.json b/tests/substack/fixtures/full_features.expected.json index ad7e75c..c7fe559 100644 --- a/tests/substack/fixtures/full_features.expected.json +++ b/tests/substack/fixtures/full_features.expected.json @@ -204,13 +204,7 @@ "content": [ { "type": "text", - "text": "A footnote whose definition is a list." - }, - { - "type": "footnoteAnchor", - "attrs": { - "number": 4 - } + "text": "A footnote whose definition is a list.[^listnote]" } ] }, @@ -548,6 +542,135 @@ { "type": "horizontal_rule" }, + { + "type": "table", + "content": [ + { + "type": "table_row", + "content": [ + { + "type": "table_header", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Header A", + "marks": [ + { + "type": "strong" + } + ] + } + ] + } + ] + }, + { + "type": "table_header", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Header B" + } + ] + } + ] + } + ] + }, + { + "type": "table_row", + "content": [ + { + "type": "table_cell", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Cell 1" + } + ] + } + ] + }, + { + "type": "table_cell", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "link", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://example.com" + } + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "table_row", + "content": [ + { + "type": "table_cell", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Cell 2" + } + ] + } + ] + }, + { + "type": "table_cell", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "italic", + "marks": [ + { + "type": "em" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "latex_block", + "attrs": { + "persistentExpression": "E=mc^2", + "dirty": true + } + }, { "type": "captionedImage", "content": [ @@ -758,6 +881,15 @@ } ] }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "e]: - list item one - list item two" + } + ] + }, { "type": "footnote", "attrs": { @@ -849,46 +981,5 @@ ] } ] - }, - { - "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 index f2eca43..b079624 100644 --- a/tests/substack/fixtures/full_features.md +++ b/tests/substack/fixtures/full_features.md @@ -55,6 +55,15 @@ plain code block without a language --- +| **Header A** | Header B | +|:-------------|:----------------------------| +| Cell 1 | [link](https://example.com) | +| Cell 2 | *italic* | + +$$ +E=mc^2 +$$ + ![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) @@ -74,6 +83,6 @@ An autolink to becomes a link. 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 + +e]: - list item one - list item two diff --git a/tests/substack/test_from_markdown_features.py b/tests/substack/test_from_markdown_features.py index 5a56323..547a56d 100644 --- a/tests/substack/test_from_markdown_features.py +++ b/tests/substack/test_from_markdown_features.py @@ -174,3 +174,44 @@ def test_linked_image(self): attrs = block["content"][0]["attrs"] assert attrs["src"] == "https://i/x.png" assert attrs["href"] == "https://link" + + def test_latex_block(self): + post = make_post() + post.from_markdown("$$\nE=mc^2\n$$\n") + block = body(post)[0] + assert block["type"] == "latex_block" + assert block["attrs"]["persistentExpression"] == "E=mc^2" + assert block["attrs"]["dirty"] is True + + def test_table(self): + post = make_post() + post.from_markdown("| **A** | B |\n|---|---|\n| 1 | [x](https://example.com) |\n") + block = body(post)[0] + assert block["type"] == "table" + rows = block["content"] + assert len(rows) == 2 + + header_row = rows[0] + assert header_row["type"] == "table_row" + assert header_row["content"][0]["type"] == "table_header" + assert header_row["content"][1]["type"] == "table_header" + # header cell content is a paragraph with inline nodes + bold = header_row["content"][0]["content"][0]["content"][0] + assert bold["text"] == "A" + assert bold["marks"] == [{"type": "strong"}] + + data_row = rows[1] + assert data_row["type"] == "table_row" + assert data_row["content"][0]["type"] == "table_cell" + assert data_row["content"][1]["type"] == "table_cell" + link_text = data_row["content"][1]["content"][0]["content"][0] + assert link_text["text"] == "x" + assert link_text["marks"][0]["attrs"]["href"] == "https://example.com" + + def test_table_single_row(self): + post = make_post() + post.from_markdown("| A |\n|---|\n") + block = body(post)[0] + assert block["type"] == "table" + assert len(block["content"]) == 1 + assert block["content"][0]["content"][0]["type"] == "table_header" From 2345f5b68c102113fd12e835e4d26b8170959e23 Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Fri, 10 Jul 2026 12:37:36 +1000 Subject: [PATCH 2/5] Add inline LaTeX support and fix corrupted footnote fixture Inline math ($x$ and $$x$$ via dollarmath's double_inline) now maps to Substack's inline `latex` node, confirmed against the live API. Previously inline math tokens were silently dropped. Also restores the [^unused] and [^listnote] footnote definitions in the e2e fixture, which had been corrupted, dropping footnote #4 from coverage. Co-Authored-By: Claude Sonnet 4.6 --- substack/mdrender.py | 4 +- substack/nodes.py | 8 ++ .../fixtures/full_features.expected.json | 89 ++++++++++++++++--- tests/substack/fixtures/full_features.md | 6 +- tests/substack/test_from_markdown_features.py | 18 ++++ 5 files changed, 112 insertions(+), 13 deletions(-) diff --git a/substack/mdrender.py b/substack/mdrender.py index c1583db..e18b4ba 100644 --- a/substack/mdrender.py +++ b/substack/mdrender.py @@ -39,7 +39,7 @@ def _make_parser() -> MarkdownIt: return ( MarkdownIt("commonmark") .use(footnote_plugin) - .use(dollarmath_plugin) + .use(dollarmath_plugin, double_inline=True) .enable("strikethrough") .enable("table") ) @@ -71,6 +71,8 @@ def _render_inline(node: SyntaxTreeNode, marks: List[Dict], ctx: Dict) -> List[D out.append(nodes.text(child.content, marks)) elif t == "code_inline": out.append(nodes.text(child.content, marks + [nodes.code_mark()])) + elif t in ("math_inline", "math_inline_double"): + out.append(nodes.latex_inline(child.content.strip())) elif t in _MARK_FOR: out.extend(_render_inline(child, marks + [_MARK_FOR[t]], ctx)) elif t == "link": diff --git a/substack/nodes.py b/substack/nodes.py index ad35f89..d73ff16 100644 --- a/substack/nodes.py +++ b/substack/nodes.py @@ -34,6 +34,7 @@ class NodeType: CAPTIONED_IMAGE = "captionedImage" CAPTION = "caption" LATEX_BLOCK = "latex_block" + LATEX_INLINE = "latex" TABLE = "table" TABLE_ROW = "table_row" TABLE_HEADER = "table_header" @@ -160,6 +161,13 @@ def latex_block(expression: str) -> Dict: } +def latex_inline(expression: str) -> Dict: + return { + "type": NodeType.LATEX_INLINE, + "attrs": {"expression": expression, "persistentExpression": expression}, + } + + def table(rows: List[Dict]) -> Dict: return {"type": NodeType.TABLE, "content": rows} diff --git a/tests/substack/fixtures/full_features.expected.json b/tests/substack/fixtures/full_features.expected.json index c7fe559..5154fa9 100644 --- a/tests/substack/fixtures/full_features.expected.json +++ b/tests/substack/fixtures/full_features.expected.json @@ -204,7 +204,13 @@ "content": [ { "type": "text", - "text": "A footnote whose definition is a list.[^listnote]" + "text": "A footnote whose definition is a list." + }, + { + "type": "footnoteAnchor", + "attrs": { + "number": 4 + } } ] }, @@ -664,6 +670,37 @@ } ] }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A paragraph with inline math " + }, + { + "type": "latex", + "attrs": { + "expression": "E=mc^2", + "persistentExpression": "E=mc^2" + } + }, + { + "type": "text", + "text": " and display-style inline " + }, + { + "type": "latex", + "attrs": { + "expression": "\\sum_{i=1}^n i", + "persistentExpression": "\\sum_{i=1}^n i" + } + }, + { + "type": "text", + "text": " math." + } + ] + }, { "type": "latex_block", "attrs": { @@ -881,15 +918,6 @@ } ] }, - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "e]: - list item one - list item two" - } - ] - }, { "type": "footnote", "attrs": { @@ -981,5 +1009,46 @@ ] } ] + }, + { + "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 index b079624..ef8d74e 100644 --- a/tests/substack/fixtures/full_features.md +++ b/tests/substack/fixtures/full_features.md @@ -60,6 +60,8 @@ plain code block without a language | Cell 1 | [link](https://example.com) | | Cell 2 | *italic* | +A paragraph with inline math $E=mc^2$ and display-style inline $$\sum_{i=1}^n i$$ math. + $$ E=mc^2 $$ @@ -83,6 +85,6 @@ An autolink to becomes a link. a continuation line in the same paragraph. And a second paragraph after a blank line. - -e]: - list item one +[^unused]: This definition is never referenced and should be dropped. +[^listnote]: - list item one - list item two diff --git a/tests/substack/test_from_markdown_features.py b/tests/substack/test_from_markdown_features.py index 547a56d..b273f44 100644 --- a/tests/substack/test_from_markdown_features.py +++ b/tests/substack/test_from_markdown_features.py @@ -183,6 +183,24 @@ def test_latex_block(self): assert block["attrs"]["persistentExpression"] == "E=mc^2" assert block["attrs"]["dirty"] is True + def test_latex_inline(self): + post = make_post() + post.from_markdown("A paragraph with $E=mc^2$ inline.") + para = body(post)[0] + assert para["type"] == "paragraph" + latex = [n for n in para["content"] if n["type"] == "latex"] + assert len(latex) == 1 + assert latex[0]["attrs"]["expression"] == "E=mc^2" + assert latex[0]["attrs"]["persistentExpression"] == "E=mc^2" + + def test_latex_inline_double_dollar(self): + post = make_post() + post.from_markdown("A paragraph with $$\\sum_i i$$ inline.") + para = body(post)[0] + latex = [n for n in para["content"] if n["type"] == "latex"] + assert len(latex) == 1 + assert latex[0]["attrs"]["expression"] == "\\sum_i i" + def test_table(self): post = make_post() post.from_markdown("| **A** | B |\n|---|---|\n| 1 | [x](https://example.com) |\n") From 95c3db799e527efc925e40f1956eb37f581ed0dc Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Fri, 10 Jul 2026 12:41:33 +1000 Subject: [PATCH 3/5] Restrict inline LaTeX to standard single-dollar syntax Drop dollarmath's double_inline option so $$...$$ is only ever a display block, matching the common Markdown convention. Inline math stays $...$. Co-Authored-By: Claude Sonnet 4.6 --- substack/mdrender.py | 4 ++-- tests/substack/fixtures/full_features.expected.json | 13 +------------ tests/substack/fixtures/full_features.md | 2 +- tests/substack/test_from_markdown_features.py | 8 -------- 4 files changed, 4 insertions(+), 23 deletions(-) diff --git a/substack/mdrender.py b/substack/mdrender.py index e18b4ba..08295c7 100644 --- a/substack/mdrender.py +++ b/substack/mdrender.py @@ -39,7 +39,7 @@ def _make_parser() -> MarkdownIt: return ( MarkdownIt("commonmark") .use(footnote_plugin) - .use(dollarmath_plugin, double_inline=True) + .use(dollarmath_plugin) .enable("strikethrough") .enable("table") ) @@ -71,7 +71,7 @@ def _render_inline(node: SyntaxTreeNode, marks: List[Dict], ctx: Dict) -> List[D out.append(nodes.text(child.content, marks)) elif t == "code_inline": out.append(nodes.text(child.content, marks + [nodes.code_mark()])) - elif t in ("math_inline", "math_inline_double"): + elif t == "math_inline": out.append(nodes.latex_inline(child.content.strip())) elif t in _MARK_FOR: out.extend(_render_inline(child, marks + [_MARK_FOR[t]], ctx)) diff --git a/tests/substack/fixtures/full_features.expected.json b/tests/substack/fixtures/full_features.expected.json index 5154fa9..2140063 100644 --- a/tests/substack/fixtures/full_features.expected.json +++ b/tests/substack/fixtures/full_features.expected.json @@ -686,18 +686,7 @@ }, { "type": "text", - "text": " and display-style inline " - }, - { - "type": "latex", - "attrs": { - "expression": "\\sum_{i=1}^n i", - "persistentExpression": "\\sum_{i=1}^n i" - } - }, - { - "type": "text", - "text": " math." + "text": " in it." } ] }, diff --git a/tests/substack/fixtures/full_features.md b/tests/substack/fixtures/full_features.md index ef8d74e..dafb1d0 100644 --- a/tests/substack/fixtures/full_features.md +++ b/tests/substack/fixtures/full_features.md @@ -60,7 +60,7 @@ plain code block without a language | Cell 1 | [link](https://example.com) | | Cell 2 | *italic* | -A paragraph with inline math $E=mc^2$ and display-style inline $$\sum_{i=1}^n i$$ math. +A paragraph with inline math $E=mc^2$ in it. $$ E=mc^2 diff --git a/tests/substack/test_from_markdown_features.py b/tests/substack/test_from_markdown_features.py index b273f44..9d805ca 100644 --- a/tests/substack/test_from_markdown_features.py +++ b/tests/substack/test_from_markdown_features.py @@ -193,14 +193,6 @@ def test_latex_inline(self): assert latex[0]["attrs"]["expression"] == "E=mc^2" assert latex[0]["attrs"]["persistentExpression"] == "E=mc^2" - def test_latex_inline_double_dollar(self): - post = make_post() - post.from_markdown("A paragraph with $$\\sum_i i$$ inline.") - para = body(post)[0] - latex = [n for n in para["content"] if n["type"] == "latex"] - assert len(latex) == 1 - assert latex[0]["attrs"]["expression"] == "\\sum_i i" - def test_table(self): post = make_post() post.from_markdown("| **A** | B |\n|---|---|\n| 1 | [x](https://example.com) |\n") From 279009643715cf0da11b7dc664d5a24a27875fc1 Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Fri, 10 Jul 2026 13:02:12 +1000 Subject: [PATCH 4/5] Replace tables with pull quotes, callouts, and sub/superscript Substack has no table renderer or insert UI, so the table node (added earlier) stored but never displayed. Removed it in favor of features Substack actually supports, confirmed by round-tripping editor-authored nodes through the live API: - pullquote and calloutBlock via `:::pullquote` / `:::callout` containers - superscript (`^x^`) and subscript (`~x~`) inline marks Subscript's single-tilde syntax coexists with `~~strikethrough~~`. The sub/superscript plugins require mdit-py-plugins >= 0.5, so the constraint and lockfile are updated (now resolves 0.6.1). Co-Authored-By: Claude Sonnet 4.6 --- poetry.lock | 16 +- pyproject.toml | 2 +- substack/mdrender.py | 36 ++-- substack/nodes.py | 31 ++- .../fixtures/full_features.expected.json | 201 ++++++++---------- tests/substack/fixtures/full_features.md | 15 +- tests/substack/test_from_markdown_features.py | 73 ++++--- 7 files changed, 179 insertions(+), 195 deletions(-) diff --git a/poetry.lock b/poetry.lock index 79a12f5..587f517 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "aiofile" @@ -1040,23 +1040,23 @@ ws = ["websockets (>=15.0.1)"] [[package]] name = "mdit-py-plugins" -version = "0.4.2" +version = "0.6.1" description = "Collection of plugins for markdown-it-py" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" 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"}, + {file = "mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d"}, + {file = "mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0"}, ] [package.dependencies] -markdown-it-py = ">=1.0.0,<4.0.0" +markdown-it-py = ">=2.0.0,<5.0.0" [package.extras] code-style = ["pre-commit"] rtd = ["myst-parser", "sphinx-book-theme"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "pytest-timeout"] [[package]] name = "mdurl" @@ -2229,4 +2229,4 @@ mcp = ["fastmcp"] [metadata] lock-version = "2.1" python-versions = "<4.0,>=3.10" -content-hash = "012ebc118846bd27cfffa8519e480a238d65ed7a38f77a92d13e721c05552b02" +content-hash = "11fb43d2644aa0a1c9a300d0aa7a642aea5f2017642cd9cabe344d6497c67f5f" diff --git a/pyproject.toml b/pyproject.toml index 86f1fad..18ce7df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ requests = "^2.32.0" python-dotenv = "^1.2.1" PyYAML = "^6.0" markdown-it-py = "^3.0" -mdit-py-plugins = "^0.4" +mdit-py-plugins = ">=0.5,<0.7" fastmcp = { version = "^3.1.1", optional = true } [tool.poetry.extras] diff --git a/substack/mdrender.py b/substack/mdrender.py index 08295c7..2f091b5 100644 --- a/substack/mdrender.py +++ b/substack/mdrender.py @@ -22,8 +22,11 @@ from markdown_it import MarkdownIt from markdown_it.tree import SyntaxTreeNode +from mdit_py_plugins.container import container_plugin from mdit_py_plugins.dollarmath import dollarmath_plugin from mdit_py_plugins.footnote import footnote_plugin +from mdit_py_plugins.subscript import sub_plugin +from mdit_py_plugins.superscript import superscript_plugin from substack import nodes from substack.nodes import MarkType, NodeType @@ -32,6 +35,8 @@ "strong": {"type": MarkType.STRONG}, "em": {"type": MarkType.EM}, "s": {"type": MarkType.STRIKETHROUGH}, + "sup": {"type": MarkType.SUPERSCRIPT}, + "sub": {"type": MarkType.SUBSCRIPT}, } @@ -40,8 +45,11 @@ def _make_parser() -> MarkdownIt: MarkdownIt("commonmark") .use(footnote_plugin) .use(dollarmath_plugin) + .use(sub_plugin) + .use(superscript_plugin) + .use(container_plugin, name="pullquote") + .use(container_plugin, name="callout") .enable("strikethrough") - .enable("table") ) @@ -171,27 +179,21 @@ def _render_block(node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]: if t == "math_block": return [nodes.latex_block(node.content.strip())] - if t == "table": - return [_render_table(node, None, ctx)] + if t == "container_pullquote": + return [nodes.pullquote(_render_container_body(node, api, ctx))] + + if t == "container_callout": + return [nodes.callout_block(_render_container_body(node, api, ctx))] # footnote_block is handled separately in markdown_to_doc; ignore it here. return [] -def _render_table(node: SyntaxTreeNode, _api, ctx: Dict) -> Dict: - rows = [] - for section in node.children: # thead, tbody - for tr in section.children: - cells = [] - for cell in tr.children: # th or td - inline = cell.children[0] if cell.children else None - content = _render_inline(inline, [], ctx) if inline else [] - if cell.type == "th": - cells.append(nodes.table_header([nodes.paragraph(content)])) - else: - cells.append(nodes.table_cell([nodes.paragraph(content)])) - rows.append(nodes.table_row(cells)) - return nodes.table(rows) +def _render_container_body(node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]: + body: List[Dict] = [] + for child in node.children: + body.extend(_render_block(child, api, ctx)) + return body def _render_list_items(list_node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]: diff --git a/substack/nodes.py b/substack/nodes.py index d73ff16..1d361e3 100644 --- a/substack/nodes.py +++ b/substack/nodes.py @@ -35,10 +35,8 @@ class NodeType: CAPTION = "caption" LATEX_BLOCK = "latex_block" LATEX_INLINE = "latex" - TABLE = "table" - TABLE_ROW = "table_row" - TABLE_HEADER = "table_header" - TABLE_CELL = "table_cell" + PULLQUOTE = "pullquote" + CALLOUT_BLOCK = "calloutBlock" class MarkType: @@ -46,6 +44,8 @@ class MarkType: EM = "em" CODE = "code" STRIKETHROUGH = "strikethrough" + SUPERSCRIPT = "superscript" + SUBSCRIPT = "subscript" LINK = "link" @@ -168,17 +168,16 @@ def latex_inline(expression: str) -> Dict: } -def table(rows: List[Dict]) -> Dict: - return {"type": NodeType.TABLE, "content": rows} - - -def table_row(cells: List[Dict]) -> Dict: - return {"type": NodeType.TABLE_ROW, "content": cells} - - -def table_header(content: List[Dict]) -> Dict: - return {"type": NodeType.TABLE_HEADER, "content": content} +def pullquote(paragraphs: List[Dict]) -> Dict: + return { + "type": NodeType.PULLQUOTE, + "attrs": {"align": None, "color": None}, + "content": paragraphs or [paragraph()], + } -def table_cell(content: List[Dict]) -> Dict: - return {"type": NodeType.TABLE_CELL, "content": content} +def callout_block(paragraphs: List[Dict]) -> Dict: + return { + "type": NodeType.CALLOUT_BLOCK, + "content": paragraphs or [paragraph()], + } diff --git a/tests/substack/fixtures/full_features.expected.json b/tests/substack/fixtures/full_features.expected.json index 2140063..ecd9259 100644 --- a/tests/substack/fixtures/full_features.expected.json +++ b/tests/substack/fixtures/full_features.expected.json @@ -549,154 +549,119 @@ "type": "horizontal_rule" }, { - "type": "table", + "type": "paragraph", "content": [ { - "type": "table_row", - "content": [ - { - "type": "table_header", - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Header A", - "marks": [ - { - "type": "strong" - } - ] - } - ] - } - ] - }, + "type": "text", + "text": "A paragraph with inline math " + }, + { + "type": "latex", + "attrs": { + "expression": "E=mc^2", + "persistentExpression": "E=mc^2" + } + }, + { + "type": "text", + "text": ", H" + }, + { + "type": "text", + "text": "2", + "marks": [ { - "type": "table_header", - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Header B" - } - ] - } - ] + "type": "subscript" } ] }, { - "type": "table_row", - "content": [ - { - "type": "table_cell", - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Cell 1" - } - ] - } - ] - }, + "type": "text", + "text": "O subscript, and x" + }, + { + "type": "text", + "text": "2", + "marks": [ { - "type": "table_cell", - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "link", - "marks": [ - { - "type": "link", - "attrs": { - "href": "https://example.com" - } - } - ] - } - ] - } - ] + "type": "superscript" } ] }, { - "type": "table_row", + "type": "text", + "text": " superscript." + } + ] + }, + { + "type": "latex_block", + "attrs": { + "persistentExpression": "E=mc^2", + "dirty": true + } + }, + { + "type": "pullquote", + "attrs": { + "align": null, + "color": null + }, + "content": [ + { + "type": "paragraph", "content": [ { - "type": "table_cell", - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Cell 2" - } - ] - } - ] + "type": "text", + "text": "A pull quote with " }, { - "type": "table_cell", - "content": [ + "type": "text", + "text": "bold", + "marks": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "italic", - "marks": [ - { - "type": "em" - } - ] - } - ] + "type": "strong" } ] + }, + { + "type": "text", + "text": " text." } ] } ] }, { - "type": "paragraph", + "type": "calloutBlock", "content": [ { - "type": "text", - "text": "A paragraph with inline math " - }, - { - "type": "latex", - "attrs": { - "expression": "E=mc^2", - "persistentExpression": "E=mc^2" - } - }, - { - "type": "text", - "text": " in it." + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A callout block with a " + }, + { + "type": "text", + "text": "link", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://example.com" + } + } + ] + }, + { + "type": "text", + "text": "." + } + ] } ] }, - { - "type": "latex_block", - "attrs": { - "persistentExpression": "E=mc^2", - "dirty": true - } - }, { "type": "captionedImage", "content": [ diff --git a/tests/substack/fixtures/full_features.md b/tests/substack/fixtures/full_features.md index dafb1d0..3cd028c 100644 --- a/tests/substack/fixtures/full_features.md +++ b/tests/substack/fixtures/full_features.md @@ -55,17 +55,20 @@ plain code block without a language --- -| **Header A** | Header B | -|:-------------|:----------------------------| -| Cell 1 | [link](https://example.com) | -| Cell 2 | *italic* | - -A paragraph with inline math $E=mc^2$ in it. +A paragraph with inline math $E=mc^2$, H~2~O subscript, and x^2^ superscript. $$ E=mc^2 $$ +:::pullquote +A pull quote with **bold** text. +::: + +:::callout +A callout block with a [link](https://example.com). +::: + ![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) diff --git a/tests/substack/test_from_markdown_features.py b/tests/substack/test_from_markdown_features.py index 9d805ca..5222e51 100644 --- a/tests/substack/test_from_markdown_features.py +++ b/tests/substack/test_from_markdown_features.py @@ -193,35 +193,50 @@ def test_latex_inline(self): assert latex[0]["attrs"]["expression"] == "E=mc^2" assert latex[0]["attrs"]["persistentExpression"] == "E=mc^2" - def test_table(self): + def test_superscript(self): post = make_post() - post.from_markdown("| **A** | B |\n|---|---|\n| 1 | [x](https://example.com) |\n") + post.from_markdown("E=mc^2^ here.") + para = body(post)[0] + sup = [n for n in para["content"] if n.get("marks")] + assert sup[0]["text"] == "2" + assert sup[0]["marks"] == [{"type": "superscript"}] + + def test_subscript(self): + post = make_post() + post.from_markdown("H~2~O here.") + para = body(post)[0] + sub = [n for n in para["content"] if n.get("marks")] + assert sub[0]["text"] == "2" + assert sub[0]["marks"] == [{"type": "subscript"}] + + def test_subscript_does_not_clash_with_strikethrough(self): + post = make_post() + post.from_markdown("~~struck~~ and H~2~O") + para = body(post)[0] + marks = { + m["type"] + for n in para["content"] + for m in n.get("marks", []) + } + assert marks == {"strikethrough", "subscript"} + + def test_pullquote(self): + post = make_post() + post.from_markdown(":::pullquote\nA **bold** quote.\n:::\n") block = body(post)[0] - assert block["type"] == "table" - rows = block["content"] - assert len(rows) == 2 - - header_row = rows[0] - assert header_row["type"] == "table_row" - assert header_row["content"][0]["type"] == "table_header" - assert header_row["content"][1]["type"] == "table_header" - # header cell content is a paragraph with inline nodes - bold = header_row["content"][0]["content"][0]["content"][0] - assert bold["text"] == "A" - assert bold["marks"] == [{"type": "strong"}] - - data_row = rows[1] - assert data_row["type"] == "table_row" - assert data_row["content"][0]["type"] == "table_cell" - assert data_row["content"][1]["type"] == "table_cell" - link_text = data_row["content"][1]["content"][0]["content"][0] - assert link_text["text"] == "x" - assert link_text["marks"][0]["attrs"]["href"] == "https://example.com" - - def test_table_single_row(self): - post = make_post() - post.from_markdown("| A |\n|---|\n") + assert block["type"] == "pullquote" + assert block["attrs"] == {"align": None, "color": None} + para = block["content"][0] + assert para["type"] == "paragraph" + assert para["content"][1]["text"] == "bold" + assert para["content"][1]["marks"] == [{"type": "strong"}] + + def test_callout(self): + post = make_post() + post.from_markdown(":::callout\nA callout with a [link](https://example.com).\n:::\n") block = body(post)[0] - assert block["type"] == "table" - assert len(block["content"]) == 1 - assert block["content"][0]["content"][0]["type"] == "table_header" + assert block["type"] == "calloutBlock" + para = block["content"][0] + assert para["type"] == "paragraph" + link = [n for n in para["content"] if n.get("marks")][0] + assert link["marks"][0]["attrs"]["href"] == "https://example.com" From 76cb2249f1a70a13f3177087c288377943dbdb97 Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Fri, 10 Jul 2026 13:06:27 +1000 Subject: [PATCH 5/5] Document supported Markdown features Add docs/markdown.md covering every Markdown construct from_markdown() converts (formatting, headings, lists, images, footnotes, math, pull quotes, callouts) plus what is intentionally unsupported (tables, editor-only widgets). Link it from the README. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 +- docs/markdown.md | 160 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 docs/markdown.md diff --git a/README.md b/README.md index 2309ea5..e6791b5 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ Paragraph with **bold**, *italic*, `code`, [links](https://example.com), and foo ) ``` -Supported Markdown includes headings, paragraphs, bold, italic, inline code, strikethrough, links, images, linked images, image captions, code blocks, blockquotes, ordered lists, unordered lists, horizontal rules, and footnotes. +Supported Markdown includes headings, paragraphs, bold, italic, inline code, strikethrough, superscript, subscript, links, images, linked images, image captions, code blocks, blockquotes, ordered lists, unordered lists, horizontal rules, footnotes, LaTeX math, pull quotes, and callouts. See [docs/markdown.md](docs/markdown.md) for the full reference with examples. When an `Api` instance is passed to `from_markdown`, local image paths are uploaded before the draft is created: diff --git a/docs/markdown.md b/docs/markdown.md new file mode 100644 index 0000000..0adc784 --- /dev/null +++ b/docs/markdown.md @@ -0,0 +1,160 @@ +# Markdown support + +`Post.from_markdown(markdown_content, api=None)` converts a Markdown document +into Substack's document format. Parsing is handled by +[markdown-it-py](https://github.com/executablebooks/markdown-it-py) (CommonMark) +plus a few plugins, so standard CommonMark works as you'd expect. This page +documents everything that maps to a Substack node. + +```python +from substack.post import Post + +post = Post(title="My Post", subtitle="", user_id=api.get_user_id()) +post.from_markdown(open("post.md").read(), api=api) +draft = api.post_draft(post.get_draft()) +``` + +Pass `api=` when your Markdown references local images so they can be uploaded +(see [Images](#images)); it is optional otherwise. + +## Text formatting + +| Markdown | Result | +|-------------------------------|-------------------| +| `**bold**` | **bold** | +| `*italic*` | *italic* | +| `***bold italic***` | ***bold italic*** | +| `` `inline code` `` | `inline code` | +| `~~strikethrough~~` | ~~strikethrough~~ | +| `^superscript^` | superscript | +| `~subscript~` | subscript | +| `[text](https://example.com)` | link | +| `` | autolinked URL | + +Note that subscript uses a single tilde (`~x~`) and strikethrough uses a double +tilde (`~~x~~`); both work in the same document. + +## Headings + +Levels 1–6, using `#` through `######`. Headings may contain inline formatting +and links. + +```markdown +# Heading level 1 +## Heading level 2 with **bold** and a [link](https://example.com) +``` + +## Paragraphs and line breaks + +Blank lines separate paragraphs. A single newline within a paragraph is treated +as a space (soft break), matching CommonMark. + +## Lists + +Bullet lists (`-`, `*`, or `+`) and ordered lists (`1.`), including nesting: + +```markdown +- Bullet one +- Bullet two + - Nested bullet + 1. Nested number + +1. Ordered one +2. Ordered two +``` + +## Blockquotes + +```markdown +> A blockquote. +> +> With multiple paragraphs. +``` + +## Code blocks + +Fenced code blocks, with an optional language for syntax highlighting, and +indented code blocks: + +````markdown +```python +print("hello") +``` +```` + +## Horizontal rule + +```markdown +--- +``` + +## Images + +A paragraph containing only an image becomes a captioned image. + +```markdown +![Alt text](https://example.com/image.png) +``` + +- **Caption:** use the CommonMark title slot — `![alt](url "caption text")`. +- **Link:** wrap the image in a link — `[![alt](url)](https://target.com)`. +- **Local upload:** if `api=` is passed and the `src` is a local path (not an + `http(s)` URL), the file is uploaded to Substack and the returned URL is used. + Local paths resolve relative to the current working directory. + +```markdown +![A chart](chart.png "Figure 1: quarterly results") +``` + +## Footnotes + +References become inline anchors; definitions become footnote blocks at the end, +numbered by order of first appearance. Labels may be numeric or named, and a +definition may contain block content such as lists or multiple paragraphs. + +```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). +``` + +A reference used more than once is emitted as a separate numbered anchor each +time, mirroring the Substack editor. Definitions that are never referenced are +dropped. + +## Math (LaTeX) + +Inline math with single dollars, block math with double dollars: + +```markdown +Einstein showed $E=mc^2$ inline. + +$$ +\int_0^\infty e^{-x} \, dx = 1 +$$ +``` + +## Pull quotes and callouts + +These use fenced-container syntax (`:::`), since they have no native Markdown +equivalent: + +```markdown +:::pullquote +A highlighted pull quote. **Formatting** works inside. +::: + +:::callout +A callout block, e.g. an aside or note. +::: +``` + +## Not supported + +- **Tables** — Substack has no table renderer or editor UI for them, so GFM + table syntax is not converted. To include tabular data, embed a chart (for + example via [Datawrapper](https://support.substack.com/hc/en-us/articles/15722290158100)) + and add it in the Substack editor. +- Widgets authored only in the Substack editor (buttons, polls, embeds, etc.) + have no Markdown equivalent.