Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
160 changes: 160 additions & 0 deletions docs/markdown.md
Original file line number Diff line number Diff line change
@@ -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 |
| `<https://example.com>` | 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.
16 changes: 8 additions & 8 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
35 changes: 34 additions & 1 deletion substack/mdrender.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +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
Expand All @@ -31,11 +35,22 @@
"strong": {"type": MarkType.STRONG},
"em": {"type": MarkType.EM},
"s": {"type": MarkType.STRIKETHROUGH},
"sup": {"type": MarkType.SUPERSCRIPT},
"sub": {"type": MarkType.SUBSCRIPT},
}


def _make_parser() -> MarkdownIt:
return MarkdownIt("commonmark").use(footnote_plugin).enable("strikethrough")
return (
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")
)


def _coalesce(out_nodes: List[Dict]) -> List[Dict]:
Expand Down Expand Up @@ -64,6 +79,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 == "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))
elif t == "link":
Expand Down Expand Up @@ -159,10 +176,26 @@ 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 == "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_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]:
items = []
for li in list_node.children:
Expand Down
35 changes: 35 additions & 0 deletions substack/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,19 @@ class NodeType:
FOOTNOTE_ANCHOR = "footnoteAnchor"
CAPTIONED_IMAGE = "captionedImage"
CAPTION = "caption"
LATEX_BLOCK = "latex_block"
LATEX_INLINE = "latex"
PULLQUOTE = "pullquote"
CALLOUT_BLOCK = "calloutBlock"


class MarkType:
STRONG = "strong"
EM = "em"
CODE = "code"
STRIKETHROUGH = "strikethrough"
SUPERSCRIPT = "superscript"
SUBSCRIPT = "subscript"
LINK = "link"


Expand Down Expand Up @@ -146,3 +152,32 @@ 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 latex_inline(expression: str) -> Dict:
return {
"type": NodeType.LATEX_INLINE,
"attrs": {"expression": expression, "persistentExpression": expression},
}


def pullquote(paragraphs: List[Dict]) -> Dict:
return {
"type": NodeType.PULLQUOTE,
"attrs": {"align": None, "color": None},
"content": paragraphs or [paragraph()],
}


def callout_block(paragraphs: List[Dict]) -> Dict:
return {
"type": NodeType.CALLOUT_BLOCK,
"content": paragraphs or [paragraph()],
}
Loading